logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled

Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree
with a 3-file patch applied (no upstream history).

Patch (crates/gpui_linux/src/linux/wayland/):
  - serial.rs: add SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; add latest_serial_of()
  - window.rs: activate() uses keyboard-enter serial (Mutter focus gate)

Mutter honors window activation only when the token carries the keyboard-
focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial.
See docs/tray-window-focus-wayland.md in logiguard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 02:22:17 +03:30
commit b72a46db68
3984 changed files with 1583326 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
[package]
name = "settings_ui"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/settings_ui.rs"
[features]
default = []
test-support = []
[dependencies]
agent.workspace = true
agent_settings.workspace = true
anyhow.workspace = true
audio.workspace = true
component.workspace = true
codestral.workspace = true
copilot.workspace = true
copilot_ui.workspace = true
cpal.workspace = true
edit_prediction.workspace = true
edit_prediction_ui.workspace = true
editor.workspace = true
fs.workspace = true
feature_flags.workspace = true
futures.workspace = true
fuzzy.workspace = true
gpui.workspace = true
heck.workspace = true
itertools.workspace = true
language.workspace = true
log.workspace = true
menu.workspace = true
paths.workspace = true
picker.workspace = true
regex.workspace = true
platform_title_bar.workspace = true
project.workspace = true
release_channel.workspace = true
rodio.workspace = true
schemars.workspace = true
search.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
shell_command_parser.workspace = true
strum.workspace = true
telemetry.workspace = true
theme.workspace = true
theme_settings.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
zed_actions.workspace = true
zed_credentials_provider.workspace = true
[dev-dependencies]
fs = { workspace = true, features = ["test-support"] }
futures.workspace = true
gpui = { workspace = true, features = ["test-support"] }
paths.workspace = true
pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
serde_json.workspace = true
settings = { workspace = true, features = ["test-support"] }
title_bar = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }

View File

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

View File

@@ -0,0 +1,17 @@
mod dropdown;
mod font_picker;
mod icon_theme_picker;
mod input_field;
mod number_field;
mod ollama_model_picker;
mod section_items;
mod theme_picker;
pub use dropdown::*;
pub use font_picker::font_picker;
pub use icon_theme_picker::icon_theme_picker;
pub use input_field::*;
pub use number_field::*;
pub use ollama_model_picker::render_ollama_model_picker;
pub use section_items::*;
pub use theme_picker::theme_picker;

View File

@@ -0,0 +1,108 @@
use std::rc::Rc;
use gpui::{App, ElementId, IntoElement, RenderOnce};
use heck::ToTitleCase as _;
use ui::{
ButtonSize, ContextMenu, DropdownMenu, DropdownStyle, FluentBuilder as _, IconPosition, px,
};
#[derive(IntoElement)]
pub struct EnumVariantDropdown<T>
where
T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + Sync + 'static,
{
id: ElementId,
current_value: T,
variants: &'static [T],
labels: &'static [&'static str],
should_do_title_case: bool,
tab_index: Option<isize>,
on_change: Rc<dyn Fn(T, &mut ui::Window, &mut App) + 'static>,
}
impl<T> EnumVariantDropdown<T>
where
T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + Sync + 'static,
{
pub fn new(
id: impl Into<ElementId>,
current_value: T,
variants: &'static [T],
labels: &'static [&'static str],
on_change: impl Fn(T, &mut ui::Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
current_value,
variants,
labels,
should_do_title_case: true,
tab_index: None,
on_change: Rc::new(on_change),
}
}
pub fn title_case(mut self, title_case: bool) -> Self {
self.should_do_title_case = title_case;
self
}
pub fn tab_index(mut self, tab_index: isize) -> Self {
self.tab_index = Some(tab_index);
self
}
}
impl<T> RenderOnce for EnumVariantDropdown<T>
where
T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + Sync + 'static,
{
fn render(self, window: &mut ui::Window, cx: &mut ui::App) -> impl gpui::IntoElement {
let current_value_label = self.labels[self
.variants
.iter()
.position(|v| *v == self.current_value)
.unwrap()];
let context_menu = window.use_keyed_state(current_value_label, cx, |window, cx| {
ContextMenu::new(window, cx, move |mut menu, _, _| {
for (&value, &label) in std::iter::zip(self.variants, self.labels) {
let on_change = self.on_change.clone();
let current_value = self.current_value;
menu = menu.toggleable_entry(
if self.should_do_title_case {
label.to_title_case()
} else {
label.to_string()
},
value == current_value,
IconPosition::End,
None,
move |window, cx| {
on_change(value, window, cx);
},
);
}
menu
})
});
DropdownMenu::new(
self.id,
if self.should_do_title_case {
current_value_label.to_title_case()
} else {
current_value_label.to_string()
},
context_menu,
)
.when_some(self.tab_index, |elem, tab_index| elem.tab_index(tab_index))
.trigger_size(ButtonSize::Medium)
.style(DropdownStyle::Outlined)
.offset(gpui::Point {
x: px(0.0),
y: px(2.0),
})
.into_any_element()
}
}

View File

@@ -0,0 +1,181 @@
use std::sync::Arc;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{AnyElement, App, Context, DismissEvent, SharedString, Task, Window};
use picker::{Picker, PickerDelegate};
use theme::FontFamilyCache;
use ui::{ListItem, ListItemSpacing, prelude::*};
type FontPicker = Picker<FontPickerDelegate>;
pub struct FontPickerDelegate {
fonts: Vec<SharedString>,
filtered_fonts: Vec<StringMatch>,
selected_index: usize,
current_font: SharedString,
on_font_changed: Arc<dyn Fn(SharedString, &mut Window, &mut App) + 'static>,
}
impl FontPickerDelegate {
fn new(
current_font: SharedString,
on_font_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
cx: &mut Context<FontPicker>,
) -> Self {
let font_family_cache = FontFamilyCache::global(cx);
let fonts = font_family_cache
.try_list_font_families()
.unwrap_or_else(|| vec![current_font.clone()]);
let selected_index = fonts
.iter()
.position(|font| *font == current_font)
.unwrap_or(0);
let filtered_fonts = fonts
.iter()
.enumerate()
.map(|(index, font)| StringMatch {
candidate_id: index,
string: font.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect();
Self {
fonts,
filtered_fonts,
selected_index,
current_font,
on_font_changed: Arc::new(on_font_changed),
}
}
}
impl PickerDelegate for FontPickerDelegate {
type ListItem = AnyElement;
fn match_count(&self) -> usize {
self.filtered_fonts.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<FontPicker>) {
self.selected_index = ix.min(self.filtered_fonts.len().saturating_sub(1));
cx.notify();
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Search fonts…".into()
}
fn update_matches(
&mut self,
query: String,
_window: &mut Window,
cx: &mut Context<FontPicker>,
) -> Task<()> {
let fonts = self.fonts.clone();
let current_font = self.current_font.clone();
let matches: Vec<StringMatch> = if query.is_empty() {
fonts
.iter()
.enumerate()
.map(|(index, font)| StringMatch {
candidate_id: index,
string: font.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect()
} else {
let _candidates: Vec<StringMatchCandidate> = fonts
.iter()
.enumerate()
.map(|(id, font)| StringMatchCandidate::new(id, font.as_ref()))
.collect();
fonts
.iter()
.enumerate()
.filter(|(_, font)| font.to_lowercase().contains(&query.to_lowercase()))
.map(|(index, font)| StringMatch {
candidate_id: index,
string: font.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect()
};
let selected_index = if query.is_empty() {
fonts
.iter()
.position(|font| *font == current_font)
.unwrap_or(0)
} else {
matches
.iter()
.position(|m| fonts[m.candidate_id] == current_font)
.unwrap_or(0)
};
self.filtered_fonts = matches;
self.selected_index = selected_index;
cx.notify();
Task::ready(())
}
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<FontPicker>) {
if let Some(font_match) = self.filtered_fonts.get(self.selected_index) {
let font = font_match.string.clone();
(self.on_font_changed)(font.into(), window, cx);
}
}
fn dismissed(&mut self, window: &mut Window, cx: &mut Context<FontPicker>) {
cx.defer_in(window, |picker, window, cx| {
picker.set_query("", window, cx);
});
cx.emit(DismissEvent);
}
fn render_match(
&self,
ix: usize,
selected: bool,
_window: &mut Window,
_cx: &mut Context<FontPicker>,
) -> Option<Self::ListItem> {
let font_match = self.filtered_fonts.get(ix)?;
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(Label::new(font_match.string.clone()))
.into_any_element(),
)
}
}
pub fn font_picker(
current_font: SharedString,
on_font_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
window: &mut Window,
cx: &mut Context<FontPicker>,
) -> FontPicker {
let delegate = FontPickerDelegate::new(current_font, on_font_changed, cx);
Picker::uniform_list(delegate, window, cx)
.show_scrollbar(true)
.width(rems_from_px(210.))
.max_height(Some(rems(18.).into()))
}

View File

@@ -0,0 +1,194 @@
use std::sync::Arc;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{AnyElement, App, Context, DismissEvent, SharedString, Task, Window};
use picker::{Picker, PickerDelegate};
use theme::ThemeRegistry;
use ui::{ListItem, ListItemSpacing, prelude::*};
type IconThemePicker = Picker<IconThemePickerDelegate>;
pub struct IconThemePickerDelegate {
icon_themes: Vec<SharedString>,
filtered_themes: Vec<StringMatch>,
selected_index: usize,
current_theme: SharedString,
on_theme_changed: Arc<dyn Fn(SharedString, &mut Window, &mut App) + 'static>,
}
impl IconThemePickerDelegate {
fn new(
current_theme: SharedString,
on_theme_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
cx: &mut Context<IconThemePicker>,
) -> Self {
let theme_registry = ThemeRegistry::global(cx);
let icon_themes: Vec<SharedString> = theme_registry
.list_icon_themes()
.into_iter()
.map(|theme_meta| theme_meta.name)
.collect();
let selected_index = icon_themes
.iter()
.position(|icon_theme| *icon_theme == current_theme)
.unwrap_or(0);
let filtered_themes = icon_themes
.iter()
.enumerate()
.map(|(index, theme)| StringMatch {
candidate_id: index,
string: theme.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect();
Self {
icon_themes,
filtered_themes,
selected_index,
current_theme,
on_theme_changed: Arc::new(on_theme_changed),
}
}
}
impl PickerDelegate for IconThemePickerDelegate {
type ListItem = AnyElement;
fn match_count(&self) -> usize {
self.filtered_themes.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
index: usize,
_window: &mut Window,
cx: &mut Context<IconThemePicker>,
) {
self.selected_index = index.min(self.filtered_themes.len().saturating_sub(1));
cx.notify();
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Search icon themes…".into()
}
fn update_matches(
&mut self,
query: String,
_window: &mut Window,
cx: &mut Context<IconThemePicker>,
) -> Task<()> {
let icon_themes = self.icon_themes.clone();
let current_theme = self.current_theme.clone();
let matches: Vec<StringMatch> = if query.is_empty() {
icon_themes
.iter()
.enumerate()
.map(|(index, theme)| StringMatch {
candidate_id: index,
string: theme.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect()
} else {
let _candidates: Vec<StringMatchCandidate> = icon_themes
.iter()
.enumerate()
.map(|(id, theme)| StringMatchCandidate::new(id, theme.as_ref()))
.collect();
icon_themes
.iter()
.enumerate()
.filter(|(_, theme)| theme.to_lowercase().contains(&query.to_lowercase()))
.map(|(index, theme)| StringMatch {
candidate_id: index,
string: theme.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect()
};
let selected_index = if query.is_empty() {
icon_themes
.iter()
.position(|theme| *theme == current_theme)
.unwrap_or(0)
} else {
matches
.iter()
.position(|m| icon_themes[m.candidate_id] == current_theme)
.unwrap_or(0)
};
self.filtered_themes = matches;
self.selected_index = selected_index;
cx.notify();
Task::ready(())
}
fn confirm(
&mut self,
_secondary: bool,
window: &mut Window,
cx: &mut Context<IconThemePicker>,
) {
if let Some(theme_match) = self.filtered_themes.get(self.selected_index) {
let theme = theme_match.string.clone();
(self.on_theme_changed)(theme.into(), window, cx);
}
}
fn dismissed(&mut self, window: &mut Window, cx: &mut Context<IconThemePicker>) {
cx.defer_in(window, |picker, window, cx| {
picker.set_query("", window, cx);
});
cx.emit(DismissEvent);
}
fn render_match(
&self,
index: usize,
selected: bool,
_window: &mut Window,
_cx: &mut Context<IconThemePicker>,
) -> Option<Self::ListItem> {
let theme_match = self.filtered_themes.get(index)?;
Some(
ListItem::new(index)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(Label::new(theme_match.string.clone()))
.into_any_element(),
)
}
}
pub fn icon_theme_picker(
current_theme: SharedString,
on_theme_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
window: &mut Window,
cx: &mut Context<IconThemePicker>,
) -> IconThemePicker {
let delegate = IconThemePickerDelegate::new(current_theme, on_theme_changed, cx);
Picker::uniform_list(delegate, window, cx)
.show_scrollbar(true)
.width(rems_from_px(210.))
.max_height(Some(rems(18.).into()))
}

View File

@@ -0,0 +1,318 @@
use std::rc::Rc;
use editor::Editor;
use gpui::{AnyElement, ElementId, Focusable, TextStyleRefinement};
use settings::Settings as _;
use theme_settings::ThemeSettings;
use ui::{Tooltip, prelude::*, rems};
#[derive(IntoElement)]
pub struct SettingsInputField {
id: Option<ElementId>,
initial_text: Option<String>,
placeholder: Option<&'static str>,
confirm: Option<Rc<dyn Fn(Option<String>, &mut Window, &mut App)>>,
tab_index: Option<isize>,
use_buffer_font: bool,
display_confirm_button: bool,
display_clear_button: bool,
clear_on_confirm: bool,
action_slot: Option<AnyElement>,
color: Option<Color>,
}
impl SettingsInputField {
pub fn new() -> Self {
Self {
id: None,
initial_text: None,
placeholder: None,
confirm: None,
tab_index: None,
use_buffer_font: false,
display_confirm_button: false,
display_clear_button: false,
clear_on_confirm: false,
action_slot: None,
color: None,
}
}
pub fn with_id(mut self, id: impl Into<ElementId>) -> Self {
self.id = Some(id.into());
self
}
pub fn with_initial_text(mut self, initial_text: String) -> Self {
self.initial_text = Some(initial_text);
self
}
pub fn with_placeholder(mut self, placeholder: &'static str) -> Self {
self.placeholder = Some(placeholder);
self
}
pub fn on_confirm(
mut self,
confirm: impl Fn(Option<String>, &mut Window, &mut App) + 'static,
) -> Self {
self.confirm = Some(Rc::new(confirm));
self
}
pub fn display_confirm_button(mut self) -> Self {
self.display_confirm_button = true;
self
}
pub fn display_clear_button(mut self) -> Self {
self.display_clear_button = true;
self
}
pub fn clear_on_confirm(mut self) -> Self {
self.clear_on_confirm = true;
self
}
pub fn action_slot(mut self, action: impl IntoElement) -> Self {
self.action_slot = Some(action.into_any_element());
self
}
pub(crate) fn tab_index(mut self, arg: isize) -> Self {
self.tab_index = Some(arg);
self
}
pub fn with_buffer_font(mut self) -> Self {
self.use_buffer_font = true;
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
}
impl RenderOnce for SettingsInputField {
fn render(self, window: &mut Window, cx: &mut App) -> impl ui::IntoElement {
let settings = ThemeSettings::get_global(cx);
let use_buffer_font = self.use_buffer_font;
let color = self.color.map(|c| c.color(cx));
let styles = TextStyleRefinement {
font_family: use_buffer_font.then(|| settings.buffer_font.family.clone()),
font_size: use_buffer_font.then(|| rems(0.75).into()),
color,
..Default::default()
};
let first_render_initial_text = window.use_state(cx, |_, _| self.initial_text.clone());
let editor = if let Some(id) = self.id {
window.use_keyed_state(id, cx, {
let initial_text = self.initial_text.clone();
let placeholder = self.placeholder;
let mut confirm = self.confirm.clone();
move |window, cx| {
let mut editor = Editor::single_line(window, cx);
let editor_focus_handle = editor.focus_handle(cx);
if let Some(text) = initial_text {
editor.set_text(text, window, cx);
}
if let Some(confirm) = confirm.take()
&& !self.display_confirm_button
&& !self.display_clear_button
&& !self.clear_on_confirm
{
cx.on_focus_out(
&editor_focus_handle,
window,
move |editor, _, window, cx| {
let text = Some(editor.text(cx));
confirm(text, window, cx);
},
)
.detach();
}
if let Some(placeholder) = placeholder {
editor.set_placeholder_text(placeholder, window, cx);
}
editor.set_text_style_refinement(styles);
editor
}
})
} else {
window.use_state(cx, {
let initial_text = self.initial_text.clone();
let placeholder = self.placeholder;
let mut confirm = self.confirm.clone();
move |window, cx| {
let mut editor = Editor::single_line(window, cx);
let editor_focus_handle = editor.focus_handle(cx);
if let Some(text) = initial_text {
editor.set_text(text, window, cx);
}
if let Some(confirm) = confirm.take()
&& !self.display_confirm_button
&& !self.display_clear_button
&& !self.clear_on_confirm
{
cx.on_focus_out(
&editor_focus_handle,
window,
move |editor, _, window, cx| {
let text = Some(editor.text(cx));
confirm(text, window, cx);
},
)
.detach();
}
if let Some(placeholder) = placeholder {
editor.set_placeholder_text(placeholder, window, cx);
}
editor.set_text_style_refinement(styles);
editor
}
})
};
// When settings change externally (e.g. editing settings.json), the page
// re-renders but use_keyed_state returns the cached editor with stale text.
// Reconcile with the expected initial_text when the editor is not focused,
// so we don't clobber what the user is actively typing.
if let Some(initial_text) = &self.initial_text
&& let Some(first_initial) = first_render_initial_text.read(cx)
{
if initial_text != first_initial && !editor.read(cx).is_focused(window) {
*first_render_initial_text.as_mut(cx) = self.initial_text.clone();
let weak_editor = editor.downgrade();
let initial_text = initial_text.clone();
window.defer(cx, move |window, cx| {
weak_editor
.update(cx, |editor, cx| {
editor.set_text(initial_text, window, cx);
})
.ok();
});
}
}
let weak_editor = editor.downgrade();
let weak_editor_for_button = editor.downgrade();
let weak_editor_for_clear = editor.downgrade();
let clear_on_confirm = self.clear_on_confirm;
let clear_on_confirm_for_button = self.clear_on_confirm;
let theme_colors = cx.theme().colors();
let display_confirm_button = self.display_confirm_button;
let display_clear_button = self.display_clear_button;
let confirm_for_button = self.confirm.clone();
let is_editor_empty = editor.read(cx).text(cx).trim().is_empty();
let is_editor_focused = editor.read(cx).is_focused(window);
h_flex()
.group("settings-input-field-editor")
.relative()
.py_1()
.px_2()
.h_8()
.min_w_64()
.rounded_md()
.border_1()
.border_color(theme_colors.border)
.bg(theme_colors.editor_background)
.when_some(self.tab_index, |this, tab_index| {
let focus_handle = editor.focus_handle(cx).tab_index(tab_index).tab_stop(true);
this.track_focus(&focus_handle)
.focus(|s| s.border_color(theme_colors.border_focused))
})
.child(editor)
.child(
h_flex()
.absolute()
.top_1()
.right_1()
.invisible()
.when(is_editor_focused, |this| this.visible())
.group_hover("settings-input-field-editor", |this| this.visible())
.when(
display_clear_button && !is_editor_empty && is_editor_focused,
|this| {
this.child(
IconButton::new("clear-button", IconName::Close)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text("Clear"))
.on_click(move |_, window, cx| {
let Some(editor) = weak_editor_for_clear.upgrade() else {
return;
};
editor.update(cx, |editor, cx| {
editor.set_text("", window, cx);
});
}),
)
},
)
.when(
display_confirm_button && !is_editor_empty && is_editor_focused,
|this| {
this.child(
IconButton::new("confirm-button", IconName::Check)
.icon_size(IconSize::Small)
.icon_color(Color::Success)
.tooltip(Tooltip::text("Enter to Confirm"))
.on_click(move |_, window, cx| {
let Some(confirm) = confirm_for_button.as_ref() else {
return;
};
let Some(editor) = weak_editor_for_button.upgrade() else {
return;
};
let new_value =
editor.read_with(cx, |editor, cx| editor.text(cx));
let new_value =
(!new_value.is_empty()).then_some(new_value);
confirm(new_value, window, cx);
if clear_on_confirm_for_button {
editor.update(cx, |editor, cx| {
editor.set_text("", window, cx);
});
}
}),
)
},
)
.when_some(self.action_slot, |this, action| this.child(action)),
)
.when_some(self.confirm, |this, confirm| {
this.on_action::<menu::Confirm>({
move |_, window, cx| {
let Some(editor) = weak_editor.upgrade() else {
return;
};
let new_value = editor.read_with(cx, |editor, cx| editor.text(cx));
let new_value = (!new_value.is_empty()).then_some(new_value);
confirm(new_value, window, cx);
if clear_on_confirm {
editor.update(cx, |editor, cx| {
editor.set_text("", window, cx);
});
}
}
})
})
}
}

View File

@@ -0,0 +1,769 @@
use std::{
fmt::Display,
num::{NonZero, NonZeroU32, NonZeroU64},
rc::Rc,
str::FromStr,
};
use editor::Editor;
use gpui::{
ClickEvent, Entity, FocusHandle, Focusable, FontWeight, Modifiers, TextAlign,
TextStyleRefinement, WeakEntity,
};
use settings::{
CenteredPaddingSettings, CodeFade, DelayMs, FontSize, FontWeightContent, InactiveOpacity,
MinimumContrast,
};
use ui::prelude::*;
use zed_actions::editor::{MoveDown, MoveUp};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NumberFieldMode {
#[default]
Read,
Edit,
}
pub trait NumberFieldType: Display + Copy + Clone + Sized + PartialOrd + FromStr + 'static {
fn default_format(value: &Self) -> String {
format!("{}", value)
}
fn default_step() -> Self;
fn large_step() -> Self;
fn small_step() -> Self;
fn min_value() -> Self;
fn max_value() -> Self;
fn saturating_add(self, rhs: Self) -> Self;
fn saturating_sub(self, rhs: Self) -> Self;
}
macro_rules! impl_newtype_numeric_stepper_float {
($type:ident, $default:expr, $large:expr, $small:expr, $min:expr, $max:expr) => {
impl NumberFieldType for $type {
fn default_step() -> Self {
$default.into()
}
fn large_step() -> Self {
$large.into()
}
fn small_step() -> Self {
$small.into()
}
fn min_value() -> Self {
$min.into()
}
fn max_value() -> Self {
$max.into()
}
fn saturating_add(self, rhs: Self) -> Self {
$type((self.0 + rhs.0).min(Self::max_value().0))
}
fn saturating_sub(self, rhs: Self) -> Self {
$type((self.0 - rhs.0).max(Self::min_value().0))
}
}
};
}
macro_rules! impl_newtype_numeric_stepper_int {
($type:ident, $default:expr, $large:expr, $small:expr, $min:expr, $max:expr) => {
impl NumberFieldType for $type {
fn default_step() -> Self {
$default.into()
}
fn large_step() -> Self {
$large.into()
}
fn small_step() -> Self {
$small.into()
}
fn min_value() -> Self {
$min.into()
}
fn max_value() -> Self {
$max.into()
}
fn saturating_add(self, rhs: Self) -> Self {
$type(self.0.saturating_add(rhs.0).min(Self::max_value().0))
}
fn saturating_sub(self, rhs: Self) -> Self {
$type(self.0.saturating_sub(rhs.0).max(Self::min_value().0))
}
}
};
}
#[rustfmt::skip]
impl_newtype_numeric_stepper_float!(FontWeight, 50., 100., 10., FontWeight::THIN, FontWeight::BLACK);
impl_newtype_numeric_stepper_float!(
FontWeightContent,
50.,
100.,
10.,
FontWeightContent::THIN,
FontWeightContent::BLACK
);
impl_newtype_numeric_stepper_float!(CodeFade, 0.1, 0.2, 0.05, 0.0, 0.9);
impl_newtype_numeric_stepper_float!(FontSize, 1.0, 4.0, 0.5, 6.0, 72.0);
impl_newtype_numeric_stepper_float!(InactiveOpacity, 0.1, 0.2, 0.05, 0.0, 1.0);
impl_newtype_numeric_stepper_float!(MinimumContrast, 1., 10., 0.5, 0.0, 106.0);
impl_newtype_numeric_stepper_int!(DelayMs, 100, 500, 10, 0, 2000);
impl_newtype_numeric_stepper_float!(
CenteredPaddingSettings,
0.05,
0.2,
0.1,
CenteredPaddingSettings::MIN_PADDING,
CenteredPaddingSettings::MAX_PADDING
);
macro_rules! impl_numeric_stepper_int {
($type:ident) => {
impl NumberFieldType for $type {
fn default_step() -> Self {
1
}
fn large_step() -> Self {
10
}
fn small_step() -> Self {
1
}
fn min_value() -> Self {
<$type>::MIN
}
fn max_value() -> Self {
<$type>::MAX
}
fn saturating_add(self, rhs: Self) -> Self {
self.saturating_add(rhs)
}
fn saturating_sub(self, rhs: Self) -> Self {
self.saturating_sub(rhs)
}
}
};
}
macro_rules! impl_numeric_stepper_nonzero_int {
($nonzero:ty, $inner:ty) => {
impl NumberFieldType for $nonzero {
fn default_step() -> Self {
<$nonzero>::new(1).unwrap()
}
fn large_step() -> Self {
<$nonzero>::new(10).unwrap()
}
fn small_step() -> Self {
<$nonzero>::new(1).unwrap()
}
fn min_value() -> Self {
<$nonzero>::MIN
}
fn max_value() -> Self {
<$nonzero>::MAX
}
fn saturating_add(self, rhs: Self) -> Self {
let result = self.get().saturating_add(rhs.get());
<$nonzero>::new(result.max(1)).unwrap()
}
fn saturating_sub(self, rhs: Self) -> Self {
let result = self.get().saturating_sub(rhs.get()).max(1);
<$nonzero>::new(result).unwrap()
}
}
};
}
macro_rules! impl_numeric_stepper_float {
($type:ident) => {
impl NumberFieldType for $type {
fn default_format(value: &Self) -> String {
format!("{:.2}", value)
}
fn default_step() -> Self {
1.0
}
fn large_step() -> Self {
10.0
}
fn small_step() -> Self {
0.1
}
fn min_value() -> Self {
<$type>::MIN
}
fn max_value() -> Self {
<$type>::MAX
}
fn saturating_add(self, rhs: Self) -> Self {
(self + rhs).clamp(Self::min_value(), Self::max_value())
}
fn saturating_sub(self, rhs: Self) -> Self {
(self - rhs).clamp(Self::min_value(), Self::max_value())
}
}
};
}
impl_numeric_stepper_float!(f32);
impl_numeric_stepper_float!(f64);
impl_numeric_stepper_int!(isize);
impl_numeric_stepper_int!(usize);
impl_numeric_stepper_int!(i32);
impl_numeric_stepper_int!(u32);
impl_numeric_stepper_int!(i64);
impl_numeric_stepper_int!(u64);
impl_numeric_stepper_nonzero_int!(NonZeroU32, u32);
impl_numeric_stepper_nonzero_int!(NonZeroU64, u64);
impl_numeric_stepper_nonzero_int!(NonZero<usize>, usize);
type OnChangeCallback<T> = Rc<dyn Fn(&T, &mut Window, &mut App) + 'static>;
#[derive(IntoElement, RegisterComponent)]
pub struct NumberField<T: NumberFieldType = usize> {
id: ElementId,
value: T,
focus_handle: FocusHandle,
mode: Entity<NumberFieldMode>,
/// Stores a weak reference to the editor when in edit mode, so buttons can update its text
edit_editor: Entity<Option<WeakEntity<Editor>>>,
/// Stores the on_change callback in Entity state so it's not stale in focus_out handlers
on_change_state: Entity<Option<OnChangeCallback<T>>>,
/// Tracks the last prop value we synced to, so we can detect external changes (like reset)
last_synced_value: Entity<Option<T>>,
format: Box<dyn FnOnce(&T) -> String>,
large_step: T,
small_step: T,
step: T,
min_value: T,
max_value: T,
on_reset: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_change: Rc<dyn Fn(&T, &mut Window, &mut App) + 'static>,
tab_index: Option<isize>,
}
impl<T: NumberFieldType> NumberField<T> {
pub fn new(id: impl Into<ElementId>, value: T, window: &mut Window, cx: &mut App) -> Self {
let id = id.into();
let (mode, focus_handle, edit_editor, on_change_state, last_synced_value) =
window.with_id(id.clone(), |window| {
let mode = window.use_state(cx, |_, _| NumberFieldMode::default());
let focus_handle = window.use_state(cx, |_, cx| cx.focus_handle());
let edit_editor = window.use_state(cx, |_, _| None);
let on_change_state: Entity<Option<OnChangeCallback<T>>> =
window.use_state(cx, |_, _| None);
let last_synced_value: Entity<Option<T>> = window.use_state(cx, |_, _| None);
(
mode,
focus_handle,
edit_editor,
on_change_state,
last_synced_value,
)
});
Self {
id,
mode,
edit_editor,
on_change_state,
last_synced_value,
value,
focus_handle: focus_handle.read(cx).clone(),
format: Box::new(T::default_format),
large_step: T::large_step(),
step: T::default_step(),
small_step: T::small_step(),
min_value: T::min_value(),
max_value: T::max_value(),
on_reset: None,
on_change: Rc::new(|_, _, _| {}),
tab_index: None,
}
}
pub fn min(mut self, min: T) -> Self {
self.min_value = min;
self
}
pub fn max(mut self, max: T) -> Self {
self.max_value = max;
self
}
pub fn mode(self, mode: NumberFieldMode, cx: &mut App) -> Self {
self.mode.write(cx, mode);
self
}
pub fn tab_index(mut self, tab_index: isize) -> Self {
self.tab_index = Some(tab_index);
self
}
pub fn on_change(mut self, on_change: impl Fn(&T, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Rc::new(on_change);
self
}
fn sync_on_change_state(&self, cx: &mut App) {
self.on_change_state
.update(cx, |state, _| *state = Some(self.on_change.clone()));
}
}
#[derive(Clone, Copy)]
enum ValueChangeDirection {
Increment,
Decrement,
}
impl<T: NumberFieldType> RenderOnce for NumberField<T> {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
// Sync the on_change callback to Entity state so focus_out handlers can access it
self.sync_on_change_state(cx);
let is_edit_mode = matches!(*self.mode.read(cx), NumberFieldMode::Edit);
let get_step = {
let large_step = self.large_step;
let step = self.step;
let small_step = self.small_step;
move |modifiers: Modifiers| -> T {
if modifiers.shift {
large_step
} else if modifiers.alt {
small_step
} else {
step
}
}
};
let clamp_value = {
let min = self.min_value;
let max = self.max_value;
move |value: T| -> T {
if value < min {
min
} else if value > max {
max
} else {
value
}
}
};
let change_value = {
move |current: T, step: T, direction: ValueChangeDirection| -> T {
let new_value = match direction {
ValueChangeDirection::Increment => current.saturating_add(step),
ValueChangeDirection::Decrement => current.saturating_sub(step),
};
clamp_value(new_value)
}
};
let get_current_value = {
let value = self.value;
let edit_editor = self.edit_editor.clone();
Rc::new(move |cx: &App| -> T {
if !is_edit_mode {
return value;
}
edit_editor
.read(cx)
.as_ref()
.and_then(|weak| weak.upgrade())
.and_then(|editor| editor.read(cx).text(cx).parse::<T>().ok())
.unwrap_or(value)
})
};
let update_editor_text = {
let edit_editor = self.edit_editor.clone();
Rc::new(move |new_value: T, window: &mut Window, cx: &mut App| {
if !is_edit_mode {
return;
}
let Some(editor) = edit_editor
.read(cx)
.as_ref()
.and_then(|weak| weak.upgrade())
else {
return;
};
editor.update(cx, |editor, cx| {
editor.set_text(format!("{}", new_value), window, cx);
});
})
};
let bg_color = cx.theme().colors().surface_background;
let hover_bg_color = cx.theme().colors().element_hover;
let border_color = cx.theme().colors().border_variant;
let focus_border_color = cx.theme().colors().border_focused;
let base_button = |icon: IconName| {
h_flex()
.cursor_pointer()
.p_1p5()
.size_full()
.justify_center()
.overflow_hidden()
.border_1()
.border_color(border_color)
.bg(bg_color)
.hover(|s| s.bg(hover_bg_color))
.focus_visible(|s| s.border_color(focus_border_color).bg(hover_bg_color))
.child(Icon::new(icon).size(IconSize::Small))
};
h_flex()
.id(self.id.clone())
.track_focus(&self.focus_handle)
.gap_1()
.when_some(self.on_reset, |this, on_reset| {
this.child(
IconButton::new("reset", IconName::RotateCcw)
.icon_size(IconSize::Small)
.when_some(self.tab_index, |this, _| this.tab_index(0isize))
.on_click(on_reset),
)
})
.child({
let on_change_for_increment = self.on_change.clone();
h_flex()
.map(|decrement| {
let decrement_handler = {
let on_change = self.on_change.clone();
let get_current_value = get_current_value.clone();
let update_editor_text = update_editor_text.clone();
move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
let current_value = get_current_value(cx);
let step = get_step(click.modifiers());
let new_value = change_value(
current_value,
step,
ValueChangeDirection::Decrement,
);
update_editor_text(new_value, window, cx);
on_change(&new_value, window, cx);
}
};
decrement.child(
base_button(IconName::Dash)
.id((self.id.clone(), "decrement_button"))
.rounded_tl_sm()
.rounded_bl_sm()
.when_some(self.tab_index, |this, _| this.tab_index(0isize))
.on_click(decrement_handler),
)
})
.child({
h_flex()
.min_w_16()
.size_full()
.border_y_1()
.border_color(border_color)
.bg(bg_color)
.in_focus(|this| this.border_color(focus_border_color))
.child(match *self.mode.read(cx) {
NumberFieldMode::Read => h_flex()
.px_1()
.flex_1()
.justify_center()
.child(
Label::new((self.format)(&self.value)).color(Color::Muted),
)
.into_any_element(),
NumberFieldMode::Edit => {
let expected_text = format!("{}", self.value);
let editor = window.use_state(cx, {
let expected_text = expected_text.clone();
move |window, cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_text_style_refinement(TextStyleRefinement {
color: Some(cx.theme().colors().text),
text_align: Some(TextAlign::Center),
..Default::default()
});
editor.set_text(expected_text, window, cx);
let editor_weak = cx.entity().downgrade();
self.edit_editor.update(cx, |state, _| {
*state = Some(editor_weak);
});
editor
.register_action::<MoveUp>({
let on_change = self.on_change.clone();
let editor_handle = cx.entity().downgrade();
move |_, window, cx| {
let Some(editor) = editor_handle.upgrade()
else {
return;
};
editor.update(cx, |editor, cx| {
if let Ok(current_value) =
editor.text(cx).parse::<T>()
{
let step =
get_step(window.modifiers());
let new_value = change_value(
current_value,
step,
ValueChangeDirection::Increment,
);
editor.set_text(
format!("{}", new_value),
window,
cx,
);
on_change(&new_value, window, cx);
}
});
}
})
.detach();
editor
.register_action::<MoveDown>({
let on_change = self.on_change.clone();
let editor_handle = cx.entity().downgrade();
move |_, window, cx| {
let Some(editor) = editor_handle.upgrade()
else {
return;
};
editor.update(cx, |editor, cx| {
if let Ok(current_value) =
editor.text(cx).parse::<T>()
{
let step =
get_step(window.modifiers());
let new_value = change_value(
current_value,
step,
ValueChangeDirection::Decrement,
);
editor.set_text(
format!("{}", new_value),
window,
cx,
);
on_change(&new_value, window, cx);
}
});
}
})
.detach();
cx.on_focus_out(&editor.focus_handle(cx), window, {
let on_change_state = self.on_change_state.clone();
move |this, _, window, cx| {
if let Ok(parsed_value) =
this.text(cx).parse::<T>()
{
let new_value = clamp_value(parsed_value);
let on_change =
on_change_state.read(cx).clone();
if let Some(on_change) = on_change.as_ref()
{
on_change(&new_value, window, cx);
}
};
}
})
.detach();
editor
}
});
let focus_handle = editor.focus_handle(cx);
let is_focused = focus_handle.is_focused(window);
if !is_focused {
let current_text = editor.read(cx).text(cx);
let last_synced = *self.last_synced_value.read(cx);
// Detect if the value changed externally (e.g., reset button)
let value_changed_externally = last_synced
.map(|last| last != self.value)
.unwrap_or(true);
let should_sync = if value_changed_externally {
true
} else {
match current_text.parse::<T>().ok() {
Some(parsed) => parsed == self.value,
None => true,
}
};
if should_sync && current_text != expected_text {
editor.update(cx, |editor, cx| {
editor.set_text(expected_text.clone(), window, cx);
});
}
self.last_synced_value
.update(cx, |state, _| *state = Some(self.value));
}
let focus_handle = if self.tab_index.is_some() {
focus_handle.tab_index(0isize).tab_stop(true)
} else {
focus_handle
};
h_flex()
.flex_1()
.h_full()
.track_focus(&focus_handle)
.when(is_focused, |this| {
this.border_1()
.border_color(cx.theme().colors().border_focused)
})
.child(editor)
.on_action::<menu::Confirm>({
move |_, window, _| {
window.blur();
}
})
.into_any_element()
}
})
})
.map(|increment| {
let increment_handler = {
let on_change = on_change_for_increment.clone();
let get_current_value = get_current_value.clone();
let update_editor_text = update_editor_text.clone();
move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
let current_value = get_current_value(cx);
let step = get_step(click.modifiers());
let new_value = change_value(
current_value,
step,
ValueChangeDirection::Increment,
);
update_editor_text(new_value, window, cx);
on_change(&new_value, window, cx);
}
};
increment.child(
base_button(IconName::Plus)
.id((self.id.clone(), "increment_button"))
.rounded_tr_sm()
.rounded_br_sm()
.when_some(self.tab_index, |this, _| this.tab_index(0isize))
.on_click(increment_handler),
)
})
})
}
}
impl Component for NumberField<usize> {
fn scope() -> ComponentScope {
ComponentScope::Input
}
fn name() -> &'static str {
"Number Field"
}
fn description() -> Option<&'static str> {
Some("A numeric input element with increment and decrement buttons.")
}
fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let default_ex = window.use_state(cx, |_, _| 100.0);
let edit_ex = window.use_state(cx, |_, _| 500.0);
Some(
v_flex()
.gap_6()
.children(vec![
single_example(
"Button-Only Number Field",
NumberField::new("number-field", *default_ex.read(cx), window, cx)
.on_change({
let default_ex = default_ex.clone();
move |value, _, cx| default_ex.write(cx, *value)
})
.min(1.0)
.max(100.0)
.into_any_element(),
),
single_example(
"Editable Number Field",
NumberField::new("editable-number-field", *edit_ex.read(cx), window, cx)
.on_change({
let edit_ex = edit_ex.clone();
move |value, _, cx| edit_ex.write(cx, *value)
})
.min(100.0)
.max(500.0)
.mode(NumberFieldMode::Edit, cx)
.into_any_element(),
),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,214 @@
use std::sync::Arc;
use fuzzy::StringMatch;
use gpui::{AnyElement, App, Context, DismissEvent, ReadGlobal, SharedString, Task, Window, px};
use picker::{Picker, PickerDelegate};
use settings::SettingsStore;
use ui::{ListItem, ListItemSpacing, PopoverMenu, prelude::*};
use util::ResultExt;
use crate::{
SettingField, SettingsFieldMetadata, SettingsUiFile, render_picker_trigger_button,
update_settings_file,
};
type OllamaModelPicker = Picker<OllamaModelPickerDelegate>;
struct OllamaModelPickerDelegate {
models: Vec<SharedString>,
filtered_models: Vec<StringMatch>,
selected_index: usize,
on_model_changed: Arc<dyn Fn(SharedString, &mut Window, &mut App) + 'static>,
}
impl OllamaModelPickerDelegate {
fn new(
current_model: SharedString,
on_model_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
cx: &mut Context<OllamaModelPicker>,
) -> Self {
let mut models = edit_prediction::ollama::fetch_models(cx);
let current_in_list = models.contains(&current_model);
if !current_model.is_empty() && !current_in_list {
models.insert(0, current_model.clone());
}
let selected_index = models
.iter()
.position(|model| *model == current_model)
.unwrap_or(0);
let filtered_models = models
.iter()
.enumerate()
.map(|(index, model)| StringMatch {
candidate_id: index,
string: model.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect();
Self {
models,
filtered_models,
selected_index,
on_model_changed: Arc::new(on_model_changed),
}
}
}
impl PickerDelegate for OllamaModelPickerDelegate {
type ListItem = AnyElement;
fn match_count(&self) -> usize {
self.filtered_models.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_: &mut Window,
cx: &mut Context<OllamaModelPicker>,
) {
self.selected_index = ix.min(self.filtered_models.len().saturating_sub(1));
cx.notify();
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Search models…".into()
}
fn update_matches(
&mut self,
query: String,
_window: &mut Window,
cx: &mut Context<OllamaModelPicker>,
) -> Task<()> {
let query_lower = query.to_lowercase();
self.filtered_models = self
.models
.iter()
.enumerate()
.filter(|(_, model)| query.is_empty() || model.to_lowercase().contains(&query_lower))
.map(|(index, model)| StringMatch {
candidate_id: index,
string: model.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect();
self.selected_index = 0;
cx.notify();
Task::ready(())
}
fn confirm(
&mut self,
_secondary: bool,
window: &mut Window,
cx: &mut Context<OllamaModelPicker>,
) {
let Some(model_match) = self.filtered_models.get(self.selected_index) else {
return;
};
(self.on_model_changed)(model_match.string.clone().into(), window, cx);
cx.emit(DismissEvent);
}
fn dismissed(&mut self, window: &mut Window, cx: &mut Context<OllamaModelPicker>) {
cx.defer_in(window, |picker, window, cx| {
picker.set_query("", window, cx);
});
cx.emit(DismissEvent);
}
fn render_match(
&self,
ix: usize,
selected: bool,
_window: &mut Window,
_cx: &mut Context<OllamaModelPicker>,
) -> Option<Self::ListItem> {
let model_match = self.filtered_models.get(ix)?;
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(Label::new(model_match.string.clone()))
.into_any_element(),
)
}
}
pub fn render_ollama_model_picker(
field: SettingField<settings::OllamaModelName>,
file: SettingsUiFile,
_metadata: Option<&SettingsFieldMetadata>,
_window: &mut Window,
cx: &mut App,
) -> AnyElement {
let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
let current_value: SharedString = value
.map(|m| m.0.clone().into())
.unwrap_or_else(|| "".into());
PopoverMenu::new("ollama-model-picker")
.trigger(render_picker_trigger_button(
"ollama_model_picker_trigger".into(),
if current_value.is_empty() {
"Select a model…".into()
} else {
current_value.clone()
},
))
.menu(move |window, cx| {
Some(cx.new(|cx| {
let file = file.clone();
let current_value = current_value.clone();
let delegate = OllamaModelPickerDelegate::new(
current_value,
move |model_name, window, cx| {
update_settings_file(
file.clone(),
field.json_path,
window,
cx,
move |settings, app| {
(field.write)(
settings,
Some(settings::OllamaModelName(model_name.to_string())),
app,
);
},
)
.log_err();
},
cx,
);
Picker::uniform_list(delegate, window, cx)
.show_scrollbar(true)
.width(rems_from_px(210.))
.max_height(Some(rems(18.).into()))
}))
})
.anchor(gpui::Anchor::TopLeft)
.offset(gpui::Point {
x: px(0.0),
y: px(2.0),
})
.with_handle(ui::PopoverMenuHandle::default())
.into_any_element()
}

View File

@@ -0,0 +1,56 @@
use gpui::{IntoElement, ParentElement, Styled};
use ui::{Divider, DividerColor, prelude::*};
#[derive(IntoElement)]
pub struct SettingsSectionHeader {
icon: Option<IconName>,
label: SharedString,
no_padding: bool,
}
impl SettingsSectionHeader {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
icon: None,
no_padding: false,
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
pub fn no_padding(mut self, no_padding: bool) -> Self {
self.no_padding = no_padding;
self
}
}
impl RenderOnce for SettingsSectionHeader {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let label = Label::new(self.label)
.size(LabelSize::Small)
.color(Color::Muted)
.buffer_font(cx);
v_flex()
.w_full()
.when(!self.no_padding, |this| this.px_8())
.gap_1p5()
.map(|this| {
if let Some(icon) = self.icon {
this.child(
h_flex()
.gap_1p5()
.child(Icon::new(icon).color(Color::Muted))
.child(label),
)
} else {
this.child(label)
}
})
.child(Divider::horizontal().color(DividerColor::BorderFaded))
}
}

View File

@@ -0,0 +1,179 @@
use std::sync::Arc;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{AnyElement, App, Context, DismissEvent, SharedString, Task, Window};
use picker::{Picker, PickerDelegate};
use theme::ThemeRegistry;
use ui::{ListItem, ListItemSpacing, prelude::*};
type ThemePicker = Picker<ThemePickerDelegate>;
pub struct ThemePickerDelegate {
themes: Vec<SharedString>,
filtered_themes: Vec<StringMatch>,
selected_index: usize,
current_theme: SharedString,
on_theme_changed: Arc<dyn Fn(SharedString, &mut Window, &mut App) + 'static>,
}
impl ThemePickerDelegate {
fn new(
current_theme: SharedString,
on_theme_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
cx: &mut Context<ThemePicker>,
) -> Self {
let theme_registry = ThemeRegistry::global(cx);
let themes = theme_registry.list_names();
let selected_index = themes
.iter()
.position(|theme| *theme == current_theme)
.unwrap_or(0);
let filtered_themes = themes
.iter()
.enumerate()
.map(|(index, theme)| StringMatch {
candidate_id: index,
string: theme.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect();
Self {
themes,
filtered_themes,
selected_index,
current_theme,
on_theme_changed: Arc::new(on_theme_changed),
}
}
}
impl PickerDelegate for ThemePickerDelegate {
type ListItem = AnyElement;
fn match_count(&self) -> usize {
self.filtered_themes.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<ThemePicker>) {
self.selected_index = ix.min(self.filtered_themes.len().saturating_sub(1));
cx.notify();
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Search theme…".into()
}
fn update_matches(
&mut self,
query: String,
_window: &mut Window,
cx: &mut Context<ThemePicker>,
) -> Task<()> {
let themes = self.themes.clone();
let current_theme = self.current_theme.clone();
let matches: Vec<StringMatch> = if query.is_empty() {
themes
.iter()
.enumerate()
.map(|(index, theme)| StringMatch {
candidate_id: index,
string: theme.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect()
} else {
let _candidates: Vec<StringMatchCandidate> = themes
.iter()
.enumerate()
.map(|(id, theme)| StringMatchCandidate::new(id, theme.as_ref()))
.collect();
themes
.iter()
.enumerate()
.filter(|(_, theme)| theme.to_lowercase().contains(&query.to_lowercase()))
.map(|(index, theme)| StringMatch {
candidate_id: index,
string: theme.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect()
};
let selected_index = if query.is_empty() {
themes
.iter()
.position(|theme| *theme == current_theme)
.unwrap_or(0)
} else {
matches
.iter()
.position(|m| themes[m.candidate_id] == current_theme)
.unwrap_or(0)
};
self.filtered_themes = matches;
self.selected_index = selected_index;
cx.notify();
Task::ready(())
}
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<ThemePicker>) {
if let Some(theme_match) = self.filtered_themes.get(self.selected_index) {
let theme = theme_match.string.clone();
(self.on_theme_changed)(theme.into(), window, cx);
}
}
fn dismissed(&mut self, window: &mut Window, cx: &mut Context<ThemePicker>) {
cx.defer_in(window, |picker, window, cx| {
picker.set_query("", window, cx);
});
cx.emit(DismissEvent);
}
fn render_match(
&self,
ix: usize,
selected: bool,
_window: &mut Window,
_cx: &mut Context<ThemePicker>,
) -> Option<Self::ListItem> {
let theme_match = self.filtered_themes.get(ix)?;
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(Label::new(theme_match.string.clone()))
.into_any_element(),
)
}
}
pub fn theme_picker(
current_theme: SharedString,
on_theme_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
window: &mut Window,
cx: &mut Context<ThemePicker>,
) -> ThemePicker {
let delegate = ThemePickerDelegate::new(current_theme, on_theme_changed, cx);
Picker::uniform_list(delegate, window, cx)
.show_scrollbar(true)
.width(rems_from_px(210.))
.max_height(Some(rems(18.).into()))
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
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::{
render_input_audio_device_dropdown, render_output_audio_device_dropdown,
};
pub(crate) use audio_test_window::open_audio_test_window;
pub(crate) use edit_prediction_provider_setup::render_edit_prediction_setup_page;
pub(crate) use feature_flags::render_feature_flags_page;
pub(crate) use tool_permissions_setup::render_tool_permissions_setup_page;
pub use tool_permissions_setup::{
render_copy_path_tool_config, render_create_directory_tool_config,
render_delete_path_tool_config, render_edit_file_tool_config, render_fetch_tool_config,
render_move_path_tool_config, render_terminal_tool_config, render_web_search_tool_config,
render_write_file_tool_config,
};

View File

@@ -0,0 +1,153 @@
use audio::{AudioDeviceInfo, AvailableAudioDevices};
use cpal::DeviceId;
use gpui::{AnyElement, App, ElementId, ReadGlobal, SharedString, Window};
use settings::{AudioInputDeviceName, AudioOutputDeviceName, SettingsStore};
use std::str::FromStr;
use ui::{ContextMenu, DropdownMenu, DropdownStyle, IconPosition, IntoElement};
use util::ResultExt;
use crate::{SettingField, SettingsFieldMetadata, SettingsUiFile, update_settings_file};
pub(crate) const SYSTEM_DEFAULT: &str = "System Default";
pub(crate) fn get_current_device(
current_id: Option<&DeviceId>,
is_input: bool,
devices: &[AudioDeviceInfo],
) -> Option<AudioDeviceInfo> {
let Some(current_id) = current_id else {
return None;
};
devices
.iter()
.find(|d| d.matches(current_id, is_input))
.cloned()
}
pub(crate) fn render_audio_device_dropdown<F>(
dropdown_id: impl Into<ElementId>,
current_device_id: Option<DeviceId>,
is_input: bool,
on_select: F,
window: &mut Window,
cx: &mut App,
) -> AnyElement
where
F: Fn(Option<DeviceId>, &mut Window, &mut App) + Clone + 'static,
{
audio::ensure_devices_initialized(cx);
let devices = cx.global::<AvailableAudioDevices>().0.clone();
let current_device = get_current_device(current_device_id.as_ref(), is_input, &devices);
let menu = ContextMenu::build(window, cx, {
let current_device = current_device.clone();
move |mut menu, _, _cx| {
let is_system_default = current_device.is_none();
menu = menu.toggleable_entry(
SYSTEM_DEFAULT,
is_system_default,
IconPosition::Start,
None,
{
let on_select = on_select.clone();
move |window, cx| {
on_select(None, window, cx);
}
},
);
for device in devices.iter().filter(|d| d.matches_input(is_input)) {
let is_current = current_device
.as_ref()
.map(|info| info.matches(&device.id, is_input))
.unwrap_or(false);
let device_id = device.id.clone();
menu = menu.toggleable_entry(
device.to_string(),
is_current,
IconPosition::Start,
None,
{
let on_select = on_select.clone();
move |window, cx| {
on_select(Some(device_id.clone()), window, cx);
}
},
);
}
menu
}
});
DropdownMenu::new(
dropdown_id,
current_device
.map(|info| info.desc.name().to_string())
.unwrap_or(SYSTEM_DEFAULT.to_string()),
menu,
)
.style(DropdownStyle::Outlined)
.full_width(true)
.into_any_element()
}
fn render_settings_audio_device_dropdown<T: AsRef<Option<String>> + From<Option<String>> + Send>(
field: SettingField<T>,
file: SettingsUiFile,
is_input: bool,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let (_, current_value): (_, Option<&T>) =
SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
let current_device_id =
current_value.and_then(|x| x.as_ref().clone().and_then(|x| DeviceId::from_str(&x).ok()));
let dropdown_id: SharedString = if is_input {
"input-audio-device-dropdown".into()
} else {
"output-audio-device-dropdown".into()
};
render_audio_device_dropdown(
dropdown_id,
current_device_id,
is_input,
move |device_id, window, cx| {
let value: Option<T> = device_id.map(|id| T::from(Some(id.to_string())));
update_settings_file(
file.clone(),
field.json_path,
window,
cx,
move |settings, app| {
(field.write)(settings, value, app);
},
)
.log_err();
},
window,
cx,
)
}
pub fn render_input_audio_device_dropdown(
field: SettingField<AudioInputDeviceName>,
file: SettingsUiFile,
_metadata: Option<&SettingsFieldMetadata>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
render_settings_audio_device_dropdown(field, file, true, window, cx)
}
pub fn render_output_audio_device_dropdown(
field: SettingField<AudioOutputDeviceName>,
file: SettingsUiFile,
_metadata: Option<&SettingsFieldMetadata>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
render_settings_audio_device_dropdown(field, file, false, window, cx)
}

View File

@@ -0,0 +1,304 @@
use audio::{AudioSettings, CHANNEL_COUNT, RodioExt, SAMPLE_RATE};
use cpal::DeviceId;
use gpui::{
App, Context, Entity, FocusHandle, Focusable, Render, Size, Tiling, Window, WindowBounds,
WindowKind, WindowOptions, prelude::*, px,
};
use platform_title_bar::PlatformTitleBar;
use release_channel::ReleaseChannel;
use rodio::Source;
use settings::{AudioInputDeviceName, AudioOutputDeviceName, Settings};
use std::{
any::Any,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
time::Duration,
};
use ui::{Button, ButtonStyle, Label, prelude::*};
use util::ResultExt;
use workspace::client_side_decorations;
use super::audio_input_output_setup::render_audio_device_dropdown;
use crate::{SettingsUiFile, update_settings_file};
pub struct AudioTestWindow {
title_bar: Option<Entity<PlatformTitleBar>>,
input_device_id: Option<DeviceId>,
output_device_id: Option<DeviceId>,
focus_handle: FocusHandle,
_stop_playback: Option<Box<dyn Any + Send>>,
}
impl AudioTestWindow {
pub fn new(cx: &mut Context<Self>) -> Self {
let title_bar = if !cfg!(target_os = "macos") {
Some(cx.new(|cx| PlatformTitleBar::new("audio-test-title-bar", cx)))
} else {
None
};
let audio_settings = AudioSettings::get_global(cx);
let input_device_id = audio_settings.input_audio_device.clone();
let output_device_id = audio_settings.output_audio_device.clone();
Self {
title_bar,
input_device_id,
output_device_id,
focus_handle: cx.focus_handle(),
_stop_playback: None,
}
}
fn toggle_testing(&mut self, cx: &mut Context<Self>) {
if let Some(_cb) = self._stop_playback.take() {
cx.notify();
return;
}
if let Some(cb) =
start_test_playback(self.input_device_id.clone(), self.output_device_id.clone()).ok()
{
self._stop_playback = Some(cb);
}
cx.notify();
}
}
fn start_test_playback(
input_device_id: Option<DeviceId>,
output_device_id: Option<DeviceId>,
) -> anyhow::Result<Box<dyn Any + Send>> {
let stop_signal = Arc::new(AtomicBool::new(false));
thread::Builder::new()
.name("AudioTestPlayback".to_string())
.spawn({
let stop_signal = stop_signal.clone();
move || {
let microphone = match open_test_microphone(input_device_id, stop_signal.clone()) {
Ok(mic) => mic,
Err(e) => {
log::error!("Could not open microphone for audio test: {e}");
return;
}
};
let Ok(output) = audio::open_test_output(output_device_id) else {
log::error!("Could not open output device for audio test");
return;
};
// let microphone = rx.recv().unwrap();
output.mixer().add(microphone);
// Keep thread (and output device) alive until stop signal
while !stop_signal.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(100));
}
}
})?;
Ok(Box::new(util::defer(move || {
stop_signal.store(true, Ordering::Relaxed);
})))
}
fn open_test_microphone(
input_device_id: Option<DeviceId>,
stop_signal: Arc<AtomicBool>,
) -> anyhow::Result<impl Source> {
let stream = audio::open_input_stream(input_device_id)?;
let stream = stream
.possibly_disconnected_channels_to_mono()
.constant_samplerate(SAMPLE_RATE)
.constant_params(CHANNEL_COUNT, SAMPLE_RATE)
.stoppable()
.periodic_access(
Duration::from_millis(50),
move |stoppable: &mut rodio::source::Stoppable<_>| {
if stop_signal.load(Ordering::Relaxed) {
stoppable.stop();
}
},
);
Ok(stream)
}
impl Render for AudioTestWindow {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_testing = self._stop_playback.is_some();
let button_text = if is_testing {
"Stop Testing"
} else {
"Start Testing"
};
let button_style = if is_testing {
ButtonStyle::Tinted(ui::TintColor::Error)
} else {
ButtonStyle::Filled
};
let weak_entity = cx.entity().downgrade();
let input_dropdown = {
let weak_entity = weak_entity.clone();
render_audio_device_dropdown(
"audio-test-input-dropdown",
self.input_device_id.clone(),
true,
move |device_id, window, cx| {
weak_entity
.update(cx, |this, cx| {
this.input_device_id = device_id.clone();
cx.notify();
})
.log_err();
let value: Option<AudioInputDeviceName> =
device_id.map(|id| AudioInputDeviceName(Some(id.to_string())));
update_settings_file(
SettingsUiFile::User,
Some("audio.experimental.input_audio_device"),
window,
cx,
move |settings, _cx| {
settings.audio.get_or_insert_default().input_audio_device = value;
},
)
.log_err();
},
window,
cx,
)
};
let output_dropdown = render_audio_device_dropdown(
"audio-test-output-dropdown",
self.output_device_id.clone(),
false,
move |device_id, window, cx| {
weak_entity
.update(cx, |this, cx| {
this.output_device_id = device_id.clone();
cx.notify();
})
.log_err();
let value: Option<AudioOutputDeviceName> =
device_id.map(|id| AudioOutputDeviceName(Some(id.to_string())));
update_settings_file(
SettingsUiFile::User,
Some("audio.experimental.output_audio_device"),
window,
cx,
move |settings, _cx| {
settings.audio.get_or_insert_default().output_audio_device = value;
},
)
.log_err();
},
window,
cx,
);
let content = v_flex()
.id("audio-test-window")
.track_focus(&self.focus_handle)
.size_full()
.p_4()
.when(cfg!(target_os = "macos"), |this| this.pt_10())
.gap_4()
.bg(cx.theme().colors().editor_background)
.child(
v_flex()
.gap_1()
.child(Label::new("Output Device"))
.child(output_dropdown),
)
.child(
v_flex()
.gap_1()
.child(Label::new("Input Device"))
.child(input_dropdown),
)
.child(
h_flex().w_full().justify_center().pt_4().child(
Button::new("test-audio-toggle", button_text)
.style(button_style)
.on_click(cx.listener(|this, _, _, cx| this.toggle_testing(cx))),
),
);
client_side_decorations(
v_flex()
.size_full()
.text_color(cx.theme().colors().text)
.children(self.title_bar.clone())
.child(content),
window,
cx,
Tiling::default(),
)
}
}
impl Focusable for AudioTestWindow {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Drop for AudioTestWindow {
fn drop(&mut self) {
let _ = self._stop_playback.take();
}
}
pub fn open_audio_test_window(_window: &mut Window, cx: &mut App) {
let existing = cx
.windows()
.into_iter()
.find_map(|w| w.downcast::<AudioTestWindow>());
if let Some(existing) = existing {
existing
.update(cx, |_, window, _| window.activate_window())
.log_err();
return;
}
let app_id = ReleaseChannel::global(cx).app_id();
let window_size = Size {
width: px(640.0),
height: px(300.0),
};
let window_min_size = Size {
width: px(400.0),
height: px(240.0),
};
cx.open_window(
WindowOptions {
titlebar: Some(gpui::TitlebarOptions {
title: Some("Audio Test".into()),
appears_transparent: true,
traffic_light_position: Some(gpui::point(px(12.0), px(12.0))),
}),
focus: true,
show: true,
is_movable: true,
kind: WindowKind::Normal,
window_background: cx.theme().window_background_appearance(),
app_id: Some(app_id.to_owned()),
window_decorations: Some(gpui::WindowDecorations::Client),
window_bounds: Some(WindowBounds::centered(window_size, cx)),
window_min_size: Some(window_min_size),
..Default::default()
},
|_, cx| cx.new(AudioTestWindow::new),
)
.log_err();
}

View File

@@ -0,0 +1,748 @@
use codestral::{CODESTRAL_API_URL, codestral_api_key_state, codestral_api_url};
use edit_prediction::{
ApiKeyState,
mercury::{MERCURY_CREDENTIALS_URL, mercury_api_token},
open_ai_compatible::{open_ai_compatible_api_token, open_ai_compatible_api_url},
};
use edit_prediction_ui::{get_available_providers, set_completion_provider};
use gpui::{App, Entity, ScrollHandle, TaskExt, prelude::*};
use language::language_settings::AllLanguageSettings;
use settings::Settings as _;
use ui::{ButtonLink, ConfiguredApiCard, ContextMenu, DropdownMenu, DropdownStyle, prelude::*};
use workspace::AppState;
const OLLAMA_API_URL_PLACEHOLDER: &str = "http://localhost:11434";
const OLLAMA_MODEL_PLACEHOLDER: &str = "qwen2.5-coder:3b-base";
use crate::{
SettingField, SettingItem, SettingsFieldMetadata, SettingsPageItem, SettingsWindow, USER,
components::{SettingsInputField, SettingsSectionHeader},
};
pub(crate) fn render_edit_prediction_setup_page(
settings_window: &SettingsWindow,
scroll_handle: &ScrollHandle,
window: &mut Window,
cx: &mut Context<SettingsWindow>,
) -> AnyElement {
let providers = [
Some(render_provider_dropdown(window, cx)),
render_github_copilot_provider(window, cx).map(IntoElement::into_any_element),
Some(
render_api_key_provider(
IconName::Inception,
"Mercury",
ApiKeyDocs::Link {
dashboard_url: "https://platform.inceptionlabs.ai/dashboard/api-keys".into(),
},
mercury_api_token(cx),
|_cx| MERCURY_CREDENTIALS_URL,
None,
window,
cx,
)
.into_any_element(),
),
Some(
render_api_key_provider(
IconName::AiMistral,
"Codestral",
ApiKeyDocs::Link {
dashboard_url: "https://console.mistral.ai/codestral".into(),
},
codestral_api_key_state(cx),
|cx| codestral_api_url(cx),
Some(
settings_window
.render_sub_page_items_section(
codestral_settings().iter().enumerate(),
true,
window,
cx,
)
.into_any_element(),
),
window,
cx,
)
.into_any_element(),
),
Some(render_ollama_provider(settings_window, window, cx).into_any_element()),
Some(
render_api_key_provider(
IconName::AiOpenAiCompat,
"OpenAI Compatible API",
ApiKeyDocs::Custom {
message: "The API key sent as Authorization: Bearer {key}.".into(),
},
open_ai_compatible_api_token(cx),
|cx| open_ai_compatible_api_url(cx),
Some(
settings_window
.render_sub_page_items_section(
open_ai_compatible_settings().iter().enumerate(),
true,
window,
cx,
)
.into_any_element(),
),
window,
cx,
)
.into_any_element(),
),
];
div()
.size_full()
.child(
v_flex()
.id("ep-setup-page")
.min_w_0()
.size_full()
.px_8()
.pb_16()
.overflow_y_scroll()
.track_scroll(&scroll_handle)
.children(providers.into_iter().flatten()),
)
.into_any_element()
}
fn render_provider_dropdown(window: &mut Window, cx: &mut App) -> AnyElement {
let current_provider = AllLanguageSettings::get_global(cx)
.edit_predictions
.provider;
let current_provider_name = current_provider.display_name().unwrap_or("No provider set");
let menu = ContextMenu::build(window, cx, move |mut menu, _, cx| {
let available_providers = get_available_providers(cx);
let fs = <dyn fs::Fs>::global(cx);
for provider in available_providers {
let Some(name) = provider.display_name() else {
continue;
};
let is_current = provider == current_provider;
menu = menu.toggleable_entry(name, is_current, IconPosition::Start, None, {
let fs = fs.clone();
move |_, cx| {
set_completion_provider(fs.clone(), cx, provider);
}
});
}
menu
});
v_flex()
.id("provider-selector")
.min_w_0()
.gap_1p5()
.child(SettingsSectionHeader::new("Active Provider").no_padding(true))
.child(
h_flex()
.pt_2p5()
.w_full()
.min_w_0()
.justify_between()
.child(
v_flex()
.w_full()
.min_w_0()
.max_w_1_2()
.child(Label::new("Provider"))
.child(
Label::new("Select which provider to use for edit predictions.")
.size(LabelSize::Small)
.color(Color::Muted),
),
)
.child(
DropdownMenu::new("provider-dropdown", current_provider_name, menu)
.tab_index(0)
.style(DropdownStyle::Outlined),
),
)
.into_any_element()
}
enum ApiKeyDocs {
Link { dashboard_url: SharedString },
Custom { message: SharedString },
}
fn render_api_key_provider(
icon: IconName,
title: &'static str,
docs: ApiKeyDocs,
api_key_state: Entity<ApiKeyState>,
current_url: fn(&mut App) -> SharedString,
additional_fields: Option<AnyElement>,
window: &mut Window,
cx: &mut Context<SettingsWindow>,
) -> impl IntoElement {
let weak_page = cx.weak_entity();
let credentials_provider = zed_credentials_provider::global(cx);
_ = window.use_keyed_state(current_url(cx), cx, |_, cx| {
let task = api_key_state.update(cx, |key_state, cx| {
key_state.load_if_needed(
current_url(cx),
|state| state,
credentials_provider.clone(),
cx,
)
});
cx.spawn(async move |_, cx| {
task.await.ok();
weak_page
.update(cx, |_, cx| {
cx.notify();
})
.ok();
})
});
let (has_key, env_var_name, is_from_env_var) = api_key_state.read_with(cx, |state, _| {
(
state.has_key(),
Some(state.env_var_name().clone()),
state.is_from_env_var(),
)
});
let write_key = move |api_key: Option<String>, cx: &mut App| {
let credentials_provider = zed_credentials_provider::global(cx);
api_key_state
.update(cx, |key_state, cx| {
let url = current_url(cx);
key_state.store(
url,
api_key,
|key_state| key_state,
credentials_provider,
cx,
)
})
.detach_and_log_err(cx);
};
let base_container = v_flex().id(title).min_w_0().pt_8().gap_1p5();
let header = SettingsSectionHeader::new(title)
.icon(icon)
.no_padding(true);
let button_link_label = format!("{} dashboard", title);
let description = match docs {
ApiKeyDocs::Custom { message } => div().min_w_0().w_full().child(
Label::new(message)
.size(LabelSize::Small)
.color(Color::Muted),
),
ApiKeyDocs::Link { dashboard_url } => h_flex()
.w_full()
.min_w_0()
.flex_wrap()
.gap_0p5()
.child(
Label::new("Visit the")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
ButtonLink::new(button_link_label, dashboard_url)
.no_icon(true)
.label_size(LabelSize::Small)
.label_color(Color::Muted),
)
.child(
Label::new("to generate an API key.")
.size(LabelSize::Small)
.color(Color::Muted),
),
};
let configured_card_label = if is_from_env_var {
"API Key Set in Environment Variable"
} else {
"API Key Configured"
};
let container = if has_key {
base_container.child(header).child(
ConfiguredApiCard::new(configured_card_label)
.button_label("Reset Key")
.button_tab_index(0)
.disabled(is_from_env_var)
.when_some(env_var_name, |this, env_var_name| {
this.when(is_from_env_var, |this| {
this.tooltip_label(format!(
"To reset your API key, unset the {} environment variable.",
env_var_name
))
})
})
.on_click(move |_, _, cx| {
write_key(None, cx);
}),
)
} else {
base_container.child(header).child(
h_flex()
.pt_2p5()
.w_full()
.min_w_0()
.justify_between()
.child(
v_flex()
.w_full()
.min_w_0()
.max_w_1_2()
.child(Label::new("API Key"))
.child(description)
.when_some(env_var_name, |this, env_var_name| {
this.child({
let label = format!(
"Or set the {} env var and restart Zed.",
env_var_name.as_ref()
);
Label::new(label).size(LabelSize::Small).color(Color::Muted)
})
}),
)
.child(
SettingsInputField::new()
.tab_index(0)
.with_placeholder("xxxxxxxxxxxxxxxxxxxx")
.on_confirm(move |api_key, _window, cx| {
write_key(api_key.filter(|key| !key.is_empty()), cx);
}),
),
)
};
container.when_some(additional_fields, |this, additional_fields| {
this.child(
div()
.map(|this| if has_key { this.mt_1() } else { this.mt_4() })
.px_neg_8()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(additional_fields),
)
})
}
fn render_ollama_provider(
settings_window: &SettingsWindow,
window: &mut Window,
cx: &mut Context<SettingsWindow>,
) -> impl IntoElement {
let ollama_settings = ollama_settings();
let additional_fields = settings_window
.render_sub_page_items_section(ollama_settings.iter().enumerate(), true, window, cx)
.into_any_element();
v_flex()
.id("ollama")
.min_w_0()
.pt_8()
.gap_1p5()
.child(
SettingsSectionHeader::new("Ollama")
.icon(IconName::AiOllama)
.no_padding(true),
)
.child(div().px_neg_8().child(additional_fields))
}
fn ollama_settings() -> Box<[SettingsPageItem]> {
Box::new([
SettingsPageItem::SettingItem(SettingItem {
title: "API URL",
description: "The base URL of your Ollama server.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.ollama
.as_ref()?
.api_url
.as_ref()
},
write: |settings, value, _app: &App| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.ollama
.get_or_insert_default()
.api_url = value;
},
json_path: Some("edit_predictions.ollama.api_url"),
}),
metadata: Some(Box::new(SettingsFieldMetadata {
placeholder: Some(OLLAMA_API_URL_PLACEHOLDER),
..Default::default()
})),
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Model",
description: "The Ollama model to use for edit predictions.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.ollama
.as_ref()?
.model
.as_ref()
},
write: |settings, value, _app: &App| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.ollama
.get_or_insert_default()
.model = value;
},
json_path: Some("edit_predictions.ollama.model"),
}),
metadata: Some(Box::new(SettingsFieldMetadata {
placeholder: Some(OLLAMA_MODEL_PLACEHOLDER),
..Default::default()
})),
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Prompt Format",
description: "The prompt format to use when requesting predictions. Set to Infer to have the format inferred based on the model name.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.ollama
.as_ref()?
.prompt_format
.as_ref()
},
write: |settings, value, _app: &App| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.ollama
.get_or_insert_default()
.prompt_format = value;
},
json_path: Some("edit_predictions.ollama.prompt_format"),
}),
files: USER,
metadata: None,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Max Output Tokens",
description: "The maximum number of tokens to generate.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.ollama
.as_ref()?
.max_output_tokens
.as_ref()
},
write: |settings, value, _app: &App| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.ollama
.get_or_insert_default()
.max_output_tokens = value;
},
json_path: Some("edit_predictions.ollama.max_output_tokens"),
}),
metadata: None,
files: USER,
}),
])
}
fn open_ai_compatible_settings() -> Box<[SettingsPageItem]> {
Box::new([
SettingsPageItem::SettingItem(SettingItem {
title: "API URL",
description: "The URL of your OpenAI-compatible server's completions API.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.open_ai_compatible_api
.as_ref()?
.api_url
.as_ref()
},
write: |settings, value, _app: &App| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.open_ai_compatible_api
.get_or_insert_default()
.api_url = value;
},
json_path: Some("edit_predictions.open_ai_compatible_api.api_url"),
}),
metadata: Some(Box::new(SettingsFieldMetadata {
placeholder: Some(OLLAMA_API_URL_PLACEHOLDER),
..Default::default()
})),
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Model",
description: "The model string to pass to the OpenAI-compatible server.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.open_ai_compatible_api
.as_ref()?
.model
.as_ref()
},
write: |settings, value, _app: &App| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.open_ai_compatible_api
.get_or_insert_default()
.model = value;
},
json_path: Some("edit_predictions.open_ai_compatible_api.model"),
}),
metadata: Some(Box::new(SettingsFieldMetadata {
placeholder: Some(OLLAMA_MODEL_PLACEHOLDER),
..Default::default()
})),
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Prompt Format",
description: "The prompt format to use when requesting predictions. Set to Infer to have the format inferred based on the model name.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.open_ai_compatible_api
.as_ref()?
.prompt_format
.as_ref()
},
write: |settings, value, _app: &App| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.open_ai_compatible_api
.get_or_insert_default()
.prompt_format = value;
},
json_path: Some("edit_predictions.open_ai_compatible_api.prompt_format"),
}),
files: USER,
metadata: None,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Max Output Tokens",
description: "The maximum number of tokens to generate.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.open_ai_compatible_api
.as_ref()?
.max_output_tokens
.as_ref()
},
write: |settings, value, _app: &App| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.open_ai_compatible_api
.get_or_insert_default()
.max_output_tokens = value;
},
json_path: Some("edit_predictions.open_ai_compatible_api.max_output_tokens"),
}),
metadata: None,
files: USER,
}),
])
}
fn codestral_settings() -> Box<[SettingsPageItem]> {
Box::new([
SettingsPageItem::SettingItem(SettingItem {
title: "API URL",
description: "The API URL to use for Codestral.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.codestral
.as_ref()?
.api_url
.as_ref()
},
write: |settings, value, _app: &App| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.codestral
.get_or_insert_default()
.api_url = value;
},
json_path: Some("edit_predictions.codestral.api_url"),
}),
metadata: Some(Box::new(SettingsFieldMetadata {
placeholder: Some(CODESTRAL_API_URL),
..Default::default()
})),
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Max Tokens",
description: "The maximum number of tokens to generate.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.codestral
.as_ref()?
.max_tokens
.as_ref()
},
write: |settings, value, _app: &App| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.codestral
.get_or_insert_default()
.max_tokens = value;
},
json_path: Some("edit_predictions.codestral.max_tokens"),
}),
metadata: None,
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Model",
description: "The Codestral model id to use.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.codestral
.as_ref()?
.model
.as_ref()
},
write: |settings, value, _app: &App| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.codestral
.get_or_insert_default()
.model = value;
},
json_path: Some("edit_predictions.codestral.model"),
}),
metadata: Some(Box::new(SettingsFieldMetadata {
placeholder: Some("codestral-latest"),
..Default::default()
})),
files: USER,
}),
])
}
fn render_github_copilot_provider(window: &mut Window, cx: &mut App) -> Option<impl IntoElement> {
let configuration_view = window.use_state(cx, |_, cx| {
copilot_ui::ConfigurationView::new(
move |cx| {
let app_state = AppState::global(cx);
copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx)
.is_some_and(|copilot| copilot.0.read(cx).is_authenticated())
},
copilot_ui::ConfigurationMode::EditPrediction,
cx,
)
});
Some(
v_flex()
.id("github-copilot")
.min_w_0()
.pt_8()
.gap_1p5()
.child(
SettingsSectionHeader::new("GitHub Copilot")
.icon(IconName::Copilot)
.no_padding(true),
)
.child(configuration_view),
)
}

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff