mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
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:
parent
8c5dfe5691
commit
ec9be5c332
32 changed files with 1290 additions and 129 deletions
18
Cargo.lock
generated
18
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
76
crates/feature_flags/src/settings.rs
Normal file
76
crates/feature_flags/src/settings.rs
Normal 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."
|
||||
}
|
||||
})
|
||||
}
|
||||
374
crates/feature_flags/src/store.rs
Normal file
374
crates/feature_flags/src/store.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
18
crates/feature_flags_macros/Cargo.toml
Normal file
18
crates/feature_flags_macros/Cargo.toml
Normal 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
|
||||
1
crates/feature_flags_macros/LICENSE-GPL
Symbolic link
1
crates/feature_flags_macros/LICENSE-GPL
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../LICENSE-GPL
|
||||
190
crates/feature_flags_macros/src/feature_flags_macros.rs
Normal file
190
crates/feature_flags_macros/src/feature_flags_macros.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ impl VsCodeSettings {
|
|||
workspace: self.workspace_settings_content(),
|
||||
which_key: None,
|
||||
modeline_lines: None,
|
||||
feature_flags: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
132
crates/settings_ui/src/pages/feature_flags.rs
Normal file
132
crates/settings_ui/src/pages/feature_flags.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue