432 lines
12 KiB
Go
432 lines
12 KiB
Go
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()
|
||
}
|