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