settings ui: Add text field support to ui layer (#37868)

Anthony Eid created

This is an initial implementation that isn't used for any settings yet,
but will be used once `Vec<String>` is implemented.

I also updated the window.with_state api to grant access to a
`Context<S>` app reference instead of just an App.

## Example

<img width="603" height="83" alt="Screenshot 2025-09-09 at 2 15 56 PM"
src="https://github.com/user-attachments/assets/7b3fc350-a157-431f-a4bc-80a1806a3147"
/>


Release Notes:

- N/A

Change summary

Cargo.lock                              |  1 
assets/keymaps/default-linux.json       |  2 
crates/dap/src/debugger_settings.rs     |  2 
crates/gpui/src/window.rs               |  4 +-
crates/settings/src/settings_ui_core.rs | 13 ++++++
crates/settings_ui/Cargo.toml           |  3 +
crates/settings_ui/src/settings_ui.rs   | 51 ++++++++++++++++++++++++++
7 files changed, 70 insertions(+), 6 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -14871,6 +14871,7 @@ dependencies = [
  "editor",
  "feature_flags",
  "gpui",
+ "menu",
  "serde",
  "serde_json",
  "settings",

assets/keymaps/default-linux.json 🔗

@@ -341,7 +341,7 @@
       "enter": "agent::Chat",
       "shift-ctrl-r": "agent::OpenAgentDiff",
       "ctrl-shift-y": "agent::KeepAll",
-      "ctrl-shift-n": "agent::RejectAll",
+      "ctrl-shift-n": "agent::RejectAll"
     }
   },
   {

crates/dap/src/debugger_settings.rs 🔗

@@ -12,7 +12,7 @@ pub enum DebugPanelDockPosition {
     Right,
 }
 
-#[derive(Serialize, Deserialize, JsonSchema, Clone, Copy, SettingsUi, SettingsKey)]
+#[derive(Serialize, Deserialize, JsonSchema, Clone, SettingsUi, SettingsKey)]
 #[serde(default)]
 // todo(settings_ui) @ben: I'm pretty sure not having the fields be optional here is a bug,
 // it means the defaults will override previously set values if a single key is missing

crates/gpui/src/window.rs 🔗

@@ -2578,7 +2578,7 @@ impl Window {
         &mut self,
         key: impl Into<ElementId>,
         cx: &mut App,
-        init: impl FnOnce(&mut Self, &mut App) -> S,
+        init: impl FnOnce(&mut Self, &mut Context<S>) -> S,
     ) -> Entity<S> {
         let current_view = self.current_view();
         self.with_global_id(key.into(), |global_id, window| {
@@ -2611,7 +2611,7 @@ impl Window {
     pub fn use_state<S: 'static>(
         &mut self,
         cx: &mut App,
-        init: impl FnOnce(&mut Self, &mut App) -> S,
+        init: impl FnOnce(&mut Self, &mut Context<S>) -> S,
     ) -> Entity<S> {
         self.use_keyed_state(
             ElementId::CodeLocation(*core::panic::Location::caller()),

crates/settings/src/settings_ui_core.rs 🔗

@@ -43,6 +43,7 @@ pub struct SettingsUiEntry {
 #[derive(Clone)]
 pub enum SettingsUiItemSingle {
     SwitchField,
+    TextField,
     /// A numeric stepper for a specific type of number
     NumericStepper(NumType),
     ToggleGroup {
@@ -150,6 +151,18 @@ impl SettingsUi for Option<bool> {
     }
 }
 
+impl SettingsUi for String {
+    fn settings_ui_item() -> SettingsUiItem {
+        SettingsUiItem::Single(SettingsUiItemSingle::TextField)
+    }
+}
+
+impl SettingsUi for SettingsUiItem {
+    fn settings_ui_item() -> SettingsUiItem {
+        SettingsUiItem::Single(SettingsUiItemSingle::TextField)
+    }
+}
+
 #[repr(u8)]
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum NumType {

crates/settings_ui/Cargo.toml 🔗

@@ -21,8 +21,9 @@ command_palette_hooks.workspace = true
 editor.workspace = true
 feature_flags.workspace = true
 gpui.workspace = true
-serde_json.workspace = true
+menu.workspace = true
 serde.workspace = true
+serde_json.workspace = true
 settings.workspace = true
 smallvec.workspace = true
 theme.workspace = true

crates/settings_ui/src/settings_ui.rs 🔗

@@ -6,7 +6,7 @@ use std::ops::{Not, Range};
 
 use anyhow::Context as _;
 use command_palette_hooks::CommandPaletteFilter;
-use editor::EditorSettingsControls;
+use editor::{Editor, EditorSettingsControls};
 use feature_flags::{FeatureFlag, FeatureFlagViewExt};
 use gpui::{App, Entity, EventEmitter, FocusHandle, Focusable, ReadGlobal, ScrollHandle, actions};
 use settings::{
@@ -612,6 +612,7 @@ fn render_item_single(
         SettingsUiItemSingle::DropDown { .. } => {
             unimplemented!("This")
         }
+        SettingsUiItemSingle::TextField => render_text_field(settings_value, window, cx),
     }
 }
 
@@ -798,6 +799,54 @@ fn render_switch_field(
     .into_any_element()
 }
 
+fn render_text_field(
+    value: SettingsValue<serde_json::Value>,
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    let value = downcast_any_item::<String>(value);
+    let path = value.path.clone();
+    let editor = window.use_state(cx, {
+        let path = path.clone();
+        move |window, cx| {
+            let mut editor = Editor::single_line(window, cx);
+
+            cx.observe_global_in::<SettingsStore>(window, move |editor, window, cx| {
+                let user_settings = SettingsStore::global(cx).raw_user_settings();
+                if let Some(value) = read_settings_value_from_path(&user_settings, &path).cloned()
+                    && let Some(value) = value.as_str()
+                {
+                    editor.set_text(value, window, cx);
+                }
+            })
+            .detach();
+
+            editor.set_text(value.read().clone(), window, cx);
+            editor
+        }
+    });
+
+    let weak_editor = editor.downgrade();
+    let theme_colors = cx.theme().colors();
+
+    div()
+        .child(editor)
+        .bg(theme_colors.editor_background)
+        .border_1()
+        .rounded_lg()
+        .border_color(theme_colors.border)
+        .on_action::<menu::Confirm>({
+            move |_, _, cx| {
+                let new_value = weak_editor.read_with(cx, |editor, cx| editor.text(cx)).ok();
+
+                if let Some(new_value) = new_value {
+                    SettingsValue::write_value(&path, serde_json::Value::String(new_value), cx);
+                }
+            }
+        })
+        .into_any_element()
+}
+
 fn render_toggle_button_group(
     value: SettingsValue<serde_json::Value>,
     variants: &'static [&'static str],