Skip to main content

fresh/view/settings/
entry_dialog.rs

1//! Entry detail dialog for editing complex map entries
2//!
3//! Provides a modal dialog for editing complex map entries using the same
4//! SettingItem/SettingControl infrastructure as the main settings UI.
5
6use super::items::{
7    build_item_from_value, control_to_value, ItemBoxStyle, SettingControl, SettingItem,
8};
9use super::schema::{SettingSchema, SettingType};
10use crate::view::controls::{FocusState, TextInputState};
11use serde_json::Value;
12use std::collections::HashMap;
13
14/// State for the entry detail dialog
15#[derive(Debug, Clone)]
16pub struct EntryDialogState {
17    /// The entry key (e.g., "rust" for language)
18    pub entry_key: String,
19    /// The map path this entry belongs to (e.g., "/languages", "/lsp")
20    pub map_path: String,
21    /// Human-readable title for the dialog
22    pub title: String,
23    /// Whether this is a new entry (vs editing existing)
24    pub is_new: bool,
25    /// Items in the dialog (using same SettingItem structure as main settings)
26    pub items: Vec<SettingItem>,
27    /// Currently selected item index
28    pub selected_item: usize,
29    /// Sub-focus index within the selected item (for TextList/Map navigation)
30    pub sub_focus: Option<usize>,
31    /// Whether we're in text editing mode
32    pub editing_text: bool,
33    /// Currently focused button (0=Save, 1=Delete, 2=Cancel for existing; 0=Save, 1=Cancel for new)
34    pub focused_button: usize,
35    /// Whether focus is on buttons (true) or items (false)
36    pub focus_on_buttons: bool,
37    /// Whether deletion was requested
38    pub delete_requested: bool,
39    /// Scroll offset for the items area
40    pub scroll_offset: usize,
41    /// Last known viewport height (updated during render)
42    pub viewport_height: usize,
43    /// Hovered item index (for mouse hover feedback)
44    pub hover_item: Option<usize>,
45    /// Hovered button index (for mouse hover feedback)
46    pub hover_button: Option<usize>,
47    /// Original value when dialog was opened (for Cancel to restore)
48    pub original_value: Value,
49    /// Index of first editable item (items before this are read-only)
50    /// Used for rendering separator and focus navigation
51    pub first_editable_index: usize,
52    /// Whether deletion is disabled (for auto-managed entries like plugins)
53    pub no_delete: bool,
54    /// When true, the dialog wraps a single non-Object value (e.g., an ObjectArray).
55    /// `to_value()` returns the raw control value instead of wrapping in an Object.
56    pub is_single_value: bool,
57    /// Set to true on the first user-driven mutation (typed char,
58    /// toggled bool, list add/remove, etc.). Drives the dirty
59    /// indicator + the Esc discard prompt without relying on a
60    /// JSON-equality check that's too noisy at the schema layer.
61    pub user_edited: bool,
62}
63
64impl EntryDialogState {
65    /// Create a dialog from a schema definition
66    ///
67    /// This is the primary, schema-driven constructor. It builds items
68    /// dynamically from the SettingSchema's properties using the same
69    /// build logic as the main settings UI.
70    pub fn from_schema(
71        key: String,
72        value: &Value,
73        schema: &SettingSchema,
74        map_path: &str,
75        is_new: bool,
76        no_delete: bool,
77        available_status_bar_tokens: &HashMap<String, String>,
78    ) -> Self {
79        let mut items = Vec::new();
80
81        // Add key field as first item (read-only for existing entries)
82        let key_item = SettingItem {
83            path: "__key__".to_string(),
84            name: "Key".to_string(),
85            description: Some("unique identifier for this entry".to_string()),
86            control: SettingControl::Text(TextInputState::new("Key").with_value(&key)),
87            default: None,
88            modified: false,
89            layer_source: crate::config_io::ConfigLayer::System,
90            read_only: !is_new, // Key is editable only for new entries
91            is_auto_managed: false,
92            nullable: false,
93            is_null: false,
94            section: None,
95            is_section_start: false,
96            style: ItemBoxStyle::default(),
97            dual_list_sibling: None,
98        };
99        items.push(key_item);
100
101        // Add schema-driven items from object properties
102        let is_single_value = !matches!(&schema.setting_type, SettingType::Object { .. });
103        if let SettingType::Object { properties } = &schema.setting_type {
104            for prop in properties {
105                let field_name = prop.path.trim_start_matches('/');
106                let field_value = value.get(field_name);
107                let item = build_item_from_value(prop, field_value, available_status_bar_tokens);
108                items.push(item);
109            }
110        } else {
111            // For non-object types (e.g., ObjectArray, Map), build a single item
112            // from the entire value so the dialog can render it
113            let item = build_item_from_value(schema, Some(value), available_status_bar_tokens);
114            items.push(item);
115        }
116
117        // Sort items: read-only first, then editable (stable sort preserves x-order)
118        items.sort_by_key(|item| !item.read_only);
119
120        // Compute is_section_start for section headers in entry dialogs
121        Self::compute_section_starts(&mut items);
122
123        // Find the first editable item index
124        let first_editable_index = items
125            .iter()
126            .position(|item| !item.read_only)
127            .unwrap_or(items.len());
128
129        // If all items are read-only, start with focus on buttons
130        let focus_on_buttons = first_editable_index >= items.len();
131        let selected_item = if focus_on_buttons {
132            0
133        } else {
134            first_editable_index
135        };
136
137        let title = if is_new {
138            format!("Add {}", schema.name)
139        } else {
140            format!("Edit {}", schema.name)
141        };
142
143        let mut result = Self {
144            entry_key: key,
145            map_path: map_path.to_string(),
146            title,
147            is_new,
148            items,
149            selected_item,
150            sub_focus: None,
151            editing_text: false,
152            focused_button: 0,
153            focus_on_buttons,
154            delete_requested: false,
155            scroll_offset: 0,
156            viewport_height: 20, // Default, updated during render
157            hover_item: None,
158            hover_button: None,
159            original_value: value.clone(),
160            first_editable_index,
161            no_delete,
162            is_single_value,
163            user_edited: false,
164        };
165        // Pre-focus the first item in any ObjectArray controls so pressing
166        // Enter opens the item editor instead of "Add new".
167        result.init_object_array_focus();
168        result
169    }
170
171    /// Create a dialog for an array item (no key field)
172    ///
173    /// Used for ObjectArray controls where items are identified by index, not key.
174    pub fn for_array_item(
175        index: Option<usize>,
176        value: &Value,
177        schema: &SettingSchema,
178        array_path: &str,
179        is_new: bool,
180        available_status_bar_tokens: &HashMap<String, String>,
181    ) -> Self {
182        let mut items = Vec::new();
183
184        // Add schema-driven items from object properties (no key field for arrays)
185        if let SettingType::Object { properties } = &schema.setting_type {
186            for prop in properties {
187                let field_name = prop.path.trim_start_matches('/');
188                let field_value = value.get(field_name);
189                let item = build_item_from_value(prop, field_value, available_status_bar_tokens);
190                items.push(item);
191            }
192        }
193
194        // Sort items: read-only first, then editable
195        items.sort_by_key(|item| !item.read_only);
196
197        // Compute is_section_start for section headers
198        Self::compute_section_starts(&mut items);
199
200        // Find the first editable item index
201        let first_editable_index = items
202            .iter()
203            .position(|item| !item.read_only)
204            .unwrap_or(items.len());
205
206        // If all items are read-only, start with focus on buttons
207        let focus_on_buttons = first_editable_index >= items.len();
208        let selected_item = if focus_on_buttons {
209            0
210        } else {
211            first_editable_index
212        };
213
214        let title = if is_new {
215            format!("Add {}", schema.name)
216        } else {
217            format!("Edit {}", schema.name)
218        };
219
220        Self {
221            entry_key: index.map_or(String::new(), |i| i.to_string()),
222            map_path: array_path.to_string(),
223            title,
224            is_new,
225            items,
226            selected_item,
227            sub_focus: None,
228            editing_text: false,
229            focused_button: 0,
230            focus_on_buttons,
231            delete_requested: false,
232            scroll_offset: 0,
233            viewport_height: 20,
234            hover_item: None,
235            hover_button: None,
236            original_value: value.clone(),
237            first_editable_index,
238            no_delete: false, // Arrays typically allow deletion
239            is_single_value: false,
240            user_edited: false,
241        }
242    }
243
244    /// Compute is_section_start flags for section headers.
245    /// Marks the first item in each new section so the renderer can draw headers.
246    fn compute_section_starts(items: &mut [SettingItem]) {
247        let mut last_section: Option<&str> = None;
248        for item in items.iter_mut() {
249            let current = item.section.as_deref();
250            if current.is_some() && current != last_section {
251                item.is_section_start = true;
252            }
253            if current.is_some() {
254                last_section = current;
255            }
256        }
257    }
258
259    /// Get the current key value from the key item
260    pub fn get_key(&self) -> String {
261        // Find the key item by path (may not be first after sorting)
262        for item in &self.items {
263            if item.path == "__key__" {
264                if let SettingControl::Text(state) = &item.control {
265                    return state.value.clone();
266                }
267            }
268        }
269        self.entry_key.clone()
270    }
271
272    /// Full JSON pointer path to the entry this dialog edits.
273    ///
274    /// For an existing map entry under `/universal_lsp` with key `quicklsp`,
275    /// this returns `/universal_lsp/quicklsp`. For array items, `entry_key`
276    /// is the stringified index. For brand-new map entries whose key has
277    /// not been chosen yet, this falls back to `map_path` (the parent
278    /// container) — callers are expected to avoid writing at that path.
279    ///
280    /// Nested dialogs and any pending-change paths derived from this dialog
281    /// must be rooted here — not at `map_path` — otherwise the entry key
282    /// segment is dropped and changes land under `""` in the saved config.
283    pub fn entry_path(&self) -> String {
284        // Use the live key field so new entries pick up whatever the user
285        // has typed before opening a nested dialog. For existing entries
286        // the key field is read-only and equals `entry_key`, so this is
287        // consistent with the on-disk path.
288        let key = self.get_key();
289        if key.is_empty() {
290            self.map_path.clone()
291        } else {
292            format!("{}/{}", self.map_path, key)
293        }
294    }
295
296    /// Get button count (3 for existing entries with Delete, 2 for new/no_delete entries)
297    pub fn button_count(&self) -> usize {
298        if self.is_new || self.no_delete {
299            2 // Save, Cancel (no Delete for new entries or when no_delete is set)
300        } else {
301            3
302        }
303    }
304
305    /// True when the user has made *any* change to the dialog since
306    /// it was opened. Tracked as an explicit flag (`user_edited`)
307    /// rather than comparing `to_value() != original_value`, because
308    /// the rebuilt JSON shape can differ from the input shape by
309    /// schema-default normalization (e.g. an absent optional field
310    /// rebuilds as an explicit empty string) — which would make the
311    /// dialog read as dirty at open, with no user input.
312    ///
313    /// Used to gate the Esc 'Discard changes?' prompt and to drive
314    /// the title-bar modified indicator.
315    pub fn is_dirty(&self) -> bool {
316        self.user_edited
317    }
318
319    /// Mark the dialog as edited. Called from every mutator path
320    /// (insert_char, toggle_bool, list add/remove, etc.) — anywhere
321    /// the user can produce a change the dialog should remember.
322    pub fn mark_edited(&mut self) {
323        self.user_edited = true;
324    }
325
326    /// Convert dialog state back to JSON value (excludes the __key__ item)
327    /// Auto-commit any draft text sitting in a TextList's trailing
328    /// `[+] Add new` slot. Without this, saving a dialog while the user
329    /// has typed (but not pressed Enter or ↓) into the new-item row
330    /// silently drops that text — the diverging commit semantics
331    /// between text fields ("typed value is just there") and list rows
332    /// ("typing isn't enough — you must commit") was the F21 surprise.
333    /// Run this from every save path so the saved value matches what
334    /// the user sees on screen.
335    pub fn commit_pending_list_drafts(&mut self) {
336        for item in &mut self.items {
337            if let SettingControl::TextList(state) = &mut item.control {
338                if !state.new_item_text.is_empty() {
339                    state.add_item();
340                }
341            }
342        }
343    }
344
345    pub fn to_value(&self) -> Value {
346        // For single-value dialogs (non-Object schemas like ObjectArray),
347        // return the control's value directly instead of wrapping in an Object.
348        if self.is_single_value {
349            for item in &self.items {
350                if item.path != "__key__" {
351                    return control_to_value(&item.control);
352                }
353            }
354        }
355
356        let mut obj = serde_json::Map::new();
357
358        for item in &self.items {
359            // Skip the special key item - it's stored separately
360            if item.path == "__key__" {
361                continue;
362            }
363
364            let field_name = item.path.trim_start_matches('/');
365            let value = control_to_value(&item.control);
366            obj.insert(field_name.to_string(), value);
367        }
368
369        Value::Object(obj)
370    }
371
372    /// Get currently selected item
373    pub fn current_item(&self) -> Option<&SettingItem> {
374        if self.focus_on_buttons {
375            None
376        } else {
377            self.items.get(self.selected_item)
378        }
379    }
380
381    /// Get currently selected item mutably
382    pub fn current_item_mut(&mut self) -> Option<&mut SettingItem> {
383        if self.focus_on_buttons {
384            None
385        } else {
386            self.items.get_mut(self.selected_item)
387        }
388    }
389
390    /// Move focus to next editable item, navigating within composite controls first.
391    ///
392    /// For composite controls (Map, ObjectArray, TextList), Down first navigates
393    /// through their internal entries and [+] Add new row before moving to the
394    /// next dialog item. When at the last editable item, wraps to buttons.
395    /// When on the last button, wraps back to the first editable item.
396    pub fn focus_next(&mut self) {
397        if self.editing_text {
398            return;
399        }
400
401        if self.focus_on_buttons {
402            if self.focused_button + 1 < self.button_count() {
403                self.focused_button += 1;
404            } else {
405                // Wrap to first editable item
406                if self.first_editable_index < self.items.len() {
407                    self.focus_on_buttons = false;
408                    self.selected_item = self.first_editable_index;
409                    self.sub_focus = None;
410                    self.init_composite_focus(true);
411                }
412            }
413        } else {
414            // Try navigating within a composite control first
415            let handled = self.try_composite_focus_next();
416            if !handled {
417                // Composite is at its exit boundary (or not a composite) — advance to next item
418                if self.selected_item + 1 < self.items.len() {
419                    self.selected_item += 1;
420                    self.sub_focus = None;
421                    self.init_composite_focus(true);
422                } else {
423                    // Past last item, go to buttons
424                    self.focus_on_buttons = true;
425                    self.focused_button = 0;
426                }
427            }
428        }
429
430        self.update_focus_states();
431        self.ensure_selected_visible(self.viewport_height);
432    }
433
434    /// Move focus to previous editable item, navigating within composite controls first.
435    ///
436    /// For composite controls, Up first navigates backwards through their internal
437    /// entries before moving to the previous dialog item. When at the first editable
438    /// item, wraps to buttons. When on the first button, wraps back to the last item.
439    pub fn focus_prev(&mut self) {
440        if self.editing_text {
441            return;
442        }
443
444        if self.focus_on_buttons {
445            if self.focused_button > 0 {
446                self.focused_button -= 1;
447            } else {
448                // Wrap to last editable item
449                if self.first_editable_index < self.items.len() {
450                    self.focus_on_buttons = false;
451                    self.selected_item = self.items.len().saturating_sub(1);
452                    self.sub_focus = None;
453                    self.init_composite_focus(false);
454                }
455            }
456        } else {
457            // Try navigating within a composite control first
458            let handled = self.try_composite_focus_prev();
459            if !handled {
460                // Composite is at its entry boundary (or not a composite) — go to previous item
461                if self.selected_item > self.first_editable_index {
462                    self.selected_item -= 1;
463                    self.sub_focus = None;
464                    self.init_composite_focus(false);
465                } else {
466                    // Before first editable item, go to buttons
467                    self.focus_on_buttons = true;
468                    self.focused_button = self.button_count().saturating_sub(1);
469                }
470            }
471        }
472
473        self.update_focus_states();
474        self.ensure_selected_visible(self.viewport_height);
475    }
476
477    /// Try to navigate forward within the current composite control.
478    /// Returns true if the navigation was handled internally, false if at the exit boundary.
479    fn try_composite_focus_next(&mut self) -> bool {
480        let item = match self.items.get(self.selected_item) {
481            Some(item) => item,
482            None => return false,
483        };
484        match &item.control {
485            SettingControl::Map(state) => {
486                // Map returns bool: true = handled internally, false = at boundary
487                let at_boundary = state.focused_entry.is_none(); // On add-new → exit
488                if at_boundary {
489                    return false;
490                }
491                if let Some(item) = self.items.get_mut(self.selected_item) {
492                    if let SettingControl::Map(state) = &mut item.control {
493                        return state.focus_next();
494                    }
495                }
496                false
497            }
498            SettingControl::ObjectArray(state) => {
499                // ObjectArray: None = on add-new → exit
500                if state.focused_index.is_none() {
501                    return false;
502                }
503                if let Some(item) = self.items.get_mut(self.selected_item) {
504                    if let SettingControl::ObjectArray(state) = &mut item.control {
505                        state.focus_next();
506                        return true;
507                    }
508                }
509                false
510            }
511            SettingControl::TextList(state) => {
512                // TextList: None = on add-new → exit
513                if state.focused_item.is_none() {
514                    return false;
515                }
516                if let Some(item) = self.items.get_mut(self.selected_item) {
517                    if let SettingControl::TextList(state) = &mut item.control {
518                        state.focus_next();
519                        return true;
520                    }
521                }
522                false
523            }
524            _ => false,
525        }
526    }
527
528    /// Try to navigate backward within the current composite control.
529    /// Returns true if the navigation was handled internally, false if at the entry boundary.
530    fn try_composite_focus_prev(&mut self) -> bool {
531        let item = match self.items.get(self.selected_item) {
532            Some(item) => item,
533            None => return false,
534        };
535        match &item.control {
536            SettingControl::Map(state) => {
537                // Map: Some(0) = at first entry → exit
538                let at_boundary = matches!(state.focused_entry, Some(0))
539                    || (state.focused_entry.is_none() && state.entries.is_empty());
540                if at_boundary {
541                    return false;
542                }
543                if let Some(item) = self.items.get_mut(self.selected_item) {
544                    if let SettingControl::Map(state) = &mut item.control {
545                        return state.focus_prev();
546                    }
547                }
548                false
549            }
550            SettingControl::ObjectArray(state) => {
551                // ObjectArray: Some(0) = at first entry → exit
552                if matches!(state.focused_index, Some(0))
553                    || (state.focused_index.is_none() && state.bindings.is_empty())
554                {
555                    return false;
556                }
557                if let Some(item) = self.items.get_mut(self.selected_item) {
558                    if let SettingControl::ObjectArray(state) = &mut item.control {
559                        state.focus_prev();
560                        return true;
561                    }
562                }
563                false
564            }
565            SettingControl::TextList(state) => {
566                // TextList: Some(0) = at first item → exit
567                if matches!(state.focused_item, Some(0))
568                    || (state.focused_item.is_none() && state.items.is_empty())
569                {
570                    return false;
571                }
572                if let Some(item) = self.items.get_mut(self.selected_item) {
573                    if let SettingControl::TextList(state) = &mut item.control {
574                        state.focus_prev();
575                        return true;
576                    }
577                }
578                false
579            }
580            _ => false,
581        }
582    }
583
584    /// Initialize a composite control's focus when entering it.
585    /// `from_above`: true = entering from the item above (start at first entry),
586    ///               false = entering from below (start at add-new / last entry).
587    fn init_composite_focus(&mut self, from_above: bool) {
588        if let Some(item) = self.items.get_mut(self.selected_item) {
589            match &mut item.control {
590                SettingControl::Map(state) => {
591                    state.init_focus(from_above);
592                }
593                SettingControl::ObjectArray(state) => {
594                    if from_above {
595                        state.focused_index = if state.bindings.is_empty() {
596                            None
597                        } else {
598                            Some(0)
599                        };
600                    } else {
601                        // Coming from below: start at add-new
602                        state.focused_index = None;
603                    }
604                }
605                SettingControl::TextList(state) => {
606                    if from_above {
607                        state.focused_item = if state.items.is_empty() {
608                            None
609                        } else {
610                            Some(0)
611                        };
612                    } else {
613                        // Coming from below: start at add-new
614                        state.focused_item = None;
615                    }
616                }
617                _ => {}
618            }
619        }
620    }
621
622    /// Toggle focus between items region and buttons region.
623    /// Used by Tab key to provide region-level navigation.
624    pub fn toggle_focus_region(&mut self) {
625        self.toggle_focus_region_direction(true);
626    }
627
628    /// Toggle between items and buttons regions.
629    /// When in buttons region, Tab cycles through buttons before returning to items.
630    /// `forward` controls direction: true = Tab, false = Shift+Tab.
631    pub fn toggle_focus_region_direction(&mut self, forward: bool) {
632        if self.editing_text {
633            return;
634        }
635
636        if self.focus_on_buttons {
637            if forward {
638                // Tab forward through buttons, then back to items
639                if self.focused_button + 1 < self.button_count() {
640                    self.focused_button += 1;
641                } else {
642                    // Past last button — return to items
643                    if self.first_editable_index < self.items.len() {
644                        self.focus_on_buttons = false;
645                        if self.selected_item < self.first_editable_index {
646                            self.selected_item = self.first_editable_index;
647                        }
648                    } else {
649                        // All items read-only, wrap to first button
650                        self.focused_button = 0;
651                    }
652                }
653            } else {
654                // Shift+Tab backward through buttons, then back to items
655                if self.focused_button > 0 {
656                    self.focused_button -= 1;
657                } else {
658                    // Before first button — return to items
659                    if self.first_editable_index < self.items.len() {
660                        self.focus_on_buttons = false;
661                        if self.selected_item < self.first_editable_index {
662                            self.selected_item = self.first_editable_index;
663                        }
664                    } else {
665                        // All items read-only, wrap to last button
666                        self.focused_button = self.button_count().saturating_sub(1);
667                    }
668                }
669            }
670        } else {
671            // Move to buttons
672            self.focus_on_buttons = true;
673            self.focused_button = if forward {
674                0
675            } else {
676                self.button_count().saturating_sub(1)
677            };
678        }
679
680        self.update_focus_states();
681        self.ensure_selected_visible(self.viewport_height);
682    }
683
684    /// Initialize composite control focus for the selected item (when dialog opens)
685    fn init_object_array_focus(&mut self) {
686        self.init_composite_focus(true);
687    }
688
689    /// Update focus states for all items
690    pub fn update_focus_states(&mut self) {
691        for (idx, item) in self.items.iter_mut().enumerate() {
692            let state = if !self.focus_on_buttons && idx == self.selected_item {
693                FocusState::Focused
694            } else {
695                FocusState::Normal
696            };
697
698            match &mut item.control {
699                SettingControl::Toggle(s) => s.focus = state,
700                SettingControl::Number(s) => s.focus = state,
701                SettingControl::Dropdown(s) => s.focus = state,
702                SettingControl::Text(s) => s.focus = state,
703                SettingControl::TextList(s) => s.focus = state,
704                SettingControl::DualList(s) => s.focus = state,
705                SettingControl::Map(s) => s.focus = state,
706                SettingControl::ObjectArray(s) => s.focus = state,
707                SettingControl::Json(s) => s.focus = state,
708                SettingControl::Complex { .. } => {}
709            }
710        }
711    }
712
713    /// Height of a section header (label + blank line)
714    const SECTION_HEADER_HEIGHT: usize = 2;
715
716    /// Calculate total content height for all items (including separator and section headers)
717    pub fn total_content_height(&self) -> usize {
718        let items_height: usize = self
719            .items
720            .iter()
721            .map(|item| {
722                let section_h = if item.is_section_start {
723                    Self::SECTION_HEADER_HEIGHT
724                } else {
725                    0
726                };
727                item.control.control_height() as usize + section_h
728            })
729            .sum();
730        // Add 1 for separator if we have both read-only and editable items
731        let separator_height =
732            if self.first_editable_index > 0 && self.first_editable_index < self.items.len() {
733                1
734            } else {
735                0
736            };
737        items_height + separator_height
738    }
739
740    /// Calculate the Y offset of the selected item (including separator and section headers)
741    pub fn selected_item_offset(&self) -> usize {
742        let items_offset: usize = self
743            .items
744            .iter()
745            .take(self.selected_item)
746            .map(|item| {
747                let section_h = if item.is_section_start {
748                    Self::SECTION_HEADER_HEIGHT
749                } else {
750                    0
751                };
752                item.control.control_height() as usize + section_h
753            })
754            .sum();
755        // Add 1 for separator if selected item is after it
756        let separator_offset = if self.first_editable_index > 0
757            && self.first_editable_index < self.items.len()
758            && self.selected_item >= self.first_editable_index
759        {
760            1
761        } else {
762            0
763        };
764        // Add section header height if the selected item itself starts a section
765        let own_section_h = self
766            .items
767            .get(self.selected_item)
768            .map(|item| {
769                if item.is_section_start {
770                    Self::SECTION_HEADER_HEIGHT
771                } else {
772                    0
773                }
774            })
775            .unwrap_or(0);
776        items_offset + separator_offset + own_section_h
777    }
778
779    /// Calculate the height of the selected item
780    pub fn selected_item_height(&self) -> usize {
781        self.items
782            .get(self.selected_item)
783            .map(|item| item.control.control_height() as usize)
784            .unwrap_or(1)
785    }
786
787    /// Ensure the selected item is visible within the viewport
788    pub fn ensure_selected_visible(&mut self, viewport_height: usize) {
789        if self.focus_on_buttons {
790            // Scroll to bottom when buttons are focused
791            let total = self.total_content_height();
792            if total > viewport_height {
793                self.scroll_offset = total.saturating_sub(viewport_height);
794            }
795            return;
796        }
797
798        let item_start = self.selected_item_offset();
799        let item_end = item_start + self.selected_item_height();
800
801        // If item starts before viewport, scroll up
802        if item_start < self.scroll_offset {
803            self.scroll_offset = item_start;
804        }
805        // If item ends after viewport, scroll down
806        else if item_end > self.scroll_offset + viewport_height {
807            self.scroll_offset = item_end.saturating_sub(viewport_height);
808        }
809    }
810
811    /// Ensure the cursor within a JSON editor is visible
812    ///
813    /// When editing a multiline JSON control, this adjusts scroll_offset
814    /// to keep the cursor row visible within the viewport.
815    pub fn ensure_cursor_visible(&mut self) {
816        if !self.editing_text || self.focus_on_buttons {
817            return;
818        }
819
820        // Get cursor row from current item (if it's a JSON editor)
821        let cursor_row = if let Some(item) = self.items.get(self.selected_item) {
822            if let SettingControl::Json(state) = &item.control {
823                state.cursor_pos().0
824            } else {
825                return; // Not a JSON editor
826            }
827        } else {
828            return;
829        };
830
831        // Calculate absolute position of cursor row in content:
832        // item_offset + 1 (for label row) + cursor_row
833        let item_offset = self.selected_item_offset();
834        let cursor_content_row = item_offset + 1 + cursor_row;
835
836        let viewport_height = self.viewport_height;
837
838        // If cursor is above viewport, scroll up
839        if cursor_content_row < self.scroll_offset {
840            self.scroll_offset = cursor_content_row;
841        }
842        // If cursor is below viewport, scroll down
843        else if cursor_content_row >= self.scroll_offset + viewport_height {
844            self.scroll_offset = cursor_content_row.saturating_sub(viewport_height) + 1;
845        }
846    }
847
848    /// Scroll up by one line
849    pub fn scroll_up(&mut self) {
850        self.scroll_offset = self.scroll_offset.saturating_sub(1);
851    }
852
853    /// Scroll down by one line
854    pub fn scroll_down(&mut self, viewport_height: usize) {
855        let max_scroll = self.total_content_height().saturating_sub(viewport_height);
856        if self.scroll_offset < max_scroll {
857            self.scroll_offset += 1;
858        }
859    }
860
861    /// Scroll to a position based on ratio (0.0 = top, 1.0 = bottom)
862    ///
863    /// Used for scrollbar drag operations.
864    pub fn scroll_to_ratio(&mut self, ratio: f32) {
865        let max_scroll = self
866            .total_content_height()
867            .saturating_sub(self.viewport_height);
868        let new_offset = (ratio * max_scroll as f32).round() as usize;
869        self.scroll_offset = new_offset.min(max_scroll);
870    }
871
872    /// Start text editing mode for the current control
873    pub fn start_editing(&mut self) {
874        if let Some(item) = self.current_item_mut() {
875            // Don't allow editing read-only fields
876            if item.read_only {
877                return;
878            }
879            match &mut item.control {
880                SettingControl::Text(state) => {
881                    state.cursor = state.value.len();
882                    state.editing = true;
883                    self.editing_text = true;
884                }
885                SettingControl::TextList(state) => {
886                    // If focused on a committed item, leave focus there
887                    // and just flip into edit mode. Otherwise (focus on
888                    // the trailing `[+] Add new` slot), explicitly
889                    // activate input mode so the row morphs from
890                    // `[+] Add new` into the bracketed input box.
891                    if state.focused_item.is_none() {
892                        state.activate_pending();
893                    }
894                    self.editing_text = true;
895                }
896                SettingControl::Number(state) => {
897                    state.start_editing();
898                    self.editing_text = true;
899                }
900                SettingControl::Json(state) => {
901                    // Wipe the `null` placeholder so typing replaces it
902                    // instead of concatenating onto the literal text.
903                    state.clear_placeholder_for_edit();
904                    self.editing_text = true;
905                }
906                _ => {}
907            }
908        }
909    }
910
911    /// Stop text editing mode
912    pub fn stop_editing(&mut self) {
913        if let Some(item) = self.current_item_mut() {
914            match &mut item.control {
915                SettingControl::Number(state) => state.cancel_editing(),
916                SettingControl::Text(state) => state.editing = false,
917                // Cancelling on a pending list row (the trailing
918                // [+] add-new slot) discards whatever the user typed
919                // and collapses the row back to `[+] Add new`. Without
920                // this, Esc was a silent no-op that left the draft
921                // text dangling until the user committed or cleared it
922                // manually.
923                SettingControl::TextList(state) if state.focused_item.is_none() => {
924                    state.cancel_pending();
925                }
926                // If the user opened a JSON field but didn't type
927                // anything (or deleted everything), put the `null`
928                // sentinel back so the value still round-trips as JSON.
929                SettingControl::Json(state) => state.restore_unset_if_empty(),
930                _ => {}
931            }
932        }
933        self.editing_text = false;
934    }
935
936    /// Handle character input
937    pub fn insert_char(&mut self, c: char) {
938        if !self.editing_text {
939            return;
940        }
941        self.user_edited = true;
942        if let Some(item) = self.current_item_mut() {
943            match &mut item.control {
944                SettingControl::Text(state) => {
945                    state.insert(c);
946                }
947                SettingControl::TextList(state) => {
948                    state.insert(c);
949                }
950                SettingControl::Number(state) => {
951                    state.insert_char(c);
952                }
953                SettingControl::Json(state) => {
954                    state.insert(c);
955                }
956                _ => {}
957            }
958        }
959    }
960
961    pub fn insert_str(&mut self, s: &str) {
962        if !self.editing_text {
963            return;
964        }
965        self.user_edited = true;
966        if let Some(item) = self.current_item_mut() {
967            match &mut item.control {
968                SettingControl::Text(state) => {
969                    state.insert_str(s);
970                }
971                SettingControl::TextList(state) => {
972                    state.insert_str(s);
973                }
974                SettingControl::Number(state) => {
975                    for c in s.chars() {
976                        state.insert_char(c);
977                    }
978                }
979                SettingControl::Json(state) => {
980                    state.insert_str(s);
981                }
982                _ => {}
983            }
984        }
985    }
986
987    /// Handle backspace
988    pub fn backspace(&mut self) {
989        if !self.editing_text {
990            return;
991        }
992        self.user_edited = true;
993        if let Some(item) = self.current_item_mut() {
994            match &mut item.control {
995                SettingControl::Text(state) => {
996                    state.backspace();
997                }
998                SettingControl::TextList(state) => {
999                    state.backspace();
1000                }
1001                SettingControl::Number(state) => {
1002                    state.backspace();
1003                }
1004                SettingControl::Json(state) => {
1005                    state.backspace();
1006                }
1007                _ => {}
1008            }
1009        }
1010    }
1011
1012    /// Handle cursor left
1013    pub fn cursor_left(&mut self) {
1014        if !self.editing_text {
1015            return;
1016        }
1017        if let Some(item) = self.current_item_mut() {
1018            match &mut item.control {
1019                SettingControl::Text(state) => {
1020                    state.move_left();
1021                }
1022                SettingControl::TextList(state) => {
1023                    state.move_left();
1024                }
1025                SettingControl::Json(state) => {
1026                    state.move_left();
1027                }
1028                _ => {}
1029            }
1030        }
1031    }
1032
1033    /// Handle cursor left with selection (Shift+Left)
1034    pub fn cursor_left_selecting(&mut self) {
1035        if !self.editing_text {
1036            return;
1037        }
1038        if let Some(item) = self.current_item_mut() {
1039            if let SettingControl::Json(state) = &mut item.control {
1040                state.editor.move_left_selecting();
1041            }
1042        }
1043    }
1044
1045    /// Handle cursor right
1046    pub fn cursor_right(&mut self) {
1047        if !self.editing_text {
1048            return;
1049        }
1050        if let Some(item) = self.current_item_mut() {
1051            match &mut item.control {
1052                SettingControl::Text(state) => {
1053                    state.move_right();
1054                }
1055                SettingControl::TextList(state) => {
1056                    state.move_right();
1057                }
1058                SettingControl::Json(state) => {
1059                    state.move_right();
1060                }
1061                _ => {}
1062            }
1063        }
1064    }
1065
1066    /// Handle cursor right with selection (Shift+Right)
1067    pub fn cursor_right_selecting(&mut self) {
1068        if !self.editing_text {
1069            return;
1070        }
1071        if let Some(item) = self.current_item_mut() {
1072            if let SettingControl::Json(state) = &mut item.control {
1073                state.editor.move_right_selecting();
1074            }
1075        }
1076    }
1077
1078    /// Handle cursor up (for multiline controls)
1079    pub fn cursor_up(&mut self) {
1080        if !self.editing_text {
1081            return;
1082        }
1083        if let Some(item) = self.current_item_mut() {
1084            if let SettingControl::Json(state) = &mut item.control {
1085                state.move_up();
1086            }
1087        }
1088        self.ensure_cursor_visible();
1089    }
1090
1091    /// Handle cursor up with selection (Shift+Up)
1092    pub fn cursor_up_selecting(&mut self) {
1093        if !self.editing_text {
1094            return;
1095        }
1096        if let Some(item) = self.current_item_mut() {
1097            if let SettingControl::Json(state) = &mut item.control {
1098                state.editor.move_up_selecting();
1099            }
1100        }
1101        self.ensure_cursor_visible();
1102    }
1103
1104    /// Handle cursor down (for multiline controls)
1105    pub fn cursor_down(&mut self) {
1106        if !self.editing_text {
1107            return;
1108        }
1109        if let Some(item) = self.current_item_mut() {
1110            if let SettingControl::Json(state) = &mut item.control {
1111                state.move_down();
1112            }
1113        }
1114        self.ensure_cursor_visible();
1115    }
1116
1117    /// Handle cursor down with selection (Shift+Down)
1118    pub fn cursor_down_selecting(&mut self) {
1119        if !self.editing_text {
1120            return;
1121        }
1122        if let Some(item) = self.current_item_mut() {
1123            if let SettingControl::Json(state) = &mut item.control {
1124                state.editor.move_down_selecting();
1125            }
1126        }
1127        self.ensure_cursor_visible();
1128    }
1129
1130    /// Insert newline in JSON editor
1131    pub fn insert_newline(&mut self) {
1132        self.user_edited = true;
1133        if !self.editing_text {
1134            return;
1135        }
1136        if let Some(item) = self.current_item_mut() {
1137            if let SettingControl::Json(state) = &mut item.control {
1138                state.insert('\n');
1139            }
1140        }
1141    }
1142
1143    /// Revert JSON changes to original and stop editing
1144    pub fn revert_json_and_stop(&mut self) {
1145        if let Some(item) = self.current_item_mut() {
1146            if let SettingControl::Json(state) = &mut item.control {
1147                state.revert();
1148            }
1149        }
1150        self.editing_text = false;
1151    }
1152
1153    /// Check if current control is a JSON editor
1154    pub fn is_editing_json(&self) -> bool {
1155        if !self.editing_text {
1156            return false;
1157        }
1158        self.current_item()
1159            .map(|item| matches!(&item.control, SettingControl::Json(_)))
1160            .unwrap_or(false)
1161    }
1162
1163    /// Toggle boolean value
1164    pub fn toggle_bool(&mut self) {
1165        self.user_edited = true;
1166        if let Some(item) = self.current_item_mut() {
1167            // Don't allow toggling read-only fields
1168            if item.read_only {
1169                return;
1170            }
1171            if let SettingControl::Toggle(state) = &mut item.control {
1172                state.checked = !state.checked;
1173            }
1174        }
1175    }
1176
1177    /// Toggle dropdown open state
1178    pub fn toggle_dropdown(&mut self) {
1179        if let Some(item) = self.current_item_mut() {
1180            // Don't allow editing read-only fields
1181            if item.read_only {
1182                return;
1183            }
1184            if let SettingControl::Dropdown(state) = &mut item.control {
1185                state.open = !state.open;
1186            }
1187        }
1188    }
1189
1190    /// Move dropdown selection up
1191    pub fn dropdown_prev(&mut self) {
1192        self.user_edited = true;
1193        if let Some(item) = self.current_item_mut() {
1194            if let SettingControl::Dropdown(state) = &mut item.control {
1195                if state.open {
1196                    state.select_prev();
1197                }
1198            }
1199        }
1200    }
1201
1202    /// Move dropdown selection down
1203    pub fn dropdown_next(&mut self) {
1204        self.user_edited = true;
1205        if let Some(item) = self.current_item_mut() {
1206            if let SettingControl::Dropdown(state) = &mut item.control {
1207                if state.open {
1208                    state.select_next();
1209                }
1210            }
1211        }
1212    }
1213
1214    /// Confirm dropdown selection
1215    pub fn dropdown_confirm(&mut self) {
1216        if let Some(item) = self.current_item_mut() {
1217            if let SettingControl::Dropdown(state) = &mut item.control {
1218                state.open = false;
1219            }
1220        }
1221    }
1222
1223    /// Delete the currently focused item from a TextList control
1224    pub fn delete_list_item(&mut self) {
1225        self.user_edited = true;
1226        if let Some(item) = self.current_item_mut() {
1227            if let SettingControl::TextList(state) = &mut item.control {
1228                // Remove the currently focused item if any
1229                if let Some(idx) = state.focused_item {
1230                    state.remove_item(idx);
1231                }
1232            }
1233        }
1234    }
1235
1236    /// Delete character at cursor (forward delete)
1237    pub fn delete(&mut self) {
1238        if !self.editing_text {
1239            return;
1240        }
1241        self.user_edited = true;
1242        if let Some(item) = self.current_item_mut() {
1243            match &mut item.control {
1244                SettingControl::Text(state) => {
1245                    state.delete();
1246                }
1247                SettingControl::TextList(state) => {
1248                    state.delete();
1249                }
1250                SettingControl::Json(state) => {
1251                    state.delete();
1252                }
1253                _ => {}
1254            }
1255        }
1256    }
1257
1258    /// Move cursor to beginning of line
1259    pub fn cursor_home(&mut self) {
1260        if !self.editing_text {
1261            return;
1262        }
1263        if let Some(item) = self.current_item_mut() {
1264            match &mut item.control {
1265                SettingControl::Text(state) => {
1266                    state.move_home();
1267                }
1268                SettingControl::TextList(state) => {
1269                    state.move_home();
1270                }
1271                SettingControl::Json(state) => {
1272                    state.move_home();
1273                }
1274                _ => {}
1275            }
1276        }
1277    }
1278
1279    /// Move cursor to end of line
1280    pub fn cursor_end(&mut self) {
1281        if !self.editing_text {
1282            return;
1283        }
1284        if let Some(item) = self.current_item_mut() {
1285            match &mut item.control {
1286                SettingControl::Text(state) => {
1287                    state.move_end();
1288                }
1289                SettingControl::TextList(state) => {
1290                    state.move_end();
1291                }
1292                SettingControl::Json(state) => {
1293                    state.move_end();
1294                }
1295                _ => {}
1296            }
1297        }
1298    }
1299
1300    /// Select all text in current control
1301    pub fn select_all(&mut self) {
1302        if !self.editing_text {
1303            return;
1304        }
1305        if let Some(item) = self.current_item_mut() {
1306            if let SettingControl::Json(state) = &mut item.control {
1307                state.select_all();
1308            }
1309            // Note: Text and TextList don't have select_all implemented
1310        }
1311    }
1312
1313    /// Get selected text from current JSON control
1314    pub fn selected_text(&self) -> Option<String> {
1315        if !self.editing_text {
1316            return None;
1317        }
1318        if let Some(item) = self.current_item() {
1319            if let SettingControl::Json(state) = &item.control {
1320                return state.selected_text();
1321            }
1322        }
1323        None
1324    }
1325
1326    /// Check if any field is currently in edit mode
1327    pub fn is_editing(&self) -> bool {
1328        self.editing_text
1329            || self
1330                .current_item()
1331                .map(|item| {
1332                    matches!(
1333                        &item.control,
1334                        SettingControl::Dropdown(s) if s.open
1335                    )
1336                })
1337                .unwrap_or(false)
1338    }
1339}
1340
1341#[cfg(test)]
1342mod tests {
1343    use super::*;
1344
1345    fn create_test_schema() -> SettingSchema {
1346        SettingSchema {
1347            path: "/test".to_string(),
1348            name: "Test".to_string(),
1349            description: Some("Test schema".to_string()),
1350            setting_type: SettingType::Object {
1351                properties: vec![
1352                    SettingSchema {
1353                        path: "/enabled".to_string(),
1354                        name: "Enabled".to_string(),
1355                        description: Some("Enable this".to_string()),
1356                        setting_type: SettingType::Boolean,
1357                        default: Some(serde_json::json!(true)),
1358                        read_only: false,
1359                        section: None,
1360                        order: None,
1361                        nullable: false,
1362                        enum_from: None,
1363                        dual_list_sibling: None,
1364                        dynamically_extendable_status_bar_elements: false,
1365                    },
1366                    SettingSchema {
1367                        path: "/command".to_string(),
1368                        name: "Command".to_string(),
1369                        description: Some("Command to run".to_string()),
1370                        setting_type: SettingType::String,
1371                        default: Some(serde_json::json!("")),
1372                        read_only: false,
1373                        section: None,
1374                        order: None,
1375                        nullable: false,
1376                        enum_from: None,
1377                        dual_list_sibling: None,
1378                        dynamically_extendable_status_bar_elements: false,
1379                    },
1380                ],
1381            },
1382            default: None,
1383            read_only: false,
1384            section: None,
1385            order: None,
1386            nullable: false,
1387            enum_from: None,
1388            dual_list_sibling: None,
1389            dynamically_extendable_status_bar_elements: false,
1390        }
1391    }
1392
1393    #[test]
1394    fn from_schema_creates_key_item_first() {
1395        let schema = create_test_schema();
1396        let dialog = EntryDialogState::from_schema(
1397            "test".to_string(),
1398            &serde_json::json!({}),
1399            &schema,
1400            "/test",
1401            false,
1402            false,
1403            &HashMap::new(),
1404        );
1405
1406        assert!(!dialog.items.is_empty());
1407        assert_eq!(dialog.items[0].path, "__key__");
1408        assert_eq!(dialog.items[0].name, "Key");
1409    }
1410
1411    #[test]
1412    fn from_schema_creates_items_from_properties() {
1413        let schema = create_test_schema();
1414        let dialog = EntryDialogState::from_schema(
1415            "test".to_string(),
1416            &serde_json::json!({"enabled": true, "command": "test-cmd"}),
1417            &schema,
1418            "/test",
1419            false,
1420            false,
1421            &HashMap::new(),
1422        );
1423
1424        // Key + 2 properties = 3 items
1425        assert_eq!(dialog.items.len(), 3);
1426        assert_eq!(dialog.items[1].name, "Enabled");
1427        assert_eq!(dialog.items[2].name, "Command");
1428    }
1429
1430    #[test]
1431    fn get_key_returns_key_value() {
1432        let schema = create_test_schema();
1433        let dialog = EntryDialogState::from_schema(
1434            "mykey".to_string(),
1435            &serde_json::json!({}),
1436            &schema,
1437            "/test",
1438            false,
1439            false,
1440            &HashMap::new(),
1441        );
1442
1443        assert_eq!(dialog.get_key(), "mykey");
1444    }
1445
1446    #[test]
1447    fn to_value_excludes_key() {
1448        let schema = create_test_schema();
1449        let dialog = EntryDialogState::from_schema(
1450            "test".to_string(),
1451            &serde_json::json!({"enabled": true, "command": "cmd"}),
1452            &schema,
1453            "/test",
1454            false,
1455            false,
1456            &HashMap::new(),
1457        );
1458
1459        let value = dialog.to_value();
1460        assert!(value.get("__key__").is_none());
1461        assert!(value.get("enabled").is_some());
1462    }
1463
1464    #[test]
1465    fn focus_navigation_works() {
1466        let schema = create_test_schema();
1467        let mut dialog = EntryDialogState::from_schema(
1468            "test".to_string(),
1469            &serde_json::json!({}),
1470            &schema,
1471            "/test",
1472            false, // existing entry - Key is read-only
1473            false, // allow delete
1474            &HashMap::new(),
1475        );
1476
1477        // With is_new=false, Key is read-only and sorted first
1478        // Items: [Key (read-only), Enabled, Command]
1479        // Focus starts at first editable item (index 1)
1480        assert_eq!(dialog.first_editable_index, 1);
1481        assert_eq!(dialog.selected_item, 1); // First editable (Enabled)
1482        assert!(!dialog.focus_on_buttons);
1483
1484        dialog.focus_next();
1485        assert_eq!(dialog.selected_item, 2); // Command
1486
1487        dialog.focus_next();
1488        assert!(dialog.focus_on_buttons); // No more editable items
1489        assert_eq!(dialog.focused_button, 0);
1490
1491        // Going back should skip read-only Key
1492        dialog.focus_prev();
1493        assert!(!dialog.focus_on_buttons);
1494        assert_eq!(dialog.selected_item, 2); // Last editable (Command)
1495
1496        dialog.focus_prev();
1497        assert_eq!(dialog.selected_item, 1); // First editable (Enabled)
1498
1499        dialog.focus_prev();
1500        assert!(dialog.focus_on_buttons); // Wraps to buttons, not to read-only Key
1501    }
1502
1503    #[test]
1504    fn entry_path_joins_map_path_and_entry_key() {
1505        let schema = create_test_schema();
1506
1507        // Existing entry: full path is map_path + "/" + entry_key
1508        let existing = EntryDialogState::from_schema(
1509            "rust".to_string(),
1510            &serde_json::json!({}),
1511            &schema,
1512            "/lsp",
1513            false,
1514            false,
1515            &HashMap::new(),
1516        );
1517        assert_eq!(existing.entry_path(), "/lsp/rust");
1518
1519        // New entry with no key typed yet falls back to the parent map path.
1520        // Nested dialogs keyed off this are outside the scope of this test.
1521        let new_entry = EntryDialogState::from_schema(
1522            String::new(),
1523            &serde_json::json!({}),
1524            &schema,
1525            "/lsp",
1526            true,
1527            false,
1528            &HashMap::new(),
1529        );
1530        assert_eq!(new_entry.entry_path(), "/lsp");
1531    }
1532
1533    #[test]
1534    fn entry_path_tracks_live_key_edits_for_new_entries() {
1535        let schema = create_test_schema();
1536        let mut dialog = EntryDialogState::from_schema(
1537            String::new(),
1538            &serde_json::json!({}),
1539            &schema,
1540            "/universal_lsp",
1541            true,
1542            false,
1543            &HashMap::new(),
1544        );
1545
1546        // User types a key into the editable key field.
1547        for item in dialog.items.iter_mut() {
1548            if item.path == "__key__" {
1549                if let SettingControl::Text(state) = &mut item.control {
1550                    state.value = "myserver".to_string();
1551                }
1552            }
1553        }
1554
1555        assert_eq!(dialog.entry_path(), "/universal_lsp/myserver");
1556    }
1557
1558    #[test]
1559    fn button_count_differs_for_new_vs_existing() {
1560        let schema = create_test_schema();
1561
1562        let new_dialog = EntryDialogState::from_schema(
1563            "test".to_string(),
1564            &serde_json::json!({}),
1565            &schema,
1566            "/test",
1567            true,
1568            false,
1569            &HashMap::new(),
1570        );
1571        assert_eq!(new_dialog.button_count(), 2); // Save, Cancel
1572
1573        let existing_dialog = EntryDialogState::from_schema(
1574            "test".to_string(),
1575            &serde_json::json!({}),
1576            &schema,
1577            "/test",
1578            false,
1579            false, // allow delete
1580            &HashMap::new(),
1581        );
1582        assert_eq!(existing_dialog.button_count(), 3); // Save, Delete, Cancel
1583
1584        // no_delete hides the Delete button even for existing entries
1585        let no_delete_dialog = EntryDialogState::from_schema(
1586            "test".to_string(),
1587            &serde_json::json!({}),
1588            &schema,
1589            "/test",
1590            false,
1591            true, // no delete (auto-managed entries like plugins)
1592            &HashMap::new(),
1593        );
1594        assert_eq!(no_delete_dialog.button_count(), 2); // Save, Cancel (no Delete)
1595    }
1596}