fresh/
config.rs

1use crate::types::{context_keys, LspServerConfig, ProcessLimits};
2
3use rust_i18n::t;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use std::borrow::Cow;
7use std::collections::HashMap;
8use std::ops::Deref;
9use std::path::Path;
10
11/// Newtype for theme name that generates proper JSON Schema with enum options
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(transparent)]
14pub struct ThemeName(pub String);
15
16impl ThemeName {
17    /// Built-in theme options shown in the settings dropdown
18    pub const BUILTIN_OPTIONS: &'static [&'static str] =
19        &["dark", "light", "high-contrast", "nostalgia"];
20}
21
22impl Deref for ThemeName {
23    type Target = str;
24    fn deref(&self) -> &Self::Target {
25        &self.0
26    }
27}
28
29impl From<String> for ThemeName {
30    fn from(s: String) -> Self {
31        Self(s)
32    }
33}
34
35impl From<&str> for ThemeName {
36    fn from(s: &str) -> Self {
37        Self(s.to_string())
38    }
39}
40
41impl PartialEq<str> for ThemeName {
42    fn eq(&self, other: &str) -> bool {
43        self.0 == other
44    }
45}
46
47impl PartialEq<ThemeName> for str {
48    fn eq(&self, other: &ThemeName) -> bool {
49        self == other.0
50    }
51}
52
53impl JsonSchema for ThemeName {
54    fn schema_name() -> Cow<'static, str> {
55        Cow::Borrowed("ThemeOptions")
56    }
57
58    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
59        schemars::json_schema!({
60            "description": "Available color themes",
61            "type": "string",
62            "enum": Self::BUILTIN_OPTIONS
63        })
64    }
65}
66
67/// Newtype for locale name that generates proper JSON Schema with enum options
68/// Wraps Option<String> to allow null for auto-detection from environment
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
70#[serde(transparent)]
71pub struct LocaleName(pub Option<String>);
72
73// Include the generated locale options from build.rs
74include!(concat!(env!("OUT_DIR"), "/locale_options.rs"));
75
76impl LocaleName {
77    /// Available locale options shown in the settings dropdown
78    /// null means auto-detect from environment
79    /// This is auto-generated from the locales/*.json files by build.rs
80    pub const LOCALE_OPTIONS: &'static [Option<&'static str>] = GENERATED_LOCALE_OPTIONS;
81
82    /// Get the inner value as Option<&str>
83    pub fn as_option(&self) -> Option<&str> {
84        self.0.as_deref()
85    }
86}
87
88impl From<Option<String>> for LocaleName {
89    fn from(s: Option<String>) -> Self {
90        Self(s)
91    }
92}
93
94impl From<Option<&str>> for LocaleName {
95    fn from(s: Option<&str>) -> Self {
96        Self(s.map(|s| s.to_string()))
97    }
98}
99
100impl JsonSchema for LocaleName {
101    fn schema_name() -> Cow<'static, str> {
102        Cow::Borrowed("LocaleOptions")
103    }
104
105    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
106        schemars::json_schema!({
107            "description": "UI locale (language). Use null for auto-detection from environment.",
108            "enum": Self::LOCALE_OPTIONS
109        })
110    }
111}
112
113/// Cursor style options for the terminal cursor
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
115#[serde(rename_all = "snake_case")]
116pub enum CursorStyle {
117    /// Use the terminal's default cursor style
118    #[default]
119    Default,
120    /// Blinking block cursor (█)
121    BlinkingBlock,
122    /// Solid block cursor (█)
123    SteadyBlock,
124    /// Blinking vertical bar cursor (│)
125    BlinkingBar,
126    /// Solid vertical bar cursor (│)
127    SteadyBar,
128    /// Blinking underline cursor (_)
129    BlinkingUnderline,
130    /// Solid underline cursor (_)
131    SteadyUnderline,
132}
133
134impl CursorStyle {
135    /// All available cursor style options
136    pub const OPTIONS: &'static [&'static str] = &[
137        "default",
138        "blinking_block",
139        "steady_block",
140        "blinking_bar",
141        "steady_bar",
142        "blinking_underline",
143        "steady_underline",
144    ];
145
146    /// Human-readable descriptions for each cursor style
147    pub const DESCRIPTIONS: &'static [&'static str] = &[
148        "Terminal default",
149        "█ Blinking block",
150        "█ Solid block",
151        "│ Blinking bar",
152        "│ Solid bar",
153        "_ Blinking underline",
154        "_ Solid underline",
155    ];
156
157    /// Convert to crossterm cursor style
158    pub fn to_crossterm_style(self) -> crossterm::cursor::SetCursorStyle {
159        use crossterm::cursor::SetCursorStyle;
160        match self {
161            Self::Default => SetCursorStyle::DefaultUserShape,
162            Self::BlinkingBlock => SetCursorStyle::BlinkingBlock,
163            Self::SteadyBlock => SetCursorStyle::SteadyBlock,
164            Self::BlinkingBar => SetCursorStyle::BlinkingBar,
165            Self::SteadyBar => SetCursorStyle::SteadyBar,
166            Self::BlinkingUnderline => SetCursorStyle::BlinkingUnderScore,
167            Self::SteadyUnderline => SetCursorStyle::SteadyUnderScore,
168        }
169    }
170
171    /// Parse from string (for command palette)
172    pub fn parse(s: &str) -> Option<Self> {
173        match s {
174            "default" => Some(CursorStyle::Default),
175            "blinking_block" => Some(CursorStyle::BlinkingBlock),
176            "steady_block" => Some(CursorStyle::SteadyBlock),
177            "blinking_bar" => Some(CursorStyle::BlinkingBar),
178            "steady_bar" => Some(CursorStyle::SteadyBar),
179            "blinking_underline" => Some(CursorStyle::BlinkingUnderline),
180            "steady_underline" => Some(CursorStyle::SteadyUnderline),
181            _ => None,
182        }
183    }
184
185    /// Convert to string representation
186    pub fn as_str(self) -> &'static str {
187        match self {
188            Self::Default => "default",
189            Self::BlinkingBlock => "blinking_block",
190            Self::SteadyBlock => "steady_block",
191            Self::BlinkingBar => "blinking_bar",
192            Self::SteadyBar => "steady_bar",
193            Self::BlinkingUnderline => "blinking_underline",
194            Self::SteadyUnderline => "steady_underline",
195        }
196    }
197}
198
199impl JsonSchema for CursorStyle {
200    fn schema_name() -> Cow<'static, str> {
201        Cow::Borrowed("CursorStyle")
202    }
203
204    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
205        schemars::json_schema!({
206            "description": "Terminal cursor style",
207            "type": "string",
208            "enum": Self::OPTIONS
209        })
210    }
211}
212
213/// Newtype for keybinding map name that generates proper JSON Schema with enum options
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(transparent)]
216pub struct KeybindingMapName(pub String);
217
218impl KeybindingMapName {
219    /// Built-in keybinding map options shown in the settings dropdown
220    pub const BUILTIN_OPTIONS: &'static [&'static str] = &["default", "emacs", "vscode", "macos"];
221}
222
223impl Deref for KeybindingMapName {
224    type Target = str;
225    fn deref(&self) -> &Self::Target {
226        &self.0
227    }
228}
229
230impl From<String> for KeybindingMapName {
231    fn from(s: String) -> Self {
232        Self(s)
233    }
234}
235
236impl From<&str> for KeybindingMapName {
237    fn from(s: &str) -> Self {
238        Self(s.to_string())
239    }
240}
241
242impl PartialEq<str> for KeybindingMapName {
243    fn eq(&self, other: &str) -> bool {
244        self.0 == other
245    }
246}
247
248/// Line ending format for new files
249#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "lowercase")]
251pub enum LineEndingOption {
252    /// Unix/Linux/macOS format (LF)
253    #[default]
254    Lf,
255    /// Windows format (CRLF)
256    Crlf,
257    /// Classic Mac format (CR) - rare
258    Cr,
259}
260
261impl LineEndingOption {
262    /// Convert to the buffer's LineEnding type
263    pub fn to_line_ending(&self) -> crate::model::buffer::LineEnding {
264        match self {
265            Self::Lf => crate::model::buffer::LineEnding::LF,
266            Self::Crlf => crate::model::buffer::LineEnding::CRLF,
267            Self::Cr => crate::model::buffer::LineEnding::CR,
268        }
269    }
270}
271
272impl JsonSchema for LineEndingOption {
273    fn schema_name() -> Cow<'static, str> {
274        Cow::Borrowed("LineEndingOption")
275    }
276
277    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
278        schemars::json_schema!({
279            "description": "Default line ending format for new files",
280            "type": "string",
281            "enum": ["lf", "crlf", "cr"],
282            "default": "lf"
283        })
284    }
285}
286
287impl PartialEq<KeybindingMapName> for str {
288    fn eq(&self, other: &KeybindingMapName) -> bool {
289        self == other.0
290    }
291}
292
293impl JsonSchema for KeybindingMapName {
294    fn schema_name() -> Cow<'static, str> {
295        Cow::Borrowed("KeybindingMapOptions")
296    }
297
298    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
299        schemars::json_schema!({
300            "description": "Available keybinding maps",
301            "type": "string",
302            "enum": Self::BUILTIN_OPTIONS
303        })
304    }
305}
306
307/// Main configuration structure
308#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
309pub struct Config {
310    /// Configuration version (for migration support)
311    /// Configs without this field are treated as version 0
312    #[serde(default)]
313    pub version: u32,
314
315    /// Color theme name
316    #[serde(default = "default_theme_name")]
317    pub theme: ThemeName,
318
319    /// UI locale (language) for translations
320    /// If not set, auto-detected from environment (LC_ALL, LC_MESSAGES, LANG)
321    #[serde(default)]
322    pub locale: LocaleName,
323
324    /// Check for new versions on startup (default: true).
325    /// When enabled, also sends basic anonymous telemetry (version, OS, terminal type).
326    #[serde(default = "default_true")]
327    pub check_for_updates: bool,
328
329    /// Editor behavior settings (indentation, line numbers, wrapping, etc.)
330    #[serde(default)]
331    pub editor: EditorConfig,
332
333    /// File explorer panel settings
334    #[serde(default)]
335    pub file_explorer: FileExplorerConfig,
336
337    /// File browser settings (Open File dialog)
338    #[serde(default)]
339    pub file_browser: FileBrowserConfig,
340
341    /// Terminal settings
342    #[serde(default)]
343    pub terminal: TerminalConfig,
344
345    /// Custom keybindings (overrides for the active map)
346    #[serde(default)]
347    pub keybindings: Vec<Keybinding>,
348
349    /// Named keybinding maps (user can define custom maps here)
350    /// Each map can optionally inherit from another map
351    #[serde(default)]
352    pub keybinding_maps: HashMap<String, KeymapConfig>,
353
354    /// Active keybinding map name
355    #[serde(default = "default_keybinding_map_name")]
356    pub active_keybinding_map: KeybindingMapName,
357
358    /// Per-language configuration overrides (tab size, formatters, etc.)
359    #[serde(default)]
360    pub languages: HashMap<String, LanguageConfig>,
361
362    /// LSP server configurations by language
363    #[serde(default)]
364    pub lsp: HashMap<String, LspServerConfig>,
365
366    /// Warning notification settings
367    #[serde(default)]
368    pub warnings: WarningsConfig,
369
370    /// Plugin configurations by plugin name
371    /// Plugins are auto-discovered from the plugins directory.
372    /// Use this to enable/disable specific plugins.
373    #[serde(default)]
374    #[schemars(extend("x-standalone-category" = true, "x-no-add" = true))]
375    pub plugins: HashMap<String, PluginConfig>,
376}
377
378fn default_keybinding_map_name() -> KeybindingMapName {
379    // On macOS, default to the macOS keymap which has Mac-specific bindings
380    // (Ctrl+A/E for Home/End, Ctrl+Shift+Z for redo, etc.)
381    if cfg!(target_os = "macos") {
382        KeybindingMapName("macos".to_string())
383    } else {
384        KeybindingMapName("default".to_string())
385    }
386}
387
388fn default_theme_name() -> ThemeName {
389    ThemeName("high-contrast".to_string())
390}
391
392/// Editor behavior configuration
393#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
394pub struct EditorConfig {
395    /// Number of spaces per tab character
396    #[serde(default = "default_tab_size")]
397    pub tab_size: usize,
398
399    /// Automatically indent new lines based on the previous line
400    #[serde(default = "default_true")]
401    pub auto_indent: bool,
402
403    /// Show line numbers in the gutter (default for new buffers)
404    #[serde(default = "default_true")]
405    pub line_numbers: bool,
406
407    /// Show line numbers relative to cursor position
408    #[serde(default = "default_false")]
409    pub relative_line_numbers: bool,
410
411    /// Minimum lines to keep visible above/below cursor when scrolling
412    #[serde(default = "default_scroll_offset")]
413    pub scroll_offset: usize,
414
415    /// Enable syntax highlighting for code files
416    #[serde(default = "default_true")]
417    pub syntax_highlighting: bool,
418
419    /// Wrap long lines to fit the window width (default for new views)
420    #[serde(default = "default_true")]
421    pub line_wrap: bool,
422
423    /// Maximum time in milliseconds for syntax highlighting per frame
424    #[serde(default = "default_highlight_timeout")]
425    pub highlight_timeout_ms: u64,
426
427    /// Undo history snapshot interval (number of edits between snapshots)
428    #[serde(default = "default_snapshot_interval")]
429    pub snapshot_interval: usize,
430
431    /// File size threshold in bytes for "large file" behavior
432    /// Files larger than this will:
433    /// - Skip LSP features
434    /// - Use constant-size scrollbar thumb (1 char)
435    ///
436    /// Files smaller will count actual lines for accurate scrollbar rendering
437    #[serde(default = "default_large_file_threshold")]
438    pub large_file_threshold_bytes: u64,
439
440    /// Estimated average line length in bytes (used for large file line estimation)
441    /// This is used by LineIterator to estimate line positions in large files
442    /// without line metadata. Typical values: 80-120 bytes.
443    #[serde(default = "default_estimated_line_length")]
444    pub estimated_line_length: usize,
445
446    /// Whether to enable LSP inlay hints (type hints, parameter hints, etc.)
447    #[serde(default = "default_true")]
448    pub enable_inlay_hints: bool,
449
450    /// Whether to request full-document LSP semantic tokens.
451    /// Range requests are still used when supported.
452    /// Default: false (range-only to avoid heavy full refreshes).
453    #[serde(default = "default_false")]
454    pub enable_semantic_tokens_full: bool,
455
456    /// Whether to enable file recovery (Emacs-style auto-save)
457    /// When enabled, buffers are periodically saved to recovery files
458    /// so they can be recovered if the editor crashes.
459    #[serde(default = "default_true")]
460    pub recovery_enabled: bool,
461
462    /// Auto-save interval in seconds for file recovery
463    /// Modified buffers are saved to recovery files at this interval.
464    /// Default: 2 seconds for fast recovery with minimal data loss.
465    /// Set to 0 to disable periodic auto-save (manual recovery only).
466    #[serde(default = "default_auto_save_interval")]
467    pub auto_save_interval_secs: u32,
468
469    /// Number of bytes to look back/forward from the viewport for syntax highlighting context.
470    /// Larger values improve accuracy for multi-line constructs (strings, comments, nested blocks)
471    /// but may slow down highlighting for very large files.
472    /// Default: 10KB (10000 bytes)
473    #[serde(default = "default_highlight_context_bytes")]
474    pub highlight_context_bytes: usize,
475
476    /// Whether mouse hover triggers LSP hover requests.
477    /// When enabled, hovering over code with the mouse will show documentation.
478    /// Default: true
479    #[serde(default = "default_true")]
480    pub mouse_hover_enabled: bool,
481
482    /// Delay in milliseconds before a mouse hover triggers an LSP hover request.
483    /// Lower values show hover info faster but may cause more LSP server load.
484    /// Default: 500ms
485    #[serde(default = "default_mouse_hover_delay")]
486    pub mouse_hover_delay_ms: u64,
487
488    /// Time window in milliseconds for detecting double-clicks.
489    /// Two clicks within this time are treated as a double-click (word selection).
490    /// Default: 500ms
491    #[serde(default = "default_double_click_time")]
492    pub double_click_time_ms: u64,
493
494    /// Poll interval in milliseconds for auto-reverting open buffers.
495    /// When auto-revert is enabled, file modification times are checked at this interval.
496    /// Lower values detect external changes faster but use more CPU.
497    /// Default: 2000ms (2 seconds)
498    #[serde(default = "default_auto_revert_poll_interval")]
499    pub auto_revert_poll_interval_ms: u64,
500
501    /// Poll interval in milliseconds for refreshing expanded directories in the file explorer.
502    /// Directory modification times are checked at this interval to detect new/deleted files.
503    /// Lower values detect changes faster but use more CPU.
504    /// Default: 3000ms (3 seconds)
505    #[serde(default = "default_file_tree_poll_interval")]
506    pub file_tree_poll_interval_ms: u64,
507
508    /// Default line ending format for new files.
509    /// Files loaded from disk will use their detected line ending format.
510    /// Options: "lf" (Unix/Linux/macOS), "crlf" (Windows), "cr" (Classic Mac)
511    /// Default: "lf"
512    #[serde(default)]
513    pub default_line_ending: LineEndingOption,
514
515    /// Cursor style for the terminal cursor.
516    /// Options: blinking_block, steady_block, blinking_bar, steady_bar, blinking_underline, steady_underline
517    /// Default: blinking_block
518    #[serde(default)]
519    pub cursor_style: CursorStyle,
520
521    /// Enable keyboard enhancement: disambiguate escape codes using CSI-u sequences.
522    /// This allows unambiguous reading of Escape and modified keys.
523    /// Requires terminal support (kitty keyboard protocol).
524    /// Default: true
525    #[serde(default = "default_true")]
526    pub keyboard_disambiguate_escape_codes: bool,
527
528    /// Enable keyboard enhancement: report key event types (repeat/release).
529    /// Adds extra events when keys are autorepeated or released.
530    /// Requires terminal support (kitty keyboard protocol).
531    /// Default: false
532    #[serde(default = "default_false")]
533    pub keyboard_report_event_types: bool,
534
535    /// Enable keyboard enhancement: report alternate keycodes.
536    /// Sends alternate keycodes in addition to the base keycode.
537    /// Requires terminal support (kitty keyboard protocol).
538    /// Default: true
539    #[serde(default = "default_true")]
540    pub keyboard_report_alternate_keys: bool,
541
542    /// Enable keyboard enhancement: report all keys as escape codes.
543    /// Represents all keyboard events as CSI-u sequences.
544    /// Required for repeat/release events on plain-text keys.
545    /// Requires terminal support (kitty keyboard protocol).
546    /// Default: false
547    #[serde(default = "default_false")]
548    pub keyboard_report_all_keys_as_escape_codes: bool,
549
550    /// Enable quick suggestions (VS Code-like behavior).
551    /// When enabled, completion suggestions appear automatically while typing,
552    /// not just on trigger characters (like `.` or `::`).
553    /// Default: true
554    #[serde(default = "default_true")]
555    pub quick_suggestions: bool,
556
557    /// Whether the menu bar is visible by default.
558    /// The menu bar provides access to menus (File, Edit, View, etc.) at the top of the screen.
559    /// Can be toggled at runtime via command palette or keybinding.
560    /// Default: true
561    #[serde(default = "default_true")]
562    pub show_menu_bar: bool,
563
564    /// Whether the tab bar is visible by default.
565    /// The tab bar shows open files in each split pane.
566    /// Can be toggled at runtime via command palette or keybinding.
567    /// Default: true
568    #[serde(default = "default_true")]
569    pub show_tab_bar: bool,
570
571    /// Use the terminal's default background color instead of the theme's editor background.
572    /// When enabled, the editor background inherits from the terminal emulator,
573    /// allowing transparency or custom terminal backgrounds to show through.
574    /// Default: false
575    #[serde(default = "default_false")]
576    pub use_terminal_bg: bool,
577}
578
579fn default_tab_size() -> usize {
580    4
581}
582
583/// Large file threshold in bytes
584/// Files larger than this will use optimized algorithms (estimation, viewport-only parsing)
585/// Files smaller will use exact algorithms (full line tracking, complete parsing)
586pub const LARGE_FILE_THRESHOLD_BYTES: u64 = 1024 * 1024; // 1MB
587
588fn default_large_file_threshold() -> u64 {
589    LARGE_FILE_THRESHOLD_BYTES
590}
591
592fn default_true() -> bool {
593    true
594}
595
596fn default_false() -> bool {
597    false
598}
599
600fn default_scroll_offset() -> usize {
601    3
602}
603
604fn default_highlight_timeout() -> u64 {
605    5
606}
607
608fn default_snapshot_interval() -> usize {
609    100
610}
611
612fn default_estimated_line_length() -> usize {
613    80
614}
615
616fn default_auto_save_interval() -> u32 {
617    2 // Auto-save every 2 seconds for fast recovery
618}
619
620fn default_highlight_context_bytes() -> usize {
621    10_000 // 10KB context for accurate syntax highlighting
622}
623
624fn default_mouse_hover_delay() -> u64 {
625    500 // 500ms delay before showing hover info
626}
627
628fn default_double_click_time() -> u64 {
629    500 // 500ms window for detecting double-clicks
630}
631
632fn default_auto_revert_poll_interval() -> u64 {
633    2000 // 2 seconds between file mtime checks
634}
635
636fn default_file_tree_poll_interval() -> u64 {
637    3000 // 3 seconds between directory mtime checks
638}
639
640impl Default for EditorConfig {
641    fn default() -> Self {
642        Self {
643            tab_size: default_tab_size(),
644            auto_indent: true,
645            line_numbers: true,
646            relative_line_numbers: false,
647            scroll_offset: default_scroll_offset(),
648            syntax_highlighting: true,
649            line_wrap: true,
650            highlight_timeout_ms: default_highlight_timeout(),
651            snapshot_interval: default_snapshot_interval(),
652            large_file_threshold_bytes: default_large_file_threshold(),
653            estimated_line_length: default_estimated_line_length(),
654            enable_inlay_hints: true,
655            enable_semantic_tokens_full: false,
656            recovery_enabled: true,
657            auto_save_interval_secs: default_auto_save_interval(),
658            highlight_context_bytes: default_highlight_context_bytes(),
659            mouse_hover_enabled: true,
660            mouse_hover_delay_ms: default_mouse_hover_delay(),
661            double_click_time_ms: default_double_click_time(),
662            auto_revert_poll_interval_ms: default_auto_revert_poll_interval(),
663            file_tree_poll_interval_ms: default_file_tree_poll_interval(),
664            default_line_ending: LineEndingOption::default(),
665            cursor_style: CursorStyle::default(),
666            keyboard_disambiguate_escape_codes: true,
667            keyboard_report_event_types: false,
668            keyboard_report_alternate_keys: true,
669            keyboard_report_all_keys_as_escape_codes: false,
670            quick_suggestions: true,
671            show_menu_bar: true,
672            show_tab_bar: true,
673            use_terminal_bg: false,
674        }
675    }
676}
677
678/// File explorer configuration
679#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
680pub struct FileExplorerConfig {
681    /// Whether to respect .gitignore files
682    #[serde(default = "default_true")]
683    pub respect_gitignore: bool,
684
685    /// Whether to show hidden files (starting with .) by default
686    #[serde(default = "default_false")]
687    pub show_hidden: bool,
688
689    /// Whether to show gitignored files by default
690    #[serde(default = "default_false")]
691    pub show_gitignored: bool,
692
693    /// Custom patterns to ignore (in addition to .gitignore)
694    #[serde(default)]
695    pub custom_ignore_patterns: Vec<String>,
696
697    /// Width of file explorer as percentage (0.0 to 1.0)
698    #[serde(default = "default_explorer_width")]
699    pub width: f32,
700}
701
702fn default_explorer_width() -> f32 {
703    0.3 // 30% of screen width
704}
705
706/// Terminal configuration
707#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
708pub struct TerminalConfig {
709    /// When viewing terminal scrollback and new output arrives,
710    /// automatically jump back to terminal mode (default: true)
711    #[serde(default = "default_true")]
712    pub jump_to_end_on_output: bool,
713}
714
715impl Default for TerminalConfig {
716    fn default() -> Self {
717        Self {
718            jump_to_end_on_output: true,
719        }
720    }
721}
722
723/// Warning notification configuration
724#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
725pub struct WarningsConfig {
726    /// Show warning/error indicators in the status bar (default: true)
727    /// When enabled, displays a colored indicator for LSP errors and other warnings
728    #[serde(default = "default_true")]
729    pub show_status_indicator: bool,
730}
731
732impl Default for WarningsConfig {
733    fn default() -> Self {
734        Self {
735            show_status_indicator: true,
736        }
737    }
738}
739
740// Re-export PluginConfig from fresh-core for shared type usage
741pub use fresh_core::config::PluginConfig;
742
743impl Default for FileExplorerConfig {
744    fn default() -> Self {
745        Self {
746            respect_gitignore: true,
747            show_hidden: false,
748            show_gitignored: false,
749            custom_ignore_patterns: Vec::new(),
750            width: default_explorer_width(),
751        }
752    }
753}
754
755/// File browser configuration (for Open File dialog)
756#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
757pub struct FileBrowserConfig {
758    /// Whether to show hidden files (starting with .) by default in Open File dialog
759    #[serde(default = "default_false")]
760    pub show_hidden: bool,
761}
762
763/// A single key in a sequence
764#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
765pub struct KeyPress {
766    /// Key name (e.g., "a", "Enter", "F1")
767    pub key: String,
768    /// Modifiers (e.g., ["ctrl"], ["ctrl", "shift"])
769    #[serde(default)]
770    pub modifiers: Vec<String>,
771}
772
773/// Keybinding definition
774#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
775#[schemars(extend("x-display-field" = "/action"))]
776pub struct Keybinding {
777    /// Key name (e.g., "a", "Enter", "F1") - for single-key bindings
778    #[serde(default, skip_serializing_if = "String::is_empty")]
779    pub key: String,
780
781    /// Modifiers (e.g., ["ctrl"], ["ctrl", "shift"]) - for single-key bindings
782    #[serde(default, skip_serializing_if = "Vec::is_empty")]
783    pub modifiers: Vec<String>,
784
785    /// Key sequence for chord bindings (e.g., [{"key": "x", "modifiers": ["ctrl"]}, {"key": "s", "modifiers": ["ctrl"]}])
786    /// If present, takes precedence over key + modifiers
787    #[serde(default, skip_serializing_if = "Vec::is_empty")]
788    pub keys: Vec<KeyPress>,
789
790    /// Action to perform (e.g., "insert_char", "move_left")
791    pub action: String,
792
793    /// Optional arguments for the action
794    #[serde(default)]
795    pub args: HashMap<String, serde_json::Value>,
796
797    /// Optional condition (e.g., "mode == insert")
798    #[serde(default)]
799    pub when: Option<String>,
800}
801
802/// Keymap configuration (for built-in and user-defined keymaps)
803#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
804#[schemars(extend("x-display-field" = "/inherits"))]
805pub struct KeymapConfig {
806    /// Optional parent keymap to inherit from
807    #[serde(default, skip_serializing_if = "Option::is_none")]
808    pub inherits: Option<String>,
809
810    /// Keybindings defined in this keymap
811    #[serde(default)]
812    pub bindings: Vec<Keybinding>,
813}
814
815/// Formatter configuration for a language
816#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
817#[schemars(extend("x-display-field" = "/command"))]
818pub struct FormatterConfig {
819    /// The formatter command to run (e.g., "rustfmt", "prettier")
820    pub command: String,
821
822    /// Arguments to pass to the formatter
823    /// Use "$FILE" to include the file path
824    #[serde(default)]
825    pub args: Vec<String>,
826
827    /// Whether to pass buffer content via stdin (default: true)
828    /// Most formatters read from stdin and write to stdout
829    #[serde(default = "default_true")]
830    pub stdin: bool,
831
832    /// Timeout in milliseconds (default: 10000)
833    #[serde(default = "default_on_save_timeout")]
834    pub timeout_ms: u64,
835}
836
837/// Action to run when a file is saved (for linters, etc.)
838#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
839#[schemars(extend("x-display-field" = "/command"))]
840pub struct OnSaveAction {
841    /// The shell command to run
842    /// The file path is available as $FILE or as an argument
843    pub command: String,
844
845    /// Arguments to pass to the command
846    /// Use "$FILE" to include the file path
847    #[serde(default)]
848    pub args: Vec<String>,
849
850    /// Working directory for the command (defaults to project root)
851    #[serde(default)]
852    pub working_dir: Option<String>,
853
854    /// Whether to use the buffer content as stdin
855    #[serde(default)]
856    pub stdin: bool,
857
858    /// Timeout in milliseconds (default: 10000)
859    #[serde(default = "default_on_save_timeout")]
860    pub timeout_ms: u64,
861
862    /// Whether this action is enabled (default: true)
863    /// Set to false to disable an action without removing it from config
864    #[serde(default = "default_true")]
865    pub enabled: bool,
866}
867
868fn default_on_save_timeout() -> u64 {
869    10000
870}
871
872/// Language-specific configuration
873#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
874#[schemars(extend("x-display-field" = "/grammar"))]
875pub struct LanguageConfig {
876    /// File extensions for this language (e.g., ["rs"] for Rust)
877    #[serde(default)]
878    pub extensions: Vec<String>,
879
880    /// Exact filenames for this language (e.g., ["Makefile", "GNUmakefile"])
881    #[serde(default)]
882    pub filenames: Vec<String>,
883
884    /// Tree-sitter grammar name
885    #[serde(default)]
886    pub grammar: String,
887
888    /// Comment prefix
889    #[serde(default)]
890    pub comment_prefix: Option<String>,
891
892    /// Whether to auto-indent
893    #[serde(default = "default_true")]
894    pub auto_indent: bool,
895
896    /// Preferred highlighter backend (auto, tree-sitter, or textmate)
897    #[serde(default)]
898    pub highlighter: HighlighterPreference,
899
900    /// Path to custom TextMate grammar file (optional)
901    /// If specified, this grammar will be used when highlighter is "textmate"
902    #[serde(default)]
903    pub textmate_grammar: Option<std::path::PathBuf>,
904
905    /// Whether to show whitespace tab indicators (→) for this language
906    /// Defaults to true. Set to false for languages like Go that use tabs for indentation.
907    #[serde(default = "default_true")]
908    pub show_whitespace_tabs: bool,
909
910    /// Whether pressing Tab should insert a tab character instead of spaces.
911    /// Defaults to false (insert spaces based on tab_size).
912    /// Set to true for languages like Go and Makefile that require tabs.
913    #[serde(default = "default_false")]
914    pub use_tabs: bool,
915
916    /// Tab size (number of spaces per tab) for this language.
917    /// If not specified, falls back to the global editor.tab_size setting.
918    #[serde(default)]
919    pub tab_size: Option<usize>,
920
921    /// The formatter for this language (used by format_buffer command)
922    #[serde(default)]
923    pub formatter: Option<FormatterConfig>,
924
925    /// Whether to automatically format on save (uses the formatter above)
926    #[serde(default)]
927    pub format_on_save: bool,
928
929    /// Actions to run when a file of this language is saved (linters, etc.)
930    /// Actions are run in order; if any fails (non-zero exit), subsequent actions don't run
931    /// Note: Use `formatter` + `format_on_save` for formatting, not on_save
932    #[serde(default)]
933    pub on_save: Vec<OnSaveAction>,
934}
935
936/// Resolved editor configuration for a specific buffer.
937///
938/// This struct contains the effective settings for a buffer after applying
939/// language-specific overrides on top of the global editor config.
940///
941/// Use `BufferConfig::resolve()` to create one from a Config and optional language ID.
942#[derive(Debug, Clone)]
943pub struct BufferConfig {
944    /// Number of spaces per tab character
945    pub tab_size: usize,
946
947    /// Whether to insert a tab character (true) or spaces (false) when pressing Tab
948    pub use_tabs: bool,
949
950    /// Whether to auto-indent new lines
951    pub auto_indent: bool,
952
953    /// Whether to show whitespace tab indicators (→)
954    pub show_whitespace_tabs: bool,
955
956    /// Formatter command for this buffer
957    pub formatter: Option<FormatterConfig>,
958
959    /// Whether to format on save
960    pub format_on_save: bool,
961
962    /// Actions to run when saving
963    pub on_save: Vec<OnSaveAction>,
964
965    /// Preferred highlighter backend
966    pub highlighter: HighlighterPreference,
967
968    /// Path to custom TextMate grammar (if any)
969    pub textmate_grammar: Option<std::path::PathBuf>,
970}
971
972impl BufferConfig {
973    /// Resolve the effective configuration for a buffer given its language.
974    ///
975    /// This merges the global editor settings with any language-specific overrides
976    /// from `Config.languages`.
977    ///
978    /// # Arguments
979    /// * `global_config` - The resolved global configuration
980    /// * `language_id` - Optional language identifier (e.g., "rust", "python")
981    pub fn resolve(global_config: &Config, language_id: Option<&str>) -> Self {
982        let editor = &global_config.editor;
983
984        // Start with global editor settings
985        let mut config = BufferConfig {
986            tab_size: editor.tab_size,
987            use_tabs: false, // Global default is spaces
988            auto_indent: editor.auto_indent,
989            show_whitespace_tabs: true, // Global default
990            formatter: None,
991            format_on_save: false,
992            on_save: Vec::new(),
993            highlighter: HighlighterPreference::Auto,
994            textmate_grammar: None,
995        };
996
997        // Apply language-specific overrides if available
998        if let Some(lang_id) = language_id {
999            if let Some(lang_config) = global_config.languages.get(lang_id) {
1000                // Tab size: use language setting if specified, else global
1001                if let Some(ts) = lang_config.tab_size {
1002                    config.tab_size = ts;
1003                }
1004
1005                // Use tabs: language override
1006                config.use_tabs = lang_config.use_tabs;
1007
1008                // Auto indent: language override
1009                config.auto_indent = lang_config.auto_indent;
1010
1011                // Show whitespace tabs: language override
1012                config.show_whitespace_tabs = lang_config.show_whitespace_tabs;
1013
1014                // Formatter: from language config
1015                config.formatter = lang_config.formatter.clone();
1016
1017                // Format on save: from language config
1018                config.format_on_save = lang_config.format_on_save;
1019
1020                // On save actions: from language config
1021                config.on_save = lang_config.on_save.clone();
1022
1023                // Highlighter preference: from language config
1024                config.highlighter = lang_config.highlighter;
1025
1026                // TextMate grammar path: from language config
1027                config.textmate_grammar = lang_config.textmate_grammar.clone();
1028            }
1029        }
1030
1031        config
1032    }
1033
1034    /// Get the effective indentation string for this buffer.
1035    ///
1036    /// Returns a tab character if `use_tabs` is true, otherwise returns
1037    /// `tab_size` spaces.
1038    pub fn indent_string(&self) -> String {
1039        if self.use_tabs {
1040            "\t".to_string()
1041        } else {
1042            " ".repeat(self.tab_size)
1043        }
1044    }
1045}
1046
1047/// Preference for which syntax highlighting backend to use
1048#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1049#[serde(rename_all = "lowercase")]
1050pub enum HighlighterPreference {
1051    /// Use tree-sitter if available, fall back to TextMate
1052    #[default]
1053    Auto,
1054    /// Force tree-sitter only (no highlighting if unavailable)
1055    #[serde(rename = "tree-sitter")]
1056    TreeSitter,
1057    /// Force TextMate grammar (skip tree-sitter even if available)
1058    #[serde(rename = "textmate")]
1059    TextMate,
1060}
1061
1062/// Menu bar configuration
1063#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1064pub struct MenuConfig {
1065    /// List of top-level menus in the menu bar
1066    #[serde(default)]
1067    pub menus: Vec<Menu>,
1068}
1069
1070// Re-export Menu and MenuItem from fresh-core for shared type usage
1071pub use fresh_core::menu::{Menu, MenuItem};
1072
1073/// Extension trait for Menu with editor-specific functionality
1074pub trait MenuExt {
1075    /// Get the identifier for matching (id if set, otherwise label).
1076    /// This is used for keybinding matching and should be stable across translations.
1077    fn match_id(&self) -> &str;
1078
1079    /// Expand all DynamicSubmenu items in this menu to regular Submenu items
1080    /// This should be called before the menu is used for rendering/navigation
1081    fn expand_dynamic_items(&mut self);
1082}
1083
1084impl MenuExt for Menu {
1085    fn match_id(&self) -> &str {
1086        self.id.as_deref().unwrap_or(&self.label)
1087    }
1088
1089    fn expand_dynamic_items(&mut self) {
1090        self.items = self
1091            .items
1092            .iter()
1093            .map(|item| item.expand_dynamic())
1094            .collect();
1095    }
1096}
1097
1098/// Extension trait for MenuItem with editor-specific functionality
1099pub trait MenuItemExt {
1100    /// Expand a DynamicSubmenu into a regular Submenu with generated items.
1101    /// Returns the original item if not a DynamicSubmenu.
1102    fn expand_dynamic(&self) -> MenuItem;
1103}
1104
1105impl MenuItemExt for MenuItem {
1106    fn expand_dynamic(&self) -> MenuItem {
1107        match self {
1108            MenuItem::DynamicSubmenu { label, source } => {
1109                let items = generate_dynamic_items(source);
1110                MenuItem::Submenu {
1111                    label: label.clone(),
1112                    items,
1113                }
1114            }
1115            other => other.clone(),
1116        }
1117    }
1118}
1119
1120/// Generate menu items for a dynamic source
1121pub fn generate_dynamic_items(source: &str) -> Vec<MenuItem> {
1122    match source {
1123        "copy_with_theme" => {
1124            // Generate theme options from available themes
1125            let theme_loader = crate::view::theme::LocalThemeLoader::new();
1126            crate::view::theme::Theme::all_available(&theme_loader)
1127                .into_iter()
1128                .map(|theme_name| {
1129                    let mut args = HashMap::new();
1130                    args.insert("theme".to_string(), serde_json::json!(theme_name));
1131                    MenuItem::Action {
1132                        label: theme_name.to_string(),
1133                        action: "copy_with_theme".to_string(),
1134                        args,
1135                        when: Some(context_keys::HAS_SELECTION.to_string()),
1136                        checkbox: None,
1137                    }
1138                })
1139                .collect()
1140        }
1141        _ => vec![MenuItem::Label {
1142            info: format!("Unknown source: {}", source),
1143        }],
1144    }
1145}
1146
1147impl Default for Config {
1148    fn default() -> Self {
1149        Self {
1150            version: 0,
1151            theme: default_theme_name(),
1152            locale: LocaleName::default(),
1153            check_for_updates: true,
1154            editor: EditorConfig::default(),
1155            file_explorer: FileExplorerConfig::default(),
1156            file_browser: FileBrowserConfig::default(),
1157            terminal: TerminalConfig::default(),
1158            keybindings: vec![], // User customizations only; defaults come from active_keybinding_map
1159            keybinding_maps: HashMap::new(), // User-defined maps go here
1160            active_keybinding_map: default_keybinding_map_name(),
1161            languages: Self::default_languages(),
1162            lsp: Self::default_lsp_config(),
1163            warnings: WarningsConfig::default(),
1164            plugins: HashMap::new(), // Populated when scanning for plugins
1165        }
1166    }
1167}
1168
1169impl MenuConfig {
1170    /// Create a MenuConfig with translated menus using the current locale
1171    pub fn translated() -> Self {
1172        Self {
1173            menus: Self::translated_menus(),
1174        }
1175    }
1176
1177    /// Create default menu bar configuration with translated labels
1178    fn translated_menus() -> Vec<Menu> {
1179        vec![
1180            // File menu
1181            Menu {
1182                id: Some("File".to_string()),
1183                label: t!("menu.file").to_string(),
1184                items: vec![
1185                    MenuItem::Action {
1186                        label: t!("menu.file.new_file").to_string(),
1187                        action: "new".to_string(),
1188                        args: HashMap::new(),
1189                        when: None,
1190                        checkbox: None,
1191                    },
1192                    MenuItem::Action {
1193                        label: t!("menu.file.open_file").to_string(),
1194                        action: "open".to_string(),
1195                        args: HashMap::new(),
1196                        when: None,
1197                        checkbox: None,
1198                    },
1199                    MenuItem::Separator { separator: true },
1200                    MenuItem::Action {
1201                        label: t!("menu.file.save").to_string(),
1202                        action: "save".to_string(),
1203                        args: HashMap::new(),
1204                        when: None,
1205                        checkbox: None,
1206                    },
1207                    MenuItem::Action {
1208                        label: t!("menu.file.save_as").to_string(),
1209                        action: "save_as".to_string(),
1210                        args: HashMap::new(),
1211                        when: None,
1212                        checkbox: None,
1213                    },
1214                    MenuItem::Action {
1215                        label: t!("menu.file.revert").to_string(),
1216                        action: "revert".to_string(),
1217                        args: HashMap::new(),
1218                        when: None,
1219                        checkbox: None,
1220                    },
1221                    MenuItem::Separator { separator: true },
1222                    MenuItem::Action {
1223                        label: t!("menu.file.close_buffer").to_string(),
1224                        action: "close".to_string(),
1225                        args: HashMap::new(),
1226                        when: None,
1227                        checkbox: None,
1228                    },
1229                    MenuItem::Separator { separator: true },
1230                    MenuItem::Action {
1231                        label: t!("menu.file.switch_project").to_string(),
1232                        action: "switch_project".to_string(),
1233                        args: HashMap::new(),
1234                        when: None,
1235                        checkbox: None,
1236                    },
1237                    MenuItem::Action {
1238                        label: t!("menu.file.quit").to_string(),
1239                        action: "quit".to_string(),
1240                        args: HashMap::new(),
1241                        when: None,
1242                        checkbox: None,
1243                    },
1244                ],
1245            },
1246            // Edit menu
1247            Menu {
1248                id: Some("Edit".to_string()),
1249                label: t!("menu.edit").to_string(),
1250                items: vec![
1251                    MenuItem::Action {
1252                        label: t!("menu.edit.undo").to_string(),
1253                        action: "undo".to_string(),
1254                        args: HashMap::new(),
1255                        when: None,
1256                        checkbox: None,
1257                    },
1258                    MenuItem::Action {
1259                        label: t!("menu.edit.redo").to_string(),
1260                        action: "redo".to_string(),
1261                        args: HashMap::new(),
1262                        when: None,
1263                        checkbox: None,
1264                    },
1265                    MenuItem::Separator { separator: true },
1266                    MenuItem::Action {
1267                        label: t!("menu.edit.cut").to_string(),
1268                        action: "cut".to_string(),
1269                        args: HashMap::new(),
1270                        when: Some(context_keys::HAS_SELECTION.to_string()),
1271                        checkbox: None,
1272                    },
1273                    MenuItem::Action {
1274                        label: t!("menu.edit.copy").to_string(),
1275                        action: "copy".to_string(),
1276                        args: HashMap::new(),
1277                        when: Some(context_keys::HAS_SELECTION.to_string()),
1278                        checkbox: None,
1279                    },
1280                    MenuItem::DynamicSubmenu {
1281                        label: t!("menu.edit.copy_with_formatting").to_string(),
1282                        source: "copy_with_theme".to_string(),
1283                    },
1284                    MenuItem::Action {
1285                        label: t!("menu.edit.paste").to_string(),
1286                        action: "paste".to_string(),
1287                        args: HashMap::new(),
1288                        when: None,
1289                        checkbox: None,
1290                    },
1291                    MenuItem::Separator { separator: true },
1292                    MenuItem::Action {
1293                        label: t!("menu.edit.select_all").to_string(),
1294                        action: "select_all".to_string(),
1295                        args: HashMap::new(),
1296                        when: None,
1297                        checkbox: None,
1298                    },
1299                    MenuItem::Separator { separator: true },
1300                    MenuItem::Action {
1301                        label: t!("menu.edit.find").to_string(),
1302                        action: "search".to_string(),
1303                        args: HashMap::new(),
1304                        when: None,
1305                        checkbox: None,
1306                    },
1307                    MenuItem::Action {
1308                        label: t!("menu.edit.find_in_selection").to_string(),
1309                        action: "find_in_selection".to_string(),
1310                        args: HashMap::new(),
1311                        when: Some(context_keys::HAS_SELECTION.to_string()),
1312                        checkbox: None,
1313                    },
1314                    MenuItem::Action {
1315                        label: t!("menu.edit.find_next").to_string(),
1316                        action: "find_next".to_string(),
1317                        args: HashMap::new(),
1318                        when: None,
1319                        checkbox: None,
1320                    },
1321                    MenuItem::Action {
1322                        label: t!("menu.edit.find_previous").to_string(),
1323                        action: "find_previous".to_string(),
1324                        args: HashMap::new(),
1325                        when: None,
1326                        checkbox: None,
1327                    },
1328                    MenuItem::Action {
1329                        label: t!("menu.edit.replace").to_string(),
1330                        action: "query_replace".to_string(),
1331                        args: HashMap::new(),
1332                        when: None,
1333                        checkbox: None,
1334                    },
1335                    MenuItem::Separator { separator: true },
1336                    MenuItem::Action {
1337                        label: t!("menu.edit.delete_line").to_string(),
1338                        action: "delete_line".to_string(),
1339                        args: HashMap::new(),
1340                        when: None,
1341                        checkbox: None,
1342                    },
1343                    MenuItem::Action {
1344                        label: t!("menu.edit.format_buffer").to_string(),
1345                        action: "format_buffer".to_string(),
1346                        args: HashMap::new(),
1347                        when: Some(context_keys::FORMATTER_AVAILABLE.to_string()),
1348                        checkbox: None,
1349                    },
1350                ],
1351            },
1352            // View menu
1353            Menu {
1354                id: Some("View".to_string()),
1355                label: t!("menu.view").to_string(),
1356                items: vec![
1357                    MenuItem::Action {
1358                        label: t!("menu.view.file_explorer").to_string(),
1359                        action: "toggle_file_explorer".to_string(),
1360                        args: HashMap::new(),
1361                        when: None,
1362                        checkbox: Some(context_keys::FILE_EXPLORER.to_string()),
1363                    },
1364                    MenuItem::Separator { separator: true },
1365                    MenuItem::Action {
1366                        label: t!("menu.view.line_numbers").to_string(),
1367                        action: "toggle_line_numbers".to_string(),
1368                        args: HashMap::new(),
1369                        when: None,
1370                        checkbox: Some(context_keys::LINE_NUMBERS.to_string()),
1371                    },
1372                    MenuItem::Action {
1373                        label: t!("menu.view.line_wrap").to_string(),
1374                        action: "toggle_line_wrap".to_string(),
1375                        args: HashMap::new(),
1376                        when: None,
1377                        checkbox: Some(context_keys::LINE_WRAP.to_string()),
1378                    },
1379                    MenuItem::Action {
1380                        label: t!("menu.view.mouse_support").to_string(),
1381                        action: "toggle_mouse_capture".to_string(),
1382                        args: HashMap::new(),
1383                        when: None,
1384                        checkbox: Some(context_keys::MOUSE_CAPTURE.to_string()),
1385                    },
1386                    MenuItem::Separator { separator: true },
1387                    MenuItem::Action {
1388                        label: t!("menu.view.set_background").to_string(),
1389                        action: "set_background".to_string(),
1390                        args: HashMap::new(),
1391                        when: None,
1392                        checkbox: None,
1393                    },
1394                    MenuItem::Action {
1395                        label: t!("menu.view.set_background_blend").to_string(),
1396                        action: "set_background_blend".to_string(),
1397                        args: HashMap::new(),
1398                        when: None,
1399                        checkbox: None,
1400                    },
1401                    MenuItem::Action {
1402                        label: t!("menu.view.set_compose_width").to_string(),
1403                        action: "set_compose_width".to_string(),
1404                        args: HashMap::new(),
1405                        when: None,
1406                        checkbox: None,
1407                    },
1408                    MenuItem::Separator { separator: true },
1409                    MenuItem::Action {
1410                        label: t!("menu.view.select_theme").to_string(),
1411                        action: "select_theme".to_string(),
1412                        args: HashMap::new(),
1413                        when: None,
1414                        checkbox: None,
1415                    },
1416                    MenuItem::Action {
1417                        label: t!("menu.view.select_locale").to_string(),
1418                        action: "select_locale".to_string(),
1419                        args: HashMap::new(),
1420                        when: None,
1421                        checkbox: None,
1422                    },
1423                    MenuItem::Action {
1424                        label: t!("menu.view.settings").to_string(),
1425                        action: "open_settings".to_string(),
1426                        args: HashMap::new(),
1427                        when: None,
1428                        checkbox: None,
1429                    },
1430                    MenuItem::Action {
1431                        label: t!("menu.view.calibrate_input").to_string(),
1432                        action: "calibrate_input".to_string(),
1433                        args: HashMap::new(),
1434                        when: None,
1435                        checkbox: None,
1436                    },
1437                    MenuItem::Separator { separator: true },
1438                    MenuItem::Action {
1439                        label: t!("menu.view.split_horizontal").to_string(),
1440                        action: "split_horizontal".to_string(),
1441                        args: HashMap::new(),
1442                        when: None,
1443                        checkbox: None,
1444                    },
1445                    MenuItem::Action {
1446                        label: t!("menu.view.split_vertical").to_string(),
1447                        action: "split_vertical".to_string(),
1448                        args: HashMap::new(),
1449                        when: None,
1450                        checkbox: None,
1451                    },
1452                    MenuItem::Action {
1453                        label: t!("menu.view.close_split").to_string(),
1454                        action: "close_split".to_string(),
1455                        args: HashMap::new(),
1456                        when: None,
1457                        checkbox: None,
1458                    },
1459                    MenuItem::Action {
1460                        label: t!("menu.view.focus_next_split").to_string(),
1461                        action: "next_split".to_string(),
1462                        args: HashMap::new(),
1463                        when: None,
1464                        checkbox: None,
1465                    },
1466                    MenuItem::Action {
1467                        label: t!("menu.view.focus_prev_split").to_string(),
1468                        action: "prev_split".to_string(),
1469                        args: HashMap::new(),
1470                        when: None,
1471                        checkbox: None,
1472                    },
1473                    MenuItem::Action {
1474                        label: t!("menu.view.toggle_maximize_split").to_string(),
1475                        action: "toggle_maximize_split".to_string(),
1476                        args: HashMap::new(),
1477                        when: None,
1478                        checkbox: None,
1479                    },
1480                    MenuItem::Separator { separator: true },
1481                    MenuItem::Submenu {
1482                        label: t!("menu.terminal").to_string(),
1483                        items: vec![
1484                            MenuItem::Action {
1485                                label: t!("menu.terminal.open").to_string(),
1486                                action: "open_terminal".to_string(),
1487                                args: HashMap::new(),
1488                                when: None,
1489                                checkbox: None,
1490                            },
1491                            MenuItem::Action {
1492                                label: t!("menu.terminal.close").to_string(),
1493                                action: "close_terminal".to_string(),
1494                                args: HashMap::new(),
1495                                when: None,
1496                                checkbox: None,
1497                            },
1498                            MenuItem::Separator { separator: true },
1499                            MenuItem::Action {
1500                                label: t!("menu.terminal.toggle_keyboard_capture").to_string(),
1501                                action: "toggle_keyboard_capture".to_string(),
1502                                args: HashMap::new(),
1503                                when: None,
1504                                checkbox: None,
1505                            },
1506                        ],
1507                    },
1508                    MenuItem::Separator { separator: true },
1509                    MenuItem::Submenu {
1510                        label: t!("menu.view.keybinding_style").to_string(),
1511                        items: vec![
1512                            MenuItem::Action {
1513                                label: t!("menu.view.keybinding_default").to_string(),
1514                                action: "switch_keybinding_map".to_string(),
1515                                args: {
1516                                    let mut map = HashMap::new();
1517                                    map.insert("map".to_string(), serde_json::json!("default"));
1518                                    map
1519                                },
1520                                when: None,
1521                                checkbox: None,
1522                            },
1523                            MenuItem::Action {
1524                                label: t!("menu.view.keybinding_emacs").to_string(),
1525                                action: "switch_keybinding_map".to_string(),
1526                                args: {
1527                                    let mut map = HashMap::new();
1528                                    map.insert("map".to_string(), serde_json::json!("emacs"));
1529                                    map
1530                                },
1531                                when: None,
1532                                checkbox: None,
1533                            },
1534                            MenuItem::Action {
1535                                label: t!("menu.view.keybinding_vscode").to_string(),
1536                                action: "switch_keybinding_map".to_string(),
1537                                args: {
1538                                    let mut map = HashMap::new();
1539                                    map.insert("map".to_string(), serde_json::json!("vscode"));
1540                                    map
1541                                },
1542                                when: None,
1543                                checkbox: None,
1544                            },
1545                        ],
1546                    },
1547                ],
1548            },
1549            // Selection menu
1550            Menu {
1551                id: Some("Selection".to_string()),
1552                label: t!("menu.selection").to_string(),
1553                items: vec![
1554                    MenuItem::Action {
1555                        label: t!("menu.selection.select_all").to_string(),
1556                        action: "select_all".to_string(),
1557                        args: HashMap::new(),
1558                        when: None,
1559                        checkbox: None,
1560                    },
1561                    MenuItem::Action {
1562                        label: t!("menu.selection.select_word").to_string(),
1563                        action: "select_word".to_string(),
1564                        args: HashMap::new(),
1565                        when: None,
1566                        checkbox: None,
1567                    },
1568                    MenuItem::Action {
1569                        label: t!("menu.selection.select_line").to_string(),
1570                        action: "select_line".to_string(),
1571                        args: HashMap::new(),
1572                        when: None,
1573                        checkbox: None,
1574                    },
1575                    MenuItem::Action {
1576                        label: t!("menu.selection.expand_selection").to_string(),
1577                        action: "expand_selection".to_string(),
1578                        args: HashMap::new(),
1579                        when: None,
1580                        checkbox: None,
1581                    },
1582                    MenuItem::Separator { separator: true },
1583                    MenuItem::Action {
1584                        label: t!("menu.selection.add_cursor_above").to_string(),
1585                        action: "add_cursor_above".to_string(),
1586                        args: HashMap::new(),
1587                        when: None,
1588                        checkbox: None,
1589                    },
1590                    MenuItem::Action {
1591                        label: t!("menu.selection.add_cursor_below").to_string(),
1592                        action: "add_cursor_below".to_string(),
1593                        args: HashMap::new(),
1594                        when: None,
1595                        checkbox: None,
1596                    },
1597                    MenuItem::Action {
1598                        label: t!("menu.selection.add_cursor_next_match").to_string(),
1599                        action: "add_cursor_next_match".to_string(),
1600                        args: HashMap::new(),
1601                        when: None,
1602                        checkbox: None,
1603                    },
1604                    MenuItem::Action {
1605                        label: t!("menu.selection.remove_secondary_cursors").to_string(),
1606                        action: "remove_secondary_cursors".to_string(),
1607                        args: HashMap::new(),
1608                        when: None,
1609                        checkbox: None,
1610                    },
1611                ],
1612            },
1613            // Go menu
1614            Menu {
1615                id: Some("Go".to_string()),
1616                label: t!("menu.go").to_string(),
1617                items: vec![
1618                    MenuItem::Action {
1619                        label: t!("menu.go.goto_line").to_string(),
1620                        action: "goto_line".to_string(),
1621                        args: HashMap::new(),
1622                        when: None,
1623                        checkbox: None,
1624                    },
1625                    MenuItem::Action {
1626                        label: t!("menu.go.goto_definition").to_string(),
1627                        action: "lsp_goto_definition".to_string(),
1628                        args: HashMap::new(),
1629                        when: None,
1630                        checkbox: None,
1631                    },
1632                    MenuItem::Action {
1633                        label: t!("menu.go.find_references").to_string(),
1634                        action: "lsp_references".to_string(),
1635                        args: HashMap::new(),
1636                        when: None,
1637                        checkbox: None,
1638                    },
1639                    MenuItem::Separator { separator: true },
1640                    MenuItem::Action {
1641                        label: t!("menu.go.next_buffer").to_string(),
1642                        action: "next_buffer".to_string(),
1643                        args: HashMap::new(),
1644                        when: None,
1645                        checkbox: None,
1646                    },
1647                    MenuItem::Action {
1648                        label: t!("menu.go.prev_buffer").to_string(),
1649                        action: "prev_buffer".to_string(),
1650                        args: HashMap::new(),
1651                        when: None,
1652                        checkbox: None,
1653                    },
1654                    MenuItem::Separator { separator: true },
1655                    MenuItem::Action {
1656                        label: t!("menu.go.command_palette").to_string(),
1657                        action: "command_palette".to_string(),
1658                        args: HashMap::new(),
1659                        when: None,
1660                        checkbox: None,
1661                    },
1662                ],
1663            },
1664            // LSP menu
1665            Menu {
1666                id: Some("LSP".to_string()),
1667                label: t!("menu.lsp").to_string(),
1668                items: vec![
1669                    MenuItem::Action {
1670                        label: t!("menu.lsp.show_hover").to_string(),
1671                        action: "lsp_hover".to_string(),
1672                        args: HashMap::new(),
1673                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
1674                        checkbox: None,
1675                    },
1676                    MenuItem::Action {
1677                        label: t!("menu.lsp.goto_definition").to_string(),
1678                        action: "lsp_goto_definition".to_string(),
1679                        args: HashMap::new(),
1680                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
1681                        checkbox: None,
1682                    },
1683                    MenuItem::Action {
1684                        label: t!("menu.lsp.find_references").to_string(),
1685                        action: "lsp_references".to_string(),
1686                        args: HashMap::new(),
1687                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
1688                        checkbox: None,
1689                    },
1690                    MenuItem::Action {
1691                        label: t!("menu.lsp.rename_symbol").to_string(),
1692                        action: "lsp_rename".to_string(),
1693                        args: HashMap::new(),
1694                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
1695                        checkbox: None,
1696                    },
1697                    MenuItem::Separator { separator: true },
1698                    MenuItem::Action {
1699                        label: t!("menu.lsp.show_completions").to_string(),
1700                        action: "lsp_completion".to_string(),
1701                        args: HashMap::new(),
1702                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
1703                        checkbox: None,
1704                    },
1705                    MenuItem::Action {
1706                        label: t!("menu.lsp.show_signature").to_string(),
1707                        action: "lsp_signature_help".to_string(),
1708                        args: HashMap::new(),
1709                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
1710                        checkbox: None,
1711                    },
1712                    MenuItem::Action {
1713                        label: t!("menu.lsp.code_actions").to_string(),
1714                        action: "lsp_code_actions".to_string(),
1715                        args: HashMap::new(),
1716                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
1717                        checkbox: None,
1718                    },
1719                    MenuItem::Separator { separator: true },
1720                    MenuItem::Action {
1721                        label: t!("menu.lsp.toggle_inlay_hints").to_string(),
1722                        action: "toggle_inlay_hints".to_string(),
1723                        args: HashMap::new(),
1724                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
1725                        checkbox: Some(context_keys::INLAY_HINTS.to_string()),
1726                    },
1727                    MenuItem::Action {
1728                        label: t!("menu.lsp.toggle_mouse_hover").to_string(),
1729                        action: "toggle_mouse_hover".to_string(),
1730                        args: HashMap::new(),
1731                        when: None,
1732                        checkbox: Some(context_keys::MOUSE_HOVER.to_string()),
1733                    },
1734                    MenuItem::Separator { separator: true },
1735                    MenuItem::Action {
1736                        label: t!("menu.lsp.restart_server").to_string(),
1737                        action: "lsp_restart".to_string(),
1738                        args: HashMap::new(),
1739                        when: None,
1740                        checkbox: None,
1741                    },
1742                    MenuItem::Action {
1743                        label: t!("menu.lsp.stop_server").to_string(),
1744                        action: "lsp_stop".to_string(),
1745                        args: HashMap::new(),
1746                        when: None,
1747                        checkbox: None,
1748                    },
1749                ],
1750            },
1751            // Explorer menu
1752            Menu {
1753                id: Some("Explorer".to_string()),
1754                label: t!("menu.explorer").to_string(),
1755                items: vec![
1756                    MenuItem::Action {
1757                        label: t!("menu.explorer.new_file").to_string(),
1758                        action: "file_explorer_new_file".to_string(),
1759                        args: HashMap::new(),
1760                        when: Some(context_keys::FILE_EXPLORER_FOCUSED.to_string()),
1761                        checkbox: None,
1762                    },
1763                    MenuItem::Action {
1764                        label: t!("menu.explorer.new_folder").to_string(),
1765                        action: "file_explorer_new_directory".to_string(),
1766                        args: HashMap::new(),
1767                        when: Some(context_keys::FILE_EXPLORER_FOCUSED.to_string()),
1768                        checkbox: None,
1769                    },
1770                    MenuItem::Separator { separator: true },
1771                    MenuItem::Action {
1772                        label: t!("menu.explorer.open").to_string(),
1773                        action: "file_explorer_open".to_string(),
1774                        args: HashMap::new(),
1775                        when: Some(context_keys::FILE_EXPLORER_FOCUSED.to_string()),
1776                        checkbox: None,
1777                    },
1778                    MenuItem::Action {
1779                        label: t!("menu.explorer.rename").to_string(),
1780                        action: "file_explorer_rename".to_string(),
1781                        args: HashMap::new(),
1782                        when: Some(context_keys::FILE_EXPLORER_FOCUSED.to_string()),
1783                        checkbox: None,
1784                    },
1785                    MenuItem::Action {
1786                        label: t!("menu.explorer.delete").to_string(),
1787                        action: "file_explorer_delete".to_string(),
1788                        args: HashMap::new(),
1789                        when: Some(context_keys::FILE_EXPLORER_FOCUSED.to_string()),
1790                        checkbox: None,
1791                    },
1792                    MenuItem::Separator { separator: true },
1793                    MenuItem::Action {
1794                        label: t!("menu.explorer.refresh").to_string(),
1795                        action: "file_explorer_refresh".to_string(),
1796                        args: HashMap::new(),
1797                        when: Some(context_keys::FILE_EXPLORER_FOCUSED.to_string()),
1798                        checkbox: None,
1799                    },
1800                    MenuItem::Separator { separator: true },
1801                    MenuItem::Action {
1802                        label: t!("menu.explorer.show_hidden").to_string(),
1803                        action: "file_explorer_toggle_hidden".to_string(),
1804                        args: HashMap::new(),
1805                        when: Some(context_keys::FILE_EXPLORER.to_string()),
1806                        checkbox: Some(context_keys::FILE_EXPLORER_SHOW_HIDDEN.to_string()),
1807                    },
1808                    MenuItem::Action {
1809                        label: t!("menu.explorer.show_gitignored").to_string(),
1810                        action: "file_explorer_toggle_gitignored".to_string(),
1811                        args: HashMap::new(),
1812                        when: Some(context_keys::FILE_EXPLORER.to_string()),
1813                        checkbox: Some(context_keys::FILE_EXPLORER_SHOW_GITIGNORED.to_string()),
1814                    },
1815                ],
1816            },
1817            // Help menu
1818            Menu {
1819                id: Some("Help".to_string()),
1820                label: t!("menu.help").to_string(),
1821                items: vec![
1822                    MenuItem::Label {
1823                        info: format!("Fresh v{}", env!("CARGO_PKG_VERSION")),
1824                    },
1825                    MenuItem::Separator { separator: true },
1826                    MenuItem::Action {
1827                        label: t!("menu.help.show_manual").to_string(),
1828                        action: "show_help".to_string(),
1829                        args: HashMap::new(),
1830                        when: None,
1831                        checkbox: None,
1832                    },
1833                    MenuItem::Action {
1834                        label: t!("menu.help.keyboard_shortcuts").to_string(),
1835                        action: "keyboard_shortcuts".to_string(),
1836                        args: HashMap::new(),
1837                        when: None,
1838                        checkbox: None,
1839                    },
1840                ],
1841            },
1842        ]
1843    }
1844}
1845
1846impl Config {
1847    /// The config filename used throughout the application
1848    pub(crate) const FILENAME: &'static str = "config.json";
1849
1850    /// Get the local config path (in the working directory)
1851    pub(crate) fn local_config_path(working_dir: &Path) -> std::path::PathBuf {
1852        working_dir.join(Self::FILENAME)
1853    }
1854
1855    /// Load configuration from a JSON file
1856    ///
1857    /// This deserializes the user's config file as a partial config and resolves
1858    /// it with system defaults. For HashMap fields like `lsp` and `languages`,
1859    /// entries from the user config are merged with the default entries.
1860    pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
1861        let contents = std::fs::read_to_string(path.as_ref())
1862            .map_err(|e| ConfigError::IoError(e.to_string()))?;
1863
1864        // Deserialize as PartialConfig first, then resolve with defaults
1865        let partial: crate::partial_config::PartialConfig =
1866            serde_json::from_str(&contents).map_err(|e| ConfigError::ParseError(e.to_string()))?;
1867
1868        Ok(partial.resolve())
1869    }
1870
1871    /// Load a built-in keymap from embedded JSON
1872    fn load_builtin_keymap(name: &str) -> Option<KeymapConfig> {
1873        let json_content = match name {
1874            "default" => include_str!("../keymaps/default.json"),
1875            "emacs" => include_str!("../keymaps/emacs.json"),
1876            "vscode" => include_str!("../keymaps/vscode.json"),
1877            "macos" => include_str!("../keymaps/macos.json"),
1878            _ => return None,
1879        };
1880
1881        match serde_json::from_str(json_content) {
1882            Ok(config) => Some(config),
1883            Err(e) => {
1884                eprintln!("Failed to parse builtin keymap '{}': {}", name, e);
1885                None
1886            }
1887        }
1888    }
1889
1890    /// Resolve a keymap with inheritance
1891    /// Returns all bindings from the keymap and its parent chain
1892    pub fn resolve_keymap(&self, map_name: &str) -> Vec<Keybinding> {
1893        let mut visited = std::collections::HashSet::new();
1894        self.resolve_keymap_recursive(map_name, &mut visited)
1895    }
1896
1897    /// Recursive helper for resolve_keymap
1898    fn resolve_keymap_recursive(
1899        &self,
1900        map_name: &str,
1901        visited: &mut std::collections::HashSet<String>,
1902    ) -> Vec<Keybinding> {
1903        // Prevent infinite loops
1904        if visited.contains(map_name) {
1905            eprintln!(
1906                "Warning: Circular inheritance detected in keymap '{}'",
1907                map_name
1908            );
1909            return Vec::new();
1910        }
1911        visited.insert(map_name.to_string());
1912
1913        // Try to load the keymap (user-defined or built-in)
1914        let keymap = self
1915            .keybinding_maps
1916            .get(map_name)
1917            .cloned()
1918            .or_else(|| Self::load_builtin_keymap(map_name));
1919
1920        let Some(keymap) = keymap else {
1921            return Vec::new();
1922        };
1923
1924        // Start with parent bindings (if any)
1925        let mut all_bindings = if let Some(ref parent_name) = keymap.inherits {
1926            self.resolve_keymap_recursive(parent_name, visited)
1927        } else {
1928            Vec::new()
1929        };
1930
1931        // Add this keymap's bindings (they override parent bindings)
1932        all_bindings.extend(keymap.bindings);
1933
1934        all_bindings
1935    }
1936    /// Create default language configurations
1937    fn default_languages() -> HashMap<String, LanguageConfig> {
1938        let mut languages = HashMap::new();
1939
1940        languages.insert(
1941            "rust".to_string(),
1942            LanguageConfig {
1943                extensions: vec!["rs".to_string()],
1944                filenames: vec![],
1945                grammar: "rust".to_string(),
1946                comment_prefix: Some("//".to_string()),
1947                auto_indent: true,
1948                highlighter: HighlighterPreference::Auto,
1949                textmate_grammar: None,
1950                show_whitespace_tabs: true,
1951                use_tabs: false,
1952                tab_size: None,
1953                formatter: Some(FormatterConfig {
1954                    command: "rustfmt".to_string(),
1955                    args: vec!["--edition".to_string(), "2021".to_string()],
1956                    stdin: true,
1957                    timeout_ms: 10000,
1958                }),
1959                format_on_save: false,
1960                on_save: vec![],
1961            },
1962        );
1963
1964        languages.insert(
1965            "javascript".to_string(),
1966            LanguageConfig {
1967                extensions: vec!["js".to_string(), "jsx".to_string(), "mjs".to_string()],
1968                filenames: vec![],
1969                grammar: "javascript".to_string(),
1970                comment_prefix: Some("//".to_string()),
1971                auto_indent: true,
1972                highlighter: HighlighterPreference::Auto,
1973                textmate_grammar: None,
1974                show_whitespace_tabs: true,
1975                use_tabs: false,
1976                tab_size: None,
1977                formatter: Some(FormatterConfig {
1978                    command: "prettier".to_string(),
1979                    args: vec!["--stdin-filepath".to_string(), "$FILE".to_string()],
1980                    stdin: true,
1981                    timeout_ms: 10000,
1982                }),
1983                format_on_save: false,
1984                on_save: vec![],
1985            },
1986        );
1987
1988        languages.insert(
1989            "typescript".to_string(),
1990            LanguageConfig {
1991                extensions: vec!["ts".to_string(), "tsx".to_string(), "mts".to_string()],
1992                filenames: vec![],
1993                grammar: "typescript".to_string(),
1994                comment_prefix: Some("//".to_string()),
1995                auto_indent: true,
1996                highlighter: HighlighterPreference::Auto,
1997                textmate_grammar: None,
1998                show_whitespace_tabs: true,
1999                use_tabs: false,
2000                tab_size: None,
2001                formatter: Some(FormatterConfig {
2002                    command: "prettier".to_string(),
2003                    args: vec!["--stdin-filepath".to_string(), "$FILE".to_string()],
2004                    stdin: true,
2005                    timeout_ms: 10000,
2006                }),
2007                format_on_save: false,
2008                on_save: vec![],
2009            },
2010        );
2011
2012        languages.insert(
2013            "python".to_string(),
2014            LanguageConfig {
2015                extensions: vec!["py".to_string(), "pyi".to_string()],
2016                filenames: vec![],
2017                grammar: "python".to_string(),
2018                comment_prefix: Some("#".to_string()),
2019                auto_indent: true,
2020                highlighter: HighlighterPreference::Auto,
2021                textmate_grammar: None,
2022                show_whitespace_tabs: true,
2023                use_tabs: false,
2024                tab_size: None,
2025                formatter: Some(FormatterConfig {
2026                    command: "ruff".to_string(),
2027                    args: vec![
2028                        "format".to_string(),
2029                        "--stdin-filename".to_string(),
2030                        "$FILE".to_string(),
2031                    ],
2032                    stdin: true,
2033                    timeout_ms: 10000,
2034                }),
2035                format_on_save: false,
2036                on_save: vec![],
2037            },
2038        );
2039
2040        languages.insert(
2041            "c".to_string(),
2042            LanguageConfig {
2043                extensions: vec!["c".to_string(), "h".to_string()],
2044                filenames: vec![],
2045                grammar: "c".to_string(),
2046                comment_prefix: Some("//".to_string()),
2047                auto_indent: true,
2048                highlighter: HighlighterPreference::Auto,
2049                textmate_grammar: None,
2050                show_whitespace_tabs: true,
2051                use_tabs: false,
2052                tab_size: None,
2053                formatter: Some(FormatterConfig {
2054                    command: "clang-format".to_string(),
2055                    args: vec![],
2056                    stdin: true,
2057                    timeout_ms: 10000,
2058                }),
2059                format_on_save: false,
2060                on_save: vec![],
2061            },
2062        );
2063
2064        languages.insert(
2065            "cpp".to_string(),
2066            LanguageConfig {
2067                extensions: vec![
2068                    "cpp".to_string(),
2069                    "cc".to_string(),
2070                    "cxx".to_string(),
2071                    "hpp".to_string(),
2072                    "hh".to_string(),
2073                    "hxx".to_string(),
2074                ],
2075                filenames: vec![],
2076                grammar: "cpp".to_string(),
2077                comment_prefix: Some("//".to_string()),
2078                auto_indent: true,
2079                highlighter: HighlighterPreference::Auto,
2080                textmate_grammar: None,
2081                show_whitespace_tabs: true,
2082                use_tabs: false,
2083                tab_size: None,
2084                formatter: Some(FormatterConfig {
2085                    command: "clang-format".to_string(),
2086                    args: vec![],
2087                    stdin: true,
2088                    timeout_ms: 10000,
2089                }),
2090                format_on_save: false,
2091                on_save: vec![],
2092            },
2093        );
2094
2095        languages.insert(
2096            "csharp".to_string(),
2097            LanguageConfig {
2098                extensions: vec!["cs".to_string()],
2099                filenames: vec![],
2100                grammar: "c_sharp".to_string(),
2101                comment_prefix: Some("//".to_string()),
2102                auto_indent: true,
2103                highlighter: HighlighterPreference::Auto,
2104                textmate_grammar: None,
2105                show_whitespace_tabs: true,
2106                use_tabs: false,
2107                tab_size: None,
2108                formatter: None,
2109                format_on_save: false,
2110                on_save: vec![],
2111            },
2112        );
2113
2114        languages.insert(
2115            "bash".to_string(),
2116            LanguageConfig {
2117                extensions: vec!["sh".to_string(), "bash".to_string()],
2118                filenames: vec![
2119                    ".bash_aliases".to_string(),
2120                    ".bash_logout".to_string(),
2121                    ".bash_profile".to_string(),
2122                    ".bashrc".to_string(),
2123                    ".env".to_string(),
2124                    ".profile".to_string(),
2125                    ".zlogin".to_string(),
2126                    ".zlogout".to_string(),
2127                    ".zprofile".to_string(),
2128                    ".zshenv".to_string(),
2129                    ".zshrc".to_string(),
2130                    // Common shell script files without extensions
2131                    "PKGBUILD".to_string(),
2132                    "APKBUILD".to_string(),
2133                ],
2134                grammar: "bash".to_string(),
2135                comment_prefix: Some("#".to_string()),
2136                auto_indent: true,
2137                highlighter: HighlighterPreference::Auto,
2138                textmate_grammar: None,
2139                show_whitespace_tabs: true,
2140                use_tabs: false,
2141                tab_size: None,
2142                formatter: None,
2143                format_on_save: false,
2144                on_save: vec![],
2145            },
2146        );
2147
2148        languages.insert(
2149            "makefile".to_string(),
2150            LanguageConfig {
2151                extensions: vec!["mk".to_string()],
2152                filenames: vec![
2153                    "Makefile".to_string(),
2154                    "makefile".to_string(),
2155                    "GNUmakefile".to_string(),
2156                ],
2157                grammar: "make".to_string(),
2158                comment_prefix: Some("#".to_string()),
2159                auto_indent: false,
2160                highlighter: HighlighterPreference::Auto,
2161                textmate_grammar: None,
2162                show_whitespace_tabs: true,
2163                use_tabs: true,    // Makefiles require tabs for recipes
2164                tab_size: Some(8), // Makefiles traditionally use 8-space tabs
2165                formatter: None,
2166                format_on_save: false,
2167                on_save: vec![],
2168            },
2169        );
2170
2171        languages.insert(
2172            "dockerfile".to_string(),
2173            LanguageConfig {
2174                extensions: vec!["dockerfile".to_string()],
2175                filenames: vec!["Dockerfile".to_string(), "Containerfile".to_string()],
2176                grammar: "dockerfile".to_string(),
2177                comment_prefix: Some("#".to_string()),
2178                auto_indent: true,
2179                highlighter: HighlighterPreference::Auto,
2180                textmate_grammar: None,
2181                show_whitespace_tabs: true,
2182                use_tabs: false,
2183                tab_size: None,
2184                formatter: None,
2185                format_on_save: false,
2186                on_save: vec![],
2187            },
2188        );
2189
2190        languages.insert(
2191            "json".to_string(),
2192            LanguageConfig {
2193                extensions: vec!["json".to_string(), "jsonc".to_string()],
2194                filenames: vec![],
2195                grammar: "json".to_string(),
2196                comment_prefix: None,
2197                auto_indent: true,
2198                highlighter: HighlighterPreference::Auto,
2199                textmate_grammar: None,
2200                show_whitespace_tabs: true,
2201                use_tabs: false,
2202                tab_size: None,
2203                formatter: Some(FormatterConfig {
2204                    command: "prettier".to_string(),
2205                    args: vec!["--stdin-filepath".to_string(), "$FILE".to_string()],
2206                    stdin: true,
2207                    timeout_ms: 10000,
2208                }),
2209                format_on_save: false,
2210                on_save: vec![],
2211            },
2212        );
2213
2214        languages.insert(
2215            "toml".to_string(),
2216            LanguageConfig {
2217                extensions: vec!["toml".to_string()],
2218                filenames: vec!["Cargo.lock".to_string()],
2219                grammar: "toml".to_string(),
2220                comment_prefix: Some("#".to_string()),
2221                auto_indent: true,
2222                highlighter: HighlighterPreference::Auto,
2223                textmate_grammar: None,
2224                show_whitespace_tabs: true,
2225                use_tabs: false,
2226                tab_size: None,
2227                formatter: None,
2228                format_on_save: false,
2229                on_save: vec![],
2230            },
2231        );
2232
2233        languages.insert(
2234            "yaml".to_string(),
2235            LanguageConfig {
2236                extensions: vec!["yml".to_string(), "yaml".to_string()],
2237                filenames: vec![],
2238                grammar: "yaml".to_string(),
2239                comment_prefix: Some("#".to_string()),
2240                auto_indent: true,
2241                highlighter: HighlighterPreference::Auto,
2242                textmate_grammar: None,
2243                show_whitespace_tabs: true,
2244                use_tabs: false,
2245                tab_size: None,
2246                formatter: Some(FormatterConfig {
2247                    command: "prettier".to_string(),
2248                    args: vec!["--stdin-filepath".to_string(), "$FILE".to_string()],
2249                    stdin: true,
2250                    timeout_ms: 10000,
2251                }),
2252                format_on_save: false,
2253                on_save: vec![],
2254            },
2255        );
2256
2257        languages.insert(
2258            "markdown".to_string(),
2259            LanguageConfig {
2260                extensions: vec!["md".to_string(), "markdown".to_string()],
2261                filenames: vec!["README".to_string()],
2262                grammar: "markdown".to_string(),
2263                comment_prefix: None,
2264                auto_indent: false,
2265                highlighter: HighlighterPreference::Auto,
2266                textmate_grammar: None,
2267                show_whitespace_tabs: true,
2268                use_tabs: false,
2269                tab_size: None,
2270                formatter: None,
2271                format_on_save: false,
2272                on_save: vec![],
2273            },
2274        );
2275
2276        // Go uses tabs for indentation by convention, so hide tab indicators and use tabs
2277        languages.insert(
2278            "go".to_string(),
2279            LanguageConfig {
2280                extensions: vec!["go".to_string()],
2281                filenames: vec![],
2282                grammar: "go".to_string(),
2283                comment_prefix: Some("//".to_string()),
2284                auto_indent: true,
2285                highlighter: HighlighterPreference::Auto,
2286                textmate_grammar: None,
2287                show_whitespace_tabs: false,
2288                use_tabs: true,    // Go convention is to use tabs
2289                tab_size: Some(8), // Go convention is 8-space tab width
2290                formatter: Some(FormatterConfig {
2291                    command: "gofmt".to_string(),
2292                    args: vec![],
2293                    stdin: true,
2294                    timeout_ms: 10000,
2295                }),
2296                format_on_save: false,
2297                on_save: vec![],
2298            },
2299        );
2300
2301        languages.insert(
2302            "odin".to_string(),
2303            LanguageConfig {
2304                extensions: vec!["odin".to_string()],
2305                filenames: vec![],
2306                grammar: "odin".to_string(),
2307                comment_prefix: Some("//".to_string()),
2308                auto_indent: true,
2309                highlighter: HighlighterPreference::Auto,
2310                textmate_grammar: None,
2311                show_whitespace_tabs: false,
2312                use_tabs: true,
2313                tab_size: Some(8),
2314                formatter: None,
2315                format_on_save: false,
2316                on_save: vec![],
2317            },
2318        );
2319
2320        languages.insert(
2321            "zig".to_string(),
2322            LanguageConfig {
2323                extensions: vec!["zig".to_string(), "zon".to_string()],
2324                filenames: vec![],
2325                grammar: "zig".to_string(),
2326                comment_prefix: Some("//".to_string()),
2327                auto_indent: true,
2328                highlighter: HighlighterPreference::Auto,
2329                textmate_grammar: None,
2330                show_whitespace_tabs: true,
2331                use_tabs: false,
2332                tab_size: None,
2333                formatter: None,
2334                format_on_save: false,
2335                on_save: vec![],
2336            },
2337        );
2338
2339        languages.insert(
2340            "java".to_string(),
2341            LanguageConfig {
2342                extensions: vec!["java".to_string()],
2343                filenames: vec![],
2344                grammar: "java".to_string(),
2345                comment_prefix: Some("//".to_string()),
2346                auto_indent: true,
2347                highlighter: HighlighterPreference::Auto,
2348                textmate_grammar: None,
2349                show_whitespace_tabs: true,
2350                use_tabs: false,
2351                tab_size: None,
2352                formatter: None,
2353                format_on_save: false,
2354                on_save: vec![],
2355            },
2356        );
2357
2358        languages.insert(
2359            "latex".to_string(),
2360            LanguageConfig {
2361                extensions: vec![
2362                    "tex".to_string(),
2363                    "latex".to_string(),
2364                    "ltx".to_string(),
2365                    "sty".to_string(),
2366                    "cls".to_string(),
2367                    "bib".to_string(),
2368                ],
2369                filenames: vec![],
2370                grammar: "latex".to_string(),
2371                comment_prefix: Some("%".to_string()),
2372                auto_indent: true,
2373                highlighter: HighlighterPreference::Auto,
2374                textmate_grammar: None,
2375                show_whitespace_tabs: true,
2376                use_tabs: false,
2377                tab_size: None,
2378                formatter: None,
2379                format_on_save: false,
2380                on_save: vec![],
2381            },
2382        );
2383
2384        languages.insert(
2385            "templ".to_string(),
2386            LanguageConfig {
2387                extensions: vec!["templ".to_string()],
2388                filenames: vec![],
2389                grammar: "go".to_string(), // Templ uses Go-like syntax
2390                comment_prefix: Some("//".to_string()),
2391                auto_indent: true,
2392                highlighter: HighlighterPreference::Auto,
2393                textmate_grammar: None,
2394                show_whitespace_tabs: true,
2395                use_tabs: false,
2396                tab_size: None,
2397                formatter: None,
2398                format_on_save: false,
2399                on_save: vec![],
2400            },
2401        );
2402
2403        // Git-related file types
2404        languages.insert(
2405            "git-rebase".to_string(),
2406            LanguageConfig {
2407                extensions: vec![],
2408                filenames: vec!["git-rebase-todo".to_string()],
2409                grammar: "Git Rebase Todo".to_string(),
2410                comment_prefix: Some("#".to_string()),
2411                auto_indent: false,
2412                highlighter: HighlighterPreference::Auto,
2413                textmate_grammar: None,
2414                show_whitespace_tabs: true,
2415                use_tabs: false,
2416                tab_size: None,
2417                formatter: None,
2418                format_on_save: false,
2419                on_save: vec![],
2420            },
2421        );
2422
2423        languages.insert(
2424            "git-commit".to_string(),
2425            LanguageConfig {
2426                extensions: vec![],
2427                filenames: vec![
2428                    "COMMIT_EDITMSG".to_string(),
2429                    "MERGE_MSG".to_string(),
2430                    "SQUASH_MSG".to_string(),
2431                    "TAG_EDITMSG".to_string(),
2432                ],
2433                grammar: "Git Commit Message".to_string(),
2434                comment_prefix: Some("#".to_string()),
2435                auto_indent: false,
2436                highlighter: HighlighterPreference::Auto,
2437                textmate_grammar: None,
2438                show_whitespace_tabs: true,
2439                use_tabs: false,
2440                tab_size: None,
2441                formatter: None,
2442                format_on_save: false,
2443                on_save: vec![],
2444            },
2445        );
2446
2447        languages.insert(
2448            "gitignore".to_string(),
2449            LanguageConfig {
2450                extensions: vec!["gitignore".to_string()],
2451                filenames: vec![
2452                    ".gitignore".to_string(),
2453                    ".dockerignore".to_string(),
2454                    ".npmignore".to_string(),
2455                    ".hgignore".to_string(),
2456                ],
2457                grammar: "Gitignore".to_string(),
2458                comment_prefix: Some("#".to_string()),
2459                auto_indent: false,
2460                highlighter: HighlighterPreference::Auto,
2461                textmate_grammar: None,
2462                show_whitespace_tabs: true,
2463                use_tabs: false,
2464                tab_size: None,
2465                formatter: None,
2466                format_on_save: false,
2467                on_save: vec![],
2468            },
2469        );
2470
2471        languages.insert(
2472            "gitconfig".to_string(),
2473            LanguageConfig {
2474                extensions: vec!["gitconfig".to_string()],
2475                filenames: vec![".gitconfig".to_string(), ".gitmodules".to_string()],
2476                grammar: "Git Config".to_string(),
2477                comment_prefix: Some("#".to_string()),
2478                auto_indent: true,
2479                highlighter: HighlighterPreference::Auto,
2480                textmate_grammar: None,
2481                show_whitespace_tabs: true,
2482                use_tabs: false,
2483                tab_size: None,
2484                formatter: None,
2485                format_on_save: false,
2486                on_save: vec![],
2487            },
2488        );
2489
2490        languages.insert(
2491            "gitattributes".to_string(),
2492            LanguageConfig {
2493                extensions: vec!["gitattributes".to_string()],
2494                filenames: vec![".gitattributes".to_string()],
2495                grammar: "Git Attributes".to_string(),
2496                comment_prefix: Some("#".to_string()),
2497                auto_indent: false,
2498                highlighter: HighlighterPreference::Auto,
2499                textmate_grammar: None,
2500                show_whitespace_tabs: true,
2501                use_tabs: false,
2502                tab_size: None,
2503                formatter: None,
2504                format_on_save: false,
2505                on_save: vec![],
2506            },
2507        );
2508
2509        languages
2510    }
2511
2512    /// Create default LSP configurations
2513    fn default_lsp_config() -> HashMap<String, LspServerConfig> {
2514        let mut lsp = HashMap::new();
2515
2516        // rust-analyzer (installed via rustup or package manager)
2517        // Enable logging to help debug LSP issues (stored in XDG state directory)
2518        let ra_log_path = crate::services::log_dirs::lsp_log_path("rust-analyzer")
2519            .to_string_lossy()
2520            .to_string();
2521
2522        // Minimal performance config for rust-analyzer:
2523        // - checkOnSave: false - disables cargo check on every save (the #1 cause of slowdowns)
2524        // - cachePriming.enable: false - disables background indexing of entire crate graph
2525        // - procMacro.enable: false - disables proc-macro expansion (saves CPU/RAM)
2526        // - cargo.buildScripts.enable: false - prevents running build.rs automatically
2527        // - cargo.autoreload: false - only reload manually
2528        // - diagnostics.enable: true - keeps basic syntax error reporting
2529        // - files.watcher: "server" - more efficient than editor-side watchers
2530        let ra_init_options = serde_json::json!({
2531            "checkOnSave": false,
2532            "cachePriming": { "enable": false },
2533            "procMacro": { "enable": false },
2534            "cargo": {
2535                "buildScripts": { "enable": false },
2536                "autoreload": false
2537            },
2538            "diagnostics": { "enable": true },
2539            "files": { "watcher": "server" }
2540        });
2541
2542        lsp.insert(
2543            "rust".to_string(),
2544            LspServerConfig {
2545                command: "rust-analyzer".to_string(),
2546                args: vec!["--log-file".to_string(), ra_log_path],
2547                enabled: true,
2548                auto_start: false,
2549                process_limits: ProcessLimits::default(),
2550                initialization_options: Some(ra_init_options),
2551            },
2552        );
2553
2554        // pylsp (installed via pip)
2555        lsp.insert(
2556            "python".to_string(),
2557            LspServerConfig {
2558                command: "pylsp".to_string(),
2559                args: vec![],
2560                enabled: true,
2561                auto_start: false,
2562                process_limits: ProcessLimits::default(),
2563                initialization_options: None,
2564            },
2565        );
2566
2567        // typescript-language-server (installed via npm)
2568        // Alternative: use "deno lsp" with initialization_options: {"enable": true}
2569        let ts_lsp = LspServerConfig {
2570            command: "typescript-language-server".to_string(),
2571            args: vec!["--stdio".to_string()],
2572            enabled: true,
2573            auto_start: false,
2574            process_limits: ProcessLimits::default(),
2575            initialization_options: None,
2576        };
2577        lsp.insert("javascript".to_string(), ts_lsp.clone());
2578        lsp.insert("typescript".to_string(), ts_lsp);
2579
2580        // vscode-html-language-server (installed via npm install -g vscode-langservers-extracted)
2581        lsp.insert(
2582            "html".to_string(),
2583            LspServerConfig {
2584                command: "vscode-html-language-server".to_string(),
2585                args: vec!["--stdio".to_string()],
2586                enabled: true,
2587                auto_start: false,
2588                process_limits: ProcessLimits::default(),
2589                initialization_options: None,
2590            },
2591        );
2592
2593        // vscode-css-language-server (installed via npm install -g vscode-langservers-extracted)
2594        lsp.insert(
2595            "css".to_string(),
2596            LspServerConfig {
2597                command: "vscode-css-language-server".to_string(),
2598                args: vec!["--stdio".to_string()],
2599                enabled: true,
2600                auto_start: false,
2601                process_limits: ProcessLimits::default(),
2602                initialization_options: None,
2603            },
2604        );
2605
2606        // clangd (installed via package manager)
2607        lsp.insert(
2608            "c".to_string(),
2609            LspServerConfig {
2610                command: "clangd".to_string(),
2611                args: vec![],
2612                enabled: true,
2613                auto_start: false,
2614                process_limits: ProcessLimits::default(),
2615                initialization_options: None,
2616            },
2617        );
2618        lsp.insert(
2619            "cpp".to_string(),
2620            LspServerConfig {
2621                command: "clangd".to_string(),
2622                args: vec![],
2623                enabled: true,
2624                auto_start: false,
2625                process_limits: ProcessLimits::default(),
2626                initialization_options: None,
2627            },
2628        );
2629
2630        // gopls (installed via go install)
2631        lsp.insert(
2632            "go".to_string(),
2633            LspServerConfig {
2634                command: "gopls".to_string(),
2635                args: vec![],
2636                enabled: true,
2637                auto_start: false,
2638                process_limits: ProcessLimits::default(),
2639                initialization_options: None,
2640            },
2641        );
2642
2643        // vscode-json-language-server (installed via npm install -g vscode-langservers-extracted)
2644        lsp.insert(
2645            "json".to_string(),
2646            LspServerConfig {
2647                command: "vscode-json-language-server".to_string(),
2648                args: vec!["--stdio".to_string()],
2649                enabled: true,
2650                auto_start: false,
2651                process_limits: ProcessLimits::default(),
2652                initialization_options: None,
2653            },
2654        );
2655
2656        // csharp-language-server (installed via dotnet tool install -g csharp-ls)
2657        lsp.insert(
2658            "csharp".to_string(),
2659            LspServerConfig {
2660                command: "csharp-ls".to_string(),
2661                args: vec![],
2662                enabled: true,
2663                auto_start: false,
2664                process_limits: ProcessLimits::default(),
2665                initialization_options: None,
2666            },
2667        );
2668
2669        // ols - Odin Language Server (https://github.com/DanielGavin/ols)
2670        // Build from source: cd ols && ./build.sh (Linux/macOS) or ./build.bat (Windows)
2671        lsp.insert(
2672            "odin".to_string(),
2673            LspServerConfig {
2674                command: "ols".to_string(),
2675                args: vec![],
2676                enabled: true,
2677                auto_start: false,
2678                process_limits: ProcessLimits::default(),
2679                initialization_options: None,
2680            },
2681        );
2682
2683        // zls - Zig Language Server (https://github.com/zigtools/zls)
2684        // Install via package manager or download from releases
2685        lsp.insert(
2686            "zig".to_string(),
2687            LspServerConfig {
2688                command: "zls".to_string(),
2689                args: vec![],
2690                enabled: true,
2691                auto_start: false,
2692                process_limits: ProcessLimits::default(),
2693                initialization_options: None,
2694            },
2695        );
2696
2697        // jdtls - Eclipse JDT Language Server for Java
2698        // Install via package manager or download from Eclipse
2699        lsp.insert(
2700            "java".to_string(),
2701            LspServerConfig {
2702                command: "jdtls".to_string(),
2703                args: vec![],
2704                enabled: true,
2705                auto_start: false,
2706                process_limits: ProcessLimits::default(),
2707                initialization_options: None,
2708            },
2709        );
2710
2711        // texlab - LaTeX Language Server (https://github.com/latex-lsp/texlab)
2712        // Install via cargo install texlab or package manager
2713        lsp.insert(
2714            "latex".to_string(),
2715            LspServerConfig {
2716                command: "texlab".to_string(),
2717                args: vec![],
2718                enabled: true,
2719                auto_start: false,
2720                process_limits: ProcessLimits::default(),
2721                initialization_options: None,
2722            },
2723        );
2724
2725        // marksman - Markdown Language Server (https://github.com/artempyanykh/marksman)
2726        // Install via package manager or download from releases
2727        lsp.insert(
2728            "markdown".to_string(),
2729            LspServerConfig {
2730                command: "marksman".to_string(),
2731                args: vec!["server".to_string()],
2732                enabled: true,
2733                auto_start: false,
2734                process_limits: ProcessLimits::default(),
2735                initialization_options: None,
2736            },
2737        );
2738
2739        // templ - Templ Language Server (https://templ.guide)
2740        // Install via go install github.com/a-h/templ/cmd/templ@latest
2741        lsp.insert(
2742            "templ".to_string(),
2743            LspServerConfig {
2744                command: "templ".to_string(),
2745                args: vec!["lsp".to_string()],
2746                enabled: true,
2747                auto_start: false,
2748                process_limits: ProcessLimits::default(),
2749                initialization_options: None,
2750            },
2751        );
2752
2753        lsp
2754    }
2755
2756    /// Validate the configuration
2757    pub fn validate(&self) -> Result<(), ConfigError> {
2758        // Validate tab size
2759        if self.editor.tab_size == 0 {
2760            return Err(ConfigError::ValidationError(
2761                "tab_size must be greater than 0".to_string(),
2762            ));
2763        }
2764
2765        // Validate scroll offset
2766        if self.editor.scroll_offset > 100 {
2767            return Err(ConfigError::ValidationError(
2768                "scroll_offset must be <= 100".to_string(),
2769            ));
2770        }
2771
2772        // Validate keybindings
2773        for binding in &self.keybindings {
2774            if binding.key.is_empty() {
2775                return Err(ConfigError::ValidationError(
2776                    "keybinding key cannot be empty".to_string(),
2777                ));
2778            }
2779            if binding.action.is_empty() {
2780                return Err(ConfigError::ValidationError(
2781                    "keybinding action cannot be empty".to_string(),
2782                ));
2783            }
2784        }
2785
2786        Ok(())
2787    }
2788}
2789
2790/// Configuration error types
2791#[derive(Debug)]
2792pub enum ConfigError {
2793    IoError(String),
2794    ParseError(String),
2795    SerializeError(String),
2796    ValidationError(String),
2797}
2798
2799impl std::fmt::Display for ConfigError {
2800    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2801        match self {
2802            Self::IoError(msg) => write!(f, "IO error: {msg}"),
2803            Self::ParseError(msg) => write!(f, "Parse error: {msg}"),
2804            Self::SerializeError(msg) => write!(f, "Serialize error: {msg}"),
2805            Self::ValidationError(msg) => write!(f, "Validation error: {msg}"),
2806        }
2807    }
2808}
2809
2810impl std::error::Error for ConfigError {}
2811
2812#[cfg(test)]
2813mod tests {
2814    use super::*;
2815
2816    #[test]
2817    fn test_default_config() {
2818        let config = Config::default();
2819        assert_eq!(config.editor.tab_size, 4);
2820        assert!(config.editor.line_numbers);
2821        assert!(config.editor.syntax_highlighting);
2822        // keybindings is empty by design - it's for user customizations only
2823        // The actual keybindings come from resolve_keymap(active_keybinding_map)
2824        assert!(config.keybindings.is_empty());
2825        // But the resolved keymap should have bindings
2826        let resolved = config.resolve_keymap(&config.active_keybinding_map);
2827        assert!(!resolved.is_empty());
2828    }
2829
2830    #[test]
2831    fn test_all_builtin_keymaps_loadable() {
2832        for name in KeybindingMapName::BUILTIN_OPTIONS {
2833            let keymap = Config::load_builtin_keymap(name);
2834            assert!(keymap.is_some(), "Failed to load builtin keymap '{}'", name);
2835        }
2836    }
2837
2838    #[test]
2839    fn test_config_validation() {
2840        let mut config = Config::default();
2841        assert!(config.validate().is_ok());
2842
2843        config.editor.tab_size = 0;
2844        assert!(config.validate().is_err());
2845    }
2846
2847    #[test]
2848    fn test_macos_keymap_inherits_enter_bindings() {
2849        let config = Config::default();
2850        let bindings = config.resolve_keymap("macos");
2851
2852        let enter_bindings: Vec<_> = bindings.iter().filter(|b| b.key == "Enter").collect();
2853        assert!(
2854            !enter_bindings.is_empty(),
2855            "macos keymap should inherit Enter bindings from default, got {} Enter bindings",
2856            enter_bindings.len()
2857        );
2858        // Should have at least insert_newline for normal mode
2859        let has_insert_newline = enter_bindings.iter().any(|b| b.action == "insert_newline");
2860        assert!(
2861            has_insert_newline,
2862            "macos keymap should have insert_newline action for Enter key"
2863        );
2864    }
2865
2866    #[test]
2867    fn test_config_serialize_deserialize() {
2868        // Test that Config can be serialized and deserialized correctly
2869        let config = Config::default();
2870
2871        // Serialize to JSON
2872        let json = serde_json::to_string_pretty(&config).unwrap();
2873
2874        // Deserialize back
2875        let loaded: Config = serde_json::from_str(&json).unwrap();
2876
2877        assert_eq!(config.editor.tab_size, loaded.editor.tab_size);
2878        assert_eq!(config.theme, loaded.theme);
2879    }
2880
2881    #[test]
2882    fn test_config_with_custom_keybinding() {
2883        let json = r#"{
2884            "editor": {
2885                "tab_size": 2
2886            },
2887            "keybindings": [
2888                {
2889                    "key": "x",
2890                    "modifiers": ["ctrl", "shift"],
2891                    "action": "custom_action",
2892                    "args": {},
2893                    "when": null
2894                }
2895            ]
2896        }"#;
2897
2898        let config: Config = serde_json::from_str(json).unwrap();
2899        assert_eq!(config.editor.tab_size, 2);
2900        assert_eq!(config.keybindings.len(), 1);
2901        assert_eq!(config.keybindings[0].key, "x");
2902        assert_eq!(config.keybindings[0].modifiers.len(), 2);
2903    }
2904
2905    #[test]
2906    fn test_sparse_config_merges_with_defaults() {
2907        // User config that only specifies one LSP server
2908        let temp_dir = tempfile::tempdir().unwrap();
2909        let config_path = temp_dir.path().join("config.json");
2910
2911        // Write a sparse config - only overriding rust LSP
2912        let sparse_config = r#"{
2913            "lsp": {
2914                "rust": {
2915                    "command": "custom-rust-analyzer",
2916                    "args": ["--custom-arg"]
2917                }
2918            }
2919        }"#;
2920        std::fs::write(&config_path, sparse_config).unwrap();
2921
2922        // Load the config - should merge with defaults
2923        let loaded = Config::load_from_file(&config_path).unwrap();
2924
2925        // User's rust override should be present
2926        assert!(loaded.lsp.contains_key("rust"));
2927        assert_eq!(
2928            loaded.lsp["rust"].command,
2929            "custom-rust-analyzer".to_string()
2930        );
2931
2932        // Default LSP servers should also be present (merged from defaults)
2933        assert!(
2934            loaded.lsp.contains_key("python"),
2935            "python LSP should be merged from defaults"
2936        );
2937        assert!(
2938            loaded.lsp.contains_key("typescript"),
2939            "typescript LSP should be merged from defaults"
2940        );
2941        assert!(
2942            loaded.lsp.contains_key("javascript"),
2943            "javascript LSP should be merged from defaults"
2944        );
2945
2946        // Default language configs should also be present
2947        assert!(loaded.languages.contains_key("rust"));
2948        assert!(loaded.languages.contains_key("python"));
2949        assert!(loaded.languages.contains_key("typescript"));
2950    }
2951
2952    #[test]
2953    fn test_empty_config_gets_all_defaults() {
2954        let temp_dir = tempfile::tempdir().unwrap();
2955        let config_path = temp_dir.path().join("config.json");
2956
2957        // Write an empty config
2958        std::fs::write(&config_path, "{}").unwrap();
2959
2960        let loaded = Config::load_from_file(&config_path).unwrap();
2961        let defaults = Config::default();
2962
2963        // Should have all default LSP servers
2964        assert_eq!(loaded.lsp.len(), defaults.lsp.len());
2965
2966        // Should have all default languages
2967        assert_eq!(loaded.languages.len(), defaults.languages.len());
2968    }
2969
2970    #[test]
2971    fn test_dynamic_submenu_expansion() {
2972        // Test that DynamicSubmenu expands to Submenu with generated items
2973        let dynamic = MenuItem::DynamicSubmenu {
2974            label: "Test".to_string(),
2975            source: "copy_with_theme".to_string(),
2976        };
2977
2978        let expanded = dynamic.expand_dynamic();
2979
2980        // Should expand to a Submenu
2981        match expanded {
2982            MenuItem::Submenu { label, items } => {
2983                assert_eq!(label, "Test");
2984                // Should have items for each available theme
2985                let theme_loader = crate::view::theme::LocalThemeLoader::new();
2986                let themes = crate::view::theme::Theme::all_available(&theme_loader);
2987                assert_eq!(items.len(), themes.len());
2988
2989                // Each item should be an Action with copy_with_theme
2990                for (item, theme_name) in items.iter().zip(themes.iter()) {
2991                    match item {
2992                        MenuItem::Action {
2993                            label,
2994                            action,
2995                            args,
2996                            ..
2997                        } => {
2998                            assert_eq!(label, theme_name);
2999                            assert_eq!(action, "copy_with_theme");
3000                            assert_eq!(
3001                                args.get("theme").and_then(|v| v.as_str()),
3002                                Some(theme_name.as_str())
3003                            );
3004                        }
3005                        _ => panic!("Expected Action item"),
3006                    }
3007                }
3008            }
3009            _ => panic!("Expected Submenu after expansion"),
3010        }
3011    }
3012
3013    #[test]
3014    fn test_non_dynamic_item_unchanged() {
3015        // Non-DynamicSubmenu items should be unchanged by expand_dynamic
3016        let action = MenuItem::Action {
3017            label: "Test".to_string(),
3018            action: "test".to_string(),
3019            args: HashMap::new(),
3020            when: None,
3021            checkbox: None,
3022        };
3023
3024        let expanded = action.expand_dynamic();
3025        match expanded {
3026            MenuItem::Action { label, action, .. } => {
3027                assert_eq!(label, "Test");
3028                assert_eq!(action, "test");
3029            }
3030            _ => panic!("Action should remain Action after expand_dynamic"),
3031        }
3032    }
3033
3034    #[test]
3035    fn test_buffer_config_uses_global_defaults() {
3036        let config = Config::default();
3037        let buffer_config = BufferConfig::resolve(&config, None);
3038
3039        assert_eq!(buffer_config.tab_size, config.editor.tab_size);
3040        assert_eq!(buffer_config.auto_indent, config.editor.auto_indent);
3041        assert!(!buffer_config.use_tabs); // Default is spaces
3042        assert!(buffer_config.show_whitespace_tabs);
3043        assert!(buffer_config.formatter.is_none());
3044        assert!(!buffer_config.format_on_save);
3045    }
3046
3047    #[test]
3048    fn test_buffer_config_applies_language_overrides() {
3049        let mut config = Config::default();
3050
3051        // Add a language config with custom settings
3052        config.languages.insert(
3053            "go".to_string(),
3054            LanguageConfig {
3055                extensions: vec!["go".to_string()],
3056                filenames: vec![],
3057                grammar: "go".to_string(),
3058                comment_prefix: Some("//".to_string()),
3059                auto_indent: true,
3060                highlighter: HighlighterPreference::Auto,
3061                textmate_grammar: None,
3062                show_whitespace_tabs: false, // Go hides tab indicators
3063                use_tabs: true,              // Go uses tabs
3064                tab_size: Some(8),           // Go uses 8-space tabs
3065                formatter: Some(FormatterConfig {
3066                    command: "gofmt".to_string(),
3067                    args: vec![],
3068                    stdin: true,
3069                    timeout_ms: 10000,
3070                }),
3071                format_on_save: true,
3072                on_save: vec![],
3073            },
3074        );
3075
3076        let buffer_config = BufferConfig::resolve(&config, Some("go"));
3077
3078        assert_eq!(buffer_config.tab_size, 8);
3079        assert!(buffer_config.use_tabs);
3080        assert!(!buffer_config.show_whitespace_tabs);
3081        assert!(buffer_config.format_on_save);
3082        assert!(buffer_config.formatter.is_some());
3083        assert_eq!(buffer_config.formatter.as_ref().unwrap().command, "gofmt");
3084    }
3085
3086    #[test]
3087    fn test_buffer_config_unknown_language_uses_global() {
3088        let config = Config::default();
3089        let buffer_config = BufferConfig::resolve(&config, Some("unknown_lang"));
3090
3091        // Should fall back to global settings
3092        assert_eq!(buffer_config.tab_size, config.editor.tab_size);
3093        assert!(!buffer_config.use_tabs);
3094    }
3095
3096    #[test]
3097    fn test_buffer_config_indent_string() {
3098        let config = Config::default();
3099
3100        // Spaces indent
3101        let spaces_config = BufferConfig::resolve(&config, None);
3102        assert_eq!(spaces_config.indent_string(), "    "); // 4 spaces
3103
3104        // Tabs indent - create a language that uses tabs
3105        let mut config_with_tabs = Config::default();
3106        config_with_tabs.languages.insert(
3107            "makefile".to_string(),
3108            LanguageConfig {
3109                use_tabs: true,
3110                tab_size: Some(8),
3111                ..Default::default()
3112            },
3113        );
3114        let tabs_config = BufferConfig::resolve(&config_with_tabs, Some("makefile"));
3115        assert_eq!(tabs_config.indent_string(), "\t");
3116    }
3117}