Skip to main content

fresh/app/
settings_actions.rs

1//! Settings modal UI operations for the Editor.
2//!
3//! This module contains all methods related to the settings modal:
4//! - Opening/closing the settings modal
5//! - Saving settings to config
6//! - Navigation (up/down)
7//! - Activating/toggling settings
8//! - Incrementing/decrementing numeric values
9
10use crate::config::Config;
11use crate::config_io::{ConfigLayer, ConfigResolver};
12use crate::input::keybindings::KeybindingResolver;
13use crate::types::LspServerConfig;
14use anyhow::Result as AnyhowResult;
15use rust_i18n::t;
16
17use super::Editor;
18
19impl Editor {
20    /// Open the settings modal
21    pub fn open_settings(&mut self) {
22        // Include schema at compile time
23        const SCHEMA_JSON: &str = include_str!("../../plugins/config-schema.json");
24
25        // Snapshot of plugin-provided schemas to inject as a
26        // "Plugin Settings" category — clone the map so we can drop
27        // the read lock before constructing SettingsState. Rebuilt on
28        // every open so plugins that lazily register their schemas
29        // after startup (or via Reload Plugin) show up without
30        // requiring a full editor restart.
31        let plugin_schemas = self.plugin_schemas.read().unwrap().clone();
32
33        match crate::view::settings::SettingsState::new_with_plugin_schemas(
34            SCHEMA_JSON,
35            &self.config,
36            &plugin_schemas,
37        ) {
38            Ok(mut state) => {
39                // Load layer sources to show where each setting value comes from
40                let resolver =
41                    ConfigResolver::new(self.dir_context.clone(), self.working_dir().to_path_buf());
42                if let Ok(sources) = resolver.get_layer_sources() {
43                    state.set_layer_sources(sources);
44                }
45                // Snapshot plugin-registered status-bar tokens for the dual-list picker.
46                let tokens = self.status_bar_token_registry.lock().unwrap().clone();
47                state.set_status_bar_tokens(tokens);
48                state.show();
49                self.settings_state = Some(state);
50            }
51            Err(e) => {
52                self.set_status_message(
53                    t!("settings.failed_to_open", error = e.to_string()).to_string(),
54                );
55            }
56        }
57    }
58
59    /// Close the settings modal
60    ///
61    /// If `save` is true and there are changes, they will be applied first.
62    pub fn close_settings(&mut self, save: bool) {
63        if save {
64            self.save_settings();
65        }
66        if let Some(ref mut state) = self.settings_state {
67            if !save && state.has_changes() {
68                // Discard changes
69                state.discard_changes();
70            }
71            state.hide();
72        }
73    }
74
75    /// Save the settings from the modal to config
76    pub fn save_settings(&mut self) {
77        let old_theme = self.config.theme.clone();
78        let old_locale = self.config.locale.clone();
79        let old_plugins = self.config.plugins.clone();
80        #[cfg(windows)]
81        let old_mouse_hover = self.config.editor.mouse_hover_enabled;
82
83        // Get target layer, new config, and the actual changes made
84        let (target_layer, new_config, pending_changes, pending_deletions) = {
85            if let Some(ref state) = self.settings_state {
86                if !state.has_changes() {
87                    return;
88                }
89                match state.apply_changes(&self.config) {
90                    Ok(config) => (
91                        state.target_layer,
92                        config,
93                        state.pending_changes.clone(),
94                        state.pending_deletions.clone(),
95                    ),
96                    Err(e) => {
97                        self.set_status_message(
98                            t!("settings.failed_to_apply", error = e.to_string()).to_string(),
99                        );
100                        return;
101                    }
102                }
103            } else {
104                return;
105            }
106        };
107
108        // Apply the new config
109        self.set_config(new_config.clone());
110
111        // Refresh cached raw user config for plugins
112        self.set_user_config_raw(Config::read_user_config_raw(self.working_dir()));
113
114        // Apply runtime changes
115        if old_theme != self.config.theme {
116            if let Some(theme) = self.theme_registry.get_cloned(&self.config.theme) {
117                *self.theme.write().unwrap() = theme;
118                tracing::info!("Theme changed to '{}'", self.config.theme.0);
119            } else {
120                tracing::error!("Theme '{}' not found", self.config.theme.0);
121                self.set_status_message(format!("Theme '{}' not found", self.config.theme.0));
122            }
123        }
124
125        // Apply locale change at runtime
126        if old_locale != self.config.locale {
127            let locale_owned = self.config.locale.as_option().map(|s| s.to_string());
128            if let Some(locale) = locale_owned {
129                crate::i18n::set_locale(&locale);
130                // Regenerate menus with the new locale
131                self.set_menus(crate::config::MenuConfig::translated());
132                tracing::info!("Locale changed to '{}'", locale);
133            } else {
134                // Auto-detect from environment
135                crate::i18n::init();
136                self.set_menus(crate::config::MenuConfig::translated());
137                tracing::info!("Locale reset to auto-detect");
138            }
139            // Refresh command palette commands with new locale
140            if let Ok(mut registry) = self.command_registry.write() {
141                registry.refresh_builtin_commands();
142            }
143        }
144
145        // Handle plugin enable/disable changes
146        self.apply_plugin_config_changes(&old_plugins);
147
148        // Update keybindings
149        *self.keybindings.write().unwrap() = KeybindingResolver::new(&self.config);
150
151        // Update LSP configs
152        let __active_id = self.active_window;
153        if let Some(lsp) = self
154            .windows
155            .get_mut(&__active_id)
156            .and_then(|w| w.lsp.as_mut())
157        {
158            for (language, lsp_configs) in &self.config.lsp {
159                lsp.set_language_configs(language.clone(), lsp_configs.as_slice().to_vec());
160            }
161            // Configure universal (global) LSP servers
162            let universal_servers: Vec<LspServerConfig> = self
163                .config
164                .universal_lsp
165                .values()
166                .flat_map(|lc| lc.as_slice().to_vec())
167                .filter(|c| c.enabled)
168                .collect();
169            lsp.set_universal_configs(universal_servers);
170        }
171
172        // Propagate editor config to all split and buffer view states
173        for view_state in self
174            .windows
175            .get_mut(&self.active_window)
176            .and_then(|w| w.split_view_states_mut())
177            .expect("active window must have a populated split layout")
178            .values_mut()
179        {
180            view_state.show_line_numbers = self.config.editor.line_numbers;
181            for buf_state in view_state.keyed_states.values_mut() {
182                buf_state.rulers = self.config.editor.rulers.clone();
183            }
184        }
185
186        // Apply bar visibility changes immediately
187        self.active_window_mut().menu_bar_visible = self.config.editor.show_menu_bar;
188        self.active_window_mut().tab_bar_visible = self.config.editor.show_tab_bar;
189        self.active_window_mut().status_bar_visible = self.config.editor.show_status_bar;
190        self.active_window_mut().prompt_line_visible = self.config.editor.show_prompt_line;
191
192        // Propagate file-explorer settings to live runtime state (IgnorePatterns
193        // and width are shadows of config, not read live on each render).
194        self.active_window_mut().file_explorer_width = self.config.file_explorer.width;
195        self.active_window_mut().file_explorer_side = self.config.file_explorer.side;
196        let active_id = self.active_window;
197        if let Some(explorer) = self
198            .windows
199            .get_mut(&active_id)
200            .and_then(|w| w.file_explorer.as_mut())
201        {
202            let patterns = explorer.ignore_patterns_mut();
203            patterns.set_show_hidden(self.config.file_explorer.show_hidden);
204            patterns.set_show_gitignored(self.config.file_explorer.show_gitignored);
205            explorer.set_compact_directories(self.config.file_explorer.compact_directories);
206        }
207
208        // On Windows, switch mouse tracking mode when mouse_hover_enabled changes.
209        // Mode 1003 (all motion) is used for hover; mode 1002 (cell motion) otherwise.
210        #[cfg(windows)]
211        if old_mouse_hover != self.config.editor.mouse_hover_enabled {
212            let mode = if self.config.editor.mouse_hover_enabled {
213                fresh_winterm::MouseMode::AllMotion
214            } else {
215                // Clear any pending hover state when disabling
216                self.active_window_mut().mouse_state.lsp_hover_state = None;
217                self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
218                fresh_winterm::MouseMode::CellMotion
219            };
220            if let Err(e) = fresh_winterm::set_mouse_mode(mode) {
221                tracing::error!("Failed to switch mouse mode: {}", e);
222            }
223        }
224
225        // Propagate tab_size/use_tabs/auto_close/whitespace visibility to all open buffers
226        // Each buffer resolves its settings from its language + the new global config
227        for (_, state) in self
228            .windows
229            .get_mut(&self.active_window)
230            .map(|w| &mut w.buffers)
231            .expect("active window present")
232        {
233            let mut whitespace =
234                crate::config::WhitespaceVisibility::from_editor_config(&self.config.editor);
235            state.buffer_settings.auto_close = self.config.editor.auto_close;
236            if let Some(lang_config) = self.config.languages.get(&state.language) {
237                state.buffer_settings.tab_size =
238                    lang_config.tab_size.unwrap_or(self.config.editor.tab_size);
239                state.buffer_settings.use_tabs =
240                    lang_config.use_tabs.unwrap_or(self.config.editor.use_tabs);
241                whitespace =
242                    whitespace.with_language_tab_override(lang_config.show_whitespace_tabs);
243                // Auto close: language override (only if globally enabled)
244                if state.buffer_settings.auto_close {
245                    if let Some(lang_auto_close) = lang_config.auto_close {
246                        state.buffer_settings.auto_close = lang_auto_close;
247                    }
248                }
249                // Word characters: from language config
250                if let Some(ref wc) = lang_config.word_characters {
251                    state.buffer_settings.word_characters = wc.clone();
252                } else {
253                    state.buffer_settings.word_characters.clear();
254                }
255            } else {
256                state.buffer_settings.tab_size = self.config.editor.tab_size;
257                state.buffer_settings.use_tabs = self.config.editor.use_tabs;
258            }
259            state.buffer_settings.whitespace = whitespace;
260        }
261
262        // Save ONLY the changes to disk (preserves external edits to the config file)
263        let resolver =
264            ConfigResolver::new(self.dir_context.clone(), self.working_dir().to_path_buf());
265
266        let layer_name = match target_layer {
267            ConfigLayer::User => "User",
268            ConfigLayer::Project => "Project",
269            ConfigLayer::Session => "Session",
270            ConfigLayer::System => "System", // Should never happen
271        };
272
273        match resolver.save_changes_to_layer(&pending_changes, &pending_deletions, target_layer) {
274            Ok(()) => {
275                self.set_status_message(
276                    t!("settings.saved_to_layer", layer = layer_name).to_string(),
277                );
278                // Clear settings state entirely so next open creates fresh state
279                // from the updated config. This fixes issue #474 where reopening
280                // settings after save would show stale values.
281                self.settings_state = None;
282            }
283            Err(e) => {
284                self.set_status_message(
285                    t!("settings.failed_to_save", error = e.to_string()).to_string(),
286                );
287            }
288        }
289    }
290
291    /// Open the config file for the specified layer in the editor.
292    /// Creates the file with default template if it doesn't exist.
293    /// If there are pending changes in the Settings UI, warns the user and doesn't proceed.
294    pub fn open_config_file(&mut self, layer: ConfigLayer) -> AnyhowResult<()> {
295        // Check for pending changes before opening config file
296        if let Some(ref state) = self.settings_state {
297            if state.has_changes() {
298                self.set_status_message(t!("settings.pending_changes").to_string());
299                return Ok(());
300            }
301        }
302
303        let resolver =
304            ConfigResolver::new(self.dir_context.clone(), self.working_dir().to_path_buf());
305
306        let path = match layer {
307            ConfigLayer::User => resolver.user_config_path(),
308            ConfigLayer::Project => resolver.project_config_write_path(),
309            ConfigLayer::Session => resolver.session_config_path(),
310            ConfigLayer::System => {
311                self.set_status_message(t!("settings.cannot_edit_system").to_string());
312                return Ok(());
313            }
314        };
315
316        // Create parent directory if needed
317        if let Some(parent) = path.parent() {
318            self.authority.filesystem.create_dir_all(parent)?;
319        }
320
321        // Create file with template if it doesn't exist
322        if !self.authority.filesystem.exists(&path) {
323            let template = match layer {
324                ConfigLayer::User => {
325                    r#"{
326  "version": 1,
327  "theme": "default",
328  "editor": {
329    "tab_size": 4,
330    "line_numbers": true
331  }
332}
333"#
334                }
335                ConfigLayer::Project => {
336                    r#"{
337  "version": 1,
338  "editor": {
339    "tab_size": 4
340  },
341  "languages": {}
342}
343"#
344                }
345                ConfigLayer::Session => {
346                    r#"{
347  "version": 1
348}
349"#
350                }
351                ConfigLayer::System => unreachable!(),
352            };
353            self.authority
354                .filesystem
355                .write_file(&path, template.as_bytes())?;
356        }
357
358        // Close settings and open the config file
359        self.settings_state = None;
360        match self.open_file(&path) {
361            Ok(_) => {
362                let layer_name = match layer {
363                    ConfigLayer::User => "User",
364                    ConfigLayer::Project => "Project",
365                    ConfigLayer::Session => "Session",
366                    ConfigLayer::System => "System",
367                };
368                self.set_status_message(
369                    t!(
370                        "settings.editing_config",
371                        layer = layer_name,
372                        path = path.display().to_string()
373                    )
374                    .to_string(),
375                );
376            }
377            Err(e) => {
378                // Check if this is a large file encoding confirmation error
379                if let Some(confirmation) =
380                    e.downcast_ref::<crate::model::buffer::LargeFileEncodingConfirmation>()
381                {
382                    self.start_large_file_encoding_confirmation(confirmation);
383                } else {
384                    self.set_status_message(
385                        t!("file.error_opening", error = e.to_string()).to_string(),
386                    );
387                }
388            }
389        }
390
391        Ok(())
392    }
393
394    /// Navigate settings up
395    pub fn settings_navigate_up(&mut self) {
396        if let Some(ref mut state) = self.settings_state {
397            state.select_prev();
398        }
399    }
400
401    /// Navigate settings down
402    pub fn settings_navigate_down(&mut self) {
403        if let Some(ref mut state) = self.settings_state {
404            state.select_next();
405        }
406    }
407
408    /// Activate/toggle the currently selected setting
409    pub fn settings_activate_current(&mut self) {
410        use crate::view::settings::items::SettingControl;
411        use crate::view::settings::FocusPanel;
412
413        // Check if we're in the Footer panel - handle button activation
414        let focus_panel = self
415            .settings_state
416            .as_ref()
417            .map(|s| s.focus_panel())
418            .unwrap_or(FocusPanel::Categories);
419
420        if focus_panel == FocusPanel::Footer {
421            let button_index = self
422                .settings_state
423                .as_ref()
424                .map(|s| s.footer_button_index)
425                .unwrap_or(2);
426            match button_index {
427                0 => {
428                    // Layer button - cycle target layer
429                    if let Some(ref mut state) = self.settings_state {
430                        state.cycle_target_layer();
431                    }
432                }
433                1 => {
434                    // Reset/Inherit button — for nullable items, set to null (inherit);
435                    // for non-nullable items, reset to default
436                    if let Some(ref mut state) = self.settings_state {
437                        let is_nullable_set = state
438                            .current_item()
439                            .map(|item| item.nullable && !item.is_null)
440                            .unwrap_or(false);
441                        if is_nullable_set {
442                            state.set_current_to_null();
443                        } else {
444                            state.reset_current_to_default();
445                        }
446                    }
447                }
448                2 => {
449                    // Save button - save and close
450                    self.close_settings(true);
451                }
452                3 => {
453                    // Cancel button
454                    self.close_settings(false);
455                }
456                _ => {}
457            }
458            return;
459        }
460
461        // When Categories panel is focused, Enter does nothing to settings controls
462        // (keys should not leak to the right panel)
463        if focus_panel == FocusPanel::Categories {
464            return;
465        }
466
467        // Get the current item's control type to determine action
468        let control_type = {
469            if let Some(ref state) = self.settings_state {
470                state.current_item().map(|item| match &item.control {
471                    SettingControl::Toggle(_) => "toggle",
472                    SettingControl::Number(_) => "number",
473                    SettingControl::Dropdown(_) => "dropdown",
474                    SettingControl::Text(_) => "text",
475                    SettingControl::TextList(_) => "textlist",
476                    SettingControl::DualList(_) => "duallist",
477                    SettingControl::Map(_) => "map",
478                    SettingControl::ObjectArray(_) => "objectarray",
479                    SettingControl::Json(_) => "json",
480                    SettingControl::Complex { .. } => "complex",
481                })
482            } else {
483                None
484            }
485        };
486
487        // Perform the action based on control type
488        match control_type {
489            Some("toggle") => {
490                if let Some(ref mut state) = self.settings_state {
491                    if let Some(item) = state.current_item_mut() {
492                        if let SettingControl::Toggle(ref mut toggle_state) = item.control {
493                            toggle_state.checked = !toggle_state.checked;
494                        }
495                    }
496                    state.on_value_changed();
497                }
498            }
499            Some("dropdown") => {
500                // Toggle dropdown open/closed, or confirm selection if open
501                if let Some(ref mut state) = self.settings_state {
502                    if state.is_dropdown_open() {
503                        state.dropdown_confirm();
504                    } else {
505                        state.dropdown_toggle();
506                    }
507                }
508            }
509            Some("textlist") => {
510                // Enter text editing mode for TextList controls
511                if let Some(ref mut state) = self.settings_state {
512                    state.start_editing();
513                }
514            }
515            Some("map") => {
516                // For Map controls: check if map has a value schema (supports entry dialogs)
517                if let Some(ref mut state) = self.settings_state {
518                    if let Some(item) = state.current_item_mut() {
519                        if let SettingControl::Map(ref mut map_state) = item.control {
520                            if map_state.focused_entry.is_none() {
521                                // On add-new row: open dialog with empty key
522                                if map_state.value_schema.is_some() {
523                                    state.open_add_entry_dialog();
524                                }
525                            } else if map_state.value_schema.is_some() {
526                                // Map has schema: open entry dialog
527                                state.open_entry_dialog();
528                            } else {
529                                // For other maps: toggle expanded
530                                if let Some(idx) = map_state.focused_entry {
531                                    if map_state.expanded.contains(&idx) {
532                                        map_state.expanded.retain(|&i| i != idx);
533                                    } else {
534                                        map_state.expanded.push(idx);
535                                    }
536                                }
537                            }
538                        }
539                    }
540                    state.on_value_changed();
541                }
542            }
543            Some("text") => {
544                // For Text controls: enter text editing mode
545                if let Some(ref mut state) = self.settings_state {
546                    state.start_editing();
547                }
548            }
549            Some("number") => {
550                // For Number controls: enter number editing mode
551                if let Some(ref mut state) = self.settings_state {
552                    state.start_number_editing();
553                }
554            }
555            _ => {}
556        }
557    }
558
559    /// Increment the current setting value (for Number and Dropdown controls)
560    pub fn settings_increment_current(&mut self) {
561        use crate::view::settings::items::SettingControl;
562        use crate::view::settings::FocusPanel;
563
564        // Check if we're in the Footer panel - navigate buttons instead
565        let focus_panel = self
566            .settings_state
567            .as_ref()
568            .map(|s| s.focus_panel())
569            .unwrap_or(FocusPanel::Categories);
570
571        if focus_panel == FocusPanel::Footer {
572            if let Some(ref mut state) = self.settings_state {
573                // Navigate to next footer button (wrapping around)
574                state.footer_button_index = (state.footer_button_index + 1) % 4;
575            }
576            return;
577        }
578
579        // When Categories panel is focused, Left/Right don't affect settings controls
580        if focus_panel == FocusPanel::Categories {
581            return;
582        }
583
584        let control_type = {
585            if let Some(ref state) = self.settings_state {
586                state.current_item().map(|item| match &item.control {
587                    SettingControl::Number(_) => "number",
588                    SettingControl::Dropdown(_) => "dropdown",
589                    _ => "other",
590                })
591            } else {
592                None
593            }
594        };
595
596        match control_type {
597            // Number inc/dec removed — direct typing only. Action still
598            // exists for Dropdown cycling.
599            Some("dropdown") => {
600                if let Some(ref mut state) = self.settings_state {
601                    if let Some(item) = state.current_item_mut() {
602                        if let SettingControl::Dropdown(ref mut dropdown_state) = item.control {
603                            dropdown_state.select_next();
604                        }
605                    }
606                    state.on_value_changed();
607                }
608            }
609            _ => {}
610        }
611    }
612
613    /// Decrement the current setting value (for Number and Dropdown controls)
614    pub fn settings_decrement_current(&mut self) {
615        use crate::view::settings::items::SettingControl;
616        use crate::view::settings::FocusPanel;
617
618        // Check if we're in the Footer panel - navigate buttons instead
619        let focus_panel = self
620            .settings_state
621            .as_ref()
622            .map(|s| s.focus_panel())
623            .unwrap_or(FocusPanel::Categories);
624
625        if focus_panel == FocusPanel::Footer {
626            if let Some(ref mut state) = self.settings_state {
627                // Navigate to previous footer button (wrapping around)
628                state.footer_button_index = if state.footer_button_index == 0 {
629                    3
630                } else {
631                    state.footer_button_index - 1
632                };
633            }
634            return;
635        }
636
637        // When Categories panel is focused, Left/Right don't affect settings controls
638        if focus_panel == FocusPanel::Categories {
639            return;
640        }
641
642        let control_type = {
643            if let Some(ref state) = self.settings_state {
644                state.current_item().map(|item| match &item.control {
645                    SettingControl::Number(_) => "number",
646                    SettingControl::Dropdown(_) => "dropdown",
647                    _ => "other",
648                })
649            } else {
650                None
651            }
652        };
653
654        match control_type {
655            // Number inc/dec removed — direct typing only. Action still
656            // exists for Dropdown cycling.
657            Some("dropdown") => {
658                if let Some(ref mut state) = self.settings_state {
659                    if let Some(item) = state.current_item_mut() {
660                        if let SettingControl::Dropdown(ref mut dropdown_state) = item.control {
661                            dropdown_state.select_prev();
662                        }
663                    }
664                    state.on_value_changed();
665                }
666            }
667            _ => {}
668        }
669    }
670
671    /// Apply plugin configuration changes by loading/unloading plugins as needed
672    fn apply_plugin_config_changes(
673        &mut self,
674        old_plugins: &std::collections::HashMap<String, crate::config::PluginConfig>,
675    ) {
676        // Collect changes first to avoid borrow issues
677        let changes: Vec<_> = self
678            .config
679            .plugins
680            .iter()
681            .filter_map(|(name, new_config)| {
682                let was_enabled = old_plugins.get(name).map(|c| c.enabled).unwrap_or(true);
683                if new_config.enabled != was_enabled {
684                    Some((name.clone(), new_config.enabled, new_config.path.clone()))
685                } else {
686                    None
687                }
688            })
689            .collect();
690
691        // Apply changes
692        for (name, now_enabled, path) in changes {
693            if now_enabled {
694                // Plugin was disabled, now enabled - load it
695                if let Some(ref path) = path {
696                    tracing::info!("Loading newly enabled plugin: {}", name);
697                    let load_result = self.plugin_manager.read().unwrap().load_plugin(path);
698                    if let Err(e) = load_result {
699                        tracing::error!("Failed to load plugin '{}': {}", name, e);
700                        self.set_status_message(format!("Failed to load plugin '{}': {}", name, e));
701                    }
702                }
703            } else {
704                // Plugin was enabled, now disabled - unload it
705                tracing::info!("Unloading disabled plugin: {}", name);
706                let unload_result = self.plugin_manager.write().unwrap().unload_plugin(&name);
707                if let Err(e) = unload_result {
708                    tracing::error!("Failed to unload plugin '{}': {}", name, e);
709                    self.set_status_message(format!("Failed to unload plugin '{}': {}", name, e));
710                } else {
711                    // Clean up status bar tokens for this plugin
712                    self.remove_plugin_status_bar_elements(&name);
713                }
714            }
715        }
716    }
717}