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