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
This commit is contained in:
Mikayla Maki 2026-04-17 23:34:19 -07:00 committed by GitHub
parent 8c5dfe5691
commit ec9be5c332
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1290 additions and 129 deletions

18
Cargo.lock generated
View file

@ -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",

View file

@ -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 }

View file

@ -346,7 +346,7 @@ pub fn worktree_info_from_thread_paths<S: std::hash::BuildHasher>(
.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<S: std::hash::BuildHasher>(
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<S: std::hash::BuildHasher>(
// 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<S: std::hash::BuildHasher>(
.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)));
}
}
}

View file

@ -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,

View file

@ -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";

View file

@ -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<EditPredictionStore>);

View file

@ -115,9 +115,9 @@ fn feature_gate_predict_edits_actions(cx: &mut App) {
})
.detach();
cx.observe_flag::<PredictEditsRatePredictionsFeatureFlag, _>(move |is_enabled, cx| {
cx.observe_flag::<PredictEditsRatePredictionsFeatureFlag, _>(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);
});

View file

@ -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<EditPredictionStore>,

View file

@ -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"] }

View file

@ -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<String>,
staff: bool,
}
pub use settings::{FeatureFlagsSettings, generate_feature_flags_schema};
pub use store::*;
pub static ZED_DISABLE_STAFF: LazyLock<bool> = LazyLock::new(|| {
std::env::var("ZED_DISABLE_STAFF").is_ok_and(|value| !value.is_empty() && value != "0")
});
impl FeatureFlags {
fn has_flag<T: FeatureFlag>(&self) -> bool {
if T::enabled_for_all() {
return true;
}
impl Global for FeatureFlagStore {}
if (cfg!(debug_assertions) || self.staff) && !*ZED_DISABLE_STAFF && T::enabled_for_staff() {
return true;
}
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];
self.flags.iter().any(|f| f.as_str() == T::NAME)
/// A stable identifier for this variant used when persisting overrides.
fn override_key(&self) -> &'static str;
fn from_wire(wire: &str) -> Option<Self>;
/// 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()
}
}
impl Global for FeatureFlags {}
/// 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,
}
}
}
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",
}
}
fn label(&self) -> &'static str {
match self {
PresenceFlag::On => "On",
PresenceFlag::Off => "Off",
}
}
fn from_wire(_: &str) -> Option<Self> {
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<V: 'static>(cx: &mut Context<V>) {
cx.observe_global::<FeatureFlagStore>(|_, cx| cx.notify())
.detach();
}
}
pub trait FeatureFlagViewExt<V: 'static> {
/// Fires the callback whenever the resolved [`T::Value`] transitions.
fn observe_flag<T: FeatureFlag, F>(&mut self, window: &Window, callback: F) -> Subscription
where
F: Fn(bool, &mut V, &mut Window, &mut Context<V>) + Send + Sync + 'static;
F: Fn(T::Value, &mut V, &mut Window, &mut Context<V>) + Send + Sync + 'static;
fn when_flag_enabled<T: FeatureFlag>(
&mut self,
@ -75,11 +161,16 @@ where
{
fn observe_flag<T: FeatureFlag, F>(&mut self, window: &Window, callback: F) -> Subscription
where
F: Fn(bool, &mut V, &mut Window, &mut Context<V>) + 'static,
F: Fn(T::Value, &mut V, &mut Window, &mut Context<V>) + 'static,
{
self.observe_global_in::<FeatureFlags>(window, move |v, window, cx| {
let feature_flags = cx.global::<FeatureFlags>();
callback(feature_flags.has_flag::<T>(), v, window, cx);
let mut last_value: Option<T::Value> = None;
self.observe_global_in::<FeatureFlagStore>(window, move |v, window, cx| {
let value = cx.flag_value::<T>();
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<V>) + Send + Sync + 'static,
) {
if self
.try_global::<FeatureFlags>()
.is_some_and(|f| f.has_flag::<T>())
.try_global::<FeatureFlagStore>()
.is_some_and(|f| f.has_flag::<T>(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::<FeatureFlags>(window, {
let inner = self.observe_global_in::<FeatureFlagStore>(window, {
let subscription = subscription.clone();
move |v, window, cx| {
let feature_flags = cx.global::<FeatureFlags>();
if feature_flags.has_flag::<T>() {
let has_flag = cx.global::<FeatureFlagStore>().has_flag::<T>(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<String>);
fn set_staff(&mut self, staff: bool);
fn has_flag<T: FeatureFlag>(&self) -> bool;
fn flag_value<T: FeatureFlag>(&self) -> T::Value;
fn is_staff(&self) -> bool;
fn on_flags_ready<F>(&mut self, callback: F) -> Subscription
@ -129,33 +221,35 @@ pub trait FeatureFlagAppExt {
fn observe_flag<T: FeatureFlag, F>(&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<String>) {
let feature_flags = self.default_global::<FeatureFlags>();
feature_flags.staff = staff;
feature_flags.flags = flags;
let store = self.default_global::<FeatureFlagStore>();
store.update_server_flags(staff, flags);
}
fn set_staff(&mut self, staff: bool) {
let feature_flags = self.default_global::<FeatureFlags>();
feature_flags.staff = staff;
let store = self.default_global::<FeatureFlagStore>();
store.set_staff(staff);
}
fn has_flag<T: FeatureFlag>(&self) -> bool {
self.try_global::<FeatureFlags>()
.map(|flags| flags.has_flag::<T>())
.unwrap_or_else(|| {
(cfg!(debug_assertions) && T::enabled_for_staff() && !*ZED_DISABLE_STAFF)
|| T::enabled_for_all()
})
self.try_global::<FeatureFlagStore>()
.map(|store| store.has_flag::<T>(self))
.unwrap_or_else(|| FeatureFlagStore::has_flag_default::<T>())
}
fn flag_value<T: FeatureFlag>(&self) -> T::Value {
self.try_global::<FeatureFlagStore>()
.and_then(|store| store.try_flag_value::<T>(self))
.unwrap_or_default()
}
fn is_staff(&self) -> bool {
self.try_global::<FeatureFlags>()
.map(|flags| flags.staff)
self.try_global::<FeatureFlagStore>()
.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::<FeatureFlags>(move |cx| {
let feature_flags = cx.global::<FeatureFlags>();
self.observe_global::<FeatureFlagStore>(move |cx| {
let store = cx.global::<FeatureFlagStore>();
callback(
OnFlagsReady {
is_staff: feature_flags.staff,
is_staff: store.is_staff(),
},
cx,
);
@ -176,11 +270,16 @@ impl FeatureFlagAppExt for App {
fn observe_flag<T: FeatureFlag, F>(&mut self, mut callback: F) -> Subscription
where
F: FnMut(bool, &mut App) + 'static,
F: FnMut(T::Value, &mut App) + 'static,
{
self.observe_global::<FeatureFlags>(move |cx| {
let feature_flags = cx.global::<FeatureFlags>();
callback(feature_flags.has_flag::<T>(), cx);
let mut last_value: Option<T::Value> = None;
self.observe_global::<FeatureFlagStore>(move |cx| {
let value = cx.flag_value::<T>();
if last_value.as_ref() == Some(&value) {
return;
}
last_value = Some(value.clone());
callback(value, cx);
})
}
}

View file

@ -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);

View file

@ -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<String, String>,
}
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<Value> = variants
.iter()
.map(|v| Value::String(v.override_key.to_string()))
.collect();
let enum_descriptions: Vec<Value> = 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."
}
})
}

View file

@ -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<FeatureFlagVariant>,
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<String, String>,
_settings_subscription: Option<Subscription>,
}
impl FeatureFlagStore {
pub fn init(cx: &mut App) {
let subscription = cx.observe_global::<SettingsStore>(|cx| {
// Touch the global so anything observing `FeatureFlagStore` re-runs
cx.update_default_global::<FeatureFlagStore, _>(|_, _| {});
});
cx.update_default_global::<FeatureFlagStore, _>(|store, _| {
store._settings_subscription = Some(subscription);
});
}
pub fn known_flags() -> impl Iterator<Item = &'static FeatureFlagDescriptor> {
let mut seen = collections::HashSet::default();
inventory::iter::<FeatureFlagDescriptor>().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<String>) {
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<dyn Fs>, 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<dyn Fs>, 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<T: FeatureFlag>(&self, cx: &App) -> Option<T::Value> {
// `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::<T::Value>(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<T: FeatureFlag>(&self, cx: &App) -> bool {
self.try_flag_value::<T>(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<T: FeatureFlag>() -> bool {
if T::enabled_for_all() {
return true;
}
cfg!(debug_assertions) && T::enabled_for_staff() && !*ZED_DISABLE_STAFF
}
}
fn variant_from_key<V: FeatureFlagValue>(key: &str) -> Option<V> {
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::<FeatureFlagsSettings>();
});
}
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::<DemoFlag>(cx));
store.update_server_flags(false, vec!["demo".to_string()]);
assert!(store.has_flag::<DemoFlag>(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::<DemoFlag>(cx));
assert_eq!(
store.try_flag_value::<DemoFlag>(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::<IntensityFlag>(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::<EnumDemo>(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::<EnumDemo>(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::<DemoFlag>(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::<DemoFlag>(cx), None);
assert_eq!(PresenceFlag::default(), PresenceFlag::Off);
}
}

View file

@ -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

View file

@ -0,0 +1 @@
../../LICENSE-GPL

View file

@ -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<TokenStream2> {
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<Self> {
::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");
}
}

View file

@ -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

View file

@ -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::<Vec<_>>();
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::DapRegistry, _>(|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)

View file

@ -68,8 +68,8 @@ pub fn init(cx: &mut App) {
}
cx.observe_flag::<NotebookFeatureFlag, _>({
move |is_enabled, cx| {
if is_enabled {
move |flag, cx| {
if *flag {
workspace::register_project_item::<NotebookEditor>(cx);
} else {
// todo: there is no way to unregister a project item, so if the feature flag

View file

@ -220,6 +220,7 @@ impl VsCodeSettings {
workspace: self.workspace_settings_content(),
which_key: None,
modeline_lines: None,
feature_flags: None,
}
}

View file

@ -211,6 +211,45 @@ pub struct SettingsContent {
///
/// Default: 5
pub modeline_lines: Option<usize>,
/// Local overrides for feature flags, keyed by flag name.
pub feature_flags: Option<FeatureFlagsMap>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, MergeFrom)]
#[serde(transparent)]
pub struct FeatureFlagsMap(pub HashMap<String, String>);
// A manual `JsonSchema` impl keeps this type's schema registered under a
// unique name. The derived impl on a `#[serde(transparent)]` newtype around
// `HashMap<String, String>` would inline to the map's own schema name (`Map_of_string`),
// which is shared with every other `HashMap<String, String>` 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<String, String>;
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 {

View file

@ -62,7 +62,7 @@ macro_rules! concat_sections {
}
pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
vec![
let mut pages = vec![
general_page(cx),
appearance_page(),
keymap_page(),
@ -77,7 +77,32 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
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 {

View file

@ -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::{

View file

@ -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<SettingsWindow>,
) -> 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<SettingsWindow>,
) -> AnyElement {
let forced_on = FeatureFlagStore::is_forced_on(descriptor);
let resolved = cx.global::<FeatureFlagStore>().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, <dyn Fs>::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<SettingsWindow>,
) -> impl IntoElement {
let variants: Vec<FeatureFlagVariant> = (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(),
<dyn Fs>::global(cx),
cx,
);
}));
}
checkbox.into_any_element()
}
});
h_flex().gap_4().flex_wrap().children(row_items)
}

View file

@ -1521,6 +1521,17 @@ impl SettingsWindow {
})
.detach();
use feature_flags::FeatureFlagAppExt as _;
let mut last_is_staff = cx.is_staff();
cx.observe_global_in::<feature_flags::FeatureFlagStore>(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<SettingsWindow>) {
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<SettingsWindow>) {
self.worktree_root_dirs.clear();

View file

@ -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

View file

@ -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<ThreadItemWorktreeInfo>,
mode: AgentThreadWorktreeLabel,
) -> Vec<ThreadItemWorktreeInfo> {
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::<AgentThreadWorktreeLabelFlag>(),
);
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)

View file

@ -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("<none>");
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]

View file

@ -22,13 +22,13 @@ pub enum WorktreeKind {
Linked,
}
#[derive(Clone)]
#[derive(Clone, Default)]
pub struct ThreadItemWorktreeInfo {
pub name: SharedString,
pub worktree_name: Option<SharedString>,
pub branch_name: Option<SharedString>,
pub full_path: SharedString,
pub highlight_positions: Vec<usize>,
pub kind: WorktreeKind,
pub branch_name: Option<SharedString>,
}
#[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,

View file

@ -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();

View file

@ -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,

View file

@ -159,8 +159,8 @@ pub fn init(cx: &mut App) {
cx.observe_flag::<PanicFeatureFlag, _>({
let mut added = false;
move |enabled, cx| {
if added || !enabled {
move |flag, cx| {
if added || !*flag {
return;
}
added = true;