On Linux with client-side decorations, opening the workspace sidebar squared off the window corners on the sidebar's side while the rest of the window stayed rounded: Before: <img width="366" height="239" alt="image" src="https://github.com/user-attachments/assets/e0ee8d28-6143-42ea-bfd2-10d069df5492" /> After: <img width="426" height="219" alt="image" src="https://github.com/user-attachments/assets/51d2979d-0103-4742-90bd-a97f5837d99b" /> Previously, `client_side_decorations` took a `border_radius_tiling` override that `MultiWorkspace` used to deliberately square the window shape on the sidebar's side, since the sidebar painted a square background that would otherwise poke out past the rounded window border. The title bar and status bar already skip rounding their corners on the sidebar side, implying the sidebar is expected to own those window corners — it just never rounded them. This PR makes the sidebar round its outer corners the same way the title bar and status bar do (including overlapping the 1px window border on untiled edges to avoid a transparent gap in the rounded corners), and removes the now-unneeded `border_radius_tiling` parameter so the window border and backdrop round purely based on actual tiling state. Only applies when the window uses client-side decorations, so macOS and Windows are unaffected. Fixes #54724 Release Notes: - Fixed square window corners on the sidebar side of client-decorated (Linux) windows when the workspace sidebar is open. --------- Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
6796 lines
250 KiB
Rust
6796 lines
250 KiB
Rust
mod components;
|
|
mod page_data;
|
|
pub mod pages;
|
|
|
|
use agent_skills::SkillIndex;
|
|
use anyhow::{Context as _, Result};
|
|
use cloud_api_types::OrganizationConfiguration;
|
|
use editor::{Editor, EditorEvent};
|
|
use futures::{StreamExt, channel::mpsc};
|
|
use fuzzy::StringMatchCandidate;
|
|
use gpui::{
|
|
Action, App, AsyncApp, ClipboardItem, DEFAULT_ADDITIONAL_WINDOW_SIZE, Div, Entity, FocusHandle,
|
|
Focusable, Global, KeyContext, ListState, ReadGlobal as _, Role, ScrollHandle, Stateful,
|
|
Subscription, Task, TitlebarOptions, UniformListScrollHandle, WeakEntity, Window, WindowBounds,
|
|
WindowHandle, WindowOptions, actions, div, list, point, prelude::*, px, uniform_list,
|
|
};
|
|
|
|
use language::Buffer;
|
|
use platform_title_bar::PlatformTitleBar;
|
|
use project::{Project, ProjectPath, Worktree, WorktreeId};
|
|
use release_channel::ReleaseChannel;
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
use settings::{
|
|
IntoGpui, Settings, SettingsContent, SettingsStore, initial_project_settings_content,
|
|
};
|
|
use std::{
|
|
any::{Any, TypeId, type_name},
|
|
cell::RefCell,
|
|
collections::{HashMap, HashSet},
|
|
num::{NonZero, NonZeroU32},
|
|
ops::Range,
|
|
path::PathBuf,
|
|
rc::Rc,
|
|
sync::{Arc, LazyLock, RwLock},
|
|
time::Duration,
|
|
};
|
|
use theme_settings::ThemeSettings;
|
|
use ui::{
|
|
Banner, ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, KeyBinding,
|
|
KeybindingHint, PopoverMenu, Scrollbars, Switch, Tooltip, TreeViewItem, WithScrollbar,
|
|
prelude::*,
|
|
};
|
|
|
|
use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath};
|
|
use workspace::{
|
|
AppState, MultiWorkspace, OpenOptions, OpenVisible, Workspace, WorkspaceSettings,
|
|
client_side_decorations,
|
|
};
|
|
use zed_actions::{
|
|
AGENT_SKILLS_SETTINGS_PATH, OpenProjectSettings, OpenSettings, OpenSettingsAt,
|
|
OpenSettingsAtTarget, OpenSettingsPage,
|
|
};
|
|
|
|
use crate::components::{
|
|
EnumVariantDropdown, NumberField, NumberFieldMode, NumberFieldType, SettingsInputField,
|
|
SettingsSectionHeader, font_picker, icon_theme_picker, render_ollama_model_picker,
|
|
text_field_a11y_state, theme_picker,
|
|
};
|
|
use crate::pages::{
|
|
CustomAgentForm, LlmProviderForm, McpServerForm, render_input_audio_device_dropdown,
|
|
render_output_audio_device_dropdown,
|
|
};
|
|
|
|
const NAVBAR_CONTAINER_TAB_INDEX: isize = 0;
|
|
const NAVBAR_GROUP_TAB_INDEX: isize = 1;
|
|
|
|
const HEADER_CONTAINER_TAB_INDEX: isize = 2;
|
|
const HEADER_GROUP_TAB_INDEX: isize = 3;
|
|
|
|
const CONTENT_CONTAINER_TAB_INDEX: isize = 4;
|
|
const CONTENT_GROUP_TAB_INDEX: isize = 5;
|
|
|
|
const SIDEBAR_WIDTH: Pixels = px(226.);
|
|
const CONTENT_MIN_WIDTH: Pixels = px(400.);
|
|
|
|
actions!(
|
|
settings_editor,
|
|
[
|
|
/// Minimizes the settings UI window.
|
|
Minimize,
|
|
/// Toggles focus between the navbar and the main content.
|
|
ToggleFocusNav,
|
|
/// Expands the navigation entry.
|
|
ExpandNavEntry,
|
|
/// Collapses the navigation entry.
|
|
CollapseNavEntry,
|
|
/// Focuses the next file in the file list.
|
|
FocusNextFile,
|
|
/// Focuses the previous file in the file list.
|
|
FocusPreviousFile,
|
|
/// Opens an editor for the current file
|
|
OpenCurrentFile,
|
|
/// Focuses the previous root navigation entry.
|
|
FocusPreviousRootNavEntry,
|
|
/// Focuses the next root navigation entry.
|
|
FocusNextRootNavEntry,
|
|
/// Focuses the first navigation entry.
|
|
FocusFirstNavEntry,
|
|
/// Focuses the last navigation entry.
|
|
FocusLastNavEntry,
|
|
/// Focuses and opens the next navigation entry without moving focus to content.
|
|
FocusNextNavEntry,
|
|
/// Focuses and opens the previous navigation entry without moving focus to content.
|
|
FocusPreviousNavEntry
|
|
]
|
|
);
|
|
|
|
#[derive(Action, PartialEq, Eq, Clone, Copy, Debug, JsonSchema, Deserialize)]
|
|
#[action(namespace = settings_editor)]
|
|
struct FocusFile(pub u32);
|
|
|
|
struct SettingField<T: 'static> {
|
|
pick: fn(&SettingsContent) -> Option<&T>,
|
|
write: fn(&mut SettingsContent, Option<T>, &App),
|
|
/// Tells us whether the setting is overridden by the currently selected
|
|
/// organization's settings. Takes the organization configuration and the
|
|
/// resolved settings value, and returns `Some(...)` if the organization
|
|
/// overrides the setting, otherwise `None`.
|
|
organization_override: Option<fn(&OrganizationConfiguration) -> Option<&T>>,
|
|
|
|
/// A json-path-like string that gives a unique-ish string that identifies
|
|
/// where in the JSON the setting is defined.
|
|
///
|
|
/// The syntax is `jq`-like, but modified slightly to be URL-safe (and
|
|
/// without the leading dot), e.g. `foo.bar`.
|
|
///
|
|
/// They are URL-safe (this is important since links are the main use-case
|
|
/// for these paths).
|
|
///
|
|
/// There are a couple of special cases:
|
|
/// - discrimminants are represented with a trailing `$`, for example
|
|
/// `terminal.working_directory$`. This is to distinguish the discrimminant
|
|
/// setting (i.e. the setting that changes whether the value is a string or
|
|
/// an object) from the setting in the case that it is a string.
|
|
/// - language-specific settings begin `languages.$(language)`. Links
|
|
/// targeting these settings should take the form `languages/Rust/...`, for
|
|
/// example, but are not currently supported.
|
|
json_path: Option<&'static str>,
|
|
}
|
|
|
|
impl<T: 'static> Clone for SettingField<T> {
|
|
fn clone(&self) -> Self {
|
|
*self
|
|
}
|
|
}
|
|
|
|
// manual impl because derive puts a Copy bound on T, which is inaccurate in our case
|
|
impl<T: 'static> Copy for SettingField<T> {}
|
|
|
|
/// Helper for unimplemented settings, used in combination with `SettingField::unimplemented`
|
|
/// to keep the setting around in the UI with valid pick and write implementations, but don't actually try to render it.
|
|
/// TODO(settings_ui): In non-dev builds (`#[cfg(not(debug_assertions))]`) make this render as edit-in-json
|
|
#[derive(Clone, Copy)]
|
|
struct UnimplementedSettingField;
|
|
|
|
impl PartialEq for UnimplementedSettingField {
|
|
fn eq(&self, _other: &Self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
impl<T: 'static> SettingField<T> {
|
|
/// Helper for settings with types that are not yet implemented.
|
|
#[allow(unused)]
|
|
fn unimplemented(self) -> SettingField<UnimplementedSettingField> {
|
|
SettingField {
|
|
pick: |_| Some(&UnimplementedSettingField),
|
|
write: |_, _, _| unreachable!(),
|
|
organization_override: None,
|
|
json_path: self.json_path,
|
|
}
|
|
}
|
|
}
|
|
|
|
trait AnySettingField {
|
|
fn as_any(&self) -> &dyn Any;
|
|
fn type_name(&self) -> &'static str;
|
|
fn type_id(&self) -> TypeId;
|
|
// Returns the file this value was set in and true, or File::Default and false to indicate it was not found in any file (missing default)
|
|
fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> (settings::SettingsFile, bool);
|
|
fn reset_to_default_fn(
|
|
&self,
|
|
current_file: &SettingsUiFile,
|
|
file_set_in: &settings::SettingsFile,
|
|
cx: &App,
|
|
) -> Option<Box<dyn Fn(&mut Window, &mut App)>>;
|
|
|
|
fn json_path(&self) -> Option<&'static str>;
|
|
|
|
fn is_overridden_by_organization(&self, cx: &App) -> bool;
|
|
}
|
|
|
|
impl<T: PartialEq + Clone + Send + Sync + 'static> AnySettingField for SettingField<T> {
|
|
fn as_any(&self) -> &dyn Any {
|
|
self
|
|
}
|
|
|
|
fn type_name(&self) -> &'static str {
|
|
type_name::<T>()
|
|
}
|
|
|
|
fn type_id(&self) -> TypeId {
|
|
TypeId::of::<T>()
|
|
}
|
|
|
|
fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> (settings::SettingsFile, bool) {
|
|
let (file, value) = cx
|
|
.global::<SettingsStore>()
|
|
.get_value_from_file(file.to_settings(), self.pick);
|
|
return (file, value.is_some());
|
|
}
|
|
|
|
fn reset_to_default_fn(
|
|
&self,
|
|
current_file: &SettingsUiFile,
|
|
file_set_in: &settings::SettingsFile,
|
|
cx: &App,
|
|
) -> Option<Box<dyn Fn(&mut Window, &mut App)>> {
|
|
if file_set_in == &settings::SettingsFile::Default {
|
|
return None;
|
|
}
|
|
if file_set_in != ¤t_file.to_settings() {
|
|
return None;
|
|
}
|
|
let this = *self;
|
|
let store = SettingsStore::global(cx);
|
|
let default_value = (this.pick)(store.raw_default_settings());
|
|
let is_default = store
|
|
.get_content_for_file(file_set_in.clone())
|
|
.map_or(None, this.pick)
|
|
== default_value;
|
|
if is_default {
|
|
return None;
|
|
}
|
|
let current_file = current_file.clone();
|
|
|
|
return Some(Box::new(move |window, cx| {
|
|
let store = SettingsStore::global(cx);
|
|
let default_value = (this.pick)(store.raw_default_settings());
|
|
let is_set_somewhere_other_than_default = store
|
|
.get_value_up_to_file(current_file.to_settings(), this.pick)
|
|
.0
|
|
!= settings::SettingsFile::Default;
|
|
let value_to_set = if is_set_somewhere_other_than_default {
|
|
default_value.cloned()
|
|
} else {
|
|
None
|
|
};
|
|
update_settings_file(
|
|
current_file.clone(),
|
|
None,
|
|
window,
|
|
cx,
|
|
move |settings, app| {
|
|
(this.write)(settings, value_to_set, app);
|
|
},
|
|
)
|
|
// todo(settings_ui): Don't log err
|
|
.log_err();
|
|
}));
|
|
}
|
|
|
|
fn json_path(&self) -> Option<&'static str> {
|
|
self.json_path
|
|
}
|
|
|
|
fn is_overridden_by_organization(&self, cx: &App) -> bool {
|
|
let Some(org_override) = self.organization_override else {
|
|
return false;
|
|
};
|
|
|
|
let user_store = AppState::global(cx).user_store.read(cx);
|
|
let Some(org_config) = user_store.current_organization_configuration() else {
|
|
return false;
|
|
};
|
|
|
|
(org_override)(&org_config).is_some()
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct SettingFieldRenderer {
|
|
renderers: Rc<
|
|
RefCell<
|
|
HashMap<
|
|
TypeId,
|
|
Box<
|
|
dyn Fn(
|
|
&SettingsWindow,
|
|
&SettingItem,
|
|
SettingsUiFile,
|
|
Option<&SettingsFieldMetadata>,
|
|
bool,
|
|
&mut Window,
|
|
&mut Context<SettingsWindow>,
|
|
) -> Stateful<Div>,
|
|
>,
|
|
>,
|
|
>,
|
|
>,
|
|
}
|
|
|
|
impl Global for SettingFieldRenderer {}
|
|
|
|
impl SettingFieldRenderer {
|
|
fn add_basic_renderer<T: 'static>(
|
|
&mut self,
|
|
render_control: impl Fn(
|
|
SettingField<T>,
|
|
SettingsUiFile,
|
|
Option<&SettingsFieldMetadata>,
|
|
&'static str,
|
|
&'static str,
|
|
&mut Window,
|
|
&mut App,
|
|
) -> AnyElement
|
|
+ 'static,
|
|
) -> &mut Self {
|
|
self.add_renderer(
|
|
move |settings_window: &SettingsWindow,
|
|
item: &SettingItem,
|
|
field: SettingField<T>,
|
|
settings_file: SettingsUiFile,
|
|
metadata: Option<&SettingsFieldMetadata>,
|
|
sub_field: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>| {
|
|
let control = render_control(
|
|
field,
|
|
settings_file.clone(),
|
|
metadata,
|
|
item.title,
|
|
item.description,
|
|
window,
|
|
cx,
|
|
);
|
|
render_settings_item(settings_window, item, settings_file, control, sub_field, cx)
|
|
},
|
|
)
|
|
}
|
|
|
|
fn add_renderer<T: 'static>(
|
|
&mut self,
|
|
renderer: impl Fn(
|
|
&SettingsWindow,
|
|
&SettingItem,
|
|
SettingField<T>,
|
|
SettingsUiFile,
|
|
Option<&SettingsFieldMetadata>,
|
|
bool,
|
|
&mut Window,
|
|
&mut Context<SettingsWindow>,
|
|
) -> Stateful<Div>
|
|
+ 'static,
|
|
) -> &mut Self {
|
|
let key = TypeId::of::<T>();
|
|
let renderer = Box::new(
|
|
move |settings_window: &SettingsWindow,
|
|
item: &SettingItem,
|
|
settings_file: SettingsUiFile,
|
|
metadata: Option<&SettingsFieldMetadata>,
|
|
sub_field: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>| {
|
|
let field = *item
|
|
.field
|
|
.as_ref()
|
|
.as_any()
|
|
.downcast_ref::<SettingField<T>>()
|
|
.unwrap();
|
|
renderer(
|
|
settings_window,
|
|
item,
|
|
field,
|
|
settings_file,
|
|
metadata,
|
|
sub_field,
|
|
window,
|
|
cx,
|
|
)
|
|
},
|
|
);
|
|
self.renderers.borrow_mut().insert(key, renderer);
|
|
self
|
|
}
|
|
}
|
|
|
|
struct NonFocusableHandle {
|
|
handle: FocusHandle,
|
|
_subscription: Subscription,
|
|
}
|
|
|
|
impl NonFocusableHandle {
|
|
fn new(tab_index: isize, tab_stop: bool, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
|
let handle = cx.focus_handle().tab_index(tab_index).tab_stop(tab_stop);
|
|
Self::from_handle(handle, window, cx)
|
|
}
|
|
|
|
fn from_handle(handle: FocusHandle, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
|
cx.new(|cx| {
|
|
let _subscription = cx.on_focus(&handle, window, {
|
|
move |_, window, cx| {
|
|
window.focus_next(cx);
|
|
}
|
|
});
|
|
Self {
|
|
handle,
|
|
_subscription,
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Focusable for NonFocusableHandle {
|
|
fn focus_handle(&self, _: &App) -> FocusHandle {
|
|
self.handle.clone()
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct SettingsFieldMetadata {
|
|
placeholder: Option<&'static str>,
|
|
should_do_titlecase: Option<bool>,
|
|
display_confirm_button: bool,
|
|
display_clear_button: bool,
|
|
confirm_on_focus_out: bool,
|
|
treat_missing_text_as_empty: bool,
|
|
}
|
|
|
|
pub fn init(cx: &mut App) {
|
|
init_renderers(cx);
|
|
let queue = ProjectSettingsUpdateQueue::new(cx);
|
|
cx.set_global(queue);
|
|
|
|
cx.on_action(|_: &OpenSettings, cx| {
|
|
open_settings_editor(None, None, None, cx);
|
|
});
|
|
cx.on_action(|_: &zed_actions::assistant::OpenSkillCreator, cx| {
|
|
open_skill_creator(pages::SkillCreatorOpenMode::Form, None, cx);
|
|
});
|
|
cx.on_action(|_: &zed_actions::assistant::CreateSkillFromUrl, cx| {
|
|
let initial_url = pages::skill_url_from_clipboard(cx);
|
|
open_skill_creator(pages::SkillCreatorOpenMode::Url { initial_url }, None, cx);
|
|
});
|
|
|
|
cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
|
|
workspace
|
|
.register_action(|_, action: &OpenSettingsAt, window, cx| {
|
|
let window_handle = window.window_handle().downcast::<MultiWorkspace>();
|
|
open_settings_editor_at_target(
|
|
Some(&action.path),
|
|
action.target.as_ref().map(SettingsFileTarget::from),
|
|
window_handle,
|
|
cx,
|
|
);
|
|
})
|
|
.register_action(|_, action: &OpenSettingsPage, window, cx| {
|
|
let window_handle = window.window_handle().downcast::<MultiWorkspace>();
|
|
open_settings_editor_to_page(
|
|
&action.page,
|
|
action.target.as_ref().map(SettingsFileTarget::from),
|
|
window_handle,
|
|
cx,
|
|
);
|
|
})
|
|
.register_action(|_, _: &OpenSettings, window, cx| {
|
|
let window_handle = window.window_handle().downcast::<MultiWorkspace>();
|
|
open_settings_editor(None, None, window_handle, cx);
|
|
})
|
|
.register_action(|workspace, _: &OpenProjectSettings, window, cx| {
|
|
let window_handle = window.window_handle().downcast::<MultiWorkspace>();
|
|
let target_worktree_id = workspace
|
|
.project()
|
|
.read(cx)
|
|
.visible_worktrees(cx)
|
|
.find_map(|tree| {
|
|
tree.read(cx)
|
|
.root_entry()?
|
|
.is_dir()
|
|
.then_some(tree.read(cx).id())
|
|
});
|
|
open_settings_editor(None, target_worktree_id, window_handle, cx);
|
|
})
|
|
.register_action(
|
|
|_, _: &zed_actions::assistant::OpenSkillCreator, window, cx| {
|
|
let window_handle = window.window_handle().downcast::<MultiWorkspace>();
|
|
open_skill_creator(pages::SkillCreatorOpenMode::Form, window_handle, cx);
|
|
},
|
|
)
|
|
.register_action(
|
|
|_, _: &zed_actions::assistant::CreateSkillFromUrl, window, cx| {
|
|
let window_handle = window.window_handle().downcast::<MultiWorkspace>();
|
|
let initial_url = pages::skill_url_from_clipboard(cx);
|
|
open_skill_creator(
|
|
pages::SkillCreatorOpenMode::Url { initial_url },
|
|
window_handle,
|
|
cx,
|
|
);
|
|
},
|
|
);
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn init_renderers(cx: &mut App) {
|
|
cx.default_global::<SettingFieldRenderer>()
|
|
.add_renderer::<UnimplementedSettingField>(
|
|
|settings_window, item, _, settings_file, _, sub_field, _, cx| {
|
|
render_settings_item(
|
|
settings_window,
|
|
item,
|
|
settings_file,
|
|
Button::new("open-in-settings-file", "Edit in settings.json")
|
|
.style(ButtonStyle::Outlined)
|
|
.size(ButtonSize::Medium)
|
|
.tab_index(0_isize)
|
|
.tooltip(Tooltip::for_action_title_in(
|
|
"Edit in settings.json",
|
|
&OpenCurrentFile,
|
|
&settings_window.focus_handle,
|
|
))
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.open_current_settings_file(window, cx);
|
|
}))
|
|
.into_any_element(),
|
|
sub_field,
|
|
cx,
|
|
)
|
|
},
|
|
)
|
|
.add_basic_renderer::<bool>(render_toggle_button)
|
|
.add_basic_renderer::<String>(render_text_field)
|
|
.add_basic_renderer::<SharedString>(render_text_field)
|
|
.add_basic_renderer::<settings::SaturatingBool>(render_toggle_button)
|
|
.add_basic_renderer::<settings::CursorShape>(render_dropdown)
|
|
.add_basic_renderer::<settings::RestoreOnStartupBehavior>(render_dropdown)
|
|
.add_basic_renderer::<settings::BottomDockLayout>(render_dropdown)
|
|
.add_basic_renderer::<settings::OnLastWindowClosed>(render_dropdown)
|
|
.add_basic_renderer::<settings::CliDefaultOpenBehavior>(render_dropdown)
|
|
.add_basic_renderer::<settings::DefaultOpenBehavior>(render_dropdown)
|
|
.add_basic_renderer::<settings::CloseWindowWhenNoItems>(render_dropdown)
|
|
.add_basic_renderer::<settings::TextRenderingMode>(render_dropdown)
|
|
.add_basic_renderer::<settings::FontFamilyName>(render_font_picker)
|
|
.add_basic_renderer::<settings::BaseKeymapContent>(render_dropdown)
|
|
.add_basic_renderer::<settings::MultiCursorModifier>(render_dropdown)
|
|
.add_basic_renderer::<settings::HideMouseMode>(render_dropdown)
|
|
.add_basic_renderer::<settings::ReduceMotionMode>(render_dropdown)
|
|
.add_basic_renderer::<settings::CurrentLineHighlight>(render_dropdown)
|
|
.add_basic_renderer::<settings::ShowWhitespaceSetting>(render_dropdown)
|
|
.add_basic_renderer::<settings::SoftWrap>(render_dropdown)
|
|
.add_basic_renderer::<settings::AutoIndentMode>(render_dropdown)
|
|
.add_basic_renderer::<settings::ScrollBeyondLastLine>(render_dropdown)
|
|
.add_basic_renderer::<settings::SnippetSortOrder>(render_dropdown)
|
|
.add_basic_renderer::<settings::ClosePosition>(render_dropdown)
|
|
.add_basic_renderer::<settings::DockSide>(render_dropdown)
|
|
.add_basic_renderer::<settings::TerminalDockPosition>(render_dropdown)
|
|
.add_basic_renderer::<settings::DockPosition>(render_dropdown)
|
|
.add_basic_renderer::<settings::SidebarDockPosition>(render_dropdown)
|
|
.add_basic_renderer::<settings::GitGutterSetting>(render_dropdown)
|
|
.add_basic_renderer::<settings::GitHunkStyleSetting>(render_dropdown)
|
|
.add_basic_renderer::<settings::GitPathStyle>(render_dropdown)
|
|
.add_basic_renderer::<settings::InlineBlameLocation>(render_dropdown)
|
|
.add_basic_renderer::<settings::DiagnosticSeverityContent>(render_dropdown)
|
|
.add_basic_renderer::<settings::SeedQuerySetting>(render_dropdown)
|
|
.add_basic_renderer::<settings::DoubleClickInMultibuffer>(render_dropdown)
|
|
.add_basic_renderer::<settings::GoToDefinitionFallback>(render_dropdown)
|
|
.add_basic_renderer::<settings::GoToDefinitionScrollStrategy>(render_dropdown)
|
|
.add_basic_renderer::<settings::OpenResultsIn>(render_dropdown)
|
|
.add_basic_renderer::<settings::ActivateOnClose>(render_dropdown)
|
|
.add_basic_renderer::<settings::ShowDiagnostics>(render_dropdown)
|
|
.add_basic_renderer::<settings::ShowCloseButton>(render_dropdown)
|
|
.add_basic_renderer::<settings::ProjectPanelEntrySpacing>(render_dropdown)
|
|
.add_basic_renderer::<settings::ProjectPanelSortMode>(render_dropdown)
|
|
.add_basic_renderer::<settings::ProjectPanelSortOrder>(render_dropdown)
|
|
.add_basic_renderer::<settings::RewrapBehavior>(render_dropdown)
|
|
.add_basic_renderer::<settings::FormatOnSave>(render_dropdown)
|
|
.add_basic_renderer::<settings::LineEndingSetting>(render_dropdown)
|
|
.add_basic_renderer::<settings::IndentGuideColoring>(render_dropdown)
|
|
.add_basic_renderer::<settings::IndentGuideBackgroundColoring>(render_dropdown)
|
|
.add_basic_renderer::<settings::ShowDiagnostics>(render_dropdown)
|
|
.add_basic_renderer::<settings::WordsCompletionMode>(render_dropdown)
|
|
.add_basic_renderer::<settings::LspInsertMode>(render_dropdown)
|
|
.add_basic_renderer::<settings::CompletionDetailAlignment>(render_dropdown)
|
|
.add_basic_renderer::<settings::CompletionMenuItemKind>(render_dropdown)
|
|
.add_basic_renderer::<settings::DiffViewStyle>(render_dropdown)
|
|
.add_basic_renderer::<settings::AlternateScroll>(render_dropdown)
|
|
.add_basic_renderer::<settings::TerminalBlink>(render_dropdown)
|
|
.add_basic_renderer::<settings::CursorShapeContent>(render_dropdown)
|
|
.add_basic_renderer::<settings::EditPredictionPromptFormatContent>(render_dropdown)
|
|
.add_basic_renderer::<settings::EditPredictionDataCollectionChoice>(render_dropdown)
|
|
.add_basic_renderer::<f32>(render_editable_number_field)
|
|
.add_basic_renderer::<settings::AutoCompactThreshold>(render_text_field)
|
|
.add_basic_renderer::<u32>(render_editable_number_field)
|
|
.add_basic_renderer::<u64>(render_editable_number_field)
|
|
.add_basic_renderer::<usize>(render_editable_number_field)
|
|
.add_basic_renderer::<NonZero<usize>>(render_editable_number_field)
|
|
.add_basic_renderer::<NonZeroU32>(render_editable_number_field)
|
|
.add_basic_renderer::<settings::CodeFade>(render_editable_number_field)
|
|
.add_basic_renderer::<settings::DelayMs>(render_editable_number_field)
|
|
.add_basic_renderer::<settings::FontWeightContent>(render_editable_number_field)
|
|
.add_basic_renderer::<settings::CenteredPaddingSettings>(render_editable_number_field)
|
|
.add_basic_renderer::<settings::InactiveOpacity>(render_editable_number_field)
|
|
.add_basic_renderer::<settings::MinimumContrast>(render_editable_number_field)
|
|
.add_basic_renderer::<settings::ShowScrollbar>(render_dropdown)
|
|
.add_basic_renderer::<settings::ScrollbarDiagnostics>(render_dropdown)
|
|
.add_basic_renderer::<settings::ShowMinimap>(render_dropdown)
|
|
.add_basic_renderer::<settings::DisplayIn>(render_dropdown)
|
|
.add_basic_renderer::<settings::MinimapThumb>(render_dropdown)
|
|
.add_basic_renderer::<settings::MinimapThumbBorder>(render_dropdown)
|
|
.add_basic_renderer::<settings::ModeContent>(render_dropdown)
|
|
.add_basic_renderer::<settings::UseSystemClipboard>(render_dropdown)
|
|
.add_basic_renderer::<settings::VimInsertModeCursorShape>(render_dropdown)
|
|
.add_basic_renderer::<settings::SteppingGranularity>(render_dropdown)
|
|
.add_basic_renderer::<settings::NotifyWhenAgentWaiting>(render_dropdown)
|
|
.add_basic_renderer::<settings::PlaySoundWhenAgentDone>(render_dropdown)
|
|
.add_basic_renderer::<settings::ThinkingBlockDisplay>(render_dropdown)
|
|
.add_basic_renderer::<settings::ImageFileSizeUnit>(render_dropdown)
|
|
.add_basic_renderer::<settings::StatusStyle>(render_dropdown)
|
|
.add_basic_renderer::<settings::GitPanelClickBehavior>(render_dropdown)
|
|
.add_basic_renderer::<settings::GitPanelSortBy>(render_dropdown)
|
|
.add_basic_renderer::<settings::GitPanelGroupBy>(render_dropdown)
|
|
.add_basic_renderer::<settings::EncodingDisplayOptions>(render_dropdown)
|
|
.add_basic_renderer::<settings::PaneSplitDirectionHorizontal>(render_dropdown)
|
|
.add_basic_renderer::<settings::PaneSplitDirectionVertical>(render_dropdown)
|
|
.add_basic_renderer::<settings::PaneSplitDirectionVertical>(render_dropdown)
|
|
.add_basic_renderer::<settings::CodeLens>(render_dropdown)
|
|
.add_basic_renderer::<settings::DocumentColorsRenderMode>(render_dropdown)
|
|
.add_basic_renderer::<settings::ThemeSelectionDiscriminants>(render_dropdown)
|
|
.add_basic_renderer::<settings::ThemeAppearanceMode>(render_dropdown)
|
|
.add_basic_renderer::<settings::ThemeName>(render_theme_picker)
|
|
.add_basic_renderer::<settings::IconThemeSelectionDiscriminants>(render_dropdown)
|
|
.add_basic_renderer::<settings::IconThemeName>(render_icon_theme_picker)
|
|
.add_basic_renderer::<settings::BufferLineHeightDiscriminants>(render_dropdown)
|
|
.add_basic_renderer::<settings::AutosaveSettingDiscriminants>(render_dropdown)
|
|
.add_basic_renderer::<settings::WorkingDirectoryDiscriminants>(render_dropdown)
|
|
.add_basic_renderer::<settings::IncludeIgnoredContent>(render_dropdown)
|
|
.add_basic_renderer::<settings::ShowIndentGuides>(render_dropdown)
|
|
.add_basic_renderer::<settings::ShellDiscriminants>(render_dropdown)
|
|
.add_basic_renderer::<settings::EditPredictionsMode>(render_dropdown)
|
|
.add_basic_renderer::<settings::RelativeLineNumbers>(render_dropdown)
|
|
.add_basic_renderer::<settings::WindowDecorations>(render_dropdown)
|
|
.add_basic_renderer::<settings::WindowButtonLayoutContentDiscriminants>(render_dropdown)
|
|
.add_basic_renderer::<settings::ScanSymlinksSetting>(render_dropdown)
|
|
.add_basic_renderer::<settings::FontSize>(render_editable_number_field)
|
|
.add_basic_renderer::<settings::OllamaModelName>(render_ollama_model_picker)
|
|
.add_basic_renderer::<settings::SemanticTokens>(render_dropdown)
|
|
.add_basic_renderer::<settings::DocumentFoldingRanges>(render_dropdown)
|
|
.add_basic_renderer::<settings::DocumentSymbols>(render_dropdown)
|
|
.add_basic_renderer::<settings::AudioInputDeviceName>(render_input_audio_device_dropdown)
|
|
.add_basic_renderer::<settings::AudioOutputDeviceName>(render_output_audio_device_dropdown)
|
|
.add_basic_renderer::<settings::TerminalBell>(render_dropdown)
|
|
// please semicolon stay on next line
|
|
;
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum SettingsFileTarget {
|
|
User,
|
|
Project(WorktreeId),
|
|
}
|
|
|
|
impl From<&OpenSettingsAtTarget> for SettingsFileTarget {
|
|
fn from(target: &OpenSettingsAtTarget) -> Self {
|
|
match target {
|
|
OpenSettingsAtTarget::User => Self::User,
|
|
OpenSettingsAtTarget::Project { worktree_id } => {
|
|
Self::Project(WorktreeId::from_usize(*worktree_id))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn open_settings_editor(
|
|
path: Option<&str>,
|
|
target_worktree_id: Option<WorktreeId>,
|
|
workspace_handle: Option<WindowHandle<MultiWorkspace>>,
|
|
cx: &mut App,
|
|
) {
|
|
open_settings_editor_at_target(
|
|
path,
|
|
target_worktree_id.map(SettingsFileTarget::Project),
|
|
workspace_handle,
|
|
cx,
|
|
);
|
|
}
|
|
|
|
fn select_settings_file_target(
|
|
target_file: SettingsFileTarget,
|
|
settings_window: &mut SettingsWindow,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) {
|
|
let file_index = settings_window
|
|
.files
|
|
.iter()
|
|
.position(|(file, _)| match target_file {
|
|
SettingsFileTarget::User => matches!(file, SettingsUiFile::User),
|
|
SettingsFileTarget::Project(worktree_id) => file.worktree_id() == Some(worktree_id),
|
|
});
|
|
if let Some(file_index) = file_index {
|
|
settings_window.change_file(file_index, window, cx);
|
|
}
|
|
}
|
|
|
|
fn open_settings_editor_to_page(
|
|
page: &str,
|
|
target_file: Option<SettingsFileTarget>,
|
|
workspace_handle: Option<WindowHandle<MultiWorkspace>>,
|
|
cx: &mut App,
|
|
) {
|
|
let page = page.to_string();
|
|
open_settings_editor_with(workspace_handle, cx, move |settings_window, window, cx| {
|
|
if let Some(target_file) = target_file {
|
|
select_settings_file_target(target_file, settings_window, window, cx);
|
|
}
|
|
|
|
settings_window.opening_link = false;
|
|
settings_window.search_bar.update(cx, |editor, cx| {
|
|
editor.set_text(String::new(), window, cx);
|
|
});
|
|
for page_filter in &mut settings_window.filter_table {
|
|
page_filter.fill(true);
|
|
}
|
|
settings_window.has_query = false;
|
|
settings_window.filter_matches_to_file();
|
|
|
|
let Some(navbar_entry_index) = settings_window
|
|
.navbar_entries
|
|
.iter()
|
|
.position(|entry| entry.is_root && entry.title.eq_ignore_ascii_case(&page))
|
|
else {
|
|
log::error!("settings page not found: {page}");
|
|
return;
|
|
};
|
|
|
|
settings_window.open_and_scroll_to_navbar_entry(
|
|
navbar_entry_index,
|
|
None,
|
|
false,
|
|
window,
|
|
cx,
|
|
);
|
|
});
|
|
}
|
|
|
|
fn open_settings_editor_at_target(
|
|
path: Option<&str>,
|
|
target_file: Option<SettingsFileTarget>,
|
|
workspace_handle: Option<WindowHandle<MultiWorkspace>>,
|
|
cx: &mut App,
|
|
) {
|
|
/// Assumes a settings GUI window is already open
|
|
fn open_path(
|
|
path: &str,
|
|
settings_window: &mut SettingsWindow,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) {
|
|
if path.starts_with("languages.$(language)") {
|
|
log::error!("language-specific settings links are not currently supported");
|
|
return;
|
|
}
|
|
|
|
let query = format!("#{path}");
|
|
let indices = settings_window.filter_by_json_path(&query);
|
|
|
|
settings_window.opening_link = true;
|
|
settings_window.search_bar.update(cx, |editor, cx| {
|
|
editor.set_text(query.clone(), window, cx);
|
|
});
|
|
settings_window.apply_match_indices(indices.iter().copied(), &query);
|
|
|
|
if indices.len() == 1
|
|
&& let Some(search_index) = settings_window.search_index.as_ref()
|
|
{
|
|
let SearchKeyLUTEntry {
|
|
page_index,
|
|
item_index,
|
|
header_index,
|
|
..
|
|
} = search_index.key_lut[indices[0]];
|
|
let page = &settings_window.pages[page_index];
|
|
let item = &page.items[item_index];
|
|
|
|
if settings_window.filter_table[page_index][item_index]
|
|
&& let SettingsPageItem::SubPageLink(link) = item
|
|
&& let SettingsPageItem::SectionHeader(header) = page.items[header_index]
|
|
{
|
|
settings_window.push_sub_page(link.clone(), SharedString::from(header), window, cx);
|
|
}
|
|
}
|
|
|
|
cx.notify();
|
|
}
|
|
|
|
let path = path.map(ToOwned::to_owned);
|
|
open_settings_editor_with(workspace_handle, cx, move |settings_window, window, cx| {
|
|
if let Some(target_file) = target_file {
|
|
select_settings_file_target(target_file, settings_window, window, cx);
|
|
}
|
|
if let Some(path) = path {
|
|
open_path(&path, settings_window, window, cx);
|
|
} else if target_file.is_some() {
|
|
cx.notify();
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn open_skill_creator(
|
|
open_mode: pages::SkillCreatorOpenMode,
|
|
workspace_handle: Option<WindowHandle<MultiWorkspace>>,
|
|
cx: &mut App,
|
|
) {
|
|
open_settings_editor_with(workspace_handle, cx, |settings_window, window, cx| {
|
|
settings_window.navigate_to_skill_creator(open_mode, window, cx);
|
|
});
|
|
}
|
|
|
|
fn open_settings_editor_with(
|
|
workspace_handle: Option<WindowHandle<MultiWorkspace>>,
|
|
cx: &mut App,
|
|
callback: impl FnOnce(&mut SettingsWindow, &mut Window, &mut Context<SettingsWindow>) + 'static,
|
|
) {
|
|
telemetry::event!("Settings Viewed");
|
|
|
|
let existing_window = cx
|
|
.windows()
|
|
.into_iter()
|
|
.find_map(|window| window.downcast::<SettingsWindow>());
|
|
|
|
if let Some(existing_window) = existing_window {
|
|
existing_window
|
|
.update(cx, |settings_window, window, cx| {
|
|
settings_window.original_window = workspace_handle;
|
|
|
|
window.activate_window();
|
|
callback(settings_window, window, cx);
|
|
})
|
|
.ok();
|
|
return;
|
|
}
|
|
|
|
// We have to defer this to get the workspace off the stack.
|
|
cx.defer(move |cx| {
|
|
let current_rem_size: f32 = theme_settings::ThemeSettings::get_global(cx)
|
|
.ui_font_size(cx)
|
|
.into();
|
|
|
|
let default_bounds = DEFAULT_ADDITIONAL_WINDOW_SIZE;
|
|
let default_rem_size = 16.0;
|
|
let scale_factor = current_rem_size / default_rem_size;
|
|
let scaled_bounds: gpui::Size<Pixels> = default_bounds.map(|axis| axis * scale_factor);
|
|
|
|
let app_id = ReleaseChannel::global(cx).app_id();
|
|
let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") {
|
|
Ok(val) if val == "server" => gpui::WindowDecorations::Server,
|
|
Ok(val) if val == "client" => gpui::WindowDecorations::Client,
|
|
_ => match WorkspaceSettings::get_global(cx).window_decorations {
|
|
settings::WindowDecorations::Server => gpui::WindowDecorations::Server,
|
|
settings::WindowDecorations::Client => gpui::WindowDecorations::Client,
|
|
},
|
|
};
|
|
|
|
cx.open_window(
|
|
WindowOptions {
|
|
titlebar: Some(TitlebarOptions {
|
|
title: Some("Zed — Settings".into()),
|
|
appears_transparent: true,
|
|
traffic_light_position: Some(point(px(12.0), px(12.0))),
|
|
}),
|
|
focus: true,
|
|
show: true,
|
|
is_movable: true,
|
|
kind: gpui::WindowKind::Normal,
|
|
window_background: cx.theme().window_background_appearance(),
|
|
app_id: Some(app_id.to_owned()),
|
|
window_decorations: Some(window_decorations),
|
|
window_min_size: Some(gpui::Size {
|
|
// Do not make the settings window thinner than this,
|
|
// otherwise, the space used to display the actual content
|
|
// gets so small that certain sections grow too tall due
|
|
// to intense text wrapping.
|
|
width: SIDEBAR_WIDTH + CONTENT_MIN_WIDTH,
|
|
height: px(240.0),
|
|
}),
|
|
window_bounds: Some(WindowBounds::centered(scaled_bounds, cx)),
|
|
..Default::default()
|
|
},
|
|
|window, cx| {
|
|
let settings_window =
|
|
cx.new(|cx| SettingsWindow::new(workspace_handle, window, cx));
|
|
settings_window.update(cx, |settings_window, cx| {
|
|
callback(settings_window, window, cx);
|
|
});
|
|
|
|
settings_window
|
|
},
|
|
)
|
|
.log_err();
|
|
});
|
|
}
|
|
|
|
/// The current sub page path that is selected.
|
|
/// If this is empty the selected page is rendered,
|
|
/// otherwise the last sub page gets rendered.
|
|
///
|
|
/// Global so that `pick` and `write` callbacks can access it
|
|
/// and use it to dynamically render sub pages (e.g. for language settings)
|
|
static ACTIVE_LANGUAGE: LazyLock<RwLock<Option<SharedString>>> =
|
|
LazyLock::new(|| RwLock::new(Option::None));
|
|
|
|
fn active_language() -> Option<SharedString> {
|
|
ACTIVE_LANGUAGE
|
|
.read()
|
|
.ok()
|
|
.and_then(|language| language.clone())
|
|
}
|
|
|
|
fn active_language_mut() -> Option<std::sync::RwLockWriteGuard<'static, Option<SharedString>>> {
|
|
ACTIVE_LANGUAGE.write().ok()
|
|
}
|
|
|
|
pub struct SettingsWindow {
|
|
title_bar: Option<Entity<PlatformTitleBar>>,
|
|
original_window: Option<WindowHandle<MultiWorkspace>>,
|
|
files: Vec<(SettingsUiFile, FocusHandle)>,
|
|
worktree_root_dirs: HashMap<WorktreeId, String>,
|
|
current_file: SettingsUiFile,
|
|
pages: Vec<SettingsPage>,
|
|
sub_page_stack: Vec<SubPage>,
|
|
opening_link: bool,
|
|
search_bar: Entity<Editor>,
|
|
search_task: Option<Task<()>>,
|
|
/// Cached settings file buffers to avoid repeated disk I/O on each settings change
|
|
project_setting_file_buffers: HashMap<ProjectPath, Entity<Buffer>>,
|
|
/// Index into navbar_entries
|
|
navbar_entry: usize,
|
|
navbar_entries: Vec<NavBarEntry>,
|
|
navbar_scroll_handle: UniformListScrollHandle,
|
|
/// [page_index][page_item_index] will be false
|
|
/// when the item is filtered out either by searches
|
|
/// or by the current file
|
|
navbar_focus_subscriptions: Vec<gpui::Subscription>,
|
|
filter_table: Vec<Vec<bool>>,
|
|
has_query: bool,
|
|
content_handles: Vec<Vec<Entity<NonFocusableHandle>>>,
|
|
focus_handle: FocusHandle,
|
|
navbar_focus_handle: Entity<NonFocusableHandle>,
|
|
content_focus_handle: Entity<NonFocusableHandle>,
|
|
files_focus_handle: FocusHandle,
|
|
search_index: Option<Arc<SearchIndex>>,
|
|
list_state: ListState,
|
|
shown_errors: HashSet<String>,
|
|
pub(crate) hidden_deleted_skill_directory_paths: HashSet<PathBuf>,
|
|
pub(crate) regex_validation_error: Option<String>,
|
|
pub(crate) sandbox_host_validation_error: Option<String>,
|
|
last_copied_link_path: Option<&'static str>,
|
|
/// Cached configuration views per provider, created lazily.
|
|
pub(crate) provider_configuration_views:
|
|
HashMap<language_model::LanguageModelProviderId, gpui::AnyView>,
|
|
/// The provider whose configuration sub-page is currently open, if any.
|
|
pub(crate) configuring_provider: Option<language_model::LanguageModelProviderId>,
|
|
/// Directory path of the skill whose share link was most recently copied,
|
|
/// used to show a transient "copied" checkmark on its share button.
|
|
pub(crate) last_copied_skill_directory_path: Option<PathBuf>,
|
|
/// State for the active "add OpenAI/Anthropic-compatible provider" form sub-page, if open.
|
|
pub(crate) llm_provider_form: Option<LlmProviderForm>,
|
|
/// Stable focus handle for the LLM "Add Provider" button, so it can show a
|
|
/// focus ring when the page auto-focuses it on open (which happens via mouse,
|
|
/// where `focus_visible` styling would otherwise be suppressed).
|
|
pub(crate) llm_provider_add_focus_handle: FocusHandle,
|
|
/// State for the active "add/edit custom MCP server" form sub-page, if open.
|
|
pub(crate) mcp_server_form: Option<McpServerForm>,
|
|
/// Stable focus handle for the MCP "Add Server" button, so it can show a
|
|
/// focus ring when the page auto-focuses it on open (which happens via mouse,
|
|
/// where `focus_visible` styling would otherwise be suppressed).
|
|
pub(crate) mcp_add_server_focus_handle: FocusHandle,
|
|
/// State for the active "add/edit custom external agent" form sub-page, if open.
|
|
pub(crate) custom_agent_form: Option<CustomAgentForm>,
|
|
/// Stable focus handle for the external agents "Add Agent" button, so it can
|
|
/// show a focus ring when the page auto-focuses it on open (which happens via
|
|
/// mouse, where `focus_visible` styling would otherwise be suppressed).
|
|
pub(crate) external_agent_add_focus_handle: FocusHandle,
|
|
skill_creator_page: Option<(Entity<pages::SkillCreatorPage>, Subscription)>,
|
|
}
|
|
|
|
struct SearchDocument {
|
|
id: usize,
|
|
words: Vec<String>,
|
|
}
|
|
|
|
struct SearchIndex {
|
|
documents: Vec<SearchDocument>,
|
|
fuzzy_match_candidates: Vec<StringMatchCandidate>,
|
|
key_lut: Vec<SearchKeyLUTEntry>,
|
|
}
|
|
|
|
struct SearchKeyLUTEntry {
|
|
page_index: usize,
|
|
header_index: usize,
|
|
item_index: usize,
|
|
json_path: Option<&'static str>,
|
|
}
|
|
|
|
struct SubPage {
|
|
link: SubPageLink,
|
|
section_header: SharedString,
|
|
scroll_handle: ScrollHandle,
|
|
}
|
|
|
|
impl SubPage {
|
|
fn new(link: SubPageLink, section_header: SharedString) -> Self {
|
|
if link.r#type == SubPageType::Language
|
|
&& let Some(mut active_language_global) = active_language_mut()
|
|
{
|
|
active_language_global.replace(link.title.clone());
|
|
}
|
|
|
|
SubPage {
|
|
link,
|
|
section_header,
|
|
scroll_handle: ScrollHandle::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for SubPage {
|
|
fn drop(&mut self) {
|
|
if self.link.r#type == SubPageType::Language
|
|
&& let Some(mut active_language_global) = active_language_mut()
|
|
&& active_language_global
|
|
.as_ref()
|
|
.is_some_and(|language_name| language_name == &self.link.title)
|
|
{
|
|
active_language_global.take();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct NavBarEntry {
|
|
title: &'static str,
|
|
is_root: bool,
|
|
expanded: bool,
|
|
page_index: usize,
|
|
item_index: Option<usize>,
|
|
focus_handle: FocusHandle,
|
|
}
|
|
|
|
struct SettingsPage {
|
|
title: &'static str,
|
|
items: Box<[SettingsPageItem]>,
|
|
}
|
|
|
|
#[derive(PartialEq)]
|
|
enum SettingsPageItem {
|
|
SectionHeader(&'static str),
|
|
SettingItem(SettingItem),
|
|
SubPageLink(SubPageLink),
|
|
DynamicItem(DynamicItem),
|
|
ActionLink(ActionLink),
|
|
}
|
|
|
|
impl std::fmt::Debug for SettingsPageItem {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
SettingsPageItem::SectionHeader(header) => write!(f, "SectionHeader({})", header),
|
|
SettingsPageItem::SettingItem(setting_item) => {
|
|
write!(f, "SettingItem({})", setting_item.title)
|
|
}
|
|
SettingsPageItem::SubPageLink(sub_page_link) => {
|
|
write!(f, "SubPageLink({})", sub_page_link.title)
|
|
}
|
|
SettingsPageItem::DynamicItem(dynamic_item) => {
|
|
write!(f, "DynamicItem({})", dynamic_item.discriminant.title)
|
|
}
|
|
SettingsPageItem::ActionLink(action_link) => {
|
|
write!(f, "ActionLink({})", action_link.title)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SettingsPageItem {
|
|
fn header_text(&self) -> Option<&'static str> {
|
|
match self {
|
|
SettingsPageItem::SectionHeader(header) => Some(header),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn render(
|
|
&self,
|
|
settings_window: &SettingsWindow,
|
|
item_index: usize,
|
|
bottom_border: bool,
|
|
extra_bottom_padding: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) -> AnyElement {
|
|
let file = settings_window.current_file.clone();
|
|
|
|
let apply_padding = |element: Stateful<Div>| -> Stateful<Div> {
|
|
let element = element.pt_4();
|
|
if extra_bottom_padding {
|
|
element.pb_10()
|
|
} else {
|
|
element.pb_4()
|
|
}
|
|
};
|
|
|
|
let mut render_setting_item_inner =
|
|
|setting_item: &SettingItem,
|
|
padding: bool,
|
|
sub_field: bool,
|
|
cx: &mut Context<SettingsWindow>| {
|
|
let renderer = cx.default_global::<SettingFieldRenderer>().clone();
|
|
let (_, found) = setting_item.field.file_set_in(file.clone(), cx);
|
|
|
|
let renderers = renderer.renderers.borrow();
|
|
|
|
let field_renderer =
|
|
renderers.get(&AnySettingField::type_id(setting_item.field.as_ref()));
|
|
let field_renderer_or_warning =
|
|
field_renderer.ok_or("NO RENDERER").and_then(|renderer| {
|
|
if cfg!(debug_assertions) && !found {
|
|
Err("NO DEFAULT")
|
|
} else {
|
|
Ok(renderer)
|
|
}
|
|
});
|
|
|
|
let field = match field_renderer_or_warning {
|
|
Ok(field_renderer) => window.with_id(item_index, |window| {
|
|
field_renderer(
|
|
settings_window,
|
|
setting_item,
|
|
file.clone(),
|
|
setting_item.metadata.as_deref(),
|
|
sub_field,
|
|
window,
|
|
cx,
|
|
)
|
|
}),
|
|
Err(warning) => render_settings_item(
|
|
settings_window,
|
|
setting_item,
|
|
file.clone(),
|
|
Button::new("error-warning", warning)
|
|
.style(ButtonStyle::Outlined)
|
|
.size(ButtonSize::Medium)
|
|
.start_icon(Icon::new(IconName::Debug).color(Color::Error))
|
|
.tab_index(0_isize)
|
|
.tooltip(Tooltip::text(setting_item.field.type_name()))
|
|
.into_any_element(),
|
|
sub_field,
|
|
cx,
|
|
),
|
|
};
|
|
|
|
let field = if padding {
|
|
field.map(apply_padding)
|
|
} else {
|
|
field
|
|
};
|
|
|
|
(field, field_renderer_or_warning.is_ok())
|
|
};
|
|
|
|
match self {
|
|
SettingsPageItem::SectionHeader(header) => {
|
|
SettingsSectionHeader::new(SharedString::new_static(header)).into_any_element()
|
|
}
|
|
SettingsPageItem::SettingItem(setting_item) => {
|
|
let (field_with_padding, _) =
|
|
render_setting_item_inner(setting_item, true, false, cx);
|
|
|
|
v_flex()
|
|
.group("setting-item")
|
|
.px_8()
|
|
.child(field_with_padding)
|
|
.when(bottom_border, |this| this.child(Divider::horizontal()))
|
|
.into_any_element()
|
|
}
|
|
SettingsPageItem::SubPageLink(sub_page_link) => v_flex()
|
|
.group("setting-item")
|
|
.px_8()
|
|
.child(
|
|
h_flex()
|
|
.id(sub_page_link.title.clone())
|
|
.w_full()
|
|
.min_w_0()
|
|
.justify_between()
|
|
.map(apply_padding)
|
|
.child(
|
|
v_flex()
|
|
.relative()
|
|
.w_full()
|
|
.max_w_1_2()
|
|
.child(Label::new(sub_page_link.title.clone()))
|
|
.when_some(
|
|
sub_page_link.description.as_ref(),
|
|
|this, description| {
|
|
this.child(
|
|
Label::new(description.clone())
|
|
.size(LabelSize::Small)
|
|
.color(Color::Muted),
|
|
)
|
|
},
|
|
),
|
|
)
|
|
.child(
|
|
Button::new(
|
|
("sub-page".into(), sub_page_link.title.clone()),
|
|
"Configure",
|
|
)
|
|
.aria_label(format!("Configure {}", sub_page_link.title))
|
|
.tab_index(0_isize)
|
|
.end_icon(
|
|
Icon::new(IconName::ChevronRight)
|
|
.size(IconSize::Small)
|
|
.color(Color::Muted),
|
|
)
|
|
.style(ButtonStyle::OutlinedGhost)
|
|
.size(ButtonSize::Medium)
|
|
.on_click({
|
|
let sub_page_link = sub_page_link.clone();
|
|
cx.listener(move |this, _, window, cx| {
|
|
let header_text = this
|
|
.sub_page_stack
|
|
.last()
|
|
.map(|sub_page| sub_page.link.title.clone())
|
|
.or_else(|| {
|
|
this.current_page()
|
|
.items
|
|
.iter()
|
|
.take(item_index)
|
|
.rev()
|
|
.find_map(|item| {
|
|
item.header_text().map(SharedString::new_static)
|
|
})
|
|
});
|
|
|
|
let Some(header) = header_text else {
|
|
unreachable!(
|
|
"All items always have a section header above them"
|
|
)
|
|
};
|
|
|
|
this.push_sub_page(sub_page_link.clone(), header, window, cx)
|
|
})
|
|
}),
|
|
)
|
|
.child(render_settings_item_link(
|
|
sub_page_link.title.clone(),
|
|
sub_page_link.json_path,
|
|
false,
|
|
settings_window,
|
|
cx,
|
|
)),
|
|
)
|
|
.when(bottom_border, |this| this.child(Divider::horizontal()))
|
|
.into_any_element(),
|
|
SettingsPageItem::DynamicItem(DynamicItem {
|
|
discriminant: discriminant_setting_item,
|
|
pick_discriminant,
|
|
fields,
|
|
}) => {
|
|
let file = file.to_settings();
|
|
let discriminant = SettingsStore::global(cx)
|
|
.get_value_from_file(file, *pick_discriminant)
|
|
.1;
|
|
|
|
let (discriminant_element, rendered_ok) =
|
|
render_setting_item_inner(discriminant_setting_item, true, false, cx);
|
|
|
|
let has_sub_fields =
|
|
rendered_ok && discriminant.is_some_and(|d| !fields[d].is_empty());
|
|
|
|
let mut content = v_flex()
|
|
.id("dynamic-item")
|
|
.child(
|
|
div()
|
|
.group("setting-item")
|
|
.px_8()
|
|
.child(discriminant_element.when(has_sub_fields, |this| this.pb_4())),
|
|
)
|
|
.when(!has_sub_fields && bottom_border, |this| {
|
|
this.child(h_flex().px_8().child(Divider::horizontal()))
|
|
});
|
|
|
|
if rendered_ok {
|
|
let discriminant =
|
|
discriminant.expect("This should be Some if rendered_ok is true");
|
|
let sub_fields = &fields[discriminant];
|
|
let sub_field_count = sub_fields.len();
|
|
|
|
for (index, field) in sub_fields.iter().enumerate() {
|
|
let is_last_sub_field = index == sub_field_count - 1;
|
|
let (raw_field, _) = render_setting_item_inner(field, false, true, cx);
|
|
|
|
content = content.child(
|
|
raw_field
|
|
.group("setting-sub-item")
|
|
.mx_8()
|
|
.p_4()
|
|
.border_t_1()
|
|
.when(is_last_sub_field, |this| this.border_b_1())
|
|
.when(is_last_sub_field && extra_bottom_padding, |this| {
|
|
this.mb_8()
|
|
})
|
|
.border_dashed()
|
|
.border_color(cx.theme().colors().border_variant)
|
|
.bg(cx.theme().colors().element_background.opacity(0.2)),
|
|
);
|
|
}
|
|
}
|
|
|
|
return content.into_any_element();
|
|
}
|
|
SettingsPageItem::ActionLink(action_link) => v_flex()
|
|
.group("setting-item")
|
|
.px_8()
|
|
.child(
|
|
h_flex()
|
|
.id(action_link.title.clone())
|
|
.w_full()
|
|
.min_w_0()
|
|
.justify_between()
|
|
.map(apply_padding)
|
|
.child(
|
|
v_flex()
|
|
.relative()
|
|
.w_full()
|
|
.max_w_1_2()
|
|
.child(Label::new(action_link.title.clone()))
|
|
.when_some(
|
|
action_link.description.as_ref(),
|
|
|this, description| {
|
|
this.child(
|
|
Label::new(description.clone())
|
|
.size(LabelSize::Small)
|
|
.color(Color::Muted),
|
|
)
|
|
},
|
|
),
|
|
)
|
|
.child(
|
|
Button::new(
|
|
("action-link".into(), action_link.title.clone()),
|
|
action_link.button_text.clone(),
|
|
)
|
|
.tab_index(0_isize)
|
|
.end_icon(
|
|
Icon::new(IconName::ArrowUpRight)
|
|
.size(IconSize::Small)
|
|
.color(Color::Muted),
|
|
)
|
|
.style(ButtonStyle::OutlinedGhost)
|
|
.size(ButtonSize::Medium)
|
|
.on_click({
|
|
let on_click = action_link.on_click.clone();
|
|
cx.listener(move |this, _, window, cx| {
|
|
on_click(this, window, cx);
|
|
})
|
|
}),
|
|
),
|
|
)
|
|
.when(bottom_border, |this| this.child(Divider::horizontal()))
|
|
.into_any_element(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Shared layout for both JSON-backed and non-JSON-backed setting items.
|
|
///
|
|
/// Renders title + description on the left, control on the right, with
|
|
/// optional reset button and copy-link icon.
|
|
fn render_settings_item_layout(
|
|
settings_window: &SettingsWindow,
|
|
title: &'static str,
|
|
description: &'static str,
|
|
control: AnyElement,
|
|
reset_fn: Option<Box<dyn Fn(&mut Window, &mut App)>>,
|
|
modified_in: Option<String>,
|
|
json_path: Option<&'static str>,
|
|
sub_field: bool,
|
|
cx: &mut Context<'_, SettingsWindow>,
|
|
) -> Stateful<Div> {
|
|
// Note: the row itself is intentionally not exposed as a labeled group.
|
|
// Each control names and describes itself (via the setting title and
|
|
// description), so adding a group with the same label here would make
|
|
// screen readers announce the setting name twice.
|
|
h_flex()
|
|
.id(title)
|
|
.min_w_0()
|
|
.justify_between()
|
|
.child(
|
|
v_flex()
|
|
.relative()
|
|
.w_full()
|
|
.max_w_2_3()
|
|
.min_w_0()
|
|
.child(
|
|
h_flex()
|
|
.w_full()
|
|
.gap_1()
|
|
.child(Label::new(SharedString::new_static(title)))
|
|
.when_some(reset_fn, |this, reset_to_default| {
|
|
this.child(
|
|
IconButton::new("reset-to-default-btn", IconName::Undo)
|
|
.icon_color(Color::Muted)
|
|
.icon_size(IconSize::Small)
|
|
.aria_label("Reset to Default")
|
|
.tooltip(Tooltip::text("Reset to Default"))
|
|
.on_click(move |_, window, cx| {
|
|
reset_to_default(window, cx);
|
|
}),
|
|
)
|
|
})
|
|
.when_some(modified_in, |this, modified_in| {
|
|
this.child(
|
|
Label::new(format!("\u{2014} Modified in {modified_in}"))
|
|
.color(Color::Muted)
|
|
.size(LabelSize::Small),
|
|
)
|
|
}),
|
|
)
|
|
.child(
|
|
Label::new(SharedString::new_static(description))
|
|
.size(LabelSize::Small)
|
|
.color(Color::Muted)
|
|
.render_code_spans(),
|
|
),
|
|
)
|
|
.child(control)
|
|
.when(settings_window.sub_page_stack.is_empty(), |this| {
|
|
this.child(render_settings_item_link(
|
|
description,
|
|
json_path,
|
|
sub_field,
|
|
settings_window,
|
|
cx,
|
|
))
|
|
})
|
|
}
|
|
|
|
fn render_settings_item(
|
|
settings_window: &SettingsWindow,
|
|
setting_item: &SettingItem,
|
|
file: SettingsUiFile,
|
|
control: AnyElement,
|
|
sub_field: bool,
|
|
cx: &mut Context<'_, SettingsWindow>,
|
|
) -> Stateful<Div> {
|
|
let (found_in_file, _) = setting_item.field.file_set_in(file.clone(), cx);
|
|
let file_set_in = SettingsUiFile::from_settings(found_in_file.clone());
|
|
|
|
let reset_fn = if sub_field {
|
|
None
|
|
} else {
|
|
setting_item
|
|
.field
|
|
.reset_to_default_fn(&file, &found_in_file, cx)
|
|
};
|
|
|
|
let modified_in = file_set_in
|
|
.filter(|f| f != &file)
|
|
.and_then(|f| settings_window.display_name(&f));
|
|
|
|
let control = if setting_item.field.is_overridden_by_organization(cx) {
|
|
h_flex()
|
|
.gap_2()
|
|
.child(
|
|
div()
|
|
.id(format!(
|
|
"{}-organization-configuration-warning",
|
|
setting_item.title
|
|
))
|
|
.child(
|
|
Icon::new(IconName::Warning)
|
|
.size(IconSize::Small)
|
|
.color(Color::Warning),
|
|
)
|
|
.tooltip(|_, cx| {
|
|
Tooltip::with_meta(
|
|
"Overridden by Organization",
|
|
None,
|
|
"Contact your organization admins to adjust this setting.",
|
|
cx,
|
|
)
|
|
}),
|
|
)
|
|
.child(control)
|
|
.into_any_element()
|
|
} else {
|
|
control
|
|
};
|
|
|
|
render_settings_item_layout(
|
|
settings_window,
|
|
setting_item.title,
|
|
setting_item.description,
|
|
control,
|
|
reset_fn,
|
|
modified_in,
|
|
setting_item.field.json_path(),
|
|
sub_field,
|
|
cx,
|
|
)
|
|
}
|
|
|
|
fn render_settings_item_link(
|
|
id: impl Into<ElementId>,
|
|
json_path: Option<&'static str>,
|
|
sub_field: bool,
|
|
settings_window: &SettingsWindow,
|
|
cx: &mut Context<'_, SettingsWindow>,
|
|
) -> impl IntoElement {
|
|
let copied_link_matches =
|
|
json_path.is_some() && json_path == settings_window.last_copied_link_path;
|
|
|
|
let (link_icon, link_icon_color) = if copied_link_matches {
|
|
(IconName::Check, Color::Success)
|
|
} else {
|
|
(IconName::Link, Color::Muted)
|
|
};
|
|
|
|
div()
|
|
.absolute()
|
|
.top(rems_from_px(18.))
|
|
.map(|this| {
|
|
if sub_field {
|
|
this.visible_on_hover("setting-sub-item")
|
|
.left(rems_from_px(-8.5))
|
|
} else {
|
|
this.visible_on_hover("setting-item")
|
|
.left(rems_from_px(-22.))
|
|
}
|
|
})
|
|
.child(
|
|
IconButton::new((id.into(), "copy-link-btn"), link_icon)
|
|
.icon_color(link_icon_color)
|
|
.icon_size(IconSize::Small)
|
|
.shape(IconButtonShape::Square)
|
|
.aria_label("Copy Link")
|
|
.tooltip(Tooltip::text("Copy Link"))
|
|
.when_some(json_path, |this, path| {
|
|
this.on_click(cx.listener(move |this, _, _, cx| {
|
|
let link = format!("zed://settings/{}", path);
|
|
cx.write_to_clipboard(ClipboardItem::new_string(link));
|
|
this.last_copied_link_path = Some(path);
|
|
cx.notify();
|
|
}))
|
|
}),
|
|
)
|
|
}
|
|
|
|
struct SettingItem {
|
|
title: &'static str,
|
|
description: &'static str,
|
|
field: Box<dyn AnySettingField>,
|
|
metadata: Option<Box<SettingsFieldMetadata>>,
|
|
files: FileMask,
|
|
}
|
|
|
|
struct DynamicItem {
|
|
discriminant: SettingItem,
|
|
pick_discriminant: fn(&SettingsContent) -> Option<usize>,
|
|
fields: Vec<Vec<SettingItem>>,
|
|
}
|
|
|
|
impl PartialEq for DynamicItem {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.discriminant == other.discriminant && self.fields == other.fields
|
|
}
|
|
}
|
|
|
|
#[derive(PartialEq, Eq, Clone, Copy)]
|
|
struct FileMask(u8);
|
|
|
|
impl std::fmt::Debug for FileMask {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "FileMask(")?;
|
|
let mut items = vec![];
|
|
|
|
if self.contains(USER) {
|
|
items.push("USER");
|
|
}
|
|
if self.contains(PROJECT) {
|
|
items.push("LOCAL");
|
|
}
|
|
if self.contains(SERVER) {
|
|
items.push("SERVER");
|
|
}
|
|
|
|
write!(f, "{})", items.join(" | "))
|
|
}
|
|
}
|
|
|
|
const USER: FileMask = FileMask(1 << 0);
|
|
const PROJECT: FileMask = FileMask(1 << 2);
|
|
const SERVER: FileMask = FileMask(1 << 3);
|
|
|
|
impl std::ops::BitAnd for FileMask {
|
|
type Output = Self;
|
|
|
|
fn bitand(self, other: Self) -> Self {
|
|
Self(self.0 & other.0)
|
|
}
|
|
}
|
|
|
|
impl std::ops::BitOr for FileMask {
|
|
type Output = Self;
|
|
|
|
fn bitor(self, other: Self) -> Self {
|
|
Self(self.0 | other.0)
|
|
}
|
|
}
|
|
|
|
impl FileMask {
|
|
fn contains(&self, other: FileMask) -> bool {
|
|
self.0 & other.0 != 0
|
|
}
|
|
}
|
|
|
|
impl PartialEq for SettingItem {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.title == other.title
|
|
&& self.description == other.description
|
|
&& (match (&self.metadata, &other.metadata) {
|
|
(None, None) => true,
|
|
(Some(m1), Some(m2)) => m1.placeholder == m2.placeholder,
|
|
_ => false,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Default)]
|
|
enum SubPageType {
|
|
Language,
|
|
SkillCreator,
|
|
#[default]
|
|
Other,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct SubPageLink {
|
|
title: SharedString,
|
|
r#type: SubPageType,
|
|
description: Option<SharedString>,
|
|
search_aliases: &'static [&'static str],
|
|
/// See [`SettingField.json_path`]
|
|
json_path: Option<&'static str>,
|
|
/// Whether or not the settings in this sub page are configurable in settings.json
|
|
/// Removes the "Edit in settings.json" button from the page.
|
|
in_json: bool,
|
|
files: FileMask,
|
|
render:
|
|
fn(&SettingsWindow, &ScrollHandle, &mut Window, &mut Context<SettingsWindow>) -> AnyElement,
|
|
}
|
|
|
|
impl PartialEq for SubPageLink {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.title == other.title
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ActionLink {
|
|
title: SharedString,
|
|
description: Option<SharedString>,
|
|
button_text: SharedString,
|
|
on_click: Arc<dyn Fn(&mut SettingsWindow, &mut Window, &mut App) + Send + Sync>,
|
|
files: FileMask,
|
|
}
|
|
|
|
impl PartialEq for ActionLink {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.title == other.title
|
|
}
|
|
}
|
|
|
|
fn all_language_names(cx: &App) -> Vec<SharedString> {
|
|
let state = workspace::AppState::global(cx);
|
|
state
|
|
.languages
|
|
.language_names()
|
|
.into_iter()
|
|
.filter(|name| name.as_ref() != "Zed Keybind Context")
|
|
.map(Into::into)
|
|
.collect()
|
|
}
|
|
|
|
#[allow(unused)]
|
|
#[derive(Clone, PartialEq, Debug)]
|
|
enum SettingsUiFile {
|
|
User, // Uses all settings.
|
|
Project((WorktreeId, Arc<RelPath>)), // Has a special name, and special set of settings
|
|
Server(&'static str), // Uses a special name, and the user settings
|
|
}
|
|
|
|
impl SettingsUiFile {
|
|
fn setting_type(&self) -> &'static str {
|
|
match self {
|
|
SettingsUiFile::User => "User",
|
|
SettingsUiFile::Project(_) => "Project",
|
|
SettingsUiFile::Server(_) => "Server",
|
|
}
|
|
}
|
|
|
|
fn is_server(&self) -> bool {
|
|
matches!(self, SettingsUiFile::Server(_))
|
|
}
|
|
|
|
fn worktree_id(&self) -> Option<WorktreeId> {
|
|
match self {
|
|
SettingsUiFile::User => None,
|
|
SettingsUiFile::Project((worktree_id, _)) => Some(*worktree_id),
|
|
SettingsUiFile::Server(_) => None,
|
|
}
|
|
}
|
|
|
|
fn from_settings(file: settings::SettingsFile) -> Option<Self> {
|
|
Some(match file {
|
|
settings::SettingsFile::User => SettingsUiFile::User,
|
|
settings::SettingsFile::Project(location) => SettingsUiFile::Project(location),
|
|
settings::SettingsFile::Server => SettingsUiFile::Server("todo: server name"),
|
|
settings::SettingsFile::Default => return None,
|
|
settings::SettingsFile::Global => return None,
|
|
})
|
|
}
|
|
|
|
fn to_settings(&self) -> settings::SettingsFile {
|
|
match self {
|
|
SettingsUiFile::User => settings::SettingsFile::User,
|
|
SettingsUiFile::Project(location) => settings::SettingsFile::Project(location.clone()),
|
|
SettingsUiFile::Server(_) => settings::SettingsFile::Server,
|
|
}
|
|
}
|
|
|
|
fn mask(&self) -> FileMask {
|
|
match self {
|
|
SettingsUiFile::User => USER,
|
|
SettingsUiFile::Project(_) => PROJECT,
|
|
SettingsUiFile::Server(_) => SERVER,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SettingsWindow {
|
|
fn new(
|
|
original_window: Option<WindowHandle<MultiWorkspace>>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let font_family_cache = theme::FontFamilyCache::global(cx);
|
|
|
|
cx.spawn(async move |this, cx| {
|
|
font_family_cache.prefetch(cx).await;
|
|
this.update(cx, |_, cx| {
|
|
cx.notify();
|
|
})
|
|
})
|
|
.detach();
|
|
|
|
let current_file = SettingsUiFile::User;
|
|
let search_bar = cx.new(|cx| {
|
|
let mut editor = Editor::single_line(window, cx);
|
|
editor.set_placeholder_text("Search settings…", window, cx);
|
|
editor
|
|
});
|
|
cx.subscribe(&search_bar, |this, _, event: &EditorEvent, cx| {
|
|
let EditorEvent::Edited { transaction_id: _ } = event else {
|
|
return;
|
|
};
|
|
|
|
if this.opening_link {
|
|
this.opening_link = false;
|
|
return;
|
|
}
|
|
this.update_matches(cx);
|
|
})
|
|
.detach();
|
|
|
|
let mut ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
|
|
cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
|
|
this.fetch_files(window, cx);
|
|
|
|
// Whenever settings are changed, it's possible that the changed
|
|
// settings affects the rendering of the `SettingsWindow`, like is
|
|
// the case with `ui_font_size`. When that happens, we need to
|
|
// instruct the `ListState` to re-measure the list items, as the
|
|
// list item heights may have changed depending on the new font
|
|
// size.
|
|
let new_ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
|
|
if new_ui_font_size != ui_font_size {
|
|
this.list_state.remeasure();
|
|
ui_font_size = new_ui_font_size;
|
|
}
|
|
|
|
cx.notify();
|
|
})
|
|
.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.observe_global_in::<SkillIndex>(window, |this, _window, cx| {
|
|
if let Some(skill_index) = cx.try_global::<SkillIndex>() {
|
|
this.hidden_deleted_skill_directory_paths
|
|
.retain(|directory_path| {
|
|
skill_index
|
|
.global_skills
|
|
.iter()
|
|
.chain(
|
|
skill_index
|
|
.project_skills
|
|
.iter()
|
|
.flat_map(|group| group.skills.iter()),
|
|
)
|
|
.any(|skill| skill.directory_path.as_path() == directory_path.as_path())
|
|
});
|
|
} else {
|
|
this.hidden_deleted_skill_directory_paths.clear();
|
|
}
|
|
cx.notify();
|
|
})
|
|
.detach();
|
|
|
|
let language_model_registry = language_model::LanguageModelRegistry::global(cx);
|
|
cx.subscribe(&language_model_registry, |_, _, _event, cx| {
|
|
cx.notify();
|
|
})
|
|
.detach();
|
|
|
|
cx.on_window_closed(|cx, _window_id| {
|
|
if let Some(existing_window) = cx
|
|
.windows()
|
|
.into_iter()
|
|
.find_map(|window| window.downcast::<SettingsWindow>())
|
|
&& cx.windows().len() == 1
|
|
{
|
|
cx.update_window(*existing_window, |_, window, _| {
|
|
window.remove_window();
|
|
})
|
|
.ok();
|
|
|
|
telemetry::event!("Settings Closed")
|
|
}
|
|
})
|
|
.detach();
|
|
|
|
let app_state = AppState::global(cx);
|
|
let workspaces: Vec<Entity<Workspace>> = app_state
|
|
.workspace_store
|
|
.read(cx)
|
|
.workspaces()
|
|
.filter_map(|weak| weak.upgrade())
|
|
.collect();
|
|
|
|
for workspace in workspaces {
|
|
let project = workspace.read(cx).project().clone();
|
|
cx.observe_release_in(&project, window, |this, _, window, cx| {
|
|
this.fetch_files(window, cx)
|
|
})
|
|
.detach();
|
|
cx.subscribe_in(&project, window, Self::handle_project_event)
|
|
.detach();
|
|
cx.observe_release_in(&workspace, window, |this, _, window, cx| {
|
|
this.fetch_files(window, cx)
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
let this_weak = cx.weak_entity();
|
|
cx.observe_new::<Project>({
|
|
let this_weak = this_weak.clone();
|
|
|
|
move |_, window, cx| {
|
|
let project = cx.entity();
|
|
let Some(window) = window else {
|
|
return;
|
|
};
|
|
|
|
this_weak
|
|
.update(cx, |_, cx| {
|
|
cx.defer_in(window, |settings_window, window, cx| {
|
|
settings_window.fetch_files(window, cx)
|
|
});
|
|
cx.observe_release_in(&project, window, |_, _, window, cx| {
|
|
cx.defer_in(window, |this, window, cx| this.fetch_files(window, cx));
|
|
})
|
|
.detach();
|
|
|
|
cx.subscribe_in(&project, window, Self::handle_project_event)
|
|
.detach();
|
|
})
|
|
.ok();
|
|
}
|
|
})
|
|
.detach();
|
|
|
|
let handle = window.window_handle();
|
|
cx.observe_new::<Workspace>(move |workspace, _, cx| {
|
|
let project = workspace.project().clone();
|
|
let this_weak = this_weak.clone();
|
|
|
|
// We defer on the settings window (via `handle`) rather than using
|
|
// the workspace's window from observe_new. When window.defer() runs
|
|
// its callback, it calls handle.update() which temporarily removes
|
|
// that window from cx.windows. If we deferred on the workspace's
|
|
// window, then when fetch_files() tries to read ALL workspaces from
|
|
// the store (including the newly created one), it would fail with
|
|
// "window not found" because that workspace's window would be
|
|
// temporarily removed from cx.windows for the duration of our callback.
|
|
handle
|
|
.update(cx, move |_, window, cx| {
|
|
window.defer(cx, move |window, cx| {
|
|
this_weak
|
|
.update(cx, |this, cx| {
|
|
this.fetch_files(window, cx);
|
|
cx.observe_release_in(&project, window, |this, _, window, cx| {
|
|
this.fetch_files(window, cx)
|
|
})
|
|
.detach();
|
|
})
|
|
.ok();
|
|
});
|
|
})
|
|
.ok();
|
|
})
|
|
.detach();
|
|
|
|
let title_bar = if !cfg!(target_os = "macos") {
|
|
Some(cx.new(|cx| PlatformTitleBar::new("settings-title-bar", cx)))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let list_state = gpui::ListState::new(0, gpui::ListAlignment::Top, px(0.0)).measure_all();
|
|
list_state.set_scroll_handler(|_, _, _| {});
|
|
|
|
let mut this = Self {
|
|
title_bar,
|
|
original_window,
|
|
|
|
worktree_root_dirs: HashMap::default(),
|
|
files: vec![],
|
|
|
|
current_file: current_file,
|
|
project_setting_file_buffers: HashMap::default(),
|
|
pages: vec![],
|
|
sub_page_stack: vec![],
|
|
opening_link: false,
|
|
navbar_entries: vec![],
|
|
navbar_entry: 0,
|
|
navbar_scroll_handle: UniformListScrollHandle::default(),
|
|
search_bar,
|
|
search_task: None,
|
|
filter_table: vec![],
|
|
has_query: false,
|
|
content_handles: vec![],
|
|
focus_handle: cx.focus_handle(),
|
|
navbar_focus_handle: NonFocusableHandle::new(
|
|
NAVBAR_CONTAINER_TAB_INDEX,
|
|
false,
|
|
window,
|
|
cx,
|
|
),
|
|
navbar_focus_subscriptions: vec![],
|
|
content_focus_handle: NonFocusableHandle::new(
|
|
CONTENT_CONTAINER_TAB_INDEX,
|
|
false,
|
|
window,
|
|
cx,
|
|
),
|
|
files_focus_handle: cx
|
|
.focus_handle()
|
|
.tab_index(HEADER_CONTAINER_TAB_INDEX)
|
|
.tab_stop(false),
|
|
search_index: None,
|
|
shown_errors: HashSet::default(),
|
|
hidden_deleted_skill_directory_paths: HashSet::default(),
|
|
regex_validation_error: None,
|
|
sandbox_host_validation_error: None,
|
|
list_state,
|
|
last_copied_link_path: None,
|
|
provider_configuration_views: HashMap::default(),
|
|
configuring_provider: None,
|
|
last_copied_skill_directory_path: None,
|
|
llm_provider_form: None,
|
|
llm_provider_add_focus_handle: cx.focus_handle(),
|
|
mcp_server_form: None,
|
|
mcp_add_server_focus_handle: cx.focus_handle(),
|
|
custom_agent_form: None,
|
|
external_agent_add_focus_handle: cx.focus_handle(),
|
|
skill_creator_page: None,
|
|
};
|
|
|
|
this.fetch_files(window, cx);
|
|
this.build_ui(window, cx);
|
|
this.build_search_index();
|
|
|
|
this.search_bar.update(cx, |editor, cx| {
|
|
editor.focus_handle(cx).focus(window, cx);
|
|
});
|
|
|
|
this
|
|
}
|
|
|
|
fn handle_project_event(
|
|
&mut self,
|
|
_: &Entity<Project>,
|
|
event: &project::Event,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) {
|
|
match event {
|
|
project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded(_) => {
|
|
cx.defer_in(window, |this, window, cx| {
|
|
this.fetch_files(window, cx);
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn toggle_navbar_entry(&mut self, nav_entry_index: usize) {
|
|
// We can only toggle root entries
|
|
if !self.navbar_entries[nav_entry_index].is_root {
|
|
return;
|
|
}
|
|
|
|
let expanded = &mut self.navbar_entries[nav_entry_index].expanded;
|
|
*expanded = !*expanded;
|
|
self.navbar_entry = nav_entry_index;
|
|
self.reset_list_state();
|
|
}
|
|
|
|
fn toggle_and_focus_navbar_entry(
|
|
&mut self,
|
|
nav_entry_index: usize,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.toggle_navbar_entry(nav_entry_index);
|
|
window.focus(&self.navbar_entries[nav_entry_index].focus_handle, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn toggle_navbar_entry_on_double_click(
|
|
&mut self,
|
|
nav_entry_index: usize,
|
|
event: &gpui::ClickEvent,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> bool {
|
|
let Some(entry) = self.navbar_entries.get(nav_entry_index) else {
|
|
return false;
|
|
};
|
|
if !entry.is_root || event.click_count() != 2 {
|
|
return false;
|
|
}
|
|
|
|
self.toggle_and_focus_navbar_entry(nav_entry_index, window, cx);
|
|
true
|
|
}
|
|
|
|
fn build_navbar(&mut self, cx: &App) {
|
|
let mut navbar_entries = Vec::new();
|
|
|
|
for (page_index, page) in self.pages.iter().enumerate() {
|
|
navbar_entries.push(NavBarEntry {
|
|
title: page.title,
|
|
is_root: true,
|
|
expanded: false,
|
|
page_index,
|
|
item_index: None,
|
|
focus_handle: cx.focus_handle().tab_index(0).tab_stop(true),
|
|
});
|
|
|
|
for (item_index, item) in page.items.iter().enumerate() {
|
|
let SettingsPageItem::SectionHeader(title) = item else {
|
|
continue;
|
|
};
|
|
navbar_entries.push(NavBarEntry {
|
|
title,
|
|
is_root: false,
|
|
expanded: false,
|
|
page_index,
|
|
item_index: Some(item_index),
|
|
focus_handle: cx.focus_handle().tab_index(0).tab_stop(true),
|
|
});
|
|
}
|
|
}
|
|
|
|
self.navbar_entries = navbar_entries;
|
|
}
|
|
|
|
fn setup_navbar_focus_subscriptions(
|
|
&mut self,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) {
|
|
let mut focus_subscriptions = Vec::new();
|
|
|
|
for entry_index in 0..self.navbar_entries.len() {
|
|
let focus_handle = self.navbar_entries[entry_index].focus_handle.clone();
|
|
|
|
let subscription = cx.on_focus(
|
|
&focus_handle,
|
|
window,
|
|
move |this: &mut SettingsWindow,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>| {
|
|
if this.sub_page_stack.is_empty() {
|
|
this.open_and_scroll_to_navbar_entry(entry_index, None, false, window, cx);
|
|
}
|
|
},
|
|
);
|
|
focus_subscriptions.push(subscription);
|
|
}
|
|
self.navbar_focus_subscriptions = focus_subscriptions;
|
|
}
|
|
|
|
fn visible_navbar_entries(&self) -> impl Iterator<Item = (usize, &NavBarEntry)> {
|
|
let mut index = 0;
|
|
let entries = &self.navbar_entries;
|
|
let search_matches = &self.filter_table;
|
|
let has_query = self.has_query;
|
|
std::iter::from_fn(move || {
|
|
while index < entries.len() {
|
|
let entry = &entries[index];
|
|
let included_in_search = if let Some(item_index) = entry.item_index {
|
|
search_matches[entry.page_index][item_index]
|
|
} else {
|
|
search_matches[entry.page_index].iter().any(|b| *b)
|
|
|| search_matches[entry.page_index].is_empty()
|
|
};
|
|
if included_in_search {
|
|
break;
|
|
}
|
|
index += 1;
|
|
}
|
|
if index >= self.navbar_entries.len() {
|
|
return None;
|
|
}
|
|
let entry = &entries[index];
|
|
let entry_index = index;
|
|
|
|
index += 1;
|
|
if entry.is_root && !entry.expanded && !has_query {
|
|
while index < entries.len() {
|
|
if entries[index].is_root {
|
|
break;
|
|
}
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
return Some((entry_index, entry));
|
|
})
|
|
}
|
|
|
|
fn filter_matches_to_file(&mut self) {
|
|
let current_file = self.current_file.mask();
|
|
for (page, page_filter) in std::iter::zip(&self.pages, &mut self.filter_table) {
|
|
let mut header_index = 0;
|
|
let mut any_found_since_last_header = true;
|
|
|
|
for (index, item) in page.items.iter().enumerate() {
|
|
match item {
|
|
SettingsPageItem::SectionHeader(_) => {
|
|
if !any_found_since_last_header {
|
|
page_filter[header_index] = false;
|
|
}
|
|
header_index = index;
|
|
any_found_since_last_header = false;
|
|
}
|
|
SettingsPageItem::SettingItem(SettingItem { files, .. })
|
|
| SettingsPageItem::SubPageLink(SubPageLink { files, .. })
|
|
| SettingsPageItem::DynamicItem(DynamicItem {
|
|
discriminant: SettingItem { files, .. },
|
|
..
|
|
}) => {
|
|
if !files.contains(current_file) {
|
|
page_filter[index] = false;
|
|
} else {
|
|
any_found_since_last_header = true;
|
|
}
|
|
}
|
|
SettingsPageItem::ActionLink(ActionLink { files, .. }) => {
|
|
if !files.contains(current_file) {
|
|
page_filter[index] = false;
|
|
} else {
|
|
any_found_since_last_header = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Some(last_header) = page_filter.get_mut(header_index)
|
|
&& !any_found_since_last_header
|
|
{
|
|
*last_header = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn filter_by_json_path(&self, query: &str) -> Vec<usize> {
|
|
let Some(path) = query.strip_prefix('#') else {
|
|
return vec![];
|
|
};
|
|
let Some(search_index) = self.search_index.as_ref() else {
|
|
return vec![];
|
|
};
|
|
let mut indices = vec![];
|
|
for (index, SearchKeyLUTEntry { json_path, .. }) in search_index.key_lut.iter().enumerate()
|
|
{
|
|
let Some(json_path) = json_path else {
|
|
continue;
|
|
};
|
|
|
|
if let Some(post) = json_path.strip_prefix(path)
|
|
&& (post.is_empty() || post.starts_with('.'))
|
|
{
|
|
indices.push(index);
|
|
}
|
|
}
|
|
indices
|
|
}
|
|
|
|
fn apply_match_indices(&mut self, match_indices: impl Iterator<Item = usize>, query: &str) {
|
|
let Some(search_index) = self.search_index.as_ref() else {
|
|
return;
|
|
};
|
|
|
|
for page in &mut self.filter_table {
|
|
page.fill(false);
|
|
}
|
|
|
|
for match_index in match_indices {
|
|
let SearchKeyLUTEntry {
|
|
page_index,
|
|
header_index,
|
|
item_index,
|
|
..
|
|
} = search_index.key_lut[match_index];
|
|
let page = &mut self.filter_table[page_index];
|
|
page[header_index] = true;
|
|
page[item_index] = true;
|
|
}
|
|
self.has_query = true;
|
|
self.filter_matches_to_file();
|
|
let query_lower = query.to_lowercase();
|
|
let query_words: Vec<&str> = query_lower.split_whitespace().collect();
|
|
self.open_best_matching_nav_page(&query_words);
|
|
self.reset_list_state();
|
|
self.scroll_content_to_best_match(&query_words);
|
|
}
|
|
|
|
fn update_matches(&mut self, cx: &mut Context<SettingsWindow>) {
|
|
self.search_task.take();
|
|
let query = self.search_bar.read(cx).text(cx);
|
|
if query.is_empty() || self.search_index.is_none() {
|
|
for page in &mut self.filter_table {
|
|
page.fill(true);
|
|
}
|
|
self.has_query = false;
|
|
self.filter_matches_to_file();
|
|
self.reset_list_state();
|
|
cx.notify();
|
|
return;
|
|
}
|
|
|
|
let is_json_link_query = query.starts_with("#");
|
|
if is_json_link_query {
|
|
let indices = self.filter_by_json_path(&query);
|
|
if !indices.is_empty() {
|
|
self.apply_match_indices(indices.into_iter(), &query);
|
|
cx.notify();
|
|
return;
|
|
}
|
|
}
|
|
|
|
let search_index = self.search_index.as_ref().unwrap().clone();
|
|
|
|
self.search_task = Some(cx.spawn(async move |this, cx| {
|
|
let exact_match_task = cx.background_spawn({
|
|
let search_index = search_index.clone();
|
|
let query = query.clone();
|
|
async move {
|
|
let query_lower = query.to_lowercase();
|
|
let query_words: Vec<&str> = query_lower.split_whitespace().collect();
|
|
if query_words.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
search_index
|
|
.documents
|
|
.iter()
|
|
.filter(|doc| {
|
|
query_words.iter().all(|query_word| {
|
|
doc.words
|
|
.iter()
|
|
.any(|doc_word| doc_word.starts_with(query_word))
|
|
})
|
|
})
|
|
.map(|doc| doc.id)
|
|
.collect::<Vec<usize>>()
|
|
}
|
|
});
|
|
let cancel_flag = std::sync::atomic::AtomicBool::new(false);
|
|
let fuzzy_search_task = fuzzy::match_strings(
|
|
search_index.fuzzy_match_candidates.as_slice(),
|
|
&query,
|
|
false,
|
|
true,
|
|
search_index.fuzzy_match_candidates.len(),
|
|
&cancel_flag,
|
|
cx.background_executor().clone(),
|
|
);
|
|
|
|
let fuzzy_matches = fuzzy_search_task.await;
|
|
let exact_matches = exact_match_task.await;
|
|
|
|
this.update(cx, |this, cx| {
|
|
let exact_indices = exact_matches.into_iter();
|
|
let fuzzy_indices = fuzzy_matches
|
|
.into_iter()
|
|
.take_while(|fuzzy_match| fuzzy_match.score >= 0.5)
|
|
.map(|fuzzy_match| fuzzy_match.candidate_id);
|
|
let merged_indices = exact_indices.chain(fuzzy_indices);
|
|
|
|
this.apply_match_indices(merged_indices, &query);
|
|
cx.notify();
|
|
})
|
|
.ok();
|
|
|
|
cx.background_executor().timer(Duration::from_secs(1)).await;
|
|
telemetry::event!("Settings Searched", query = query)
|
|
}));
|
|
}
|
|
|
|
fn build_filter_table(&mut self) {
|
|
self.filter_table = self
|
|
.pages
|
|
.iter()
|
|
.map(|page| vec![true; page.items.len()])
|
|
.collect::<Vec<_>>();
|
|
}
|
|
|
|
fn build_search_index(&mut self) {
|
|
fn split_into_words(parts: &[&str]) -> Vec<String> {
|
|
parts
|
|
.iter()
|
|
.flat_map(|s| {
|
|
s.split(|c: char| !c.is_alphanumeric())
|
|
.filter(|w| !w.is_empty())
|
|
.map(|w| w.to_lowercase())
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
let mut key_lut: Vec<SearchKeyLUTEntry> = vec![];
|
|
let mut documents: Vec<SearchDocument> = Vec::default();
|
|
let mut fuzzy_match_candidates = Vec::default();
|
|
|
|
fn push_candidates(
|
|
fuzzy_match_candidates: &mut Vec<StringMatchCandidate>,
|
|
key_index: usize,
|
|
input: &str,
|
|
) {
|
|
for word in input.split_ascii_whitespace() {
|
|
fuzzy_match_candidates.push(StringMatchCandidate::new(key_index, word));
|
|
}
|
|
}
|
|
|
|
// PERF: We are currently searching all items even in project files
|
|
// where many settings are filtered out, using the logic in filter_matches_to_file
|
|
// we could only search relevant items based on the current file
|
|
for (page_index, page) in self.pages.iter().enumerate() {
|
|
let mut header_index = 0;
|
|
let mut header_str = "";
|
|
for (item_index, item) in page.items.iter().enumerate() {
|
|
let key_index = key_lut.len();
|
|
let mut json_path = None;
|
|
match item {
|
|
SettingsPageItem::DynamicItem(DynamicItem {
|
|
discriminant: item, ..
|
|
})
|
|
| SettingsPageItem::SettingItem(item) => {
|
|
json_path = item
|
|
.field
|
|
.json_path()
|
|
.map(|path| path.trim_end_matches('$'));
|
|
documents.push(SearchDocument {
|
|
id: key_index,
|
|
words: split_into_words(&[
|
|
page.title,
|
|
header_str,
|
|
item.title,
|
|
item.description,
|
|
]),
|
|
});
|
|
push_candidates(&mut fuzzy_match_candidates, key_index, item.title);
|
|
push_candidates(&mut fuzzy_match_candidates, key_index, item.description);
|
|
}
|
|
SettingsPageItem::SectionHeader(header) => {
|
|
documents.push(SearchDocument {
|
|
id: key_index,
|
|
words: split_into_words(&[header]),
|
|
});
|
|
push_candidates(&mut fuzzy_match_candidates, key_index, header);
|
|
header_index = item_index;
|
|
header_str = *header;
|
|
}
|
|
SettingsPageItem::SubPageLink(sub_page_link) => {
|
|
json_path = sub_page_link.json_path;
|
|
let mut parts = vec![page.title, header_str, sub_page_link.title.as_ref()];
|
|
parts.extend(sub_page_link.search_aliases);
|
|
documents.push(SearchDocument {
|
|
id: key_index,
|
|
words: split_into_words(&parts),
|
|
});
|
|
push_candidates(
|
|
&mut fuzzy_match_candidates,
|
|
key_index,
|
|
sub_page_link.title.as_ref(),
|
|
);
|
|
for alias in sub_page_link.search_aliases {
|
|
push_candidates(&mut fuzzy_match_candidates, key_index, alias);
|
|
}
|
|
}
|
|
SettingsPageItem::ActionLink(action_link) => {
|
|
documents.push(SearchDocument {
|
|
id: key_index,
|
|
words: split_into_words(&[
|
|
page.title,
|
|
header_str,
|
|
action_link.title.as_ref(),
|
|
]),
|
|
});
|
|
push_candidates(
|
|
&mut fuzzy_match_candidates,
|
|
key_index,
|
|
action_link.title.as_ref(),
|
|
);
|
|
}
|
|
}
|
|
push_candidates(&mut fuzzy_match_candidates, key_index, page.title);
|
|
push_candidates(&mut fuzzy_match_candidates, key_index, header_str);
|
|
|
|
key_lut.push(SearchKeyLUTEntry {
|
|
page_index,
|
|
header_index,
|
|
item_index,
|
|
json_path,
|
|
});
|
|
}
|
|
}
|
|
self.search_index = Some(Arc::new(SearchIndex {
|
|
documents,
|
|
key_lut,
|
|
fuzzy_match_candidates,
|
|
}));
|
|
}
|
|
|
|
fn build_content_handles(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
|
|
self.content_handles = self
|
|
.pages
|
|
.iter()
|
|
.map(|page| {
|
|
std::iter::repeat_with(|| NonFocusableHandle::new(0, false, window, cx))
|
|
.take(page.items.len())
|
|
.collect()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
}
|
|
|
|
fn reset_list_state(&mut self) {
|
|
let mut visible_items_count = self.visible_page_items().count();
|
|
|
|
if visible_items_count > 0 {
|
|
// show page title if page is non empty
|
|
visible_items_count += 1;
|
|
}
|
|
|
|
self.list_state.reset(visible_items_count);
|
|
}
|
|
|
|
fn build_ui(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
|
|
if self.pages.is_empty() {
|
|
self.pages = page_data::settings_data(cx);
|
|
self.build_navbar(cx);
|
|
self.setup_navbar_focus_subscriptions(window, cx);
|
|
self.build_content_handles(window, cx);
|
|
}
|
|
self.sub_page_stack.clear();
|
|
// PERF: doesn't have to be rebuilt, can just be filled with true. pages is constant once it is built
|
|
self.build_filter_table();
|
|
self.reset_list_state();
|
|
self.update_matches(cx);
|
|
|
|
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();
|
|
let prev_files = self.files.clone();
|
|
let settings_store = cx.global::<SettingsStore>();
|
|
let mut ui_files = vec![];
|
|
let mut all_files = settings_store.get_all_files();
|
|
if !all_files.contains(&settings::SettingsFile::User) {
|
|
all_files.push(settings::SettingsFile::User);
|
|
}
|
|
for file in all_files {
|
|
let Some(settings_ui_file) = SettingsUiFile::from_settings(file) else {
|
|
continue;
|
|
};
|
|
if settings_ui_file.is_server() {
|
|
continue;
|
|
}
|
|
|
|
if let Some(worktree_id) = settings_ui_file.worktree_id() {
|
|
let directory_name = all_projects(self.original_window.as_ref(), cx)
|
|
.find_map(|project| project.read(cx).worktree_for_id(worktree_id, cx))
|
|
.map(|worktree| worktree.read(cx).root_name());
|
|
|
|
let Some(directory_name) = directory_name else {
|
|
log::error!(
|
|
"No directory name found for settings file at worktree ID: {}",
|
|
worktree_id
|
|
);
|
|
continue;
|
|
};
|
|
|
|
self.worktree_root_dirs
|
|
.insert(worktree_id, directory_name.as_unix_str().to_string());
|
|
}
|
|
|
|
let focus_handle = prev_files
|
|
.iter()
|
|
.find_map(|(prev_file, handle)| {
|
|
(prev_file == &settings_ui_file).then(|| handle.clone())
|
|
})
|
|
.unwrap_or_else(|| cx.focus_handle().tab_index(0).tab_stop(true));
|
|
ui_files.push((settings_ui_file, focus_handle));
|
|
}
|
|
|
|
ui_files.reverse();
|
|
|
|
if self.original_window.is_some() {
|
|
let mut missing_worktrees = Vec::new();
|
|
|
|
for worktree in all_projects(self.original_window.as_ref(), cx)
|
|
.flat_map(|project| project.read(cx).visible_worktrees(cx))
|
|
.filter(|tree| !self.worktree_root_dirs.contains_key(&tree.read(cx).id()))
|
|
{
|
|
let worktree = worktree.read(cx);
|
|
let worktree_id = worktree.id();
|
|
let Some(directory_name) = worktree.root_dir().and_then(|file| {
|
|
file.file_name()
|
|
.map(|os_string| os_string.to_string_lossy().to_string())
|
|
}) else {
|
|
continue;
|
|
};
|
|
|
|
missing_worktrees.push((worktree_id, directory_name.clone()));
|
|
let path = RelPath::empty().to_owned().into_arc();
|
|
|
|
let settings_ui_file = SettingsUiFile::Project((worktree_id, path));
|
|
|
|
let focus_handle = prev_files
|
|
.iter()
|
|
.find_map(|(prev_file, handle)| {
|
|
(prev_file == &settings_ui_file).then(|| handle.clone())
|
|
})
|
|
.unwrap_or_else(|| cx.focus_handle().tab_index(0).tab_stop(true));
|
|
|
|
ui_files.push((settings_ui_file, focus_handle));
|
|
}
|
|
|
|
self.worktree_root_dirs.extend(missing_worktrees);
|
|
}
|
|
|
|
self.files = ui_files;
|
|
let current_file_still_exists = self
|
|
.files
|
|
.iter()
|
|
.any(|(file, _)| file == &self.current_file);
|
|
if !current_file_still_exists {
|
|
self.change_file(0, window, cx);
|
|
}
|
|
}
|
|
|
|
fn open_navbar_entry_page(&mut self, navbar_entry: usize) {
|
|
// Navigating to another page dismisses the transient "copied share
|
|
// link" checkmark shown on a Skills page row.
|
|
self.last_copied_skill_directory_path = None;
|
|
|
|
if !self.is_nav_entry_visible(navbar_entry) {
|
|
self.open_first_nav_page();
|
|
}
|
|
|
|
let is_new_page = self.navbar_entries[self.navbar_entry].page_index
|
|
!= self.navbar_entries[navbar_entry].page_index;
|
|
|
|
self.navbar_entry = navbar_entry;
|
|
|
|
// We only need to reset visible items when updating matches
|
|
// and selecting a new page
|
|
if is_new_page {
|
|
self.reset_list_state();
|
|
}
|
|
|
|
self.sub_page_stack.clear();
|
|
}
|
|
|
|
fn open_best_matching_nav_page(&mut self, query_words: &[&str]) {
|
|
let mut entries = self.visible_navbar_entries().peekable();
|
|
let first_entry = entries.peek().map(|(index, _)| (0, *index));
|
|
let best_match = entries
|
|
.enumerate()
|
|
.filter(|(_, (_, entry))| !entry.is_root)
|
|
.map(|(logical_index, (index, entry))| {
|
|
let title_lower = entry.title.to_lowercase();
|
|
let matching_words = query_words
|
|
.iter()
|
|
.filter(|query_word| {
|
|
title_lower
|
|
.split_whitespace()
|
|
.any(|title_word| title_word.starts_with(*query_word))
|
|
})
|
|
.count();
|
|
(logical_index, index, matching_words)
|
|
})
|
|
.filter(|(_, _, count)| *count > 0)
|
|
.max_by_key(|(_, _, count)| *count)
|
|
.map(|(logical_index, index, _)| (logical_index, index));
|
|
if let Some((logical_index, navbar_entry_index)) = best_match.or(first_entry) {
|
|
self.open_navbar_entry_page(navbar_entry_index);
|
|
self.navbar_scroll_handle
|
|
.scroll_to_item(logical_index + 1, gpui::ScrollStrategy::Top);
|
|
}
|
|
}
|
|
|
|
fn scroll_content_to_best_match(&self, query_words: &[&str]) {
|
|
let position = self
|
|
.visible_page_items()
|
|
.enumerate()
|
|
.find(|(_, (_, item))| match item {
|
|
SettingsPageItem::SectionHeader(title) => {
|
|
let title_lower = title.to_lowercase();
|
|
query_words.iter().all(|query_word| {
|
|
title_lower
|
|
.split_whitespace()
|
|
.any(|title_word| title_word.starts_with(query_word))
|
|
})
|
|
}
|
|
_ => false,
|
|
})
|
|
.map(|(position, _)| position);
|
|
if let Some(position) = position {
|
|
self.list_state.scroll_to(gpui::ListOffset {
|
|
item_ix: position + 1,
|
|
offset_in_item: px(0.),
|
|
});
|
|
}
|
|
}
|
|
|
|
fn open_first_nav_page(&mut self) {
|
|
let Some(first_navbar_entry_index) = self.visible_navbar_entries().next().map(|e| e.0)
|
|
else {
|
|
return;
|
|
};
|
|
self.open_navbar_entry_page(first_navbar_entry_index);
|
|
}
|
|
|
|
fn change_file(&mut self, ix: usize, window: &mut Window, cx: &mut Context<SettingsWindow>) {
|
|
if ix >= self.files.len() {
|
|
self.current_file = SettingsUiFile::User;
|
|
self.build_ui(window, cx);
|
|
return;
|
|
}
|
|
|
|
if self.files[ix].0 == self.current_file {
|
|
return;
|
|
}
|
|
self.current_file = self.files[ix].0.clone();
|
|
|
|
if let SettingsUiFile::Project((_, _)) = &self.current_file {
|
|
telemetry::event!("Setting Project Clicked");
|
|
}
|
|
|
|
self.build_ui(window, cx);
|
|
|
|
if self
|
|
.visible_navbar_entries()
|
|
.any(|(index, _)| index == self.navbar_entry)
|
|
{
|
|
self.open_and_scroll_to_navbar_entry(self.navbar_entry, None, true, window, cx);
|
|
} else {
|
|
self.open_first_nav_page();
|
|
};
|
|
}
|
|
|
|
/// Changes the current settings file like [`Self::change_file`], but keeps
|
|
/// the currently open sub-page stack when every sub-page in it is
|
|
/// available in the new file's scope (e.g. switching a Skills sub-page
|
|
/// between the user scope and a project scope).
|
|
fn change_file_in_sub_page(
|
|
&mut self,
|
|
ix: usize,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) {
|
|
if ix >= self.files.len() || self.files[ix].0 == self.current_file {
|
|
return;
|
|
}
|
|
self.current_file = self.files[ix].0.clone();
|
|
|
|
if let SettingsUiFile::Project((_, _)) = &self.current_file {
|
|
telemetry::event!("Setting Project Clicked");
|
|
}
|
|
|
|
self.last_copied_skill_directory_path = None;
|
|
|
|
let sub_page_stack = std::mem::take(&mut self.sub_page_stack);
|
|
self.build_ui(window, cx);
|
|
|
|
let file_mask = self.current_file.mask();
|
|
if let Some(first_sub_page) = sub_page_stack.first()
|
|
&& sub_page_stack
|
|
.iter()
|
|
.all(|sub_page| sub_page.link.files.contains(file_mask))
|
|
{
|
|
if !self.is_nav_entry_visible(self.navbar_entry) {
|
|
// The previously selected page may be filtered out in the new
|
|
// scope (e.g. after deep-linking into a sub-page). Re-anchor
|
|
// the navbar to the page containing the open sub-page, which
|
|
// is visible because its sub-page link supports this scope.
|
|
let anchor_entry = self
|
|
.pages
|
|
.iter()
|
|
.position(|page| {
|
|
page.items.iter().any(|item| {
|
|
matches!(item, SettingsPageItem::SubPageLink(link) if link == &first_sub_page.link)
|
|
})
|
|
})
|
|
.and_then(|page_index| {
|
|
self.navbar_entries
|
|
.iter()
|
|
.position(|entry| entry.is_root && entry.page_index == page_index)
|
|
});
|
|
if let Some(anchor_entry) = anchor_entry
|
|
&& self.is_nav_entry_visible(anchor_entry)
|
|
{
|
|
self.open_navbar_entry_page(anchor_entry);
|
|
}
|
|
}
|
|
if self.is_nav_entry_visible(self.navbar_entry) {
|
|
self.sub_page_stack = sub_page_stack;
|
|
cx.notify();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if self.is_nav_entry_visible(self.navbar_entry) {
|
|
self.open_and_scroll_to_navbar_entry(self.navbar_entry, None, true, window, cx);
|
|
} else {
|
|
self.open_first_nav_page();
|
|
}
|
|
}
|
|
|
|
fn render_files_header(
|
|
&self,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) -> impl IntoElement {
|
|
static OVERFLOW_LIMIT: usize = 1;
|
|
|
|
let file_button =
|
|
|ix, file: &SettingsUiFile, focus_handle, cx: &mut Context<SettingsWindow>| {
|
|
Button::new(
|
|
ix,
|
|
self.display_name(&file)
|
|
.expect("Files should always have a name"),
|
|
)
|
|
.toggle_state(file == &self.current_file)
|
|
.selected_style(ButtonStyle::Tinted(ui::TintColor::Accent))
|
|
.track_focus(focus_handle)
|
|
.on_click(cx.listener({
|
|
let focus_handle = focus_handle.clone();
|
|
move |this, _: &gpui::ClickEvent, window, cx| {
|
|
this.change_file(ix, window, cx);
|
|
focus_handle.focus(window, cx);
|
|
}
|
|
}))
|
|
};
|
|
|
|
let this = cx.entity();
|
|
|
|
let selected_file_ix = self
|
|
.files
|
|
.iter()
|
|
.enumerate()
|
|
.skip(OVERFLOW_LIMIT)
|
|
.find_map(|(ix, (file, _))| {
|
|
if file == &self.current_file {
|
|
Some(ix)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.unwrap_or(OVERFLOW_LIMIT);
|
|
let edit_in_json_id = SharedString::new(format!("edit-in-json-{}", selected_file_ix));
|
|
|
|
h_flex()
|
|
.id("settings-ui-files-header")
|
|
.role(Role::Group)
|
|
.aria_label("Settings File")
|
|
.w_full()
|
|
.gap_1()
|
|
.justify_between()
|
|
.track_focus(&self.files_focus_handle)
|
|
.tab_group()
|
|
.tab_index(HEADER_GROUP_TAB_INDEX)
|
|
.child(
|
|
h_flex()
|
|
.gap_1()
|
|
.children(
|
|
self.files.iter().enumerate().take(OVERFLOW_LIMIT).map(
|
|
|(ix, (file, focus_handle))| file_button(ix, file, focus_handle, cx),
|
|
),
|
|
)
|
|
.when(self.files.len() > OVERFLOW_LIMIT, |div| {
|
|
let (file, focus_handle) = &self.files[selected_file_ix];
|
|
|
|
div.child(file_button(selected_file_ix, file, focus_handle, cx))
|
|
.when(self.files.len() > OVERFLOW_LIMIT + 1, |div| {
|
|
div.child(
|
|
DropdownMenu::new(
|
|
"more-files",
|
|
format!("+{}", self.files.len() - (OVERFLOW_LIMIT + 1)),
|
|
ContextMenu::build(window, cx, move |mut menu, _, _| {
|
|
for (mut ix, (file, focus_handle)) in self
|
|
.files
|
|
.iter()
|
|
.enumerate()
|
|
.skip(OVERFLOW_LIMIT + 1)
|
|
{
|
|
let (display_name, focus_handle) =
|
|
if selected_file_ix == ix {
|
|
ix = OVERFLOW_LIMIT;
|
|
(
|
|
self.display_name(&self.files[ix].0),
|
|
self.files[ix].1.clone(),
|
|
)
|
|
} else {
|
|
(
|
|
self.display_name(&file),
|
|
focus_handle.clone(),
|
|
)
|
|
};
|
|
|
|
menu = menu.entry(
|
|
display_name
|
|
.expect("Files should always have a name"),
|
|
None,
|
|
{
|
|
let this = this.clone();
|
|
move |window, cx| {
|
|
this.update(cx, |this, cx| {
|
|
this.change_file(ix, window, cx);
|
|
});
|
|
focus_handle.focus(window, cx);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
menu
|
|
}),
|
|
)
|
|
.style(DropdownStyle::Subtle)
|
|
.trigger_tooltip(Tooltip::text("View Other Projects"))
|
|
.trigger_icon(IconName::ChevronDown)
|
|
.attach(gpui::Anchor::BottomLeft)
|
|
.offset(gpui::Point {
|
|
x: px(0.0),
|
|
y: px(2.0),
|
|
})
|
|
.tab_index(0),
|
|
)
|
|
})
|
|
}),
|
|
)
|
|
.child(
|
|
Button::new(edit_in_json_id, "Edit in settings.json")
|
|
.tab_index(0_isize)
|
|
.style(ButtonStyle::OutlinedGhost)
|
|
.tooltip(Tooltip::for_action_title_in(
|
|
"Edit in settings.json",
|
|
&OpenCurrentFile,
|
|
&self.focus_handle,
|
|
))
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.open_current_settings_file(window, cx);
|
|
})),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn display_name(&self, file: &SettingsUiFile) -> Option<String> {
|
|
match file {
|
|
SettingsUiFile::User => Some("User".to_string()),
|
|
SettingsUiFile::Project((worktree_id, path)) => self
|
|
.worktree_root_dirs
|
|
.get(&worktree_id)
|
|
.map(|directory_name| {
|
|
let path_style = PathStyle::local();
|
|
if path.is_empty() {
|
|
directory_name.clone()
|
|
} else {
|
|
format!(
|
|
"{}{}{}",
|
|
directory_name,
|
|
path_style.primary_separator(),
|
|
path.display(path_style)
|
|
)
|
|
}
|
|
}),
|
|
SettingsUiFile::Server(file) => Some(file.to_string()),
|
|
}
|
|
}
|
|
|
|
// TODO:
|
|
// Reconsider this after preview launch
|
|
// fn file_location_str(&self) -> String {
|
|
// match &self.current_file {
|
|
// SettingsUiFile::User => "settings.json".to_string(),
|
|
// SettingsUiFile::Project((worktree_id, path)) => self
|
|
// .worktree_root_dirs
|
|
// .get(&worktree_id)
|
|
// .map(|directory_name| {
|
|
// let path_style = PathStyle::local();
|
|
// let file_path = path.join(paths::local_settings_file_relative_path());
|
|
// format!(
|
|
// "{}{}{}",
|
|
// directory_name,
|
|
// path_style.separator(),
|
|
// file_path.display(path_style)
|
|
// )
|
|
// })
|
|
// .expect("Current file should always be present in root dir map"),
|
|
// SettingsUiFile::Server(file) => file.to_string(),
|
|
// }
|
|
// }
|
|
|
|
fn render_search(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let (a11y_value, a11y_text_runs) =
|
|
text_field_a11y_state("settings-ui-search", &self.search_bar, window, cx);
|
|
|
|
h_flex()
|
|
.id("settings-ui-search")
|
|
.role(Role::SearchInput)
|
|
.aria_label("Search Settings")
|
|
.aria_value(a11y_value)
|
|
.track_focus(&self.search_bar.focus_handle(cx))
|
|
.a11y_synthetic_children(a11y_text_runs)
|
|
.py_1()
|
|
.px_1p5()
|
|
.mb_3()
|
|
.gap_1p5()
|
|
.rounded_sm()
|
|
.bg(cx.theme().colors().editor_background)
|
|
.border_1()
|
|
.border_color(cx.theme().colors().border)
|
|
.child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
|
|
.child(self.search_bar.clone())
|
|
}
|
|
|
|
fn render_nav(
|
|
&self,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) -> impl IntoElement {
|
|
let visible_count = self.visible_navbar_entries().count();
|
|
|
|
let focus_keybind_label = if self
|
|
.navbar_focus_handle
|
|
.read(cx)
|
|
.handle
|
|
.contains_focused(window, cx)
|
|
|| self
|
|
.visible_navbar_entries()
|
|
.any(|(_, entry)| entry.focus_handle.is_focused(window))
|
|
{
|
|
"Focus Content"
|
|
} else {
|
|
"Focus Navbar"
|
|
};
|
|
|
|
let mut key_context = KeyContext::new_with_defaults();
|
|
key_context.add("NavigationMenu");
|
|
key_context.add("menu");
|
|
if self.search_bar.focus_handle(cx).is_focused(window) {
|
|
key_context.add("search");
|
|
}
|
|
|
|
v_flex()
|
|
.key_context(key_context)
|
|
.on_action(cx.listener(|this, _: &CollapseNavEntry, window, cx| {
|
|
let Some(focused_entry) = this.focused_nav_entry(window, cx) else {
|
|
return;
|
|
};
|
|
let focused_entry_parent = this.root_entry_containing(focused_entry);
|
|
if this.navbar_entries[focused_entry_parent].expanded {
|
|
this.toggle_navbar_entry(focused_entry_parent);
|
|
window.focus(&this.navbar_entries[focused_entry_parent].focus_handle, cx);
|
|
}
|
|
cx.notify();
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ExpandNavEntry, window, cx| {
|
|
let Some(focused_entry) = this.focused_nav_entry(window, cx) else {
|
|
return;
|
|
};
|
|
if !this.navbar_entries[focused_entry].is_root {
|
|
return;
|
|
}
|
|
if !this.navbar_entries[focused_entry].expanded {
|
|
this.toggle_navbar_entry(focused_entry);
|
|
}
|
|
cx.notify();
|
|
}))
|
|
.on_action(
|
|
cx.listener(|this, _: &FocusPreviousRootNavEntry, window, cx| {
|
|
let entry_index = this
|
|
.focused_nav_entry(window, cx)
|
|
.unwrap_or(this.navbar_entry);
|
|
let mut root_index = None;
|
|
for (index, entry) in this.visible_navbar_entries() {
|
|
if index >= entry_index {
|
|
break;
|
|
}
|
|
if entry.is_root {
|
|
root_index = Some(index);
|
|
}
|
|
}
|
|
let Some(previous_root_index) = root_index else {
|
|
return;
|
|
};
|
|
this.focus_and_scroll_to_nav_entry(previous_root_index, window, cx);
|
|
}),
|
|
)
|
|
.on_action(cx.listener(|this, _: &FocusNextRootNavEntry, window, cx| {
|
|
let entry_index = this
|
|
.focused_nav_entry(window, cx)
|
|
.unwrap_or(this.navbar_entry);
|
|
let mut root_index = None;
|
|
for (index, entry) in this.visible_navbar_entries() {
|
|
if index <= entry_index {
|
|
continue;
|
|
}
|
|
if entry.is_root {
|
|
root_index = Some(index);
|
|
break;
|
|
}
|
|
}
|
|
let Some(next_root_index) = root_index else {
|
|
return;
|
|
};
|
|
this.focus_and_scroll_to_nav_entry(next_root_index, window, cx);
|
|
}))
|
|
.on_action(cx.listener(|this, _: &FocusFirstNavEntry, window, cx| {
|
|
if let Some((first_entry_index, _)) = this.visible_navbar_entries().next() {
|
|
this.focus_and_scroll_to_nav_entry(first_entry_index, window, cx);
|
|
}
|
|
}))
|
|
.on_action(cx.listener(|this, _: &FocusLastNavEntry, window, cx| {
|
|
if let Some((last_entry_index, _)) = this.visible_navbar_entries().last() {
|
|
this.focus_and_scroll_to_nav_entry(last_entry_index, window, cx);
|
|
}
|
|
}))
|
|
.on_action(cx.listener(|this, _: &FocusNextNavEntry, window, cx| {
|
|
let entry_index = this
|
|
.focused_nav_entry(window, cx)
|
|
.unwrap_or(this.navbar_entry);
|
|
let mut next_index = None;
|
|
for (index, _) in this.visible_navbar_entries() {
|
|
if index > entry_index {
|
|
next_index = Some(index);
|
|
break;
|
|
}
|
|
}
|
|
let Some(next_entry_index) = next_index else {
|
|
return;
|
|
};
|
|
this.open_and_scroll_to_navbar_entry(
|
|
next_entry_index,
|
|
Some(gpui::ScrollStrategy::Bottom),
|
|
false,
|
|
window,
|
|
cx,
|
|
);
|
|
}))
|
|
.on_action(cx.listener(|this, _: &FocusPreviousNavEntry, window, cx| {
|
|
let entry_index = this
|
|
.focused_nav_entry(window, cx)
|
|
.unwrap_or(this.navbar_entry);
|
|
let mut prev_index = None;
|
|
for (index, _) in this.visible_navbar_entries() {
|
|
if index >= entry_index {
|
|
break;
|
|
}
|
|
prev_index = Some(index);
|
|
}
|
|
let Some(prev_entry_index) = prev_index else {
|
|
return;
|
|
};
|
|
this.open_and_scroll_to_navbar_entry(
|
|
prev_entry_index,
|
|
Some(gpui::ScrollStrategy::Top),
|
|
false,
|
|
window,
|
|
cx,
|
|
);
|
|
}))
|
|
.w(SIDEBAR_WIDTH)
|
|
.h_full()
|
|
.p_2p5()
|
|
.when(cfg!(target_os = "macos"), |this| this.pt_10())
|
|
.flex_none()
|
|
.border_r_1()
|
|
.border_color(cx.theme().colors().border)
|
|
.bg(cx.theme().colors().panel_background)
|
|
.child(self.render_search(window, cx))
|
|
.child(
|
|
v_flex()
|
|
.id("settings-ui-nav")
|
|
.role(Role::Tree)
|
|
.aria_label("Settings Navigation")
|
|
.flex_1()
|
|
.overflow_hidden()
|
|
.track_focus(&self.navbar_focus_handle.focus_handle(cx))
|
|
.tab_group()
|
|
.tab_index(NAVBAR_GROUP_TAB_INDEX)
|
|
.child(
|
|
uniform_list(
|
|
"settings-ui-nav-bar",
|
|
visible_count + 1,
|
|
cx.processor(move |this, range: Range<usize>, _, cx| {
|
|
this.visible_navbar_entries()
|
|
.skip(range.start.saturating_sub(1))
|
|
.take(range.len())
|
|
.map(|(entry_index, entry)| {
|
|
TreeViewItem::new(
|
|
("settings-ui-navbar-entry", entry_index),
|
|
entry.title,
|
|
)
|
|
.track_focus(&entry.focus_handle)
|
|
.root_item(entry.is_root)
|
|
.toggle_state(this.is_navbar_entry_selected(entry_index))
|
|
.when(entry.is_root, |item| {
|
|
item.expanded(entry.expanded || this.has_query)
|
|
.on_toggle(cx.listener(
|
|
move |this, _, window, cx| {
|
|
this.toggle_and_focus_navbar_entry(
|
|
entry_index,
|
|
window,
|
|
cx,
|
|
);
|
|
},
|
|
))
|
|
})
|
|
.on_click({
|
|
let category = this.pages[entry.page_index].title;
|
|
let subcategory =
|
|
(!entry.is_root).then_some(entry.title);
|
|
|
|
cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
|
|
if this.toggle_navbar_entry_on_double_click(
|
|
entry_index,
|
|
event,
|
|
window,
|
|
cx,
|
|
)
|
|
{
|
|
return;
|
|
}
|
|
|
|
telemetry::event!(
|
|
"Settings Navigation Clicked",
|
|
category = category,
|
|
subcategory = subcategory
|
|
);
|
|
|
|
this.open_and_scroll_to_navbar_entry(
|
|
entry_index,
|
|
None,
|
|
true,
|
|
window,
|
|
cx,
|
|
);
|
|
})
|
|
})
|
|
})
|
|
.collect()
|
|
}),
|
|
)
|
|
.size_full()
|
|
.track_scroll(&self.navbar_scroll_handle),
|
|
)
|
|
.vertical_scrollbar_for(&self.navbar_scroll_handle, window, cx),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.w_full()
|
|
.h_8()
|
|
.p_2()
|
|
.pb_0p5()
|
|
.flex_shrink_0()
|
|
.border_t_1()
|
|
.border_color(cx.theme().colors().border_variant)
|
|
.child(
|
|
KeybindingHint::new(
|
|
KeyBinding::for_action_in(
|
|
&ToggleFocusNav,
|
|
&self.navbar_focus_handle.focus_handle(cx),
|
|
cx,
|
|
),
|
|
cx.theme().colors().surface_background.opacity(0.5),
|
|
)
|
|
.suffix(focus_keybind_label),
|
|
),
|
|
)
|
|
}
|
|
|
|
fn open_and_scroll_to_navbar_entry(
|
|
&mut self,
|
|
navbar_entry_index: usize,
|
|
scroll_strategy: Option<gpui::ScrollStrategy>,
|
|
focus_content: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.open_navbar_entry_page(navbar_entry_index);
|
|
cx.notify();
|
|
|
|
let mut handle_to_focus = None;
|
|
|
|
if self.navbar_entries[navbar_entry_index].is_root
|
|
|| !self.is_nav_entry_visible(navbar_entry_index)
|
|
{
|
|
if let Some(scroll_handle) = self.current_sub_page_scroll_handle() {
|
|
scroll_handle.set_offset(point(px(0.), px(0.)));
|
|
}
|
|
|
|
if focus_content {
|
|
let Some(first_item_index) =
|
|
self.visible_page_items().next().map(|(index, _)| index)
|
|
else {
|
|
return;
|
|
};
|
|
handle_to_focus = Some(self.focus_handle_for_content_element(first_item_index, cx));
|
|
} else if !self.is_nav_entry_visible(navbar_entry_index) {
|
|
let Some(first_visible_nav_entry_index) =
|
|
self.visible_navbar_entries().next().map(|(index, _)| index)
|
|
else {
|
|
return;
|
|
};
|
|
self.focus_and_scroll_to_nav_entry(first_visible_nav_entry_index, window, cx);
|
|
} else {
|
|
handle_to_focus =
|
|
Some(self.navbar_entries[navbar_entry_index].focus_handle.clone());
|
|
}
|
|
} else {
|
|
let entry_item_index = self.navbar_entries[navbar_entry_index]
|
|
.item_index
|
|
.expect("Non-root items should have an item index");
|
|
self.scroll_to_content_item(entry_item_index, window, cx);
|
|
if focus_content {
|
|
handle_to_focus = Some(self.focus_handle_for_content_element(entry_item_index, cx));
|
|
} else {
|
|
handle_to_focus =
|
|
Some(self.navbar_entries[navbar_entry_index].focus_handle.clone());
|
|
}
|
|
}
|
|
|
|
if let Some(scroll_strategy) = scroll_strategy
|
|
&& let Some(logical_entry_index) = self
|
|
.visible_navbar_entries()
|
|
.into_iter()
|
|
.position(|(index, _)| index == navbar_entry_index)
|
|
{
|
|
self.navbar_scroll_handle
|
|
.scroll_to_item(logical_entry_index + 1, scroll_strategy);
|
|
}
|
|
|
|
// Page scroll handle updates the active item index
|
|
// in it's next paint call after using scroll_handle.scroll_to_top_of_item
|
|
// The call after that updates the offset of the scroll handle. So to
|
|
// ensure the scroll handle doesn't lag behind we need to render three frames
|
|
// back to back.
|
|
cx.on_next_frame(window, move |_, window, cx| {
|
|
if let Some(handle) = handle_to_focus.as_ref() {
|
|
window.focus(handle, cx);
|
|
}
|
|
|
|
cx.on_next_frame(window, |_, _, cx| {
|
|
cx.notify();
|
|
});
|
|
cx.notify();
|
|
});
|
|
cx.notify();
|
|
}
|
|
|
|
fn scroll_to_content_item(
|
|
&self,
|
|
content_item_index: usize,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let index = self
|
|
.visible_page_items()
|
|
.position(|(index, _)| index == content_item_index)
|
|
.unwrap_or(0);
|
|
if index == 0 {
|
|
if let Some(scroll_handle) = self.current_sub_page_scroll_handle() {
|
|
scroll_handle.set_offset(point(px(0.), px(0.)));
|
|
}
|
|
|
|
self.list_state.scroll_to(gpui::ListOffset {
|
|
item_ix: 0,
|
|
offset_in_item: px(0.),
|
|
});
|
|
return;
|
|
}
|
|
self.list_state.scroll_to(gpui::ListOffset {
|
|
item_ix: index + 1,
|
|
offset_in_item: px(0.),
|
|
});
|
|
cx.notify();
|
|
}
|
|
|
|
fn is_nav_entry_visible(&self, nav_entry_index: usize) -> bool {
|
|
self.visible_navbar_entries()
|
|
.any(|(index, _)| index == nav_entry_index)
|
|
}
|
|
|
|
fn focus_and_scroll_to_first_visible_nav_entry(
|
|
&self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(nav_entry_index) = self.visible_navbar_entries().next().map(|(index, _)| index)
|
|
{
|
|
self.focus_and_scroll_to_nav_entry(nav_entry_index, window, cx);
|
|
}
|
|
}
|
|
|
|
fn focus_and_scroll_to_nav_entry(
|
|
&self,
|
|
nav_entry_index: usize,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(position) = self
|
|
.visible_navbar_entries()
|
|
.position(|(index, _)| index == nav_entry_index)
|
|
else {
|
|
return;
|
|
};
|
|
self.navbar_scroll_handle
|
|
.scroll_to_item(position, gpui::ScrollStrategy::Top);
|
|
window.focus(&self.navbar_entries[nav_entry_index].focus_handle, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn current_sub_page_scroll_handle(&self) -> Option<&ScrollHandle> {
|
|
self.sub_page_stack.last().map(|page| &page.scroll_handle)
|
|
}
|
|
|
|
fn visible_page_items(&self) -> impl Iterator<Item = (usize, &SettingsPageItem)> {
|
|
let page_idx = self.current_page_index();
|
|
|
|
self.current_page()
|
|
.items
|
|
.iter()
|
|
.enumerate()
|
|
.filter(move |&(item_index, _)| self.filter_table[page_idx][item_index])
|
|
}
|
|
|
|
fn render_sub_page_breadcrumbs(
|
|
&self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> impl IntoElement {
|
|
let scope_name: SharedString = self
|
|
.display_name(&self.current_file)
|
|
.unwrap_or_else(|| self.current_file.setting_type().to_string())
|
|
.into();
|
|
|
|
// Only offer scopes in which every sub-page in the stack is available.
|
|
let allowed_mask = self
|
|
.sub_page_stack
|
|
.iter()
|
|
.fold(USER | PROJECT | SERVER, |mask, sub_page| {
|
|
mask & sub_page.link.files
|
|
});
|
|
let allowed_file_indices: Vec<usize> = self
|
|
.files
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, (file, _))| allowed_mask.contains(file.mask()))
|
|
.map(|(ix, _)| ix)
|
|
.collect();
|
|
|
|
let scope_element = if allowed_file_indices.len() > 1 {
|
|
let this = cx.entity();
|
|
DropdownMenu::new(
|
|
"sub-page-scope-picker",
|
|
scope_name,
|
|
ContextMenu::build(window, cx, move |mut menu, _, _| {
|
|
menu = menu.header("Scope");
|
|
|
|
for ix in allowed_file_indices {
|
|
let (file, focus_handle) = &self.files[ix];
|
|
let display_name = self
|
|
.display_name(file)
|
|
.expect("Files should always have a name");
|
|
|
|
menu = menu.toggleable_entry(
|
|
display_name,
|
|
file == &self.current_file,
|
|
IconPosition::End,
|
|
None,
|
|
{
|
|
let this = this.clone();
|
|
let focus_handle = focus_handle.clone();
|
|
move |window, cx| {
|
|
this.update(cx, |this, cx| {
|
|
this.change_file_in_sub_page(ix, window, cx);
|
|
});
|
|
focus_handle.focus(window, cx);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
menu
|
|
}),
|
|
)
|
|
.style(DropdownStyle::Subtle)
|
|
.trigger_tooltip(Tooltip::text("Change Scope"))
|
|
.attach(gpui::Anchor::BottomLeft)
|
|
.offset(gpui::Point {
|
|
x: px(0.0),
|
|
y: px(2.0),
|
|
})
|
|
.tab_index(0)
|
|
.into_any_element()
|
|
} else {
|
|
Label::new(scope_name)
|
|
.color(Color::Muted)
|
|
.into_any_element()
|
|
};
|
|
|
|
h_flex()
|
|
.min_w_0()
|
|
.gap_1()
|
|
.overflow_x_hidden()
|
|
.child(scope_element)
|
|
.child(Label::new("/").color(Color::Muted))
|
|
.children(
|
|
itertools::intersperse(
|
|
std::iter::once(self.current_page().title.into()).chain(
|
|
self.sub_page_stack
|
|
.iter()
|
|
.enumerate()
|
|
.flat_map(|(index, page)| {
|
|
(index == 0)
|
|
.then(|| page.section_header.clone())
|
|
.into_iter()
|
|
.chain(std::iter::once(page.link.title.clone()))
|
|
}),
|
|
),
|
|
"/".into(),
|
|
)
|
|
.map(|item| Label::new(item).color(Color::Muted)),
|
|
)
|
|
}
|
|
|
|
fn render_no_results(&self, cx: &App) -> impl IntoElement {
|
|
let search_query = self.search_bar.read(cx).text(cx);
|
|
|
|
v_flex()
|
|
.size_full()
|
|
.items_center()
|
|
.justify_center()
|
|
.gap_1()
|
|
.child(Label::new("No Results"))
|
|
.child(
|
|
Label::new(format!("No settings match \"{}\"", search_query))
|
|
.size(LabelSize::Small)
|
|
.color(Color::Muted),
|
|
)
|
|
}
|
|
|
|
fn render_current_page_items(
|
|
&mut self,
|
|
_window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) -> impl IntoElement {
|
|
let current_page_index = self.current_page_index();
|
|
let mut page_content = v_flex()
|
|
.id("settings-ui-page")
|
|
.role(Role::Group)
|
|
.aria_label("Settings Content")
|
|
.size_full();
|
|
|
|
let has_active_search = !self.search_bar.read(cx).is_empty(cx);
|
|
let has_no_results = self.visible_page_items().next().is_none() && has_active_search;
|
|
|
|
if has_no_results {
|
|
page_content = page_content.child(self.render_no_results(cx))
|
|
} else {
|
|
let last_non_header_index = self
|
|
.visible_page_items()
|
|
.filter_map(|(index, item)| {
|
|
(!matches!(item, SettingsPageItem::SectionHeader(_))).then_some(index)
|
|
})
|
|
.last();
|
|
|
|
let root_nav_label = self
|
|
.navbar_entries
|
|
.iter()
|
|
.find(|entry| entry.is_root && entry.page_index == self.current_page_index())
|
|
.map(|entry| entry.title);
|
|
|
|
let list_content = list(
|
|
self.list_state.clone(),
|
|
cx.processor(move |this, index, window, cx| {
|
|
if index == 0 {
|
|
return div()
|
|
.px_8()
|
|
.when(this.sub_page_stack.is_empty(), |this| {
|
|
this.when_some(root_nav_label, |this, title| {
|
|
this.child(
|
|
Label::new(title).size(LabelSize::Large).mt_2().mb_3(),
|
|
)
|
|
})
|
|
})
|
|
.into_any_element();
|
|
}
|
|
|
|
let mut visible_items = this.visible_page_items();
|
|
let Some((actual_item_index, item)) = visible_items.nth(index - 1) else {
|
|
return gpui::Empty.into_any_element();
|
|
};
|
|
|
|
let next_is_header = visible_items
|
|
.next()
|
|
.map(|(_, item)| matches!(item, SettingsPageItem::SectionHeader(_)))
|
|
.unwrap_or(false);
|
|
|
|
let is_last = Some(actual_item_index) == last_non_header_index;
|
|
let is_last_in_section = next_is_header || is_last;
|
|
|
|
let bottom_border = !is_last_in_section;
|
|
let extra_bottom_padding = is_last_in_section;
|
|
|
|
let item_focus_handle = this.content_handles[current_page_index]
|
|
[actual_item_index]
|
|
.focus_handle(cx);
|
|
|
|
v_flex()
|
|
.id(("settings-page-item", actual_item_index))
|
|
.track_focus(&item_focus_handle)
|
|
.w_full()
|
|
.min_w_0()
|
|
.child(item.render(
|
|
this,
|
|
actual_item_index,
|
|
bottom_border,
|
|
extra_bottom_padding,
|
|
window,
|
|
cx,
|
|
))
|
|
.into_any_element()
|
|
}),
|
|
);
|
|
|
|
page_content = page_content.child(list_content.size_full())
|
|
}
|
|
page_content
|
|
}
|
|
|
|
fn render_sub_page_items<'a, Items>(
|
|
&self,
|
|
items: Items,
|
|
scroll_handle: &ScrollHandle,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) -> impl IntoElement
|
|
where
|
|
Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
|
|
{
|
|
let page_content = v_flex()
|
|
.id("settings-ui-page")
|
|
.size_full()
|
|
.overflow_y_scroll()
|
|
.track_scroll(scroll_handle);
|
|
self.render_sub_page_items_in(page_content, items, false, window, cx)
|
|
}
|
|
|
|
fn render_sub_page_items_section<'a, Items>(
|
|
&self,
|
|
items: Items,
|
|
is_inline_section: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) -> impl IntoElement
|
|
where
|
|
Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
|
|
{
|
|
let page_content = v_flex().id("settings-ui-sub-page-section").size_full();
|
|
self.render_sub_page_items_in(page_content, items, is_inline_section, window, cx)
|
|
}
|
|
|
|
fn render_sub_page_items_in<'a, Items>(
|
|
&self,
|
|
page_content: Stateful<Div>,
|
|
items: Items,
|
|
is_inline_section: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) -> impl IntoElement
|
|
where
|
|
Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
|
|
{
|
|
let items: Vec<_> = items.collect();
|
|
let items_len = items.len();
|
|
|
|
let has_active_search = !self.search_bar.read(cx).is_empty(cx);
|
|
let has_no_results = items_len == 0 && has_active_search;
|
|
|
|
if has_no_results {
|
|
page_content.child(self.render_no_results(cx))
|
|
} else {
|
|
let last_non_header_index = items
|
|
.iter()
|
|
.enumerate()
|
|
.rev()
|
|
.find(|(_, (_, item))| !matches!(item, SettingsPageItem::SectionHeader(_)))
|
|
.map(|(index, _)| index);
|
|
|
|
let root_nav_label = self
|
|
.navbar_entries
|
|
.iter()
|
|
.find(|entry| entry.is_root && entry.page_index == self.current_page_index())
|
|
.map(|entry| entry.title);
|
|
|
|
page_content
|
|
.when(self.sub_page_stack.is_empty(), |this| {
|
|
this.when_some(root_nav_label, |this, title| {
|
|
this.child(Label::new(title).size(LabelSize::Large).mt_2().mb_3())
|
|
})
|
|
})
|
|
.children(items.clone().into_iter().enumerate().map(
|
|
|(index, (actual_item_index, item))| {
|
|
let is_last_item = Some(index) == last_non_header_index;
|
|
let next_is_header = items.get(index + 1).is_some_and(|(_, next_item)| {
|
|
matches!(next_item, SettingsPageItem::SectionHeader(_))
|
|
});
|
|
let bottom_border = !is_inline_section && !next_is_header && !is_last_item;
|
|
|
|
let extra_bottom_padding =
|
|
!is_inline_section && (next_is_header || is_last_item);
|
|
|
|
v_flex()
|
|
.w_full()
|
|
.min_w_0()
|
|
.id(("settings-page-item", actual_item_index))
|
|
.child(item.render(
|
|
self,
|
|
actual_item_index,
|
|
bottom_border,
|
|
extra_bottom_padding,
|
|
window,
|
|
cx,
|
|
))
|
|
},
|
|
))
|
|
}
|
|
}
|
|
|
|
fn render_page(
|
|
&mut self,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) -> impl IntoElement {
|
|
let page_header;
|
|
let page_content;
|
|
|
|
if let Some(current_sub_page) = self.sub_page_stack.last() {
|
|
let is_skills_page =
|
|
current_sub_page.link.json_path == Some(AGENT_SKILLS_SETTINGS_PATH);
|
|
let is_llm_providers_page = current_sub_page.link.json_path == Some("llm_providers")
|
|
&& current_sub_page.link.title.as_ref() == "LLM Providers";
|
|
let is_external_agents_page = current_sub_page.link.json_path == Some("agent_servers");
|
|
let is_mcp_servers_page = current_sub_page.link.json_path == Some("context_servers");
|
|
|
|
page_header = h_flex()
|
|
.w_full()
|
|
.min_w_0()
|
|
.justify_between()
|
|
.child(
|
|
h_flex()
|
|
.min_w_0()
|
|
.ml_neg_1p5()
|
|
.gap_1()
|
|
.child(
|
|
IconButton::new("back-btn", IconName::ArrowLeft)
|
|
.icon_size(IconSize::Small)
|
|
.shape(IconButtonShape::Square)
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.pop_sub_page(window, cx);
|
|
})),
|
|
)
|
|
.child(self.render_sub_page_breadcrumbs(window, cx)),
|
|
)
|
|
.child(
|
|
div()
|
|
.flex_shrink_0()
|
|
.when(current_sub_page.link.in_json, |this| {
|
|
this.child(
|
|
Button::new("open-in-settings-file", "Edit in settings.json")
|
|
.tab_index(0_isize)
|
|
.style(ButtonStyle::OutlinedGhost)
|
|
.tooltip(Tooltip::for_action_title_in(
|
|
"Edit in settings.json",
|
|
&OpenCurrentFile,
|
|
&self.focus_handle,
|
|
))
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.open_current_settings_file(window, cx);
|
|
})),
|
|
)
|
|
})
|
|
.when(is_llm_providers_page, |this| {
|
|
this.child(pages::render_add_llm_provider_popover(self, window, cx))
|
|
})
|
|
.when(is_skills_page, |this| {
|
|
this.child(
|
|
Button::new("open-skill-creator", "Create Skill")
|
|
.tab_index(0_isize)
|
|
.style(ButtonStyle::OutlinedGhost)
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.open_skill_creator_sub_page(
|
|
pages::SkillCreatorOpenMode::Form,
|
|
window,
|
|
cx,
|
|
);
|
|
})),
|
|
)
|
|
})
|
|
.when(is_external_agents_page, |this| {
|
|
this.child(pages::render_add_agent_popover(self, window, cx))
|
|
})
|
|
.when(is_mcp_servers_page, |this| {
|
|
this.child(pages::render_add_server_popover(self, window, cx))
|
|
}),
|
|
)
|
|
.into_any_element();
|
|
|
|
let active_page_render_fn = ¤t_sub_page.link.render;
|
|
page_content =
|
|
(active_page_render_fn)(self, ¤t_sub_page.scroll_handle, window, cx);
|
|
} else {
|
|
page_header = self.render_files_header(window, cx).into_any_element();
|
|
|
|
page_content = self
|
|
.render_current_page_items(window, cx)
|
|
.into_any_element();
|
|
}
|
|
|
|
let current_sub_page = self.sub_page_stack.last();
|
|
|
|
let mut warning_banner = gpui::Empty.into_any_element();
|
|
if let Some(error) =
|
|
SettingsStore::global(cx).error_for_file(self.current_file.to_settings())
|
|
{
|
|
fn banner(
|
|
label: &'static str,
|
|
error: String,
|
|
shown_errors: &mut HashSet<String>,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) -> impl IntoElement {
|
|
if shown_errors.insert(error.clone()) {
|
|
telemetry::event!("Settings Error Shown", label = label, error = &error);
|
|
}
|
|
Banner::new()
|
|
.severity(Severity::Warning)
|
|
.child(
|
|
v_flex()
|
|
.my_0p5()
|
|
.gap_0p5()
|
|
.child(Label::new(label))
|
|
.child(Label::new(error).size(LabelSize::Small).color(Color::Muted)),
|
|
)
|
|
.action_slot(
|
|
div().pr_1().pb_1().child(
|
|
Button::new("fix-in-json", "Fix in settings.json")
|
|
.tab_index(0_isize)
|
|
.style(ButtonStyle::Tinted(ui::TintColor::Warning))
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.open_current_settings_file(window, cx);
|
|
})),
|
|
),
|
|
)
|
|
}
|
|
|
|
let parse_error = error.parse_error();
|
|
let parse_failed = parse_error.is_some();
|
|
|
|
warning_banner = v_flex()
|
|
.gap_2()
|
|
.when_some(parse_error, |this, err| {
|
|
this.child(banner(
|
|
"Failed to load your settings. Some values may be incorrect and changes may be lost.",
|
|
err,
|
|
&mut self.shown_errors,
|
|
cx,
|
|
))
|
|
})
|
|
.map(|this| match &error.migration_status {
|
|
settings::MigrationStatus::Succeeded => this.child(banner(
|
|
"Your settings are out of date, and need to be updated.",
|
|
match &self.current_file {
|
|
SettingsUiFile::User => "They can be automatically migrated to the latest version.",
|
|
SettingsUiFile::Server(_) | SettingsUiFile::Project(_) => "They must be manually migrated to the latest version."
|
|
}.to_string(),
|
|
&mut self.shown_errors,
|
|
cx,
|
|
)),
|
|
settings::MigrationStatus::Failed { error: err } if !parse_failed => this
|
|
.child(banner(
|
|
"Your settings file is out of date, automatic migration failed",
|
|
err.clone(),
|
|
&mut self.shown_errors,
|
|
cx,
|
|
)),
|
|
_ => this,
|
|
})
|
|
.into_any_element()
|
|
}
|
|
|
|
let mut restricted_banner = gpui::Empty.into_any_element();
|
|
if let SettingsUiFile::Project((worktree_id, _)) = &self.current_file {
|
|
let worktree_id = *worktree_id;
|
|
let is_restricted = all_projects(self.original_window.as_ref(), cx)
|
|
.find(|project| project.read(cx).worktree_for_id(worktree_id, cx).is_some())
|
|
.map(|project| {
|
|
let worktree_store = project.read(cx).worktree_store();
|
|
project::trusted_worktrees::TrustedWorktrees::has_restricted_worktrees(
|
|
&worktree_store,
|
|
cx,
|
|
)
|
|
})
|
|
.unwrap_or(false);
|
|
|
|
if is_restricted {
|
|
let original_window = self.original_window;
|
|
restricted_banner = Banner::new()
|
|
.severity(Severity::Warning)
|
|
.child(
|
|
v_flex()
|
|
.my_0p5()
|
|
.gap_0p5()
|
|
.child(Label::new("Restricted Mode"))
|
|
.child(
|
|
Label::new(
|
|
"This project is in restricted mode. Some project settings may not apply.",
|
|
)
|
|
.size(LabelSize::Small)
|
|
.color(Color::Muted),
|
|
),
|
|
)
|
|
.action_slot(
|
|
div().pr_2().pb_1().child(
|
|
Button::new("manage-trust", "Manage Trust")
|
|
.style(ButtonStyle::Tinted(ui::TintColor::Warning))
|
|
.on_click(cx.listener(move |_this, _, window, cx| {
|
|
if let Some(original_window) = original_window {
|
|
original_window
|
|
.update(cx, |multi_workspace, window, cx| {
|
|
multi_workspace
|
|
.workspace()
|
|
.update(cx, |workspace, cx| {
|
|
workspace
|
|
.show_worktree_trust_security_modal(
|
|
true, window, cx,
|
|
);
|
|
});
|
|
})
|
|
.log_err();
|
|
}
|
|
// Close the settings window
|
|
window.remove_window();
|
|
})),
|
|
),
|
|
)
|
|
.into_any_element();
|
|
}
|
|
}
|
|
|
|
v_flex()
|
|
.id("settings-ui-page")
|
|
.on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| {
|
|
if !this.sub_page_stack.is_empty() {
|
|
// Keep Tab navigation within the sub-page content. Global
|
|
// `focus_next` would otherwise wrap past the last control to
|
|
// the navbar; instead, when focus leaves the content region we
|
|
// wrap back to the first content tab stop.
|
|
let content_handle = this.content_focus_handle.focus_handle(cx);
|
|
window.focus_next(cx);
|
|
if !content_handle.contains_focused(window, cx) {
|
|
content_handle.focus(window, cx);
|
|
window.focus_next(cx);
|
|
}
|
|
return;
|
|
}
|
|
for (logical_index, (actual_index, _)) in this.visible_page_items().enumerate() {
|
|
let handle = this.content_handles[this.current_page_index()][actual_index]
|
|
.focus_handle(cx);
|
|
let mut offset = 1; // for page header
|
|
|
|
if let Some((_, next_item)) = this.visible_page_items().nth(logical_index + 1)
|
|
&& matches!(next_item, SettingsPageItem::SectionHeader(_))
|
|
{
|
|
offset += 1;
|
|
}
|
|
if handle.contains_focused(window, cx) {
|
|
let next_logical_index = logical_index + offset + 1;
|
|
this.list_state.scroll_to_reveal_item(next_logical_index);
|
|
// We need to render the next item to ensure it's focus handle is in the element tree
|
|
cx.on_next_frame(window, |_, window, cx| {
|
|
cx.notify();
|
|
cx.on_next_frame(window, |_, window, cx| {
|
|
window.focus_next(cx);
|
|
cx.notify();
|
|
});
|
|
});
|
|
cx.notify();
|
|
return;
|
|
}
|
|
}
|
|
window.focus_next(cx);
|
|
}))
|
|
.on_action(cx.listener(|this, _: &menu::SelectPrevious, window, cx| {
|
|
if !this.sub_page_stack.is_empty() {
|
|
window.focus_prev(cx);
|
|
return;
|
|
}
|
|
let mut prev_was_header = false;
|
|
for (logical_index, (actual_index, item)) in this.visible_page_items().enumerate() {
|
|
let is_header = matches!(item, SettingsPageItem::SectionHeader(_));
|
|
let handle = this.content_handles[this.current_page_index()][actual_index]
|
|
.focus_handle(cx);
|
|
let mut offset = 1; // for page header
|
|
|
|
if prev_was_header {
|
|
offset -= 1;
|
|
}
|
|
if handle.contains_focused(window, cx) {
|
|
let next_logical_index = logical_index + offset - 1;
|
|
this.list_state.scroll_to_reveal_item(next_logical_index);
|
|
// We need to render the next item to ensure it's focus handle is in the element tree
|
|
cx.on_next_frame(window, |_, window, cx| {
|
|
cx.notify();
|
|
cx.on_next_frame(window, |_, window, cx| {
|
|
window.focus_prev(cx);
|
|
cx.notify();
|
|
});
|
|
});
|
|
cx.notify();
|
|
return;
|
|
}
|
|
prev_was_header = is_header;
|
|
}
|
|
window.focus_prev(cx);
|
|
}))
|
|
.when(current_sub_page.is_none(), |this| {
|
|
this.vertical_scrollbar_for(&self.list_state, window, cx)
|
|
})
|
|
.when_some(current_sub_page, |this, current_sub_page| {
|
|
this.custom_scrollbars(
|
|
Scrollbars::new(ui::ScrollAxes::Vertical)
|
|
.tracked_scroll_handle(¤t_sub_page.scroll_handle)
|
|
.id((current_sub_page.link.title.clone(), 42)),
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
.track_focus(&self.content_focus_handle.focus_handle(cx))
|
|
.pt_6()
|
|
.gap_4()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.bg(cx.theme().colors().editor_background)
|
|
.child(
|
|
v_flex()
|
|
.px_8()
|
|
.gap_2()
|
|
.child(page_header)
|
|
.child(warning_banner)
|
|
.child(restricted_banner),
|
|
)
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.min_h_0()
|
|
.size_full()
|
|
.tab_group()
|
|
.tab_index(CONTENT_GROUP_TAB_INDEX)
|
|
.child(page_content),
|
|
)
|
|
}
|
|
|
|
/// This function will create a new settings file if one doesn't exist
|
|
/// if the current file is a project settings with a valid worktree id
|
|
/// We do this because the settings ui allows initializing project settings
|
|
pub(crate) fn open_current_settings_file(
|
|
&mut self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
match &self.current_file {
|
|
SettingsUiFile::User => {
|
|
let Some(original_window) = self.original_window else {
|
|
return;
|
|
};
|
|
original_window
|
|
.update(cx, |multi_workspace, window, cx| {
|
|
multi_workspace
|
|
.workspace()
|
|
.clone()
|
|
.update(cx, |workspace, cx| {
|
|
workspace
|
|
.with_local_or_wsl_workspace(
|
|
window,
|
|
cx,
|
|
open_user_settings_in_workspace,
|
|
)
|
|
.detach();
|
|
});
|
|
})
|
|
.ok();
|
|
|
|
window.remove_window();
|
|
}
|
|
SettingsUiFile::Project((worktree_id, path)) => {
|
|
let settings_path = path.join(paths::local_settings_file_relative_path());
|
|
let app_state = workspace::AppState::global(cx);
|
|
|
|
let Some((workspace_window, worktree, corresponding_workspace)) = app_state
|
|
.workspace_store
|
|
.read(cx)
|
|
.workspaces_with_windows()
|
|
.filter_map(|(window_handle, weak)| {
|
|
let workspace = weak.upgrade()?;
|
|
let window = window_handle.downcast::<MultiWorkspace>()?;
|
|
Some((window, workspace))
|
|
})
|
|
.find_map(|(window, workspace): (_, Entity<Workspace>)| {
|
|
workspace
|
|
.read(cx)
|
|
.project()
|
|
.read(cx)
|
|
.worktree_for_id(*worktree_id, cx)
|
|
.map(|worktree| (window, worktree, workspace))
|
|
})
|
|
else {
|
|
log::error!(
|
|
"No corresponding workspace contains worktree id: {}",
|
|
worktree_id
|
|
);
|
|
|
|
return;
|
|
};
|
|
|
|
let create_task = if worktree.read(cx).entry_for_path(&settings_path).is_some() {
|
|
None
|
|
} else {
|
|
Some(worktree.update(cx, |tree, cx| {
|
|
tree.create_entry(
|
|
settings_path.clone().into(),
|
|
false,
|
|
Some(initial_project_settings_content().as_bytes().to_vec()),
|
|
cx,
|
|
)
|
|
}))
|
|
};
|
|
|
|
let worktree_id = *worktree_id;
|
|
|
|
// TODO: move zed::open_local_file() APIs to this crate, and
|
|
// re-implement the "initial_contents" behavior
|
|
let workspace_weak = corresponding_workspace.downgrade();
|
|
workspace_window
|
|
.update(cx, |_, window, cx| {
|
|
cx.spawn_in(window, async move |_, cx| {
|
|
if let Some(create_task) = create_task {
|
|
create_task.await.ok()?;
|
|
};
|
|
|
|
workspace_weak
|
|
.update_in(cx, |workspace, window, cx| {
|
|
workspace.open_path(
|
|
(worktree_id, settings_path.clone()),
|
|
None,
|
|
true,
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
.ok()?
|
|
.await
|
|
.log_err()?;
|
|
|
|
workspace_weak
|
|
.update_in(cx, |_, window, cx| {
|
|
window.activate_window();
|
|
cx.notify();
|
|
})
|
|
.ok();
|
|
|
|
Some(())
|
|
})
|
|
.detach();
|
|
})
|
|
.ok();
|
|
|
|
window.remove_window();
|
|
}
|
|
SettingsUiFile::Server(_) => {
|
|
// Server files are not editable
|
|
return;
|
|
}
|
|
};
|
|
}
|
|
|
|
fn current_page_index(&self) -> usize {
|
|
if self.navbar_entries.is_empty() {
|
|
return 0;
|
|
}
|
|
|
|
self.navbar_entries[self.navbar_entry].page_index
|
|
}
|
|
|
|
fn current_page(&self) -> &SettingsPage {
|
|
&self.pages[self.current_page_index()]
|
|
}
|
|
|
|
fn is_navbar_entry_selected(&self, ix: usize) -> bool {
|
|
ix == self.navbar_entry
|
|
}
|
|
|
|
fn push_sub_page(
|
|
&mut self,
|
|
sub_page_link: SubPageLink,
|
|
section_header: SharedString,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) {
|
|
self.sandbox_host_validation_error = None;
|
|
self.sub_page_stack
|
|
.push(SubPage::new(sub_page_link, section_header));
|
|
self.content_focus_handle.focus_handle(cx).focus(window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
/// Push a dynamically-created sub-page with a custom render function.
|
|
/// This is useful for nested sub-pages that aren't defined in the main pages list.
|
|
pub fn push_dynamic_sub_page(
|
|
&mut self,
|
|
title: impl Into<SharedString>,
|
|
section_header: impl Into<SharedString>,
|
|
json_path: Option<&'static str>,
|
|
in_json: bool,
|
|
render: fn(
|
|
&SettingsWindow,
|
|
&ScrollHandle,
|
|
&mut Window,
|
|
&mut Context<SettingsWindow>,
|
|
) -> AnyElement,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) {
|
|
self.regex_validation_error = None;
|
|
let sub_page_link = SubPageLink {
|
|
title: title.into(),
|
|
r#type: SubPageType::default(),
|
|
description: None,
|
|
search_aliases: &[],
|
|
json_path,
|
|
in_json,
|
|
files: USER,
|
|
render,
|
|
};
|
|
self.push_sub_page(sub_page_link, section_header.into(), window, cx);
|
|
}
|
|
|
|
pub(crate) fn skill_creator_page(&self) -> Option<Entity<pages::SkillCreatorPage>> {
|
|
self.skill_creator_page
|
|
.as_ref()
|
|
.map(|(page, _)| page.clone())
|
|
}
|
|
|
|
/// If the creator is already the active sub-page, the open mode is applied
|
|
/// to the existing form instead
|
|
pub fn open_skill_creator_sub_page(
|
|
&mut self,
|
|
open_mode: pages::SkillCreatorOpenMode,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) {
|
|
let creator_is_active_sub_page = self
|
|
.sub_page_stack
|
|
.last()
|
|
.is_some_and(|sub_page| sub_page.link.r#type == SubPageType::SkillCreator);
|
|
|
|
if creator_is_active_sub_page && let Some((page, _)) = &self.skill_creator_page {
|
|
let page = page.clone();
|
|
page.update(cx, |page, cx| page.apply_open_mode(open_mode, window, cx));
|
|
return;
|
|
}
|
|
|
|
let settings_window = cx.weak_entity();
|
|
let page = cx.new(|cx| pages::SkillCreatorPage::new(settings_window, window, cx));
|
|
|
|
let subscription =
|
|
cx.subscribe_in(
|
|
&page,
|
|
window,
|
|
|this, _page, event: &pages::SkillCreatorEvent, window, cx| match event {
|
|
pages::SkillCreatorEvent::Dismissed | pages::SkillCreatorEvent::Saved => {
|
|
if this.sub_page_stack.last().is_some_and(|sub_page| {
|
|
sub_page.link.r#type == SubPageType::SkillCreator
|
|
}) {
|
|
this.pop_sub_page(window, cx);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
self.skill_creator_page = Some((page.clone(), subscription));
|
|
|
|
let sub_page_link = SubPageLink {
|
|
title: "Create Skill".into(),
|
|
r#type: SubPageType::SkillCreator,
|
|
description: None,
|
|
search_aliases: &[],
|
|
json_path: None,
|
|
in_json: false,
|
|
files: USER | PROJECT,
|
|
render: pages::render_skill_creator_page,
|
|
};
|
|
|
|
self.push_sub_page(sub_page_link, "Agent".into(), window, cx);
|
|
|
|
let creating_from_url = !matches!(open_mode, pages::SkillCreatorOpenMode::Url { .. });
|
|
page.update(cx, |page, cx| {
|
|
page.apply_open_mode(open_mode, window, cx);
|
|
});
|
|
if creating_from_url {
|
|
let name_editor_focus_handle = page.read(cx).name_editor_focus_handle(cx);
|
|
window.focus(&name_editor_focus_handle, cx);
|
|
}
|
|
}
|
|
|
|
pub fn navigate_to_skill_creator(
|
|
&mut self,
|
|
open_mode: pages::SkillCreatorOpenMode,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) {
|
|
self.sub_page_stack.clear();
|
|
let skills_page_index = self.pages.iter().position(|page| {
|
|
page.items.iter().any(|item| {
|
|
matches!(
|
|
item,
|
|
SettingsPageItem::SubPageLink(link)
|
|
if link.json_path == Some(AGENT_SKILLS_SETTINGS_PATH)
|
|
)
|
|
})
|
|
});
|
|
if let Some(page_index) = skills_page_index
|
|
&& let Some(navbar_entry_index) = self
|
|
.navbar_entries
|
|
.iter()
|
|
.position(|entry| entry.page_index == page_index && entry.is_root)
|
|
{
|
|
self.open_navbar_entry_page(navbar_entry_index);
|
|
}
|
|
self.navigate_to_sub_page(AGENT_SKILLS_SETTINGS_PATH, window, cx);
|
|
self.open_skill_creator_sub_page(open_mode, window, cx);
|
|
}
|
|
|
|
/// Navigate to a sub-page by its json_path.
|
|
/// Returns true if the sub-page was found and pushed, false otherwise.
|
|
pub fn navigate_to_sub_page(
|
|
&mut self,
|
|
json_path: &str,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) -> bool {
|
|
for page in &self.pages {
|
|
for (item_index, item) in page.items.iter().enumerate() {
|
|
if let SettingsPageItem::SubPageLink(sub_page_link) = item {
|
|
if sub_page_link.json_path == Some(json_path) {
|
|
let section_header = page
|
|
.items
|
|
.iter()
|
|
.take(item_index)
|
|
.rev()
|
|
.find_map(|item| item.header_text().map(SharedString::new_static))
|
|
.unwrap_or_else(|| "Settings".into());
|
|
|
|
self.push_sub_page(sub_page_link.clone(), section_header, window, cx);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Navigate to a setting by its json_path.
|
|
/// Clears the sub-page stack and scrolls to the setting item.
|
|
/// Returns true if the setting was found, false otherwise.
|
|
pub fn navigate_to_setting(
|
|
&mut self,
|
|
json_path: &str,
|
|
window: &mut Window,
|
|
cx: &mut Context<SettingsWindow>,
|
|
) -> bool {
|
|
self.sub_page_stack.clear();
|
|
|
|
for (page_index, page) in self.pages.iter().enumerate() {
|
|
for (item_index, item) in page.items.iter().enumerate() {
|
|
let item_json_path = match item {
|
|
SettingsPageItem::SettingItem(setting_item) => setting_item.field.json_path(),
|
|
SettingsPageItem::DynamicItem(dynamic_item) => {
|
|
dynamic_item.discriminant.field.json_path()
|
|
}
|
|
_ => None,
|
|
};
|
|
if item_json_path == Some(json_path) {
|
|
if let Some(navbar_entry_index) = self
|
|
.navbar_entries
|
|
.iter()
|
|
.position(|e| e.page_index == page_index && e.is_root)
|
|
{
|
|
self.open_and_scroll_to_navbar_entry(
|
|
navbar_entry_index,
|
|
None,
|
|
false,
|
|
window,
|
|
cx,
|
|
);
|
|
self.scroll_to_content_item(item_index, window, cx);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
pub(crate) fn pop_sub_page(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
|
|
self.regex_validation_error = None;
|
|
self.sandbox_host_validation_error = None;
|
|
if let Some(popped) = self.sub_page_stack.pop()
|
|
&& popped.link.r#type == SubPageType::SkillCreator
|
|
{
|
|
self.skill_creator_page = None;
|
|
}
|
|
self.content_focus_handle.focus_handle(cx).focus(window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn focus_file_at_index(&mut self, index: usize, window: &mut Window, cx: &mut App) {
|
|
if let Some((_, handle)) = self.files.get(index) {
|
|
handle.focus(window, cx);
|
|
}
|
|
}
|
|
|
|
fn focused_file_index(&self, window: &Window, cx: &Context<Self>) -> usize {
|
|
if self.files_focus_handle.contains_focused(window, cx)
|
|
&& let Some(index) = self
|
|
.files
|
|
.iter()
|
|
.position(|(_, handle)| handle.is_focused(window))
|
|
{
|
|
return index;
|
|
}
|
|
if let Some(current_file_index) = self
|
|
.files
|
|
.iter()
|
|
.position(|(file, _)| file == &self.current_file)
|
|
{
|
|
return current_file_index;
|
|
}
|
|
0
|
|
}
|
|
|
|
fn focus_handle_for_content_element(
|
|
&self,
|
|
actual_item_index: usize,
|
|
cx: &Context<Self>,
|
|
) -> FocusHandle {
|
|
let page_index = self.current_page_index();
|
|
self.content_handles[page_index][actual_item_index].focus_handle(cx)
|
|
}
|
|
|
|
fn focused_nav_entry(&self, window: &Window, cx: &App) -> Option<usize> {
|
|
if !self
|
|
.navbar_focus_handle
|
|
.focus_handle(cx)
|
|
.contains_focused(window, cx)
|
|
{
|
|
return None;
|
|
}
|
|
for (index, entry) in self.navbar_entries.iter().enumerate() {
|
|
if entry.focus_handle.is_focused(window) {
|
|
return Some(index);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn root_entry_containing(&self, nav_entry_index: usize) -> usize {
|
|
let mut index = Some(nav_entry_index);
|
|
while let Some(prev_index) = index
|
|
&& !self.navbar_entries[prev_index].is_root
|
|
{
|
|
index = prev_index.checked_sub(1);
|
|
}
|
|
return index.expect("No root entry found");
|
|
}
|
|
}
|
|
|
|
impl Render for SettingsWindow {
|
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let ui_font = theme_settings::setup_ui_font(window, cx);
|
|
|
|
client_side_decorations(
|
|
v_flex()
|
|
.text_color(cx.theme().colors().text)
|
|
.size_full()
|
|
.children(self.title_bar.clone())
|
|
.child(
|
|
div()
|
|
.id("settings-window")
|
|
.key_context("SettingsWindow")
|
|
.track_focus(&self.focus_handle)
|
|
.on_action(cx.listener(|this, _: &OpenCurrentFile, window, cx| {
|
|
this.open_current_settings_file(window, cx);
|
|
}))
|
|
.on_action(|_: &Minimize, window, _cx| {
|
|
window.minimize_window();
|
|
})
|
|
.on_action(cx.listener(|this, _: &search::FocusSearch, window, cx| {
|
|
this.search_bar.focus_handle(cx).focus(window, cx);
|
|
}))
|
|
.on_action(cx.listener(|this, _: &ToggleFocusNav, window, cx| {
|
|
if this
|
|
.navbar_focus_handle
|
|
.focus_handle(cx)
|
|
.contains_focused(window, cx)
|
|
{
|
|
this.open_and_scroll_to_navbar_entry(
|
|
this.navbar_entry,
|
|
None,
|
|
true,
|
|
window,
|
|
cx,
|
|
);
|
|
} else {
|
|
this.focus_and_scroll_to_nav_entry(this.navbar_entry, window, cx);
|
|
}
|
|
}))
|
|
.on_action(cx.listener(
|
|
|this, FocusFile(file_index): &FocusFile, window, cx| {
|
|
this.focus_file_at_index(*file_index as usize, window, cx);
|
|
},
|
|
))
|
|
.on_action(cx.listener(|this, _: &FocusNextFile, window, cx| {
|
|
let next_index = usize::min(
|
|
this.focused_file_index(window, cx) + 1,
|
|
this.files.len().saturating_sub(1),
|
|
);
|
|
this.focus_file_at_index(next_index, window, cx);
|
|
}))
|
|
.on_action(cx.listener(|this, _: &FocusPreviousFile, window, cx| {
|
|
let prev_index = this.focused_file_index(window, cx).saturating_sub(1);
|
|
this.focus_file_at_index(prev_index, window, cx);
|
|
}))
|
|
.on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| {
|
|
if this
|
|
.search_bar
|
|
.focus_handle(cx)
|
|
.contains_focused(window, cx)
|
|
{
|
|
this.focus_and_scroll_to_first_visible_nav_entry(window, cx);
|
|
} else {
|
|
window.focus_next(cx);
|
|
}
|
|
}))
|
|
.on_action(|_: &menu::SelectPrevious, window, cx| {
|
|
window.focus_prev(cx);
|
|
})
|
|
.flex()
|
|
.flex_row()
|
|
.flex_1()
|
|
.min_h_0()
|
|
.font(ui_font)
|
|
.bg(cx.theme().colors().background)
|
|
.text_color(cx.theme().colors().text)
|
|
.when(!cfg!(target_os = "macos"), |this| {
|
|
this.border_t_1().border_color(cx.theme().colors().border)
|
|
})
|
|
.child(self.render_nav(window, cx))
|
|
.child(self.render_page(window, cx)),
|
|
),
|
|
window,
|
|
cx,
|
|
)
|
|
}
|
|
}
|
|
|
|
pub(crate) fn all_projects(
|
|
window: Option<&WindowHandle<MultiWorkspace>>,
|
|
cx: &App,
|
|
) -> impl Iterator<Item = Entity<Project>> {
|
|
let mut seen_project_ids = std::collections::HashSet::new();
|
|
let app_state = workspace::AppState::global(cx);
|
|
app_state
|
|
.workspace_store
|
|
.read(cx)
|
|
.workspaces()
|
|
.filter_map(|weak| weak.upgrade())
|
|
.map(|workspace: Entity<Workspace>| workspace.read(cx).project().clone())
|
|
.chain(
|
|
window
|
|
.and_then(|handle| handle.read(cx).ok())
|
|
.into_iter()
|
|
.flat_map(|multi_workspace| {
|
|
multi_workspace
|
|
.workspaces()
|
|
.map(|workspace| workspace.read(cx).project().clone())
|
|
.collect::<Vec<_>>()
|
|
}),
|
|
)
|
|
.filter(move |project| seen_project_ids.insert(project.entity_id()))
|
|
}
|
|
|
|
fn open_user_settings_in_workspace(
|
|
workspace: &mut Workspace,
|
|
window: &mut Window,
|
|
cx: &mut Context<Workspace>,
|
|
) {
|
|
let project = workspace.project().clone();
|
|
|
|
cx.spawn_in(window, async move |workspace, cx| {
|
|
let (config_dir, settings_file) = project.update(cx, |project, cx| {
|
|
(
|
|
project.try_windows_path_to_wsl(paths::config_dir().as_path(), cx),
|
|
project.try_windows_path_to_wsl(paths::settings_file().as_path(), cx),
|
|
)
|
|
});
|
|
let config_dir = config_dir.await?;
|
|
let settings_file = settings_file.await?;
|
|
project
|
|
.update(cx, |project, cx| {
|
|
project.find_or_create_worktree(&config_dir, false, cx)
|
|
})
|
|
.await
|
|
.ok();
|
|
workspace
|
|
.update_in(cx, |workspace, window, cx| {
|
|
workspace.open_paths(
|
|
vec![settings_file],
|
|
OpenOptions {
|
|
visible: Some(OpenVisible::None),
|
|
..Default::default()
|
|
},
|
|
None,
|
|
window,
|
|
cx,
|
|
)
|
|
})?
|
|
.await;
|
|
|
|
workspace.update_in(cx, |_, window, cx| {
|
|
window.activate_window();
|
|
cx.notify();
|
|
})
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn update_settings_file(
|
|
file: SettingsUiFile,
|
|
file_name: Option<&'static str>,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
update: impl 'static + Send + FnOnce(&mut SettingsContent, &App),
|
|
) -> Result<()> {
|
|
telemetry::event!("Settings Change", setting = file_name, type = file.setting_type());
|
|
|
|
match file {
|
|
SettingsUiFile::Project((worktree_id, rel_path)) => {
|
|
let rel_path = rel_path.join(paths::local_settings_file_relative_path());
|
|
let Some(settings_window) = window.root::<SettingsWindow>().flatten() else {
|
|
anyhow::bail!("No settings window found");
|
|
};
|
|
|
|
update_project_setting_file(worktree_id, rel_path.into(), update, settings_window, cx)
|
|
}
|
|
SettingsUiFile::User => {
|
|
// todo(settings_ui) error?
|
|
SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), update);
|
|
Ok(())
|
|
}
|
|
SettingsUiFile::Server(_) => unimplemented!(),
|
|
}
|
|
}
|
|
|
|
struct ProjectSettingsUpdateEntry {
|
|
worktree_id: WorktreeId,
|
|
rel_path: Arc<RelPath>,
|
|
settings_window: WeakEntity<SettingsWindow>,
|
|
project: WeakEntity<Project>,
|
|
worktree: WeakEntity<Worktree>,
|
|
update: Box<dyn FnOnce(&mut SettingsContent, &App)>,
|
|
}
|
|
|
|
struct ProjectSettingsUpdateQueue {
|
|
tx: mpsc::UnboundedSender<ProjectSettingsUpdateEntry>,
|
|
_task: Task<()>,
|
|
}
|
|
|
|
impl Global for ProjectSettingsUpdateQueue {}
|
|
|
|
impl ProjectSettingsUpdateQueue {
|
|
fn new(cx: &mut App) -> Self {
|
|
let (tx, mut rx) = mpsc::unbounded();
|
|
let task = cx.spawn(async move |mut cx| {
|
|
while let Some(entry) = rx.next().await {
|
|
if let Err(err) = Self::process_entry(entry, &mut cx).await {
|
|
log::error!("Failed to update project settings: {err:?}");
|
|
}
|
|
}
|
|
});
|
|
Self { tx, _task: task }
|
|
}
|
|
|
|
fn enqueue(cx: &mut App, entry: ProjectSettingsUpdateEntry) {
|
|
cx.update_global::<Self, _>(|queue, _cx| {
|
|
if let Err(err) = queue.tx.unbounded_send(entry) {
|
|
log::error!("Failed to enqueue project settings update: {err}");
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn process_entry(entry: ProjectSettingsUpdateEntry, cx: &mut AsyncApp) -> Result<()> {
|
|
let ProjectSettingsUpdateEntry {
|
|
worktree_id,
|
|
rel_path,
|
|
settings_window,
|
|
project,
|
|
worktree,
|
|
update,
|
|
} = entry;
|
|
|
|
let project_path = ProjectPath {
|
|
worktree_id,
|
|
path: rel_path.clone(),
|
|
};
|
|
|
|
let needs_creation = worktree.read_with(cx, |worktree, _| {
|
|
worktree.entry_for_path(&rel_path).is_none()
|
|
})?;
|
|
|
|
if needs_creation {
|
|
worktree
|
|
.update(cx, |worktree, cx| {
|
|
worktree.create_entry(rel_path.clone(), false, None, cx)
|
|
})?
|
|
.await?;
|
|
}
|
|
|
|
let buffer_store = project.read_with(cx, |project, _cx| project.buffer_store().clone())?;
|
|
|
|
let cached_buffer = settings_window
|
|
.read_with(cx, |settings_window, _| {
|
|
settings_window
|
|
.project_setting_file_buffers
|
|
.get(&project_path)
|
|
.cloned()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let buffer = if let Some(cached_buffer) = cached_buffer {
|
|
let needs_reload = cached_buffer.read_with(cx, |buffer, _| buffer.has_conflict());
|
|
if needs_reload {
|
|
cached_buffer
|
|
.update(cx, |buffer, cx| buffer.reload(cx))
|
|
.await
|
|
.context("Failed to reload settings file")?;
|
|
}
|
|
cached_buffer
|
|
} else {
|
|
let buffer = buffer_store
|
|
.update(cx, |store, cx| store.open_buffer(project_path.clone(), cx))
|
|
.await
|
|
.context("Failed to open settings file")?;
|
|
|
|
let _ = settings_window.update(cx, |this, _cx| {
|
|
this.project_setting_file_buffers
|
|
.insert(project_path, buffer.clone());
|
|
});
|
|
|
|
buffer
|
|
};
|
|
|
|
buffer.update(cx, |buffer, cx| {
|
|
let current_text = buffer.text();
|
|
if let Some(new_text) = cx
|
|
.global::<SettingsStore>()
|
|
.new_text_for_update(current_text, |settings| update(settings, cx))
|
|
.log_err()
|
|
{
|
|
buffer.edit([(0..buffer.len(), new_text)], None, cx);
|
|
}
|
|
});
|
|
|
|
buffer_store
|
|
.update(cx, |store, cx| store.save_buffer(buffer, cx))
|
|
.await
|
|
.context("Failed to save settings file")?;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn update_project_setting_file(
|
|
worktree_id: WorktreeId,
|
|
rel_path: Arc<RelPath>,
|
|
update: impl 'static + FnOnce(&mut SettingsContent, &App),
|
|
settings_window: Entity<SettingsWindow>,
|
|
cx: &mut App,
|
|
) -> Result<()> {
|
|
let Some((worktree, project)) =
|
|
all_projects(settings_window.read(cx).original_window.as_ref(), cx).find_map(|project| {
|
|
project
|
|
.read(cx)
|
|
.worktree_for_id(worktree_id, cx)
|
|
.zip(Some(project))
|
|
})
|
|
else {
|
|
anyhow::bail!("Could not find project with worktree id: {}", worktree_id);
|
|
};
|
|
|
|
let entry = ProjectSettingsUpdateEntry {
|
|
worktree_id,
|
|
rel_path,
|
|
settings_window: settings_window.downgrade(),
|
|
project: project.downgrade(),
|
|
worktree: worktree.downgrade(),
|
|
update: Box::new(update),
|
|
};
|
|
|
|
ProjectSettingsUpdateQueue::enqueue(cx, entry);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Derives a human-readable label for assistive technology from a setting's
|
|
/// JSON path, e.g. `"buffer_font_size"` becomes `"Buffer Font Size"`.
|
|
struct CurrentSettingsValue<'a, T> {
|
|
value: &'a T,
|
|
disabled: bool,
|
|
}
|
|
|
|
fn get_current_value<'a, T>(
|
|
settings_store: &'a SettingsStore,
|
|
file: &SettingsUiFile,
|
|
field: &'a SettingField<T>,
|
|
cx: &'a App,
|
|
) -> Option<CurrentSettingsValue<'a, T>> {
|
|
let user_store = AppState::global(cx).user_store.read(cx);
|
|
let org_config = user_store.current_organization_configuration();
|
|
|
|
let (_file, value) = settings_store.get_value_from_file(file.to_settings(), field.pick);
|
|
let value = value?;
|
|
|
|
let org_value = org_config
|
|
.zip(field.organization_override)
|
|
.and_then(|(org_config, org_override)| (org_override)(org_config));
|
|
|
|
Some(CurrentSettingsValue {
|
|
disabled: org_value.is_some(),
|
|
value: org_value.unwrap_or(&value),
|
|
})
|
|
}
|
|
|
|
fn render_text_field<T: From<String> + Into<String> + AsRef<str> + Clone>(
|
|
field: SettingField<T>,
|
|
file: SettingsUiFile,
|
|
metadata: Option<&SettingsFieldMetadata>,
|
|
title: &'static str,
|
|
description: &'static str,
|
|
_window: &mut Window,
|
|
cx: &mut App,
|
|
) -> AnyElement {
|
|
let (_, initial_text) =
|
|
SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
|
|
let initial_text = if metadata.is_some_and(|metadata| metadata.treat_missing_text_as_empty) {
|
|
Some(
|
|
initial_text
|
|
.map(|text| text.as_ref().to_string())
|
|
.unwrap_or_default(),
|
|
)
|
|
} else {
|
|
initial_text
|
|
.filter(|text| !text.as_ref().is_empty())
|
|
.map(|text| text.as_ref().to_string())
|
|
};
|
|
|
|
// The JSON path uniquely identifies the setting this field edits, making
|
|
// it a stable, collision-free element ID within the page.
|
|
SettingsInputField::new(field.json_path.unwrap_or("settings-text-field"))
|
|
.tab_index(0)
|
|
.aria_label(title)
|
|
.when(!description.is_empty(), |editor| {
|
|
editor.aria_description(description)
|
|
})
|
|
.when_some(initial_text, |editor, text| editor.with_initial_text(text))
|
|
.when_some(
|
|
metadata.and_then(|metadata| metadata.placeholder),
|
|
|editor, placeholder| editor.with_placeholder(placeholder),
|
|
)
|
|
.when(
|
|
metadata.is_some_and(|metadata| metadata.display_confirm_button),
|
|
|editor| editor.display_confirm_button(),
|
|
)
|
|
.when(
|
|
metadata.is_some_and(|metadata| metadata.display_clear_button),
|
|
|editor| editor.display_clear_button(),
|
|
)
|
|
.when(
|
|
metadata.is_some_and(|metadata| metadata.confirm_on_focus_out),
|
|
|editor| editor.confirm_on_focus_out(),
|
|
)
|
|
.on_confirm({
|
|
move |new_text, window, cx| {
|
|
update_settings_file(
|
|
file.clone(),
|
|
field.json_path,
|
|
window,
|
|
cx,
|
|
move |settings, app| {
|
|
(field.write)(settings, new_text.map(Into::into), app);
|
|
},
|
|
)
|
|
.log_err(); // todo(settings_ui) don't log err
|
|
}
|
|
})
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
|
|
field: SettingField<B>,
|
|
file: SettingsUiFile,
|
|
_metadata: Option<&SettingsFieldMetadata>,
|
|
title: &'static str,
|
|
description: &'static str,
|
|
_window: &mut Window,
|
|
cx: &mut App,
|
|
) -> AnyElement {
|
|
let value = get_current_value(&SettingsStore::global(cx), &file, &field, cx);
|
|
let (value, disabled) = value
|
|
.map(|current_value| (*current_value.value, current_value.disabled))
|
|
.unwrap_or((false.into(), false));
|
|
|
|
let toggle_state = if value.into() {
|
|
ToggleState::Selected
|
|
} else {
|
|
ToggleState::Unselected
|
|
};
|
|
|
|
Switch::new("toggle_button", toggle_state)
|
|
.tab_index(0_isize)
|
|
.aria_label(title)
|
|
.when(!description.is_empty(), |this| {
|
|
this.aria_description(description)
|
|
})
|
|
.disabled(disabled)
|
|
.on_click({
|
|
move |state, window, cx| {
|
|
telemetry::event!("Settings Change", setting = field.json_path, type = file.setting_type());
|
|
|
|
let state = *state == ui::ToggleState::Selected;
|
|
update_settings_file(file.clone(), field.json_path, window, cx, move |settings, app| {
|
|
(field.write)(settings, Some(state.into()), app);
|
|
})
|
|
.log_err(); // todo(settings_ui) don't log err
|
|
}
|
|
})
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_editable_number_field<T: NumberFieldType + Send + Sync>(
|
|
field: SettingField<T>,
|
|
file: SettingsUiFile,
|
|
_metadata: Option<&SettingsFieldMetadata>,
|
|
title: &'static str,
|
|
description: &'static str,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> AnyElement {
|
|
let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
|
|
let value = value.copied().unwrap_or_else(T::min_value);
|
|
|
|
let id = field
|
|
.json_path
|
|
.map(|p| format!("numeric_stepper_{}", p))
|
|
.unwrap_or_else(|| "numeric_stepper".to_string());
|
|
|
|
NumberField::new(id, value, window, cx)
|
|
.mode(NumberFieldMode::Edit, cx)
|
|
.tab_index(0_isize)
|
|
.aria_label(title)
|
|
.when(!description.is_empty(), |this| {
|
|
this.aria_description(description)
|
|
})
|
|
.on_change({
|
|
move |value, window, cx| {
|
|
let value = *value;
|
|
update_settings_file(
|
|
file.clone(),
|
|
field.json_path,
|
|
window,
|
|
cx,
|
|
move |settings, app| {
|
|
(field.write)(settings, Some(value), app);
|
|
},
|
|
)
|
|
.log_err(); // todo(settings_ui) don't log err
|
|
}
|
|
})
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_dropdown<T>(
|
|
field: SettingField<T>,
|
|
file: SettingsUiFile,
|
|
metadata: Option<&SettingsFieldMetadata>,
|
|
title: &'static str,
|
|
description: &'static str,
|
|
_window: &mut Window,
|
|
cx: &mut App,
|
|
) -> AnyElement
|
|
where
|
|
T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + Sync + 'static,
|
|
{
|
|
let variants = || -> &'static [T] { <T as strum::VariantArray>::VARIANTS };
|
|
let labels = || -> &'static [&'static str] { <T as strum::VariantNames>::VARIANTS };
|
|
let should_do_titlecase = metadata
|
|
.and_then(|metadata| metadata.should_do_titlecase)
|
|
.unwrap_or(true);
|
|
|
|
let current_value = get_current_value(&SettingsStore::global(cx), &file, &field, cx);
|
|
let (current_value, disabled) = current_value
|
|
.map(|current_value| (*current_value.value, current_value.disabled))
|
|
.unwrap_or((variants()[0], false));
|
|
|
|
EnumVariantDropdown::new("dropdown", current_value, variants(), labels(), {
|
|
move |value, window, cx| {
|
|
if value == current_value {
|
|
return;
|
|
}
|
|
update_settings_file(
|
|
file.clone(),
|
|
field.json_path,
|
|
window,
|
|
cx,
|
|
move |settings, app| {
|
|
(field.write)(settings, Some(value), app);
|
|
},
|
|
)
|
|
.log_err(); // todo(settings_ui) don't log err
|
|
}
|
|
})
|
|
.aria_label(title)
|
|
.when(!description.is_empty(), |this| {
|
|
this.aria_description(description)
|
|
})
|
|
.disabled(disabled)
|
|
.tab_index(0)
|
|
.title_case(should_do_titlecase)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_picker_trigger_button(id: SharedString, label: SharedString) -> Button {
|
|
Button::new(id, label)
|
|
.aria_role(Role::ComboBox)
|
|
.tab_index(0_isize)
|
|
.style(ButtonStyle::Outlined)
|
|
.size(ButtonSize::Medium)
|
|
.end_icon(
|
|
Icon::new(IconName::ChevronUpDown)
|
|
.size(IconSize::Small)
|
|
.color(Color::Muted),
|
|
)
|
|
}
|
|
|
|
/// Wires the Expand/Collapse accessibility actions on a picker trigger button to
|
|
/// the popover handle, so assistive technology can open and close the picker
|
|
/// (used by UIA on Windows and AX on macOS; Linux/AT-SPI uses the click action).
|
|
fn wire_picker_trigger_a11y<M: gpui::ManagedView>(
|
|
button: Button,
|
|
handle: ui::PopoverMenuHandle<M>,
|
|
) -> Button {
|
|
let show_handle = handle.clone();
|
|
let hide_handle = handle;
|
|
button
|
|
.on_a11y_action(gpui::accesskit::Action::Expand, move |_, window, cx| {
|
|
show_handle.show(window, cx);
|
|
})
|
|
.on_a11y_action(gpui::accesskit::Action::Collapse, move |_, _window, cx| {
|
|
hide_handle.hide(cx);
|
|
})
|
|
}
|
|
|
|
fn render_font_picker(
|
|
field: SettingField<settings::FontFamilyName>,
|
|
file: SettingsUiFile,
|
|
_metadata: Option<&SettingsFieldMetadata>,
|
|
title: &'static str,
|
|
description: &'static str,
|
|
_window: &mut Window,
|
|
cx: &mut App,
|
|
) -> AnyElement {
|
|
let current_value = SettingsStore::global(cx)
|
|
.get_value_from_file(file.to_settings(), field.pick)
|
|
.1
|
|
.cloned()
|
|
.map_or_else(|| SharedString::default(), |value| value.into_gpui());
|
|
|
|
let handle = ui::PopoverMenuHandle::default();
|
|
PopoverMenu::new("font-picker")
|
|
.trigger(wire_picker_trigger_a11y(
|
|
render_picker_trigger_button(
|
|
"font_family_picker_trigger".into(),
|
|
current_value.clone(),
|
|
)
|
|
.aria_label(title)
|
|
.when(!description.is_empty(), |this| {
|
|
this.aria_description(description)
|
|
}),
|
|
handle.clone(),
|
|
))
|
|
.menu(move |window, cx| {
|
|
let file = file.clone();
|
|
let current_value = current_value.clone();
|
|
|
|
Some(cx.new(move |cx| {
|
|
font_picker(
|
|
current_value,
|
|
move |font_name, window, cx| {
|
|
update_settings_file(
|
|
file.clone(),
|
|
field.json_path,
|
|
window,
|
|
cx,
|
|
move |settings, app| {
|
|
(field.write)(settings, Some(font_name.to_string().into()), app);
|
|
},
|
|
)
|
|
.log_err(); // todo(settings_ui) don't log err
|
|
},
|
|
window,
|
|
cx,
|
|
)
|
|
}))
|
|
})
|
|
.anchor(gpui::Anchor::TopLeft)
|
|
.offset(gpui::Point {
|
|
x: px(0.0),
|
|
y: px(2.0),
|
|
})
|
|
.with_handle(handle)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_theme_picker(
|
|
field: SettingField<settings::ThemeName>,
|
|
file: SettingsUiFile,
|
|
_metadata: Option<&SettingsFieldMetadata>,
|
|
title: &'static str,
|
|
description: &'static str,
|
|
_window: &mut Window,
|
|
cx: &mut App,
|
|
) -> AnyElement {
|
|
let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
|
|
let current_value = value
|
|
.cloned()
|
|
.map(|theme_name| theme_name.0.into())
|
|
.unwrap_or_else(|| cx.theme().name.clone());
|
|
|
|
let handle = ui::PopoverMenuHandle::default();
|
|
PopoverMenu::new("theme-picker")
|
|
.trigger(wire_picker_trigger_a11y(
|
|
render_picker_trigger_button("theme_picker_trigger".into(), current_value.clone())
|
|
.aria_label(title)
|
|
.when(!description.is_empty(), |this| {
|
|
this.aria_description(description)
|
|
}),
|
|
handle.clone(),
|
|
))
|
|
.menu(move |window, cx| {
|
|
Some(cx.new(|cx| {
|
|
let file = file.clone();
|
|
let current_value = current_value.clone();
|
|
theme_picker(
|
|
current_value,
|
|
move |theme_name, window, cx| {
|
|
update_settings_file(
|
|
file.clone(),
|
|
field.json_path,
|
|
window,
|
|
cx,
|
|
move |settings, app| {
|
|
(field.write)(
|
|
settings,
|
|
Some(settings::ThemeName(theme_name.into())),
|
|
app,
|
|
);
|
|
},
|
|
)
|
|
.log_err(); // todo(settings_ui) don't log err
|
|
},
|
|
window,
|
|
cx,
|
|
)
|
|
}))
|
|
})
|
|
.anchor(gpui::Anchor::TopLeft)
|
|
.offset(gpui::Point {
|
|
x: px(0.0),
|
|
y: px(2.0),
|
|
})
|
|
.with_handle(handle)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_icon_theme_picker(
|
|
field: SettingField<settings::IconThemeName>,
|
|
file: SettingsUiFile,
|
|
_metadata: Option<&SettingsFieldMetadata>,
|
|
title: &'static str,
|
|
description: &'static str,
|
|
_window: &mut Window,
|
|
cx: &mut App,
|
|
) -> AnyElement {
|
|
let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
|
|
let current_value = value
|
|
.cloned()
|
|
.map(|theme_name| theme_name.0.into())
|
|
.unwrap_or_else(|| cx.theme().name.clone());
|
|
|
|
let handle = ui::PopoverMenuHandle::default();
|
|
PopoverMenu::new("icon-theme-picker")
|
|
.trigger(wire_picker_trigger_a11y(
|
|
render_picker_trigger_button("icon_theme_picker_trigger".into(), current_value.clone())
|
|
.aria_label(title)
|
|
.when(!description.is_empty(), |this| {
|
|
this.aria_description(description)
|
|
}),
|
|
handle.clone(),
|
|
))
|
|
.menu(move |window, cx| {
|
|
Some(cx.new(|cx| {
|
|
let file = file.clone();
|
|
let current_value = current_value.clone();
|
|
icon_theme_picker(
|
|
current_value,
|
|
move |theme_name, window, cx| {
|
|
update_settings_file(
|
|
file.clone(),
|
|
field.json_path,
|
|
window,
|
|
cx,
|
|
move |settings, app| {
|
|
(field.write)(
|
|
settings,
|
|
Some(settings::IconThemeName(theme_name.into())),
|
|
app,
|
|
);
|
|
},
|
|
)
|
|
.log_err(); // todo(settings_ui) don't log err
|
|
},
|
|
window,
|
|
cx,
|
|
)
|
|
}))
|
|
})
|
|
.anchor(gpui::Anchor::TopLeft)
|
|
.offset(gpui::Point {
|
|
x: px(0.0),
|
|
y: px(2.0),
|
|
})
|
|
.with_handle(handle)
|
|
.into_any_element()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub mod test {
|
|
|
|
use super::*;
|
|
|
|
impl SettingsWindow {
|
|
fn navbar_entry(&self) -> usize {
|
|
self.navbar_entry
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn test(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
let search_bar = cx.new(|cx| Editor::single_line(window, cx));
|
|
let dummy_page = SettingsPage {
|
|
title: "Test",
|
|
items: Box::new([]),
|
|
};
|
|
Self {
|
|
title_bar: None,
|
|
original_window: None,
|
|
worktree_root_dirs: HashMap::default(),
|
|
files: Vec::default(),
|
|
current_file: SettingsUiFile::User,
|
|
project_setting_file_buffers: HashMap::default(),
|
|
pages: vec![dummy_page],
|
|
search_bar,
|
|
navbar_entry: 0,
|
|
navbar_entries: Vec::default(),
|
|
navbar_scroll_handle: UniformListScrollHandle::default(),
|
|
navbar_focus_subscriptions: Vec::default(),
|
|
filter_table: Vec::default(),
|
|
has_query: false,
|
|
content_handles: Vec::default(),
|
|
search_task: None,
|
|
sub_page_stack: Vec::default(),
|
|
opening_link: false,
|
|
focus_handle: cx.focus_handle(),
|
|
navbar_focus_handle: NonFocusableHandle::new(
|
|
NAVBAR_CONTAINER_TAB_INDEX,
|
|
false,
|
|
window,
|
|
cx,
|
|
),
|
|
content_focus_handle: NonFocusableHandle::new(
|
|
CONTENT_CONTAINER_TAB_INDEX,
|
|
false,
|
|
window,
|
|
cx,
|
|
),
|
|
files_focus_handle: cx.focus_handle(),
|
|
search_index: None,
|
|
list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)),
|
|
shown_errors: HashSet::default(),
|
|
hidden_deleted_skill_directory_paths: HashSet::default(),
|
|
regex_validation_error: None,
|
|
sandbox_host_validation_error: None,
|
|
last_copied_link_path: None,
|
|
provider_configuration_views: HashMap::default(),
|
|
configuring_provider: None,
|
|
last_copied_skill_directory_path: None,
|
|
llm_provider_form: None,
|
|
llm_provider_add_focus_handle: cx.focus_handle(),
|
|
mcp_server_form: None,
|
|
mcp_add_server_focus_handle: cx.focus_handle(),
|
|
custom_agent_form: None,
|
|
external_agent_add_focus_handle: cx.focus_handle(),
|
|
skill_creator_page: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PartialEq for NavBarEntry {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.title == other.title
|
|
&& self.is_root == other.is_root
|
|
&& self.expanded == other.expanded
|
|
&& self.page_index == other.page_index
|
|
&& self.item_index == other.item_index
|
|
// ignoring focus_handle
|
|
}
|
|
}
|
|
|
|
pub fn register_settings(cx: &mut App) {
|
|
settings::init(cx);
|
|
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
|
editor::init(cx);
|
|
menu::init();
|
|
language_model::init(cx);
|
|
}
|
|
|
|
fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
|
|
struct PageBuilder {
|
|
title: &'static str,
|
|
items: Vec<SettingsPageItem>,
|
|
}
|
|
let mut page_builders: Vec<PageBuilder> = Vec::new();
|
|
let mut expanded_pages = Vec::new();
|
|
let mut selected_idx = None;
|
|
let mut index = 0;
|
|
let mut in_expanded_section = false;
|
|
|
|
for mut line in input
|
|
.lines()
|
|
.map(|line| line.trim())
|
|
.filter(|line| !line.is_empty())
|
|
{
|
|
if let Some(pre) = line.strip_suffix('*') {
|
|
assert!(selected_idx.is_none(), "Only one selected entry allowed");
|
|
selected_idx = Some(index);
|
|
line = pre;
|
|
}
|
|
let (kind, title) = line.split_once(" ").unwrap();
|
|
assert_eq!(kind.len(), 1);
|
|
let kind = kind.chars().next().unwrap();
|
|
if kind == 'v' {
|
|
let page_idx = page_builders.len();
|
|
expanded_pages.push(page_idx);
|
|
page_builders.push(PageBuilder {
|
|
title,
|
|
items: vec![],
|
|
});
|
|
index += 1;
|
|
in_expanded_section = true;
|
|
} else if kind == '>' {
|
|
page_builders.push(PageBuilder {
|
|
title,
|
|
items: vec![],
|
|
});
|
|
index += 1;
|
|
in_expanded_section = false;
|
|
} else if kind == '-' {
|
|
page_builders
|
|
.last_mut()
|
|
.unwrap()
|
|
.items
|
|
.push(SettingsPageItem::SectionHeader(title));
|
|
if selected_idx == Some(index) && !in_expanded_section {
|
|
panic!("Items in unexpanded sections cannot be selected");
|
|
}
|
|
index += 1;
|
|
} else {
|
|
panic!(
|
|
"Entries must start with one of 'v', '>', or '-'\n line: {}",
|
|
line
|
|
);
|
|
}
|
|
}
|
|
|
|
let pages: Vec<SettingsPage> = page_builders
|
|
.into_iter()
|
|
.map(|builder| SettingsPage {
|
|
title: builder.title,
|
|
items: builder.items.into_boxed_slice(),
|
|
})
|
|
.collect();
|
|
|
|
let mut settings_window = SettingsWindow {
|
|
title_bar: None,
|
|
original_window: None,
|
|
worktree_root_dirs: HashMap::default(),
|
|
files: Vec::default(),
|
|
current_file: crate::SettingsUiFile::User,
|
|
project_setting_file_buffers: HashMap::default(),
|
|
pages,
|
|
search_bar: cx.new(|cx| Editor::single_line(window, cx)),
|
|
navbar_entry: selected_idx.expect("Must have a selected navbar entry"),
|
|
navbar_entries: Vec::default(),
|
|
navbar_scroll_handle: UniformListScrollHandle::default(),
|
|
navbar_focus_subscriptions: vec![],
|
|
filter_table: vec![],
|
|
sub_page_stack: vec![],
|
|
opening_link: false,
|
|
has_query: false,
|
|
content_handles: vec![],
|
|
search_task: None,
|
|
focus_handle: cx.focus_handle(),
|
|
navbar_focus_handle: NonFocusableHandle::new(
|
|
NAVBAR_CONTAINER_TAB_INDEX,
|
|
false,
|
|
window,
|
|
cx,
|
|
),
|
|
content_focus_handle: NonFocusableHandle::new(
|
|
CONTENT_CONTAINER_TAB_INDEX,
|
|
false,
|
|
window,
|
|
cx,
|
|
),
|
|
files_focus_handle: cx.focus_handle(),
|
|
search_index: None,
|
|
list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)),
|
|
shown_errors: HashSet::default(),
|
|
hidden_deleted_skill_directory_paths: HashSet::default(),
|
|
regex_validation_error: None,
|
|
sandbox_host_validation_error: None,
|
|
last_copied_link_path: None,
|
|
provider_configuration_views: HashMap::default(),
|
|
configuring_provider: None,
|
|
last_copied_skill_directory_path: None,
|
|
llm_provider_form: None,
|
|
llm_provider_add_focus_handle: cx.focus_handle(),
|
|
mcp_server_form: None,
|
|
mcp_add_server_focus_handle: cx.focus_handle(),
|
|
custom_agent_form: None,
|
|
external_agent_add_focus_handle: cx.focus_handle(),
|
|
skill_creator_page: None,
|
|
};
|
|
|
|
settings_window.build_filter_table();
|
|
settings_window.build_navbar(cx);
|
|
for expanded_page_index in expanded_pages {
|
|
for entry in &mut settings_window.navbar_entries {
|
|
if entry.page_index == expanded_page_index && entry.is_root {
|
|
entry.expanded = true;
|
|
}
|
|
}
|
|
}
|
|
settings_window
|
|
}
|
|
|
|
#[track_caller]
|
|
fn check_navbar_toggle(
|
|
before: &'static str,
|
|
toggle_page: &'static str,
|
|
after: &'static str,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) {
|
|
let mut settings_window = parse(before, window, cx);
|
|
let toggle_page_idx = settings_window
|
|
.pages
|
|
.iter()
|
|
.position(|page| page.title == toggle_page)
|
|
.expect("page not found");
|
|
let toggle_idx = settings_window
|
|
.navbar_entries
|
|
.iter()
|
|
.position(|entry| entry.page_index == toggle_page_idx)
|
|
.expect("page not found");
|
|
settings_window.toggle_navbar_entry(toggle_idx);
|
|
|
|
let expected_settings_window = parse(after, window, cx);
|
|
|
|
pretty_assertions::assert_eq!(
|
|
settings_window
|
|
.visible_navbar_entries()
|
|
.map(|(_, entry)| entry)
|
|
.collect::<Vec<_>>(),
|
|
expected_settings_window
|
|
.visible_navbar_entries()
|
|
.map(|(_, entry)| entry)
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
pretty_assertions::assert_eq!(
|
|
settings_window.navbar_entries[settings_window.navbar_entry()],
|
|
expected_settings_window.navbar_entries[expected_settings_window.navbar_entry()],
|
|
);
|
|
}
|
|
|
|
macro_rules! check_navbar_toggle {
|
|
($name:ident, before: $before:expr, toggle_page: $toggle_page:expr, after: $after:expr) => {
|
|
#[gpui::test]
|
|
fn $name(cx: &mut gpui::TestAppContext) {
|
|
let window = cx.add_empty_window();
|
|
window.update(|window, cx| {
|
|
register_settings(cx);
|
|
check_navbar_toggle($before, $toggle_page, $after, window, cx);
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
check_navbar_toggle!(
|
|
navbar_basic_open,
|
|
before: r"
|
|
v General
|
|
- General
|
|
- Privacy*
|
|
v Project
|
|
- Project Settings
|
|
",
|
|
toggle_page: "General",
|
|
after: r"
|
|
> General*
|
|
v Project
|
|
- Project Settings
|
|
"
|
|
);
|
|
|
|
check_navbar_toggle!(
|
|
navbar_basic_close,
|
|
before: r"
|
|
> General*
|
|
- General
|
|
- Privacy
|
|
v Project
|
|
- Project Settings
|
|
",
|
|
toggle_page: "General",
|
|
after: r"
|
|
v General*
|
|
- General
|
|
- Privacy
|
|
v Project
|
|
- Project Settings
|
|
"
|
|
);
|
|
|
|
check_navbar_toggle!(
|
|
navbar_basic_second_root_entry_close,
|
|
before: r"
|
|
> General
|
|
- General
|
|
- Privacy
|
|
v Project
|
|
- Project Settings*
|
|
",
|
|
toggle_page: "Project",
|
|
after: r"
|
|
> General
|
|
> Project*
|
|
"
|
|
);
|
|
|
|
check_navbar_toggle!(
|
|
navbar_toggle_subroot,
|
|
before: r"
|
|
v General Page
|
|
- General
|
|
- Privacy
|
|
v Project
|
|
- Worktree Settings Content*
|
|
v AI
|
|
- General
|
|
> Appearance & Behavior
|
|
",
|
|
toggle_page: "Project",
|
|
after: r"
|
|
v General Page
|
|
- General
|
|
- Privacy
|
|
> Project*
|
|
v AI
|
|
- General
|
|
> Appearance & Behavior
|
|
"
|
|
);
|
|
|
|
check_navbar_toggle!(
|
|
navbar_toggle_close_propagates_selected_index,
|
|
before: r"
|
|
v General Page
|
|
- General
|
|
- Privacy
|
|
v Project
|
|
- Worktree Settings Content
|
|
v AI
|
|
- General*
|
|
> Appearance & Behavior
|
|
",
|
|
toggle_page: "General Page",
|
|
after: r"
|
|
> General Page*
|
|
v Project
|
|
- Worktree Settings Content
|
|
v AI
|
|
- General
|
|
> Appearance & Behavior
|
|
"
|
|
);
|
|
|
|
check_navbar_toggle!(
|
|
navbar_toggle_expand_propagates_selected_index,
|
|
before: r"
|
|
> General Page
|
|
- General
|
|
- Privacy
|
|
v Project
|
|
- Worktree Settings Content
|
|
v AI
|
|
- General*
|
|
> Appearance & Behavior
|
|
",
|
|
toggle_page: "General Page",
|
|
after: r"
|
|
v General Page*
|
|
- General
|
|
- Privacy
|
|
v Project
|
|
- Worktree Settings Content
|
|
v AI
|
|
- General
|
|
> Appearance & Behavior
|
|
"
|
|
);
|
|
|
|
#[gpui::test]
|
|
fn navbar_double_click_toggle(cx: &mut gpui::TestAppContext) {
|
|
let (settings_window, cx) = cx.add_window_view(|window, cx| {
|
|
register_settings(cx);
|
|
let mut settings_window = parse(
|
|
r"
|
|
> General*
|
|
- General
|
|
- Privacy
|
|
v Project
|
|
- Project Settings
|
|
",
|
|
window,
|
|
cx,
|
|
);
|
|
settings_window.build_content_handles(window, cx);
|
|
settings_window
|
|
});
|
|
|
|
settings_window.update_in(cx, |settings_window, window, cx| {
|
|
let general_idx = settings_window
|
|
.navbar_entries
|
|
.iter()
|
|
.position(|entry| entry.title == "General" && entry.is_root)
|
|
.expect("General root entry should exist");
|
|
let privacy_idx = settings_window
|
|
.navbar_entries
|
|
.iter()
|
|
.position(|entry| entry.title == "Privacy" && !entry.is_root)
|
|
.expect("Privacy nested entry should exist");
|
|
|
|
let click_event = |click_count| {
|
|
gpui::ClickEvent::Mouse(gpui::MouseClickEvent {
|
|
down: gpui::MouseDownEvent {
|
|
button: gpui::MouseButton::Left,
|
|
click_count,
|
|
..Default::default()
|
|
},
|
|
up: gpui::MouseUpEvent {
|
|
button: gpui::MouseButton::Left,
|
|
click_count,
|
|
..Default::default()
|
|
},
|
|
})
|
|
};
|
|
|
|
assert!(
|
|
!settings_window.toggle_navbar_entry_on_double_click(
|
|
general_idx,
|
|
&click_event(1),
|
|
window,
|
|
cx,
|
|
),
|
|
"single-clicks should use the normal navigation path"
|
|
);
|
|
assert!(!settings_window.navbar_entries[general_idx].expanded);
|
|
|
|
assert!(settings_window.toggle_navbar_entry_on_double_click(
|
|
general_idx,
|
|
&click_event(2),
|
|
window,
|
|
cx,
|
|
));
|
|
assert!(settings_window.navbar_entries[general_idx].expanded);
|
|
|
|
assert!(
|
|
!settings_window.toggle_navbar_entry_on_double_click(
|
|
general_idx,
|
|
&click_event(3),
|
|
window,
|
|
cx,
|
|
),
|
|
"triple-clicks should not toggle the entry again"
|
|
);
|
|
assert!(settings_window.navbar_entries[general_idx].expanded);
|
|
|
|
assert!(!settings_window.toggle_navbar_entry_on_double_click(
|
|
privacy_idx,
|
|
&click_event(2),
|
|
window,
|
|
cx,
|
|
));
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_settings_window_shows_worktrees_from_multiple_workspaces(
|
|
cx: &mut gpui::TestAppContext,
|
|
) {
|
|
use project::Project;
|
|
use serde_json::json;
|
|
|
|
cx.update(|cx| {
|
|
register_settings(cx);
|
|
});
|
|
|
|
let app_state = cx.update(|cx| {
|
|
let app_state = AppState::test(cx);
|
|
AppState::set_global(app_state.clone(), cx);
|
|
app_state
|
|
});
|
|
|
|
let fake_fs = app_state.fs.as_fake();
|
|
|
|
fake_fs
|
|
.insert_tree(
|
|
"/workspace1",
|
|
json!({
|
|
"worktree_a": {
|
|
"file1.rs": "fn main() {}"
|
|
},
|
|
"worktree_b": {
|
|
"file2.rs": "fn test() {}"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fake_fs
|
|
.insert_tree(
|
|
"/workspace2",
|
|
json!({
|
|
"worktree_c": {
|
|
"file3.rs": "fn foo() {}"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let project1 = cx.update(|cx| {
|
|
Project::local(
|
|
app_state.client.clone(),
|
|
app_state.node_runtime.clone(),
|
|
app_state.user_store.clone(),
|
|
app_state.languages.clone(),
|
|
app_state.fs.clone(),
|
|
None,
|
|
project::LocalProjectFlags::default(),
|
|
cx,
|
|
)
|
|
});
|
|
|
|
project1
|
|
.update(cx, |project, cx| {
|
|
project.find_or_create_worktree("/workspace1/worktree_a", true, cx)
|
|
})
|
|
.await
|
|
.expect("Failed to create worktree_a");
|
|
project1
|
|
.update(cx, |project, cx| {
|
|
project.find_or_create_worktree("/workspace1/worktree_b", true, cx)
|
|
})
|
|
.await
|
|
.expect("Failed to create worktree_b");
|
|
|
|
let project2 = cx.update(|cx| {
|
|
Project::local(
|
|
app_state.client.clone(),
|
|
app_state.node_runtime.clone(),
|
|
app_state.user_store.clone(),
|
|
app_state.languages.clone(),
|
|
app_state.fs.clone(),
|
|
None,
|
|
project::LocalProjectFlags::default(),
|
|
cx,
|
|
)
|
|
});
|
|
|
|
project2
|
|
.update(cx, |project, cx| {
|
|
project.find_or_create_worktree("/workspace2/worktree_c", true, cx)
|
|
})
|
|
.await
|
|
.expect("Failed to create worktree_c");
|
|
|
|
let (_multi_workspace1, cx) = cx.add_window_view(|window, cx| {
|
|
let workspace = cx.new(|cx| {
|
|
Workspace::new(
|
|
Default::default(),
|
|
project1.clone(),
|
|
app_state.clone(),
|
|
window,
|
|
cx,
|
|
)
|
|
});
|
|
MultiWorkspace::new(workspace, window, cx)
|
|
});
|
|
|
|
let (_multi_workspace2, cx) = cx.add_window_view(|window, cx| {
|
|
let workspace = cx.new(|cx| {
|
|
Workspace::new(
|
|
Default::default(),
|
|
project2.clone(),
|
|
app_state.clone(),
|
|
window,
|
|
cx,
|
|
)
|
|
});
|
|
MultiWorkspace::new(workspace, window, cx)
|
|
});
|
|
|
|
let workspace2_handle = cx.window_handle().downcast::<MultiWorkspace>().unwrap();
|
|
|
|
cx.run_until_parked();
|
|
|
|
let (settings_window, cx) = cx
|
|
.add_window_view(|window, cx| SettingsWindow::new(Some(workspace2_handle), window, cx));
|
|
|
|
cx.run_until_parked();
|
|
|
|
settings_window.read_with(cx, |settings_window, _| {
|
|
let worktree_names: Vec<_> = settings_window
|
|
.worktree_root_dirs
|
|
.values()
|
|
.cloned()
|
|
.collect();
|
|
|
|
assert!(
|
|
worktree_names.iter().any(|name| name == "worktree_a"),
|
|
"Should contain worktree_a from workspace1, but found: {:?}",
|
|
worktree_names
|
|
);
|
|
assert!(
|
|
worktree_names.iter().any(|name| name == "worktree_b"),
|
|
"Should contain worktree_b from workspace1, but found: {:?}",
|
|
worktree_names
|
|
);
|
|
assert!(
|
|
worktree_names.iter().any(|name| name == "worktree_c"),
|
|
"Should contain worktree_c from workspace2, but found: {:?}",
|
|
worktree_names
|
|
);
|
|
|
|
assert_eq!(
|
|
worktree_names.len(),
|
|
3,
|
|
"Should have exactly 3 worktrees from both workspaces, but found: {:?}",
|
|
worktree_names
|
|
);
|
|
|
|
let project_files: Vec<_> = settings_window
|
|
.files
|
|
.iter()
|
|
.filter_map(|(f, _)| match f {
|
|
SettingsUiFile::Project((worktree_id, _)) => Some(*worktree_id),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
|
|
let unique_project_files: std::collections::HashSet<_> = project_files.iter().collect();
|
|
assert_eq!(
|
|
project_files.len(),
|
|
unique_project_files.len(),
|
|
"Should have no duplicate project files, but found duplicates. All files: {:?}",
|
|
project_files
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_settings_window_updates_when_new_workspace_created(
|
|
cx: &mut gpui::TestAppContext,
|
|
) {
|
|
use project::Project;
|
|
use serde_json::json;
|
|
|
|
cx.update(|cx| {
|
|
register_settings(cx);
|
|
});
|
|
|
|
let app_state = cx.update(|cx| {
|
|
let app_state = AppState::test(cx);
|
|
AppState::set_global(app_state.clone(), cx);
|
|
app_state
|
|
});
|
|
|
|
let fake_fs = app_state.fs.as_fake();
|
|
|
|
fake_fs
|
|
.insert_tree(
|
|
"/workspace1",
|
|
json!({
|
|
"worktree_a": {
|
|
"file1.rs": "fn main() {}"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fake_fs
|
|
.insert_tree(
|
|
"/workspace2",
|
|
json!({
|
|
"worktree_b": {
|
|
"file2.rs": "fn test() {}"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let project1 = cx.update(|cx| {
|
|
Project::local(
|
|
app_state.client.clone(),
|
|
app_state.node_runtime.clone(),
|
|
app_state.user_store.clone(),
|
|
app_state.languages.clone(),
|
|
app_state.fs.clone(),
|
|
None,
|
|
project::LocalProjectFlags::default(),
|
|
cx,
|
|
)
|
|
});
|
|
|
|
project1
|
|
.update(cx, |project, cx| {
|
|
project.find_or_create_worktree("/workspace1/worktree_a", true, cx)
|
|
})
|
|
.await
|
|
.expect("Failed to create worktree_a");
|
|
|
|
let (_multi_workspace1, cx) = cx.add_window_view(|window, cx| {
|
|
let workspace = cx.new(|cx| {
|
|
Workspace::new(
|
|
Default::default(),
|
|
project1.clone(),
|
|
app_state.clone(),
|
|
window,
|
|
cx,
|
|
)
|
|
});
|
|
MultiWorkspace::new(workspace, window, cx)
|
|
});
|
|
|
|
let workspace1_handle = cx.window_handle().downcast::<MultiWorkspace>().unwrap();
|
|
|
|
cx.run_until_parked();
|
|
|
|
let (settings_window, cx) = cx
|
|
.add_window_view(|window, cx| SettingsWindow::new(Some(workspace1_handle), window, cx));
|
|
|
|
cx.run_until_parked();
|
|
|
|
settings_window.read_with(cx, |settings_window, _| {
|
|
assert_eq!(
|
|
settings_window.worktree_root_dirs.len(),
|
|
1,
|
|
"Should have 1 worktree initially"
|
|
);
|
|
});
|
|
|
|
let project2 = cx.update(|_, cx| {
|
|
Project::local(
|
|
app_state.client.clone(),
|
|
app_state.node_runtime.clone(),
|
|
app_state.user_store.clone(),
|
|
app_state.languages.clone(),
|
|
app_state.fs.clone(),
|
|
None,
|
|
project::LocalProjectFlags::default(),
|
|
cx,
|
|
)
|
|
});
|
|
|
|
project2
|
|
.update(&mut cx.cx, |project, cx| {
|
|
project.find_or_create_worktree("/workspace2/worktree_b", true, cx)
|
|
})
|
|
.await
|
|
.expect("Failed to create worktree_b");
|
|
|
|
let (_multi_workspace2, cx) = cx.add_window_view(|window, cx| {
|
|
let workspace = cx.new(|cx| {
|
|
Workspace::new(
|
|
Default::default(),
|
|
project2.clone(),
|
|
app_state.clone(),
|
|
window,
|
|
cx,
|
|
)
|
|
});
|
|
MultiWorkspace::new(workspace, window, cx)
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
settings_window.read_with(cx, |settings_window, _| {
|
|
let worktree_names: Vec<_> = settings_window
|
|
.worktree_root_dirs
|
|
.values()
|
|
.cloned()
|
|
.collect();
|
|
|
|
assert!(
|
|
worktree_names.iter().any(|name| name == "worktree_a"),
|
|
"Should contain worktree_a, but found: {:?}",
|
|
worktree_names
|
|
);
|
|
assert!(
|
|
worktree_names.iter().any(|name| name == "worktree_b"),
|
|
"Should contain worktree_b from newly created workspace, but found: {:?}",
|
|
worktree_names
|
|
);
|
|
|
|
assert_eq!(
|
|
worktree_names.len(),
|
|
2,
|
|
"Should have 2 worktrees after new workspace created, but found: {:?}",
|
|
worktree_names
|
|
);
|
|
|
|
let project_files: Vec<_> = settings_window
|
|
.files
|
|
.iter()
|
|
.filter_map(|(f, _)| match f {
|
|
SettingsUiFile::Project((worktree_id, _)) => Some(*worktree_id),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
|
|
let unique_project_files: std::collections::HashSet<_> = project_files.iter().collect();
|
|
assert_eq!(
|
|
project_files.len(),
|
|
unique_project_files.len(),
|
|
"Should have no duplicate project files, but found duplicates. All files: {:?}",
|
|
project_files
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_skills_page_scope_switch_updates_displayed_skills(cx: &mut gpui::TestAppContext) {
|
|
use agent_skills::{
|
|
ProjectSkillGroup, Skill, SkillScopeId, SkillSource, load_skills_from_directory,
|
|
};
|
|
use project::Project;
|
|
use serde_json::json;
|
|
use std::path::Path;
|
|
|
|
cx.update(|cx| {
|
|
register_settings(cx);
|
|
});
|
|
|
|
let app_state = cx.update(|cx| {
|
|
let app_state = AppState::test(cx);
|
|
AppState::set_global(app_state.clone(), cx);
|
|
app_state
|
|
});
|
|
|
|
let fake_fs = app_state.fs.as_fake();
|
|
|
|
fake_fs
|
|
.insert_tree(
|
|
"/global-skills",
|
|
json!({
|
|
"global-skill": {
|
|
"SKILL.md": "---\nname: global-skill\ndescription: A user level skill\n---\n\nGlobal instructions."
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fake_fs
|
|
.insert_tree(
|
|
"/project",
|
|
json!({
|
|
".agents": {
|
|
"skills": {
|
|
"project-skill": {
|
|
"SKILL.md": "---\nname: project-skill\ndescription: A project level skill\n---\n\nProject instructions."
|
|
}
|
|
}
|
|
},
|
|
"main.rs": "fn main() {}"
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let project = cx.update(|cx| {
|
|
Project::local(
|
|
app_state.client.clone(),
|
|
app_state.node_runtime.clone(),
|
|
app_state.user_store.clone(),
|
|
app_state.languages.clone(),
|
|
app_state.fs.clone(),
|
|
None,
|
|
project::LocalProjectFlags::default(),
|
|
cx,
|
|
)
|
|
});
|
|
|
|
let (worktree, _) = project
|
|
.update(cx, |project, cx| {
|
|
project.find_or_create_worktree("/project", true, cx)
|
|
})
|
|
.await
|
|
.expect("Failed to create worktree");
|
|
let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
|
|
|
|
// Load both skills from the fake filesystem the same way the agent
|
|
// does, then publish them as the global skill index.
|
|
let fs = app_state.fs.clone();
|
|
let global_skills: Vec<Skill> =
|
|
load_skills_from_directory(&fs, Path::new("/global-skills"), SkillSource::Global)
|
|
.await
|
|
.into_iter()
|
|
.map(|result| result.expect("global skill should load"))
|
|
.collect();
|
|
let project_skills: Vec<Skill> = load_skills_from_directory(
|
|
&fs,
|
|
Path::new("/project/.agents/skills"),
|
|
SkillSource::ProjectLocal {
|
|
worktree_id: SkillScopeId(worktree_id.to_usize()),
|
|
worktree_root_name: "project".into(),
|
|
},
|
|
)
|
|
.await
|
|
.into_iter()
|
|
.map(|result| result.expect("project skill should load"))
|
|
.collect();
|
|
assert_eq!(global_skills.len(), 1);
|
|
assert_eq!(project_skills.len(), 1);
|
|
|
|
cx.update(|cx| {
|
|
cx.set_global(SkillIndex {
|
|
global_skills,
|
|
project_skills: vec![ProjectSkillGroup {
|
|
worktree_id: SkillScopeId(worktree_id.to_usize()),
|
|
worktree_root_name: "project".into(),
|
|
skills: project_skills,
|
|
}],
|
|
});
|
|
});
|
|
|
|
let (_multi_workspace, cx) = cx.add_window_view(|window, cx| {
|
|
let workspace = cx.new(|cx| {
|
|
Workspace::new(
|
|
Default::default(),
|
|
project.clone(),
|
|
app_state.clone(),
|
|
window,
|
|
cx,
|
|
)
|
|
});
|
|
MultiWorkspace::new(workspace, window, cx)
|
|
});
|
|
let workspace_handle = cx.window_handle().downcast::<MultiWorkspace>().unwrap();
|
|
|
|
cx.run_until_parked();
|
|
|
|
let (settings_window, cx) = cx
|
|
.add_window_view(|window, cx| SettingsWindow::new(Some(workspace_handle), window, cx));
|
|
|
|
cx.run_until_parked();
|
|
|
|
settings_window.update_in(cx, |settings_window, window, cx| {
|
|
fn displayed_skill_names(settings_window: &SettingsWindow, cx: &App) -> Vec<String> {
|
|
crate::pages::displayed_skills(settings_window, cx)
|
|
.iter()
|
|
.map(|skill| skill.name.to_string())
|
|
.collect()
|
|
}
|
|
|
|
assert_eq!(settings_window.current_file, SettingsUiFile::User);
|
|
assert!(
|
|
settings_window.navigate_to_sub_page(AGENT_SKILLS_SETTINGS_PATH, window, cx),
|
|
"Skills sub-page should exist"
|
|
);
|
|
assert_eq!(displayed_skill_names(settings_window, cx), ["global-skill"]);
|
|
|
|
let project_file_index = settings_window
|
|
.files
|
|
.iter()
|
|
.position(|(file, _)| file.worktree_id() == Some(worktree_id))
|
|
.expect("project settings file should be listed");
|
|
settings_window.change_file_in_sub_page(project_file_index, window, cx);
|
|
|
|
assert_eq!(
|
|
settings_window.current_file.worktree_id(),
|
|
Some(worktree_id)
|
|
);
|
|
assert_eq!(
|
|
settings_window.sub_page_stack.len(),
|
|
1,
|
|
"Skills sub-page should stay open when switching scope"
|
|
);
|
|
assert_eq!(settings_window.sub_page_stack[0].link.title, "Skills");
|
|
assert_eq!(
|
|
displayed_skill_names(settings_window, cx),
|
|
["project-skill"]
|
|
);
|
|
|
|
let user_file_index = settings_window
|
|
.files
|
|
.iter()
|
|
.position(|(file, _)| file == &SettingsUiFile::User)
|
|
.expect("user settings file should be listed");
|
|
settings_window.change_file_in_sub_page(user_file_index, window, cx);
|
|
|
|
assert_eq!(settings_window.current_file, SettingsUiFile::User);
|
|
assert_eq!(settings_window.sub_page_stack.len(), 1);
|
|
assert_eq!(displayed_skill_names(settings_window, cx), ["global-skill"]);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_open_skill_creator_navigates_to_sub_page(cx: &mut gpui::TestAppContext) {
|
|
use project::Project;
|
|
|
|
cx.update(|cx| {
|
|
register_settings(cx);
|
|
});
|
|
|
|
let app_state = cx.update(|cx| {
|
|
let app_state = AppState::test(cx);
|
|
AppState::set_global(app_state.clone(), cx);
|
|
app_state
|
|
});
|
|
|
|
app_state
|
|
.fs
|
|
.as_fake()
|
|
.insert_tree("/project", serde_json::json!({ "main.rs": "fn main() {}" }))
|
|
.await;
|
|
|
|
let project = cx.update(|cx| {
|
|
Project::local(
|
|
app_state.client.clone(),
|
|
app_state.node_runtime.clone(),
|
|
app_state.user_store.clone(),
|
|
app_state.languages.clone(),
|
|
app_state.fs.clone(),
|
|
None,
|
|
project::LocalProjectFlags::default(),
|
|
cx,
|
|
)
|
|
});
|
|
project
|
|
.update(cx, |project, cx| {
|
|
project.find_or_create_worktree("/project", true, cx)
|
|
})
|
|
.await
|
|
.expect("Failed to create worktree");
|
|
|
|
let (_multi_workspace, cx) = cx.add_window_view(|window, cx| {
|
|
let workspace = cx.new(|cx| {
|
|
Workspace::new(
|
|
Default::default(),
|
|
project.clone(),
|
|
app_state.clone(),
|
|
window,
|
|
cx,
|
|
)
|
|
});
|
|
MultiWorkspace::new(workspace, window, cx)
|
|
});
|
|
let workspace_handle = cx.window_handle().downcast::<MultiWorkspace>().unwrap();
|
|
|
|
cx.run_until_parked();
|
|
|
|
let (settings_window, cx) = cx
|
|
.add_window_view(|window, cx| SettingsWindow::new(Some(workspace_handle), window, cx));
|
|
|
|
cx.run_until_parked();
|
|
|
|
settings_window.update_in(cx, |settings_window, window, cx| {
|
|
settings_window.navigate_to_skill_creator(
|
|
pages::SkillCreatorOpenMode::Form,
|
|
window,
|
|
cx,
|
|
);
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
settings_window.read_with(cx, |settings_window, _| {
|
|
let titles: Vec<_> = settings_window
|
|
.sub_page_stack
|
|
.iter()
|
|
.map(|sub_page| sub_page.link.title.to_string())
|
|
.collect();
|
|
assert_eq!(
|
|
titles,
|
|
["Skills", "Create Skill"],
|
|
"skill creator should be pushed on top of the skills page"
|
|
);
|
|
assert!(
|
|
settings_window.skill_creator_page().is_some(),
|
|
"skill creator page state should exist"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_open_skill_creator_action_opens_settings_window_at_sub_page(
|
|
cx: &mut gpui::TestAppContext,
|
|
) {
|
|
use project::Project;
|
|
|
|
cx.update(|cx| {
|
|
register_settings(cx);
|
|
release_channel::init("0.0.0".parse().unwrap(), cx);
|
|
crate::init(cx);
|
|
});
|
|
|
|
let app_state = cx.update(|cx| {
|
|
let app_state = AppState::test(cx);
|
|
AppState::set_global(app_state.clone(), cx);
|
|
app_state
|
|
});
|
|
|
|
app_state
|
|
.fs
|
|
.as_fake()
|
|
.insert_tree("/project", serde_json::json!({ "main.rs": "fn main() {}" }))
|
|
.await;
|
|
|
|
let project = cx.update(|cx| {
|
|
Project::local(
|
|
app_state.client.clone(),
|
|
app_state.node_runtime.clone(),
|
|
app_state.user_store.clone(),
|
|
app_state.languages.clone(),
|
|
app_state.fs.clone(),
|
|
None,
|
|
project::LocalProjectFlags::default(),
|
|
cx,
|
|
)
|
|
});
|
|
project
|
|
.update(cx, |project, cx| {
|
|
project.find_or_create_worktree("/project", true, cx)
|
|
})
|
|
.await
|
|
.expect("Failed to create worktree");
|
|
|
|
let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
|
|
let workspace = cx.new(|cx| {
|
|
Workspace::new(
|
|
Default::default(),
|
|
project.clone(),
|
|
app_state.clone(),
|
|
window,
|
|
cx,
|
|
)
|
|
});
|
|
MultiWorkspace::new(workspace, window, cx)
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
// Dispatch the action the way the command palette does: on the
|
|
// workspace window.
|
|
multi_workspace.update_in(cx, |_multi_workspace, window, cx| {
|
|
window.dispatch_action(Box::new(zed_actions::assistant::OpenSkillCreator), cx);
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
|
|
let settings_window = cx
|
|
.update(|_, cx| {
|
|
cx.windows()
|
|
.into_iter()
|
|
.find_map(|window| window.downcast::<SettingsWindow>())
|
|
})
|
|
.expect("dispatching agent::OpenSkillCreator should open the settings window");
|
|
|
|
settings_window
|
|
.read_with(cx, |settings_window, _| {
|
|
let titles: Vec<_> = settings_window
|
|
.sub_page_stack
|
|
.iter()
|
|
.map(|sub_page| sub_page.link.title.to_string())
|
|
.collect();
|
|
assert_eq!(
|
|
titles,
|
|
["Skills", "Create Skill"],
|
|
"skill creator should be pushed on top of the skills page"
|
|
);
|
|
})
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod project_settings_update_tests {
|
|
use super::*;
|
|
use fs::{FakeFs, Fs as _};
|
|
use gpui::TestAppContext;
|
|
use project::Project;
|
|
use serde_json::json;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
struct TestSetup {
|
|
fs: Arc<FakeFs>,
|
|
project: Entity<Project>,
|
|
worktree_id: WorktreeId,
|
|
worktree: WeakEntity<Worktree>,
|
|
rel_path: Arc<RelPath>,
|
|
project_path: ProjectPath,
|
|
}
|
|
|
|
async fn init_test(cx: &mut TestAppContext, initial_settings: Option<&str>) -> TestSetup {
|
|
cx.update(|cx| {
|
|
let store = settings::SettingsStore::test(cx);
|
|
cx.set_global(store);
|
|
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
|
editor::init(cx);
|
|
menu::init();
|
|
let queue = ProjectSettingsUpdateQueue::new(cx);
|
|
cx.set_global(queue);
|
|
});
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
let tree = if let Some(settings_content) = initial_settings {
|
|
json!({
|
|
".zed": {
|
|
"settings.json": settings_content
|
|
},
|
|
"src": { "main.rs": "" }
|
|
})
|
|
} else {
|
|
json!({ "src": { "main.rs": "" } })
|
|
};
|
|
fs.insert_tree("/project", tree).await;
|
|
|
|
let project = Project::test(fs.clone(), ["/project".as_ref()], cx).await;
|
|
|
|
let (worktree_id, worktree) = project.read_with(cx, |project, cx| {
|
|
let worktree = project.worktrees(cx).next().unwrap();
|
|
(worktree.read(cx).id(), worktree.downgrade())
|
|
});
|
|
|
|
let rel_path: Arc<RelPath> = RelPath::from_unix_str(".zed/settings.json")
|
|
.expect("valid path")
|
|
.into_arc();
|
|
let project_path = ProjectPath {
|
|
worktree_id,
|
|
path: rel_path.clone(),
|
|
};
|
|
|
|
TestSetup {
|
|
fs,
|
|
project,
|
|
worktree_id,
|
|
worktree,
|
|
rel_path,
|
|
project_path,
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_creates_settings_file_if_missing(cx: &mut TestAppContext) {
|
|
let setup = init_test(cx, None).await;
|
|
|
|
let entry = ProjectSettingsUpdateEntry {
|
|
worktree_id: setup.worktree_id,
|
|
rel_path: setup.rel_path.clone(),
|
|
settings_window: WeakEntity::new_invalid(),
|
|
project: setup.project.downgrade(),
|
|
worktree: setup.worktree,
|
|
update: Box::new(|content, _cx| {
|
|
content.project.all_languages.defaults.tab_size = Some(NonZeroU32::new(4).unwrap());
|
|
}),
|
|
};
|
|
|
|
cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
|
|
cx.executor().run_until_parked();
|
|
|
|
let buffer_store = setup
|
|
.project
|
|
.read_with(cx, |project, _| project.buffer_store().clone());
|
|
let buffer = buffer_store
|
|
.update(cx, |store, cx| store.open_buffer(setup.project_path, cx))
|
|
.await
|
|
.expect("buffer should exist");
|
|
|
|
let text = buffer.read_with(cx, |buffer, _| buffer.text());
|
|
assert!(
|
|
text.contains("\"tab_size\": 4"),
|
|
"Expected tab_size setting in: {}",
|
|
text
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_updates_existing_settings_file(cx: &mut TestAppContext) {
|
|
let setup = init_test(cx, Some(r#"{ "tab_size": 2 }"#)).await;
|
|
|
|
let entry = ProjectSettingsUpdateEntry {
|
|
worktree_id: setup.worktree_id,
|
|
rel_path: setup.rel_path.clone(),
|
|
settings_window: WeakEntity::new_invalid(),
|
|
project: setup.project.downgrade(),
|
|
worktree: setup.worktree,
|
|
update: Box::new(|content, _cx| {
|
|
content.project.all_languages.defaults.tab_size = Some(NonZeroU32::new(8).unwrap());
|
|
}),
|
|
};
|
|
|
|
cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
|
|
cx.executor().run_until_parked();
|
|
|
|
let buffer_store = setup
|
|
.project
|
|
.read_with(cx, |project, _| project.buffer_store().clone());
|
|
let buffer = buffer_store
|
|
.update(cx, |store, cx| store.open_buffer(setup.project_path, cx))
|
|
.await
|
|
.expect("buffer should exist");
|
|
|
|
let text = buffer.read_with(cx, |buffer, _| buffer.text());
|
|
assert!(
|
|
text.contains("\"tab_size\": 8"),
|
|
"Expected updated tab_size in: {}",
|
|
text
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_updates_are_serialized(cx: &mut TestAppContext) {
|
|
let setup = init_test(cx, Some("{}")).await;
|
|
|
|
let update_order = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
|
|
for i in 1..=3 {
|
|
let update_order = update_order.clone();
|
|
let entry = ProjectSettingsUpdateEntry {
|
|
worktree_id: setup.worktree_id,
|
|
rel_path: setup.rel_path.clone(),
|
|
settings_window: WeakEntity::new_invalid(),
|
|
project: setup.project.downgrade(),
|
|
worktree: setup.worktree.clone(),
|
|
update: Box::new(move |content, _cx| {
|
|
update_order.lock().unwrap().push(i);
|
|
content.project.all_languages.defaults.tab_size =
|
|
Some(NonZeroU32::new(i).unwrap());
|
|
}),
|
|
};
|
|
cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
|
|
}
|
|
|
|
cx.executor().run_until_parked();
|
|
|
|
let order = update_order.lock().unwrap().clone();
|
|
assert_eq!(order, vec![1, 2, 3], "Updates should be processed in order");
|
|
|
|
let buffer_store = setup
|
|
.project
|
|
.read_with(cx, |project, _| project.buffer_store().clone());
|
|
let buffer = buffer_store
|
|
.update(cx, |store, cx| store.open_buffer(setup.project_path, cx))
|
|
.await
|
|
.expect("buffer should exist");
|
|
|
|
let text = buffer.read_with(cx, |buffer, _| buffer.text());
|
|
assert!(
|
|
text.contains("\"tab_size\": 3"),
|
|
"Final tab_size should be 3: {}",
|
|
text
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_queue_continues_after_failure(cx: &mut TestAppContext) {
|
|
let setup = init_test(cx, Some("{}")).await;
|
|
|
|
let successful_updates = Arc::new(AtomicUsize::new(0));
|
|
|
|
{
|
|
let successful_updates = successful_updates.clone();
|
|
let entry = ProjectSettingsUpdateEntry {
|
|
worktree_id: setup.worktree_id,
|
|
rel_path: setup.rel_path.clone(),
|
|
settings_window: WeakEntity::new_invalid(),
|
|
project: setup.project.downgrade(),
|
|
worktree: setup.worktree.clone(),
|
|
update: Box::new(move |content, _cx| {
|
|
successful_updates.fetch_add(1, Ordering::SeqCst);
|
|
content.project.all_languages.defaults.tab_size =
|
|
Some(NonZeroU32::new(2).unwrap());
|
|
}),
|
|
};
|
|
cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
|
|
}
|
|
|
|
{
|
|
let entry = ProjectSettingsUpdateEntry {
|
|
worktree_id: setup.worktree_id,
|
|
rel_path: setup.rel_path.clone(),
|
|
settings_window: WeakEntity::new_invalid(),
|
|
project: WeakEntity::new_invalid(),
|
|
worktree: setup.worktree.clone(),
|
|
update: Box::new(|content, _cx| {
|
|
content.project.all_languages.defaults.tab_size =
|
|
Some(NonZeroU32::new(99).unwrap());
|
|
}),
|
|
};
|
|
cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
|
|
}
|
|
|
|
{
|
|
let successful_updates = successful_updates.clone();
|
|
let entry = ProjectSettingsUpdateEntry {
|
|
worktree_id: setup.worktree_id,
|
|
rel_path: setup.rel_path.clone(),
|
|
settings_window: WeakEntity::new_invalid(),
|
|
project: setup.project.downgrade(),
|
|
worktree: setup.worktree.clone(),
|
|
update: Box::new(move |content, _cx| {
|
|
successful_updates.fetch_add(1, Ordering::SeqCst);
|
|
content.project.all_languages.defaults.tab_size =
|
|
Some(NonZeroU32::new(4).unwrap());
|
|
}),
|
|
};
|
|
cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
|
|
}
|
|
|
|
cx.executor().run_until_parked();
|
|
|
|
assert_eq!(
|
|
successful_updates.load(Ordering::SeqCst),
|
|
2,
|
|
"Two updates should have succeeded despite middle failure"
|
|
);
|
|
|
|
let buffer_store = setup
|
|
.project
|
|
.read_with(cx, |project, _| project.buffer_store().clone());
|
|
let buffer = buffer_store
|
|
.update(cx, |store, cx| store.open_buffer(setup.project_path, cx))
|
|
.await
|
|
.expect("buffer should exist");
|
|
|
|
let text = buffer.read_with(cx, |buffer, _| buffer.text());
|
|
assert!(
|
|
text.contains("\"tab_size\": 4"),
|
|
"Final tab_size should be 4 (third update): {}",
|
|
text
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_handles_dropped_worktree(cx: &mut TestAppContext) {
|
|
let setup = init_test(cx, Some("{}")).await;
|
|
|
|
let entry = ProjectSettingsUpdateEntry {
|
|
worktree_id: setup.worktree_id,
|
|
rel_path: setup.rel_path.clone(),
|
|
settings_window: WeakEntity::new_invalid(),
|
|
project: setup.project.downgrade(),
|
|
worktree: WeakEntity::new_invalid(),
|
|
update: Box::new(|content, _cx| {
|
|
content.project.all_languages.defaults.tab_size =
|
|
Some(NonZeroU32::new(99).unwrap());
|
|
}),
|
|
};
|
|
|
|
cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
|
|
cx.executor().run_until_parked();
|
|
|
|
let file_content = setup
|
|
.fs
|
|
.load("/project/.zed/settings.json".as_ref())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
file_content, "{}",
|
|
"File should be unchanged when worktree is dropped"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_reloads_conflicted_buffer(cx: &mut TestAppContext) {
|
|
let setup = init_test(cx, Some(r#"{ "tab_size": 2 }"#)).await;
|
|
|
|
let buffer_store = setup
|
|
.project
|
|
.read_with(cx, |project, _| project.buffer_store().clone());
|
|
let buffer = buffer_store
|
|
.update(cx, |store, cx| {
|
|
store.open_buffer(setup.project_path.clone(), cx)
|
|
})
|
|
.await
|
|
.expect("buffer should exist");
|
|
|
|
buffer.update(cx, |buffer, cx| {
|
|
buffer.edit([(0..0, "// comment\n")], None, cx);
|
|
});
|
|
|
|
let has_unsaved_edits = buffer.read_with(cx, |buffer, _| buffer.has_unsaved_edits());
|
|
assert!(has_unsaved_edits, "Buffer should have unsaved edits");
|
|
|
|
setup
|
|
.fs
|
|
.save(
|
|
"/project/.zed/settings.json".as_ref(),
|
|
&r#"{ "tab_size": 99 }"#.into(),
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.expect("save should succeed");
|
|
|
|
cx.executor().run_until_parked();
|
|
|
|
let has_conflict = buffer.read_with(cx, |buffer, _| buffer.has_conflict());
|
|
assert!(
|
|
has_conflict,
|
|
"Buffer should have conflict after external modification"
|
|
);
|
|
|
|
let (settings_window, _) = cx.add_window_view(|window, cx| {
|
|
let mut sw = SettingsWindow::test(window, cx);
|
|
sw.project_setting_file_buffers
|
|
.insert(setup.project_path.clone(), buffer.clone());
|
|
sw
|
|
});
|
|
|
|
let entry = ProjectSettingsUpdateEntry {
|
|
worktree_id: setup.worktree_id,
|
|
rel_path: setup.rel_path.clone(),
|
|
settings_window: settings_window.downgrade(),
|
|
project: setup.project.downgrade(),
|
|
worktree: setup.worktree.clone(),
|
|
update: Box::new(|content, _cx| {
|
|
content.project.all_languages.defaults.tab_size = Some(NonZeroU32::new(4).unwrap());
|
|
}),
|
|
};
|
|
|
|
cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
|
|
cx.executor().run_until_parked();
|
|
|
|
let text = buffer.read_with(cx, |buffer, _| buffer.text());
|
|
assert!(
|
|
text.contains("\"tab_size\": 4"),
|
|
"Buffer should have the new tab_size after reload and update: {}",
|
|
text
|
|
);
|
|
assert!(
|
|
!text.contains("// comment"),
|
|
"Buffer should not contain the unsaved edit after reload: {}",
|
|
text
|
|
);
|
|
assert!(
|
|
!text.contains("99"),
|
|
"Buffer should not contain the external modification value: {}",
|
|
text
|
|
);
|
|
}
|
|
}
|