From ec9be5c332f79986b046a2985e003766591ec82f Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Fri, 17 Apr 2026 23:34:19 -0700 Subject: [PATCH] Feature flag overrides (#54206) This PR revamps our feature flag system, to enable richer iteration. Feature flags can now: - Support enum values, for richer configuration - Be manually set via the settings file - Be manually set via the settings UI This PR also adds a feature flag to demonstrate this behavior, a `agent-thread-worktree-label`, which controls which how the worktree tag UI displays. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- Cargo.lock | 18 + Cargo.toml | 3 + crates/agent_ui/src/thread_metadata_store.rs | 13 +- crates/csv_preview/src/csv_preview.rs | 4 +- crates/debugger_ui/src/debugger_panel.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/json_schema_store/Cargo.toml | 5 + .../src/json_schema_store.rs | 55 ++- crates/repl/src/notebook/notebook_ui.rs | 4 +- crates/settings/src/vscode_import.rs | 1 + .../settings_content/src/settings_content.rs | 39 ++ crates/settings_ui/src/page_data.rs | 29 +- 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 | 41 +- crates/sidebar/src/sidebar_tests.rs | 20 +- crates/ui/src/components/ai/thread_item.rs | 89 +++-- crates/zed/src/main.rs | 1 + crates/zed/src/visual_test_runner.rs | 18 +- crates/zed/src/zed.rs | 4 +- 32 files changed, 1289 insertions(+), 128 deletions(-) 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 ca135c2285297ab96d0ba25abddc8a73933cbab6..d475eac0b8438db29e9ff296994f3b2ea4c5ba77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6180,7 +6180,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]] @@ -9166,6 +9182,7 @@ dependencies = [ "collections", "dap", "extension", + "feature_flags", "gpui", "language", "parking_lot", @@ -16120,6 +16137,7 @@ dependencies = [ "db", "editor", "extension", + "feature_flags", "fs", "git", "gpui", diff --git a/Cargo.toml b/Cargo.toml index e845ba983e2df5f97ec4f8eeea08724ac8a6f867..99cfe573a4d9bfc09058de0d1db87e5656785471 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", @@ -326,6 +327,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" } @@ -894,6 +896,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/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 475c4908d39dcb56f5c509c6e9b3c619b6543226..c49220f8d44dde1132dd6db5ba520065a299c210 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))); + } } } diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index a1b10feea074f6e42974528a65b2b14ff46592bc..ba7987384809190a56e641c93599e4d4060bcb97 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -1,5 +1,5 @@ use editor::{Editor, EditorEvent}; -use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; +use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_feature_flag}; use gpui::{ AppContext, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, Task, actions, }; @@ -29,7 +29,9 @@ pub struct TabularDataPreviewFeatureFlag; impl FeatureFlag for TabularDataPreviewFeatureFlag { const NAME: &'static str = "tabular-data-preview"; + type Value = PresenceFlag; } +register_feature_flag!(TabularDataPreviewFeatureFlag); pub struct CsvPreviewView { pub(crate) engine: TableDataEngine, diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index c2d8a7a5478cfc9eae53f9e7a6018864865a4d1a..d727a112e31950c280683e31dd43018b355e28ac 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -15,7 +15,7 @@ use dap::adapters::DebugAdapterName; use dap::{DapRegistry, StartDebuggingRequestArguments}; use dap::{client::SessionId, debugger_settings::DebuggerSettings}; use editor::{Editor, MultiBufferOffset, ToPoint}; -use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; +use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_feature_flag}; use gpui::{ Action, App, AsyncWindowContext, ClipboardItem, Context, Corner, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point, @@ -50,7 +50,9 @@ pub struct DebuggerHistoryFeatureFlag; impl FeatureFlag for DebuggerHistoryFeatureFlag { const NAME: &'static str = "debugger-history"; + type Value = PresenceFlag; } +register_feature_flag!(DebuggerHistoryFeatureFlag); const DEBUG_PANEL_KEY: &str = "DebugPanel"; diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 5efa626f20ba33f48f16427372487f46011a6e80..d61cba71922582b98bdc64444bc8227c0043fa2e 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -17,7 +17,7 @@ use copilot::{Copilot, Reinstall, SignIn, SignOut}; use credentials_provider::CredentialsProvider; use db::kvp::{Dismissable, KeyValueStore}; use edit_prediction_context::{RelatedExcerptStore, RelatedExcerptStoreEvent, RelatedFile}; -use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; +use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_feature_flag}; use futures::{ AsyncReadExt as _, FutureExt as _, StreamExt as _, channel::mpsc::{self, UnboundedReceiver}, @@ -120,7 +120,9 @@ pub struct EditPredictionJumpsFeatureFlag; impl FeatureFlag for EditPredictionJumpsFeatureFlag { const NAME: &'static str = "edit_prediction_jumps"; + type Value = PresenceFlag; } +register_feature_flag!(EditPredictionJumpsFeatureFlag); #[derive(Clone)] struct EditPredictionStoreGlobal(Entity); 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 6ff7c0e2e46efe1142414e9e5717e1607323636c..de6f322454ce6ac9681dc7a50467c7893b9466e9 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/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/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 74084421455a9aa4f6032a03810289744a3dd928..d2e4681db7584f46f6d4131ea08607f4abb7ac96 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/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 80f2714960a17497bd7556c1b8df6ce90ddc4fa9..4b69026fa3adf7a36e8bcc749abc773536af5bf1 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 { 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 2597bd9546d5584bc6c10fe67a2adcbcbd7f9c63..64e235e9045d3bde6a69aedd590df228340d62fa 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -18,6 +18,9 @@ use agent_ui::{ }; 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, @@ -391,6 +394,30 @@ fn workspace_menu_worktree_labels( .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` @@ -454,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); @@ -1246,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; } @@ -3835,6 +3867,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) @@ -3843,7 +3880,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) diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index c4d2dde70fb98801ae5515668402d28ce81eabf6..bd266fb88c3657c6e28c49ccfd364edb919b649a 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -449,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() { @@ -3837,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; @@ -3847,7 +3853,7 @@ 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=`{}`", @@ -10426,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] @@ -10463,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] diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index a106d3fa3998b43b6da008f8d1cf2948c811dff2..af538069638e678d78c4592805c1c822b7152d16 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -22,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)] @@ -371,6 +371,7 @@ impl RenderOnce for ThreadItem { .worktrees .into_iter() .filter(|wt| wt.kind == WorktreeKind::Linked) + .filter(|wt| wt.worktree_name.is_some() || wt.branch_name.is_some()) .collect(); let has_worktree = !linked_worktrees.is_empty(); @@ -470,42 +471,68 @@ impl RenderOnce for ThreadItem { }) .children( linked_worktrees.into_iter().map(|wt| { - let worktree_label = if wt.highlight_positions.is_empty() { - Label::new(wt.name) + 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 { - HighlightedLabel::new(wt.name, wt.highlight_positions) + 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() .gap_0p5() .child( - Icon::new(IconName::GitWorktree) + Icon::new(chip_icon) .size(IconSize::XSmall) .color(Color::Muted), ) - .child(worktree_label) - .when_some(wt.branch_name, |this, branch| { + .when_some(worktree_label, |this, label| { + this.child(label) + }) + .when(show_separator, |this| { this.child( Label::new("/") .size(LabelSize::Small) .color(separator_color) .flex_shrink_0(), ) - .child( - Label::new(branch) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate(), - ) + }) + .when_some(branch_label, |this, label| { + this.child(label) }) }), ) @@ -628,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, @@ -656,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, @@ -675,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, @@ -694,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, @@ -713,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, @@ -732,7 +759,9 @@ impl Component for ThreadItem { ThreadItem::new("ti-5f", "Thread with a very long worktree name") .icon(IconName::AiClaude) .worktrees(vec![ThreadItemWorktreeInfo { - name: "very-long-worktree-name-that-should-truncate".into(), + 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, @@ -749,7 +778,7 @@ impl Component for ThreadItem { ThreadItem::new("ti-5g", "Filtered thread with highlighted worktree") .icon(IconName::AiClaude) .worktrees(vec![ThreadItemWorktreeInfo { - name: "jade-glen".into(), + worktree_name: Some("jade-glen".into()), full_path: "/worktrees/jade-glen/zed".into(), highlight_positions: vec![0, 1, 2, 3], kind: WorktreeKind::Linked, @@ -767,14 +796,14 @@ impl Component for ThreadItem { .icon(IconName::AiClaude) .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, branch_name: None, }, ThreadItemWorktreeInfo { - name: "fawn-otter".into(), + worktree_name: Some("fawn-otter".into()), full_path: "/worktrees/fawn-otter/zed-slides".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Linked, @@ -793,14 +822,14 @@ impl Component for ThreadItem { .icon(IconName::ZedAgent) .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, branch_name: Some("fix".into()), }, ThreadItemWorktreeInfo { - name: "fawn-otter".into(), + worktree_name: Some("fawn-otter".into()), full_path: "/worktrees/fawn-otter/zed-slides".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Linked, @@ -819,7 +848,7 @@ impl Component for ThreadItem { .icon(IconName::AiClaude) .project_name("my-remote-server") .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, @@ -840,7 +869,7 @@ impl Component for ThreadItem { PathBuf::from("/projects/zed-slides"), ])) .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, @@ -858,7 +887,7 @@ impl Component for ThreadItem { .icon(IconName::ZedAgent) .project_name("remote-dev") .worktrees(vec![ThreadItemWorktreeInfo { - name: "my-worktree".into(), + worktree_name: Some("my-worktree".into()), full_path: "/worktrees/my-worktree/zed".into(), highlight_positions: Vec::new(), kind: WorktreeKind::Linked, 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 3e07140adcc97d783ce7f04f21b0986f92c6ecc4..3980bcdc472c1b23166eb09249c771c398016164 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 71f7ab51f255aa109341b5ca17508da74237e3aa..b200dc0c1167d81e38c93e29c5adf6247360df8a 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;