package main import ( "bufio" "fmt" "os" "path/filepath" "runtime" "strconv" "strings" ) // Config хранит все настройки редактора type Config struct { // [settings] TreeWidth int PreviewWidth int LineNumbers bool TabWidth int AutosaveInterval int // секунды (0 = выключен) ShowPreview bool ShowTree bool Theme string // [keys] — map: действие → клавиша // Например: "toggle_tree" → "ctrl+b" Keys map[string]string // Путь откуда загружен конфиг (для отображения в :set) LoadedFrom string } // DefaultConfig возвращает конфиг со значениями по умолчанию func DefaultConfig() Config { return Config{ TreeWidth: 25, PreviewWidth: 40, LineNumbers: true, TabWidth: 4, AutosaveInterval: 60, ShowPreview: false, // включается автоматически для .md ShowTree: false, Theme: "default", Keys: defaultKeys(), } } // defaultKeys возвращает стандартные биндинги func defaultKeys() map[string]string { return map[string]string{ "quit": "ctrl+c", "quit_alt": "ctrl+d", "redo": "ctrl+r", "toggle_tree": "ctrl+b", "focus_switch": "tab", "panel_shrink": "<", "panel_grow": ">", "toggle_preview": " ", // пробел } } // ActionForKey возвращает имя действия для заданной клавиши, // или "" если биндинг не найден func (c *Config) ActionForKey(key string) string { for action, boundKey := range c.Keys { if boundKey == key { return action } } return "" } // KeyForAction возвращает клавишу для действия, или дефолтную если не переопределена func (c *Config) KeyForAction(action string) string { if k, ok := c.Keys[action]; ok { return k } return defaultKeys()[action] } // === Поиск файла конфига === // configSearchPaths возвращает список путей для поиска конфига, // в порядке приоритета (первый найденный используется) func configSearchPaths() []string { var paths []string appName := "md-editor" fileName := "md-editor.conf" switch runtime.GOOS { case "windows": // %APPDATA%\md-editor\md-editor.conf // %USERPROFILE%\md-editor.conf if appData := os.Getenv("APPDATA"); appData != "" { paths = append(paths, filepath.Join(appData, appName, fileName)) } if userProfile := os.Getenv("USERPROFILE"); userProfile != "" { paths = append(paths, filepath.Join(userProfile, fileName)) } case "darwin": // ~/Library/Application Support/md-editor/md-editor.conf // ~/.config/md-editor/md-editor.conf // ~/.md-editor.conf if home, err := os.UserHomeDir(); err == nil { paths = append(paths, filepath.Join(home, "Library", "Application Support", appName, fileName), filepath.Join(home, ".config", appName, fileName), filepath.Join(home, "."+fileName), ) } default: // linux и прочие Unix // $XDG_CONFIG_HOME/md-editor/md-editor.conf (если задан) // ~/.config/md-editor/md-editor.conf (XDG default) // ~/.md-editor.conf if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { paths = append(paths, filepath.Join(xdg, appName, fileName)) } if home, err := os.UserHomeDir(); err == nil { paths = append(paths, filepath.Join(home, ".config", appName, fileName), filepath.Join(home, "."+fileName), ) } } // os.UserConfigDir() как дополнительный fallback (кроссплатформенный) if dir, err := os.UserConfigDir(); err == nil { candidate := filepath.Join(dir, appName, fileName) if !containsPath(paths, candidate) { paths = append(paths, candidate) } } // Локальный конфиг в текущей директории (наивысший приоритет — добавляем первым) localPath := fileName if abs, err := filepath.Abs(localPath); err == nil { localPath = abs } return append([]string{localPath}, paths...) } func containsPath(paths []string, p string) bool { for _, existing := range paths { if existing == p { return true } } return false } // === Загрузка конфига === // LoadConfig ищет и загружает конфиг. Возвращает DefaultConfig если файл не найден. func LoadConfig() Config { cfg := DefaultConfig() for _, path := range configSearchPaths() { data, err := os.ReadFile(path) if err != nil { continue } if err := parseConfig(data, &cfg); err != nil { // Файл найден но с ошибками — сообщим при запуске cfg.LoadedFrom = path + " (parse error: " + err.Error() + ")" } else { cfg.LoadedFrom = path } return cfg } cfg.LoadedFrom = "(defaults, config not found)" return cfg } // ConfigPaths возвращает список путей поиска (для команды :config paths) func ConfigPaths() []string { return configSearchPaths() } // parseConfig разбирает INI-файл и заполняет cfg func parseConfig(data []byte, cfg *Config) error { scanner := bufio.NewScanner(strings.NewReader(string(data))) section := "" lineNum := 0 for scanner.Scan() { lineNum++ line := strings.TrimSpace(scanner.Text()) // Пропускаем пустые строки и комментарии if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { continue } // Секция [name] if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { section = strings.ToLower(line[1 : len(line)-1]) continue } // Пара key = value eqIdx := strings.IndexByte(line, '=') if eqIdx == -1 { return fmt.Errorf("line %d: missing '=' in %q", lineNum, line) } key := strings.TrimSpace(line[:eqIdx]) value := strings.TrimSpace(line[eqIdx+1:]) // Удаляем инлайн-комментарии (# или ; после значения) if idx := strings.IndexAny(value, "#;"); idx != -1 { value = strings.TrimSpace(value[:idx]) } key = strings.ToLower(key) switch section { case "settings": if err := applySettingKey(cfg, key, value, lineNum); err != nil { return err } case "keys": if err := applyKeyBinding(cfg, key, value, lineNum); err != nil { return err } case "": // Секция не задана — пробуем применить как settings if err := applySettingKey(cfg, key, value, lineNum); err != nil { return fmt.Errorf("line %d: unknown key %q (no section specified)", lineNum, key) } default: // Неизвестная секция — игнорируем } } return scanner.Err() } // applySettingKey применяет одну настройку из секции [settings] func applySettingKey(cfg *Config, key, value string, line int) error { switch key { case "tree_width", "treewidth": n, err := parseInt(value, line) if err != nil { return err } cfg.TreeWidth = n case "preview_width", "previewwidth": n, err := parseInt(value, line) if err != nil { return err } cfg.PreviewWidth = n case "line_numbers", "linenumbers", "nu", "number": b, err := parseBool(value, line) if err != nil { return err } cfg.LineNumbers = b case "tab_width", "tabwidth", "tabstop", "ts": n, err := parseInt(value, line) if err != nil { return err } if n < 1 || n > 16 { return fmt.Errorf("line %d: tab_width must be 1–16", line) } cfg.TabWidth = n case "autosave", "autosave_interval": n, err := parseInt(value, line) if err != nil { return err } cfg.AutosaveInterval = n case "show_preview", "preview": b, err := parseBool(value, line) if err != nil { return err } cfg.ShowPreview = b case "show_tree", "tree": b, err := parseBool(value, line) if err != nil { return err } cfg.ShowTree = b case "theme": cfg.Theme = value default: return fmt.Errorf("line %d: unknown setting %q", line, key) } return nil } // applyKeyBinding применяет один биндинг из секции [keys] func applyKeyBinding(cfg *Config, action, key string, line int) error { // Нормализуем имя действия action = strings.ToLower(strings.ReplaceAll(action, "-", "_")) // Проверяем что действие известно known := []string{ "quit", "quit_alt", "redo", "toggle_tree", "focus_switch", "panel_shrink", "panel_grow", "toggle_preview", } for _, a := range known { if a == action { cfg.Keys[action] = key return nil } } // Неизвестное действие — предупреждаем но не падаем return fmt.Errorf("line %d: unknown action %q", line, action) } // === Хелперы парсинга === func parseInt(s string, line int) (int, error) { n, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("line %d: expected integer, got %q", line, s) } return n, nil } func parseBool(s string, line int) (bool, error) { switch strings.ToLower(s) { case "true", "yes", "on", "1": return true, nil case "false", "no", "off", "0": return false, nil } return false, fmt.Errorf("line %d: expected bool (true/false/yes/no/on/off/1/0), got %q", line, s) } // === Сохранение конфига === // WriteDefaultConfig записывает дефолтный конфиг с комментариями в path func WriteDefaultConfig(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } content := `# md-editor configuration file # Format: key = value # Comments start with # or ; [settings] # Ширина панели файлового дерева (символы) tree_width = 25 # Ширина панели preview (символы) preview_width = 40 # Показывать номера строк (true/false) line_numbers = true # Ширина табуляции (1–16) tab_width = 4 # Автосохранение каждые N секунд (0 = выключено) autosave = 60 # Показывать preview при открытии .md файлов (true/false) show_preview = false # Показывать файловое дерево при запуске (true/false) show_tree = false # Тема (default — единственная пока) theme = default [keys] # Формат: action = key # Примеры клавиш: ctrl+c, ctrl+b, tab, f1, alt+t, <, >, " " (пробел) # Выход quit = ctrl+c quit_alt = ctrl+d # Redo redo = ctrl+r # Показать/скрыть файловое дерево toggle_tree = ctrl+b # Переключение фокуса между панелями focus_switch = tab # Уменьшить/увеличить ширину активной панели panel_shrink = < panel_grow = > # Показать/скрыть preview (только для .md) toggle_preview = " " ` return os.WriteFile(path, []byte(content), 0644) } // === Применение конфига к модели === // ApplyConfig применяет загруженный конфиг к EditorModel func (m *EditorModel) ApplyConfig(cfg Config) { m.treeWidth = cfg.TreeWidth m.previewWidth = cfg.PreviewWidth m.showLineNumbers = cfg.LineNumbers m.tabWidth = cfg.TabWidth m.autosaveInterval = cfg.AutosaveInterval if cfg.ShowTree { m.showFileTree = true m.fileTree.reload() } // ShowPreview из конфига применяется только если тип файла md — // это проверяется при открытии файла, здесь только сохраняем флаг m.cfgShowPreview = cfg.ShowPreview m.config = cfg m.clampPanelWidths() }