From 8eeff17e96fc238f84d23ffbdb5dd70c133e1161 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Mon, 20 Apr 2026 17:05:51 -0700 Subject: [PATCH] Cherry picks for v0.233.x preview release (#54368) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry picks for the next v0.233.x preview release, focused on sidebar and agent panel fixes and improvements. Built incrementally so CI can validate as we go. ### Cherry-picked PRs Listed in the order they were applied (chronological merge order on `main`). `[x]` = applied on this branch; `[ ]` = todo; `[-]` = skipped. - [x] #53956 — sidebar: Remove View More batched thread expansion - [x] #53998 — Make flexible dock widths more closely match full-height pane splits - [x] #54006 — agent: Inline worktree info fetching to remove expects in agent_panel - [x] #53854 — Simplify parallel agents onboarding - [x] #54025 — sidebar: Add some UI adjustments - [-] #53982 — sidebar: Refactor thread time storage (skipped — paired with revert #54078, net zero) - [-] #54078 — Revert "sidebar: Refactor thread time storage (#53982)" (skipped — paired with #53982, net zero) - [x] #54090 — agent_ui: Do not show token limit callout for external agents - [x] #54126 — agent_ui: Adjust thread item component and fix thread switcher - [x] #54075 — Rename Archive view to Thread History - [x] #54125 — agent: Auto-select user model when there's no default - [x] #54128 — sidebar: Fix cmd-click in the header not taking to the last workspace - [x] #54178 — agent_ui: Fix UI issues with activity bar - [x] #54187 — agent_ui: Only surface the regenerate title item for the Zed agent - [x] #54205 — agent: Clean up old remove worktree manage folder code - [x] #54207 — Add list of open workspaces to the project group menu in the sidebar - [x] #54198 — Fix remote projects not appearing in the sidebar after adding them to the window - [x] #54206 — Feature flag overrides - [-] #53100 — workspace: Skip read-only paths when choosing default save location (skipped for this release) - [-] #54183 — Move the worktree picker to the title bar + make it always visible (skipped — very large refactor, deferred to a future release) - [x] #54314 — agent_ui: Add `show thread metadata` action - [x] #54317 — sidebar: Open project header ellipsis menu on right-click - [x] #54316 — agent_ui: Add setting for turning off content max-width - [x] #54256 — Add support for Netpbm image previews - [x] #54320 — sidebar: Consistently set `interacted_at` - [x] #54318 — agent: Respect favorite model settings and sync UI changes back to settings - [x] #54348 — Always use `ArchiveSelectedThread` action for archiving threads - [x] #54353 — agent: When opening a remote thread check that the linked worktree path exists - [x] #54365 — Avoid constantly scrolling thread history to top as agent generates ### Conflict resolution notes - **#53956** — On `v0.233.x`, `sidebar_tests.rs` still contained `test_search_finds_threads_hidden_behind_view_more`, which exercised behavior #53956 removes. Deleted the test along with the rest of the View More functionality; no changes to the substance of the cherry-picked patch. - **#53998** — Supersedes standalone cherry-pick PR #54366 (which can be closed). - **#53982 / #54078** — Skipped both. They form a refactor-and-revert pair on `main` with net zero change. Since `v0.233.x` already has #54173 ("sidebar: Fix sidebar thread times") applied on top of the pre-#53982 state, pulling both in would be churn with no end-state difference. - **#54025** — `crates/ui/src/components/ai/thread_item.rs`: accepted incoming side for the `worktree_tooltip_title` removal and converted the patch's `match (wt.kind, wt.branch_name)` structure back to `v0.233.x`'s `match wt.kind` structure (the #54067 tuple match isn't on this branch). Dropped the `linked_worktree_count` local after it became unused. - **#54207** — `crates/sidebar/src/sidebar.rs`: accepted incoming side for the project group menu restructure. This removes the `show_multi_project_entries` gate around the "Remove Project" entry that #54025 added — matching `main`'s state at #54207's parent. - **#54314** — `crates/agent_ui/src/agent_panel.rs`: merged both sides of two import conflicts, adding `ShowAllSidebarThreadMetadata` and `ShowThreadMetadata` alongside the `CreateWorktree`/`NewWorktreeBranchTarget`/`SwitchWorktree`/`ToggleWorktreeSelector` imports that remain on `v0.233.x` (#54183 isn't here). Also merged the `anyhow` and `chrono` `use` lines. - **#54183** — Skipped. It's a 3,379/4,702-line refactor that moves the worktree picker from the agent panel to the title bar. Too risky for a preview release; should be handled as a dedicated PR if/when we want it on `v0.233.x`. Release Notes: - Fixed a bug where flexible docks resized incorrectly in certain cases. ([#53998](https://github.com/zed-industries/zed/pull/53998)) - Fixed an issue where resizing a flexible-width panel in the left dock would also resize fixed-width panels. ([#53998](https://github.com/zed-industries/zed/pull/53998)) - Agent: Fixed worktree and branch labels not showing up in the thread switcher. ([#54126](https://github.com/zed-industries/zed/pull/54126)) - Agent: Fixed the thread switcher not selecting on hover. ([#54126](https://github.com/zed-industries/zed/pull/54126)) - Renamed the threads Archive view to Thread History and updated its icon to a clock. ([#54075](https://github.com/zed-industries/zed/pull/54075)) - Agent: Automatically select a model when there's no selected model or configured default. ([#54125](https://github.com/zed-industries/zed/pull/54125)) - Agent: Fixed a bug where cmd-clicking on the project header wouldn't actually take you to the last active workspace. ([#54128](https://github.com/zed-industries/zed/pull/54128)) - Agent: Added a new `limit_content_width` setting in the agent panel that allows turning off the content max-width limit. ([#54316](https://github.com/zed-industries/zed/pull/54316)) - Added support for PNM image previews (`.pbm`, `.ppm`, `.pgm`). ([#54256](https://github.com/zed-industries/zed/pull/54256)) - Agent favorite models now remember and restore per-model thinking, effort level, and fast mode preferences. ([#54318](https://github.com/zed-industries/zed/pull/54318)) --------- Co-authored-by: Nathan Sobo Co-authored-by: Max Brunsfeld Co-authored-by: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Co-authored-by: Bennet Bo Fenner Co-authored-by: Danilo Leal Co-authored-by: Mikayla Maki Co-authored-by: Cameron Mcloughlin Co-authored-by: Vinícius Dutra Co-authored-by: Smit Barmase --- Cargo.lock | 19 + Cargo.toml | 3 + assets/icons/clock.svg | 4 + assets/icons/knockouts/archive_bg.svg | 10 - assets/icons/knockouts/archive_fg.svg | 4 - assets/icons/thread_import.svg | 5 - assets/keymaps/default-linux.json | 11 +- assets/keymaps/default-macos.json | 11 +- assets/keymaps/default-windows.json | 12 +- assets/settings/default.json | 6 +- crates/agent/src/agent.rs | 44 +- crates/agent/src/native_agent_server.rs | 46 +- crates/agent/src/tool_permissions.rs | 2 +- crates/agent_settings/src/agent_settings.rs | 49 +- crates/agent_ui/src/agent_configuration.rs | 68 +- .../configure_context_server_modal.rs | 10 +- crates/agent_ui/src/agent_panel.rs | 331 ++++--- crates/agent_ui/src/agent_ui.rs | 14 +- crates/agent_ui/src/conversation_view.rs | 8 +- .../src/conversation_view/thread_view.rs | 220 +++-- crates/agent_ui/src/favorite_models.rs | 24 +- .../agent_ui/src/language_model_selector.rs | 56 +- crates/agent_ui/src/thread_history_view.rs | 2 +- crates/agent_ui/src/thread_import.rs | 24 +- crates/agent_ui/src/thread_metadata_store.rs | 17 +- .../agent_ui/src/thread_worktree_archive.rs | 181 ---- crates/agent_ui/src/thread_worktree_picker.rs | 1 - crates/agent_ui/src/threads_archive_view.rs | 145 +-- crates/agent_ui/src/ui/undo_reject_toast.rs | 24 +- crates/ai_onboarding/src/ai_onboarding.rs | 132 +-- crates/auto_update_ui/Cargo.toml | 1 + crates/auto_update_ui/src/auto_update_ui.rs | 41 +- .../src/component_preview.rs | 14 +- crates/csv_preview/src/csv_preview.rs | 4 +- crates/debugger_ui/src/debugger_panel.rs | 4 +- .../src/session/running/memory_view.rs | 4 +- crates/edit_prediction/src/edit_prediction.rs | 4 +- .../src/edit_prediction_ui.rs | 4 +- .../src/rate_prediction_modal.rs | 4 +- crates/feature_flags/Cargo.toml | 11 + crates/feature_flags/src/feature_flags.rs | 191 +++- crates/feature_flags/src/flags.rs | 41 +- crates/feature_flags/src/settings.rs | 76 ++ crates/feature_flags/src/store.rs | 374 ++++++++ crates/feature_flags_macros/Cargo.toml | 18 + crates/feature_flags_macros/LICENSE-GPL | 1 + .../src/feature_flags_macros.rs | 190 ++++ crates/git/src/repository.rs | 2 +- crates/git_ui/src/clone.rs | 12 +- crates/git_ui/src/git_panel.rs | 48 +- crates/git_ui/src/worktree_picker.rs | 37 +- crates/gpui/src/platform.rs | 4 + crates/gpui_linux/src/linux/x11/clipboard.rs | 3 +- crates/gpui_macos/src/pasteboard.rs | 6 + crates/icons/src/icons.rs | 2 +- crates/json_schema_store/Cargo.toml | 5 + .../src/json_schema_store.rs | 55 +- crates/keymap_editor/src/keymap_editor.rs | 10 +- crates/language_model/src/registry.rs | 84 +- crates/language_models/src/language_models.rs | 62 +- crates/notifications/src/status_toast.rs | 90 +- crates/onboarding/src/onboarding.rs | 24 +- crates/project/src/image_store.rs | 1 + crates/project/src/worktree_store.rs | 6 - crates/project_panel/src/project_panel.rs | 10 +- crates/repl/src/notebook/notebook_ui.rs | 4 +- crates/settings/src/vscode_import.rs | 1 + crates/settings_content/src/agent.rs | 31 +- .../settings_content/src/settings_content.rs | 39 + crates/settings_ui/src/page_data.rs | 98 ++- crates/settings_ui/src/pages.rs | 2 + crates/settings_ui/src/pages/feature_flags.rs | 132 +++ crates/settings_ui/src/settings_ui.rs | 20 + crates/sidebar/Cargo.toml | 1 + crates/sidebar/src/sidebar.rs | 826 ++++++++++-------- crates/sidebar/src/sidebar_tests.rs | 361 +++----- crates/sidebar/src/thread_switcher.rs | 63 +- .../ai/parallel_agents_illustration.rs | 199 ++++- crates/ui/src/components/ai/thread_item.rs | 570 ++++++------ crates/ui/src/components/context_menu.rs | 16 +- crates/ui/src/components/icon.rs | 3 +- .../ui/src/components/icon/icon_decoration.rs | 5 - crates/workspace/src/dock.rs | 43 +- crates/workspace/src/multi_workspace.rs | 217 ++++- crates/workspace/src/multi_workspace_tests.rs | 150 +++- crates/workspace/src/pane_group.rs | 55 +- crates/workspace/src/persistence/model.rs | 10 +- crates/workspace/src/toast_layer.rs | 9 +- crates/workspace/src/workspace.rs | 194 ++-- crates/zed/src/main.rs | 1 + crates/zed/src/visual_test_runner.rs | 18 +- crates/zed/src/zed.rs | 4 +- 92 files changed, 3885 insertions(+), 2113 deletions(-) create mode 100644 assets/icons/clock.svg delete mode 100644 assets/icons/knockouts/archive_bg.svg delete mode 100644 assets/icons/knockouts/archive_fg.svg delete mode 100644 assets/icons/thread_import.svg create mode 100644 crates/feature_flags/src/settings.rs create mode 100644 crates/feature_flags/src/store.rs create mode 100644 crates/feature_flags_macros/Cargo.toml create mode 120000 crates/feature_flags_macros/LICENSE-GPL create mode 100644 crates/feature_flags_macros/src/feature_flags_macros.rs create mode 100644 crates/settings_ui/src/pages/feature_flags.rs diff --git a/Cargo.lock b/Cargo.lock index a0a9f798c921c8541ae2e4aaa5ed3bb957d9fb71..2521700c1b6c0453bc14e740a2a32c60a35d34c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1253,6 +1253,7 @@ dependencies = [ "fs", "gpui", "markdown_preview", + "notifications", "release_channel", "semver", "serde", @@ -6137,7 +6138,23 @@ dependencies = [ name = "feature_flags" version = "0.1.0" dependencies = [ + "collections", + "feature_flags_macros", + "fs", "gpui", + "inventory", + "schemars", + "serde_json", + "settings", +] + +[[package]] +name = "feature_flags_macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -9095,6 +9112,7 @@ dependencies = [ "collections", "dap", "extension", + "feature_flags", "gpui", "language", "parking_lot", @@ -16043,6 +16061,7 @@ dependencies = [ "db", "editor", "extension", + "feature_flags", "fs", "git", "gpui", diff --git a/Cargo.toml b/Cargo.toml index 75cd2f54b733b7ca7894743223f854d8c26a71c3..73487fc162c039e21ad74d7de33c5c8e023aea58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ members = [ "crates/extension_host", "crates/extensions_ui", "crates/feature_flags", + "crates/feature_flags_macros", "crates/feedback", "crates/file_finder", "crates/file_icons", @@ -325,6 +326,7 @@ extension = { path = "crates/extension" } extension_host = { path = "crates/extension_host" } extensions_ui = { path = "crates/extensions_ui" } feature_flags = { path = "crates/feature_flags" } +feature_flags_macros = { path = "crates/feature_flags_macros" } feedback = { path = "crates/feedback" } file_finder = { path = "crates/file_finder" } file_icons = { path = "crates/file_icons" } @@ -891,6 +893,7 @@ debug = true # proc-macros start gpui_macros = { opt-level = 3 } derive_refineable = { opt-level = 3 } +feature_flags_macros = { opt-level = 3 } settings_macros = { opt-level = 3 } sqlez_macros = { opt-level = 3, codegen-units = 1 } ui_macros = { opt-level = 3 } diff --git a/assets/icons/clock.svg b/assets/icons/clock.svg new file mode 100644 index 0000000000000000000000000000000000000000..fb8c6f851fff966732d287e296d29cf6383942b3 --- /dev/null +++ b/assets/icons/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/knockouts/archive_bg.svg b/assets/icons/knockouts/archive_bg.svg deleted file mode 100644 index 1954d14b1ee16adf605e2cfe31309838d2448f7a..0000000000000000000000000000000000000000 --- a/assets/icons/knockouts/archive_bg.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/assets/icons/knockouts/archive_fg.svg b/assets/icons/knockouts/archive_fg.svg deleted file mode 100644 index 74d1238c5399105ba83046c63f4505b0d1fec877..0000000000000000000000000000000000000000 --- a/assets/icons/knockouts/archive_fg.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/thread_import.svg b/assets/icons/thread_import.svg deleted file mode 100644 index a56b5a7cccc09c5795bfadff06f06d15833232f3..0000000000000000000000000000000000000000 --- a/assets/icons/thread_import.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 15a4a6f64d5e1a12686d43ddc01dee14df80a7f4..41618016de95757615ea6d528aff11906620c0c5 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -384,6 +384,12 @@ "backspace": "agent::RemoveSelectedThread", }, }, + { + "context": "ThreadsArchiveView", + "bindings": { + "shift-backspace": "agent::ArchiveSelectedThread", + }, + }, { "context": "RulesLibrary", "bindings": { @@ -720,8 +726,9 @@ "right": "menu::SelectChild", "enter": "menu::Confirm", "ctrl-f": "agents_sidebar::FocusSidebarFilter", - "ctrl-g": "agents_sidebar::ViewAllThreads", - "shift-backspace": "agent::RemoveSelectedThread", + "ctrl-g": "agents_sidebar::ToggleThreadHistory", + "shift-backspace": "agent::ArchiveSelectedThread", + "ctrl-backspace": "agent::RemoveSelectedThread", "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 4121b376b5a142e298bc655ac8c925507aaeae6e..fa255bfe6a21ce917b64fa8e4a056d1b53e6936c 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -431,6 +431,12 @@ "shift-backspace": "agent::RemoveSelectedThread", }, }, + { + "context": "ThreadsArchiveView", + "bindings": { + "backspace": "agent::ArchiveSelectedThread", + }, + }, { "context": "RulesLibrary", "use_key_equivalents": true, @@ -783,8 +789,9 @@ "right": "menu::SelectChild", "enter": "menu::Confirm", "cmd-f": "agents_sidebar::FocusSidebarFilter", - "cmd-g": "agents_sidebar::ViewAllThreads", - "shift-backspace": "agent::RemoveSelectedThread", + "cmd-g": "agents_sidebar::ToggleThreadHistory", + "shift-backspace": "agent::ArchiveSelectedThread", + "cmd-shift-backspace": "agent::RemoveSelectedThread", "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 2098f18ff0f2e264b6891f412a29540972629a92..39674ec90db9489b7430c08969fc3c424d483ff3 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -386,6 +386,13 @@ "backspace": "agent::RemoveSelectedThread", }, }, + { + "context": "ThreadsArchiveView", + "use_key_equivalents": true, + "bindings": { + "shift-backspace": "agent::ArchiveSelectedThread", + }, + }, { "context": "RulesLibrary", "use_key_equivalents": true, @@ -720,8 +727,9 @@ "right": "menu::SelectChild", "enter": "menu::Confirm", "ctrl-f": "agents_sidebar::FocusSidebarFilter", - "ctrl-g": "agents_sidebar::ViewAllThreads", - "shift-backspace": "agent::RemoveSelectedThread", + "ctrl-g": "agents_sidebar::ToggleThreadHistory", + "shift-backspace": "agent::ArchiveSelectedThread", + "ctrl-backspace": "agent::RemoveSelectedThread", "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, diff --git a/assets/settings/default.json b/assets/settings/default.json index 584c4c4d49d573be8ca600edde638c428bace3e6..c25919678bc3ab4b54aa8ac29ed17cc5650b77e8 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -990,7 +990,11 @@ "default_width": 640, // Default height when the agent panel is docked to the bottom. "default_height": 320, - // Maximum content width when the agent panel is wider than this value. + // Whether to limit the content width in the agent panel. When enabled, + // content will be constrained to `max_content_width` and centered when + // the panel is wider, for optimal readability. + "limit_content_width": true, + // Maximum content width in pixels when limit_content_width is enabled. // Content will be centered within the panel. "max_content_width": 850, // The default model to use when creating new threads. diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index fcb901347a12798aa8e2e40942f88b47beee011d..160172c5c49d3af5548ccd76af1e441662fadfbb 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -47,7 +47,7 @@ use prompt_store::{ WorktreeContext, }; use serde::{Deserialize, Serialize}; -use settings::{LanguageModelSelection, update_settings_file}; +use settings::{LanguageModelSelection, Settings as _, update_settings_file}; use std::any::Any; use std::path::PathBuf; use std::rc::Rc; @@ -201,7 +201,7 @@ impl LanguageModels { .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) .collect::>(); - cx.background_spawn(async move { + cx.spawn(async move |cx| { for (provider_id, provider_name, authenticate_task) in authenticate_all_providers { if let Err(err) = authenticate_task.await { match err { @@ -244,6 +244,8 @@ impl LanguageModels { } } } + + cx.update(language_models::update_environment_fallback_model); }) } } @@ -365,7 +367,7 @@ impl NativeAgent { }); let registry = LanguageModelRegistry::read_global(cx); - let summarization_model = registry.thread_summary_model().map(|c| c.model); + let summarization_model = registry.thread_summary_model(cx).map(|c| c.model); let weak = cx.weak_entity(); let weak_thread = thread_handle.downgrade(); @@ -749,13 +751,14 @@ impl NativeAgent { let registry = LanguageModelRegistry::read_global(cx); let default_model = registry.default_model().map(|m| m.model); - let summarization_model = registry.thread_summary_model().map(|m| m.model); + let summarization_model = registry.thread_summary_model(cx).map(|m| m.model); for session in self.sessions.values_mut() { session.thread.update(cx, |thread, cx| { - if thread.model().is_none() - && let Some(model) = default_model.clone() - { + let should_update_model = thread.model().is_none() + || (thread.is_empty() + && matches!(event, language_model::Event::DefaultModelChanged)); + if should_update_model && let Some(model) = default_model.clone() { thread.set_model(model, cx); cx.notify(); } @@ -910,7 +913,7 @@ impl NativeAgent { .get(&project_id) .context("project state not found")?; let summarization_model = LanguageModelRegistry::read_global(cx) - .thread_summary_model() + .thread_summary_model(cx) .map(|c| c.model); Ok(cx.new(|cx| { @@ -1420,16 +1423,29 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector { return Task::ready(Err(anyhow!("Invalid model ID {}", model_id))); }; - // We want to reset the effort level when switching models, as the currently-selected effort level may - // not be compatible. - let effort = model - .default_effort_level() - .map(|effort_level| effort_level.value.to_string()); + let favorite = agent_settings::AgentSettings::get_global(cx) + .favorite_models + .iter() + .find(|favorite| { + favorite.provider.0 == model.provider_id().0.as_ref() + && favorite.model == model.id().0.as_ref() + }) + .cloned(); + + let LanguageModelSelection { + enable_thinking, + effort, + speed, + .. + } = agent_settings::language_model_to_selection(&model, favorite.as_ref()); thread.update(cx, |thread, cx| { thread.set_model(model.clone(), cx); thread.set_thinking_effort(effort.clone(), cx); - thread.set_thinking_enabled(model.supports_thinking(), cx); + thread.set_thinking_enabled(enable_thinking, cx); + if let Some(speed) = speed { + thread.set_speed(speed, cx); + } }); update_settings_file( diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index 305c4f51952b3bf7e2771a8372a5975cac9e9385..e11e823caee7ad3b8968372f1b089f053a5fe721 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -2,11 +2,12 @@ use std::{any::Any, rc::Rc, sync::Arc}; use agent_client_protocol as acp; use agent_servers::{AgentServer, AgentServerDelegate}; -use agent_settings::AgentSettings; +use agent_settings::{AgentSettings, language_model_to_selection}; use anyhow::Result; use collections::HashSet; use fs::Fs; use gpui::{App, Entity, Task}; +use language_model::{LanguageModelId, LanguageModelProviderId, LanguageModelRegistry}; use project::{AgentId, Project}; use prompt_store::PromptStore; use settings::{LanguageModelSelection, Settings as _, update_settings_file}; @@ -76,7 +77,7 @@ impl AgentServer for NativeAgentServer { fs: Arc, cx: &App, ) { - let selection = model_id_to_selection(&model_id); + let selection = model_id_to_selection(&model_id, cx); update_settings_file(fs, cx, move |settings, _| { let agent = settings.agent.get_or_insert_default(); if should_be_favorite { @@ -89,16 +90,41 @@ impl AgentServer for NativeAgentServer { } /// Convert a ModelId (e.g. "anthropic/claude-3-5-sonnet") to a LanguageModelSelection. -fn model_id_to_selection(model_id: &acp::ModelId) -> LanguageModelSelection { +fn model_id_to_selection(model_id: &acp::ModelId, cx: &App) -> LanguageModelSelection { let id = model_id.0.as_ref(); let (provider, model) = id.split_once('/').unwrap_or(("", id)); - LanguageModelSelection { - provider: provider.to_owned().into(), - model: model.to_owned(), - enable_thinking: false, - effort: None, - speed: None, - } + + let provider_id = LanguageModelProviderId(provider.to_string().into()); + let model_id_typed = LanguageModelId(model.to_string().into()); + let resolved = LanguageModelRegistry::global(cx) + .read(cx) + .provider(&provider_id) + .and_then(|p| { + p.provided_models(cx) + .into_iter() + .find(|m| m.id() == model_id_typed) + }); + + let Some(resolved) = resolved else { + return LanguageModelSelection { + provider: provider.to_owned().into(), + model: model.to_owned(), + enable_thinking: false, + effort: None, + speed: None, + }; + }; + + let current_user_selection = AgentSettings::get_global(cx) + .default_model + .as_ref() + .filter(|selection| { + selection.provider.0 == resolved.provider_id().0.as_ref() + && selection.model == resolved.id().0.as_ref() + }) + .cloned(); + + language_model_to_selection(&resolved, current_user_selection.as_ref()) } #[cfg(test)] diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index ff9e735b6c4181588ed5cddbd6dada7fbae5f18f..65cbcfb2c609cb146537d784dd04f7749d71fe90 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -574,7 +574,7 @@ mod tests { flexible: true, default_width: px(300.), default_height: px(600.), - max_content_width: px(850.), + max_content_width: Some(px(850.)), default_model: None, inline_assistant_model: None, inline_assistant_use_streaming_tools: false, diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index a8b21fcbd849962a4a1d098eaa681b0f56016bc6..e3a52555e70dba1a825889209f2ec7b9c1f689e3 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -142,7 +142,7 @@ pub struct AgentSettings { pub sidebar_side: SidebarDockPosition, pub default_width: Pixels, pub default_height: Pixels, - pub max_content_width: Pixels, + pub max_content_width: Option, pub default_model: Option, pub inline_assistant_model: Option, pub inline_assistant_use_streaming_tools: bool, @@ -210,7 +210,48 @@ impl AgentSettings { .map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model))) .collect() } +} + +pub fn language_model_to_selection( + model: &Arc, + override_selection: Option<&LanguageModelSelection>, +) -> LanguageModelSelection { + let provider = model.provider_id().0.to_string().into(); + let model_name = model.id().0.to_string(); + match override_selection { + Some(current) => LanguageModelSelection { + provider, + model: model_name, + enable_thinking: current.enable_thinking && model.supports_thinking(), + effort: current + .effort + .clone() + .filter(|value| { + model + .supported_effort_levels() + .iter() + .any(|level| level.value.as_ref() == value.as_str()) + }) + .or_else(|| { + model + .default_effort_level() + .map(|effort| effort.value.to_string()) + }), + speed: current.speed.filter(|_| model.supports_fast_mode()), + }, + None => LanguageModelSelection { + provider, + model: model_name, + enable_thinking: model.supports_thinking(), + effort: model + .default_effort_level() + .map(|effort| effort.value.to_string()), + speed: None, + }, + } +} +impl AgentSettings { pub fn get_layout(cx: &App) -> WindowLayout { let store = cx.global::(); let merged = store.merged_settings(); @@ -593,7 +634,11 @@ impl Settings for AgentSettings { sidebar_side: agent.sidebar_side.unwrap(), default_width: px(agent.default_width.unwrap()), default_height: px(agent.default_height.unwrap()), - max_content_width: px(agent.max_content_width.unwrap()), + max_content_width: if agent.limit_content_width.unwrap() { + Some(px(agent.max_content_width.unwrap())) + } else { + None + }, flexible: agent.flexible.unwrap(), default_model: Some(agent.default_model.unwrap()), inline_assistant_model: agent.inline_assistant_model, diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index fda3cb9907b2f02cce29ff0ae8c4762e6efa625a..13ec53b25c50b5865c0070daee76d7eadde10c7b 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -26,7 +26,7 @@ use language_model::{ ZED_CLOUD_PROVIDER_ID, }; use language_models::AllLanguageModelSettings; -use notifications::status_toast::{StatusToast, ToastIcon}; +use notifications::status_toast::StatusToast; use project::{ agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource}, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, @@ -1330,40 +1330,44 @@ fn show_unable_to_uninstall_extension_with_context_server( move |this, _cx| { let workspace_handle = workspace_handle.clone(); - this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning)) - .dismiss_button(true) - .action("Uninstall", move |_, _cx| { - if let Some((extension_id, _)) = - resolve_extension_for_context_server(&context_server_id, _cx) - { - ExtensionStore::global(_cx).update(_cx, |store, cx| { - store - .uninstall_extension(extension_id, cx) - .detach_and_log_err(cx); - }); + this.icon( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .dismiss_button(true) + .action("Uninstall", move |_, _cx| { + if let Some((extension_id, _)) = + resolve_extension_for_context_server(&context_server_id, _cx) + { + ExtensionStore::global(_cx).update(_cx, |store, cx| { + store + .uninstall_extension(extension_id, cx) + .detach_and_log_err(cx); + }); - workspace_handle - .update(_cx, |workspace, cx| { - let fs = workspace.app_state().fs.clone(); - cx.spawn({ - let context_server_id = context_server_id.clone(); - async move |_workspace_handle, cx| { - cx.update(|cx| { - update_settings_file(fs, cx, move |settings, _| { - settings - .project - .context_servers - .remove(&context_server_id.0); - }); + workspace_handle + .update(_cx, |workspace, cx| { + let fs = workspace.app_state().fs.clone(); + cx.spawn({ + let context_server_id = context_server_id.clone(); + async move |_workspace_handle, cx| { + cx.update(|cx| { + update_settings_file(fs, cx, move |settings, _| { + settings + .project + .context_servers + .remove(&context_server_id.0); }); - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); + }); + anyhow::Ok(()) + } }) - .log_err(); - } - }) + .detach_and_log_err(cx); + }) + .log_err(); + } + }) }, ); diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index 9c44288e1cd23cd3bb0d6876f086c3f0e89dc4c7..465d31b416e9e85a4fc94a0b7f507c7560bf422a 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -9,7 +9,7 @@ use gpui::{ }; use language::{Language, LanguageRegistry}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; -use notifications::status_toast::{StatusToast, ToastIcon}; +use notifications::status_toast::StatusToast; use parking_lot::Mutex; use project::{ context_server_store::{ @@ -631,8 +631,12 @@ impl ConfigureContextServerModal { format!("{} configured successfully.", id.0), cx, |this, _cx| { - this.icon(ToastIcon::new(IconName::ToolHammer).color(Color::Muted)) - .action("Dismiss", |_, _| {}) + this.icon( + Icon::new(IconName::ToolHammer) + .size(IconSize::Small) + .color(Color::Muted), + ) + .action("Dismiss", |_, _| {}) }, ); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 88375dc7d6c4c4f4bf291b2a8d5d7d8dae602e41..9b79b2c57c685f37566ad5f21e0afd45c127b6d1 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -35,8 +35,8 @@ use crate::{ AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CreateWorktree, Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, NewWorktreeBranchTarget, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, - SwitchWorktree, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, - ToggleWorktreeSelector, + ShowAllSidebarThreadMetadata, ShowThreadMetadata, SwitchWorktree, ToggleNavigationMenu, + ToggleNewThreadMenu, ToggleOptionsMenu, ToggleWorktreeSelector, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, conversation_view::{AcpThreadViewEvent, ThreadView}, thread_worktree_picker::ThreadWorktreePicker, @@ -49,13 +49,14 @@ use crate::{ use crate::{ExpandMessageEditor, ThreadHistoryView}; use crate::{ManageProfiles, ThreadHistoryViewEvent}; use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore}; -use agent_settings::{AgentSettings, WindowLayout}; +use agent_settings::AgentSettings; use ai_onboarding::AgentPanelOnboarding; use anyhow::{Context as _, Result, anyhow}; +use chrono::{DateTime, Utc}; use client::UserStore; use cloud_api_types::Plan; use collections::HashMap; -use editor::Editor; +use editor::{Editor, MultiBuffer}; use extension::ExtensionEvents; use extension_host::ExtensionStore; use fs::Fs; @@ -325,6 +326,24 @@ pub fn init(cx: &mut App) { }); } }) + .register_action(|workspace, _: &ShowThreadMetadata, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.show_thread_metadata(&ShowThreadMetadata, window, cx); + }); + } + }) + .register_action(|workspace, _: &ShowAllSidebarThreadMetadata, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.show_all_sidebar_thread_metadata( + &ShowAllSidebarThreadMetadata, + window, + cx, + ); + }); + } + }) .register_action(|workspace, action: &ReviewBranchDiff, window, cx| { let Some(panel) = workspace.panel::(cx) else { return; @@ -624,6 +643,46 @@ fn build_conflicted_files_resolution_prompt( content } +fn format_timestamp_human(dt: &DateTime) -> String { + let now = Utc::now(); + let duration = now.signed_duration_since(*dt); + + let relative = if duration.num_seconds() < 0 { + "in the future".to_string() + } else if duration.num_seconds() < 60 { + let seconds = duration.num_seconds(); + format!("{seconds} seconds ago") + } else if duration.num_minutes() < 60 { + let minutes = duration.num_minutes(); + format!("{minutes} minutes ago") + } else if duration.num_hours() < 24 { + let hours = duration.num_hours(); + format!("{hours} hours ago") + } else { + let days = duration.num_days(); + format!("{days} days ago") + }; + + format!("{} ({})", dt.to_rfc3339(), relative) +} + +/// Used for `dev: show thread metadata` action +fn thread_metadata_to_debug_json( + metadata: &crate::thread_metadata_store::ThreadMetadata, +) -> serde_json::Value { + serde_json::json!({ + "thread_id": metadata.thread_id, + "session_id": metadata.session_id.as_ref().map(|s| s.0.to_string()), + "agent_id": metadata.agent_id.0.to_string(), + "title": metadata.title.as_ref().map(|t| t.to_string()), + "updated_at": format_timestamp_human(&metadata.updated_at), + "created_at": metadata.created_at.as_ref().map(format_timestamp_human), + "interacted_at": metadata.interacted_at.as_ref().map(format_timestamp_human), + "worktree_paths": format!("{:?}", metadata.worktree_paths), + "archived": metadata.archived, + }) +} + pub(crate) struct AgentThread { conversation_view: Entity, } @@ -760,8 +819,6 @@ pub struct AgentPanel { pending_serialization: Option>>, new_user_onboarding: Entity, new_user_onboarding_upsell_dismissed: AtomicBool, - agent_layout_onboarding: Entity, - agent_layout_onboarding_dismissed: AtomicBool, selected_agent: Agent, worktree_creation_status: Option<(EntityId, WorktreeCreationStatus)>, _thread_view_subscription: Option, @@ -1064,46 +1121,6 @@ impl AgentPanel { ) }); - let weak_panel = cx.entity().downgrade(); - - let layout = AgentSettings::get_layout(cx); - let is_agent_layout = matches!(layout, WindowLayout::Agent(_)); - - let agent_layout_onboarding = cx.new(|_cx| ai_onboarding::AgentLayoutOnboarding { - use_agent_layout: Arc::new({ - let fs = fs.clone(); - let weak_panel = weak_panel.clone(); - move |_window, cx| { - let _ = AgentSettings::set_layout(WindowLayout::Agent(None), fs.clone(), cx); - weak_panel - .update(cx, |panel, cx| { - panel.dismiss_agent_layout_onboarding(cx); - }) - .ok(); - } - }), - revert_to_editor_layout: Arc::new({ - let fs = fs.clone(); - let weak_panel = weak_panel.clone(); - move |_window, cx| { - let _ = AgentSettings::set_layout(WindowLayout::Editor(None), fs.clone(), cx); - weak_panel - .update(cx, |panel, cx| { - panel.dismiss_agent_layout_onboarding(cx); - }) - .ok(); - } - }), - dismissed: Arc::new(move |_window, cx| { - weak_panel - .update(cx, |panel, cx| { - panel.dismiss_agent_layout_onboarding(cx); - }) - .ok(); - }), - is_agent_layout, - }); - // Subscribe to extension events to sync agent servers when extensions change let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx) { @@ -1168,7 +1185,6 @@ impl AgentPanel { zoomed: false, pending_serialization: None, new_user_onboarding: onboarding, - agent_layout_onboarding, thread_store, selected_agent: Agent::default(), worktree_creation_status: None, @@ -1177,9 +1193,6 @@ impl AgentPanel { _worktree_creation_task: None, show_trust_workspace_message: false, new_user_onboarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)), - agent_layout_onboarding_dismissed: AtomicBool::new(AgentLayoutOnboarding::dismissed( - cx, - )), _base_view_observation: None, _draft_editor_observation: None, }; @@ -2025,6 +2038,108 @@ impl AgentPanel { .detach_and_log_err(cx); } + fn show_thread_metadata( + &mut self, + _: &ShowThreadMetadata, + window: &mut Window, + cx: &mut Context, + ) { + let Some(thread_id) = self.active_thread_id(cx) else { + Self::show_deferred_toast(&self.workspace, "No active thread", cx); + return; + }; + + let Some(store) = ThreadMetadataStore::try_global(cx) else { + Self::show_deferred_toast(&self.workspace, "Thread metadata store not available", cx); + return; + }; + + let Some(metadata) = store.read(cx).entry(thread_id).cloned() else { + Self::show_deferred_toast(&self.workspace, "No metadata found for active thread", cx); + return; + }; + + let json = thread_metadata_to_debug_json(&metadata); + let text = serde_json::to_string_pretty(&json).unwrap_or_default(); + let title = format!("Thread Metadata: {}", metadata.display_title()); + + self.open_json_buffer(title, text, window, cx); + } + + fn show_all_sidebar_thread_metadata( + &mut self, + _: &ShowAllSidebarThreadMetadata, + window: &mut Window, + cx: &mut Context, + ) { + let Some(store) = ThreadMetadataStore::try_global(cx) else { + Self::show_deferred_toast(&self.workspace, "Thread metadata store not available", cx); + return; + }; + + let entries: Vec = store + .read(cx) + .entries() + .filter(|t| !t.archived) + .map(thread_metadata_to_debug_json) + .collect(); + + let json = serde_json::Value::Array(entries); + let text = serde_json::to_string_pretty(&json).unwrap_or_default(); + + self.open_json_buffer("All Sidebar Thread Metadata".to_string(), text, window, cx); + } + + fn open_json_buffer( + &self, + title: String, + text: String, + window: &mut Window, + cx: &mut Context, + ) { + let json_language = self.language_registry.language_for_name("JSON"); + let project = self.project.clone(); + let workspace = self.workspace.clone(); + + window + .spawn(cx, async move |cx| { + let json_language = json_language.await.ok(); + + let buffer = project + .update(cx, |project, cx| { + project.create_buffer(json_language, false, cx) + }) + .await?; + + buffer.update(cx, |buffer, cx| { + buffer.set_text(text, cx); + buffer.set_capability(language::Capability::ReadWrite, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + let buffer = + cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title(title.clone())); + + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + let mut editor = + Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); + editor.set_breadcrumb_header(title); + editor.disable_mouse_wheel_zoom(); + editor + })), + None, + true, + window, + cx, + ); + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + fn handle_agent_configuration_event( &mut self, _entity: &Entity, @@ -2445,7 +2560,7 @@ impl AgentPanel { &tv, window, |this, _view, event: &AcpThreadViewEvent, _window, cx| match event { - AcpThreadViewEvent::MessageSentOrQueued => { + AcpThreadViewEvent::Interacted => { let Some(thread_id) = this.active_thread_id(cx) else { return; }; @@ -2457,7 +2572,7 @@ impl AgentPanel { this._draft_editor_observation = None; } this.retained_threads.remove(&thread_id); - cx.emit(AgentPanelEvent::MessageSentOrQueued { thread_id }); + cx.emit(AgentPanelEvent::ThreadInteracted { thread_id }); } }, ) @@ -3276,26 +3391,6 @@ impl AgentPanel { return; } - let (worktree_receivers, worktree_directory_setting) = - if matches!(args, WorktreeCreationArgs::New { .. }) { - ( - Some( - git_repos - .iter() - .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees())) - .collect::>(), - ), - Some( - ProjectSettings::get_global(cx) - .git - .worktree_directory - .clone(), - ), - ) - } else { - (None, None) - }; - let remote_connection_options = self.project.read(cx).remote_connection_options(cx); if remote_connection_options.is_some() { @@ -3332,10 +3427,18 @@ impl AgentPanel { worktree_name, branch_target, } => { - let worktree_receivers = worktree_receivers - .expect("worktree receivers must be prepared for new worktree creation"); - let worktree_directory_setting = worktree_directory_setting - .expect("worktree directory must be prepared for new worktree creation"); + let worktree_receivers: Vec<_> = this.update_in(cx, |_this, _window, cx| { + git_repos + .iter() + .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees())) + .collect() + })?; + let worktree_directory_setting = this.update_in(cx, |_this, _window, cx| { + ProjectSettings::get_global(cx) + .git + .worktree_directory + .clone() + })?; let mut existing_worktree_names = Vec::new(); let mut existing_worktree_paths = HashSet::default(); @@ -3740,7 +3843,7 @@ pub enum AgentPanelEvent { ActiveViewChanged, ThreadFocused, RetainedThreadChanged, - MessageSentOrQueued { thread_id: ThreadId }, + ThreadInteracted { thread_id: ThreadId }, } impl EventEmitter for AgentPanel {} @@ -3989,12 +4092,14 @@ impl AgentPanel { BaseView::AgentThread { conversation_view } => Some(conversation_view.clone()), _ => None, }; - let thread_with_messages = match &self.base_view { - BaseView::AgentThread { conversation_view } => { - conversation_view.read(cx).has_user_submitted_prompt(cx) - } - _ => false, - }; + + let can_regenerate_thread_title = + conversation_view.as_ref().is_some_and(|conversation_view| { + let conversation_view = conversation_view.read(cx); + conversation_view.has_user_submitted_prompt(cx) + && conversation_view.as_native_thread(cx).is_some() + }); + let has_auth_methods = match &self.base_view { BaseView::AgentThread { conversation_view } => { conversation_view.read(cx).has_auth_methods() @@ -4025,7 +4130,7 @@ impl AgentPanel { Some(ContextMenu::build(window, cx, |mut menu, _window, _| { menu = menu.context(focus_handle.clone()); - if thread_with_messages { + if can_regenerate_thread_title { menu = menu.header("Current Thread"); if let Some(conversation_view) = conversation_view.as_ref() { @@ -4159,7 +4264,7 @@ impl AgentPanel { let current_path = &repo.work_directory_abs_path; return linked_worktree_short_name(main_path, current_path) - .unwrap_or_else(|| "main".into()); + .unwrap_or_else(|| "main worktree".into()); } project @@ -4527,9 +4632,8 @@ impl AgentPanel { let base_container = h_flex() .size_full() - // TODO: This is only until we remove Agent settings from the panel. .when(!is_in_history_or_config, |this| { - this.max_w(max_content_width).mx_auto() + this.when_some(max_content_width, |this, max_w| this.max_w(max_w).mx_auto()) }) .flex_none() .justify_between() @@ -4737,56 +4841,10 @@ impl AgentPanel { plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial } - fn should_render_agent_layout_onboarding(&self, cx: &mut Context) -> bool { - // We only want to show this for existing users: those who - // have used the agent panel before the sidebar was introduced. - // We can infer that state by users having seen the onboarding - // at one point, but not the agent layout onboarding. - - let has_messages = self.active_thread_has_messages(cx); - let is_dismissed = self - .agent_layout_onboarding_dismissed - .load(Ordering::Acquire); - - if is_dismissed || has_messages { - return false; - } - - match &self.base_view { - BaseView::Uninitialized => false, - BaseView::AgentThread { .. } => { - let existing_user = self - .new_user_onboarding_upsell_dismissed - .load(Ordering::Acquire); - existing_user - } - } - } - - fn render_agent_layout_onboarding( - &self, - _window: &mut Window, - cx: &mut Context, - ) -> Option { - if !self.should_render_agent_layout_onboarding(cx) { - return None; - } - - Some(div().child(self.agent_layout_onboarding.clone())) - } - - fn dismiss_agent_layout_onboarding(&mut self, cx: &mut Context) { - self.agent_layout_onboarding_dismissed - .store(true, Ordering::Release); - AgentLayoutOnboarding::set_dismissed(true, cx); - cx.notify(); - } - fn dismiss_ai_onboarding(&mut self, cx: &mut Context) { self.new_user_onboarding_upsell_dismissed .store(true, Ordering::Release); OnboardingUpsell::set_dismissed(true, cx); - self.dismiss_agent_layout_onboarding(cx); cx.notify(); } @@ -5048,7 +5106,6 @@ impl Render for AgentPanel { .child(self.render_toolbar(window, cx)) .children(self.render_workspace_trust_message(cx)) .children(self.render_new_user_onboarding(window, cx)) - .children(self.render_agent_layout_onboarding(window, cx)) .map(|parent| match self.visible_surface() { VisibleSurface::Uninitialized => parent, VisibleSurface::AgentThread(conversation_view) => parent @@ -5139,12 +5196,6 @@ impl Dismissable for OnboardingUpsell { const KEY: &'static str = "dismissed-trial-upsell"; } -struct AgentLayoutOnboarding; - -impl Dismissable for AgentLayoutOnboarding { - const KEY: &'static str = "dismissed-agent-layout-onboarding"; -} - struct TrialEndUpsell; impl Dismissable for TrialEndUpsell { diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index b5b6a7dea69962c2604252913216dcfe72bb2e84..f703507de126fceae632bfd562e4639a18ff052a 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -112,6 +112,8 @@ actions!( OpenHistory, /// Adds a context server to the configuration. AddContextServer, + /// Archives the currently selected thread. + ArchiveSelectedThread, /// Removes the currently selected thread. RemoveSelectedThread, /// Starts a chat conversation with follow-up enabled. @@ -203,6 +205,16 @@ actions!( ] ); +actions!( + dev, + [ + /// Shows metadata for the currently active thread. + ShowThreadMetadata, + /// Shows metadata for all threads in the sidebar. + ShowAllSidebarThreadMetadata, + ] +); + /// Action to authorize a tool call with a specific permission option. /// This is used by the permission granularity dropdown to authorize tool calls. #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] @@ -766,7 +778,7 @@ mod tests { flexible: true, default_width: px(300.), default_height: px(600.), - max_content_width: px(850.), + max_content_width: Some(px(850.)), default_model: None, inline_assistant_model: None, inline_assistant_use_streaming_tools: false, diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 787fe774c3b7864ebe679b7bef1525f8a5e6ec49..bd78440df1e49ce55b3cf311b64141a677f473f4 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -396,18 +396,18 @@ fn affects_thread_metadata(event: &AcpThreadEvent) -> bool { match event { AcpThreadEvent::NewEntry | AcpThreadEvent::TitleUpdated - | AcpThreadEvent::EntryUpdated(_) - | AcpThreadEvent::EntriesRemoved(_) | AcpThreadEvent::ToolAuthorizationRequested(_) | AcpThreadEvent::ToolAuthorizationReceived(_) - | AcpThreadEvent::Retry(_) | AcpThreadEvent::Stopped(_) | AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) | AcpThreadEvent::Refusal | AcpThreadEvent::WorkingDirectoriesUpdated => true, // -- - AcpThreadEvent::TokenUsageUpdated + AcpThreadEvent::EntryUpdated(_) + | AcpThreadEvent::EntriesRemoved(_) + | AcpThreadEvent::Retry(_) + | AcpThreadEvent::TokenUsageUpdated | AcpThreadEvent::PromptCapabilitiesUpdated | AcpThreadEvent::AvailableCommandsUpdated(_) | AcpThreadEvent::ModeUpdated(_) diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 1a2f5d38af2d13cf7efc4829d498fa88dc4a09cd..b6bda7738d5424e842d66c4a625fe64684ce5c14 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -206,7 +206,7 @@ impl RenderOnce for GeneratingSpinnerElement { } pub enum AcpThreadViewEvent { - MessageSentOrQueued, + Interacted, } impl EventEmitter for ThreadView {} @@ -954,7 +954,6 @@ impl ThreadView { let has_queued = self.has_queued_messages(); if is_editor_empty && self.can_fast_track_queue && has_queued { self.can_fast_track_queue = false; - cx.emit(AcpThreadViewEvent::MessageSentOrQueued); self.send_queued_message_at_index(0, true, window, cx); return; } @@ -964,7 +963,7 @@ impl ThreadView { } if is_generating { - cx.emit(AcpThreadViewEvent::MessageSentOrQueued); + cx.emit(AcpThreadViewEvent::Interacted); self.queue_message(message_editor, window, cx); return; } @@ -1006,7 +1005,7 @@ impl ThreadView { } } - cx.emit(AcpThreadViewEvent::MessageSentOrQueued); + cx.emit(AcpThreadViewEvent::Interacted); self.send_impl(message_editor, window, cx) } @@ -1209,6 +1208,8 @@ impl ThreadView { return; } + cx.emit(AcpThreadViewEvent::Interacted); + let message_editor = self.message_editor.clone(); if thread.read(cx).status() == ThreadStatus::Idle { self.send_impl(message_editor, window, cx); @@ -1371,6 +1372,7 @@ impl ThreadView { } let task = thread.update(cx, |thread, cx| thread.retry(cx)); + cx.emit(AcpThreadViewEvent::Interacted); self.sync_generating_indicator(cx); cx.notify(); cx.spawn(async move |this, cx| { @@ -1430,6 +1432,7 @@ impl ThreadView { .update(cx, |thread, cx| thread.rewind(user_message_id, cx)) .await?; this.update_in(cx, |thread, window, cx| { + cx.emit(AcpThreadViewEvent::Interacted); thread.send_impl(message_editor, window, cx); thread.focus_handle(cx).focus(window, cx); })?; @@ -1522,6 +1525,8 @@ impl ThreadView { return; }; + cx.emit(AcpThreadViewEvent::Interacted); + self.message_editor.focus_handle(cx).focus(window, cx); let content = queued.content; @@ -2285,13 +2290,15 @@ impl ThreadView { h_flex() .w_full() + .px_2() .justify_center() .child( v_flex() - .flex_basis(max_content_width) + .when_some(max_content_width, |this, max_w| this.flex_basis(max_w)) + .when(max_content_width.is_none(), |this| this.w_full()) .flex_shrink() .flex_grow_0() - .mx_2() + .max_w_full() .bg(self.activity_bar_bg(cx)) .border_1() .border_b_0() @@ -2844,7 +2851,7 @@ impl ThreadView { IconButton::new("dismiss-plan", IconName::Close) .icon_size(IconSize::XSmall) .shape(ui::IconButtonShape::Square) - .tooltip(Tooltip::text("Clear plan")) + .tooltip(Tooltip::text("Clear Plan")) .on_click(cx.listener(|this, _, _, cx| { this.thread.update(cx, |thread, cx| thread.clear_plan(cx)); cx.stop_propagation(); @@ -2868,51 +2875,64 @@ impl ThreadView { .max_h_40() .overflow_y_scroll() .children(plan.entries.iter().enumerate().flat_map(|(index, entry)| { - let element = h_flex() - .py_1() - .px_2() - .gap_2() - .justify_between() - .bg(cx.theme().colors().editor_background) - .when(index < plan.entries.len() - 1, |parent| { - parent.border_color(cx.theme().colors().border).border_b_1() - }) - .child( - h_flex() - .id(("plan_entry", index)) - .gap_1p5() - .max_w_full() - .overflow_x_scroll() - .text_xs() - .text_color(cx.theme().colors().text_muted) - .child(match entry.status { - acp::PlanEntryStatus::InProgress => { - Icon::new(IconName::TodoProgress) - .size(IconSize::Small) - .color(Color::Accent) - .with_rotate_animation(2) - .into_any_element() - } - acp::PlanEntryStatus::Completed => { - Icon::new(IconName::TodoComplete) - .size(IconSize::Small) - .color(Color::Success) - .into_any_element() - } - acp::PlanEntryStatus::Pending | _ => { - Icon::new(IconName::TodoPending) - .size(IconSize::Small) - .color(Color::Muted) - .into_any_element() - } - }) - .child(MarkdownElement::new( - entry.content.clone(), - plan_label_markdown_style(&entry.status, window, cx), - )), - ); + let entry_bg = cx.theme().colors().editor_background; + let tooltip_text: SharedString = entry.content.read(cx).source().to_string().into(); - Some(element) + Some( + h_flex() + .id(("plan_entry_row", index)) + .py_1() + .px_2() + .gap_2() + .justify_between() + .relative() + .bg(entry_bg) + .when(index < plan.entries.len() - 1, |parent| { + parent.border_color(cx.theme().colors().border).border_b_1() + }) + .overflow_hidden() + .child( + h_flex() + .id(("plan_entry", index)) + .gap_1p5() + .min_w_0() + .text_xs() + .text_color(cx.theme().colors().text_muted) + .child(match entry.status { + acp::PlanEntryStatus::InProgress => { + Icon::new(IconName::TodoProgress) + .size(IconSize::Small) + .color(Color::Accent) + .with_rotate_animation(2) + .into_any_element() + } + acp::PlanEntryStatus::Completed => { + Icon::new(IconName::TodoComplete) + .size(IconSize::Small) + .color(Color::Success) + .into_any_element() + } + acp::PlanEntryStatus::Pending | _ => { + Icon::new(IconName::TodoPending) + .size(IconSize::Small) + .color(Color::Muted) + .into_any_element() + } + }) + .child(MarkdownElement::new( + entry.content.clone(), + plan_label_markdown_style(&entry.status, window, cx), + )), + ) + .child(div().absolute().top_0().right_0().h_full().w_8().bg( + linear_gradient( + 90., + linear_color_stop(entry_bg, 1.), + linear_color_stop(entry_bg.opacity(0.), 0.), + ), + )) + .tooltip(Tooltip::text(tooltip_text)), + ) })) .into_any_element() } @@ -3181,8 +3201,7 @@ impl ThreadView { .child( h_flex() .size_full() - .max_w(max_content_width) - .mx_auto() + .when_some(max_content_width, |this, max_w| this.max_w(max_w).mx_auto()) .pl_2() .pr_1() .flex_shrink_0() @@ -3279,7 +3298,8 @@ impl ThreadView { }) .child( v_flex() - .flex_basis(max_content_width) + .when_some(max_content_width, |this, max_w| this.flex_basis(max_w)) + .when(max_content_width.is_none(), |this| this.w_full()) .flex_shrink() .flex_grow_0() .when(fills_container, |this| this.h_full()) @@ -3822,12 +3842,22 @@ impl ThreadView { let enable_thinking = !thread.thinking_enabled(); thread.set_thinking_enabled(enable_thinking, cx); + let favorite_key = thread.model().map(|model| { + (model.provider_id().0.to_string(), model.id().0.to_string()) + }); let fs = thread.project().read(cx).fs().clone(); update_settings_file(fs, cx, move |settings, _| { - if let Some(agent) = settings.agent.as_mut() - && let Some(default_model) = agent.default_model.as_mut() - { - default_model.enable_thinking = enable_thinking; + if let Some(agent) = settings.agent.as_mut() { + if let Some(default_model) = agent.default_model.as_mut() { + default_model.enable_thinking = enable_thinking; + } + if let Some((provider_id, model_id)) = &favorite_key { + agent.update_favorite_model( + provider_id, + model_id, + |favorite| favorite.enable_thinking = enable_thinking, + ); + } } }); }); @@ -3958,14 +3988,33 @@ impl ThreadView { cx, ); + let favorite_key = thread.model().map(|model| { + ( + model.provider_id().0.to_string(), + model.id().0.to_string(), + ) + }); let fs = thread.project().read(cx).fs().clone(); update_settings_file(fs, cx, move |settings, _| { - if let Some(agent) = settings.agent.as_mut() - && let Some(default_model) = + if let Some(agent) = settings.agent.as_mut() { + if let Some(default_model) = agent.default_model.as_mut() - { - default_model.effort = - Some(effort.to_string()); + { + default_model.effort = + Some(effort.to_string()); + } + if let Some((provider_id, model_id)) = + &favorite_key + { + agent.update_favorite_model( + provider_id, + model_id, + |favorite| { + favorite.effort = + Some(effort.to_string()) + }, + ); + } } }); }); @@ -4467,10 +4516,12 @@ impl ThreadView { fn render_entries(&mut self, cx: &mut Context) -> List { let max_content_width = AgentSettings::get_global(cx).max_content_width; let centered_container = move |content: AnyElement| { - h_flex() - .w_full() - .justify_center() - .child(div().max_w(max_content_width).w_full().child(content)) + h_flex().w_full().justify_center().child( + div() + .when_some(max_content_width, |this, max_w| this.max_w(max_w)) + .w_full() + .child(content), + ) }; list( @@ -7693,6 +7744,7 @@ impl ThreadView { gpui::ImageFormat::Bmp => "BMP", gpui::ImageFormat::Tiff => "TIFF", gpui::ImageFormat::Ico => "ICO", + gpui::ImageFormat::Pnm => "PNM", }; let dimensions = image::ImageReader::new(std::io::Cursor::new(image.bytes())) .with_guessed_format() @@ -8771,7 +8823,7 @@ impl ThreadView { } fn render_token_limit_callout(&self, cx: &mut Context) -> Option { - if self.token_limit_callout_dismissed { + if self.token_limit_callout_dismissed || self.as_native_thread(cx).is_none() { return None; } @@ -8858,12 +8910,20 @@ impl ThreadView { .unwrap_or(Speed::Fast); thread.set_speed(new_speed, cx); + let favorite_key = thread + .model() + .map(|model| (model.provider_id().0.to_string(), model.id().0.to_string())); let fs = thread.project().read(cx).fs().clone(); update_settings_file(fs, cx, move |settings, _| { - if let Some(agent) = settings.agent.as_mut() - && let Some(default_model) = agent.default_model.as_mut() - { - default_model.speed = Some(new_speed); + if let Some(agent) = settings.agent.as_mut() { + if let Some(default_model) = agent.default_model.as_mut() { + default_model.speed = Some(new_speed); + } + if let Some((provider_id, model_id)) = &favorite_key { + agent.update_favorite_model(provider_id, model_id, |favorite| { + favorite.speed = Some(new_speed) + }); + } } }); }); @@ -8904,12 +8964,20 @@ impl ThreadView { thread.update(cx, |thread, cx| { thread.set_thinking_effort(Some(next_effort.clone()), cx); + let favorite_key = thread + .model() + .map(|model| (model.provider_id().0.to_string(), model.id().0.to_string())); let fs = thread.project().read(cx).fs().clone(); update_settings_file(fs, cx, move |settings, _| { - if let Some(agent) = settings.agent.as_mut() - && let Some(default_model) = agent.default_model.as_mut() - { - default_model.effort = Some(next_effort); + if let Some(agent) = settings.agent.as_mut() { + if let Some(default_model) = agent.default_model.as_mut() { + default_model.effort = Some(next_effort.clone()); + } + if let Some((provider_id, model_id)) = &favorite_key { + agent.update_favorite_model(provider_id, model_id, |favorite| { + favorite.effort = Some(next_effort) + }); + } } }); }); diff --git a/crates/agent_ui/src/favorite_models.rs b/crates/agent_ui/src/favorite_models.rs index aa48ca8d12459b2860982a6b204a5350e6c4ce4c..c655f9b6a55aecb7e7ce6e419702e3d0cc6983c2 100644 --- a/crates/agent_ui/src/favorite_models.rs +++ b/crates/agent_ui/src/favorite_models.rs @@ -1,27 +1,27 @@ use std::sync::Arc; +use agent_settings::{AgentSettings, language_model_to_selection}; use fs::Fs; use language_model::LanguageModel; -use settings::{LanguageModelSelection, update_settings_file}; +use settings::{Settings as _, update_settings_file}; use ui::App; -fn language_model_to_selection(model: &Arc) -> LanguageModelSelection { - LanguageModelSelection { - provider: model.provider_id().to_string().into(), - model: model.id().0.to_string(), - enable_thinking: false, - effort: None, - speed: None, - } -} - pub fn toggle_in_settings( model: Arc, should_be_favorite: bool, fs: Arc, cx: &mut App, ) { - let selection = language_model_to_selection(&model); + let current_user_selection = AgentSettings::get_global(cx) + .default_model + .as_ref() + .filter(|selection| { + selection.provider.0 == model.provider_id().0.as_ref() + && selection.model == model.id().0.as_ref() + }) + .cloned(); + + let selection = language_model_to_selection(&model, current_user_selection.as_ref()); update_settings_file(fs, cx, move |settings, _| { let agent = settings.agent.get_or_insert_default(); if should_be_favorite { diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 899542245ab8f3618f6d70d807363cc91af3a257..7de58fd54ffd0d984b3a6079681f15f6a56507ae 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -8,8 +8,8 @@ use gpui::{ Subscription, Task, }; use language_model::{ - AuthenticateError, ConfiguredModel, IconOrSvg, LanguageModel, LanguageModelId, - LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, + ConfiguredModel, IconOrSvg, LanguageModel, LanguageModelId, LanguageModelProvider, + LanguageModelProviderId, LanguageModelRegistry, }; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; @@ -124,7 +124,6 @@ pub struct LanguageModelPickerDelegate { all_models: Arc, filtered_entries: Vec, selected_index: usize, - _authenticate_all_providers_task: Task<()>, _subscriptions: Vec, popover_styles: bool, focus_handle: FocusHandle, @@ -151,7 +150,6 @@ impl LanguageModelPickerDelegate { filtered_entries: entries, get_active_model: Arc::new(get_active_model), on_toggle_favorite: Arc::new(on_toggle_favorite), - _authenticate_all_providers_task: Self::authenticate_all_providers(cx), _subscriptions: vec![cx.subscribe_in( &LanguageModelRegistry::global(cx), window, @@ -197,56 +195,6 @@ impl LanguageModelPickerDelegate { .unwrap_or(0) } - /// Authenticates all providers in the [`LanguageModelRegistry`]. - /// - /// We do this so that we can populate the language selector with all of the - /// models from the configured providers. - fn authenticate_all_providers(cx: &mut App) -> Task<()> { - let authenticate_all_providers = LanguageModelRegistry::global(cx) - .read(cx) - .visible_providers() - .iter() - .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) - .collect::>(); - - cx.spawn(async move |_cx| { - for (provider_id, provider_name, authenticate_task) in authenticate_all_providers { - if let Err(err) = authenticate_task.await { - if matches!(err, AuthenticateError::CredentialsNotFound) { - // Since we're authenticating these providers in the - // background for the purposes of populating the - // language selector, we don't care about providers - // where the credentials are not found. - } else { - // Some providers have noisy failure states that we - // don't want to spam the logs with every time the - // language model selector is initialized. - // - // Ideally these should have more clear failure modes - // that we know are safe to ignore here, like what we do - // with `CredentialsNotFound` above. - match provider_id.0.as_ref() { - "lmstudio" | "ollama" => { - // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated". - // - // These fail noisily, so we don't log them. - } - "copilot_chat" => { - // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors. - } - _ => { - log::error!( - "Failed to authenticate provider: {}: {err:#}", - provider_name.0 - ); - } - } - } - } - } - }) - } - pub fn active_model(&self, cx: &App) -> Option { (self.get_active_model)(cx) } diff --git a/crates/agent_ui/src/thread_history_view.rs b/crates/agent_ui/src/thread_history_view.rs index 8facafecd9518eafcbf2a9e0486674e0abcd9ebc..1cebd175be46eaabd420853a3997ae1fd6ce7a50 100644 --- a/crates/agent_ui/src/thread_history_view.rs +++ b/crates/agent_ui/src/thread_history_view.rs @@ -74,7 +74,7 @@ impl ThreadHistoryView { ) -> Self { let search_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Search threads...", window, cx); + editor.set_placeholder_text("Search all threads…", window, cx); editor }); diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs index 3c05c77820cc6e73b1747941480310a7ce1f1de8..a8bd95916a7111afe4a7a75f11ff78f3547b4398 100644 --- a/crates/agent_ui/src/thread_import.rs +++ b/crates/agent_ui/src/thread_import.rs @@ -11,7 +11,7 @@ use gpui::{ App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, SharedString, Task, WeakEntity, Window, }; -use notifications::status_toast::{StatusToast, ToastIcon}; +use notifications::status_toast::StatusToast; use project::{AgentId, AgentRegistryStore, AgentServerStore}; use release_channel::ReleaseChannel; use remote::RemoteConnectionOptions; @@ -275,8 +275,12 @@ impl ThreadImportModal { fn show_imported_threads_toast(&self, imported_count: usize, cx: &mut App) { let status_toast = if imported_count == 0 { StatusToast::new("No threads found to import.", cx, |this, _cx| { - this.icon(ToastIcon::new(IconName::Info).color(Color::Muted)) - .dismiss_button(true) + this.icon( + Icon::new(IconName::Info) + .size(IconSize::Small) + .color(Color::Muted), + ) + .dismiss_button(true) }) } else { let message = if imported_count == 1 { @@ -285,8 +289,12 @@ impl ThreadImportModal { format!("Imported {imported_count} threads.") }; StatusToast::new(message, cx, |this, _cx| { - this.icon(ToastIcon::new(IconName::Check).color(Color::Success)) - .dismiss_button(true) + this.icon( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success), + ) + .dismiss_button(true) }) }; @@ -383,7 +391,7 @@ impl Render for ThreadImportModal { .headline("Import External Agent Threads") .description( "Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client. \ - Choose which agents to include, and their threads will appear in your archive." + Choose which agents to include, and their threads will appear in your thread history." ) .show_dismiss_button(true), @@ -661,7 +669,7 @@ fn show_cross_channel_import_toast( ) { let status_toast = if imported_count == 0 { StatusToast::new("No new threads found to import.", cx, |this, _cx| { - this.icon(ToastIcon::new(IconName::Info).color(Color::Muted)) + this.icon(Icon::new(IconName::Info).color(Color::Muted)) .dismiss_button(true) }) } else { @@ -671,7 +679,7 @@ fn show_cross_channel_import_toast( format!("Imported {imported_count} threads from other channels.") }; StatusToast::new(message, cx, |this, _cx| { - this.icon(ToastIcon::new(IconName::Check).color(Color::Success)) + this.icon(Icon::new(IconName::Check).color(Color::Success)) .dismiss_button(true) }) }; diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 0377a8d2675a4c32e26a70f48f3e6b93561deebd..8a65a682ac806384cc9048964377ada9c9df2e41 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -346,7 +346,7 @@ pub fn worktree_info_from_thread_paths( .unwrap_or_default(); linked_short_names.push((short_name.clone(), project_name)); infos.push(ThreadItemWorktreeInfo { - name: short_name, + worktree_name: Some(short_name), full_path: SharedString::from(folder_path.display().to_string()), highlight_positions: Vec::new(), kind: WorktreeKind::Linked, @@ -357,7 +357,7 @@ pub fn worktree_info_from_thread_paths( continue; }; infos.push(ThreadItemWorktreeInfo { - name: SharedString::from(name.to_string_lossy().to_string()), + worktree_name: Some(SharedString::from(name.to_string_lossy().to_string())), full_path: SharedString::from(folder_path.display().to_string()), highlight_positions: Vec::new(), kind: WorktreeKind::Main, @@ -370,7 +370,10 @@ pub fn worktree_info_from_thread_paths( // folder paths don't all share the same short name, prefix each // linked worktree chip with its main project name so the user knows // which project it belongs to. - let all_same_name = infos.len() > 1 && infos.iter().all(|i| i.name == infos[0].name); + let all_same_name = infos.len() > 1 + && infos + .iter() + .all(|i| i.worktree_name == infos[0].worktree_name); if unique_main_count.len() > 1 && !all_same_name { for (info, (_short_name, project_name)) in infos @@ -378,7 +381,9 @@ pub fn worktree_info_from_thread_paths( .filter(|i| i.kind == WorktreeKind::Linked) .zip(linked_short_names.iter()) { - info.name = SharedString::from(format!("{}:{}", project_name, info.name)); + if let Some(name) = &info.worktree_name { + info.worktree_name = Some(SharedString::from(format!("{}:{}", project_name, name))); + } } } @@ -1171,7 +1176,9 @@ impl ThreadMetadataStore { .and_then(|t| t.created_at) .unwrap_or_else(|| updated_at); - let interacted_at = existing_thread.and_then(|t| t.interacted_at); + let interacted_at = existing_thread + .map(|t| t.interacted_at) + .unwrap_or(Some(updated_at)); let agent_id = thread_ref.connection().agent_id(); diff --git a/crates/agent_ui/src/thread_worktree_archive.rs b/crates/agent_ui/src/thread_worktree_archive.rs index d131daf5cc5af410a63a854ef4821efbc8f7180d..bc2dfdd07114dc20bae64deb43c86acec3635376 100644 --- a/crates/agent_ui/src/thread_worktree_archive.rs +++ b/crates/agent_ui/src/thread_worktree_archive.rs @@ -247,78 +247,9 @@ async fn remove_root_after_worktree_removal( // alive until the repo removes the worktree drop(project); result.context("git worktree metadata cleanup failed")?; - - // Empty-parent cleanup uses local std::fs — skip for remote projects. - if root.remote_connection.is_none() { - remove_empty_parent_dirs_up_to_worktrees_base( - root.root_path.clone(), - root.main_repo_path.clone(), - cx, - ) - .await; - } - Ok(()) } -/// After `git worktree remove` deletes the worktree directory, clean up any -/// empty parent directories between it and the Zed-managed worktrees base -/// directory (configured via `git.worktree_directory`). The base directory -/// itself is never removed. -/// -/// If the base directory is not an ancestor of `root_path`, no parent -/// directories are removed. -async fn remove_empty_parent_dirs_up_to_worktrees_base( - root_path: PathBuf, - main_repo_path: PathBuf, - cx: &mut AsyncApp, -) { - let worktrees_base = cx.update(|cx| worktrees_base_for_repo(&main_repo_path, cx)); - - if let Some(worktrees_base) = worktrees_base { - cx.background_executor() - .spawn(async move { - remove_empty_ancestors(&root_path, &worktrees_base); - }) - .await; - } -} - -/// Removes empty directories between `child_path` and `base_path`. -/// -/// Walks upward from `child_path`, removing each empty parent directory, -/// stopping before `base_path` itself is removed. If `base_path` is not -/// an ancestor of `child_path`, nothing is removed. If any directory is -/// non-empty (i.e. `std::fs::remove_dir` fails), the walk stops. -fn remove_empty_ancestors(child_path: &Path, base_path: &Path) { - let mut current = child_path; - while let Some(parent) = current.parent() { - if parent == base_path { - break; - } - if !parent.starts_with(base_path) { - break; - } - match std::fs::remove_dir(parent) { - Ok(()) => { - log::info!("Removed empty parent directory: {}", parent.display()); - } - Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => break, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - // Already removed by a concurrent process; keep walking upward. - } - Err(err) => { - log::error!( - "Failed to remove parent directory {}: {err}", - parent.display() - ); - break; - } - } - current = parent; - } -} - /// Finds a live `Repository` entity for the given path, or creates a temporary /// project to obtain one. /// @@ -913,7 +844,6 @@ mod tests { use project::Project; use serde_json::json; use settings::SettingsStore; - use tempfile::TempDir; use workspace::MultiWorkspace; fn init_test(cx: &mut TestAppContext) { @@ -926,117 +856,6 @@ mod tests { }); } - #[test] - fn test_remove_empty_ancestors_single_empty_parent() { - let tmp = TempDir::new().unwrap(); - let base = tmp.path().join("worktrees"); - let branch_dir = base.join("my-branch"); - let child = branch_dir.join("zed"); - - std::fs::create_dir_all(&child).unwrap(); - // Simulate git worktree remove having deleted the child. - std::fs::remove_dir(&child).unwrap(); - - assert!(branch_dir.exists()); - remove_empty_ancestors(&child, &base); - assert!(!branch_dir.exists(), "empty parent should be removed"); - assert!(base.exists(), "base directory should be preserved"); - } - - #[test] - fn test_remove_empty_ancestors_nested_empty_parents() { - let tmp = TempDir::new().unwrap(); - let base = tmp.path().join("worktrees"); - // Branch name with slash creates nested dirs: fix/thing/zed - let child = base.join("fix").join("thing").join("zed"); - - std::fs::create_dir_all(&child).unwrap(); - std::fs::remove_dir(&child).unwrap(); - - assert!(base.join("fix").join("thing").exists()); - remove_empty_ancestors(&child, &base); - assert!(!base.join("fix").join("thing").exists()); - assert!( - !base.join("fix").exists(), - "all empty ancestors should be removed" - ); - assert!(base.exists(), "base directory should be preserved"); - } - - #[test] - fn test_remove_empty_ancestors_stops_at_non_empty_parent() { - let tmp = TempDir::new().unwrap(); - let base = tmp.path().join("worktrees"); - let branch_dir = base.join("my-branch"); - let child = branch_dir.join("zed"); - let sibling = branch_dir.join("other-file.txt"); - - std::fs::create_dir_all(&child).unwrap(); - std::fs::write(&sibling, "content").unwrap(); - std::fs::remove_dir(&child).unwrap(); - - remove_empty_ancestors(&child, &base); - assert!(branch_dir.exists(), "non-empty parent should be preserved"); - assert!(sibling.exists()); - } - - #[test] - fn test_remove_empty_ancestors_not_an_ancestor() { - let tmp = TempDir::new().unwrap(); - let base = tmp.path().join("worktrees"); - let unrelated = tmp.path().join("other-place").join("branch").join("zed"); - - std::fs::create_dir_all(&base).unwrap(); - std::fs::create_dir_all(&unrelated).unwrap(); - std::fs::remove_dir(&unrelated).unwrap(); - - let parent = unrelated.parent().unwrap(); - assert!(parent.exists()); - remove_empty_ancestors(&unrelated, &base); - assert!(parent.exists(), "should not remove dirs outside base"); - } - - #[test] - fn test_remove_empty_ancestors_child_is_direct_child_of_base() { - let tmp = TempDir::new().unwrap(); - let base = tmp.path().join("worktrees"); - let child = base.join("zed"); - - std::fs::create_dir_all(&child).unwrap(); - std::fs::remove_dir(&child).unwrap(); - - remove_empty_ancestors(&child, &base); - assert!(base.exists(), "base directory should be preserved"); - } - - #[test] - fn test_remove_empty_ancestors_partially_non_empty_chain() { - let tmp = TempDir::new().unwrap(); - let base = tmp.path().join("worktrees"); - // Structure: base/a/b/c/zed where a/ has another child besides b/ - let child = base.join("a").join("b").join("c").join("zed"); - let other_in_a = base.join("a").join("other-branch"); - - std::fs::create_dir_all(&child).unwrap(); - std::fs::create_dir_all(&other_in_a).unwrap(); - std::fs::remove_dir(&child).unwrap(); - - remove_empty_ancestors(&child, &base); - assert!( - !base.join("a").join("b").join("c").exists(), - "c/ should be removed (empty)" - ); - assert!( - !base.join("a").join("b").exists(), - "b/ should be removed (empty)" - ); - assert!( - base.join("a").exists(), - "a/ should be preserved (has other-branch sibling)" - ); - assert!(other_in_a.exists()); - } - #[gpui::test] async fn test_build_root_plan_returns_none_for_main_worktree(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/thread_worktree_picker.rs b/crates/agent_ui/src/thread_worktree_picker.rs index c77da77d0d353e27d8e45d896e9657a853633e05..93d04fd131d4241d15c2fbb0af96b5d69d3920af 100644 --- a/crates/agent_ui/src/thread_worktree_picker.rs +++ b/crates/agent_ui/src/thread_worktree_picker.rs @@ -361,7 +361,6 @@ impl PickerDelegate for ThreadWorktreePickerDelegate { } // When the user is typing, fuzzy-match worktree names using display_name - // For the main worktree, also match against "main" let main_worktree_path = repo_worktrees .iter() .find(|wt| wt.is_main) diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index aa082a0c23e524c142426bde171a7ed28aa32cf7..b86d19f2013442690ebc3ca80afb71ae8b576e0e 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -7,7 +7,7 @@ use crate::agent_connection_store::AgentConnectionStore; use crate::thread_metadata_store::{ ThreadId, ThreadMetadata, ThreadMetadataStore, worktree_info_from_thread_paths, }; -use crate::{Agent, DEFAULT_THREAD_TITLE, RemoveSelectedThread}; +use crate::{Agent, ArchiveSelectedThread, DEFAULT_THREAD_TITLE, RemoveSelectedThread}; use agent::ThreadStore; use agent_client_protocol as acp; @@ -30,10 +30,9 @@ use picker::{ use project::{AgentId, AgentServerStore}; use settings::Settings as _; use theme::ActiveTheme; -use ui::{AgentThreadStatus, IconDecoration, IconDecorationKind, Tab, ThreadItem}; use ui::{ - Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tooltip, WithScrollbar, - prelude::*, utils::platform_title_bar_height, + AgentThreadStatus, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tab, + ThreadItem, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height, }; use ui_input::ErasedEditor; use util::ResultExt; @@ -46,6 +45,13 @@ use workspace::{ use zed_actions::agents_sidebar::FocusSidebarFilter; use zed_actions::editor::{MoveDown, MoveUp}; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum ThreadFilter { + #[default] + All, + ArchivedOnly, +} + #[derive(Clone)] enum ArchiveListItem { BucketSeparator(TimeBucket), @@ -118,6 +124,7 @@ pub enum ThreadsArchiveViewEvent { Close, Activate { thread: ThreadMetadata }, CancelRestore { thread_id: ThreadId }, + Import, } impl EventEmitter for ThreadsArchiveView {} @@ -140,7 +147,7 @@ pub struct ThreadsArchiveView { archived_thread_ids: HashSet, archived_branch_names: HashMap>, _load_branch_names_task: Task<()>, - show_archived_only: bool, + thread_filter: ThreadFilter, } impl ThreadsArchiveView { @@ -155,7 +162,7 @@ impl ThreadsArchiveView { let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Search threads…", window, cx); + editor.set_placeholder_text("Search all threads…", window, cx); editor }); @@ -214,7 +221,7 @@ impl ThreadsArchiveView { archived_thread_ids: HashSet::default(), archived_branch_names: HashMap::default(), _load_branch_names_task: Task::ready(()), - show_archived_only: false, + thread_filter: ThreadFilter::All, }; this.update_items(cx); @@ -253,11 +260,14 @@ impl ThreadsArchiveView { } fn update_items(&mut self, cx: &mut Context) { - let show_archived_only = self.show_archived_only; + let thread_filter = self.thread_filter; let sessions = ThreadMetadataStore::global(cx) .read(cx) .entries() - .filter(|t| !show_archived_only || t.archived) + .filter(|t| match thread_filter { + ThreadFilter::All => true, + ThreadFilter::ArchivedOnly => t.archived, + }) .sorted_by_cached_key(|t| t.created_at.unwrap_or(t.updated_at)) .rev() .cloned() @@ -310,11 +320,7 @@ impl ThreadsArchiveView { let preserve = self.preserve_selection_on_next_update; self.preserve_selection_on_next_update = false; - let saved_scroll = if preserve { - Some(self.list_state.logical_scroll_top()) - } else { - None - }; + let saved_scroll = self.list_state.logical_scroll_top(); self.list_state.reset(items.len()); self.items = items; @@ -327,9 +333,9 @@ impl ThreadsArchiveView { } } - if let Some(scroll_top) = saved_scroll { - self.list_state.scroll_to(scroll_top); + self.list_state.scroll_to(saved_scroll); + if preserve { if let Some(ix) = self.selection { let next = self.find_next_selectable(ix).or_else(|| { ix.checked_sub(1) @@ -389,6 +395,24 @@ impl ThreadsArchiveView { ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(thread_id, None, cx)); } + fn archive_selected_thread( + &mut self, + _: &ArchiveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.selection else { return }; + let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else { + return; + }; + + if thread.archived { + return; + } + + self.archive_thread(thread.thread_id, cx); + } + fn unarchive_thread( &mut self, thread: ThreadMetadata, @@ -606,24 +630,14 @@ impl ThreadsArchiveView { &branch_names_for_thread, ); - let color = cx.theme().colors(); - let knockout_color = color - .title_bar_background - .blend(color.panel_background.opacity(0.25)); - let archived_decoration = - IconDecoration::new(IconDecorationKind::Archive, knockout_color, cx) - .color(color.icon_disabled) - .position(gpui::Point { - x: px(-3.), - y: px(-3.5), - }); + let archived_color = Color::Custom(cx.theme().colors().icon_muted.opacity(0.6)); let base = ThreadItem::new(id, thread.display_title()) .icon(icon) .when(is_archived, |this| { - this.icon_color(Color::Muted) + this.archived(true) + .icon_color(archived_color) .title_label_color(Color::Muted) - .icon_decoration(archived_decoration) }) .when_some(icon_from_external_svg, |this, svg| { this.custom_icon_from_external_svg(svg) @@ -661,7 +675,6 @@ impl ThreadsArchiveView { }) }), ) - .tooltip(Tooltip::text("Restoring…")) .into_any_element() } else if is_archived { base.action_slot( @@ -694,9 +707,6 @@ impl ThreadsArchiveView { }) }), ) - .tooltip(move |_, cx| { - Tooltip::for_action("Open Archived Thread", &menu::Confirm, cx) - }) .on_click({ let thread = thread.clone(); cx.listener(move |this, _, window, cx| { @@ -709,7 +719,16 @@ impl ThreadsArchiveView { IconButton::new("archive-thread", IconName::Archive) .icon_size(IconSize::Small) .icon_color(Color::Muted) - .tooltip(Tooltip::text("Archive Thread")) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + "Archive Thread", + &ArchiveSelectedThread, + &focus_handle, + cx, + ) + } + }) .on_click({ let thread_id = thread.thread_id; cx.listener(move |this, _, _, cx| { @@ -718,7 +737,6 @@ impl ThreadsArchiveView { }) }), ) - .tooltip(move |_, cx| Tooltip::for_action("Open Thread", &menu::Confirm, cx)) .on_click({ let thread = thread.clone(); cx.listener(move |this, _, window, cx| { @@ -869,14 +887,13 @@ impl ThreadsArchiveView { .filter(|item| matches!(item, ArchiveListItem::Entry { .. })) .count(); + let has_archived_threads = { + let store = ThreadMetadataStore::global(cx).read(cx); + store.archived_entries().next().is_some() + }; + let count_label = if entry_count == 1 { - if self.show_archived_only { - "1 archived thread".to_string() - } else { - "1 thread".to_string() - } - } else if self.show_archived_only { - format!("{} archived threads", entry_count) + "1 thread".to_string() } else { format!("{} threads", entry_count) }; @@ -895,18 +912,37 @@ impl ThreadsArchiveView { .color(Color::Muted), ) .child( - IconButton::new("toggle-archived-only", IconName::ListFilter) - .icon_size(IconSize::Small) - .toggle_state(self.show_archived_only) - .tooltip(Tooltip::text(if self.show_archived_only { - "Show All Threads" - } else { - "Show Archived Only" - })) - .on_click(cx.listener(|this, _, _, cx| { - this.show_archived_only = !this.show_archived_only; - this.update_items(cx); - })), + h_flex() + .child( + IconButton::new("thread-import", IconName::Download) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Import Threads")) + .on_click(cx.listener(|_this, _, _, cx| { + cx.emit(ThreadsArchiveViewEvent::Import); + })), + ) + .child( + IconButton::new("filter-archived-only", IconName::Archive) + .icon_size(IconSize::Small) + .disabled(!has_archived_threads) + .toggle_state(self.thread_filter == ThreadFilter::ArchivedOnly) + .tooltip(Tooltip::text( + if self.thread_filter == ThreadFilter::ArchivedOnly { + "Show All Threads" + } else { + "Show Only Archived Threads" + }, + )) + .on_click(cx.listener(|this, _, _, cx| { + this.thread_filter = + if this.thread_filter == ThreadFilter::ArchivedOnly { + ThreadFilter::All + } else { + ThreadFilter::ArchivedOnly + }; + this.update_items(cx); + })), + ), ) } } @@ -989,6 +1025,7 @@ impl Render for ThreadsArchiveView { .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::remove_selected_thread)) + .on_action(cx.listener(Self::archive_selected_thread)) .size_full() .child(self.render_header(window, cx)) .when(!has_query, |this| this.child(self.render_toolbar(cx))) diff --git a/crates/agent_ui/src/ui/undo_reject_toast.rs b/crates/agent_ui/src/ui/undo_reject_toast.rs index 90c8a9c7ea98edd56ca935eddb36206a83bcc4bc..97352fa67d690d5770442c662e7e3145dcf25bf5 100644 --- a/crates/agent_ui/src/ui/undo_reject_toast.rs +++ b/crates/agent_ui/src/ui/undo_reject_toast.rs @@ -1,6 +1,6 @@ use action_log::ActionLog; use gpui::{App, Entity}; -use notifications::status_toast::{StatusToast, ToastIcon}; +use notifications::status_toast::StatusToast; use ui::prelude::*; use workspace::Workspace; @@ -11,15 +11,19 @@ pub fn show_undo_reject_toast( ) { let action_log_weak = action_log.downgrade(); let status_toast = StatusToast::new("Agent Changes Rejected", cx, move |this, _cx| { - this.icon(ToastIcon::new(IconName::Undo).color(Color::Muted)) - .action("Undo", move |_window, cx| { - if let Some(action_log) = action_log_weak.upgrade() { - action_log - .update(cx, |action_log, cx| action_log.undo_last_reject(cx)) - .detach(); - } - }) - .dismiss_button(true) + this.icon( + Icon::new(IconName::Undo) + .size(IconSize::Small) + .color(Color::Muted), + ) + .action("Undo", move |_window, cx| { + if let Some(action_log) = action_log_weak.upgrade() { + action_log + .update(cx, |action_log, cx| action_log.undo_last_reject(cx)) + .detach(); + } + }) + .dismiss_button(true) }); workspace.toggle_status_toast(status_toast, cx); } diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index c49558e50472f3b497ba74ac388f3874aceec77b..147458923045c15faf926a1ad4424b666b5204d8 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -17,9 +17,7 @@ use std::sync::Arc; use client::{Client, UserStore, zed_urls}; use gpui::{AnyElement, Entity, IntoElement, ParentElement}; -use ui::{ - Divider, List, ListBulletItem, RegisterComponent, Tooltip, Vector, VectorName, prelude::*, -}; +use ui::{Divider, RegisterComponent, Tooltip, Vector, VectorName, prelude::*}; #[derive(PartialEq)] pub enum SignInStatus { @@ -442,131 +440,3 @@ impl Component for ZedAiOnboarding { ) } } - -#[derive(RegisterComponent)] -pub struct AgentLayoutOnboarding { - pub use_agent_layout: Arc, - pub revert_to_editor_layout: Arc, - pub dismissed: Arc, - pub is_agent_layout: bool, -} - -impl Render for AgentLayoutOnboarding { - fn render(&mut self, _window: &mut ui::Window, _cx: &mut Context) -> impl IntoElement { - let description = "With the new Threads Sidebar, you can manage multiple agents across several projects, all in one window."; - - let dismiss_button = div().absolute().top_0().right_0().child( - IconButton::new("dismiss", IconName::Close) - .icon_size(IconSize::Small) - .on_click({ - let dismiss = self.dismissed.clone(); - move |_, window, cx| { - telemetry::event!("Agentic Layout Onboarding Dismissed"); - dismiss(window, cx) - } - }), - ); - - let primary_button = if self.is_agent_layout { - Button::new("revert", "Use Previous Layout") - .label_size(LabelSize::Small) - .style(ButtonStyle::Outlined) - .on_click({ - let revert = self.revert_to_editor_layout.clone(); - let dismiss = self.dismissed.clone(); - move |_, window, cx| { - telemetry::event!("Clicked to Use Previous Layout"); - revert(window, cx); - dismiss(window, cx); - } - }) - } else { - Button::new("start", "Use New Layout") - .label_size(LabelSize::Small) - .style(ButtonStyle::Outlined) - .on_click({ - let use_layout = self.use_agent_layout.clone(); - let dismiss = self.dismissed.clone(); - move |_, window, cx| { - telemetry::event!("Clicked to Use New Layout"); - use_layout(window, cx); - dismiss(window, cx); - } - }) - }; - - let content = v_flex() - .min_w_0() - .w_full() - .relative() - .gap_1() - .child(Label::new("A new workspace layout for agentic workflows")) - .child(Label::new(description).color(Color::Muted).mb_2()) - .child( - List::new() - .child(ListBulletItem::new( - "The Sidebar and Agent Panel are on the left by default", - )) - .child(ListBulletItem::new( - "The Project Panel and all other panels shift to the right", - )) - .child(ListBulletItem::new( - "You can always customize your workspace layout in your Settings", - )), - ) - .child( - h_flex() - .w_full() - .gap_1() - .flex_wrap() - .justify_end() - .child( - Button::new("learn", "Learn More") - .label_size(LabelSize::Small) - .style(ButtonStyle::OutlinedGhost) - .on_click(move |_, _, cx| { - cx.open_url(&zed_urls::parallel_agents_blog(cx)) - }), - ) - .child(primary_button), - ) - .child(dismiss_button); - - AgentPanelOnboardingCard::new().child(content) - } -} - -impl Component for AgentLayoutOnboarding { - fn scope() -> ComponentScope { - ComponentScope::Onboarding - } - - fn name() -> &'static str { - "Agent Layout Onboarding" - } - - fn preview(_window: &mut Window, cx: &mut App) -> Option { - let onboarding = cx.new(|_cx| AgentLayoutOnboarding { - use_agent_layout: Arc::new(|_, _| {}), - revert_to_editor_layout: Arc::new(|_, _| {}), - dismissed: Arc::new(|_, _| {}), - is_agent_layout: false, - }); - - Some( - v_flex() - .min_w_0() - .gap_4() - .child(single_example( - "Agent Layout Onboarding", - div() - .w_full() - .min_w_40() - .max_w(px(1100.)) - .child(onboarding) - .into_any_element(), - )) - .into_any_element(), - ) - } -} diff --git a/crates/auto_update_ui/Cargo.toml b/crates/auto_update_ui/Cargo.toml index b7b51c4a28448434ec4483f898e2d67b3301533e..16783aa9e963ee314776d57712fc343e8227f94f 100644 --- a/crates/auto_update_ui/Cargo.toml +++ b/crates/auto_update_ui/Cargo.toml @@ -19,6 +19,7 @@ client.workspace = true db.workspace = true fs.workspace = true editor.workspace = true +notifications.workspace = true gpui.workspace = true markdown_preview.workspace = true release_channel.workspace = true diff --git a/crates/auto_update_ui/src/auto_update_ui.rs b/crates/auto_update_ui/src/auto_update_ui.rs index ec199c18a3179ef70c9092cb737dd22c4514ceac..ebd0d2c06dc977a0e0b7cfadd8d787a11ec81e28 100644 --- a/crates/auto_update_ui/src/auto_update_ui.rs +++ b/crates/auto_update_ui/src/auto_update_ui.rs @@ -9,6 +9,7 @@ use gpui::{ App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window, actions, prelude::*, }; use markdown_preview::markdown_preview_view::{MarkdownPreviewMode, MarkdownPreviewView}; +use notifications::status_toast::StatusToast; use release_channel::{AppVersion, ReleaseChannel}; use semver::Version; use serde::Deserialize; @@ -207,17 +208,17 @@ fn announcement_for_version(version: &Version, cx: &App) -> Option::global(cx); Some(AnnouncementContent { heading: "Introducing Parallel Agents".into(), - description: "Run multiple agent threads simultaneously across projects.".into(), + description: "Run multiple threads of your favorite agents simultaneously across projects in a new workspace layout, tailored for agentic workflows.".into(), bullet_items: vec![ "Use your favorite agents in parallel".into(), "Optionally isolate agents using worktrees".into(), "Combine multiple projects in one window".into(), ], - primary_action_label: "Try Now".into(), + primary_action_label: "Try Agentic Layout".into(), primary_action_url: None, primary_action_callback: Some(Arc::new(move |window, cx| { - let already_agent_layout = - matches!(AgentSettings::get_layout(cx), WindowLayout::Agent(_)); + let get_layout = AgentSettings::get_layout(cx); + let already_agent_layout = matches!(get_layout, WindowLayout::Agent(_)); let update; if !already_agent_layout { @@ -230,6 +231,7 @@ fn announcement_for_version(version: &Version, cx: &App) -> Option Option); diff --git a/crates/edit_prediction_ui/src/edit_prediction_ui.rs b/crates/edit_prediction_ui/src/edit_prediction_ui.rs index 0735a8ccab69cfc812b84195adb14743167c651a..2f6280619adafd291c20549e4a9cbaab312a04cf 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_ui.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_ui.rs @@ -115,9 +115,9 @@ fn feature_gate_predict_edits_actions(cx: &mut App) { }) .detach(); - cx.observe_flag::(move |is_enabled, cx| { + cx.observe_flag::(move |value, cx| { if !DisableAiSettings::get_global(cx).disable_ai { - if is_enabled { + if *value { CommandPaletteFilter::update_global(cx, |filter, _cx| { filter.show_action_types(&rate_completion_action_types); }); diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index eb071bf955cede173e74993c93ab5cd294338474..8945ab11d7067ca3eaf4b99b37414c2fe552b57c 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -1,7 +1,7 @@ use buffer_diff::BufferDiff; use edit_prediction::{EditPrediction, EditPredictionRating, EditPredictionStore}; use editor::{Editor, Inlay, MultiBuffer}; -use feature_flags::FeatureFlag; +use feature_flags::{FeatureFlag, PresenceFlag, register_feature_flag}; use gpui::{ App, BorderStyle, DismissEvent, EdgesRefinement, Entity, EventEmitter, FocusHandle, Focusable, Length, StyleRefinement, TextStyleRefinement, Window, actions, prelude::*, @@ -43,7 +43,9 @@ pub struct PredictEditsRatePredictionsFeatureFlag; impl FeatureFlag for PredictEditsRatePredictionsFeatureFlag { const NAME: &'static str = "predict-edits-rate-completions"; + type Value = PresenceFlag; } +register_feature_flag!(PredictEditsRatePredictionsFeatureFlag); pub struct RatePredictionsModal { ep_store: Entity, diff --git a/crates/feature_flags/Cargo.toml b/crates/feature_flags/Cargo.toml index 960834211ff18980675b236cd0cc2893d563d668..31ec5ec0da8ceb0e249230d0fd5a2994670c7986 100644 --- a/crates/feature_flags/Cargo.toml +++ b/crates/feature_flags/Cargo.toml @@ -12,4 +12,15 @@ workspace = true path = "src/feature_flags.rs" [dependencies] +collections.workspace = true +feature_flags_macros.workspace = true +fs.workspace = true gpui.workspace = true +inventory.workspace = true +schemars.workspace = true +serde_json.workspace = true +settings.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index 5b8af1180aae812ed1475810acc1920a8ec708f1..ae2980c699fd190543ec53b1950652b42bd66259 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -1,4 +1,10 @@ +// Makes the derive macro's reference to `::feature_flags::FeatureFlagValue` +// resolve when the macro is invoked inside this crate itself. +extern crate self as feature_flags; + mod flags; +mod settings; +mod store; use std::cell::RefCell; use std::rc::Rc; @@ -6,33 +12,98 @@ use std::sync::LazyLock; use gpui::{App, Context, Global, Subscription, Window}; +pub use feature_flags_macros::EnumFeatureFlag; pub use flags::*; - -#[derive(Default)] -struct FeatureFlags { - flags: Vec, - staff: bool, -} +pub use settings::{FeatureFlagsSettings, generate_feature_flags_schema}; +pub use store::*; pub static ZED_DISABLE_STAFF: LazyLock = LazyLock::new(|| { std::env::var("ZED_DISABLE_STAFF").is_ok_and(|value| !value.is_empty() && value != "0") }); -impl FeatureFlags { - fn has_flag(&self) -> bool { - if T::enabled_for_all() { - return true; +impl Global for FeatureFlagStore {} + +pub trait FeatureFlagValue: + Sized + Clone + Eq + Default + std::fmt::Debug + Send + Sync + 'static +{ + /// Every possible value for this flag, in the order the UI should display them. + fn all_variants() -> &'static [Self]; + + /// A stable identifier for this variant used when persisting overrides. + fn override_key(&self) -> &'static str; + + fn from_wire(wire: &str) -> Option; + + /// Human-readable label for use in the configuration UI. + fn label(&self) -> &'static str { + self.override_key() + } + + /// The variant that represents "on" — what the store resolves to when + /// staff rules, `enabled_for_all`, or a server announcement apply. + /// + /// For enum flags this is usually the same as [`Default::default`] (the + /// variant marked `#[default]` in the derive). [`PresenceFlag`] overrides + /// this so that `default() == Off` (the "unconfigured" state) but + /// `on_variant() == On` (the "enabled" state). + fn on_variant() -> Self { + Self::default() + } +} + +/// Default value type for simple on/off feature flags. +/// +/// The fallback value is [`PresenceFlag::Off`] so that an absent / unknown +/// flag reads as disabled; the `on_variant` override pins the "enabled" +/// state to [`PresenceFlag::On`] so staff / server / `enabled_for_all` +/// resolution still lights the flag up. +#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)] +pub enum PresenceFlag { + On, + #[default] + Off, +} + +/// Presence flags deref to a `bool` so call sites can use `if *flag` without +/// spelling out the enum variant — or pass them anywhere a `&bool` is wanted. +impl std::ops::Deref for PresenceFlag { + type Target = bool; + + fn deref(&self) -> &bool { + match self { + PresenceFlag::On => &true, + PresenceFlag::Off => &false, } + } +} - if (cfg!(debug_assertions) || self.staff) && !*ZED_DISABLE_STAFF && T::enabled_for_staff() { - return true; +impl FeatureFlagValue for PresenceFlag { + fn all_variants() -> &'static [Self] { + &[PresenceFlag::On, PresenceFlag::Off] + } + + fn override_key(&self) -> &'static str { + match self { + PresenceFlag::On => "on", + PresenceFlag::Off => "off", } + } - self.flags.iter().any(|f| f.as_str() == T::NAME) + fn label(&self) -> &'static str { + match self { + PresenceFlag::On => "On", + PresenceFlag::Off => "Off", + } } -} -impl Global for FeatureFlags {} + fn from_wire(_: &str) -> Option { + Some(PresenceFlag::On) + } + + fn on_variant() -> Self { + PresenceFlag::On + } +} /// To create a feature flag, implement this trait on a trivial type and use it as /// a generic parameter when called [`FeatureFlagAppExt::has_flag`]. @@ -43,6 +114,10 @@ impl Global for FeatureFlags {} pub trait FeatureFlag { const NAME: &'static str; + /// The type of value this flag can hold. Use [`PresenceFlag`] for simple + /// on/off flags. + type Value: FeatureFlagValue; + /// Returns whether this feature flag is enabled for Zed staff. fn enabled_for_staff() -> bool { true @@ -55,12 +130,23 @@ pub trait FeatureFlag { fn enabled_for_all() -> bool { false } + + /// Subscribes the current view to changes in the feature flag store, so + /// that any mutation of flags or overrides will trigger a re-render. + /// + /// The returned subscription is immediately detached; use [`observe_flag`] + /// directly if you need to hold onto the subscription. + fn watch(cx: &mut Context) { + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + } } pub trait FeatureFlagViewExt { + /// Fires the callback whenever the resolved [`T::Value`] transitions. fn observe_flag(&mut self, window: &Window, callback: F) -> Subscription where - F: Fn(bool, &mut V, &mut Window, &mut Context) + Send + Sync + 'static; + F: Fn(T::Value, &mut V, &mut Window, &mut Context) + Send + Sync + 'static; fn when_flag_enabled( &mut self, @@ -75,11 +161,16 @@ where { fn observe_flag(&mut self, window: &Window, callback: F) -> Subscription where - F: Fn(bool, &mut V, &mut Window, &mut Context) + 'static, + F: Fn(T::Value, &mut V, &mut Window, &mut Context) + 'static, { - self.observe_global_in::(window, move |v, window, cx| { - let feature_flags = cx.global::(); - callback(feature_flags.has_flag::(), v, window, cx); + let mut last_value: Option = None; + self.observe_global_in::(window, move |v, window, cx| { + let value = cx.flag_value::(); + if last_value.as_ref() == Some(&value) { + return; + } + last_value = Some(value.clone()); + callback(value, v, window, cx); }) } @@ -89,8 +180,8 @@ where callback: impl Fn(&mut V, &mut Window, &mut Context) + Send + Sync + 'static, ) { if self - .try_global::() - .is_some_and(|f| f.has_flag::()) + .try_global::() + .is_some_and(|f| f.has_flag::(self)) { self.defer_in(window, move |view, window, cx| { callback(view, window, cx); @@ -98,11 +189,11 @@ where return; } let subscription = Rc::new(RefCell::new(None)); - let inner = self.observe_global_in::(window, { + let inner = self.observe_global_in::(window, { let subscription = subscription.clone(); move |v, window, cx| { - let feature_flags = cx.global::(); - if feature_flags.has_flag::() { + let has_flag = cx.global::().has_flag::(cx); + if has_flag { callback(v, window, cx); subscription.take(); } @@ -121,6 +212,7 @@ pub trait FeatureFlagAppExt { fn update_flags(&mut self, staff: bool, flags: Vec); fn set_staff(&mut self, staff: bool); fn has_flag(&self) -> bool; + fn flag_value(&self) -> T::Value; fn is_staff(&self) -> bool; fn on_flags_ready(&mut self, callback: F) -> Subscription @@ -129,33 +221,35 @@ pub trait FeatureFlagAppExt { fn observe_flag(&mut self, callback: F) -> Subscription where - F: FnMut(bool, &mut App) + 'static; + F: FnMut(T::Value, &mut App) + 'static; } impl FeatureFlagAppExt for App { fn update_flags(&mut self, staff: bool, flags: Vec) { - let feature_flags = self.default_global::(); - feature_flags.staff = staff; - feature_flags.flags = flags; + let store = self.default_global::(); + store.update_server_flags(staff, flags); } fn set_staff(&mut self, staff: bool) { - let feature_flags = self.default_global::(); - feature_flags.staff = staff; + let store = self.default_global::(); + store.set_staff(staff); } fn has_flag(&self) -> bool { - self.try_global::() - .map(|flags| flags.has_flag::()) - .unwrap_or_else(|| { - (cfg!(debug_assertions) && T::enabled_for_staff() && !*ZED_DISABLE_STAFF) - || T::enabled_for_all() - }) + self.try_global::() + .map(|store| store.has_flag::(self)) + .unwrap_or_else(|| FeatureFlagStore::has_flag_default::()) + } + + fn flag_value(&self) -> T::Value { + self.try_global::() + .and_then(|store| store.try_flag_value::(self)) + .unwrap_or_default() } fn is_staff(&self) -> bool { - self.try_global::() - .map(|flags| flags.staff) + self.try_global::() + .map(|store| store.is_staff()) .unwrap_or(false) } @@ -163,11 +257,11 @@ impl FeatureFlagAppExt for App { where F: FnMut(OnFlagsReady, &mut App) + 'static, { - self.observe_global::(move |cx| { - let feature_flags = cx.global::(); + self.observe_global::(move |cx| { + let store = cx.global::(); callback( OnFlagsReady { - is_staff: feature_flags.staff, + is_staff: store.is_staff(), }, cx, ); @@ -176,11 +270,16 @@ impl FeatureFlagAppExt for App { fn observe_flag(&mut self, mut callback: F) -> Subscription where - F: FnMut(bool, &mut App) + 'static, + F: FnMut(T::Value, &mut App) + 'static, { - self.observe_global::(move |cx| { - let feature_flags = cx.global::(); - callback(feature_flags.has_flag::(), cx); + let mut last_value: Option = None; + self.observe_global::(move |cx| { + let value = cx.flag_value::(); + if last_value.as_ref() == Some(&value) { + return; + } + last_value = Some(value.clone()); + callback(value, cx); }) } } diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index d9541d819626c52861e7679d2e9dff525bfbb1f9..1665e6ffb6c0685370beab589c1bb88f714d70d0 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -1,26 +1,32 @@ -use crate::FeatureFlag; +use crate::{EnumFeatureFlag, FeatureFlag, PresenceFlag, register_feature_flag}; pub struct NotebookFeatureFlag; impl FeatureFlag for NotebookFeatureFlag { const NAME: &'static str = "notebooks"; + type Value = PresenceFlag; } +register_feature_flag!(NotebookFeatureFlag); pub struct PanicFeatureFlag; impl FeatureFlag for PanicFeatureFlag { const NAME: &'static str = "panic"; + type Value = PresenceFlag; } +register_feature_flag!(PanicFeatureFlag); pub struct AgentV2FeatureFlag; impl FeatureFlag for AgentV2FeatureFlag { const NAME: &'static str = "agent-v2"; + type Value = PresenceFlag; fn enabled_for_staff() -> bool { true } } +register_feature_flag!(AgentV2FeatureFlag); /// A feature flag for granting access to beta ACP features. /// @@ -29,50 +35,83 @@ pub struct AcpBetaFeatureFlag; impl FeatureFlag for AcpBetaFeatureFlag { const NAME: &'static str = "acp-beta"; + type Value = PresenceFlag; } +register_feature_flag!(AcpBetaFeatureFlag); pub struct AgentSharingFeatureFlag; impl FeatureFlag for AgentSharingFeatureFlag { const NAME: &'static str = "agent-sharing"; + type Value = PresenceFlag; } +register_feature_flag!(AgentSharingFeatureFlag); pub struct DiffReviewFeatureFlag; impl FeatureFlag for DiffReviewFeatureFlag { const NAME: &'static str = "diff-review"; + type Value = PresenceFlag; fn enabled_for_staff() -> bool { false } } +register_feature_flag!(DiffReviewFeatureFlag); pub struct StreamingEditFileToolFeatureFlag; impl FeatureFlag for StreamingEditFileToolFeatureFlag { const NAME: &'static str = "streaming-edit-file-tool"; + type Value = PresenceFlag; fn enabled_for_staff() -> bool { true } } +register_feature_flag!(StreamingEditFileToolFeatureFlag); pub struct UpdatePlanToolFeatureFlag; impl FeatureFlag for UpdatePlanToolFeatureFlag { const NAME: &'static str = "update-plan-tool"; + type Value = PresenceFlag; fn enabled_for_staff() -> bool { false } } +register_feature_flag!(UpdatePlanToolFeatureFlag); pub struct ProjectPanelUndoRedoFeatureFlag; impl FeatureFlag for ProjectPanelUndoRedoFeatureFlag { const NAME: &'static str = "project-panel-undo-redo"; + type Value = PresenceFlag; fn enabled_for_staff() -> bool { true } } +register_feature_flag!(ProjectPanelUndoRedoFeatureFlag); + +/// Controls how agent thread worktree chips are labeled in the sidebar. +#[derive(Clone, Copy, PartialEq, Eq, Debug, EnumFeatureFlag)] +pub enum AgentThreadWorktreeLabel { + #[default] + Both, + Worktree, + Branch, +} + +pub struct AgentThreadWorktreeLabelFlag; + +impl FeatureFlag for AgentThreadWorktreeLabelFlag { + const NAME: &'static str = "agent-thread-worktree-label"; + type Value = AgentThreadWorktreeLabel; + + fn enabled_for_staff() -> bool { + false + } +} +register_feature_flag!(AgentThreadWorktreeLabelFlag); diff --git a/crates/feature_flags/src/settings.rs b/crates/feature_flags/src/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..13ba699665488ed075b381fce13f6394efbfef37 --- /dev/null +++ b/crates/feature_flags/src/settings.rs @@ -0,0 +1,76 @@ +use collections::HashMap; +use schemars::{Schema, json_schema}; +use serde_json::{Map, Value}; +use settings::{RegisterSetting, Settings, SettingsContent}; + +use crate::FeatureFlagStore; + +#[derive(Clone, Debug, Default, RegisterSetting)] +pub struct FeatureFlagsSettings { + pub overrides: HashMap, +} + +impl Settings for FeatureFlagsSettings { + fn from_settings(content: &SettingsContent) -> Self { + Self { + overrides: content + .feature_flags + .as_ref() + .map(|map| map.0.clone()) + .unwrap_or_default(), + } + } +} + +/// Produces a JSON schema for the `feature_flags` object that lists each known +/// flag as a property with its variant keys as an `enum`. +/// +/// Unknown flags are permitted via `additionalProperties: { "type": "string" }`, +/// so removing a flag from the binary never turns existing entries in +/// `settings.json` into validation errors. +pub fn generate_feature_flags_schema() -> Schema { + let mut properties = Map::new(); + + for descriptor in FeatureFlagStore::known_flags() { + let variants = (descriptor.variants)(); + let enum_values: Vec = variants + .iter() + .map(|v| Value::String(v.override_key.to_string())) + .collect(); + let enum_descriptions: Vec = variants + .iter() + .map(|v| Value::String(v.label.to_string())) + .collect(); + + let mut property = Map::new(); + property.insert("type".to_string(), Value::String("string".to_string())); + property.insert("enum".to_string(), Value::Array(enum_values)); + // VS Code / json-language-server use `enumDescriptions` for hover docs + // on each enum value; schemars passes them through untouched. + property.insert( + "enumDescriptions".to_string(), + Value::Array(enum_descriptions), + ); + property.insert( + "description".to_string(), + Value::String(format!( + "Override for the `{}` feature flag. Default: `{}` (the {} variant).", + descriptor.name, + (descriptor.default_variant_key)(), + (descriptor.default_variant_key)(), + )), + ); + + properties.insert(descriptor.name.to_string(), Value::Object(property)); + } + + json_schema!({ + "type": "object", + "description": "Local overrides for feature flags, keyed by flag name.", + "properties": properties, + "additionalProperties": { + "type": "string", + "description": "Unknown feature flag; retained so removed flags don't trip settings validation." + } + }) +} diff --git a/crates/feature_flags/src/store.rs b/crates/feature_flags/src/store.rs new file mode 100644 index 0000000000000000000000000000000000000000..54d261fc7261a06e49051b783ea423db727a3a64 --- /dev/null +++ b/crates/feature_flags/src/store.rs @@ -0,0 +1,374 @@ +use std::any::TypeId; +use std::sync::Arc; + +use collections::HashMap; +use fs::Fs; +use gpui::{App, BorrowAppContext, Subscription}; +use settings::{Settings, SettingsStore, update_settings_file}; + +use crate::{FeatureFlag, FeatureFlagValue, FeatureFlagsSettings, ZED_DISABLE_STAFF}; + +pub struct FeatureFlagDescriptor { + pub name: &'static str, + pub variants: fn() -> Vec, + pub on_variant_key: fn() -> &'static str, + pub default_variant_key: fn() -> &'static str, + pub enabled_for_all: fn() -> bool, + pub enabled_for_staff: fn() -> bool, + pub type_id: fn() -> TypeId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FeatureFlagVariant { + pub override_key: &'static str, + pub label: &'static str, +} + +inventory::collect!(FeatureFlagDescriptor); + +#[doc(hidden)] +pub mod __private { + pub use inventory; +} + +/// Submits a [`FeatureFlagDescriptor`] for this flag so it shows up in the +/// configuration UI and in `FeatureFlagStore::known_flags()`. +#[macro_export] +macro_rules! register_feature_flag { + ($flag:ty) => { + $crate::__private::inventory::submit! { + $crate::FeatureFlagDescriptor { + name: <$flag as $crate::FeatureFlag>::NAME, + variants: || { + <<$flag as $crate::FeatureFlag>::Value as $crate::FeatureFlagValue>::all_variants() + .iter() + .map(|v| $crate::FeatureFlagVariant { + override_key: <<$flag as $crate::FeatureFlag>::Value as $crate::FeatureFlagValue>::override_key(v), + label: <<$flag as $crate::FeatureFlag>::Value as $crate::FeatureFlagValue>::label(v), + }) + .collect() + }, + on_variant_key: || { + <<$flag as $crate::FeatureFlag>::Value as $crate::FeatureFlagValue>::override_key( + &<<$flag as $crate::FeatureFlag>::Value as $crate::FeatureFlagValue>::on_variant(), + ) + }, + default_variant_key: || { + <<$flag as $crate::FeatureFlag>::Value as $crate::FeatureFlagValue>::override_key( + &<<$flag as $crate::FeatureFlag>::Value as ::std::default::Default>::default(), + ) + }, + enabled_for_all: <$flag as $crate::FeatureFlag>::enabled_for_all, + enabled_for_staff: <$flag as $crate::FeatureFlag>::enabled_for_staff, + type_id: || std::any::TypeId::of::<$flag>(), + } + } + }; +} + +#[derive(Default)] +pub struct FeatureFlagStore { + staff: bool, + server_flags: HashMap, + + _settings_subscription: Option, +} + +impl FeatureFlagStore { + pub fn init(cx: &mut App) { + let subscription = cx.observe_global::(|cx| { + // Touch the global so anything observing `FeatureFlagStore` re-runs + cx.update_default_global::(|_, _| {}); + }); + + cx.update_default_global::(|store, _| { + store._settings_subscription = Some(subscription); + }); + } + + pub fn known_flags() -> impl Iterator { + let mut seen = collections::HashSet::default(); + inventory::iter::().filter(move |d| seen.insert((d.type_id)())) + } + + pub fn is_staff(&self) -> bool { + self.staff + } + + pub fn set_staff(&mut self, staff: bool) { + self.staff = staff; + } + + pub fn update_server_flags(&mut self, staff: bool, flags: Vec) { + self.staff = staff; + self.server_flags.clear(); + for flag in flags { + self.server_flags.insert(flag.clone(), flag); + } + } + + /// The user's override key for this flag, read directly from + /// [`FeatureFlagsSettings`]. + pub fn override_for<'a>(flag_name: &str, cx: &'a App) -> Option<&'a str> { + FeatureFlagsSettings::get_global(cx) + .overrides + .get(flag_name) + .map(String::as_str) + } + + /// Applies an override by writing to `settings.json`. The store's own + /// `overrides` field will be updated when the settings-store observer + /// fires. Pass the [`FeatureFlagValue::override_key`] of the variant + /// you want forced. + pub fn set_override(flag_name: &str, override_key: String, fs: Arc, cx: &App) { + let flag_name = flag_name.to_owned(); + update_settings_file(fs, cx, move |content, _| { + content + .feature_flags + .get_or_insert_default() + .insert(flag_name, override_key); + }); + } + + /// Removes any override for the given flag from `settings.json`. Leaves + /// an empty `"feature_flags"` object rather than removing the key + /// entirely so the user can see it's still a meaningful settings surface. + pub fn clear_override(flag_name: &str, fs: Arc, cx: &App) { + let flag_name = flag_name.to_owned(); + update_settings_file(fs, cx, move |content, _| { + if let Some(map) = content.feature_flags.as_mut() { + map.remove(&flag_name); + } + }); + } + + /// The resolved value of the flag for the current user, taking overrides, + /// `enabled_for_all`, staff rules, and server flags into account in that + /// order of precedence. Overrides are read directly from + /// [`FeatureFlagsSettings`]. + pub fn try_flag_value(&self, cx: &App) -> Option { + // `enabled_for_all` always wins, including over user overrides. + if T::enabled_for_all() { + return Some(T::Value::on_variant()); + } + + if let Some(override_key) = FeatureFlagsSettings::get_global(cx).overrides.get(T::NAME) { + return variant_from_key::(override_key); + } + + // Staff default: resolve to the enabled variant. + if (cfg!(debug_assertions) || self.staff) && !*ZED_DISABLE_STAFF && T::enabled_for_staff() { + return Some(T::Value::on_variant()); + } + + // Server-delivered flag. + if let Some(wire) = self.server_flags.get(T::NAME) { + return T::Value::from_wire(wire); + } + + None + } + + /// Whether the flag resolves to its "on" value. Best for presence-style + /// flags. For enum flags with meaningful non-default variants, prefer + /// [`crate::FeatureFlagAppExt::flag_value`]. + pub fn has_flag(&self, cx: &App) -> bool { + self.try_flag_value::(cx) + .is_some_and(|v| v == T::Value::on_variant()) + } + + /// Mirrors the resolution order of [`Self::try_flag_value`], but falls + /// back to the [`Default`] variant when no rule applies so the UI always + /// shows *something* selected — matching what + /// [`crate::FeatureFlagAppExt::flag_value`] would return. + pub fn resolved_key(&self, descriptor: &FeatureFlagDescriptor, cx: &App) -> &'static str { + let on_variant_key = (descriptor.on_variant_key)(); + + if (descriptor.enabled_for_all)() { + return on_variant_key; + } + + if let Some(requested) = FeatureFlagsSettings::get_global(cx) + .overrides + .get(descriptor.name) + { + if let Some(variant) = (descriptor.variants)() + .into_iter() + .find(|v| v.override_key == requested.as_str()) + { + return variant.override_key; + } + } + + if (cfg!(debug_assertions) || self.staff) + && !*ZED_DISABLE_STAFF + && (descriptor.enabled_for_staff)() + { + return on_variant_key; + } + + if self.server_flags.contains_key(descriptor.name) { + return on_variant_key; + } + + (descriptor.default_variant_key)() + } + + /// Whether this flag is forced on by `enabled_for_all` and therefore not + /// user-overridable. The UI uses this to render the row as disabled. + pub fn is_forced_on(descriptor: &FeatureFlagDescriptor) -> bool { + (descriptor.enabled_for_all)() + } + + /// Fallback used when the store isn't installed as a global yet (e.g. very + /// early in startup). Matches the pre-existing default behavior. + pub fn has_flag_default() -> bool { + if T::enabled_for_all() { + return true; + } + cfg!(debug_assertions) && T::enabled_for_staff() && !*ZED_DISABLE_STAFF + } +} + +fn variant_from_key(key: &str) -> Option { + V::all_variants() + .iter() + .find(|v| v.override_key() == key) + .cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EnumFeatureFlag, FeatureFlag, PresenceFlag}; + use gpui::UpdateGlobal; + use settings::SettingsStore; + + struct DemoFlag; + impl FeatureFlag for DemoFlag { + const NAME: &'static str = "demo"; + type Value = PresenceFlag; + fn enabled_for_staff() -> bool { + false + } + } + + #[derive(Clone, Copy, PartialEq, Eq, Debug, EnumFeatureFlag)] + enum Intensity { + #[default] + Low, + High, + } + + struct IntensityFlag; + impl FeatureFlag for IntensityFlag { + const NAME: &'static str = "intensity"; + type Value = Intensity; + fn enabled_for_all() -> bool { + true + } + } + + fn init_settings_store(cx: &mut App) { + let store = SettingsStore::test(cx); + cx.set_global(store); + SettingsStore::update_global(cx, |store, _| { + store.register_setting::(); + }); + } + + fn set_override(name: &str, value: &str, cx: &mut App) { + SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |content| { + content + .feature_flags + .get_or_insert_default() + .insert(name.to_string(), value.to_string()); + }); + }); + } + + #[gpui::test] + fn server_flag_enables_presence(cx: &mut App) { + init_settings_store(cx); + let mut store = FeatureFlagStore::default(); + assert!(!store.has_flag::(cx)); + store.update_server_flags(false, vec!["demo".to_string()]); + assert!(store.has_flag::(cx)); + } + + #[gpui::test] + fn off_override_beats_server_flag(cx: &mut App) { + init_settings_store(cx); + let mut store = FeatureFlagStore::default(); + store.update_server_flags(false, vec!["demo".to_string()]); + set_override(DemoFlag::NAME, "off", cx); + assert!(!store.has_flag::(cx)); + assert_eq!( + store.try_flag_value::(cx), + Some(PresenceFlag::Off) + ); + } + + #[gpui::test] + fn enabled_for_all_wins_over_override(cx: &mut App) { + init_settings_store(cx); + let store = FeatureFlagStore::default(); + set_override(IntensityFlag::NAME, "high", cx); + assert_eq!( + store.try_flag_value::(cx), + Some(Intensity::Low) + ); + } + + #[gpui::test] + fn enum_override_selects_specific_variant(cx: &mut App) { + init_settings_store(cx); + let store = FeatureFlagStore::default(); + // Staff path would normally resolve to `Low`; the override pushes + // us to `High` instead. + set_override("enum-demo", "high", cx); + + struct EnumDemo; + impl FeatureFlag for EnumDemo { + const NAME: &'static str = "enum-demo"; + type Value = Intensity; + } + + assert_eq!(store.try_flag_value::(cx), Some(Intensity::High)); + } + + #[gpui::test] + fn unknown_variant_key_resolves_to_none(cx: &mut App) { + init_settings_store(cx); + let store = FeatureFlagStore::default(); + set_override("enum-demo", "nonsense", cx); + + struct EnumDemo; + impl FeatureFlag for EnumDemo { + const NAME: &'static str = "enum-demo"; + type Value = Intensity; + } + + assert_eq!(store.try_flag_value::(cx), None); + } + + #[gpui::test] + fn on_override_enables_without_server_or_staff(cx: &mut App) { + init_settings_store(cx); + let store = FeatureFlagStore::default(); + set_override(DemoFlag::NAME, "on", cx); + assert!(store.has_flag::(cx)); + } + + /// No rule applies, so the store's `try_flag_value` returns `None`. The + /// `FeatureFlagAppExt::flag_value` path (used by most callers) falls + /// back to [`Default`], which for `PresenceFlag` is `Off`. + #[gpui::test] + fn presence_flag_defaults_to_off(cx: &mut App) { + init_settings_store(cx); + let store = FeatureFlagStore::default(); + assert_eq!(store.try_flag_value::(cx), None); + assert_eq!(PresenceFlag::default(), PresenceFlag::Off); + } +} diff --git a/crates/feature_flags_macros/Cargo.toml b/crates/feature_flags_macros/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..794160e68398c15079ebab9362189313ee2450f2 --- /dev/null +++ b/crates/feature_flags_macros/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "feature_flags_macros" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lib] +path = "src/feature_flags_macros.rs" +proc-macro = true + +[lints] +workspace = true + +[dependencies] +proc-macro2.workspace = true +quote.workspace = true +syn.workspace = true diff --git a/crates/feature_flags_macros/LICENSE-GPL b/crates/feature_flags_macros/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/feature_flags_macros/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/feature_flags_macros/src/feature_flags_macros.rs b/crates/feature_flags_macros/src/feature_flags_macros.rs new file mode 100644 index 0000000000000000000000000000000000000000..0ea8fad6f285688e0108773461e436c73fa4c4e5 --- /dev/null +++ b/crates/feature_flags_macros/src/feature_flags_macros.rs @@ -0,0 +1,190 @@ +use proc_macro::TokenStream; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::quote; +use syn::{Data, DeriveInput, Fields, Ident, LitStr, parse_macro_input}; + +/// Derives [`feature_flags::FeatureFlagValue`] for a unit-only enum. +/// +/// Exactly one variant must be marked with `#[default]`. The default variant +/// is the one returned when the feature flag is announced by the server, +/// enabled for all users, or enabled by the staff rule — it's the "on" +/// value, and also the fallback for `from_wire`. +/// +/// The generated impl derives: +/// +/// * `all_variants` — every variant, in source order. +/// * `override_key` — the variant name, lower-cased with dashes between +/// PascalCase word boundaries (e.g. `NewWorktree` → `"new-worktree"`). +/// * `label` — the variant name with PascalCase boundaries expanded to +/// spaces (e.g. `NewWorktree` → `"New Worktree"`). +/// * `from_wire` — always returns the default variant, since today the +/// server wire format is just presence and does not carry a variant. +/// +/// ## Example +/// +/// ```ignore +/// #[derive(Clone, Copy, PartialEq, Eq, Debug, EnumFeatureFlag)] +/// enum Intensity { +/// #[default] +/// Low, +/// High, +/// } +/// ``` +// `attributes(default)` lets users write `#[default]` on a variant even when +// they're not also deriving `Default`. If `#[derive(Default)]` is present in +// the same list, it reuses the same attribute — there's no conflict, because +// helper attributes aren't consumed. +#[proc_macro_derive(EnumFeatureFlag, attributes(default))] +pub fn derive_enum_feature_flag(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + match expand(&input) { + Ok(tokens) => tokens.into(), + Err(e) => e.to_compile_error().into(), + } +} + +fn expand(input: &DeriveInput) -> syn::Result { + let Data::Enum(data) = &input.data else { + return Err(syn::Error::new_spanned( + input, + "EnumFeatureFlag can only be derived for enums", + )); + }; + + if data.variants.is_empty() { + return Err(syn::Error::new_spanned( + input, + "EnumFeatureFlag requires at least one variant", + )); + } + + let mut default_ident: Option<&Ident> = None; + let mut variant_idents: Vec<&Ident> = Vec::new(); + + for variant in &data.variants { + if !matches!(variant.fields, Fields::Unit) { + return Err(syn::Error::new_spanned( + variant, + "EnumFeatureFlag only supports unit variants (no fields)", + )); + } + if has_default_attr(variant) { + if default_ident.is_some() { + return Err(syn::Error::new_spanned( + variant, + "only one variant may be marked with #[default]", + )); + } + default_ident = Some(&variant.ident); + } + variant_idents.push(&variant.ident); + } + + let Some(default_ident) = default_ident else { + return Err(syn::Error::new_spanned( + input, + "EnumFeatureFlag requires exactly one variant to be marked with #[default]", + )); + }; + + let name = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let override_key_arms = variant_idents.iter().map(|variant| { + let key = LitStr::new(&to_kebab_case(&variant.to_string()), Span::call_site()); + quote! { #name::#variant => #key } + }); + + let label_arms = variant_idents.iter().map(|variant| { + let label = LitStr::new(&to_space_separated(&variant.to_string()), Span::call_site()); + quote! { #name::#variant => #label } + }); + + let all_variants = variant_idents.iter().map(|v| quote! { #name::#v }); + + Ok(quote! { + impl #impl_generics ::std::default::Default for #name #ty_generics #where_clause { + fn default() -> Self { + #name::#default_ident + } + } + + impl #impl_generics ::feature_flags::FeatureFlagValue for #name #ty_generics #where_clause { + fn all_variants() -> &'static [Self] { + &[ #( #all_variants ),* ] + } + + fn override_key(&self) -> &'static str { + match self { + #( #override_key_arms ),* + } + } + + fn label(&self) -> &'static str { + match self { + #( #label_arms ),* + } + } + + fn from_wire(_: &str) -> ::std::option::Option { + ::std::option::Option::Some(#name::#default_ident) + } + } + }) +} + +fn has_default_attr(variant: &syn::Variant) -> bool { + variant.attrs.iter().any(|a| a.path().is_ident("default")) +} + +/// Converts a PascalCase identifier to lowercase kebab-case. +/// +/// `"NewWorktree"` → `"new-worktree"`, `"Low"` → `"low"`, +/// `"HTTPServer"` → `"httpserver"` (acronyms are not split — keep variant +/// names descriptive to avoid this). +fn to_kebab_case(ident: &str) -> String { + let mut out = String::with_capacity(ident.len() + 4); + for (i, ch) in ident.chars().enumerate() { + if ch.is_ascii_uppercase() { + if i != 0 { + out.push('-'); + } + out.push(ch.to_ascii_lowercase()); + } else { + out.push(ch); + } + } + out +} + +/// Converts a PascalCase identifier to space-separated word form for display. +/// +/// `"NewWorktree"` → `"New Worktree"`, `"Low"` → `"Low"`. +fn to_space_separated(ident: &str) -> String { + let mut out = String::with_capacity(ident.len() + 4); + for (i, ch) in ident.chars().enumerate() { + if ch.is_ascii_uppercase() && i != 0 { + out.push(' '); + } + out.push(ch); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kebab_case() { + assert_eq!(to_kebab_case("Low"), "low"); + assert_eq!(to_kebab_case("NewWorktree"), "new-worktree"); + assert_eq!(to_kebab_case("A"), "a"); + } + + #[test] + fn space_separated() { + assert_eq!(to_space_separated("Low"), "Low"); + assert_eq!(to_space_separated("NewWorktree"), "New Worktree"); + } +} diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 3215d761e0ace95d9010bbcb9ac7bb0b711c2c82..1e0f97bf6acf48a64b8464e521d1c19c421561ff 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -295,7 +295,7 @@ impl Worktree { pub fn directory_name(&self, main_worktree_path: Option<&Path>) -> String { if self.is_main { - return "main".to_string(); + return "main worktree".to_string(); } let dir_name = self diff --git a/crates/git_ui/src/clone.rs b/crates/git_ui/src/clone.rs index a6767d33304d3f20b7a5e78340f62c89ebe3ae58..b3b8a9ed6fb302b1412523c8d5f18f710902576d 100644 --- a/crates/git_ui/src/clone.rs +++ b/crates/git_ui/src/clone.rs @@ -1,7 +1,7 @@ use gpui::{App, Context, WeakEntity, Window}; -use notifications::status_toast::{StatusToast, ToastIcon}; +use notifications::status_toast::StatusToast; use std::sync::Arc; -use ui::{Color, IconName, SharedString}; +use ui::{Color, Icon, IconName, IconSize, SharedString}; use util::ResultExt; use workspace::{self, Workspace}; @@ -48,8 +48,12 @@ pub fn clone_and_open( workspace .update(cx, |workspace, cx| { let toast = StatusToast::new(error.to_string(), cx, |this, _| { - this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error)) - .dismiss_button(true) + this.icon( + Icon::new(IconName::XCircle) + .size(IconSize::Small) + .color(Color::Error), + ) + .dismiss_button(true) }); workspace.toggle_status_toast(toast, cx); }) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 2548ac8a81ccc8026d3e020bd88026ef33e2bf72..4722887f5942ded70fa34bc1fd9441bb27590e46 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -50,7 +50,7 @@ use language_model::{ }; use menu; use multi_buffer::ExcerptBoundaryInfo; -use notifications::status_toast::{StatusToast, ToastIcon}; +use notifications::status_toast::StatusToast; use panel::{PanelHeader, panel_button, panel_filled_button, panel_icon_button}; use project::{ Fs, Project, ProjectPath, @@ -2686,7 +2686,7 @@ impl GitPanel { } let Some(ConfiguredModel { provider, model }) = - LanguageModelRegistry::read_global(cx).commit_message_model() + LanguageModelRegistry::read_global(cx).commit_message_model(cx) else { return; }; @@ -3864,9 +3864,17 @@ impl GitPanel { let status_toast = StatusToast::new(message, cx, move |this, _cx| { use remote_output::SuccessStyle::*; match style { - Toast => this.icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted)), + Toast => this.icon( + Icon::new(IconName::GitBranch) + .size(IconSize::Small) + .color(Color::Muted), + ), ToastWithLog { output } => this - .icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted)) + .icon( + Icon::new(IconName::GitBranch) + .size(IconSize::Small) + .color(Color::Muted), + ) .action("View Log", move |window, cx| { let output = output.clone(); let output = @@ -3878,7 +3886,11 @@ impl GitPanel { .ok(); }), PushPrLink { text, link } => this - .icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted)) + .icon( + Icon::new(IconName::GitBranch) + .size(IconSize::Small) + .color(Color::Muted), + ) .action(text, move |_, cx| cx.open_url(&link)), } .dismiss_button(true) @@ -4023,7 +4035,7 @@ impl GitPanel { let model_registry = LanguageModelRegistry::read_global(cx); let has_commit_model_configuration_error = model_registry - .configuration_error(model_registry.commit_message_model(), cx) + .configuration_error(model_registry.commit_message_model(cx), cx) .is_some(); let can_commit = self.can_commit(); @@ -6479,16 +6491,20 @@ pub(crate) fn show_error_toast( workspace.update(cx, |workspace, cx| { let workspace_weak = cx.weak_entity(); let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| { - this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error)) - .action("View Log", move |window, cx| { - let message = message.clone(); - let action = action.clone(); - workspace_weak - .update(cx, move |workspace, cx| { - open_output(action, workspace, &message, window, cx) - }) - .ok(); - }) + this.icon( + Icon::new(IconName::XCircle) + .size(IconSize::Small) + .color(Color::Error), + ) + .action("View Log", move |window, cx| { + let message = message.clone(); + let action = action.clone(); + workspace_weak + .update(cx, move |workspace, cx| { + open_output(action, workspace, &message, window, cx) + }) + .ok(); + }) }); workspace.toggle_status_toast(toast, cx) }); diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 89d84cb7fb86cbf6359c8f3336a5ac9ae9fc1a9a..f9069d2920eedcd3ec75c1af781d90c818d49ba3 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -969,7 +969,7 @@ impl PickerDelegate for WorktreeListDelegate { } })), ) - .when(!entry.is_new, |this| { + .when(!entry.is_new && !is_current, |this| { let focus_handle = self.focus_handle.clone(); let open_in_new_window_button = IconButton::new(("open-new-window", ix), IconName::ArrowUpRight) @@ -1007,6 +1007,13 @@ impl PickerDelegate for WorktreeListDelegate { let is_creating = selected_entry.is_some_and(|entry| entry.is_new); let can_delete = selected_entry .is_some_and(|entry| entry.can_delete(self.forbidden_deletion_path.as_ref())); + let is_current = selected_entry.is_some_and(|entry| { + !entry.is_new + && self + .current_worktree_path + .as_ref() + .is_some_and(|current| *current == entry.worktree.path) + }); let footer_container = h_flex() .w_full() @@ -1066,20 +1073,22 @@ impl PickerDelegate for WorktreeListDelegate { }), ) }) - .child( - Button::new("open-in-new-window", "Open in New Window") - .key_binding( - KeyBinding::for_action_in( - &menu::SecondaryConfirm, - &focus_handle, - cx, + .when(!is_current, |this| { + this.child( + Button::new("open-in-new-window", "Open in New Window") + .key_binding( + KeyBinding::for_action_in( + &menu::SecondaryConfirm, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) - }), - ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) + }), + ) + }) .child( Button::new("open-in-window", "Open") .key_binding( diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 5028915c968d07994c7496728d7a71ad593fe502..b7485802a6dc16ea8e80487a9878350e14bf6a4e 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1981,6 +1981,8 @@ pub enum ImageFormat { Tiff, /// .ico Ico, + /// Netpbm image formats (.pbm, .ppm, .pgm). + Pnm, } impl ImageFormat { @@ -1995,6 +1997,7 @@ impl ImageFormat { ImageFormat::Bmp => "image/bmp", ImageFormat::Tiff => "image/tiff", ImageFormat::Ico => "image/ico", + ImageFormat::Pnm => "image/x-portable-anymap", } } @@ -2121,6 +2124,7 @@ impl Image { .render_single_frame(&self.bytes, 1.0) .map_err(Into::into); } + ImageFormat::Pnm => frames_for_image(&self.bytes, image::ImageFormat::Pnm)?, }; Ok(Arc::new(RenderImage::new(frames))) diff --git a/crates/gpui_linux/src/linux/x11/clipboard.rs b/crates/gpui_linux/src/linux/x11/clipboard.rs index d2ea58b3f8c2acd0b6fbeba44eaf8b5a2e57531f..cbefea7650b0934f6fc8c46ffbe5802835ee792a 100644 --- a/crates/gpui_linux/src/linux/x11/clipboard.rs +++ b/crates/gpui_linux/src/linux/x11/clipboard.rs @@ -87,7 +87,7 @@ x11rb::atom_manager! { BMP__MIME: ImageFormat::mime_type(ImageFormat::Bmp ).as_bytes(), TIFF_MIME: ImageFormat::mime_type(ImageFormat::Tiff).as_bytes(), ICO__MIME: ImageFormat::mime_type(ImageFormat::Ico ).as_bytes(), - + PNM__MIME: ImageFormat::mime_type(ImageFormat::Pnm ).as_bytes(), // This is just some random name for the property on our window, into which // the clipboard owner writes the data we requested. ARBOARD_CLIPBOARD, @@ -1005,6 +1005,7 @@ impl Clipboard { ImageFormat::Bmp => self.inner.atoms.BMP__MIME, ImageFormat::Tiff => self.inner.atoms.TIFF_MIME, ImageFormat::Ico => self.inner.atoms.ICO__MIME, + ImageFormat::Pnm => self.inner.atoms.PNM__MIME, }; let data = vec![ClipboardData { bytes: image.bytes, diff --git a/crates/gpui_macos/src/pasteboard.rs b/crates/gpui_macos/src/pasteboard.rs index d8b7f5627ddc44bea867132c91216b00729488d9..8362ab8f3b5c0ec76686d933699d5401b3c2c9fb 100644 --- a/crates/gpui_macos/src/pasteboard.rs +++ b/crates/gpui_macos/src/pasteboard.rs @@ -272,6 +272,7 @@ impl From for UTType { ImageFormat::Bmp => Self::bmp(), ImageFormat::Svg => Self::svg(), ImageFormat::Ico => Self::ico(), + ImageFormat::Pnm => Self::pnm(), } } } @@ -320,6 +321,11 @@ impl UTType { Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType } + pub fn pnm() -> Self { + //https://en.wikipedia.org/w/index.php?title=Netpbm&oldid=1336679433 under Uniform Type Identifier + Self(unsafe { ns_string("public.pbm") }) + } + fn inner(&self) -> *const Object { self.0 } diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 3c6e09cf60451ea36d9ab44471137d96d76987da..20d7b609d8de07c4de4c489eac90b312fbf9c210 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -66,6 +66,7 @@ pub enum IconName { ChevronUpDown, Circle, CircleHelp, + Clock, Close, CloudDownload, Code, @@ -242,7 +243,6 @@ pub enum IconName { ThinkingModeOff, Thread, ThreadFromSummary, - ThreadImport, ThreadsSidebarLeftClosed, ThreadsSidebarLeftOpen, ThreadsSidebarRightClosed, diff --git a/crates/json_schema_store/Cargo.toml b/crates/json_schema_store/Cargo.toml index e62c976abb934972b2c81981b13ce3021bef00d0..8180ffd7917718fa909b0aecf7afe3e50b277333 100644 --- a/crates/json_schema_store/Cargo.toml +++ b/crates/json_schema_store/Cargo.toml @@ -14,12 +14,17 @@ path = "src/json_schema_store.rs" [features] default = [] +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } + [dependencies] anyhow.workspace = true collections.workspace = true dap.workspace = true parking_lot.workspace = true extension.workspace = true +feature_flags.workspace = true gpui.workspace = true language.workspace = true paths.workspace = true diff --git a/crates/json_schema_store/src/json_schema_store.rs b/crates/json_schema_store/src/json_schema_store.rs index c13f42f9bb7d92b7c136815f720abfe6ec6faac3..629042f745dbee46b326778ad8c002e7e2464bd4 100644 --- a/crates/json_schema_store/src/json_schema_store.rs +++ b/crates/json_schema_store/src/json_schema_store.rs @@ -352,13 +352,16 @@ async fn resolve_dynamic_schema( let icon_theme_names = icon_theme_names.as_slice(); let theme_names = theme_names.as_slice(); - settings::SettingsStore::json_schema(&settings::SettingsJsonSchemaParams { - language_names, - font_names, - theme_names, - icon_theme_names, - lsp_adapter_names: &lsp_adapter_names, - }) + let mut schema = + settings::SettingsStore::json_schema(&settings::SettingsJsonSchemaParams { + language_names, + font_names, + theme_names, + icon_theme_names, + lsp_adapter_names: &lsp_adapter_names, + }); + inject_feature_flags_schema(&mut schema); + schema }) } "project_settings" => { @@ -374,16 +377,19 @@ async fn resolve_dynamic_schema( .map(|name| name.to_string()) .collect::>(); - settings::SettingsStore::project_json_schema(&settings::SettingsJsonSchemaParams { - language_names, - lsp_adapter_names: &lsp_adapter_names, - // These are not allowed in project-specific settings but - // they're still fields required by the - // `SettingsJsonSchemaParams` struct. - font_names: &[], - theme_names: &[], - icon_theme_names: &[], - }) + let mut schema = + settings::SettingsStore::project_json_schema(&settings::SettingsJsonSchemaParams { + language_names, + lsp_adapter_names: &lsp_adapter_names, + // These are not allowed in project-specific settings but + // they're still fields required by the + // `SettingsJsonSchemaParams` struct. + font_names: &[], + theme_names: &[], + icon_theme_names: &[], + }); + inject_feature_flags_schema(&mut schema); + schema } "debug_tasks" => { let adapter_schemas = cx.read_global::(|dap_registry, _| { @@ -513,6 +519,21 @@ pub fn all_schema_file_associations( file_associations } +/// Swaps the placeholder [`settings::FeatureFlagsMap`] subschema produced by +/// schemars for an enriched one that lists each known flag's variants. The +/// placeholder is registered in the `settings_content` crate so the +/// `settings` crate doesn't need a reverse dependency on `feature_flags`. +fn inject_feature_flags_schema(schema: &mut serde_json::Value) { + use schemars::JsonSchema; + + let Some(defs) = schema.get_mut("$defs").and_then(|d| d.as_object_mut()) else { + return; + }; + let schema_name = settings::FeatureFlagsMap::schema_name(); + let enriched = feature_flags::generate_feature_flags_schema().to_value(); + defs.insert(schema_name.into_owned(), enriched); +} + fn generate_jsonc_schema() -> serde_json::Value { let generator = schemars::generate::SchemaSettings::draft2019_09() .with_transform(DefaultDenyUnknownFields) diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index c4833620cf4ec0a6dc965aa9e23c2690a44773fd..70d6f326a5fd0a17c306c8a8af4645d8e234f837 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -25,7 +25,7 @@ use gpui::{ }; use language::{Language, LanguageConfig, ToOffset as _}; -use notifications::status_toast::{StatusToast, ToastIcon}; +use notifications::status_toast::StatusToast; use project::{CompletionDisplayOptions, Project}; use settings::{ BaseKeymap, KeybindSource, KeymapFile, Settings as _, SettingsAssets, infer_json_indent_size, @@ -2883,8 +2883,12 @@ impl KeybindingEditorModal { format!("Saved edits to the {} action.", humanized_action_name), cx, move |this, _cx| { - this.icon(ToastIcon::new(IconName::Check).color(Color::Success)) - .dismiss_button(true) + this.icon( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success), + ) + .dismiss_button(true) // .action("Undo", f) todo: wire the undo functionality }, ); diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs index 680078808ab33cc2a90caead8b304326beccf11b..219d9f4b39e8facbefc56c479dad8acd0b5c53c5 100644 --- a/crates/language_model/src/registry.rs +++ b/crates/language_model/src/registry.rs @@ -6,7 +6,6 @@ use collections::{BTreeMap, HashSet}; use gpui::{App, Context, Entity, EventEmitter, Global, prelude::*}; use std::{str::FromStr, sync::Arc}; use thiserror::Error; -use util::maybe; /// Function type for checking if a built-in provider should be hidden. /// Returns Some(extension_id) if the provider should be hidden when that extension is installed. @@ -46,7 +45,9 @@ impl std::fmt::Debug for ConfigurationError { #[derive(Default)] pub struct LanguageModelRegistry { default_model: Option, - default_fast_model: Option, + /// This model is automatically configured by a user's environment after + /// authenticating all providers. It's only used when `default_model` is not set. + available_fallback_model: Option, inline_assistant_model: Option, commit_message_model: Option, thread_summary_model: Option, @@ -349,22 +350,29 @@ impl LanguageModelRegistry { } pub fn set_default_model(&mut self, model: Option, cx: &mut Context) { - match (self.default_model.as_ref(), model.as_ref()) { + match (self.default_model(), model.as_ref()) { (Some(old), Some(new)) if old.is_same_as(new) => {} (None, None) => {} _ => cx.emit(Event::DefaultModelChanged), } - self.default_fast_model = maybe!({ - let provider = &model.as_ref()?.provider; - let fast_model = provider.default_fast_model(cx)?; - Some(ConfiguredModel { - provider: provider.clone(), - model: fast_model, - }) - }); self.default_model = model; } + pub fn set_environment_fallback_model( + &mut self, + model: Option, + cx: &mut Context, + ) { + if self.default_model.is_none() { + match (self.available_fallback_model.as_ref(), model.as_ref()) { + (Some(old), Some(new)) if old.is_same_as(new) => {} + (None, None) => {} + _ => cx.emit(Event::DefaultModelChanged), + } + } + self.available_fallback_model = model; + } + pub fn set_inline_assistant_model( &mut self, model: Option, @@ -410,7 +418,18 @@ impl LanguageModelRegistry { return None; } - self.default_model.clone() + self.default_model + .clone() + .or_else(|| self.available_fallback_model.clone()) + } + + pub fn default_fast_model(&self, cx: &App) -> Option { + let configured = self.default_model()?; + let fast_model = configured.provider.default_fast_model(cx)?; + Some(ConfiguredModel { + provider: configured.provider, + model: fast_model, + }) } pub fn inline_assistant_model(&self) -> Option { @@ -424,7 +443,7 @@ impl LanguageModelRegistry { .or_else(|| self.default_model.clone()) } - pub fn commit_message_model(&self) -> Option { + pub fn commit_message_model(&self, cx: &App) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { return None; @@ -432,11 +451,11 @@ impl LanguageModelRegistry { self.commit_message_model .clone() - .or_else(|| self.default_fast_model.clone()) - .or_else(|| self.default_model.clone()) + .or_else(|| self.default_fast_model(cx)) + .or_else(|| self.default_model()) } - pub fn thread_summary_model(&self) -> Option { + pub fn thread_summary_model(&self, cx: &App) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { return None; @@ -444,8 +463,8 @@ impl LanguageModelRegistry { self.thread_summary_model .clone() - .or_else(|| self.default_fast_model.clone()) - .or_else(|| self.default_model.clone()) + .or_else(|| self.default_fast_model(cx)) + .or_else(|| self.default_model()) } /// The models to use for inline assists. Returns the union of the active @@ -576,6 +595,35 @@ mod tests { assert!(!registry_read.should_hide_provider(&LanguageModelProviderId("unknown".into()))); } + #[gpui::test] + async fn test_configure_environment_fallback_model(cx: &mut gpui::TestAppContext) { + let registry = cx.new(|_| LanguageModelRegistry::default()); + + let provider = Arc::new(FakeLanguageModelProvider::default()); + registry.update(cx, |registry, cx| { + registry.register_provider(provider.clone(), cx); + }); + + cx.update(|cx| provider.authenticate(cx)).await.unwrap(); + + registry.update(cx, |registry, cx| { + let provider = registry.provider(&provider.id()).unwrap(); + let model = provider.default_model(cx).unwrap(); + + registry.set_environment_fallback_model( + Some(ConfiguredModel { + provider: provider.clone(), + model: model.clone(), + }), + cx, + ); + + let default_model = registry.default_model().unwrap(); + assert_eq!(default_model.model.id(), model.id()); + assert_eq!(default_model.provider.id(), provider.id()); + }); + } + #[gpui::test] fn test_sync_installed_llm_extensions(cx: &mut App) { let registry = cx.new(|_| LanguageModelRegistry::default()); diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 3154db91a43d1381f5b3f122a724be249adeb79b..bd29dbe08dbd16af25be4bd55b44067f47fa2a8a 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -5,7 +5,9 @@ use client::{Client, UserStore}; use collections::HashSet; use credentials_provider::CredentialsProvider; use gpui::{App, Context, Entity}; -use language_model::{LanguageModelProviderId, LanguageModelRegistry}; +use language_model::{ + ConfiguredModel, LanguageModelProviderId, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID, +}; use provider::deepseek::DeepSeekLanguageModelProvider; pub mod extension; @@ -116,6 +118,20 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { cx, ); }); + + cx.subscribe( + ®istry, + |_registry, event: &language_model::Event, cx| match event { + language_model::Event::ProviderStateChanged(_) + | language_model::Event::AddedProvider(_) + | language_model::Event::RemovedProvider(_) => { + update_environment_fallback_model(cx); + } + _ => {} + }, + ) + .detach(); + let registry = registry.downgrade(); cx.observe_global::(move |cx| { let Some(registry) = registry.upgrade() else { @@ -143,6 +159,50 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { .detach(); } +/// Recomputes and sets the [`LanguageModelRegistry`]'s environment fallback +/// model based on currently authenticated providers. +/// +/// Prefers the Zed cloud provider so that, once the user is signed in, we +/// always pick a Zed-hosted model over models from other authenticated +/// providers in the environment. If the Zed cloud provider is authenticated +/// but hasn't finished loading its models yet, we don't fall back to another +/// provider to avoid flickering between providers during sign in. +pub fn update_environment_fallback_model(cx: &mut App) { + let registry = LanguageModelRegistry::global(cx); + let fallback_model = { + let registry = registry.read(cx); + let cloud_provider = registry.provider(&ZED_CLOUD_PROVIDER_ID); + if cloud_provider + .as_ref() + .is_some_and(|provider| provider.is_authenticated(cx)) + { + cloud_provider.and_then(|provider| { + let model = provider + .default_model(cx) + .or_else(|| provider.recommended_models(cx).first().cloned())?; + Some(ConfiguredModel { provider, model }) + }) + } else { + registry + .providers() + .iter() + .filter(|provider| provider.is_authenticated(cx)) + .find_map(|provider| { + let model = provider + .default_model(cx) + .or_else(|| provider.recommended_models(cx).first().cloned())?; + Some(ConfiguredModel { + provider: provider.clone(), + model, + }) + }) + } + }; + registry.update(cx, |registry, cx| { + registry.set_environment_fallback_model(fallback_model, cx); + }); +} + fn register_openai_compatible_providers( registry: &mut LanguageModelRegistry, old: &HashSet>, diff --git a/crates/notifications/src/status_toast.rs b/crates/notifications/src/status_toast.rs index 8c177bfe9ca66a81af2b4441b6c4703e9d871395..d9ffcddc6ffe6539723e04d37749b7c7f03b1ca1 100644 --- a/crates/notifications/src/status_toast.rs +++ b/crates/notifications/src/status_toast.rs @@ -5,41 +5,13 @@ use ui::{Tooltip, prelude::*}; use workspace::{ToastAction, ToastView}; use zed_actions::toast; -#[derive(Clone, Copy)] -pub struct ToastIcon { - icon: IconName, - color: Color, -} - -impl ToastIcon { - pub fn new(icon: IconName) -> Self { - Self { - icon, - color: Color::default(), - } - } - - pub fn color(mut self, color: Color) -> Self { - self.color = color; - self - } -} - -impl From for ToastIcon { - fn from(icon: IconName) -> Self { - Self { - icon, - color: Color::default(), - } - } -} - #[derive(RegisterComponent)] pub struct StatusToast { - icon: Option, + icon: Option, text: SharedString, action: Option, show_dismiss: bool, + auto_dismiss: bool, this_handle: Entity, focus_handle: FocusHandle, } @@ -59,6 +31,7 @@ impl StatusToast { icon: None, action: None, show_dismiss: false, + auto_dismiss: true, this_handle: cx.entity(), focus_handle, }, @@ -67,11 +40,16 @@ impl StatusToast { }) } - pub fn icon(mut self, icon: ToastIcon) -> Self { + pub fn icon(mut self, icon: Icon) -> Self { self.icon = Some(icon); self } + pub fn auto_dismiss(mut self, auto_dismiss: bool) -> Self { + self.auto_dismiss = auto_dismiss; + self + } + pub fn action( mut self, label: impl Into, @@ -116,9 +94,7 @@ impl Render for StatusToast { .flex_none() .bg(cx.theme().colors().surface_background) .shadow_lg() - .when_some(self.icon.as_ref(), |this, icon| { - this.child(Icon::new(icon.icon).color(icon.color)) - }) + .when_some(self.icon.clone(), |this, icon| this.child(icon)) .child(Label::new(self.text.clone()).color(Color::Default)) .when_some(self.action.as_ref(), |this, action| { this.child( @@ -155,6 +131,10 @@ impl ToastView for StatusToast { fn action(&self) -> Option { self.action.clone() } + + fn auto_dismiss(&self) -> bool { + self.auto_dismiss + } } impl Focusable for StatusToast { @@ -183,33 +163,55 @@ impl Component for StatusToast { let icon_example = StatusToast::new( "Nathan Sobo accepted your contact request", cx, - |this, _| this.icon(ToastIcon::new(IconName::Check).color(Color::Muted)), + |this, _| { + this.icon( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Muted), + ) + }, ); let success_example = StatusToast::new("Pushed 4 changes to `zed/main`", cx, |this, _| { - this.icon(ToastIcon::new(IconName::Check).color(Color::Success)) + this.icon( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success), + ) }); let error_example = StatusToast::new( "git push: Couldn't find remote origin `iamnbutler/zed`", cx, |this, _cx| { - this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error)) - .action("More Info", |_, _| {}) + this.icon( + Icon::new(IconName::XCircle) + .size(IconSize::Small) + .color(Color::Error), + ) + .action("More Info", |_, _| {}) }, ); let warning_example = StatusToast::new("You have outdated settings", cx, |this, _cx| { - this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning)) - .action("More Info", |_, _| {}) + this.icon( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .action("More Info", |_, _| {}) }); let pr_example = StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| { - this.icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted)) - .action("Open Pull Request", |_, cx| { - cx.open_url("https://github.com/") - }) + this.icon( + Icon::new(IconName::GitBranch) + .size(IconSize::Small) + .color(Color::Muted), + ) + .action("Open Pull Request", |_, cx| { + cx.open_url("https://github.com/") + }) }); Some( diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index caa1a5458f66f77a46665627731f720a47a0cdbd..4a6a3c821cdb3ae5fc03b2711a39176bd3d432d9 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -7,7 +7,7 @@ use gpui::{ FocusHandle, Focusable, Global, IntoElement, KeyContext, Render, ScrollHandle, SharedString, Subscription, Task, WeakEntity, Window, actions, }; -use notifications::status_toast::{StatusToast, ToastIcon}; +use notifications::status_toast::StatusToast; use schemars::JsonSchema; use serde::Deserialize; use settings::{SettingsStore, VsCodeSettingsSource}; @@ -495,8 +495,12 @@ pub async fn handle_import_vscode_settings( format!("Your {} settings were successfully imported.", source), cx, |this, _| { - this.icon(ToastIcon::new(IconName::Check).color(Color::Success)) - .dismiss_button(true) + this.icon( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success), + ) + .dismiss_button(true) }, ); SettingsImportState::update(cx, |state, _| match source { @@ -514,11 +518,15 @@ pub async fn handle_import_vscode_settings( "Failed to import settings. See log for details", cx, |this, _| { - this.icon(ToastIcon::new(IconName::Close).color(Color::Error)) - .action("Open Log", |window, cx| { - window.dispatch_action(workspace::OpenLog.boxed_clone(), cx) - }) - .dismiss_button(true) + this.icon( + Icon::new(IconName::Close) + .size(IconSize::Small) + .color(Color::Error), + ) + .action("Open Log", |window, cx| { + window.dispatch_action(workspace::OpenLog.boxed_clone(), cx) + }) + .dismiss_button(true) }, ); workspace.toggle_status_toast(error_toast, cx); diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 0ba9787d2e4144cb529756b15fc05ff72dab83c8..0b6dcfa0078588a55067f18757ca575b4aad45d2 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -902,6 +902,7 @@ fn create_gpui_image(content: Vec) -> anyhow::Result> { image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, image::ImageFormat::Ico => gpui::ImageFormat::Ico, + image::ImageFormat::Pnm => gpui::ImageFormat::Pnm, format => anyhow::bail!("Image format {format:?} not supported"), }, content, diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index ab5c46752c6cd92e35a2eb283a50b38e76b649d7..768ab9afb91b4ec9c72962eb4f60f2d90519e791 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -1383,12 +1383,6 @@ impl WorktreeStore { pub fn paths(&self, cx: &App) -> WorktreePaths { let (mains, folders): (Vec, Vec) = self .visible_worktrees(cx) - .filter(|worktree| { - let worktree = worktree.read(cx); - // Remote worktrees that haven't received their first update - // don't have enough data to contribute yet. - !worktree.is_remote() || worktree.root_entry().is_some() - }) .map(|worktree| { let snapshot = worktree.read(cx).snapshot(); let folder_path = snapshot.abs_path().to_path_buf(); diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index b409962d9fd20621ad4c1153ab723cf9e08d85a0..3a5047c0d7a6d40968ca7b5d10f65e317dbc92a8 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -31,7 +31,7 @@ use gpui::{ }; use language::DiagnosticSeverity; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; -use notifications::status_toast::{StatusToast, ToastIcon}; +use notifications::status_toast::StatusToast; use project::{ Entry, EntryKind, Fs, GitEntry, GitEntryRef, GitTraversal, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId, @@ -2275,8 +2275,12 @@ impl ProjectPanel { .update(cx, |panel, cx| { let message = format!("Failed to restore {}: {}", file_name, e); let toast = StatusToast::new(message, cx, |this, _| { - this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error)) - .dismiss_button(true) + this.icon( + Icon::new(IconName::XCircle) + .size(IconSize::Small) + .color(Color::Error), + ) + .dismiss_button(true) }); panel .workspace diff --git a/crates/repl/src/notebook/notebook_ui.rs b/crates/repl/src/notebook/notebook_ui.rs index 0f80b20050b678590c9cf7fd20d2e0a2cfbd2428..ce0b6f598971ecdb07bdf763a359830dd334be1e 100644 --- a/crates/repl/src/notebook/notebook_ui.rs +++ b/crates/repl/src/notebook/notebook_ui.rs @@ -68,8 +68,8 @@ pub fn init(cx: &mut App) { } cx.observe_flag::({ - move |is_enabled, cx| { - if is_enabled { + move |flag, cx| { + if *flag { workspace::register_project_item::(cx); } else { // todo: there is no way to unregister a project item, so if the feature flag diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index c83e56577373aa9834f76b3c32488a069844d249..0913f1d0b9cb82333863f87a0cdf2174640a4eee 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -220,6 +220,7 @@ impl VsCodeSettings { workspace: self.workspace_settings_content(), which_key: None, modeline_lines: None, + feature_flags: None, } } diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 76891185c42ee36324c1cc160edfb27d63ecc0d6..12756c9bad5d9bf15ca5d02a977d816e80822d8b 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -128,6 +128,12 @@ pub struct AgentSettingsContent { /// Default: 320 #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")] pub default_height: Option, + /// Whether to limit the content width in the agent panel. When enabled, + /// content will be constrained to `max_content_width` and centered when + /// the panel is wider than that value, for optimal readability. + /// + /// Default: true + pub limit_content_width: Option, /// Maximum content width in pixels for the agent panel. Content will be /// centered when the panel is wider than this value. /// @@ -269,13 +275,34 @@ impl AgentSettingsContent { } pub fn add_favorite_model(&mut self, model: LanguageModelSelection) { - if !self.favorite_models.contains(&model) { + // Note: this is intentional to not compare using `PartialEq`here. + // Full equality would treat entries that differ just in thinking/effort/speed + // as distinct and silently produce duplicates. + if !self + .favorite_models + .iter() + .any(|m| m.provider == model.provider && m.model == model.model) + { self.favorite_models.push(model); } } pub fn remove_favorite_model(&mut self, model: &LanguageModelSelection) { - self.favorite_models.retain(|m| m != model); + self.favorite_models + .retain(|m| !(m.provider == model.provider && m.model == model.model)); + } + + pub fn update_favorite_model(&mut self, provider: &str, model: &str, f: F) + where + F: FnOnce(&mut LanguageModelSelection), + { + if let Some(entry) = self + .favorite_models + .iter_mut() + .find(|m| m.provider.0 == provider && m.model == model) + { + f(entry); + } } pub fn set_tool_default_permission(&mut self, tool_id: &str, mode: ToolPermissionMode) { diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 34072825c9c8c0f118f56220c51a54d2c5f6b832..d949b81e2329daaaa7e5ad040898b5a732a8c52b 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -211,6 +211,45 @@ pub struct SettingsContent { /// /// Default: 5 pub modeline_lines: Option, + + /// Local overrides for feature flags, keyed by flag name. + pub feature_flags: Option, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, MergeFrom)] +#[serde(transparent)] +pub struct FeatureFlagsMap(pub HashMap); + +// A manual `JsonSchema` impl keeps this type's schema registered under a +// unique name. The derived impl on a `#[serde(transparent)]` newtype around +// `HashMap` would inline to the map's own schema name (`Map_of_string`), +// which is shared with every other `HashMap` setting field in +// `SettingsContent`. A named placeholder lets `json_schema_store` find and +// replace just this field's schema at runtime without clobbering the others. +impl JsonSchema for FeatureFlagsMap { + fn schema_name() -> std::borrow::Cow<'static, str> { + "FeatureFlagsMap".into() + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "object", + "additionalProperties": { "type": "string" } + }) + } +} + +impl std::ops::Deref for FeatureFlagsMap { + type Target = HashMap; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for FeatureFlagsMap { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } } impl SettingsContent { diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 0f5679b85f80c418ecc677349689878e7322597a..cf53fa598a281db15509459508effe8109da219c 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -62,7 +62,7 @@ macro_rules! concat_sections { } pub(crate) fn settings_data(cx: &App) -> Vec { - vec![ + let mut pages = vec![ general_page(cx), appearance_page(), keymap_page(), @@ -77,7 +77,32 @@ pub(crate) fn settings_data(cx: &App) -> Vec { collaboration_page(), ai_page(cx), network_page(), - ] + ]; + + use feature_flags::FeatureFlagAppExt as _; + if cx.is_staff() || cfg!(debug_assertions) { + pages.push(developer_page()); + } + + pages +} + +fn developer_page() -> SettingsPage { + SettingsPage { + title: "Developer", + items: Box::new([ + SettingsPageItem::SectionHeader("Feature Flags"), + SettingsPageItem::SubPageLink(SubPageLink { + title: "Feature Flags".into(), + r#type: Default::default(), + description: None, + json_path: Some("feature_flags"), + in_json: true, + files: USER, + render: crate::pages::render_feature_flags_page, + }), + ]), + } } fn general_page(cx: &App) -> SettingsPage { @@ -5811,23 +5836,58 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), - SettingsPageItem::SettingItem(SettingItem { - title: "Agent Panel Max Content Width", - description: "Maximum content width in pixels. Content will be centered when the panel is wider than this value.", - field: Box::new(SettingField { - json_path: Some("agent.max_content_width"), - pick: |settings_content| { - settings_content.agent.as_ref()?.max_content_width.as_ref() - }, - write: |settings_content, value| { - settings_content - .agent - .get_or_insert_default() - .max_content_width = value; - }, - }), - metadata: None, - files: USER, + SettingsPageItem::DynamicItem(DynamicItem { + discriminant: SettingItem { + files: USER, + title: "Limit Content Width", + description: "Whether to constrain the agent panel content to a maximum width, centering it when the panel is wider, for optimal readability.", + field: Box::new(SettingField:: { + json_path: Some("agent.limit_content_width"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .limit_content_width + .as_ref() + }, + write: |settings_content, value| { + settings_content + .agent + .get_or_insert_default() + .limit_content_width = value; + }, + }), + metadata: None, + }, + pick_discriminant: |settings_content| { + let enabled = settings_content + .agent + .as_ref()? + .limit_content_width + .unwrap_or(true); + Some(if enabled { 1 } else { 0 }) + }, + fields: vec![ + vec![], + vec![SettingItem { + files: USER, + title: "Max Content Width", + description: "Maximum content width in pixels. Content will be centered when the panel is wider than this value.", + field: Box::new(SettingField { + json_path: Some("agent.max_content_width"), + pick: |settings_content| { + settings_content.agent.as_ref()?.max_content_width.as_ref() + }, + write: |settings_content, value| { + settings_content + .agent + .get_or_insert_default() + .max_content_width = value; + }, + }), + metadata: None, + }], + ], }), ] } diff --git a/crates/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs index a54f52b09cae65268b95e16a2131ef3c9aa48ae3..401534b66059e61e52406b85f509ae9c935eeab2 100644 --- a/crates/settings_ui/src/pages.rs +++ b/crates/settings_ui/src/pages.rs @@ -1,6 +1,7 @@ mod audio_input_output_setup; mod audio_test_window; mod edit_prediction_provider_setup; +mod feature_flags; mod tool_permissions_setup; pub(crate) use audio_input_output_setup::{ @@ -8,6 +9,7 @@ pub(crate) use audio_input_output_setup::{ }; 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 feature_flags::render_feature_flags_page; pub(crate) use tool_permissions_setup::render_tool_permissions_setup_page; pub use tool_permissions_setup::{ diff --git a/crates/settings_ui/src/pages/feature_flags.rs b/crates/settings_ui/src/pages/feature_flags.rs new file mode 100644 index 0000000000000000000000000000000000000000..462f5513b25f9516e0200928a93b74c75dbb4b6e --- /dev/null +++ b/crates/settings_ui/src/pages/feature_flags.rs @@ -0,0 +1,132 @@ +use feature_flags::{FeatureFlagDescriptor, FeatureFlagStore, FeatureFlagVariant}; +use fs::Fs; +use gpui::{ScrollHandle, prelude::*}; +use ui::{Checkbox, ToggleState, prelude::*}; + +use crate::SettingsWindow; + +pub(crate) fn render_feature_flags_page( + _settings_window: &SettingsWindow, + scroll_handle: &ScrollHandle, + _window: &mut Window, + cx: &mut Context, +) -> AnyElement { + // Sort by flag name so the list is stable between renders even though + // `inventory::iter` order depends on link order. + let mut descriptors: Vec<&'static FeatureFlagDescriptor> = + FeatureFlagStore::known_flags().collect(); + descriptors.sort_by_key(|descriptor| descriptor.name); + + v_flex() + .id("feature-flags-page") + .min_w_0() + .size_full() + .pt_2p5() + .px_8() + .pb_16() + .gap_4() + .overflow_y_scroll() + .track_scroll(scroll_handle) + .children( + descriptors + .into_iter() + .map(|descriptor| render_flag_row(descriptor, cx)), + ) + .into_any_element() +} + +fn render_flag_row( + descriptor: &'static FeatureFlagDescriptor, + cx: &mut Context, +) -> AnyElement { + let forced_on = FeatureFlagStore::is_forced_on(descriptor); + let resolved = cx.global::().resolved_key(descriptor, cx); + let has_override = FeatureFlagStore::override_for(descriptor.name, cx).is_some(); + + let header = + h_flex() + .justify_between() + .items_center() + .child( + h_flex() + .gap_2() + .child(Label::new(descriptor.name).size(LabelSize::Default).color( + if forced_on { + Color::Muted + } else { + Color::Default + }, + )) + .when(forced_on, |this| { + this.child( + Label::new("enabled for all") + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + .when(has_override && !forced_on, |this| { + let name = descriptor.name; + this.child( + Button::new(SharedString::from(format!("reset-{}", name)), "Reset") + .label_size(LabelSize::Small) + .on_click(cx.listener(move |_, _, _, cx| { + FeatureFlagStore::clear_override(name, ::global(cx), cx); + })), + ) + }); + + v_flex() + .id(SharedString::from(format!("flag-row-{}", descriptor.name))) + .gap_1() + .child(header) + .child(render_flag_variants(descriptor, resolved, forced_on, cx)) + .into_any_element() +} + +fn render_flag_variants( + descriptor: &'static FeatureFlagDescriptor, + resolved: &'static str, + forced_on: bool, + cx: &mut Context, +) -> impl IntoElement { + let variants: Vec = (descriptor.variants)(); + + let row_items = variants.into_iter().map({ + let name = descriptor.name; + move |variant| { + let key = variant.override_key; + let label = variant.label; + let selected = resolved == key; + let state = if selected { + ToggleState::Selected + } else { + ToggleState::Unselected + }; + let checkbox_id = SharedString::from(format!("{}-{}", name, key)); + let disabled = forced_on; + let mut checkbox = Checkbox::new(ElementId::from(checkbox_id), state) + .label(label) + .disabled(disabled); + if !disabled { + checkbox = + checkbox.on_click(cx.listener(move |_, new_state: &ToggleState, _, cx| { + // Clicking an already-selected option is a no-op rather than a + // "deselect" — there's no valid "nothing selected" state. + if *new_state == ToggleState::Unselected { + return; + } + FeatureFlagStore::set_override( + name, + key.to_string(), + ::global(cx), + cx, + ); + })); + } + checkbox.into_any_element() + } + }); + + h_flex().gap_4().flex_wrap().children(row_items) +} diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 63813b2783b6bfebed3599fece426a8f3ee23141..bd503dcb28061749ca60ebf2af5c3f92eb839305 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -1521,6 +1521,17 @@ impl SettingsWindow { }) .detach(); + use feature_flags::FeatureFlagAppExt as _; + let mut last_is_staff = cx.is_staff(); + cx.observe_global_in::(window, move |this, window, cx| { + let is_staff = cx.is_staff(); + if is_staff != last_is_staff { + last_is_staff = is_staff; + this.rebuild_pages(window, cx); + } + }) + .detach(); + cx.on_window_closed(|cx, _window_id| { if let Some(existing_window) = cx .windows() @@ -2143,6 +2154,15 @@ impl SettingsWindow { cx.notify(); } + fn rebuild_pages(&mut self, window: &mut Window, cx: &mut Context) { + self.pages.clear(); + self.navbar_entries.clear(); + self.navbar_focus_subscriptions.clear(); + self.content_handles.clear(); + self.build_ui(window, cx); + self.build_search_index(); + } + #[track_caller] fn fetch_files(&mut self, window: &mut Window, cx: &mut Context) { self.worktree_root_dirs.clear(); diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 4ac1257d94a417c56bbfd90366a01c7f64bb003a..c0c0b26e1dfe2daa8bfa6f2cc7445def417ff177 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -24,6 +24,7 @@ agent_ui = { workspace = true, features = ["audio"] } anyhow.workspace = true chrono.workspace = true editor.workspace = true +feature_flags.workspace = true fs.workspace = true git.workspace = true gpui.workspace = true diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index e18e91fb02139fd79447c6adb8171a43d55be6b3..08fad745fcfa2e1719f5ba40a67cb589e1118024 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -12,12 +12,15 @@ use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp, }; use agent_ui::{ - AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, CrossChannelImportOnboarding, - DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread, ThreadId, ThreadImportModal, + AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, ArchiveSelectedThread, + CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewThread, ThreadId, ThreadImportModal, channels_with_threads, import_threads_from_other_channels, }; use chrono::{DateTime, Utc}; use editor::Editor; +use feature_flags::{ + AgentThreadWorktreeLabel, AgentThreadWorktreeLabelFlag, FeatureFlag, FeatureFlagAppExt as _, +}; use gpui::{ Action as _, AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EntityId, FocusHandle, Focusable, KeyContext, ListState, Modifiers, Pixels, Render, SharedString, Task, WeakEntity, @@ -50,8 +53,8 @@ use util::path_list::PathList; use workspace::{ CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, NextProject, NextThread, Open, OpenMode, PreviousProject, PreviousThread, ProjectGroupKey, SaveIntent, - ShowFewerThreads, ShowMoreThreads, Sidebar as WorkspaceSidebar, SidebarSide, Toast, - ToggleWorkspaceSidebar, Workspace, notifications::NotificationId, sidebar_side_context_menu, + Sidebar as WorkspaceSidebar, SidebarSide, Toast, ToggleWorkspaceSidebar, Workspace, + notifications::NotificationId, sidebar_side_context_menu, }; use zed_actions::OpenRecent; @@ -69,8 +72,8 @@ gpui::actions!( [ /// Creates a new thread in the currently selected or active project group. NewThreadInGroup, - /// Toggles between the thread list and the archive view. - ViewAllThreads, + /// Toggles between the thread list and the thread history. + ToggleThreadHistory, ] ); @@ -85,13 +88,13 @@ gpui::actions!( const DEFAULT_WIDTH: Pixels = px(300.0); const MIN_WIDTH: Pixels = px(200.0); const MAX_WIDTH: Pixels = px(800.0); -const DEFAULT_THREADS_SHOWN: usize = 5; #[derive(Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] enum SerializedSidebarView { #[default] ThreadList, - Archive, + #[serde(alias = "Archive")] + History, } #[derive(Default, Serialize, Deserialize)] @@ -229,10 +232,6 @@ enum ListEntry { has_threads: bool, }, Thread(ThreadEntry), - ViewMore { - key: ProjectGroupKey, - is_fully_expanded: bool, - }, } #[cfg(test)] @@ -257,7 +256,6 @@ impl ListEntry { ListEntry::ProjectHeader { key, .. } => multi_workspace .workspaces_for_project_group(key, cx) .unwrap_or_default(), - ListEntry::ViewMore { .. } => Vec::new(), } } } @@ -330,6 +328,96 @@ fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { PathList::new(&workspace.read(cx).root_paths(cx)) } +#[derive(Clone)] +struct WorkspaceMenuWorktreeLabel { + icon: Option, + primary_name: SharedString, + secondary_name: Option, +} + +fn workspace_menu_worktree_labels( + workspace: &Entity, + cx: &App, +) -> Vec { + let root_paths = workspace.read(cx).root_paths(cx); + let show_folder_name = root_paths.len() > 1; + let project = workspace.read(cx).project().clone(); + let repository_snapshots: Vec<_> = project + .read(cx) + .repositories(cx) + .values() + .map(|repo| repo.read(cx).snapshot()) + .collect(); + + root_paths + .into_iter() + .map(|root_path| { + let root_path = root_path.as_ref(); + let folder_name = root_path + .file_name() + .map(|name| SharedString::from(name.to_string_lossy().to_string())) + .unwrap_or_default(); + let repository_snapshot = repository_snapshots + .iter() + .find(|snapshot| snapshot.work_directory_abs_path.as_ref() == root_path); + + if let Some(snapshot) = repository_snapshot + && snapshot.is_linked_worktree() + { + let worktree_name = project::linked_worktree_short_name( + snapshot.original_repo_abs_path.as_ref(), + root_path, + ) + .unwrap_or_else(|| folder_name.clone()); + + if show_folder_name { + WorkspaceMenuWorktreeLabel { + icon: Some(IconName::GitWorktree), + primary_name: folder_name, + secondary_name: Some(worktree_name), + } + } else { + WorkspaceMenuWorktreeLabel { + icon: Some(IconName::GitWorktree), + primary_name: worktree_name, + secondary_name: None, + } + } + } else { + WorkspaceMenuWorktreeLabel { + icon: None, + primary_name: folder_name, + secondary_name: None, + } + } + }) + .collect() +} + +fn apply_worktree_label_mode( + mut worktrees: Vec, + mode: AgentThreadWorktreeLabel, +) -> Vec { + match mode { + AgentThreadWorktreeLabel::Both => {} + AgentThreadWorktreeLabel::Worktree => { + for wt in &mut worktrees { + wt.branch_name = None; + } + } + AgentThreadWorktreeLabel::Branch => { + for wt in &mut worktrees { + // Fall back to showing the worktree name when no branch is + // known; an empty chip would be worse than a mismatched icon. + if wt.branch_name.is_some() { + wt.worktree_name = None; + } + } + } + } + worktrees +} + /// Shows a [`RemoteConnectionModal`] on the given workspace and establishes /// an SSH connection. Suitable for passing to /// [`MultiWorkspace::find_or_create_workspace`] as the `connect_remote` @@ -372,6 +460,7 @@ pub struct Sidebar { view: SidebarView, restoring_tasks: HashMap>, recent_projects_popover_handle: PopoverMenuHandle, + project_header_menu_handles: HashMap>, project_header_menu_ix: Option, _subscriptions: Vec, /// For the thread import banners, if there is just one we show "Import @@ -392,6 +481,8 @@ impl Sidebar { cx.on_focus_in(&focus_handle, window, Self::focus_in) .detach(); + AgentThreadWorktreeLabelFlag::watch(cx); + let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); editor.set_use_modal_editing(true); @@ -465,6 +556,7 @@ impl Sidebar { view: SidebarView::default(), restoring_tasks: HashMap::new(), recent_projects_popover_handle: PopoverMenuHandle::default(), + project_header_menu_handles: HashMap::new(), project_header_menu_ix: None, _subscriptions: Vec::new(), import_banners_use_verbose_labels: None, @@ -486,17 +578,6 @@ impl Sidebar { .unwrap_or(false) } - fn group_extra_batches(&self, key: &ProjectGroupKey, cx: &App) -> usize { - self.multi_workspace - .upgrade() - .and_then(|mw| { - mw.read(cx) - .group_state_by_key(key) - .and_then(|state| state.visible_thread_count) - }) - .unwrap_or(0) - } - fn set_group_expanded(&self, key: &ProjectGroupKey, expanded: bool, cx: &mut Context) { if let Some(mw) = self.multi_workspace.upgrade() { mw.update(cx, |mw, cx| { @@ -508,22 +589,6 @@ impl Sidebar { } } - fn set_group_visible_thread_count( - &self, - key: &ProjectGroupKey, - count: Option, - cx: &mut Context, - ) { - if let Some(mw) = self.multi_workspace.upgrade() { - mw.update(cx, |mw, cx| { - if let Some(state) = mw.group_state_by_key_mut(key) { - state.visible_thread_count = count; - } - mw.serialize(cx); - }); - } - } - fn is_active_workspace(&self, workspace: &Entity, cx: &App) -> bool { self.multi_workspace .upgrade() @@ -671,8 +736,8 @@ impl Sidebar { this.sync_active_entry_from_panel(_agent_panel, cx); this.update_entries(cx); } - AgentPanelEvent::MessageSentOrQueued { thread_id } => { - this.record_thread_message_sent_or_queued(thread_id, cx); + AgentPanelEvent::ThreadInteracted { thread_id } => { + this.record_thread_interacted(thread_id, cx); this.update_entries(cx); } }, @@ -1210,7 +1275,10 @@ impl Sidebar { } let mut worktree_matched = false; for worktree in &mut thread.worktrees { - if let Some(positions) = fuzzy_match_positions(&query, &worktree.name) { + let Some(name) = worktree.worktree_name.as_ref() else { + continue; + }; + if let Some(positions) = fuzzy_match_positions(&query, name) { worktree.highlight_positions = positions; worktree_matched = true; } @@ -1261,55 +1329,13 @@ impl Sidebar { continue; } - let total = threads.len(); - - let extra_batches = self.group_extra_batches(&group_key, cx); - let threads_to_show = - DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN); - let count = threads_to_show.min(total); - - let mut promoted_threads: HashSet = HashSet::new(); - - // Build visible entries in a single pass. Threads within - // the cutoff are always shown. Threads beyond it are shown - // only if they should be promoted (running, waiting, or - // focused) - for (index, thread) in threads.into_iter().enumerate() { - let is_hidden = index >= count; - - if is_hidden { - let is_notified = notified_threads.contains(&thread.metadata.thread_id); - let is_promoted = thread.status == AgentThreadStatus::Running - || thread.status == AgentThreadStatus::WaitingForConfirmation - || is_notified - || self.active_entry.as_ref().is_some_and(|active| { - active.matches_entry(&ListEntry::Thread(thread.clone())) - }); - if is_promoted { - promoted_threads.insert(thread.metadata.thread_id); - } - let is_in_promoted = promoted_threads.contains(&thread.metadata.thread_id); - if !is_in_promoted { - continue; - } - } - + for thread in threads { if let Some(sid) = &thread.metadata.session_id { current_session_ids.insert(sid.clone()); } current_thread_ids.insert(thread.metadata.thread_id); entries.push(thread.into()); } - - let visible = count + promoted_threads.len(); - let is_fully_expanded = visible >= total; - - if total > DEFAULT_THREADS_SHOWN { - entries.push(ListEntry::ViewMore { - key: group_key.clone(), - is_fully_expanded, - }); - } } } @@ -1407,6 +1433,7 @@ impl Sidebar { panel.active_thread_is_draft(cx) || panel.active_conversation_view().is_none() }); + self.project_header_menu_handles.entry(ix).or_default(); self.render_project_header( ix, false, @@ -1423,10 +1450,6 @@ impl Sidebar { ) } ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx), - ListEntry::ViewMore { - key, - is_fully_expanded, - } => self.render_view_more(ix, key, *is_fully_expanded, is_selected, cx), }; if is_group_header_after_first { @@ -1487,15 +1510,14 @@ impl Sidebar { let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}")); let is_collapsed = self.is_group_collapsed(key, cx); - let (disclosure_icon, disclosure_tooltip) = if is_collapsed { - (IconName::ChevronRight, "Expand Project") + let disclosure_icon = if is_collapsed { + IconName::ChevronRight } else { - (IconName::ChevronDown, "Collapse Project") + IconName::ChevronDown }; let key_for_toggle = key.clone(); - let key_for_collapse = key.clone(); - let view_more_expanded = self.group_extra_batches(key, cx) > 0; + let key_for_focus = key.clone(); let label = if highlight_positions.is_empty() { Label::new(label.clone()) @@ -1528,8 +1550,6 @@ impl Sidebar { .group_name(group_name_for_gradient.clone()) }; - let is_ellipsis_menu_open = self.project_header_menu_ix == Some(ix); - let header = h_flex() .id(id) .group(&group_name) @@ -1602,9 +1622,6 @@ impl Sidebar { .child(gradient_overlay()) .child( h_flex() - .when(!is_ellipsis_menu_open && !has_active_draft, |this| { - this.visible_on_hover(&group_name) - }) .child(gradient_overlay()) .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { cx.stop_propagation(); @@ -1621,6 +1638,7 @@ impl Sidebar { ) .icon_size(IconSize::Small) .when(has_active_draft, |this| this.icon_color(Color::Accent)) + .when(!has_active_draft, |this| this.visible_on_hover(&group_name)) .tooltip(move |_, cx| { Tooltip::for_action_in( "Start New Agent Thread", @@ -1641,71 +1659,36 @@ impl Sidebar { }, )) }) - .when(has_threads && view_more_expanded && !is_collapsed, |this| { - this.child( - IconButton::new( - SharedString::from(format!( - "{id_prefix}project-header-collapse-{ix}", - )), - IconName::ListCollapse, - ) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Show Fewer Threads")) - .on_click(cx.listener({ - let key_for_collapse = key_for_collapse.clone(); - move |this, _, _window, cx| { - this.selection = None; - this.set_group_visible_thread_count( - &key_for_collapse, - None, - cx, - ); - this.update_entries(cx); - } - })), - ) - }) - .child(self.render_project_header_ellipsis_menu(ix, id_prefix, key, cx)), + .child(self.render_project_header_ellipsis_menu( + ix, + id_prefix, + key, + is_active, + has_threads, + &group_name, + cx, + )), ) - .tooltip(Tooltip::element({ - move |_, cx| { - v_flex() - .gap_1() - .child(Label::new(disclosure_tooltip)) - .child( - h_flex() - .pt_1() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child(h_flex().flex_shrink_0().children(render_modifiers( - &Modifiers::secondary_key(), - PlatformStyle::platform(), - None, - Some(TextSize::Default.rems(cx).into()), - false, - ))) - .child( - Label::new("-click to activate most recent workspace") - .color(Color::Muted), - ), - ) - .into_any_element() + .on_mouse_down(gpui::MouseButton::Right, { + let menu_handle = self + .project_header_menu_handles + .get(&ix) + .cloned() + .unwrap_or_default(); + move |_, window, cx| { + cx.stop_propagation(); + menu_handle.toggle(window, cx); } - })) - .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| { - if event.modifiers().platform { - let key = key_for_toggle.clone(); - if let Some(workspace) = this.workspace_for_group(&key, cx) { - this.activate_workspace(&workspace, window, cx); + }) + .on_click( + cx.listener(move |this, event: &gpui::ClickEvent, window, cx| { + if event.modifiers().secondary() { + this.activate_or_open_workspace_for_group(&key_for_focus, window, cx); } else { - this.open_workspace_for_group(&key, window, cx); + this.toggle_collapse(&key_for_toggle, window, cx); } - this.selection = None; - this.active_entry = None; - } else { - this.toggle_collapse(&key_for_toggle, window, cx); - } - })); + }), + ); if !is_collapsed && !has_threads { v_flex() @@ -1737,49 +1720,37 @@ impl Sidebar { ix: usize, id_prefix: &str, project_group_key: &ProjectGroupKey, + is_active: bool, + has_threads: bool, + group_name: &SharedString, cx: &mut Context, ) -> AnyElement { let multi_workspace = self.multi_workspace.clone(); let project_group_key = project_group_key.clone(); - let show_menu = multi_workspace + let show_multi_project_entries = multi_workspace .read_with(cx, |mw, _| { project_group_key.host().is_none() && mw.project_group_keys().len() >= 2 }) .unwrap_or(false); - if !show_menu { - return IconButton::new( - SharedString::from(format!("{id_prefix}-close-project-{ix}")), - IconName::Close, - ) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Remove Project")) - .on_click(cx.listener({ - move |_, _, window, cx| { - multi_workspace - .update(cx, |multi_workspace, cx| { - multi_workspace - .remove_project_group(&project_group_key, window, cx) - .detach_and_log_err(cx); - }) - .ok(); - } - })) - .into_any_element(); - } - let this = cx.weak_entity(); + let trigger_id = SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")); + let menu_handle = self + .project_header_menu_handles + .get(&ix) + .cloned() + .unwrap_or_default(); + let is_menu_open = menu_handle.is_deployed(); + PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}")) + .with_handle(menu_handle) .trigger( - IconButton::new( - SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")), - IconName::Ellipsis, - ) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Toggle Project Menu")), + IconButton::new(trigger_id, IconName::Ellipsis) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .icon_size(IconSize::Small) + .when(!is_menu_open, |el| el.visible_on_hover(group_name)), ) .on_open(Rc::new({ let this = this.clone(); @@ -1794,48 +1765,262 @@ impl Sidebar { .menu(move |window, cx| { let multi_workspace = multi_workspace.clone(); let project_group_key = project_group_key.clone(); + let this_for_menu = this.clone(); + + let open_workspaces = multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspaces_for_project_group(&project_group_key, cx) + .unwrap_or_default() + }) + .unwrap_or_default(); + + let active_workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .ok(); + let workspace_labels: Vec<_> = open_workspaces + .iter() + .map(|workspace| workspace_menu_worktree_labels(workspace, cx)) + .collect(); + let workspace_is_active: Vec<_> = open_workspaces + .iter() + .map(|workspace| active_workspace.as_ref() == Some(workspace)) + .collect(); let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, menu_cx| { + let menu = menu.end_slot_action(Box::new(menu::SecondaryConfirm)); let weak_menu = menu_cx.weak_entity(); - let menu = menu.entry( - "Open Project in New Window", - Some(Box::new(workspace::MoveProjectToNewWindow)), - { - let project_group_key = project_group_key.clone(); - let multi_workspace = multi_workspace.clone(); - move |window, cx| { - multi_workspace - .update(cx, |multi_workspace, cx| { - multi_workspace - .open_project_group_in_new_window( + let menu = menu.when(show_multi_project_entries, |this| { + this.entry( + "Open Project in New Window", + Some(Box::new(workspace::MoveProjectToNewWindow)), + { + let project_group_key = project_group_key.clone(); + let multi_workspace = multi_workspace.clone(); + move |window, cx| { + multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace + .open_project_group_in_new_window( + &project_group_key, + window, + cx, + ) + .detach_and_log_err(cx); + }) + .ok(); + } + }, + ) + }); + + let menu = menu + .custom_entry( + { + move |_window, cx| { + let action = h_flex() + .opacity(0.6) + .children(render_modifiers( + &Modifiers::secondary_key(), + PlatformStyle::platform(), + None, + Some(TextSize::Default.rems(cx).into()), + false, + )) + .child(Label::new("-click").color(Color::Muted)); + + let label = if has_threads { + "Focus Last Workspace" + } else { + "Focus Workspace" + }; + + h_flex() + .w_full() + .justify_between() + .gap_4() + .child( + Label::new(label) + .when(is_active, |s| s.color(Color::Disabled)), + ) + .child(action) + .into_any_element() + } + }, + { + let project_group_key = project_group_key.clone(); + let this = this_for_menu.clone(); + move |window, cx| { + if is_active { + return; + } + this.update(cx, |sidebar, cx| { + if let Some(workspace) = + sidebar.workspace_for_group(&project_group_key, cx) + { + sidebar.activate_workspace(&workspace, window, cx); + } else { + sidebar.open_workspace_for_group( &project_group_key, window, cx, - ) - .detach_and_log_err(cx); + ); + } + sidebar.selection = None; + sidebar.active_entry = None; }) .ok(); - } - }, - ); + } + }, + ) + .selectable(!is_active); + + let menu = if open_workspaces.is_empty() { + menu + } else { + let mut menu = menu.separator().header("Open Workspaces"); + + for ( + workspace_index, + ((workspace, workspace_label), is_active_workspace), + ) in open_workspaces + .iter() + .cloned() + .zip(workspace_labels.iter().cloned()) + .zip(workspace_is_active.iter().copied()) + .enumerate() + { + let activate_multi_workspace = multi_workspace.clone(); + let close_multi_workspace = multi_workspace.clone(); + let activate_weak_menu = weak_menu.clone(); + let close_weak_menu = weak_menu.clone(); + let activate_workspace = workspace.clone(); + let close_workspace = workspace.clone(); + + menu = menu.custom_entry( + move |_window, _cx| { + let close_multi_workspace = close_multi_workspace.clone(); + let close_weak_menu = close_weak_menu.clone(); + let close_workspace = close_workspace.clone(); + let label_color = if is_active_workspace { + Color::Accent + } else { + Color::Default + }; + let row_group_name = SharedString::from(format!( + "workspace-menu-row-{workspace_index}" + )); + + h_flex() + .group(&row_group_name) + .w_full() + .gap_2() + .justify_between() + .child(h_flex().min_w_0().gap_2().children( + workspace_label.iter().map(|label| { + h_flex() + .min_w_0() + .gap_0p5() + .when_some(label.icon, |this, icon| { + this.child( + Icon::new(icon) + .size(IconSize::XSmall) + .color(label_color), + ) + }) + .child( + Label::new(label.primary_name.clone()) + .color(label_color) + .truncate(), + ) + .when_some( + label.secondary_name.clone(), + |this, secondary_name| { + this.child( + Label::new(":") + .color(label_color) + .alpha(0.5), + ) + .child( + Label::new(secondary_name) + .color(label_color) + .truncate(), + ) + }, + ) + .into_any_element() + }), + )) + .child( + IconButton::new( + ("close-workspace", workspace_index), + IconName::Close, + ) + .shape(ui::IconButtonShape::Square) + .visible_on_hover(&row_group_name) + .tooltip(Tooltip::text("Close Workspace")) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + window.prevent_default(); + close_multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace + .close_workspace( + &close_workspace, + window, + cx, + ) + .detach_and_log_err(cx); + }) + .ok(); + close_weak_menu + .update(cx, |_, cx| cx.emit(DismissEvent)) + .ok(); + }), + ) + .into_any_element() + }, + move |window, cx| { + activate_multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace.activate( + activate_workspace.clone(), + window, + cx, + ); + }) + .ok(); + activate_weak_menu + .update(cx, |_, cx| cx.emit(DismissEvent)) + .ok(); + }, + ); + } + + menu + }; let project_group_key = project_group_key.clone(); - let multi_workspace = multi_workspace.clone(); - menu.entry("Remove Project", None, move |window, cx| { - multi_workspace - .update(cx, |multi_workspace, cx| { - multi_workspace - .remove_project_group(&project_group_key, window, cx) - .detach_and_log_err(cx); - }) - .ok(); - weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); - }) + let remove_multi_workspace = multi_workspace.clone(); + menu.separator() + .entry("Remove Project", None, move |window, cx| { + remove_multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace + .remove_project_group(&project_group_key, window, cx) + .detach_and_log_err(cx); + }) + .ok(); + weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); + }) }); let this = this.clone(); + window .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| { this.update(cx, |sidebar, cx| { @@ -2155,18 +2340,6 @@ impl Sidebar { } } } - ListEntry::ViewMore { - key, - is_fully_expanded, - .. - } => { - let key = key.clone(); - if *is_fully_expanded { - self.reset_thread_group_expansion(&key, cx); - } else { - self.expand_thread_group(&key, cx); - } - } } } @@ -2733,7 +2906,7 @@ impl Sidebar { self.update_entries(cx); } } - Some(ListEntry::Thread(_) | ListEntry::ViewMore { .. }) => { + Some(ListEntry::Thread(_)) => { for i in (0..ix).rev() { if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i) { @@ -2760,7 +2933,7 @@ impl Sidebar { // Find the group header for the current selection. let header_ix = match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { .. }) => Some(ix), - Some(ListEntry::Thread(_) | ListEntry::ViewMore { .. }) => (0..ix).rev().find(|&i| { + Some(ListEntry::Thread(_)) => (0..ix).rev().find(|&i| { matches!( self.contents.entries.get(i), Some(ListEntry::ProjectHeader { .. }) @@ -3369,9 +3542,9 @@ impl Sidebar { } } - fn remove_selected_thread( + fn archive_selected_thread( &mut self, - _: &RemoveSelectedThread, + _: &ArchiveSelectedThread, window: &mut Window, cx: &mut Context, ) { @@ -3398,11 +3571,7 @@ impl Sidebar { self.thread_last_accessed.insert(*id, Utc::now()); } - fn record_thread_message_sent_or_queued( - &mut self, - thread_id: &agent_ui::ThreadId, - cx: &mut App, - ) { + fn record_thread_interacted(&mut self, thread_id: &agent_ui::ThreadId, cx: &mut App) { let store = ThreadMetadataStore::global(cx); store.update(cx, |store, cx| { store.update_interacted_at(thread_id, Utc::now(), cx); @@ -3484,7 +3653,6 @@ impl Sidebar { timestamp, }) } - _ => None, }) .collect(); @@ -3706,6 +3874,11 @@ impl Sidebar { let is_remote = thread.workspace.is_remote(cx); + let worktrees = apply_worktree_label_mode( + thread.worktrees.clone(), + cx.flag_value::(), + ); + ThreadItem::new(id, title) .base_bg(sidebar_bg) .icon(thread.icon) @@ -3714,7 +3887,7 @@ impl Sidebar { .when_some(thread.icon_from_external_svg.clone(), |this, svg| { this.custom_icon_from_external_svg(svg) }) - .worktrees(thread.worktrees.clone()) + .worktrees(worktrees) .timestamp(timestamp) .highlight_positions(thread.highlight_positions.to_vec()) .title_generating(thread.is_title_generating) @@ -3760,7 +3933,7 @@ impl Sidebar { move |_window, cx| { Tooltip::for_action_in( "Archive Thread", - &RemoveSelectedThread, + &ArchiveSelectedThread, &focus_handle, cx, ) @@ -3867,38 +4040,6 @@ impl Sidebar { .anchor(gpui::Corner::BottomRight) } - fn render_view_more( - &self, - ix: usize, - key: &ProjectGroupKey, - is_fully_expanded: bool, - is_selected: bool, - cx: &mut Context, - ) -> AnyElement { - let key = key.clone(); - let id = SharedString::from(format!("view-more-{}", ix)); - - let label: SharedString = if is_fully_expanded { - "Collapse".into() - } else { - "View More".into() - }; - - ThreadItem::new(id, label) - .focused(is_selected) - .icon_visible(false) - .title_label_color(Color::Muted) - .on_click(cx.listener(move |this, _, _window, cx| { - this.selection = None; - if is_fully_expanded { - this.reset_thread_group_expansion(&key, cx); - } else { - this.expand_thread_group(&key, cx); - } - })) - .into_any_element() - } - fn new_thread_in_group( &mut self, _: &NewThreadInGroup, @@ -3955,7 +4096,7 @@ impl Sidebar { let ix = self.selection?; match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { key, .. }) => Some(key.clone()), - Some(ListEntry::Thread(_) | ListEntry::ViewMore { .. }) => { + Some(ListEntry::Thread(_)) => { (0..ix) .rev() .find_map(|i| match self.contents.entries.get(i) { @@ -3979,6 +4120,26 @@ impl Sidebar { } } + pub(crate) fn activate_or_open_workspace_for_group( + &mut self, + key: &ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + let workspace = self + .multi_workspace + .upgrade() + .and_then(|mw| mw.read(cx).last_active_workspace_for_group(key, cx)) + .or_else(|| self.workspace_for_group(key, cx)); + if let Some(workspace) = workspace { + self.activate_workspace(&workspace, window, cx); + } else { + self.open_workspace_for_group(key, window, cx); + } + self.selection = None; + self.active_entry = None; + } + fn active_project_group_key(&self, cx: &App) -> Option { let multi_workspace = self.multi_workspace.upgrade()?; let multi_workspace = multi_workspace.read(cx); @@ -4132,59 +4293,6 @@ impl Sidebar { self.cycle_thread_impl(false, window, cx); } - fn expand_thread_group(&mut self, project_group_key: &ProjectGroupKey, cx: &mut Context) { - let current = self.group_extra_batches(project_group_key, cx); - self.set_group_visible_thread_count(project_group_key, Some(current + 1), cx); - self.update_entries(cx); - } - - fn reset_thread_group_expansion( - &mut self, - project_group_key: &ProjectGroupKey, - cx: &mut Context, - ) { - self.set_group_visible_thread_count(project_group_key, None, cx); - self.update_entries(cx); - } - - fn collapse_thread_group( - &mut self, - project_group_key: &ProjectGroupKey, - cx: &mut Context, - ) { - let batches = self.group_extra_batches(project_group_key, cx); - match batches { - 0 => return, - 1 => self.set_group_visible_thread_count(project_group_key, None, cx), - _ => self.set_group_visible_thread_count(project_group_key, Some(batches - 1), cx), - } - self.update_entries(cx); - } - - fn on_show_more_threads( - &mut self, - _: &ShowMoreThreads, - _window: &mut Window, - cx: &mut Context, - ) { - let Some(active_key) = self.active_project_group_key(cx) else { - return; - }; - self.expand_thread_group(&active_key, cx); - } - - fn on_show_fewer_threads( - &mut self, - _: &ShowFewerThreads, - _window: &mut Window, - cx: &mut Context, - ) { - let Some(active_key) = self.active_project_group_key(cx) else { - return; - }; - self.collapse_thread_group(&active_key, cx); - } - fn render_no_results(&self, cx: &mut Context) -> impl IntoElement { let has_query = self.has_filter_query(cx); let message = if has_query { @@ -4397,45 +4505,33 @@ impl Sidebar { fn render_sidebar_bottom_bar(&mut self, cx: &mut Context) -> impl IntoElement { let is_archive = matches!(self.view, SidebarView::Archive(..)); - let show_import_button = is_archive && !self.should_render_acp_import_onboarding(cx); let on_right = self.side(cx) == SidebarSide::Right; - let action_buttons = h_flex() + h_flex() + .p_1() .gap_1() .when(on_right, |this| this.flex_row_reverse()) - .when(show_import_button, |this| { - this.child( - IconButton::new("thread-import", IconName::ThreadImport) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Import External Agent Threads")) - .on_click(cx.listener(|this, _, window, cx| { - this.show_archive(window, cx); - this.show_thread_import_modal(window, cx); - })), - ) - }) + .border_t_1() + .border_color(cx.theme().colors().border) + .child(self.render_sidebar_toggle_button(cx)) .child( - IconButton::new("archive", IconName::Archive) + IconButton::new("history", IconName::Clock) .icon_size(IconSize::Small) .toggle_state(is_archive) .tooltip(move |_, cx| { - Tooltip::for_action("View All Threads", &ViewAllThreads, cx) + let label = if is_archive { + "Hide Thread History" + } else { + "Show Thread History" + }; + Tooltip::for_action(label, &ToggleThreadHistory, cx) }) .on_click(cx.listener(|this, _, window, cx| { - this.toggle_archive(&ViewAllThreads, window, cx); + this.toggle_archive(&ToggleThreadHistory, window, cx); })), ) - .child(self.render_recent_projects_button(cx)); - - h_flex() - .p_1() - .gap_1() - .when(on_right, |this| this.flex_row_reverse()) - .justify_between() - .border_t_1() - .border_color(cx.theme().colors().border) - .child(self.render_sidebar_toggle_button(cx)) - .child(action_buttons) + .child(div().flex_1()) + .child(self.render_recent_projects_button(cx)) } fn active_workspace(&self, cx: &App) -> Option> { @@ -4561,14 +4657,19 @@ impl Sidebar { ) } - fn toggle_archive(&mut self, _: &ViewAllThreads, window: &mut Window, cx: &mut Context) { + fn toggle_archive( + &mut self, + _: &ToggleThreadHistory, + window: &mut Window, + cx: &mut Context, + ) { match &self.view { SidebarView::ThreadList => { let side = match self.side(cx) { SidebarSide::Left => "left", SidebarSide::Right => "right", }; - telemetry::event!("Sidebar Archive Viewed", side = side); + telemetry::event!("Thread History Viewed", side = side); self.show_archive(window, cx); } SidebarView::Archive(_) => self.show_thread_list(window, cx), @@ -4619,6 +4720,9 @@ impl Sidebar { ThreadsArchiveViewEvent::CancelRestore { thread_id } => { this.restoring_tasks.remove(thread_id); } + ThreadsArchiveViewEvent::Import => { + this.show_thread_import_modal(window, cx); + } }, ); @@ -4691,7 +4795,7 @@ fn render_import_onboarding_banner( .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border)) .label_size(LabelSize::Small) .start_icon( - Icon::new(IconName::ThreadImport) + Icon::new(IconName::Download) .size(IconSize::Small) .color(Color::Muted), ) @@ -4748,7 +4852,7 @@ impl WorkspaceSidebar for Sidebar { width: Some(f32::from(self.width)), active_view: match self.view { SidebarView::ThreadList => SerializedSidebarView::ThreadList, - SidebarView::Archive(_) => SerializedSidebarView::Archive, + SidebarView::Archive(_) => SerializedSidebarView::History, }, }; serde_json::to_string(&serialized).ok() @@ -4764,7 +4868,7 @@ impl WorkspaceSidebar for Sidebar { if let Some(width) = serialized.width { self.width = px(width).clamp(MIN_WIDTH, MAX_WIDTH); } - if serialized.active_view == SerializedSidebarView::Archive { + if serialized.active_view == SerializedSidebarView::History { cx.defer_in(window, |this, window, cx| { this.show_archive(window, cx); }); @@ -4813,7 +4917,7 @@ impl Render for Sidebar { .on_action(cx.listener(Self::fold_all)) .on_action(cx.listener(Self::unfold_all)) .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::remove_selected_thread)) + .on_action(cx.listener(Self::archive_selected_thread)) .on_action(cx.listener(Self::new_thread_in_group)) .on_action(cx.listener(Self::toggle_archive)) .on_action(cx.listener(Self::focus_sidebar_filter)) @@ -4822,8 +4926,6 @@ impl Render for Sidebar { .on_action(cx.listener(Self::on_previous_project)) .on_action(cx.listener(Self::on_next_thread)) .on_action(cx.listener(Self::on_previous_thread)) - .on_action(cx.listener(Self::on_show_more_threads)) - .on_action(cx.listener(Self::on_show_fewer_threads)) .on_action(cx.listener(|this, _: &OpenRecent, window, cx| { this.recent_projects_popover_handle.toggle(window, cx); })) diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 7ea2d96ee54a7f616d57018183323c865ae6277f..bd266fb88c3657c6e28c49ccfd364edb919b649a 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -147,11 +147,6 @@ fn assert_remote_project_integration_sidebar_state( title ); } - ListEntry::ViewMore { .. } => { - panic!( - "unexpected `View More` entry while simulating remote project integration flicker" - ); - } } } @@ -454,9 +449,12 @@ fn format_linked_worktree_chips(worktrees: &[ThreadItemWorktreeInfo]) -> String if wt.kind == ui::WorktreeKind::Main { continue; } - if !seen.contains(&wt.name) { - seen.push(wt.name.clone()); - chips.push(format!("{{{}}}", wt.name)); + let Some(name) = wt.worktree_name.as_ref() else { + continue; + }; + if !seen.contains(name) { + seen.push(name.clone()); + chips.push(format!("{{{}}}", name)); } } if chips.is_empty() { @@ -519,15 +517,6 @@ fn visible_entries_as_strings( format!(" {title}{worktree}{live}{status_str}{notified}{selected}") } } - ListEntry::ViewMore { - is_fully_expanded, .. - } => { - if *is_fully_expanded { - format!(" - Collapse{}", selected) - } else { - format!(" + View More{}", selected) - } - } } }) .collect() @@ -545,7 +534,7 @@ async fn test_serialization_round_trip(cx: &mut TestAppContext) { let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx)); - // Set a custom width, collapse the group, and expand "View More". + // Set a custom width and collapse the group. sidebar.update_in(cx, |sidebar, window, cx| { sidebar.set_width(Some(px(420.0)), cx); sidebar.toggle_collapse(&project_group_key, window, cx); @@ -587,7 +576,7 @@ async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppCon let serialized = serde_json::to_string(&SerializedSidebar { width: Some(400.0), - active_view: SerializedSidebarView::Archive, + active_view: SerializedSidebarView::History, }) .expect("serialization should succeed"); @@ -730,101 +719,6 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) { ); } -#[gpui::test] -async fn test_view_more_pagination(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - save_n_test_threads(12, &project, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - // - "v [my-project]", - " Thread 12", - " Thread 11", - " Thread 10", - " Thread 9", - " Thread 8", - " + View More", - ] - ); -} - -#[gpui::test] -async fn test_view_more_batched_expansion(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse - save_n_test_threads(17, &project, cx).await; - - let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx)); - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Initially shows 5 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); // header + 5 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Focus and navigate to View More, then confirm to expand by one batch - focus_sidebar(&sidebar, cx); - for _ in 0..7 { - cx.dispatch_action(SelectNext); - } - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // Now shows 10 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 12); // header + 10 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Expand again by one batch - sidebar.update_in(cx, |s, _window, cx| { - s.expand_thread_group(&project_group_key, cx); - }); - cx.run_until_parked(); - - // Now shows 15 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 17); // header + 15 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Expand one more time - should show all 17 threads with Collapse button - sidebar.update_in(cx, |s, _window, cx| { - s.expand_thread_group(&project_group_key, cx); - }); - cx.run_until_parked(); - - // All 17 threads shown with Collapse button - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 19); // header + 17 threads + Collapse - assert!(!entries.iter().any(|e| e.contains("View More"))); - assert!(entries.iter().any(|e| e.contains("Collapse"))); - - // Click collapse - should go back to showing 5 threads - sidebar.update_in(cx, |s, _window, cx| { - s.reset_thread_group_expansion(&project_group_key, cx); - }); - cx.run_until_parked(); - - // Back to initial state: 5 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); // header + 5 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); -} - #[gpui::test] async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; @@ -948,7 +842,6 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { key: ProjectGroupKey::new(None, collapsed_path.clone()), workspaces: Vec::new(), expanded: false, - visible_thread_count: None, }); }); @@ -1092,11 +985,6 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { worktrees: Vec::new(), diff_stats: DiffStats::default(), }), - // View More entry - ListEntry::ViewMore { - key: ProjectGroupKey::new(None, expanded_path.clone()), - is_fully_expanded: false, - }, // Collapsed project header ListEntry::ProjectHeader { key: ProjectGroupKey::new(None, collapsed_path.clone()), @@ -1123,14 +1011,13 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { " Error thread * (error)", " Waiting thread (waiting)", " Notified thread * (!)", - " + View More", "> [collapsed-project]", ] ); // Move selection to the collapsed header sidebar.update_in(cx, |s, _window, _cx| { - s.selection = Some(7); + s.selection = Some(6); }); assert_eq!( @@ -1319,40 +1206,6 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA ); } -#[gpui::test] -async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - save_n_test_threads(8, &project, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Should show header + 5 threads + "View More" - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) - focus_sidebar(&sidebar, cx); - for _ in 0..7 { - cx.dispatch_action(SelectNext); - } - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); - - // Confirm on "View More" to expand - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // All 8 threads should now be visible with a "Collapse" button - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button - assert!(!entries.iter().any(|e| e.contains("View More"))); - assert!(entries.iter().any(|e| e.contains("Collapse"))); -} - #[gpui::test] async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; @@ -2090,61 +1943,6 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { ); } -#[gpui::test] -async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Create 8 threads. The oldest one has a unique name and will be - // behind View More (only 5 shown by default). - for i in 0..8u32 { - let title = if i == 0 { - "Hidden gem thread".to_string() - } else { - format!("Thread {}", i + 1) - }; - save_thread_metadata( - acp::SessionId::new(Arc::from(format!("thread-{}", i))), - Some(title.into()), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), - None, - None, - &project, - cx, - ) - } - cx.run_until_parked(); - - // Confirm the thread is not visible and View More is shown. - let entries = visible_entries_as_strings(&sidebar, cx); - assert!( - entries.iter().any(|e| e.contains("View More")), - "should have View More button" - ); - assert!( - !entries.iter().any(|e| e.contains("Hidden gem")), - "Hidden gem should be behind View More" - ); - - // User searches for the hidden thread — it appears, and View More is gone. - type_in_search(&sidebar, "hidden gem", cx); - let filtered = visible_entries_as_strings(&sidebar, cx); - assert_eq!( - filtered, - vec![ - // - "v [my-project]", - " Hidden gem thread <== selected", - ] - ); - assert!( - !filtered.iter().any(|e| e.contains("View More")), - "View More should not appear when filtering" - ); -} - #[gpui::test] async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; @@ -2454,7 +2252,6 @@ async fn test_confirm_on_historical_thread_in_new_project_group_opens_real_threa key: project_b_key.clone(), workspaces: Vec::new(), expanded: true, - visible_thread_count: None, }); }); @@ -4043,7 +3840,10 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje } ListEntry::Thread(thread) if thread.metadata.title.as_ref().map(|t| t.as_ref()) == Some("WT Thread") - && thread.worktrees.first().map(|wt| wt.name.as_ref()) + && thread + .worktrees + .first() + .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref())) == Some("wt-feature-a") => { saw_expected_thread = true; @@ -4053,16 +3853,13 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje let worktree_name = thread .worktrees .first() - .map(|wt| wt.name.as_ref()) + .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref())) .unwrap_or(""); panic!( "unexpected sidebar thread while opening linked worktree thread: title=`{}`, worktree=`{}`", title, worktree_name ); } - ListEntry::ViewMore { .. } => { - panic!("unexpected `View More` entry while opening linked worktree thread"); - } } } @@ -10635,7 +10432,7 @@ fn test_worktree_info_branch_names_for_main_worktrees() { assert_eq!(infos.len(), 1); assert_eq!(infos[0].kind, ui::WorktreeKind::Main); assert_eq!(infos[0].branch_name, Some(SharedString::from("feature-x"))); - assert_eq!(infos[0].name, SharedString::from("myapp")); + assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp"))); } #[test] @@ -10672,7 +10469,7 @@ fn test_worktree_info_missing_branch_returns_none() { assert_eq!(infos.len(), 1); assert_eq!(infos[0].kind, ui::WorktreeKind::Main); assert_eq!(infos[0].branch_name, None); - assert_eq!(infos[0].name, SharedString::from("myapp")); + assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp"))); } #[gpui::test] @@ -11044,3 +10841,129 @@ async fn test_collab_guest_move_thread_paths_is_noop(cx: &mut TestAppContext) { ); }); } + +#[gpui::test] +async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_workspace( + cx: &mut TestAppContext, +) { + // Regression test for: cmd-clicking a project group header should return + // the user to the workspace they most recently had active in that group, + // including workspaces rooted at a linked worktree. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project-a", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + + fs.add_linked_worktree_for_repo( + Path::new("/project-a/.git"), + false, + git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let worktree_project_a = + project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + main_project_a + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project_a + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + // The multi-workspace starts with the main-paths workspace of group A + // as the initially active workspace. + let (multi_workspace, cx) = cx + .add_window_view(|window, cx| MultiWorkspace::test_new(main_project_a.clone(), window, cx)); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Capture the initially active workspace (group A's main-paths workspace) + // *before* registering additional workspaces, since `workspaces()` returns + // retained workspaces in registration order — not activation order — and + // the multi-workspace's starting workspace may not be retained yet. + let main_workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + // Register the linked-worktree workspace (group A) and the group-B + // workspace. Both get retained by the multi-workspace. + let worktree_workspace_a = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project_a.clone(), window, cx) + }); + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx) + }); + + cx.run_until_parked(); + + // Step 1: activate the linked-worktree workspace. The MultiWorkspace + // records this as the last-active workspace for group A on its + // ProjectGroupState. (We don't assert on the initial active workspace + // because `test_add_workspace` may auto-activate newly registered + // workspaces — what matters for this test is the explicit sequence of + // activations below.) + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate(worktree_workspace_a.clone(), window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()), + worktree_workspace_a, + "linked-worktree workspace should be active after step 1" + ); + + // Step 2: switch to the workspace for group B. Group A's last-active + // workspace remains the linked-worktree one (group B getting activated + // records *its own* last-active workspace, not group A's). + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate(workspace_b.clone(), window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()), + workspace_b, + "group B's workspace should be active after step 2" + ); + + // Step 3: simulate cmd-click on group A's header. The project group key + // for group A is derived from the *main-paths* workspace (linked-worktree + // workspaces share the same key because it normalizes to main-worktree + // paths). + let group_a_key = main_workspace_a.read_with(cx, |ws, cx| ws.project_group_key(cx)); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_or_open_workspace_for_group(&group_a_key, window, cx); + }); + cx.run_until_parked(); + + // Expected: we're back in the linked-worktree workspace, not the + // main-paths one. + let active_after_cmd_click = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + assert_eq!( + active_after_cmd_click, worktree_workspace_a, + "cmd-click on group A's header should return to the last-active \ + linked-worktree workspace, not the main-paths workspace" + ); + assert_ne!( + active_after_cmd_click, main_workspace_a, + "cmd-click must not fall back to the main-paths workspace when a \ + linked-worktree workspace was the last-active one for the group" + ); +} diff --git a/crates/sidebar/src/thread_switcher.rs b/crates/sidebar/src/thread_switcher.rs index d525f6d67838c82e0e222fa4755227664f93d166..97c291e0dc928dfb94a530234002bd4e99e2b3be 100644 --- a/crates/sidebar/src/thread_switcher.rs +++ b/crates/sidebar/src/thread_switcher.rs @@ -146,6 +146,15 @@ impl ThreadSwitcher { } } + fn select_index(&mut self, index: usize, cx: &mut Context) { + if index >= self.entries.len() || index == self.selected_index { + return; + } + self.selected_index = index; + self.emit_preview(cx); + cx.notify(); + } + fn cancel(&mut self, _: &menu::Cancel, _window: &mut gpui::Window, cx: &mut Context) { cx.emit(ThreadSwitcherEvent::Dismissed); cx.emit(DismissEvent); @@ -213,37 +222,39 @@ impl Render for ThreadSwitcher { .children(self.entries.iter().enumerate().map(|(ix, entry)| { let id = SharedString::from(format!("thread-switcher-{}", entry.session_id)); - div() - .id(id.clone()) + ThreadItem::new(id, entry.title.clone()) + .rounded(true) + .icon(entry.icon) + .status(entry.status) + .when_some(entry.icon_from_external_svg.clone(), |this, svg| { + this.custom_icon_from_external_svg(svg) + }) + .when_some(entry.project_name.clone(), |this, name| { + this.project_name(name) + }) + .worktrees(entry.worktrees.clone()) + .timestamp(entry.timestamp.clone()) + .title_generating(entry.is_title_generating) + .notified(entry.notified) + .when(entry.diff_stats.lines_added > 0, |this| { + this.added(entry.diff_stats.lines_added as usize) + }) + .when(entry.diff_stats.lines_removed > 0, |this| { + this.removed(entry.diff_stats.lines_removed as usize) + }) + .selected(ix == selected_index) + .base_bg(cx.theme().colors().elevated_surface_background) + .on_hover(cx.listener(move |this, hovered: &bool, _window, cx| { + if *hovered { + this.select_index(ix, cx); + } + })) + // TODO: This is not properly propagating to the tread item. .on_click( cx.listener(move |this, _event: &gpui::ClickEvent, _window, cx| { this.select_and_confirm(ix, cx); }), ) - .child( - ThreadItem::new(id, entry.title.clone()) - .rounded(true) - .icon(entry.icon) - .status(entry.status) - .when_some(entry.icon_from_external_svg.clone(), |this, svg| { - this.custom_icon_from_external_svg(svg) - }) - .when_some(entry.project_name.clone(), |this, name| { - this.project_name(name) - }) - .worktrees(entry.worktrees.clone()) - .timestamp(entry.timestamp.clone()) - .title_generating(entry.is_title_generating) - .notified(entry.notified) - .when(entry.diff_stats.lines_added > 0, |this| { - this.added(entry.diff_stats.lines_added as usize) - }) - .when(entry.diff_stats.lines_removed > 0, |this| { - this.removed(entry.diff_stats.lines_removed as usize) - }) - .selected(ix == selected_index) - .base_bg(cx.theme().colors().elevated_surface_background), - ) .into_any_element() })) } diff --git a/crates/ui/src/components/ai/parallel_agents_illustration.rs b/crates/ui/src/components/ai/parallel_agents_illustration.rs index 3640f71c075b3ba8f8cfb24fbde0b583c3763ab8..e7694e9359f3d9ceaebcd92ad32dfa5bd6705dea 100644 --- a/crates/ui/src/components/ai/parallel_agents_illustration.rs +++ b/crates/ui/src/components/ai/parallel_agents_illustration.rs @@ -15,32 +15,39 @@ impl RenderOnce for ParallelAgentsIllustration { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let icon_container = || h_flex().size_4().flex_shrink_0().justify_center(); - let title_bar = |id: &'static str, width: DefiniteLength, duration_ms: u64| { + let loading_bar = |id: &'static str, width: DefiniteLength, duration_ms: u64| { div() - .h_2() + .h(rems_from_px(5.)) .w(width) .rounded_full() - .debug_bg_blue() .bg(cx.theme().colors().element_selected) .with_animation( id, Animation::new(Duration::from_millis(duration_ms)) .repeat() - .with_easing(pulsating_between(0.4, 0.8)), + .with_easing(pulsating_between(0.1, 0.8)), |label, delta| label.opacity(delta), ) }; + let skeleton_bar = |width: DefiniteLength| { + div().h(rems_from_px(5.)).w(width).rounded_full().bg(cx + .theme() + .colors() + .text_muted + .opacity(0.05)) + }; + let time = |time: SharedString| Label::new(time).size(LabelSize::XSmall).color(Color::Muted); let worktree = |worktree: SharedString| { h_flex() - .gap_1() + .gap_0p5() .child( Icon::new(IconName::GitWorktree) .color(Color::Muted) - .size(IconSize::XSmall), + .size(IconSize::Indicator), ) .child( Label::new(worktree) @@ -56,51 +63,53 @@ impl RenderOnce for ParallelAgentsIllustration { .alpha(0.5) }; - let agent = |id: &'static str, - icon: IconName, - width: DefiniteLength, - duration_ms: u64, - data: Vec| { + let agent = |title: SharedString, icon: IconName, selected: bool, data: Vec| { v_flex() - .p_2() + .when(selected, |this| { + this.bg(cx.theme().colors().element_active.opacity(0.2)) + }) + .p_1() .child( h_flex() .w_full() - .gap_2() + .gap_1() .child( icon_container() - .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)), + .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)), ) - .child(title_bar(id, width, duration_ms)), + .map(|this| { + if selected { + this.child( + Label::new(title) + .color(Color::Muted) + .size(LabelSize::XSmall), + ) + } else { + this.child(skeleton_bar(relative(0.7))) + } + }), ) .child( h_flex() .opacity(0.8) .w_full() - .gap_2() + .gap_1() .child(icon_container()) .children(data), ) }; let agents = v_flex() - .absolute() - .w(rems_from_px(380.)) - .top_8() - .rounded_t_sm() - .border_1() - .border_color(cx.theme().colors().border.opacity(0.5)) + .col_span(3) .bg(cx.theme().colors().elevated_surface_background) - .shadow_md() .child(agent( - "zed-agent-bar", + "Fix branch label".into(), IconName::ZedAgent, - relative(0.7), - 1800, + true, vec![ - worktree("happy-tree".into()).into_any_element(), + worktree("bug-fix".into()).into_any_element(), dot_separator().into_any_element(), - DiffStat::new("ds", 23, 13) + DiffStat::new("ds", 5, 2) .label_size(LabelSize::XSmall) .into_any_element(), dot_separator().into_any_element(), @@ -109,10 +118,9 @@ impl RenderOnce for ParallelAgentsIllustration { )) .child(Divider::horizontal()) .child(agent( - "claude-bar", + "Improve thread id".into(), IconName::AiClaude, - relative(0.85), - 2400, + false, vec![ DiffStat::new("ds", 120, 84) .label_size(LabelSize::XSmall) @@ -123,27 +131,142 @@ impl RenderOnce for ParallelAgentsIllustration { )) .child(Divider::horizontal()) .child(agent( - "openai-bar", + "Refactor archive view".into(), IconName::AiOpenAi, - relative(0.4), - 3100, + false, vec![ worktree("silent-forest".into()).into_any_element(), dot_separator().into_any_element(), time("37m".into()).into_any_element(), ], - )) - .child(Divider::horizontal()); + )); + + let thread_view = v_flex() + .col_span(3) + .h_full() + .flex_1() + .border_l_1() + .border_color(cx.theme().colors().border.opacity(0.5)) + .bg(cx.theme().colors().panel_background) + .child( + h_flex() + .px_1p5() + .py_0p5() + .w_full() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border.opacity(0.5)) + .child( + Label::new("Fix branch label") + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child( + Icon::new(IconName::Plus) + .size(IconSize::Indicator) + .color(Color::Muted), + ), + ) + .child( + div().p_1().child( + v_flex() + .px_1() + .py_1p5() + .gap_1() + .border_1() + .border_color(cx.theme().colors().border.opacity(0.5)) + .bg(cx.theme().colors().editor_background) + .rounded_sm() + .shadow_sm() + .child(skeleton_bar(relative(0.7))) + .child(skeleton_bar(relative(0.2))), + ), + ) + .child( + v_flex() + .p_2() + .gap_1() + .child(loading_bar("a", relative(0.55), 2200)) + .child(loading_bar("b", relative(0.75), 2000)) + .child(loading_bar("c", relative(0.25), 2400)), + ); + + let file_row = |indent: usize, is_folder: bool, bar_width: Rems| { + let indent_px = rems_from_px((indent as f32) * 4.0); + + h_flex() + .px_2() + .py_px() + .gap_1() + .pl(indent_px) + .child( + icon_container().child( + Icon::new(if is_folder { + IconName::FolderOpen + } else { + IconName::FileRust + }) + .size(IconSize::Indicator) + .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.2))), + ), + ) + .child( + div().h_1p5().w(bar_width).rounded_sm().bg(cx + .theme() + .colors() + .text + .opacity(if is_folder { 0.15 } else { 0.1 })), + ) + }; + + let project_panel = v_flex() + .col_span(1) + .h_full() + .flex_1() + .border_l_1() + .border_color(cx.theme().colors().border.opacity(0.5)) + .bg(cx.theme().colors().panel_background) + .child( + v_flex() + .child(file_row(0, true, rems_from_px(42.0))) + .child(file_row(1, true, rems_from_px(28.0))) + .child(file_row(2, false, rems_from_px(52.0))) + .child(file_row(2, false, rems_from_px(36.0))) + .child(file_row(2, false, rems_from_px(44.0))) + .child(file_row(1, true, rems_from_px(34.0))) + .child(file_row(2, false, rems_from_px(48.0))) + .child(file_row(2, true, rems_from_px(26.0))) + .child(file_row(3, false, rems_from_px(40.0))) + .child(file_row(3, false, rems_from_px(56.0))) + .child(file_row(1, false, rems_from_px(38.0))) + .child(file_row(0, true, rems_from_px(30.0))) + .child(file_row(1, false, rems_from_px(46.0))) + .child(file_row(1, false, rems_from_px(32.0))), + ); + + let workspace = div() + .absolute() + .top_8() + .grid() + .grid_cols(7) + .w(rems_from_px(380.)) + .rounded_t_sm() + .border_1() + .border_color(cx.theme().colors().border.opacity(0.5)) + .shadow_md() + .child(agents) + .child(thread_view) + .child(project_panel); h_flex() .relative() .h(rems_from_px(180.)) - .bg(cx.theme().colors().editor_background) + .bg(cx.theme().colors().editor_background.opacity(0.6)) .justify_center() .items_end() .rounded_t_md() .overflow_hidden() .bg(gpui::black().opacity(0.2)) - .child(agents) + .child(workspace) } } diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index e579a9bdb8713007736ede496444edb2d9125649..af538069638e678d78c4592805c1c822b7152d16 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -1,11 +1,7 @@ -use crate::{ - CommonAnimationExt, DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration, - Tooltip, prelude::*, -}; +use crate::{CommonAnimationExt, DiffStat, GradientFade, HighlightedLabel, Tooltip, prelude::*}; use gpui::{ - Animation, AnimationExt, AnyView, ClickEvent, Hsla, MouseButton, SharedString, - pulsating_between, + Animation, AnimationExt, ClickEvent, Hsla, MouseButton, SharedString, pulsating_between, }; use itertools::Itertools as _; use std::{path::PathBuf, sync::Arc, time::Duration}; @@ -26,13 +22,13 @@ pub enum WorktreeKind { Linked, } -#[derive(Clone)] +#[derive(Clone, Default)] pub struct ThreadItemWorktreeInfo { - pub name: SharedString, + pub worktree_name: Option, + pub branch_name: Option, pub full_path: SharedString, pub highlight_positions: Vec, pub kind: WorktreeKind, - pub branch_name: Option, } #[derive(IntoElement, RegisterComponent)] @@ -42,7 +38,6 @@ pub struct ThreadItem { icon_color: Option, icon_visible: bool, custom_icon_from_external_svg: Option, - icon_decoration: Option, title: SharedString, title_label_color: Option, title_generating: bool, @@ -60,10 +55,10 @@ pub struct ThreadItem { project_name: Option, worktrees: Vec, is_remote: bool, + archived: bool, on_click: Option>, on_hover: Box, action_slot: Option, - tooltip: Option AnyView + 'static>>, base_bg: Option, } @@ -75,7 +70,6 @@ impl ThreadItem { icon_color: None, icon_visible: true, custom_icon_from_external_svg: None, - icon_decoration: None, title: title.into(), title_label_color: None, title_generating: false, @@ -89,15 +83,14 @@ impl ThreadItem { rounded: false, added: None, removed: None, - project_paths: None, project_name: None, worktrees: Vec::new(), is_remote: false, + archived: false, on_click: None, on_hover: Box::new(|_, _, _| {}), action_slot: None, - tooltip: None, base_bg: None, } } @@ -122,11 +115,6 @@ impl ThreadItem { self } - pub fn icon_decoration(mut self, decoration: IconDecoration) -> Self { - self.icon_decoration = Some(decoration); - self - } - pub fn custom_icon_from_external_svg(mut self, svg: impl Into) -> Self { self.custom_icon_from_external_svg = Some(svg.into()); self @@ -197,6 +185,11 @@ impl ThreadItem { self } + pub fn archived(mut self, archived: bool) -> Self { + self.archived = archived; + self + } + pub fn hovered(mut self, hovered: bool) -> Self { self.hovered = hovered; self @@ -225,11 +218,6 @@ impl ThreadItem { self } - pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self { - self.tooltip = Some(Box::new(tooltip)); - self - } - pub fn base_bg(mut self, color: Hsla) -> Self { self.base_bg = Some(color); self @@ -263,11 +251,11 @@ impl RenderOnce for ThreadItem { .gradient_stop(0.75) .group_name("thread-item"); + let separator_color = Color::Custom(color.text_muted.opacity(0.4)); let dot_separator = || { Label::new("•") .size(LabelSize::Small) - .color(Color::Muted) - .alpha(0.5) + .color(separator_color) }; let icon_id = format!("icon-{}", self.id); @@ -289,35 +277,26 @@ impl RenderOnce for ThreadItem { Icon::new(self.icon).color(icon_color).size(IconSize::Small) }; - let (status_icon, icon_tooltip) = if self.status == AgentThreadStatus::Error { - ( - Some( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ), - Some("Thread has an Error"), + let status_icon = if self.status == AgentThreadStatus::Error { + Some( + Icon::new(IconName::Close) + .size(IconSize::Small) + .color(Color::Error), ) } else if self.status == AgentThreadStatus::WaitingForConfirmation { - ( - Some( - Icon::new(IconName::Warning) - .size(IconSize::XSmall) - .color(Color::Warning), - ), - Some("Thread is Waiting for Confirmation"), + Some( + Icon::new(IconName::Warning) + .size(IconSize::XSmall) + .color(Color::Warning), ) } else if self.notified { - ( - Some( - Icon::new(IconName::Circle) - .size(IconSize::Small) - .color(Color::Accent), - ), - Some("Thread's Generation is Complete"), + Some( + Icon::new(IconName::Circle) + .size(IconSize::Small) + .color(Color::Accent), ) } else { - (None, None) + None }; let icon = if self.status == AgentThreadStatus::Running { @@ -330,16 +309,7 @@ impl RenderOnce for ThreadItem { ) .into_any_element() } else if let Some(status_icon) = status_icon { - icon_container() - .child(status_icon) - .when_some(icon_tooltip, |icon, tooltip| { - icon.tooltip(Tooltip::text(tooltip)) - }) - .into_any_element() - } else if let Some(decoration) = self.icon_decoration { - icon_container() - .child(DecoratedIcon::new(agent_icon, Some(decoration))) - .into_any_element() + icon_container().child(status_icon).into_any_element() } else { icon_container().child(agent_icon).into_any_element() }; @@ -392,80 +362,25 @@ impl RenderOnce for ThreadItem { let has_timestamp = !self.timestamp.is_empty(); let timestamp = self.timestamp; - let linked_worktree_count = self + let show_tooltip = matches!( + self.status, + AgentThreadStatus::Error | AgentThreadStatus::WaitingForConfirmation + ); + + let linked_worktrees: Vec = self .worktrees - .iter() + .into_iter() .filter(|wt| wt.kind == WorktreeKind::Linked) - .count(); - - let worktree_tooltip_title = match (self.is_remote, linked_worktree_count > 1) { - (true, true) => "Thread Running in Remote Git Worktrees", - (true, false) => "Thread Running in a Remote Git Worktree", - (false, true) => "Thread Running in Local Git Worktrees", - (false, false) => "Thread Running in a Local Git Worktree", - }; + .filter(|wt| wt.worktree_name.is_some() || wt.branch_name.is_some()) + .collect(); - let mut worktree_labels: Vec = Vec::new(); - - let slash_color = Color::Custom(cx.theme().colors().text_muted.opacity(0.4)); - - for wt in self.worktrees { - match wt.kind { - WorktreeKind::Main => continue, - WorktreeKind::Linked => { - let chip_index = worktree_labels.len(); - let tooltip_title = worktree_tooltip_title; - let full_path = wt.full_path.clone(); - - let label = if wt.highlight_positions.is_empty() { - Label::new(wt.name) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate() - .into_any_element() - } else { - HighlightedLabel::new(wt.name, wt.highlight_positions) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate() - .into_any_element() - }; - - worktree_labels.push( - h_flex() - .id(format!("{}-worktree-{chip_index}", self.id.clone())) - .min_w_0() - .gap_0p5() - .child( - Icon::new(IconName::GitWorktree) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(label) - .when_some(wt.branch_name, |this, branch| { - this.child( - Label::new("/") - .size(LabelSize::Small) - .color(slash_color) - .flex_shrink_0(), - ) - .child( - Label::new(branch) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate(), - ) - }) - .tooltip(move |_, cx| { - Tooltip::with_meta(tooltip_title, None, full_path.clone(), cx) - }) - .into_any_element(), - ); - } - } - } + let has_worktree = !linked_worktrees.is_empty(); - let has_worktree = !worktree_labels.is_empty(); + let has_metadata = has_project_name + || has_project_paths + || has_worktree + || has_diff_stats + || has_timestamp; v_flex() .id(self.id.clone()) @@ -496,8 +411,7 @@ impl RenderOnce for ThreadItem { .flex_1() .gap_1p5() .child(icon) - .child(title_label) - .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)), + .child(title_label), ) .child(gradient_overlay) .when(self.hovered, |this| { @@ -520,78 +434,154 @@ impl RenderOnce for ThreadItem { }) }), ) - .when( - has_project_name - || has_project_paths - || has_worktree - || has_diff_stats - || has_timestamp, - |this| { - this.child( - h_flex() - .min_w_0() - .gap_1p5() - .child(icon_container()) // Icon Spacing - .when( - has_project_name || has_project_paths || has_worktree, - |this| { + .when(has_metadata, |this| { + this.child( + h_flex() + .gap_1p5() + .child(icon_container()) // Icon Spacing + .when(self.archived, |this| { + this.child( + Icon::new(IconName::Archive).size(IconSize::XSmall).color( + Color::Custom(cx.theme().colors().icon_muted.opacity(0.5)), + ), + ) + // .child(dot_separator()) + }) + .when( + has_project_name || has_project_paths || has_worktree, + |this| { + this.when_some(self.project_name, |this, name| { this.child( + Label::new(name).size(LabelSize::Small).color(Color::Muted), + ) + }) + .when( + has_project_name && (has_project_paths || has_worktree), + |this| this.child(dot_separator()), + ) + .when_some(project_paths, |this, paths| { + this.child( + Label::new(paths) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + .when(has_project_paths && has_worktree, |this| { + this.child(dot_separator()) + }) + .children( + linked_worktrees.into_iter().map(|wt| { + let worktree_label = wt.worktree_name.clone().map(|name| { + if wt.highlight_positions.is_empty() { + Label::new(name) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate() + .into_any_element() + } else { + HighlightedLabel::new( + name, + wt.highlight_positions.clone(), + ) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate() + .into_any_element() + } + }); + + // When only the branch is shown, lead with a branch icon; + // otherwise keep the worktree icon (which "covers" both the + // worktree and any accompanying branch). + let chip_icon = if wt.worktree_name.is_none() + && wt.branch_name.is_some() + { + IconName::GitBranch + } else { + IconName::GitWorktree + }; + + let branch_label = wt.branch_name.map(|branch| { + Label::new(branch) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate() + .into_any_element() + }); + + let show_separator = + worktree_label.is_some() && branch_label.is_some(); + h_flex() .min_w_0() - .flex_shrink() - .overflow_hidden() - .gap_1p5() - .when_some(self.project_name, |this, name| { - this.child( - Label::new(name) - .size(LabelSize::Small) - .color(Color::Muted), - ) - }) - .when( - has_project_name - && (has_project_paths || has_worktree), - |this| this.child(dot_separator()), + .gap_0p5() + .child( + Icon::new(chip_icon) + .size(IconSize::XSmall) + .color(Color::Muted), ) - .when_some(project_paths, |this, paths| { + .when_some(worktree_label, |this, label| { + this.child(label) + }) + .when(show_separator, |this| { this.child( - Label::new(paths) + Label::new("/") .size(LabelSize::Small) - .color(Color::Muted) - .into_any_element(), + .color(separator_color) + .flex_shrink_0(), ) }) - .when(has_project_paths && has_worktree, |this| { - this.child(dot_separator()) + .when_some(branch_label, |this, label| { + this.child(label) }) - .children(worktree_labels), - ) - }, - ) - .when( - (has_project_name || has_project_paths || has_worktree) - && (has_diff_stats || has_timestamp), - |this| this.child(dot_separator()), - ) - .when(has_diff_stats, |this| { - this.child( - DiffStat::new(diff_stat_id, added_count, removed_count) - .tooltip("Unreviewed Changes"), + }), ) - }) - .when(has_diff_stats && has_timestamp, |this| { - this.child(dot_separator()) - }) - .when(has_timestamp, |this| { - this.child( - Label::new(timestamp.clone()) - .size(LabelSize::Small) - .color(Color::Muted), - ) - }), - ) - }, - ) + }, + ) + .when( + (has_project_name || has_project_paths || has_worktree) + && (has_diff_stats || has_timestamp), + |this| this.child(dot_separator()), + ) + .when(has_diff_stats, |this| { + this.child(DiffStat::new(diff_stat_id, added_count, removed_count)) + }) + .when(has_diff_stats && has_timestamp, |this| { + this.child(dot_separator()) + }) + .when(has_timestamp, |this| { + this.child( + Label::new(timestamp.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + }) + .when(show_tooltip, |this| { + let status = self.status; + this.tooltip(Tooltip::element(move |_, _| match status { + AgentThreadStatus::Error => h_flex() + .gap_1() + .child( + Icon::new(IconName::Close) + .size(IconSize::Small) + .color(Color::Error), + ) + .child(Label::new("Thread has an Error")) + .into_any_element(), + AgentThreadStatus::WaitingForConfirmation => h_flex() + .gap_1() + .child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .child(Label::new("Waiting for Confirmation")) + .into_any_element(), + _ => gpui::Empty.into_any_element(), + })) + }) .when_some(self.on_click, |this, on_click| this.on_click(on_click)) } } @@ -617,7 +607,7 @@ impl Component for ThreadItem { let thread_item_examples = vec![ single_example( - "Default (minutes)", + "Default", container() .child( ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings") @@ -626,16 +616,6 @@ impl Component for ThreadItem { ) .into_any_element(), ), - single_example( - "Notified (weeks)", - container() - .child( - ThreadItem::new("ti-2", "Refine thread view scrolling behavior") - .timestamp("1w") - .notified(true), - ) - .into_any_element(), - ), single_example( "Waiting for Confirmation", container() @@ -675,7 +655,7 @@ impl Component for ThreadItem { .icon(IconName::AiClaude) .timestamp("2w") .worktrees(vec![ThreadItemWorktreeInfo { - name: "link-agent-panel".into(), + worktree_name: Some("link-agent-panel".into()), full_path: "link-agent-panel".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Linked, @@ -685,7 +665,7 @@ impl Component for ThreadItem { .into_any_element(), ), single_example( - "With Changes (months)", + "With Changes", container() .child( ThreadItem::new("ti-5", "Managing user and project settings interactions") @@ -703,7 +683,7 @@ impl Component for ThreadItem { ThreadItem::new("ti-5b", "Full metadata example") .icon(IconName::AiClaude) .worktrees(vec![ThreadItemWorktreeInfo { - name: "my-project".into(), + worktree_name: Some("my-project".into()), full_path: "my-project".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Linked, @@ -722,7 +702,7 @@ impl Component for ThreadItem { ThreadItem::new("ti-5c", "Full metadata with branch") .icon(IconName::AiClaude) .worktrees(vec![ThreadItemWorktreeInfo { - name: "my-project".into(), + worktree_name: Some("my-project".into()), full_path: "/worktrees/my-project/zed".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Linked, @@ -741,7 +721,7 @@ impl Component for ThreadItem { ThreadItem::new("ti-5d", "Metadata overflow with long branch name") .icon(IconName::AiClaude) .worktrees(vec![ThreadItemWorktreeInfo { - name: "my-project".into(), + worktree_name: Some("my-project".into()), full_path: "/worktrees/my-project/zed".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Linked, @@ -760,7 +740,7 @@ impl Component for ThreadItem { ThreadItem::new("ti-5e", "Main worktree branch with diff stats") .icon(IconName::ZedAgent) .worktrees(vec![ThreadItemWorktreeInfo { - name: "zed".into(), + worktree_name: Some("zed".into()), full_path: "/projects/zed".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Main, @@ -773,80 +753,176 @@ impl Component for ThreadItem { .into_any_element(), ), single_example( - "Selected Item", + "Long Worktree Name (truncation)", container() .child( - ThreadItem::new("ti-6", "Refine textarea interaction behavior") - .icon(IconName::AiGemini) - .timestamp("45m") - .selected(true), + ThreadItem::new("ti-5f", "Thread with a very long worktree name") + .icon(IconName::AiClaude) + .worktrees(vec![ThreadItemWorktreeInfo { + worktree_name: Some( + "very-long-worktree-name-that-should-truncate".into(), + ), + full_path: "/worktrees/very-long-worktree-name/zed".into(), + highlight_positions: Vec::new(), + kind: WorktreeKind::Linked, + branch_name: None, + }]) + .timestamp("1h"), ) .into_any_element(), ), single_example( - "Focused Item (Keyboard Selection)", + "Worktree with Search Highlights", container() .child( - ThreadItem::new("ti-7", "Implement keyboard navigation") + ThreadItem::new("ti-5g", "Filtered thread with highlighted worktree") .icon(IconName::AiClaude) - .timestamp("12h") - .focused(true), + .worktrees(vec![ThreadItemWorktreeInfo { + worktree_name: Some("jade-glen".into()), + full_path: "/worktrees/jade-glen/zed".into(), + highlight_positions: vec![0, 1, 2, 3], + kind: WorktreeKind::Linked, + branch_name: Some("fix-scrolling".into()), + }]) + .timestamp("3d"), ) .into_any_element(), ), single_example( - "Selected + Focused", + "Multiple Worktrees (no branches)", container() .child( - ThreadItem::new("ti-8", "Active and keyboard-focused thread") - .icon(IconName::AiGemini) - .timestamp("2mo") - .selected(true) - .focused(true), + ThreadItem::new("ti-5h", "Thread spanning multiple worktrees") + .icon(IconName::AiClaude) + .worktrees(vec![ + ThreadItemWorktreeInfo { + worktree_name: Some("jade-glen".into()), + full_path: "/worktrees/jade-glen/zed".into(), + highlight_positions: Vec::new(), + kind: WorktreeKind::Linked, + branch_name: None, + }, + ThreadItemWorktreeInfo { + worktree_name: Some("fawn-otter".into()), + full_path: "/worktrees/fawn-otter/zed-slides".into(), + highlight_positions: Vec::new(), + kind: WorktreeKind::Linked, + branch_name: None, + }, + ]) + .timestamp("2h"), ) .into_any_element(), ), single_example( - "Hovered with Action Slot", + "Multiple Worktrees with Branches", container() .child( - ThreadItem::new("ti-9", "Hover to see action button") - .icon(IconName::AiClaude) - .timestamp("6h") - .hovered(true) - .action_slot( - IconButton::new("delete", IconName::Trash) - .icon_size(IconSize::Small) - .icon_color(Color::Muted), - ), + ThreadItem::new("ti-5i", "Multi-root with per-worktree branches") + .icon(IconName::ZedAgent) + .worktrees(vec![ + ThreadItemWorktreeInfo { + worktree_name: Some("jade-glen".into()), + full_path: "/worktrees/jade-glen/zed".into(), + highlight_positions: Vec::new(), + kind: WorktreeKind::Linked, + branch_name: Some("fix".into()), + }, + ThreadItemWorktreeInfo { + worktree_name: Some("fawn-otter".into()), + full_path: "/worktrees/fawn-otter/zed-slides".into(), + highlight_positions: Vec::new(), + kind: WorktreeKind::Linked, + branch_name: Some("main".into()), + }, + ]) + .timestamp("15m"), ) .into_any_element(), ), single_example( - "Search Highlight", + "Project Name + Worktree + Branch", container() .child( - ThreadItem::new("ti-10", "Implement keyboard navigation") + ThreadItem::new("ti-5j", "Thread with project context") .icon(IconName::AiClaude) - .timestamp("4w") - .highlight_positions(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), + .project_name("my-remote-server") + .worktrees(vec![ThreadItemWorktreeInfo { + worktree_name: Some("jade-glen".into()), + full_path: "/worktrees/jade-glen/zed".into(), + highlight_positions: Vec::new(), + kind: WorktreeKind::Linked, + branch_name: Some("feature-branch".into()), + }]) + .timestamp("1d"), ) .into_any_element(), ), single_example( - "Worktree Search Highlight", + "Project Paths + Worktree (archive view)", container() .child( - ThreadItem::new("ti-11", "Search in worktree name") + ThreadItem::new("ti-5k", "Archived thread with folder paths") .icon(IconName::AiClaude) - .timestamp("3mo") + .project_paths(Arc::from(vec![ + PathBuf::from("/projects/zed"), + PathBuf::from("/projects/zed-slides"), + ])) .worktrees(vec![ThreadItemWorktreeInfo { - name: "my-project-name".into(), - full_path: "my-project-name".into(), - highlight_positions: vec![3, 4, 5, 6, 7, 8, 9, 10, 11], + worktree_name: Some("jade-glen".into()), + full_path: "/worktrees/jade-glen/zed".into(), + highlight_positions: Vec::new(), kind: WorktreeKind::Linked, - branch_name: None, - }]), + branch_name: Some("feature".into()), + }]) + .timestamp("2mo"), + ) + .into_any_element(), + ), + single_example( + "All Metadata", + container() + .child( + ThreadItem::new("ti-5l", "Thread with every metadata field populated") + .icon(IconName::ZedAgent) + .project_name("remote-dev") + .worktrees(vec![ThreadItemWorktreeInfo { + worktree_name: Some("my-worktree".into()), + full_path: "/worktrees/my-worktree/zed".into(), + highlight_positions: Vec::new(), + kind: WorktreeKind::Linked, + branch_name: Some("main".into()), + }]) + .added(15) + .removed(4) + .timestamp("8h"), + ) + .into_any_element(), + ), + single_example( + "Focused Item (Keyboard Selection)", + container() + .child( + ThreadItem::new("ti-7", "Implement keyboard navigation") + .icon(IconName::AiClaude) + .timestamp("12h") + .focused(true), + ) + .into_any_element(), + ), + single_example( + "Action Slot", + container() + .child( + ThreadItem::new("ti-9", "Hover to see action button") + .icon(IconName::AiClaude) + .timestamp("6h") + .hovered(true) + .action_slot( + IconButton::new("delete", IconName::Trash) + .icon_size(IconSize::Small) + .icon_color(Color::Muted), + ), ) .into_any_element(), ), diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 2fcfd73b93d7c47018819fd9ec4426e9f1b38147..006892effc8676756a10988bfdbff9b60673810c 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -1,6 +1,6 @@ use crate::{ - IconButtonShape, KeyBinding, List, ListItem, ListSeparator, ListSubHeader, Tooltip, prelude::*, - utils::WithRemSize, + ButtonCommon, ButtonStyle, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, + ListSubHeader, Tooltip, prelude::*, utils::WithRemSize, }; use gpui::{ Action, AnyElement, App, Bounds, Corner, DismissEvent, Entity, EventEmitter, FocusHandle, @@ -680,6 +680,17 @@ impl ContextMenu { self } + pub fn selectable(mut self, selectable: bool) -> Self { + if let Some(ContextMenuItem::CustomEntry { + selectable: entry_selectable, + .. + }) = self.items.last_mut() + { + *entry_selectable = selectable; + } + self + } + pub fn label(mut self, label: impl Into) -> Self { self.items.push(ContextMenuItem::Label(label.into())); self @@ -1968,6 +1979,7 @@ impl ContextMenu { el.end_slot({ let icon_button = IconButton::new("end-slot-icon", *icon) .shape(IconButtonShape::Square) + .style(ButtonStyle::Subtle) .tooltip({ let action_context = self.action_context.clone(); let title = title.clone(); diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index a0e880af5e7029cb08670d151647489db1d05f4f..d9a1553b7da442090668d6dc06c91431ede3f4ad 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -113,6 +113,7 @@ impl From for Icon { } /// The source of an icon. +#[derive(Clone)] enum IconSource { /// An SVG embedded in the Zed binary. Embedded(SharedString), @@ -126,7 +127,7 @@ enum IconSource { ExternalSvg(SharedString), } -#[derive(IntoElement, RegisterComponent)] +#[derive(Clone, IntoElement, RegisterComponent)] pub struct Icon { source: IconSource, color: Color, diff --git a/crates/ui/src/components/icon/icon_decoration.rs b/crates/ui/src/components/icon/icon_decoration.rs index 4515b8fa44f50d21ba5ca4274689324fbbde2bb8..1c40890c4343415d88ea735346868082637f155c 100644 --- a/crates/ui/src/components/icon/icon_decoration.rs +++ b/crates/ui/src/components/icon/icon_decoration.rs @@ -18,8 +18,6 @@ pub enum KnockoutIconName { DotBg, TriangleFg, TriangleBg, - ArchiveFg, - ArchiveBg, } impl KnockoutIconName { @@ -35,7 +33,6 @@ pub enum IconDecorationKind { X, Dot, Triangle, - Archive, } impl IconDecorationKind { @@ -44,7 +41,6 @@ impl IconDecorationKind { Self::X => KnockoutIconName::XFg, Self::Dot => KnockoutIconName::DotFg, Self::Triangle => KnockoutIconName::TriangleFg, - Self::Archive => KnockoutIconName::ArchiveFg, } } @@ -53,7 +49,6 @@ impl IconDecorationKind { Self::X => KnockoutIconName::XBg, Self::Dot => KnockoutIconName::DotBg, Self::Triangle => KnockoutIconName::TriangleBg, - Self::Archive => KnockoutIconName::ArchiveBg, } } } diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 81e46b513b4b902aeebb1a912261826c0c4f30dc..ca8584fb1eb6dc2db6a08a528526ac76c37e860e 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -338,6 +338,15 @@ pub struct PanelButtons { pub(crate) const PANEL_SIZE_STATE_KEY: &str = "dock_panel_size"; +fn panel_uses_flexible_width( + position: DockPosition, + panel: &dyn PanelHandle, + window: &Window, + cx: &App, +) -> bool { + position.axis() == Axis::Horizontal && panel.has_flexible_size(window, cx) +} + fn resize_panel_entry( position: DockPosition, entry: &mut PanelEntry, @@ -347,8 +356,8 @@ fn resize_panel_entry( cx: &mut App, ) -> (&'static str, PanelSizeState) { let size = size.map(|size| size.max(RESIZE_HANDLE_SIZE).round()); - let use_flex = entry.panel.has_flexible_size(window, cx) && position.axis() == Axis::Horizontal; - if use_flex { + let uses_flexible_width = panel_uses_flexible_width(position, entry.panel.as_ref(), window, cx); + if uses_flexible_width { entry.size_state.flex = flex; } else { entry.size_state.size = size; @@ -960,11 +969,31 @@ impl Dock { window: &mut Window, cx: &mut Context, ) { - let size_states_to_persist: Vec<_> = self - .panel_entries - .iter_mut() - .map(|entry| resize_panel_entry(self.position, entry, size, flex, window, cx)) - .collect(); + let Some(active_panel_index) = self.active_panel_index else { + return; + }; + + let active_panel_uses_flexible_width = { + let Some(active_entry) = self.panel_entries.get(active_panel_index) else { + return; + }; + panel_uses_flexible_width(self.position, active_entry.panel.as_ref(), window, cx) + }; + let mut size_states_to_persist = Vec::new(); + for entry in &mut self.panel_entries { + if panel_uses_flexible_width(self.position, entry.panel.as_ref(), window, cx) + == active_panel_uses_flexible_width + { + size_states_to_persist.push(resize_panel_entry( + self.position, + entry, + size, + flex, + window, + cx, + )); + } + } let workspace = self.workspace.clone(); cx.defer(move |cx| { diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index d359a44e4474b1d1caebfd6d357588a61cbfd670..8895df67372676bd5e58417bd902007ee9422d2b 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -3,8 +3,8 @@ use fs::Fs; use gpui::{ AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, - ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId, - actions, deferred, px, + ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, WeakEntity, Window, + WindowId, actions, deferred, px, }; pub use project::ProjectGroupKey; use project::{DisableAiSettings, Project}; @@ -50,10 +50,6 @@ actions!( NextThread, /// Activates the previous thread in sidebar order. PreviousThread, - /// Expands the thread list for the current project to show more threads. - ShowMoreThreads, - /// Collapses the thread list for the current project to show fewer threads. - ShowFewerThreads, /// Creates a new thread in the current workspace. NewThread, /// Moves the active project to a new window. @@ -272,20 +268,18 @@ pub struct ProjectGroup { pub key: ProjectGroupKey, pub workspaces: Vec>, pub expanded: bool, - pub visible_thread_count: Option, } pub struct SerializedProjectGroupState { pub key: ProjectGroupKey, pub expanded: bool, - pub visible_thread_count: Option, } #[derive(Clone)] pub struct ProjectGroupState { pub key: ProjectGroupKey, pub expanded: bool, - pub visible_thread_count: Option, + pub last_active_workspace: Option>, } pub struct MultiWorkspace { @@ -641,7 +635,7 @@ impl MultiWorkspace { ProjectGroupState { key, expanded: true, - visible_thread_count: None, + last_active_workspace: None, }, ); } @@ -720,6 +714,26 @@ impl MultiWorkspace { cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); } + pub(crate) fn activate_provisional_workspace( + &mut self, + workspace: Entity, + provisional_key: ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + if workspace != self.active_workspace { + self.register_workspace(&workspace, window, cx); + } + + self.ensure_project_group_state(provisional_key); + if !self.is_workspace_retained(&workspace) { + self.retained_workspaces.push(workspace.clone()); + } + + self.activate(workspace.clone(), window, cx); + cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); + } + fn register_workspace( &mut self, workspace: &Entity, @@ -757,12 +771,7 @@ impl MultiWorkspace { _cx: &mut Context, ) { let mut restored: Vec = Vec::new(); - for SerializedProjectGroupState { - key, - expanded, - visible_thread_count, - } in groups - { + for SerializedProjectGroupState { key, expanded } in groups { if key.path_list().paths().is_empty() { continue; } @@ -772,7 +781,7 @@ impl MultiWorkspace { restored.push(ProjectGroupState { key, expanded, - visible_thread_count, + last_active_workspace: None, }); } for existing in std::mem::take(&mut self.project_groups) { @@ -802,7 +811,6 @@ impl MultiWorkspace { .cloned() .collect(), expanded: group.expanded, - visible_thread_count: group.visible_thread_count, }) .collect() } @@ -811,6 +819,17 @@ impl MultiWorkspace { self.derived_project_groups(cx) } + pub fn last_active_workspace_for_group( + &self, + key: &ProjectGroupKey, + cx: &App, + ) -> Option> { + let group = self.project_groups.iter().find(|g| g.key == *key)?; + let weak = group.last_active_workspace.as_ref()?; + let workspace = weak.upgrade()?; + (workspace.read(cx).project_group_key(cx) == *key).then_some(workspace) + } + pub fn group_state_by_key(&self, key: &ProjectGroupKey) -> Option<&ProjectGroupState> { self.project_groups.iter().find(|group| group.key == *key) } @@ -830,12 +849,6 @@ impl MultiWorkspace { } } - pub fn set_all_groups_visible_thread_count(&mut self, count: Option) { - for group in &mut self.project_groups { - group.visible_thread_count = count; - } - } - pub fn workspaces_for_project_group( &self, key: &ProjectGroupKey, @@ -856,6 +869,105 @@ impl MultiWorkspace { }) } + pub fn close_workspace( + &mut self, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let group_key = workspace.read(cx).project_group_key(cx); + let excluded_workspace = workspace.clone(); + + self.remove( + [workspace.clone()], + move |this, window, cx| { + if let Some(workspace) = this + .workspaces_for_project_group(&group_key, cx) + .unwrap_or_default() + .into_iter() + .find(|candidate| candidate != &excluded_workspace) + { + return Task::ready(Ok(workspace)); + } + + let current_group_index = this + .project_groups + .iter() + .position(|group| group.key == group_key); + + if let Some(current_group_index) = current_group_index { + for distance in 1..this.project_groups.len() { + for neighboring_index in [ + current_group_index.checked_add(distance), + current_group_index.checked_sub(distance), + ] + .into_iter() + .flatten() + { + let Some(neighboring_group) = + this.project_groups.get(neighboring_index) + else { + continue; + }; + + if let Some(workspace) = this + .last_active_workspace_for_group(&neighboring_group.key, cx) + .or_else(|| { + this.workspaces_for_project_group(&neighboring_group.key, cx) + .unwrap_or_default() + .into_iter() + .find(|candidate| candidate != &excluded_workspace) + }) + { + return Task::ready(Ok(workspace)); + } + } + } + } + + let neighboring_group_key = current_group_index.and_then(|index| { + this.project_groups + .get(index + 1) + .or_else(|| { + index + .checked_sub(1) + .and_then(|previous| this.project_groups.get(previous)) + }) + .map(|group| group.key.clone()) + }); + + if let Some(neighboring_group_key) = neighboring_group_key { + return this.find_or_create_local_workspace( + neighboring_group_key.path_list().clone(), + Some(neighboring_group_key), + std::slice::from_ref(&excluded_workspace), + None, + OpenMode::Activate, + window, + cx, + ); + } + + let app_state = this.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)); + Task::ready(Ok(new_workspace)) + }, + window, + cx, + ) + } + pub fn remove_project_group( &mut self, group_key: &ProjectGroupKey, @@ -1066,13 +1178,40 @@ impl MultiWorkspace { ) }); + let effective_paths_vec = + if let Some(project_group) = provisional_project_group_key.as_ref() { + let resolve_tasks = cx.update(|cx| { + let project = new_project.read(cx); + paths_vec + .iter() + .map(|path| project.resolve_abs_path(&path.to_string_lossy(), cx)) + .collect::>() + }); + let resolved = futures::future::join_all(resolve_tasks).await; + // `resolve_abs_path` returns `None` for both "definitely + // absent" and transport errors (it swallows the error via + // `log_err`). This is a weaker guarantee than the local + // `Ok(None)` check, but it matches how the rest of the + // codebase consumes this API. + let all_paths_missing = + !paths_vec.is_empty() && resolved.iter().all(|resolved| resolved.is_none()); + + if all_paths_missing { + project_group.path_list().paths().to_vec() + } else { + paths_vec + } + } else { + paths_vec + }; + let window_handle = window_handle.ok_or_else(|| anyhow::anyhow!("Window is not a MultiWorkspace"))?; open_remote_project_with_existing_connection( connection_options, new_project, - paths_vec, + effective_paths_vec, app_state, window_handle, provisional_project_group_key, @@ -1238,6 +1377,11 @@ impl MultiWorkspace { self.active_workspace = workspace; + let active_key = self.active_workspace.read(cx).project_group_key(cx); + if let Some(group) = self.project_groups.iter_mut().find(|g| g.key == active_key) { + group.last_active_workspace = Some(self.active_workspace.downgrade()); + } + if !self.sidebar_open && !old_active_was_retained { self.detach_workspace(&old_active_workspace, cx); } @@ -1288,6 +1432,17 @@ impl MultiWorkspace { fn detach_workspace(&mut self, workspace: &Entity, cx: &mut Context) { self.retained_workspaces .retain(|retained| retained != workspace); + for group in &mut self.project_groups { + if group + .last_active_workspace + .as_ref() + .and_then(WeakEntity::upgrade) + .as_ref() + == Some(workspace) + { + group.last_active_workspace = None; + } + } cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(workspace.entity_id())); workspace.update(cx, |workspace, _cx| { workspace.session_id.take(); @@ -1329,7 +1484,6 @@ impl MultiWorkspace { crate::persistence::model::SerializedProjectGroup::from_group( &group.key, group.expanded, - group.visible_thread_count, ) }) .collect::>(), @@ -1471,7 +1625,6 @@ impl MultiWorkspace { #[cfg(any(test, feature = "test-support"))] pub fn test_expand_all_groups(&mut self) { self.set_all_groups_expanded(true); - self.set_all_groups_visible_thread_count(Some(10_000)); } #[cfg(any(test, feature = "test-support"))] @@ -1527,7 +1680,7 @@ impl MultiWorkspace { self.project_groups.push(ProjectGroupState { key: group.key, expanded: group.expanded, - visible_thread_count: group.visible_thread_count, + last_active_workspace: None, }); } @@ -1647,13 +1800,13 @@ impl MultiWorkspace { let fallback_task = removing_active.then(|| fallback_workspace(self, window, cx)); cx.spawn_in(window, async move |this, cx| { - // Prompt each workspace for unsaved changes. If any workspace - // has dirty buffers, save_all_internal will emit Activate to - // bring it into view before showing the save dialog. + // Run the standard workspace close lifecycle for every workspace + // being removed from this window. This handles save prompting and + // session cleanup consistently with other replace-in-window flows. for workspace in &workspaces { let should_continue = workspace .update_in(cx, |workspace, window, cx| { - workspace.save_all_internal(crate::SaveIntent::Close, window, cx) + workspace.prepare_to_close(CloseIntent::ReplaceWindow, window, cx) })? .await?; diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index dd86e210f9643a70acc360d8a0820c9964172f2a..e5ee718a52876546768f1ee6482e224b4134cf52 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -524,6 +524,98 @@ async fn test_find_or_create_local_workspace_reuses_active_workspace_after_sideb }); } +#[gpui::test] +async fn test_close_workspace_prefers_already_loaded_neighboring_workspace( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root_a", json!({ "file_a.txt": "" })).await; + fs.insert_tree("/root_b", json!({ "file_b.txt": "" })).await; + fs.insert_tree("/root_c", json!({ "file_c.txt": "" })).await; + let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await; + let project_b = Project::test(fs.clone(), ["/root_b".as_ref()], cx).await; + let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx)); + let project_c = Project::test(fs, ["/root_c".as_ref()], cx).await; + let project_c_key = project_c.read_with(cx, |project, cx| project.project_group_key(cx)); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.open_sidebar(cx); + }); + cx.run_until_parked(); + + let workspace_a = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }); + let workspace_b = multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project_b, window, cx) + }); + + multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.activate(workspace_a.clone(), window, cx); + multi_workspace.test_add_project_group(ProjectGroup { + key: project_c_key.clone(), + workspaces: Vec::new(), + expanded: true, + }); + }); + + multi_workspace.read_with(cx, |multi_workspace, _cx| { + let keys = multi_workspace.project_group_keys(); + assert_eq!( + keys.len(), + 3, + "expected three project groups in the test setup" + ); + assert_eq!(keys[0], project_b_key); + assert_eq!( + keys[1], + workspace_a.read_with(cx, |workspace, cx| { workspace.project_group_key(cx) }) + ); + assert_eq!(keys[2], project_c_key); + assert_eq!( + multi_workspace.workspace().entity_id(), + workspace_a.entity_id(), + "workspace A should be active before closing" + ); + }); + + let closed = multi_workspace + .update_in(cx, |multi_workspace, window, cx| { + multi_workspace.close_workspace(&workspace_a, window, cx) + }) + .await + .expect("closing the active workspace should succeed"); + + assert!( + closed, + "close_workspace should report that it removed a workspace" + ); + + multi_workspace.read_with(cx, |multi_workspace, cx| { + assert_eq!( + multi_workspace.workspace().entity_id(), + workspace_b.entity_id(), + "closing workspace A should activate the already-loaded workspace B instead of opening group C" + ); + assert_eq!( + multi_workspace.workspaces().count(), + 1, + "only workspace B should remain loaded after closing workspace A" + ); + assert!( + multi_workspace + .workspaces_for_project_group(&project_c_key, cx) + .unwrap_or_default() + .is_empty(), + "the unloaded neighboring group C should remain unopened" + ); + }); +} + #[gpui::test] async fn test_switching_projects_with_sidebar_closed_detaches_old_active_workspace( cx: &mut TestAppContext, @@ -575,44 +667,52 @@ async fn test_switching_projects_with_sidebar_closed_detaches_old_active_workspa } #[gpui::test] -async fn test_remote_worktree_without_git_updates_project_group(cx: &mut TestAppContext) { +async fn test_remote_project_root_dir_changes_update_groups(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/local", json!({ "file.txt": "" })).await; - let project = Project::test(fs.clone(), ["/local".as_ref()], cx).await; + fs.insert_tree("/root_a", json!({ "file.txt": "" })).await; + fs.insert_tree("/local_b", json!({ "file.txt": "" })).await; + let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await; + let project_b = Project::test(fs.clone(), ["/local_b".as_ref()], cx).await; let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); multi_workspace.update(cx, |mw, cx| { mw.open_sidebar(cx); }); cx.run_until_parked(); - let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx)); + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx)); + let key = workspace.read(cx).project_group_key(cx); + mw.activate_provisional_workspace(workspace.clone(), key, window, cx); + workspace + }); + cx.run_until_parked(); + + multi_workspace.read_with(cx, |mw, _cx| { + assert_eq!( + mw.workspace().entity_id(), + workspace_b.entity_id(), + "registered workspace should become active" + ); + }); + + let initial_key = project_b.read_with(cx, |p, cx| p.project_group_key(cx)); multi_workspace.read_with(cx, |mw, _cx| { let keys = mw.project_group_keys(); - assert_eq!(keys.len(), 1); - assert_eq!(keys[0], initial_key); + assert!( + keys.contains(&initial_key), + "project groups should contain the initial key for the registered workspace" + ); }); - // Add a remote worktree without git repo info. - let remote_worktree = project.update(cx, |project, cx| { + let remote_worktree = project_b.update(cx, |project, cx| { project.add_test_remote_worktree("/remote/project", cx) }); cx.run_until_parked(); - // The remote worktree has no entries yet, so project_group_key should - // still exclude it. - let key_after_add = project.read_with(cx, |p, cx| p.project_group_key(cx)); - assert_eq!( - key_after_add, initial_key, - "remote worktree without entries should not affect the group key" - ); - - // Send an UpdateWorktree to the remote worktree with entries but no repo. - // This triggers UpdatedRootRepoCommonDir on the first update (the fix), - // which propagates through WorktreeStore → Project → MultiWorkspace. let worktree_id = remote_worktree.read_with(cx, |wt, _| wt.id().to_proto()); remote_worktree.update(cx, |worktree, _cx| { worktree @@ -649,17 +749,21 @@ async fn test_remote_worktree_without_git_updates_project_group(cx: &mut TestApp }); cx.run_until_parked(); - let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx)); + let updated_key = project_b.read_with(cx, |p, cx| p.project_group_key(cx)); assert_ne!( initial_key, updated_key, - "adding a remote worktree should change the project group key" + "remote worktree update should change the project group key" ); multi_workspace.read_with(cx, |mw, _cx| { let keys = mw.project_group_keys(); assert!( keys.contains(&updated_key), - "should contain the updated key; got {keys:?}" + "project groups should contain the updated key after remote change; got {keys:?}" + ); + assert!( + !keys.contains(&initial_key), + "project groups should no longer contain the stale initial key; got {keys:?}" ); }); } diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index c5f78eef6c4a7403589cb4e947326f9fe87ec610..0a5c1b61bdc919a6a5f56da6833b05e879e29894 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -98,8 +98,8 @@ impl PaneGroup { } } - pub fn width_fraction_for_pane(&self, pane: &Entity) -> Option { - self.root.width_fraction_for_pane(pane) + pub fn full_height_column_count(&self) -> usize { + self.root.full_height_column_count() } pub fn pane_at_pixel_position(&self, coordinate: Point) -> Option<&Entity> { @@ -307,10 +307,10 @@ impl Member { } } - fn width_fraction_for_pane(&self, pane: &Entity) -> Option { + fn full_height_column_count(&self) -> usize { match self { - Member::Pane(found) => (found == pane).then_some(1.0), - Member::Axis(axis) => axis.width_fraction_for_pane(pane), + Member::Pane(_) => 1, + Member::Axis(axis) => axis.full_height_column_count(), } } } @@ -901,38 +901,21 @@ impl PaneAxis { None } - fn width_fraction_for_pane(&self, pane: &Entity) -> Option { - let flexes = self.flexes.lock(); - let total_flex = flexes.iter().copied().sum::(); - - for (index, member) in self.members.iter().enumerate() { - let child_fraction = if total_flex > 0.0 { - flexes[index] / total_flex - } else { - 1.0 / self.members.len() as f32 - }; - - match member { - Member::Pane(found) => { - if found == pane { - return Some(match self.axis { - Axis::Horizontal => child_fraction, - Axis::Vertical => 1.0, - }); - } - } - Member::Axis(axis) => { - if let Some(descendant_fraction) = axis.width_fraction_for_pane(pane) { - return Some(match self.axis { - Axis::Horizontal => child_fraction * descendant_fraction, - Axis::Vertical => descendant_fraction, - }); - } - } - } + fn full_height_column_count(&self) -> usize { + match self.axis { + Axis::Horizontal => self + .members + .iter() + .map(Member::full_height_column_count) + .sum::() + .max(1), + Axis::Vertical => self + .members + .iter() + .map(Member::full_height_column_count) + .max() + .unwrap_or(1), } - - None } fn render( diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 23970f52427cfda09aab4c149261459b0484751a..c1bfcefc17a4c2735acaebf1fbae3a6b5852ce90 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -66,8 +66,6 @@ pub struct SerializedProjectGroup { pub(crate) location: SerializedWorkspaceLocation, #[serde(default = "default_expanded")] pub expanded: bool, - #[serde(default)] - pub visible_thread_count: Option, } fn default_expanded() -> bool { @@ -75,11 +73,7 @@ fn default_expanded() -> bool { } impl SerializedProjectGroup { - pub fn from_group( - key: &ProjectGroupKey, - expanded: bool, - visible_thread_count: Option, - ) -> Self { + pub fn from_group(key: &ProjectGroupKey, expanded: bool) -> Self { Self { path_list: key.path_list().serialize(), location: match key.host() { @@ -87,7 +81,6 @@ impl SerializedProjectGroup { None => SerializedWorkspaceLocation::Local, }, expanded, - visible_thread_count, } } @@ -100,7 +93,6 @@ impl SerializedProjectGroup { SerializedProjectGroupState { key: ProjectGroupKey::new(host, path_list), expanded: self.expanded, - visible_thread_count: self.visible_thread_count, } } } diff --git a/crates/workspace/src/toast_layer.rs b/crates/workspace/src/toast_layer.rs index 5979c376f6542b0429eadd40622efa1f5ea56325..47759645e5b4b3051ce90efbb60f049189ba4c26 100644 --- a/crates/workspace/src/toast_layer.rs +++ b/crates/workspace/src/toast_layer.rs @@ -44,6 +44,10 @@ pub fn init(cx: &mut App) { pub trait ToastView: ManagedView { fn action(&self) -> Option; + + fn auto_dismiss(&self) -> bool { + true + } } #[derive(Clone)] @@ -131,6 +135,7 @@ impl ToastLayer { V: ToastView, { let action = new_toast.read(cx).action(); + let auto_dismiss = new_toast.read(cx).auto_dismiss(); let focus_handle = cx.focus_handle(); self.active_toast = Some(ActiveToast { @@ -143,7 +148,9 @@ impl ToastLayer { focus_handle, }); - self.start_dismiss_timer(DEFAULT_TOAST_DURATION, cx); + if auto_dismiss { + self.start_dismiss_timer(DEFAULT_TOAST_DURATION, cx); + } cx.notify(); } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index f0ca1eff5daa10c6513714a042799e7bf337f04c..419588b9f2d6176ffba8645c3e3f553a0e6db039 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -33,9 +33,9 @@ pub use dock::Panel; pub use multi_workspace::{ CloseWorkspaceSidebar, DraggedSidebar, FocusWorkspaceSidebar, MoveProjectToNewWindow, MultiWorkspace, MultiWorkspaceEvent, NewThread, NextProject, NextThread, PreviousProject, - PreviousThread, ProjectGroup, ProjectGroupKey, SerializedProjectGroupState, ShowFewerThreads, - ShowMoreThreads, Sidebar, SidebarEvent, SidebarHandle, SidebarRenderState, SidebarSide, - ToggleWorkspaceSidebar, sidebar_side_context_menu, + PreviousThread, ProjectGroup, ProjectGroupKey, SerializedProjectGroupState, Sidebar, + SidebarEvent, SidebarHandle, SidebarRenderState, SidebarSide, ToggleWorkspaceSidebar, + sidebar_side_context_menu, }; pub use path_list::{PathList, SerializedPathList}; pub use remote::{ @@ -2302,18 +2302,19 @@ impl Workspace { return None; } let flex = flex.max(0.001); + let center_column_count = self.center_full_height_column_count(); let opposite = self.opposite_dock_panel_and_size_state(position, window, cx); if let Some(opposite_flex) = opposite.as_ref().and_then(|(_, s)| s.flex) { - // Both docks are flex items sharing the full workspace width. - let total_flex = flex + 1.0 + opposite_flex; + let total_flex = flex + center_column_count + opposite_flex; return Some((flex / total_flex * workspace_width).max(RESIZE_HANDLE_SIZE)); } else { - // Opposite dock is fixed-width; flex items share (W - fixed). let opposite_fixed = opposite .map(|(panel, s)| s.size.unwrap_or_else(|| panel.default_size(window, cx))) .unwrap_or_default(); let available = (workspace_width - opposite_fixed).max(RESIZE_HANDLE_SIZE); - return Some((flex / (flex + 1.0) * available).max(RESIZE_HANDLE_SIZE)); + return Some( + (flex / (flex + center_column_count) * available).max(RESIZE_HANDLE_SIZE), + ); } } @@ -2340,17 +2341,18 @@ impl Workspace { return None; } + let center_column_count = self.center_full_height_column_count(); let opposite = self.opposite_dock_panel_and_size_state(position, window, cx); if let Some(opposite_flex) = opposite.as_ref().and_then(|(_, s)| s.flex) { let size = size.clamp(px(0.), workspace_width - px(1.)); - Some((size * (1.0 + opposite_flex) / (workspace_width - size)).max(0.0)) + Some((size * (center_column_count + opposite_flex) / (workspace_width - size)).max(0.0)) } else { let opposite_width = opposite .map(|(panel, s)| s.size.unwrap_or_else(|| panel.default_size(window, cx))) .unwrap_or_default(); let available = (workspace_width - opposite_width).max(RESIZE_HANDLE_SIZE); let remaining = (available - size).max(px(1.)); - Some((size / remaining).max(0.0)) + Some((size * center_column_count / remaining).max(0.0)) } } @@ -2377,13 +2379,16 @@ impl Workspace { Some((panel.clone(), size_state)) } + fn center_full_height_column_count(&self) -> f32 { + self.center.full_height_column_count().max(1) as f32 + } + pub fn default_dock_flex(&self, position: DockPosition) -> Option { if position.axis() != Axis::Horizontal { return None; } - let pane = self.last_active_center_pane.clone()?.upgrade()?; - Some(self.center.width_fraction_for_pane(&pane).unwrap_or(1.0)) + Some(1.0) } pub fn is_edited(&self) -> bool { @@ -7484,7 +7489,7 @@ impl Workspace { None }; if let Some(grow) = flex_grow { - let grow = grow.max(0.001); + let grow = (grow / self.center_full_height_column_count()).max(0.001); let style = container.style(); style.flex_grow = Some(grow); style.flex_shrink = Some(1.0); @@ -8802,11 +8807,7 @@ pub async fn apply_restored_multiworkspace_state( // stale keys from previous sessions get normalized and deduped. let mut resolved_groups: Vec = Vec::new(); for serialized in project_groups.iter().cloned() { - let SerializedProjectGroupState { - key, - expanded, - visible_thread_count, - } = serialized.into_restored_state(); + let SerializedProjectGroupState { key, expanded } = serialized.into_restored_state(); if key.path_list().paths().is_empty() { continue; } @@ -8827,7 +8828,6 @@ pub async fn apply_restored_multiworkspace_state( resolved_groups.push(SerializedProjectGroupState { key: resolved, expanded, - visible_thread_count, }); } } @@ -9938,9 +9938,15 @@ async fn open_remote_project_inner( }); if let Some(project_group_key) = provisional_project_group_key.clone() { - multi_workspace.retain_workspace(new_workspace.clone(), project_group_key, cx); + multi_workspace.activate_provisional_workspace( + new_workspace.clone(), + project_group_key, + window, + cx, + ); + } else { + multi_workspace.activate(new_workspace.clone(), window, cx); } - multi_workspace.activate(new_workspace.clone(), window, cx); new_workspace })?; @@ -12749,74 +12755,40 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); workspace.update(cx, |workspace, _cx| { - workspace.bounds.size.width = px(800.); + workspace.set_random_database_id(); }); workspace.update_in(cx, |workspace, window, cx| { let panel = cx.new(|cx| TestPanel::new_flexible(DockPosition::Right, 100, cx)); - workspace.add_panel(panel, window, cx); + workspace.add_panel(panel.clone(), window, cx); workspace.toggle_dock(DockPosition::Right, window, cx); - }); - - let (panel, resized_width, ratio_basis_width) = - workspace.update_in(cx, |workspace, window, cx| { - let item = cx.new(|cx| { - TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "one.txt", cx)]) - }); - workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx); - - let dock = workspace.right_dock().read(cx); - let workspace_width = workspace.bounds.size.width; - let initial_width = workspace - .dock_size(&dock, window, cx) - .expect("flexible dock should have an initial width"); - - assert_eq!(initial_width, workspace_width / 2.); - workspace.resize_right_dock(px(300.), window, cx); - - let dock = workspace.right_dock().read(cx); - let resized_width = workspace - .dock_size(&dock, window, cx) - .expect("flexible dock should keep its resized width"); - - assert_eq!(resized_width, px(300.)); - - let panel = workspace - .right_dock() - .read(cx) - .visible_panel() - .expect("flexible dock should have a visible panel") - .panel_id(); - - (panel, resized_width, workspace_width) + let right_dock = workspace.right_dock().clone(); + right_dock.update(cx, |dock, cx| { + dock.set_panel_size_state( + &panel, + dock::PanelSizeState { + size: None, + flex: Some(1.0), + }, + cx, + ); }); + }); workspace.update_in(cx, |workspace, window, cx| { - workspace.toggle_dock(DockPosition::Right, window, cx); - workspace.toggle_dock(DockPosition::Right, window, cx); + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "one.txt", cx)]) + }); + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx); + workspace.bounds.size.width = px(1920.); let dock = workspace.right_dock().read(cx); - let reopened_width = workspace + let initial_width = workspace .dock_size(&dock, window, cx) - .expect("flexible dock should restore when reopened"); - - assert_eq!(reopened_width, resized_width); + .expect("flexible dock should have an initial width"); - let right_dock = workspace.right_dock().read(cx); - let flexible_panel = right_dock - .visible_panel() - .expect("flexible dock should still have a visible panel"); - assert_eq!(flexible_panel.panel_id(), panel); - assert_eq!( - right_dock - .stored_panel_size_state(flexible_panel.as_ref()) - .and_then(|size_state| size_state.flex), - Some( - resized_width.to_f64() as f32 - / (workspace.bounds.size.width - resized_width).to_f64() as f32 - ) - ); + assert_eq!(initial_width, px(960.)); }); workspace.update_in(cx, |workspace, window, cx| { @@ -12827,25 +12799,16 @@ mod tests { cx, ); - let dock = workspace.right_dock().read(cx); - let split_width = workspace - .dock_size(&dock, window, cx) - .expect("flexible dock should keep its user-resized proportion"); + let center_column_count = workspace.center.full_height_column_count(); + assert_eq!(center_column_count, 2); - assert_eq!(split_width, px(300.)); + let dock = workspace.right_dock().read(cx); + assert_eq!(workspace.dock_size(&dock, window, cx).unwrap(), px(640.)); - workspace.bounds.size.width = px(1600.); + workspace.bounds.size.width = px(2400.); let dock = workspace.right_dock().read(cx); - let resized_window_width = workspace - .dock_size(&dock, window, cx) - .expect("flexible dock should preserve proportional size on window resize"); - - assert_eq!( - resized_window_width, - workspace.bounds.size.width - * (resized_width.to_f64() as f32 / ratio_basis_width.to_f64() as f32) - ); + assert_eq!(workspace.dock_size(&dock, window, cx).unwrap(), px(800.)); }); } @@ -13017,9 +12980,31 @@ mod tests { ); }); - // Step 2: Split the center pane vertically (top/bottom). Vertical splits do not - // change horizontal width fractions, so the flexible panel stays at the same - // width as each half of the split. + // Step 2: Split the center pane left/right. The flexible panel is treated as one + // average center column, so with two center columns it should take one third of + // the workspace width. + workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane( + workspace.active_pane().clone(), + SplitDirection::Right, + window, + cx, + ); + + let left_dock = workspace.left_dock().read(cx); + let left_width = workspace + .dock_size(&left_dock, window, cx) + .expect("left dock should still have an active panel after horizontal split"); + + assert_eq!( + left_width, + workspace.bounds.size.width / 3., + "flexible left panel width should match the average center column width" + ); + }); + + // Step 3: Split the active center pane vertically (top/bottom). Vertical splits do + // not change the number of center columns, so the flexible panel width stays the same. workspace.update_in(cx, |workspace, window, cx| { workspace.split_pane( workspace.active_pane().clone(), @@ -13035,14 +13020,14 @@ mod tests { assert_eq!( left_width, - workspace.bounds.size.width / 2., - "flexible left panel width should match each vertically-split pane" + workspace.bounds.size.width / 3., + "flexible left panel width should still match the average center column width" ); }); - // Step 3: Open a fixed-width panel in the right dock. The right dock's default - // size reduces the available width, so the flexible left panel and the center - // panes all shrink proportionally to accommodate it. + // Step 4: Open a fixed-width panel in the right dock. The right dock's default + // size reduces the available width, so the flexible left panel keeps matching one + // average center column within the remaining space. workspace.update_in(cx, |workspace, window, cx| { let panel = cx.new(|cx| TestPanel::new(DockPosition::Right, 200, cx)); workspace.add_panel(panel, window, cx); @@ -13061,14 +13046,14 @@ mod tests { let available_width = workspace.bounds.size.width - right_width; assert_eq!( left_width, - available_width / 2., - "flexible left panel should shrink proportionally as the right dock takes space" + available_width / 3., + "flexible left panel should keep matching one average center column" ); }); - // Step 4: Toggle the right dock's panel to flexible. Now both docks use - // flex sizing and the workspace width is divided among left-flex, center - // (implicit flex 1.0), and right-flex. + // Step 5: Toggle the right dock's panel to flexible. Now both docks use + // column-equivalent flex sizing and the workspace width is divided among + // left-flex, two center columns, and right-flex. workspace.update_in(cx, |workspace, window, cx| { let right_dock = workspace.right_dock().clone(); let right_panel = right_dock @@ -13110,8 +13095,9 @@ mod tests { let left_flex = workspace .default_dock_flex(DockPosition::Left) .expect("left dock should have a default flex"); + let center_column_count = workspace.center.full_height_column_count() as f32; - let total_flex = left_flex + 1.0 + right_flex; + let total_flex = left_flex + center_column_count + right_flex; let expected_left = left_flex / total_flex * workspace.bounds.size.width; let expected_right = right_flex / total_flex * workspace.bounds.size.width; assert_eq!( diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 342ff9487f46f7712b63c3a65350d67cea818d18..f0bc7d557d5199d4b9599d09eebb4962e5eb6bbb 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -555,6 +555,7 @@ fn main() { debugger_ui::init(cx); debugger_tools::init(cx); client::init(&client, cx); + feature_flags::FeatureFlagStore::init(cx); let system_id = cx.foreground_executor().block_on(system_id).ok(); let installation_id = cx.foreground_executor().block_on(installation_id).ok(); diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index d4c0d29ade5c4bd6496509675f9ccb3fc188eb8f..dd953179f0f22bbe33712e1a053bad8c79fb856f 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -2914,7 +2914,7 @@ impl gpui::Render for ThreadItemBranchNameTestView { .icon(IconName::AiClaude) .timestamp("5m") .worktrees(vec![ThreadItemWorktreeInfo { - name: "jade-glen".into(), + worktree_name: Some("jade-glen".into()), full_path: "/worktrees/jade-glen/zed".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Linked, @@ -2931,7 +2931,7 @@ impl gpui::Render for ThreadItemBranchNameTestView { .icon(IconName::AiClaude) .timestamp("1h") .worktrees(vec![ThreadItemWorktreeInfo { - name: "focal-arrow".into(), + worktree_name: Some("focal-arrow".into()), full_path: "/worktrees/focal-arrow/zed".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Linked, @@ -2946,7 +2946,7 @@ impl gpui::Render for ThreadItemBranchNameTestView { .icon(IconName::ZedAgent) .timestamp("2d") .worktrees(vec![ThreadItemWorktreeInfo { - name: "zed".into(), + worktree_name: Some("zed".into()), full_path: "/projects/zed".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Main, @@ -2963,7 +2963,7 @@ impl gpui::Render for ThreadItemBranchNameTestView { .icon(IconName::ZedAgent) .timestamp("3d") .worktrees(vec![ThreadItemWorktreeInfo { - name: "zed".into(), + worktree_name: Some("zed".into()), full_path: "/projects/zed".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Main, @@ -2978,7 +2978,7 @@ impl gpui::Render for ThreadItemBranchNameTestView { .icon(IconName::AiClaude) .timestamp("6d") .worktrees(vec![ThreadItemWorktreeInfo { - name: "stoic-reed".into(), + worktree_name: Some("stoic-reed".into()), full_path: "/worktrees/stoic-reed/zed".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Linked, @@ -2995,7 +2995,7 @@ impl gpui::Render for ThreadItemBranchNameTestView { .icon(IconName::ZedAgent) .timestamp("40m") .worktrees(vec![ThreadItemWorktreeInfo { - name: "focal-arrow".into(), + worktree_name: Some("focal-arrow".into()), full_path: "/worktrees/focal-arrow/zed".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Linked, @@ -3014,7 +3014,7 @@ impl gpui::Render for ThreadItemBranchNameTestView { .added(42) .removed(17) .worktrees(vec![ThreadItemWorktreeInfo { - name: "jade-glen".into(), + worktree_name: Some("jade-glen".into()), full_path: "/worktrees/jade-glen/zed".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Linked, @@ -3031,7 +3031,7 @@ impl gpui::Render for ThreadItemBranchNameTestView { .added(108) .removed(53) .worktrees(vec![ThreadItemWorktreeInfo { - name: "my-project".into(), + worktree_name: Some("my-project".into()), full_path: "/worktrees/my-project/zed".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Linked, @@ -3052,7 +3052,7 @@ impl gpui::Render for ThreadItemBranchNameTestView { .added(23) .removed(8) .worktrees(vec![ThreadItemWorktreeInfo { - name: "zed".into(), + worktree_name: Some("zed".into()), full_path: "/projects/zed".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Main, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index c3c02b83cbc4bbdd6d10b59af4c3fac652a9599c..edc431d7c145cbfbd61e5b6c4bb46022d1baab00 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -159,8 +159,8 @@ pub fn init(cx: &mut App) { cx.observe_flag::({ let mut added = false; - move |enabled, cx| { - if added || !enabled { + move |flag, cx| { + if added || !*flag { return; } added = true;