Skip to main content

fresh/app/
toggle_actions.rs

1//! Toggle actions and configuration operations for the Editor.
2//!
3//! This module contains toggle methods and configuration operations:
4//! - Toggle line numbers, debug highlights, menu bar
5//! - Toggle mouse capture, mouse hover, inlay hints
6//! - Reset buffer settings
7//! - Config dump, save, and reload
8
9use crate::types::LspServerConfig;
10use rust_i18n::t;
11
12use crate::config::{Config, FileExplorerSide};
13use crate::config_io::{ConfigLayer, ConfigResolver};
14
15use super::Editor;
16
17impl Editor {
18    /// Toggle line numbers in the gutter for the active split.
19    ///
20    /// Line number visibility is stored per-split in `BufferViewState` so that
21    /// different splits of the same buffer can independently show/hide line numbers
22    /// (e.g., source mode shows them, compose mode hides them).
23    ///
24    /// The new value is also written to the global `editor.line_numbers`
25    /// preference and persisted to the user config layer, so the choice
26    /// survives a restart (issue #474). Per-split overrides (e.g. compose mode
27    /// forcing them off) still apply on top of this default.
28    pub fn toggle_line_numbers(&mut self) {
29        let active_split = self
30            .windows
31            .get(&self.active_window)
32            .and_then(|w| w.buffers.splits())
33            .map(|(mgr, _)| mgr)
34            .expect("active window must have a populated split layout")
35            .active_split();
36        let Some(new_value) = self
37            .windows
38            .get_mut(&self.active_window)
39            .and_then(|w| w.split_view_states_mut())
40            .expect("active window must have a populated split layout")
41            .get_mut(&active_split)
42            .map(|vs| {
43                let new_value = !vs.show_line_numbers;
44                vs.show_line_numbers = new_value;
45                // The user is expressing a global intent on this view, so drop
46                // any per-buffer pin here — otherwise the override would revert
47                // this change on the next view-mode switch / restart.
48                vs.line_numbers_override = None;
49                new_value
50            })
51        else {
52            return;
53        };
54
55        // `editor.line_numbers` is a global preference, so persist the toggle
56        // to the user config layer (issue #474). Without this the change lives
57        // only in the per-split runtime state and is forgotten on next launch.
58        self.config_mut().editor.line_numbers = new_value;
59        self.persist_config_change("/editor/line_numbers", serde_json::Value::Bool(new_value));
60
61        let status = if new_value {
62            t!("toggle.line_numbers_shown")
63        } else {
64            t!("toggle.line_numbers_hidden")
65        };
66        self.set_status_message(status.to_string());
67    }
68
69    /// Toggle line numbers for the current buffer only.
70    ///
71    /// Unlike [`toggle_line_numbers`](Self::toggle_line_numbers), this does not
72    /// touch the global `editor.line_numbers` default and does not affect other
73    /// buffers. It records an explicit per-buffer override on the active buffer's
74    /// view state, which is persisted in the per-file workspace state so the
75    /// choice survives a restart (issue #474 follow-up).
76    pub fn toggle_line_numbers_current_buffer(&mut self) {
77        let active_split = self
78            .windows
79            .get(&self.active_window)
80            .and_then(|w| w.buffers.splits())
81            .map(|(mgr, _)| mgr)
82            .expect("active window must have a populated split layout")
83            .active_split();
84        let Some(new_value) = self
85            .windows
86            .get_mut(&self.active_window)
87            .and_then(|w| w.split_view_states_mut())
88            .expect("active window must have a populated split layout")
89            .get_mut(&active_split)
90            .map(|vs| {
91                // Deref reaches the active buffer's BufferViewState, so this
92                // scopes the override to the current buffer in this split only.
93                let new_value = !vs.show_line_numbers;
94                vs.show_line_numbers = new_value;
95                vs.line_numbers_override = Some(new_value);
96                new_value
97            })
98        else {
99            return;
100        };
101
102        let status = if new_value {
103            t!("toggle.line_numbers_shown")
104        } else {
105            t!("toggle.line_numbers_hidden")
106        };
107        self.set_status_message(status.to_string());
108    }
109
110    /// Toggle line wrap for the current buffer only.
111    ///
112    /// Per-buffer counterpart of [`Action::ToggleLineWrap`](crate::input::keybindings::Action::ToggleLineWrap):
113    /// it does not touch the global `editor.line_wrap` default and does not affect
114    /// other buffers. Records an explicit per-buffer override, persisted in the
115    /// per-file workspace state.
116    pub fn toggle_line_wrap_current_buffer(&mut self) {
117        let active_split = self
118            .windows
119            .get(&self.active_window)
120            .and_then(|w| w.buffers.splits())
121            .map(|(mgr, _)| mgr)
122            .expect("active window must have a populated split layout")
123            .active_split();
124        let buffer_id = self.active_buffer();
125        let wrap_column = self
126            .active_window()
127            .resolve_wrap_column_for_buffer(buffer_id);
128        let wrap_indent = self.config.editor.wrap_indent;
129        let Some(new_value) = self
130            .windows
131            .get_mut(&self.active_window)
132            .and_then(|w| w.split_view_states_mut())
133            .expect("active window must have a populated split layout")
134            .get_mut(&active_split)
135            .map(|vs| {
136                let new_value = !vs.viewport.line_wrap_enabled;
137                vs.viewport.line_wrap_enabled = new_value;
138                vs.viewport.wrap_indent = wrap_indent;
139                vs.viewport.wrap_column = wrap_column;
140                vs.line_wrap_override = Some(new_value);
141                new_value
142            })
143        else {
144            return;
145        };
146
147        let state = if new_value {
148            t!("view.state_enabled").to_string()
149        } else {
150            t!("view.state_disabled").to_string()
151        };
152        self.set_status_message(t!("view.line_wrap_state", state = state).to_string());
153    }
154
155    /// Kick off the full-screen wave animation: a crest of wave glyphs
156    /// rises from the bottom edge and bounces every painted cell — text,
157    /// gutter, menu bar, status bar — up, down, and sideways before they
158    /// spring back into place. Purely cosmetic; the underlying UI keeps
159    /// re-painting underneath and is restored intact once the wave ends.
160    pub fn trigger_wave_animation(&mut self) {
161        let area = ratatui::layout::Rect {
162            x: 0,
163            y: 0,
164            width: self.terminal_width,
165            height: self.terminal_height,
166        };
167        if area.width == 0 || area.height == 0 {
168            return;
169        }
170        // Long safety duration: the wave runs until the user dismisses it
171        // (any key / mouse), so this is just an upper bound, not the show's
172        // real length.
173        self.active_window_mut().animations.start(
174            area,
175            crate::view::animation::AnimationKind::Wave {
176                duration: std::time::Duration::from_secs(600),
177            },
178        );
179        self.set_status_message(t!("wave.triggered").to_string());
180    }
181
182    /// Whether the interactive wave animation is currently running in any
183    /// window (it persists until the user presses a key or moves the mouse).
184    pub fn wave_animation_active(&self) -> bool {
185        self.windows
186            .values()
187            .any(|w| w.animations.has_dismissable())
188    }
189
190    /// Dismiss the wave animation everywhere it's running.
191    pub fn cancel_wave_animation(&mut self) {
192        for w in self.windows.values_mut() {
193            w.animations.cancel_dismissable();
194        }
195    }
196
197    /// The configured screensaver idle threshold, or `None` when the
198    /// screensaver is disabled (turned off, or a zero-minute timeout).
199    pub fn screensaver_idle_timeout(&self) -> Option<std::time::Duration> {
200        let editor = &self.config.editor;
201        if editor.screensaver_enabled && editor.screensaver_idle_minutes > 0 {
202            Some(std::time::Duration::from_secs(
203                editor.screensaver_idle_minutes as u64 * 60,
204            ))
205        } else {
206            None
207        }
208    }
209
210    /// Start the wave animation as a screensaver if the editor has been
211    /// idle (no key/mouse input) for at least the configured threshold.
212    /// No-op — returns `false` — when the screensaver is disabled, the
213    /// idle time is below the threshold, or a wave is already running.
214    /// Returns `true` iff it started the screensaver on this call. The
215    /// main loop calls this each time it polls and finds no input event;
216    /// any real input both dismisses the wave and resets the idle clock.
217    pub fn maybe_start_screensaver(&mut self, idle: std::time::Duration) -> bool {
218        let Some(timeout) = self.screensaver_idle_timeout() else {
219            return false;
220        };
221        if idle < timeout || self.wave_animation_active() {
222            return false;
223        }
224        self.trigger_wave_animation();
225        true
226    }
227
228    /// Toggle menu bar visibility.
229    ///
230    /// `editor.show_menu_bar` is a global preference, so the toggle updates the
231    /// runtime config and persists the change to the user config layer (same
232    /// pattern as the file-explorer toggles). See issue #1156.
233    pub fn toggle_menu_bar(&mut self) {
234        let new_value = !self.active_window_mut().menu_bar_visible;
235        self.config_mut().editor.show_menu_bar = new_value;
236        self.active_window_mut().menu_bar_visible = new_value;
237        // When explicitly toggling, clear auto-show state
238        self.active_window_mut().menu_bar_auto_shown = false;
239        // Close any open menu when hiding the menu bar
240        if !self.active_window_mut().menu_bar_visible {
241            self.menu_state.close_menu();
242        }
243        self.persist_config_change("/editor/show_menu_bar", serde_json::Value::Bool(new_value));
244        let status = if self.active_window_mut().menu_bar_visible {
245            t!("toggle.menu_bar_shown")
246        } else {
247            t!("toggle.menu_bar_hidden")
248        };
249        self.set_status_message(status.to_string());
250    }
251
252    // `toggle_tab_bar` / `toggle_status_bar` / `toggle_prompt_line` and
253    // their `*_visible` getters live on `impl Window` — call them via
254    // `self.active_window_mut().toggle_tab_bar()` etc. (or read
255    // `active_window().tab_bar_visible` for the flag directly).
256
257    /// Toggle the file explorer side between left and right.
258    ///
259    /// Updates the runtime config and the active window's runtime side
260    /// shadow, then persists the change to the user config layer (same
261    /// pattern as `toggle_menu_bar`).
262    pub fn toggle_file_explorer_side(&mut self) {
263        let new_side = match self.config.file_explorer.side {
264            FileExplorerSide::Left => FileExplorerSide::Right,
265            FileExplorerSide::Right => FileExplorerSide::Left,
266        };
267        self.config_mut().file_explorer.side = new_side;
268        self.active_window_mut().file_explorer_side = new_side;
269        self.persist_config_change(
270            "/file_explorer/side",
271            serde_json::json!(match new_side {
272                FileExplorerSide::Left => "left",
273                FileExplorerSide::Right => "right",
274            }),
275        );
276        let status = match new_side {
277            FileExplorerSide::Left => t!("toggle.file_explorer_side_left"),
278            FileExplorerSide::Right => t!("toggle.file_explorer_side_right"),
279        };
280        self.set_status_message(status.to_string());
281    }
282
283    /// Toggle vertical scrollbar visibility
284    pub fn toggle_vertical_scrollbar(&mut self) {
285        let new_value = !self.config.editor.show_vertical_scrollbar;
286        self.config_mut().editor.show_vertical_scrollbar = new_value;
287        // Persist to the user config layer so the choice survives restart
288        // (issue #474), matching the other global View-menu toggles.
289        self.persist_config_change(
290            "/editor/show_vertical_scrollbar",
291            serde_json::Value::Bool(new_value),
292        );
293        let status = if new_value {
294            t!("toggle.vertical_scrollbar_shown")
295        } else {
296            t!("toggle.vertical_scrollbar_hidden")
297        };
298        self.set_status_message(status.to_string());
299    }
300
301    /// Toggle horizontal scrollbar visibility
302    pub fn toggle_horizontal_scrollbar(&mut self) {
303        let new_value = !self.config.editor.show_horizontal_scrollbar;
304        self.config_mut().editor.show_horizontal_scrollbar = new_value;
305        // Persist to the user config layer so the choice survives restart
306        // (issue #474), matching the other global View-menu toggles.
307        self.persist_config_change(
308            "/editor/show_horizontal_scrollbar",
309            serde_json::Value::Bool(new_value),
310        );
311        let status = if new_value {
312            t!("toggle.horizontal_scrollbar_shown")
313        } else {
314            t!("toggle.horizontal_scrollbar_hidden")
315        };
316        self.set_status_message(status.to_string());
317    }
318
319    /// Reset buffer settings (tab_size, use_tabs, auto_close, whitespace visibility) to config defaults
320    pub fn reset_buffer_settings(&mut self) {
321        use crate::config::WhitespaceVisibility;
322        let buffer_id = self.active_buffer();
323
324        // Determine settings from config using buffer's stored language
325        let mut whitespace = WhitespaceVisibility::from_editor_config(&self.config.editor);
326        let mut auto_close = self.config.editor.auto_close;
327        let mut word_characters = String::new();
328        let (tab_size, use_tabs) = if let Some(state) = self
329            .windows
330            .get(&self.active_window)
331            .map(|w| &w.buffers)
332            .expect("active window present")
333            .get(&buffer_id)
334        {
335            let language = &state.language;
336            if let Some(lang_config) = self.config.languages.get(language) {
337                whitespace =
338                    whitespace.with_language_tab_override(lang_config.show_whitespace_tabs);
339                // Auto close: language override (only if globally enabled)
340                if auto_close {
341                    if let Some(lang_auto_close) = lang_config.auto_close {
342                        auto_close = lang_auto_close;
343                    }
344                }
345                if let Some(ref wc) = lang_config.word_characters {
346                    word_characters = wc.clone();
347                }
348                (
349                    lang_config.tab_size.unwrap_or(self.config.editor.tab_size),
350                    lang_config.use_tabs.unwrap_or(self.config.editor.use_tabs),
351                )
352            } else {
353                (self.config.editor.tab_size, self.config.editor.use_tabs)
354            }
355        } else {
356            (self.config.editor.tab_size, self.config.editor.use_tabs)
357        };
358
359        // Apply settings to buffer
360        if let Some(state) = self
361            .windows
362            .get_mut(&self.active_window)
363            .map(|w| &mut w.buffers)
364            .expect("active window present")
365            .get_mut(&buffer_id)
366        {
367            state.buffer_settings.tab_size = tab_size;
368            state.buffer_settings.use_tabs = use_tabs;
369            state.buffer_settings.auto_close = auto_close;
370            state.buffer_settings.whitespace = whitespace;
371            state.buffer_settings.word_characters = word_characters;
372        }
373
374        self.set_status_message(t!("toggle.buffer_settings_reset").to_string());
375    }
376
377    /// Toggle mouse capture on/off
378    pub fn toggle_mouse_capture(&mut self) {
379        use std::io::stdout;
380
381        self.active_window_mut().mouse_enabled = !self.active_window_mut().mouse_enabled;
382
383        if self.active_window_mut().mouse_enabled {
384            // Best-effort terminal mouse capture toggle.
385            #[allow(clippy::let_underscore_must_use)]
386            let _ = crossterm::execute!(stdout(), crossterm::event::EnableMouseCapture);
387            self.set_status_message(t!("toggle.mouse_capture_enabled").to_string());
388        } else {
389            // Best-effort terminal mouse capture toggle.
390            #[allow(clippy::let_underscore_must_use)]
391            let _ = crossterm::execute!(stdout(), crossterm::event::DisableMouseCapture);
392            self.set_status_message(t!("toggle.mouse_capture_disabled").to_string());
393        }
394    }
395
396    /// Check if mouse capture is enabled
397    pub fn is_mouse_enabled(&self) -> bool {
398        self.active_window().mouse_enabled
399    }
400
401    /// Toggle mouse hover for LSP on/off
402    ///
403    /// On Windows, this also switches the mouse tracking mode: mode 1003
404    /// (all motion) when enabled, mode 1002 (cell motion) when disabled.
405    pub fn toggle_mouse_hover(&mut self) {
406        let new_value = !self.config.editor.mouse_hover_enabled;
407        self.config_mut().editor.mouse_hover_enabled = new_value;
408
409        if self.config.editor.mouse_hover_enabled {
410            self.set_status_message(t!("toggle.mouse_hover_enabled").to_string());
411        } else {
412            // Clear any pending hover state
413            self.active_window_mut().mouse_state.lsp_hover_state = None;
414            self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
415            self.set_status_message(t!("toggle.mouse_hover_disabled").to_string());
416        }
417
418        // On Windows, switch mouse tracking mode to match
419        #[cfg(windows)]
420        {
421            let mode = if self.config.editor.mouse_hover_enabled {
422                fresh_winterm::MouseMode::AllMotion
423            } else {
424                fresh_winterm::MouseMode::CellMotion
425            };
426            if let Err(e) = fresh_winterm::set_mouse_mode(mode) {
427                tracing::error!("Failed to switch mouse mode: {}", e);
428            }
429        }
430    }
431
432    /// Check if mouse hover is enabled
433    pub fn is_mouse_hover_enabled(&self) -> bool {
434        self.config.editor.mouse_hover_enabled
435    }
436
437    /// Set GPM active flag (enables software mouse cursor rendering)
438    ///
439    /// When GPM is used for mouse input on Linux consoles, we need to draw
440    /// our own mouse cursor because GPM can't draw on the alternate screen
441    /// buffer used by TUI applications.
442    pub fn set_gpm_active(&mut self, active: bool) {
443        self.active_window_mut().gpm_active = active;
444    }
445
446    /// Toggle inlay hints visibility
447    pub fn toggle_inlay_hints(&mut self) {
448        let new_value = !self.config.editor.enable_inlay_hints;
449        self.config_mut().editor.enable_inlay_hints = new_value;
450        // `Window::send_lsp_changes_for_buffer` reads
451        // `resources.config.editor.enable_inlay_hints`; sync so the per-edit
452        // LSP refresh sees the new value without waiting for a reload.
453        self.sync_windows_config();
454
455        if self.config.editor.enable_inlay_hints {
456            // Re-request inlay hints for the active buffer
457            self.request_inlay_hints_for_active_buffer();
458            self.set_status_message(t!("toggle.inlay_hints_enabled").to_string());
459        } else {
460            // Clear inlay hints from all buffers
461            for (_, state) in self
462                .windows
463                .get_mut(&self.active_window)
464                .map(|w| &mut w.buffers)
465                .expect("active window present")
466            {
467                state.virtual_texts.clear(&mut state.marker_list);
468            }
469            self.set_status_message(t!("toggle.inlay_hints_disabled").to_string());
470        }
471    }
472
473    /// Dump the current configuration to the user's config file
474    pub fn dump_config(&mut self) {
475        // Create the config directory if it doesn't exist
476        if let Err(e) = self
477            .authority()
478            .filesystem
479            .create_dir_all(&self.dir_context.config_dir)
480        {
481            self.set_status_message(
482                t!("error.config_dir_failed", error = e.to_string()).to_string(),
483            );
484            return;
485        }
486
487        let config_path = self.dir_context.config_path();
488        let resolver =
489            ConfigResolver::new(self.dir_context.clone(), self.working_dir().to_path_buf());
490
491        // Save the config to user layer
492        match resolver.save_to_layer(&self.config, ConfigLayer::User) {
493            Ok(()) => {
494                // Open the saved config file in a new buffer
495                match self.open_file(&config_path) {
496                    Ok(_buffer_id) => {
497                        self.set_status_message(
498                            t!("config.saved", path = config_path.display().to_string())
499                                .to_string(),
500                        );
501                    }
502                    Err(e) => {
503                        // Check if this is a large file encoding confirmation error
504                        if let Some(confirmation) =
505                            e.downcast_ref::<crate::model::buffer::LargeFileEncodingConfirmation>()
506                        {
507                            self.start_large_file_encoding_confirmation(confirmation);
508                        } else {
509                            self.set_status_message(
510                                t!("config.saved_failed_open", error = e.to_string()).to_string(),
511                            );
512                        }
513                    }
514                }
515            }
516            Err(e) => {
517                self.set_status_message(
518                    t!("error.config_save_failed", error = e.to_string()).to_string(),
519                );
520            }
521        }
522    }
523
524    /// Save the current configuration to file (without opening it)
525    ///
526    /// Returns Ok(()) on success, or an error message on failure
527    pub fn save_config(&self) -> Result<(), String> {
528        // Create the config directory if it doesn't exist
529        self.authority()
530            .filesystem
531            .create_dir_all(&self.dir_context.config_dir)
532            .map_err(|e| format!("Failed to create config directory: {}", e))?;
533
534        let resolver =
535            ConfigResolver::new(self.dir_context.clone(), self.working_dir().to_path_buf());
536        resolver
537            .save_to_layer(&self.config, ConfigLayer::User)
538            .map_err(|e| format!("Failed to save config: {}", e))
539    }
540
541    /// Reload configuration from the config file
542    ///
543    /// This reloads the config from disk, applies runtime changes (theme, keybindings),
544    /// and emits a config_changed event so plugins can update their state accordingly.
545    /// Uses the layered config system to properly merge with defaults.
546    pub fn reload_config(&mut self) {
547        let old_theme = self.config.theme.clone();
548        self.set_config(Config::load_with_layers(
549            &self.dir_context,
550            self.working_dir(),
551        ));
552
553        // Refresh cached raw user config for plugins
554        self.set_user_config_raw(Config::read_user_config_raw(self.working_dir()));
555
556        // Apply theme change if needed
557        if old_theme != self.config.theme {
558            if let Some(theme) = self.theme_registry.get_cloned(&self.config.theme) {
559                *self.theme.write().unwrap() = theme;
560                self.start_theme_transition_animation();
561                tracing::info!("Theme changed to '{}'", self.config.theme.0);
562            } else {
563                tracing::error!("Theme '{}' not found", self.config.theme.0);
564            }
565        }
566
567        // Always reload keybindings (complex types don't implement PartialEq),
568        // keeping plugin-contributed bindings alive across the reload (#2307).
569        self.keybindings
570            .write()
571            .unwrap()
572            .reload_from_config(&self.config);
573
574        // Update clipboard configuration
575        self.clipboard.apply_config(&self.config.clipboard);
576
577        // Apply bar visibility changes immediately
578        self.active_window_mut().menu_bar_visible = self.config.editor.show_menu_bar;
579        self.active_window_mut().tab_bar_visible = self.config.editor.show_tab_bar;
580        self.active_window_mut().status_bar_visible = self.config.editor.show_status_bar;
581        self.active_window_mut().prompt_line_visible = self.config.editor.show_prompt_line;
582
583        // Update LSP configs
584        let __active_id = self.active_window;
585        if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
586            lsp.set_globally_enabled(self.config.lsp_enabled);
587            for (language, lsp_configs) in &self.config.lsp {
588                lsp.set_language_configs(language.clone(), lsp_configs.as_slice().to_vec());
589            }
590            // Configure universal (global) LSP servers
591            let universal_servers: Vec<LspServerConfig> = self
592                .config
593                .universal_lsp
594                .values()
595                .flat_map(|lc| lc.as_slice().to_vec())
596                .filter(|c| c.enabled)
597                .collect();
598            lsp.set_universal_configs(universal_servers);
599        }
600
601        // Emit event so plugins know config changed
602        let config_path = Config::find_config_path(self.working_dir());
603        self.emit_event(
604            "config_changed",
605            serde_json::json!({
606                "path": config_path.map(|p| p.to_string_lossy().into_owned()),
607            }),
608        );
609    }
610
611    /// Reload the theme registry from disk.
612    ///
613    /// Call this after installing new theme packages or saving new themes.
614    /// This rescans all theme directories and updates the available themes list.
615    pub fn reload_themes(&mut self) {
616        use crate::view::theme::ThemeLoader;
617
618        let theme_loader = ThemeLoader::new(self.dir_context.themes_dir());
619        self.theme_registry = std::sync::Arc::new(theme_loader.load_all(&[]));
620        self.expanded_menus_cache.invalidate();
621
622        // Propagate the new registry to every window's resources so
623        // window-side reads see the updated catalogue. (Theme registry
624        // is `Arc<ThemeRegistry>` not `Arc<RwLock<>>`, so swapping the
625        // Editor's pointer leaves Window clones stale unless we sync.)
626        for w in self.windows.values_mut() {
627            w.resources.theme_registry = self.theme_registry.clone();
628        }
629
630        // Update shared theme cache for plugin access
631        *self.theme_cache.write().unwrap() = self.theme_registry.to_json_map();
632
633        // Re-apply current theme if it still exists, otherwise it might have been updated
634        if let Some(theme) = self.theme_registry.get_cloned(&self.config.theme) {
635            *self.theme.write().unwrap() = theme;
636        }
637
638        tracing::info!(
639            "Theme registry reloaded ({} themes)",
640            self.theme_registry.len()
641        );
642
643        // Emit event so plugins know themes changed
644        self.emit_event("themes_changed", serde_json::json!({}));
645    }
646
647    /// Persist a single config change to the user config file.
648    ///
649    /// Used when toggling settings via menu/command palette so that
650    /// the change is saved immediately (matching the settings UI behavior).
651    pub(super) fn persist_config_change(&self, json_pointer: &str, value: serde_json::Value) {
652        let resolver =
653            ConfigResolver::new(self.dir_context.clone(), self.working_dir().to_path_buf());
654        let changes = std::collections::HashMap::from([(json_pointer.to_string(), value)]);
655        let deletions = std::collections::HashSet::new();
656        if let Err(e) = resolver.save_changes_to_layer(&changes, &deletions, ConfigLayer::User) {
657            tracing::error!("Failed to persist config change {}: {}", json_pointer, e);
658        }
659    }
660}