diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index 248395f5..8310e2bb 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -42,6 +42,50 @@ Surge implements a self-healing configuration system to ensure the application r - **Syntactic Validation**: Proxy URLs and DNS server lists are validated for correct syntax. - **Category Integrity**: If a custom category has an invalid regular expression pattern, it is automatically pruned from the active list to prevent engine crashes. - **Corrupt JSON Fallback**: If the `settings.json` file is completely unparseable (e.g., missing brackets or commas), Surge will log a warning and start with all factory default settings for that session. +## Keymap Configuration + +Surge allows you to customize your keyboard shortcuts by editing the `keymap.json` file located in the application config directory: + +- **Windows:** `%APPDATA%\surge\keymap.json` +- **macOS:** `~/Library/Application Support/surge/keymap.json` +- **Linux:** `~/.config/surge/keymap.json` + +Surge will automatically generate this file with all default keybindings (including Vim-style keys) on the first startup. + +### Structure + +The `keymap.json` file is structured into nested sections matching each TUI state (e.g., `dashboard`, `settings`, `file_picker`, etc.). Each binding consists of an array of key strings and a help description. For example: + +```json +{ + "dashboard": { + "Quit": { + "keys": [ + "ctrl+c", + "ctrl+q" + ], + "help": "quit" + }, + "Up": { + "keys": [ + "up", + "k" + ], + "help": "up" + } + } +} +``` + +*Note: You do not need to specify all keys. Surge will automatically validate and fall back to internal defaults for any missing or invalid keybindings on startup.* + +### Ambiguous Bindings & ForceQuit + +By default, both `Quit` and `ForceQuit` in the dashboard bind `ctrl+c`. +- **Quit** (`ctrl+c` or `ctrl+q`) initiates a graceful shutdown of all active download tasks, ensuring that progress and state are fully persisted before exiting the application. +- **ForceQuit** (`ctrl+c`) performs an immediate exit of the application without waiting for the graceful shutdown of the background download engine. + +If you choose to customize these bindings, you can separate them (e.g. binding `Quit` exclusively to `ctrl+q` and `ForceQuit` exclusively to `ctrl+c`) to avoid any ambiguity during normal exit. ## Directory Structure @@ -49,7 +93,7 @@ Surge follows OS conventions for storing its files. Below is a breakdown of ever | Directory | Purpose | Linux | macOS | Windows | | :---------- | :-------------------------------- | :--------------------------- | :------------------------------------------ | :---------------------- | -| **Config** | `settings.json` | `~/.config/surge/` | `~/Library/Application Support/surge/` | `%APPDATA%\surge\` | +| **Config** | `settings.json`, `keymap.json` | `~/.config/surge/` | `~/Library/Application Support/surge/` | `%APPDATA%\surge\` | | **State** | Database (`surge.db`), auth token | `~/.local/state/surge/` | `~/Library/Application Support/surge/` | `%APPDATA%\surge\` | | **Logs** | Timestamped `.log` files | `~/.local/state/surge/logs/` | `~/Library/Application Support/surge/logs/` | `%APPDATA%\surge\logs\` | | **Themes** | Custom `.toml` theme files | `~/.config/surge/themes/` | `~/Library/Application Support/surge/themes/` | `%APPDATA%\surge\themes\` | diff --git a/internal/config/keymaps.go b/internal/config/keymaps.go new file mode 100644 index 00000000..77b55feb --- /dev/null +++ b/internal/config/keymaps.go @@ -0,0 +1,824 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + + "charm.land/bubbles/v2/key" + "github.com/SurgeDM/Surge/internal/utils" +) + +// KeyMap defines the keybindings for the entire application +type KeyMap struct { + Dashboard DashboardKeyMap `json:"dashboard"` + Input InputKeyMap `json:"input"` + FilePicker FilePickerKeyMap `json:"file_picker"` + Duplicate DuplicateKeyMap `json:"duplicate"` + Extension ExtensionKeyMap `json:"extension"` + Settings SettingsKeyMap `json:"settings"` + SettingsEditor SettingsEditorKeyMap `json:"settings_editor"` + BatchConfirm BatchConfirmKeyMap `json:"batch_confirm"` + Update UpdateKeyMap `json:"update"` + BugReport BugReportKeyMap `json:"bug_report"` + CategoryMgr CategoryManagerKeyMap `json:"category_mgr"` + QuitConfirm QuitConfirmKeyMap `json:"quit_confirm"` + + // StartupWarnings holds validation messages from the most recent LoadKeyMap call. + // It is ignored during JSON serialization. + StartupWarnings []string `json:"-"` +} + +// DashboardKeyMap defines keybindings for the main dashboard +type DashboardKeyMap struct { + TabQueued key.Binding + TabActive key.Binding + TabDone key.Binding + NextTab key.Binding + PrevTab key.Binding + Add key.Binding + BatchImport key.Binding + Search key.Binding + Pause key.Binding + Refresh key.Binding + Delete key.Binding + Settings key.Binding + Log key.Binding + ToggleHelp key.Binding + ReportBug key.Binding + OpenFile key.Binding + Quit key.Binding + ForceQuit key.Binding + CategoryFilter key.Binding + PinTab key.Binding + // Navigation + Up key.Binding + Down key.Binding + // Log Navigation + LogUp key.Binding + LogDown key.Binding + LogTop key.Binding + LogBottom key.Binding + LogClose key.Binding +} + +// InputKeyMap defines keybindings for the add download input +type InputKeyMap struct { + Tab key.Binding + Enter key.Binding + Esc key.Binding + Up key.Binding + Down key.Binding + Cancel key.Binding +} + +// FilePickerKeyMap defines keybindings for the file picker +type FilePickerKeyMap struct { + UseDir key.Binding + GotoHome key.Binding + Back key.Binding + Forward key.Binding + Open key.Binding + Cancel key.Binding +} + +// DuplicateKeyMap defines keybindings for duplicate warning +type DuplicateKeyMap struct { + Continue key.Binding + Focus key.Binding + Cancel key.Binding +} + +// ExtensionKeyMap defines keybindings for extension confirmation +type ExtensionKeyMap struct { + Confirm key.Binding + Browse key.Binding + Next key.Binding + Prev key.Binding + Cancel key.Binding +} + +// SettingsKeyMap defines keybindings for the settings view +type SettingsKeyMap struct { + Tab1 key.Binding + Tab2 key.Binding + Tab3 key.Binding + Tab4 key.Binding + Tab5 key.Binding + NextTab key.Binding + PrevTab key.Binding + Browse key.Binding + Edit key.Binding + Up key.Binding + Down key.Binding + Reset key.Binding + Close key.Binding + ReportBug key.Binding +} + +// SettingsEditorKeyMap defines keybindings for editing a setting +type SettingsEditorKeyMap struct { + Confirm key.Binding + Cancel key.Binding +} + +// BatchConfirmKeyMap defines keybindings for batch import confirmation +type BatchConfirmKeyMap struct { + Confirm key.Binding + Cancel key.Binding +} + +// UpdateKeyMap defines keybindings for update notification +type UpdateKeyMap struct { + OpenGitHub key.Binding + IgnoreNow key.Binding + NeverRemind key.Binding +} + +// BugReportKeyMap defines keybindings for selecting bug report target. +type BugReportKeyMap struct { + Core key.Binding + Extension key.Binding + Cancel key.Binding +} + +// QuitConfirmKeyMap defines keybindings for the quit confirmation modal +type QuitConfirmKeyMap struct { + Left key.Binding + Right key.Binding + Yes key.Binding + No key.Binding + Select key.Binding + Cancel key.Binding +} + +// CategoryManagerKeyMap defines keybindings for the category manager +type CategoryManagerKeyMap struct { + Up key.Binding + Down key.Binding + Edit key.Binding + Add key.Binding + Delete key.Binding + Toggle key.Binding // toggle enable/disable + Tab key.Binding + Close key.Binding +} + +// KeyBindingConfig represents a single key binding. +type KeyBindingConfig struct { + Keys []string `json:"keys"` + Help string `json:"help"` +} + +// KeyMapConfig mirrors the structure of KeyMap for configuration. +type KeyMapConfig struct { + Dashboard map[string]KeyBindingConfig `json:"dashboard"` + Input map[string]KeyBindingConfig `json:"input"` + FilePicker map[string]KeyBindingConfig `json:"file_picker"` + Duplicate map[string]KeyBindingConfig `json:"duplicate"` + Extension map[string]KeyBindingConfig `json:"extension"` + Settings map[string]KeyBindingConfig `json:"settings"` + SettingsEditor map[string]KeyBindingConfig `json:"settings_editor"` + BatchConfirm map[string]KeyBindingConfig `json:"batch_confirm"` + Update map[string]KeyBindingConfig `json:"update"` + BugReport map[string]KeyBindingConfig `json:"bug_report"` + CategoryMgr map[string]KeyBindingConfig `json:"category_mgr"` + QuitConfirm map[string]KeyBindingConfig `json:"quit_confirm"` +} + +// GetKeyMapConfigPath returns the path to the Keymaps JSON file. +func GetKeyMapConfigPath() string { + return filepath.Join(GetSurgeDir(), "keymap.json") +} + +// LoadKeyMap loads the keymap configuration from file. +func LoadKeyMap() (*KeyMap, error) { + defaults := DefaultKeyMap() + path := GetKeyMapConfigPath() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + utils.Debug("Warning: Created New %s file \u2014 using defaults", path) + _ = SaveKeyMap(defaults) + return defaults, nil + } + return nil, err + } + + var cfg KeyMapConfig + if err := json.Unmarshal(data, &cfg); err != nil { + utils.Debug("Warning: corrupt keymap file %s: %v \u2014 using defaults", path, err) + defaults.StartupWarnings = append(defaults.StartupWarnings, + fmt.Sprintf("Config: keymap file is corrupt (%v) — all keybindings reset to defaults & rewrite the file", err)) + err = SaveKeyMap(defaults) + return defaults, err + } + + defaults.ApplyConfig(&cfg) + defaults.Validate() + // Self-healing: save the fully-merged and validated keymap back to disk + // so that any new defaults, keys, or sections are immediately preserved. + _ = SaveKeyMap(defaults) + return defaults, nil +} + +// SaveKeyMap saves the keymap configuration to file. +func SaveKeyMap(k *KeyMap) error { + path := GetKeyMapConfigPath() + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + cfg := k.ToConfig() + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + // Atomic write: write to temp file, then rename + tempPath := path + ".tmp" + if err := os.WriteFile(tempPath, data, 0o644); err != nil { + return err + } + + return os.Rename(tempPath, path) +} + +// ApplyConfig applies configuration from KeyMapConfig to KeyMap. +func (k *KeyMap) ApplyConfig(cfg *KeyMapConfig) { + if cfg == nil { + return + } + + applyToStruct := func(s any, m map[string]KeyBindingConfig) { + v := reflect.ValueOf(s).Elem() + t := v.Type() + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + if field.Type() == reflect.TypeFor[key.Binding]() { + fieldName := t.Field(i).Name + if bCfg, ok := m[fieldName]; ok { + if len(bCfg.Keys) > 0 { + helpDesc := bCfg.Help + if helpDesc == "" { + helpDesc = field.Interface().(key.Binding).Help().Desc + } + helpKey := strings.Join(bCfg.Keys, "/") + newBinding := key.NewBinding( + key.WithKeys(bCfg.Keys...), + key.WithHelp(helpKey, helpDesc), + ) + field.Set(reflect.ValueOf(newBinding)) + } + } + } + } + } + + applyToStruct(&k.Dashboard, cfg.Dashboard) + applyToStruct(&k.Input, cfg.Input) + applyToStruct(&k.FilePicker, cfg.FilePicker) + applyToStruct(&k.Duplicate, cfg.Duplicate) + applyToStruct(&k.Extension, cfg.Extension) + applyToStruct(&k.Settings, cfg.Settings) + applyToStruct(&k.SettingsEditor, cfg.SettingsEditor) + applyToStruct(&k.BatchConfirm, cfg.BatchConfirm) + applyToStruct(&k.Update, cfg.Update) + applyToStruct(&k.BugReport, cfg.BugReport) + applyToStruct(&k.CategoryMgr, cfg.CategoryMgr) + applyToStruct(&k.QuitConfirm, cfg.QuitConfirm) +} + +// ToConfig converts KeyMap to KeyMapConfig for serialization. +func (k *KeyMap) ToConfig() *KeyMapConfig { + structToMap := func(s any) map[string]KeyBindingConfig { + v := reflect.ValueOf(s) + t := v.Type() + m := make(map[string]KeyBindingConfig) + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + if field.Type() == reflect.TypeFor[key.Binding]() { + binding := field.Interface().(key.Binding) + m[t.Field(i).Name] = KeyBindingConfig{ + Keys: binding.Keys(), + Help: binding.Help().Desc, + } + } + } + return m + } + + return &KeyMapConfig{ + Dashboard: structToMap(k.Dashboard), + Input: structToMap(k.Input), + FilePicker: structToMap(k.FilePicker), + Duplicate: structToMap(k.Duplicate), + Extension: structToMap(k.Extension), + Settings: structToMap(k.Settings), + SettingsEditor: structToMap(k.SettingsEditor), + BatchConfirm: structToMap(k.BatchConfirm), + Update: structToMap(k.Update), + BugReport: structToMap(k.BugReport), + CategoryMgr: structToMap(k.CategoryMgr), + QuitConfirm: structToMap(k.QuitConfirm), + } +} + +// Validate checks keymap for missing or invalid bindings and fills with defaults at both section and binding levels. +func (k *KeyMap) Validate() { + defaults := DefaultKeyMap() + if k == nil { + return + } + + v := reflect.ValueOf(k).Elem() + dV := reflect.ValueOf(defaults).Elem() + t := v.Type() + + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + defaultField := dV.Field(i) + fieldType := t.Field(i).Type + + if fieldType.Kind() == reflect.Struct { + // If the entire section struct is zero, copy it completely from default + if reflect.DeepEqual(field.Interface(), reflect.Zero(fieldType).Interface()) { + field.Set(defaultField) + continue + } + + // Otherwise, do field-by-field check for each key.Binding within the section + // to heal any individual missing or invalid bindings. + if field.CanAddr() { + fieldAddr := field.Addr().Interface() + defaultFieldVal := defaultField + + subV := reflect.ValueOf(fieldAddr).Elem() + subDV := defaultFieldVal + + for j := 0; j < subV.NumField(); j++ { + subField := subV.Field(j) + if subField.Type() == reflect.TypeFor[key.Binding]() { + b := subField.Interface().(key.Binding) + // If the binding is uninitialized or has no keys configured, restore the default binding + if len(b.Keys()) == 0 { + subField.Set(subDV.Field(j)) + } + } + } + } + } + } +} + +func DefaultKeyMap() *KeyMap { + return &KeyMap{ + Dashboard: DashboardKeyMap{ + TabQueued: key.NewBinding( + key.WithKeys("q"), + key.WithHelp("q", "queued tab"), + ), + TabActive: key.NewBinding( + key.WithKeys("w"), + key.WithHelp("w", "active tab"), + ), + TabDone: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "done tab"), + ), + NextTab: key.NewBinding( + key.WithKeys("tab", "right", "l"), + key.WithHelp("tab/→/l", "next tab"), + ), + PrevTab: key.NewBinding( + key.WithKeys("shift+tab", "left", "h"), + key.WithHelp("shift+tab/←/h", "prev tab"), + ), + Add: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "add download"), + ), + BatchImport: key.NewBinding( + key.WithKeys("b", "B"), + key.WithHelp("b", "batch import"), + ), + Search: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "search"), + ), + Pause: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "pause/resume"), + ), + Refresh: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "refresh url"), + ), + Delete: key.NewBinding( + key.WithKeys("x"), + key.WithHelp("x", "delete"), + ), + Settings: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "settings"), + ), + Log: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "toggle log"), + ), + ToggleHelp: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "keybindings"), + ), + ReportBug: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "bug report"), + ), + OpenFile: key.NewBinding( + key.WithKeys("o"), + key.WithHelp("o", "open file"), + ), + Quit: key.NewBinding( + key.WithKeys("ctrl+c", "ctrl+q"), + key.WithHelp("ctrl+q", "quit"), + ), + ForceQuit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "force quit"), + ), + CategoryFilter: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "category"), + ), + PinTab: key.NewBinding( + key.WithKeys("t"), + key.WithHelp("t", "pin tab"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("\u2191/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("\u2193/j", "down"), + ), + LogUp: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("↑", "scroll up"), + ), + LogDown: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("↓", "scroll down"), + ), + LogTop: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("g", "top"), + ), + LogBottom: key.NewBinding( + key.WithKeys("G"), + key.WithHelp("G", "bottom"), + ), + LogClose: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "close log"), + ), + }, + Input: InputKeyMap{ + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "browse/next"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm/next"), + ), + Esc: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + Up: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("\u2191", "previous"), + ), + Down: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("\u2193", "next"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + }, + FilePicker: FilePickerKeyMap{ + UseDir: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "use current"), + ), + GotoHome: key.NewBinding( + key.WithKeys("h", "H"), + key.WithHelp("h/H", "home"), + ), + Back: key.NewBinding( + key.WithKeys("left"), + key.WithHelp("\u2190", "back"), + ), + Forward: key.NewBinding( + key.WithKeys("right"), + key.WithHelp("\u2192", "open"), + ), + Open: key.NewBinding( + key.WithKeys("."), + key.WithHelp(".", "select highlighted"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + }, + Duplicate: DuplicateKeyMap{ + Continue: key.NewBinding( + key.WithKeys("c", "C", "enter"), + key.WithHelp("c/enter", "continue"), + ), + Focus: key.NewBinding( + key.WithKeys("f", "F", "down", "j"), + key.WithHelp("f/j", "focus existing"), + ), + Cancel: key.NewBinding( + key.WithKeys("x", "X", "esc", "q"), + key.WithHelp("x/q", "cancel"), + ), + }, + Extension: ExtensionKeyMap{ + Confirm: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ), + Browse: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "browse path"), + ), + Next: key.NewBinding( + key.WithKeys("down", "j", "tab"), + key.WithHelp("\u2193/j", "next field"), + ), + Prev: key.NewBinding( + key.WithKeys("up", "k", "shift+tab"), + key.WithHelp("\u2191/k", "prev field"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + }, + Settings: SettingsKeyMap{ + Tab1: key.NewBinding( + key.WithKeys("1"), + key.WithHelp("1", "general"), + ), + Tab2: key.NewBinding( + key.WithKeys("2"), + key.WithHelp("2", "network"), + ), + Tab3: key.NewBinding( + key.WithKeys("3"), + key.WithHelp("3", "performance"), + ), + Tab4: key.NewBinding( + key.WithKeys("4"), + key.WithHelp("4", "categories"), + ), + Tab5: key.NewBinding( + key.WithKeys("5"), + key.WithHelp("5", "extension"), + ), + NextTab: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("\u2192/l", "next tab"), + ), + PrevTab: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("\u2190/h", "prev tab"), + ), + Browse: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "browse dir"), + ), + Edit: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "edit"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("\u2191/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("\u2193/j", "down"), + ), + Reset: key.NewBinding( + key.WithKeys("r", "R"), + key.WithHelp("r", "reset"), + ), + Close: key.NewBinding( + key.WithKeys("esc", "q"), + key.WithHelp("esc/q", "save & close"), + ), + ReportBug: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "bug report"), + ), + }, + SettingsEditor: SettingsEditorKeyMap{ + Confirm: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + }, + BatchConfirm: BatchConfirmKeyMap{ + Confirm: key.NewBinding( + key.WithKeys("y", "Y", "enter"), + key.WithHelp("y", "confirm"), + ), + Cancel: key.NewBinding( + key.WithKeys("n", "N", "esc"), + key.WithHelp("n", "cancel"), + ), + }, + Update: UpdateKeyMap{ + OpenGitHub: key.NewBinding( + key.WithKeys("o", "O", "enter"), + key.WithHelp("o", "open on github"), + ), + IgnoreNow: key.NewBinding( + key.WithKeys("i", "I", "esc"), + key.WithHelp("i", "ignore for now"), + ), + NeverRemind: key.NewBinding( + key.WithKeys("n", "N"), + key.WithHelp("n", "never remind"), + ), + }, + BugReport: BugReportKeyMap{ + Core: key.NewBinding( + key.WithKeys("1", "c", "C"), + key.WithHelp("1", "core report"), + ), + Extension: key.NewBinding( + key.WithKeys("2", "e", "E"), + key.WithHelp("2", "extension report"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc", "q"), + key.WithHelp("esc/q", "cancel"), + ), + }, + CategoryMgr: CategoryManagerKeyMap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), + Edit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "edit")), + Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")), + Delete: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "delete")), + Toggle: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "toggle")), + Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next field")), + Close: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc/q", "save & close")), + }, + QuitConfirm: QuitConfirmKeyMap{ + Left: key.NewBinding( + key.WithKeys("left", "h"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l", "tab"), + ), + Yes: key.NewBinding( + key.WithKeys("y", "Y"), + ), + No: key.NewBinding( + key.WithKeys("n", "N"), + ), + Select: key.NewBinding( + key.WithKeys("enter", "space"), + key.WithHelp("y/enter", "confirm"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc", "ctrl+c", "ctrl+q"), + key.WithHelp("n/esc", "cancel"), + ), + }, + StartupWarnings: nil, + } +} + +// ShortHelp returns keybindings to show in the mini help view +func (k DashboardKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.ToggleHelp, k.ReportBug} +} + +// FullHelp returns keybindings for the expanded help view +func (k DashboardKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.TabQueued, k.TabActive, k.TabDone, k.NextTab, k.PrevTab}, + {k.Add, k.BatchImport, k.Search, k.CategoryFilter, k.Pause, k.Refresh, k.Delete, k.Settings, k.PinTab}, + {k.Log, k.OpenFile, k.ReportBug, k.Quit}, + } +} + +func (k InputKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Tab, k.Enter, k.Esc} +} + +func (k InputKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Tab, k.Enter, k.Esc}} +} + +func (k FilePickerKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Back, k.Forward, k.UseDir, k.GotoHome, k.Open, k.Cancel} +} + +func (k FilePickerKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Back, k.Forward, k.UseDir, k.GotoHome, k.Open, k.Cancel}} +} + +func (k DuplicateKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Continue, k.Focus, k.Cancel} +} + +func (k DuplicateKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Continue, k.Focus, k.Cancel}} +} + +func (k ExtensionKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Browse, k.Prev, k.Next, k.Confirm, k.Cancel} +} + +func (k ExtensionKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Browse, k.Prev, k.Next, k.Confirm, k.Cancel}} +} + +func (k SettingsKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.PrevTab, k.NextTab, k.Edit, k.Reset, k.Close} +} + +func (k SettingsKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Tab1, k.Tab2, k.Tab3, k.Tab4, k.Tab5}, + {k.PrevTab, k.NextTab, k.Up, k.Down, k.Edit, k.Reset, k.Browse, k.ReportBug, k.Close}, + } +} + +func (k SettingsEditorKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Confirm, k.Cancel} +} + +func (k SettingsEditorKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Confirm, k.Cancel}} +} + +func (k BatchConfirmKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Confirm, k.Cancel} +} + +func (k BatchConfirmKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Confirm, k.Cancel}} +} + +func (k UpdateKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.OpenGitHub, k.IgnoreNow, k.NeverRemind} +} + +func (k UpdateKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.OpenGitHub, k.IgnoreNow, k.NeverRemind}} +} + +func (k BugReportKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Core, k.Extension, k.Cancel} +} + +func (k BugReportKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Core, k.Extension, k.Cancel}} +} + +func (k CategoryManagerKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Edit, k.Add, k.Delete, k.Tab, k.Toggle, k.Close} +} + +func (k CategoryManagerKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Up, k.Down, k.Edit, k.Add, k.Delete, k.Tab, k.Toggle, k.Close}} +} + +func (k QuitConfirmKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Select, k.Cancel} +} + +func (k QuitConfirmKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Select, k.Cancel}} +} diff --git a/internal/config/keymaps_test.go b/internal/config/keymaps_test.go new file mode 100644 index 00000000..a16246be --- /dev/null +++ b/internal/config/keymaps_test.go @@ -0,0 +1,143 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestDefaultKeyMap(t *testing.T) { + km := DefaultKeyMap() + if km == nil { + t.Fatal("DefaultKeyMap returned nil") + } + if len(km.Dashboard.Quit.Keys()) == 0 { + t.Error("Default Dashboard.Quit keys should not be empty") + } +} + +func TestKeyMapConversion(t *testing.T) { + km := DefaultKeyMap() + cfg := km.ToConfig() + + if cfg == nil { + t.Fatal("ToConfig returned nil") + } + + // Verify some fields + if len(cfg.Dashboard["Quit"].Keys) == 0 { + t.Error("Config Dashboard.Quit keys should not be empty") + } + + // Verify reflection-based conversion + km2 := DefaultKeyMap() + // Change a key in config + cfg.Dashboard["Quit"] = KeyBindingConfig{ + Keys: []string{"ctrl+x"}, + Help: "exit", + } + km2.ApplyConfig(cfg) + + if km2.Dashboard.Quit.Keys()[0] != "ctrl+x" { + t.Errorf("Expected Quit key to be ctrl+x, got %v", km2.Dashboard.Quit.Keys()) + } + if km2.Dashboard.Quit.Help().Desc != "exit" { + t.Errorf("Expected Quit help desc to be exit, got %s", km2.Dashboard.Quit.Help().Desc) + } +} + +func TestSaveAndLoadKeyMap(t *testing.T) { + // Mock SurgeDir + tmpDir, err := os.MkdirTemp("", "surge-test-*") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + // We need to override GetSurgeDir or similar if it's used. + // Since I can't easily override the function, I'll test the inner logic. + + km := DefaultKeyMap() + cfg := km.ToConfig() + cfg.Dashboard["Quit"] = KeyBindingConfig{ + Keys: []string{"q"}, + Help: "quit app", + } + + path := filepath.Join(tmpDir, "keymap.json") + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatal(err) + } + err = os.WriteFile(path, data, 0644) + if err != nil { + t.Fatal(err) + } + + // Test loading logic manually since LoadKeyMap uses a fixed path + var loadedCfg KeyMapConfig + data, err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + err = json.Unmarshal(data, &loadedCfg) + if err != nil { + t.Fatal(err) + } + + kmLoaded := DefaultKeyMap() + kmLoaded.ApplyConfig(&loadedCfg) + + if kmLoaded.Dashboard.Quit.Keys()[0] != "q" { + t.Errorf("Expected loaded Quit key to be q, got %v", kmLoaded.Dashboard.Quit.Keys()) + } +} + +func TestValidateKeyMap(t *testing.T) { + km := &KeyMap{} + km.Validate() + + defaults := DefaultKeyMap() + if !reflect.DeepEqual(km.Dashboard, defaults.Dashboard) { + t.Error("Validate should have filled Dashboard with defaults") + } +} + +func TestReportBugAndToggleHelpKeymaps(t *testing.T) { + km := DefaultKeyMap() + + // 1. Dashboard.ToggleHelp + toggleHelpKeys := km.Dashboard.ToggleHelp.Keys() + if len(toggleHelpKeys) != 1 || toggleHelpKeys[0] != "/" { + t.Errorf("Expected Dashboard.ToggleHelp default keys to be ['/'], got %v", toggleHelpKeys) + } + if km.Dashboard.ToggleHelp.Help().Key != "/" { + t.Errorf("Expected Dashboard.ToggleHelp help key to be '/', got %q", km.Dashboard.ToggleHelp.Help().Key) + } + + // 2. Dashboard.ReportBug + reportBugKeys := km.Dashboard.ReportBug.Keys() + if len(reportBugKeys) != 1 || reportBugKeys[0] != "?" { + t.Errorf("Expected Dashboard.ReportBug default keys to be ['?'], got %v", reportBugKeys) + } + if km.Dashboard.ReportBug.Help().Key != "?" { + t.Errorf("Expected Dashboard.ReportBug help key to be '?', got %q", km.Dashboard.ReportBug.Help().Key) + } + if km.Dashboard.ReportBug.Help().Desc != "bug report" { + t.Errorf("Expected Dashboard.ReportBug help desc to be 'bug report', got %q", km.Dashboard.ReportBug.Help().Desc) + } + + // 3. Settings.ReportBug + settingsReportBugKeys := km.Settings.ReportBug.Keys() + if len(settingsReportBugKeys) != 1 || settingsReportBugKeys[0] != "?" { + t.Errorf("Expected Settings.ReportBug default keys to be ['?'], got %v", settingsReportBugKeys) + } + if km.Settings.ReportBug.Help().Key != "?" { + t.Errorf("Expected Settings.ReportBug help key to be '?', got %q", km.Settings.ReportBug.Help().Key) + } + if km.Settings.ReportBug.Help().Desc != "bug report" { + t.Errorf("Expected Settings.ReportBug help desc to be 'bug report', got %q", km.Settings.ReportBug.Help().Desc) + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go index d2738b00..7fef40d1 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -68,19 +68,19 @@ type ExtensionSettings struct { // NetworkSettings contains network connection parameters. type NetworkSettings struct { - MaxConnectionsPerDownload int `json:"max_connections_per_host" ui_label:"Max Connections/Download" ui_desc:"Maximum concurrent connections per download (1-64)."` + MaxConnectionsPerDownload int `json:"max_connections_per_host" ui_label:"Max Connections/Download" ui_desc:"Maximum concurrent connections per download (1-64)."` // Deprecated: use MaxConnectionsPerDownload. // Kept as a non-serialized compatibility alias for older code paths and tests. - MaxConnectionsPerHost int `json:"-" ui_ignored:"true"` - MaxConcurrentDownloads int `json:"max_concurrent_downloads" ui_label:"Max Concurrent Downloads" ui_desc:"Maximum number of downloads running at once (1-10)." ui_restart:"true"` - MaxConcurrentProbes int `json:"max_concurrent_probes" ui_label:"Max Concurrent Probes" ui_desc:"Maximum number of simultaneous server probes when adding many downloads at once (1-10)." ui_restart:"true"` - UserAgent string `json:"user_agent" ui_label:"User Agent" ui_desc:"Custom User-Agent string for HTTP requests. Leave empty for default."` - ProxyURL string `json:"proxy_url" ui_label:"Proxy URL" ui_desc:"HTTP/HTTPS proxy URL (e.g. http://127.0.0.1:1700). Leave empty to use system default."` - CustomDNS string `json:"custom_dns" ui_label:"Custom DNS Server" ui_desc:"Set custom DNS (e.g., 1.1.1.1:53, 94.140.14.14:53). Leave empty for system."` - SequentialDownload bool `json:"sequential_download" ui_label:"Sequential Download" ui_desc:"Download pieces in order (Streaming Mode). May be slower."` - MinChunkSize int64 `json:"min_chunk_size" ui_label:"Min Chunk Size" ui_desc:"Minimum download chunk size in MB (e.g., 2)."` - WorkerBufferSize int `json:"worker_buffer_size" ui_label:"Worker Buffer Size" ui_desc:"I/O buffer size per worker in KB (e.g., 512)."` - DialHedgeCount int `json:"dial_hedge_count" ui_label:"Dial Hedge Count" ui_desc:"Number of extra connections to dial pre-emptively to avoid slow connects (0-16)."` + MaxConnectionsPerHost int `json:"-" ui_ignored:"true"` + MaxConcurrentDownloads int `json:"max_concurrent_downloads" ui_label:"Max Concurrent Downloads" ui_desc:"Maximum number of downloads running at once (1-10)." ui_restart:"true"` + MaxConcurrentProbes int `json:"max_concurrent_probes" ui_label:"Max Concurrent Probes" ui_desc:"Maximum number of simultaneous server probes when adding many downloads at once (1-10)." ui_restart:"true"` + UserAgent string `json:"user_agent" ui_label:"User Agent" ui_desc:"Custom User-Agent string for HTTP requests. Leave empty for default."` + ProxyURL string `json:"proxy_url" ui_label:"Proxy URL" ui_desc:"HTTP/HTTPS proxy URL (e.g. http://127.0.0.1:1700). Leave empty to use system default."` + CustomDNS string `json:"custom_dns" ui_label:"Custom DNS Server" ui_desc:"Set custom DNS (e.g., 1.1.1.1:53, 94.140.14.14:53). Leave empty for system."` + SequentialDownload bool `json:"sequential_download" ui_label:"Sequential Download" ui_desc:"Download pieces in order (Streaming Mode). May be slower."` + MinChunkSize int64 `json:"min_chunk_size" ui_label:"Min Chunk Size" ui_desc:"Minimum download chunk size in MB (e.g., 2)."` + WorkerBufferSize int `json:"worker_buffer_size" ui_label:"Worker Buffer Size" ui_desc:"I/O buffer size per worker in KB (e.g., 512)."` + DialHedgeCount int `json:"dial_hedge_count" ui_label:"Dial Hedge Count" ui_desc:"Number of extra connections to dial pre-emptively to avoid slow connects (0-16)."` } // PerformanceSettings contains performance tuning parameters. diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index 16b52526..2347adf1 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -455,20 +455,59 @@ func TestToRuntimeConfig_Exhaustive(t *testing.T) { settings.Performance.StallTimeout = 1 * time.Second settings.Performance.SpeedEmaAlpha = 0.1 - runtime := settings.ToRuntimeConfig() + runtimeConfig := settings.ToRuntimeConfig() - v := reflect.ValueOf(*runtime) + // 1. Verify that every field in runtimeConfig is non-zero when populated on input + v := reflect.ValueOf(*runtimeConfig) typeOfS := v.Type() for i := 0; i < v.NumField(); i++ { field := v.Field(i) fieldName := typeOfS.Field(i).Name - // Ensure no field is zero-valued if field.IsZero() { t.Errorf("Field %q is zero in resulting RuntimeConfig. Did you forget to map it in Settings.ToRuntimeConfig?", fieldName) } } + + // 2. Perform reciprocal reflection checks to ensure all fields in types.RuntimeConfig exist in either Settings.Network or Settings.Performance. + networkType := reflect.TypeOf(settings.Network) + performanceType := reflect.TypeOf(settings.Performance) + runtimeType := reflect.TypeOf(*runtimeConfig) + + for i := 0; i < runtimeType.NumField(); i++ { + fieldName := runtimeType.Field(i).Name + // Ensure the field exists in either NetworkSettings or PerformanceSettings + _, foundInNetwork := networkType.FieldByName(fieldName) + _, foundInPerformance := performanceType.FieldByName(fieldName) + if !foundInNetwork && !foundInPerformance { + t.Errorf("Field %q in types.RuntimeConfig has no matching field in NetworkSettings or PerformanceSettings", fieldName) + } + } + + // Conversely, check that all fields in NetworkSettings and PerformanceSettings that should be converted exist in types.RuntimeConfig. + ignoredNetworkFields := map[string]bool{ + "MaxConcurrentDownloads": true, + "MaxConcurrentProbes": true, + "MaxConnectionsPerHost": true, + } + + for i := 0; i < networkType.NumField(); i++ { + fieldName := networkType.Field(i).Name + if ignoredNetworkFields[fieldName] { + continue + } + if _, found := runtimeType.FieldByName(fieldName); !found { + t.Errorf("Field %q in NetworkSettings has no matching field in types.RuntimeConfig", fieldName) + } + } + + for i := 0; i < performanceType.NumField(); i++ { + fieldName := performanceType.Field(i).Name + if _, found := runtimeType.FieldByName(fieldName); !found { + t.Errorf("Field %q in PerformanceSettings has no matching field in types.RuntimeConfig", fieldName) + } + } } func TestGetSettingsMetadata(t *testing.T) { diff --git a/internal/engine/concurrent/health_test.go b/internal/engine/concurrent/health_test.go index d244a00d..7a63b860 100644 --- a/internal/engine/concurrent/health_test.go +++ b/internal/engine/concurrent/health_test.go @@ -184,9 +184,9 @@ func TestHealth_StallDetection(t *testing.T) { func TestHealth_ZeroStallTimeoutDisablesStallDetection(t *testing.T) { runtime := &types.RuntimeConfig{ - SlowWorkerThreshold: 0, + SlowWorkerThreshold: 0.5, SlowWorkerGracePeriod: 0, - StallTimeout: 0, + StallTimeout: 0, // Disabled } state := types.NewProgressState("test", 1000) d := NewConcurrentDownloader("test", nil, state, runtime) @@ -195,19 +195,53 @@ func TestHealth_ZeroStallTimeoutDisablesStallDetection(t *testing.T) { defer cancel() now := time.Now() + + stalledCtx, stalledCancel := context.WithCancel(ctx) active := &ActiveTask{ StartTime: now.Add(-10 * time.Second), - Cancel: cancel, + Cancel: stalledCancel, } - active.LastActivity.Store(now.Add(-2 * time.Second).UnixNano()) + active.LastActivity.Store(now.Add(-2 * time.Second).UnixNano()) // Stalled for 2s active.Speed = 5 * 1024 * 1024 d.activeTasks[0] = active d.checkWorkerHealth() + // Verify stalled worker was NOT cancelled select { - case <-ctx.Done(): - t.Error("worker should not have been cancelled when stall timeout is disabled") + case <-stalledCtx.Done(): + t.Error("Stalled worker should NOT have been cancelled since stall detection is disabled") default: + // Success + } +} + +func TestHealth_ZeroSlowWorkerThresholdDisablesSlowCheck(t *testing.T) { + runtime := &types.RuntimeConfig{ + SlowWorkerThreshold: 0, // Disabled + SlowWorkerGracePeriod: 0, + } + state := types.NewProgressState("test", 1000) + d := NewConcurrentDownloader("test", nil, state, runtime) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + now := time.Now() + + _, w0Cancel := context.WithCancel(ctx) + w1Ctx, w1Cancel := context.WithCancel(ctx) + + d.activeTasks[0] = &ActiveTask{StartTime: now.Add(-10 * time.Second), Speed: 10 * 1024 * 1024, Cancel: w0Cancel} + d.activeTasks[1] = &ActiveTask{StartTime: now.Add(-10 * time.Second), Speed: 1 * 1024 * 1024, Cancel: w1Cancel} + + d.checkWorkerHealth() + + // Verify slow worker (Worker 1) was NOT cancelled + select { + case <-w1Ctx.Done(): + t.Error("Worker 1 should NOT have been cancelled since slow worker checks are disabled") + default: + // Success } } diff --git a/internal/engine/types/accuracy_test.go b/internal/engine/types/accuracy_test.go index 27c939aa..33b8f57f 100644 --- a/internal/engine/types/accuracy_test.go +++ b/internal/engine/types/accuracy_test.go @@ -94,7 +94,7 @@ func TestRestoreBitmap_ShortBitmapRecoversWithoutPanic(t *testing.T) { state := types.NewProgressState("test-short-restore", totalSize) malformed := []byte{0x02} // Too short: only enough storage for 4 chunks. - expectedBytes := 25 // 100 chunks * 2 bits = 25 bytes. + expectedBytes := 25 // 100 chunks * 2 bits = 25 bytes. defer func() { if r := recover(); r != nil { diff --git a/internal/engine/types/progress.go b/internal/engine/types/progress.go index e15e04a6..73673044 100644 --- a/internal/engine/types/progress.go +++ b/internal/engine/types/progress.go @@ -332,10 +332,7 @@ func (ps *ProgressState) RestoreBitmap(bitmap []byte, actualChunkSize int64) { ps.mu.Lock() defer ps.mu.Unlock() - if len(bitmap) == 0 || actualChunkSize <= 0 { - return - } - if ps.TotalSize <= 0 { + if len(bitmap) == 0 || actualChunkSize <= 0 || ps.TotalSize <= 0 { return } @@ -344,8 +341,7 @@ func (ps *ProgressState) RestoreBitmap(bitmap []byte, actualChunkSize int64) { return } - //utils.Debug("RestoreBitmap: Len=%d, ChunkSize=%d", len(bitmap), actualChunkSize) - + // Deep copy to prevent mutation hazard of caller's backing array ps.ChunkBitmap = make([]byte, bytesNeeded) copy(ps.ChunkBitmap, bitmap) ps.ActualChunkSize = actualChunkSize diff --git a/internal/tui/keys.go b/internal/tui/keys.go index c83268ec..f3281b9e 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -1,594 +1,6 @@ package tui -import "charm.land/bubbles/v2/key" +import "github.com/SurgeDM/Surge/internal/config" -// KeyMap defines the keybindings for the entire application -type KeyMap struct { - Dashboard DashboardKeyMap - Input InputKeyMap - FilePicker FilePickerKeyMap - Duplicate DuplicateKeyMap - Extension ExtensionKeyMap - Settings SettingsKeyMap - SettingsEditor SettingsEditorKeyMap - BatchConfirm BatchConfirmKeyMap - Update UpdateKeyMap - BugReport BugReportKeyMap - CategoryMgr CategoryManagerKeyMap - QuitConfirm QuitConfirmKeyMap -} - -// DashboardKeyMap defines keybindings for the main dashboard -type DashboardKeyMap struct { - TabQueued key.Binding - TabActive key.Binding - TabDone key.Binding - NextTab key.Binding - PrevTab key.Binding - Add key.Binding - BatchImport key.Binding - Search key.Binding - Pause key.Binding - Refresh key.Binding - Delete key.Binding - Settings key.Binding - Log key.Binding - ToggleHelp key.Binding - ReportBug key.Binding - OpenFile key.Binding - Quit key.Binding - ForceQuit key.Binding - CategoryFilter key.Binding - PinTab key.Binding - // Navigation - Up key.Binding - Down key.Binding - // Log Navigation - LogUp key.Binding - LogDown key.Binding - LogTop key.Binding - LogBottom key.Binding - LogClose key.Binding -} - -// InputKeyMap defines keybindings for the add download input -type InputKeyMap struct { - Tab key.Binding - Enter key.Binding - Esc key.Binding - Up key.Binding - Down key.Binding - Cancel key.Binding -} - -// FilePickerKeyMap defines keybindings for the file picker -type FilePickerKeyMap struct { - UseDir key.Binding - GotoHome key.Binding - Back key.Binding - Forward key.Binding - Open key.Binding - Cancel key.Binding -} - -// DuplicateKeyMap defines keybindings for duplicate warning -type DuplicateKeyMap struct { - Continue key.Binding - Focus key.Binding - Cancel key.Binding -} - -// ExtensionKeyMap defines keybindings for extension confirmation -type ExtensionKeyMap struct { - Confirm key.Binding - Browse key.Binding - Next key.Binding - Prev key.Binding - Cancel key.Binding -} - -// SettingsKeyMap defines keybindings for the settings view -type SettingsKeyMap struct { - Tab1 key.Binding - Tab2 key.Binding - Tab3 key.Binding - Tab4 key.Binding - Tab5 key.Binding - NextTab key.Binding - PrevTab key.Binding - Browse key.Binding - Edit key.Binding - Up key.Binding - Down key.Binding - Reset key.Binding - Close key.Binding -} - -// SettingsEditorKeyMap defines keybindings for editing a setting -type SettingsEditorKeyMap struct { - Confirm key.Binding - Cancel key.Binding -} - -// BatchConfirmKeyMap defines keybindings for batch import confirmation -type BatchConfirmKeyMap struct { - Confirm key.Binding - Cancel key.Binding -} - -// UpdateKeyMap defines keybindings for update notification -type UpdateKeyMap struct { - OpenGitHub key.Binding - IgnoreNow key.Binding - NeverRemind key.Binding -} - -// BugReportKeyMap defines keybindings for selecting bug report target. -type BugReportKeyMap struct { - Core key.Binding - Extension key.Binding - Cancel key.Binding -} - -// QuitConfirmKeyMap defines keybindings for the quit confirmation modal -type QuitConfirmKeyMap struct { - Left key.Binding - Right key.Binding - Yes key.Binding - No key.Binding - Select key.Binding - Cancel key.Binding -} - -// CategoryManagerKeyMap defines keybindings for the category manager -type CategoryManagerKeyMap struct { - Up key.Binding - Down key.Binding - Edit key.Binding - Add key.Binding - Delete key.Binding - Toggle key.Binding // toggle enable/disable - Tab key.Binding - Close key.Binding -} - -// Keys contains all the keybindings for the application -var Keys = KeyMap{ - Dashboard: DashboardKeyMap{ - TabQueued: key.NewBinding( - key.WithKeys("q"), - key.WithHelp("q", "queued tab"), - ), - TabActive: key.NewBinding( - key.WithKeys("w"), - key.WithHelp("w", "active tab"), - ), - TabDone: key.NewBinding( - key.WithKeys("e"), - key.WithHelp("e", "done tab"), - ), - NextTab: key.NewBinding( - key.WithKeys("tab", "right"), - key.WithHelp("tab/→", "next tab"), - ), - PrevTab: key.NewBinding( - key.WithKeys("left"), - key.WithHelp("←", "prev tab"), - ), - Add: key.NewBinding( - key.WithKeys("a"), - key.WithHelp("a", "add download"), - ), - BatchImport: key.NewBinding( - key.WithKeys("b", "B"), - key.WithHelp("b", "batch import"), - ), - Search: key.NewBinding( - key.WithKeys("f"), - key.WithHelp("f", "search"), - ), - Pause: key.NewBinding( - key.WithKeys("p"), - key.WithHelp("p", "pause/resume"), - ), - Refresh: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("r", "refresh url"), - ), - Delete: key.NewBinding( - key.WithKeys("x"), - key.WithHelp("x", "delete"), - ), - Settings: key.NewBinding( - key.WithKeys("s"), - key.WithHelp("s", "settings"), - ), - Log: key.NewBinding( - key.WithKeys("l"), - key.WithHelp("l", "toggle log"), - ), - ToggleHelp: key.NewBinding( - key.WithKeys("h"), - key.WithHelp("h", "keybindings"), - ), - ReportBug: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "report bug"), - ), - OpenFile: key.NewBinding( - key.WithKeys("o"), - key.WithHelp("o", "open file"), - ), - Quit: key.NewBinding( - key.WithKeys("ctrl+c", "ctrl+q"), - key.WithHelp("ctrl+q", "quit"), - ), - ForceQuit: key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "force quit"), - ), - CategoryFilter: key.NewBinding( - key.WithKeys("c"), - key.WithHelp("c", "category"), - ), - PinTab: key.NewBinding( - key.WithKeys("t"), - key.WithHelp("t", "pin tab"), - ), - Up: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("\u2191", "up"), - ), - Down: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("\u2193", "down"), - ), - LogUp: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("↑", "scroll up"), - ), - LogDown: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("↓", "scroll down"), - ), - LogTop: key.NewBinding( - key.WithKeys("g"), - key.WithHelp("g", "top"), - ), - LogBottom: key.NewBinding( - key.WithKeys("G"), - key.WithHelp("G", "bottom"), - ), - LogClose: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close log"), - ), - }, - Input: InputKeyMap{ - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "browse/next"), - ), - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "confirm/next"), - ), - Esc: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), - Up: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("\u2191", "previous"), - ), - Down: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("\u2193", "next"), - ), - Cancel: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), - }, - FilePicker: FilePickerKeyMap{ - UseDir: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "use current"), - ), - GotoHome: key.NewBinding( - key.WithKeys("h", "H"), - key.WithHelp("h", "home"), - ), - Back: key.NewBinding( - key.WithKeys("left"), - key.WithHelp("\u2190", "back"), - ), - Forward: key.NewBinding( - key.WithKeys("right"), - key.WithHelp("\u2192", "open"), - ), - Open: key.NewBinding( - key.WithKeys("."), - key.WithHelp(".", "select highlighted"), - ), - Cancel: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), - }, - Duplicate: DuplicateKeyMap{ - Continue: key.NewBinding( - key.WithKeys("c", "C"), - key.WithHelp("c", "continue"), - ), - Focus: key.NewBinding( - key.WithKeys("f", "F"), - key.WithHelp("f", "focus existing"), - ), - Cancel: key.NewBinding( - key.WithKeys("x", "X", "esc"), - key.WithHelp("x", "cancel"), - ), - }, - Extension: ExtensionKeyMap{ - Confirm: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "confirm"), - ), - Browse: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "browse path"), - ), - Next: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("\u2193", "next field"), - ), - Prev: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("\u2191", "prev field"), - ), - Cancel: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), - }, - Settings: SettingsKeyMap{ - Tab1: key.NewBinding( - key.WithKeys("1"), - key.WithHelp("1", "general"), - ), - Tab2: key.NewBinding( - key.WithKeys("2"), - key.WithHelp("2", "network"), - ), - Tab3: key.NewBinding( - key.WithKeys("3"), - key.WithHelp("3", "performance"), - ), - Tab4: key.NewBinding( - key.WithKeys("4"), - key.WithHelp("4", "categories"), - ), - Tab5: key.NewBinding( - key.WithKeys("5"), - key.WithHelp("5", "extension"), - ), - NextTab: key.NewBinding( - key.WithKeys("right"), - key.WithHelp("\u2192", "next tab"), - ), - PrevTab: key.NewBinding( - key.WithKeys("left"), - key.WithHelp("\u2190", "prev tab"), - ), - Browse: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "browse dir"), - ), - Edit: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "edit"), - ), - Up: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("\u2191", "up"), - ), - Down: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("\u2193", "down"), - ), - Reset: key.NewBinding( - key.WithKeys("r", "R"), - key.WithHelp("r", "reset"), - ), - Close: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "save & close"), - ), - }, - SettingsEditor: SettingsEditorKeyMap{ - Confirm: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "confirm"), - ), - Cancel: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), - }, - BatchConfirm: BatchConfirmKeyMap{ - Confirm: key.NewBinding( - key.WithKeys("y", "Y", "enter"), - key.WithHelp("y", "confirm"), - ), - Cancel: key.NewBinding( - key.WithKeys("n", "N", "esc"), - key.WithHelp("n", "cancel"), - ), - }, - Update: UpdateKeyMap{ - OpenGitHub: key.NewBinding( - key.WithKeys("o", "O", "enter"), - key.WithHelp("o", "open on github"), - ), - IgnoreNow: key.NewBinding( - key.WithKeys("i", "I", "esc"), - key.WithHelp("i", "ignore for now"), - ), - NeverRemind: key.NewBinding( - key.WithKeys("n", "N"), - key.WithHelp("n", "never remind"), - ), - }, - BugReport: BugReportKeyMap{ - Core: key.NewBinding( - key.WithKeys("1", "c", "C"), - key.WithHelp("1", "core report"), - ), - Extension: key.NewBinding( - key.WithKeys("2", "e", "E"), - key.WithHelp("2", "extension report"), - ), - Cancel: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), - }, - CategoryMgr: CategoryManagerKeyMap{ - Up: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")), - Down: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")), - Edit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "edit")), - Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")), - Delete: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "delete")), - Toggle: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "toggle")), - Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next field")), - Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "save & close")), - }, - QuitConfirm: QuitConfirmKeyMap{ - Left: key.NewBinding( - key.WithKeys("left", "h"), - ), - Right: key.NewBinding( - key.WithKeys("right", "l", "tab"), - ), - Yes: key.NewBinding( - key.WithKeys("y", "Y"), - ), - No: key.NewBinding( - key.WithKeys("n", "N"), - ), - Select: key.NewBinding( - key.WithKeys("enter", "space"), - key.WithHelp("y/enter", "confirm"), - ), - Cancel: key.NewBinding( - key.WithKeys("esc", "ctrl+c", "ctrl+q"), - key.WithHelp("n/esc", "cancel"), - ), - }, -} - -// ShortHelp returns keybindings to show in the mini help view -func (k DashboardKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.ToggleHelp, k.ReportBug} -} - -// FullHelp returns keybindings for the expanded help view -func (k DashboardKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.TabQueued, k.TabActive, k.TabDone, k.NextTab, k.PrevTab}, - {k.Add, k.BatchImport, k.Search, k.CategoryFilter, k.Pause, k.Refresh, k.Delete, k.Settings, k.PinTab}, - {k.Log, k.OpenFile, k.ReportBug, k.Quit}, - } -} - -func (k InputKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Tab, k.Enter, k.Esc} -} - -func (k InputKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.Tab, k.Enter, k.Esc}} -} - -func (k FilePickerKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Back, k.Forward, k.UseDir, k.GotoHome, k.Open, k.Cancel} -} - -func (k FilePickerKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.Back, k.Forward, k.UseDir, k.GotoHome, k.Open, k.Cancel}} -} - -func (k DuplicateKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Continue, k.Focus, k.Cancel} -} - -func (k DuplicateKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.Continue, k.Focus, k.Cancel}} -} - -func (k ExtensionKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Browse, k.Prev, k.Next, k.Confirm, k.Cancel} -} - -func (k ExtensionKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.Browse, k.Prev, k.Next, k.Confirm, k.Cancel}} -} - -func (k SettingsKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.PrevTab, k.NextTab, k.Edit, k.Reset, k.Close} -} - -func (k SettingsKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.Tab1, k.Tab2, k.Tab3, k.Tab4, k.Tab5}, - {k.PrevTab, k.NextTab, k.Up, k.Down, k.Edit, k.Reset, k.Browse, k.Close}, - } -} - -func (k SettingsEditorKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Confirm, k.Cancel} -} - -func (k SettingsEditorKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.Confirm, k.Cancel}} -} - -func (k BatchConfirmKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Confirm, k.Cancel} -} - -func (k BatchConfirmKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.Confirm, k.Cancel}} -} - -func (k UpdateKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.OpenGitHub, k.IgnoreNow, k.NeverRemind} -} - -func (k UpdateKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.OpenGitHub, k.IgnoreNow, k.NeverRemind}} -} - -func (k BugReportKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Core, k.Extension, k.Cancel} -} - -func (k BugReportKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.Core, k.Extension, k.Cancel}} -} - -func (k CategoryManagerKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Up, k.Down, k.Edit, k.Add, k.Delete, k.Tab, k.Toggle, k.Close} -} - -func (k CategoryManagerKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.Up, k.Down, k.Edit, k.Add, k.Delete, k.Tab, k.Toggle, k.Close}} -} - -func (k QuitConfirmKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Select, k.Cancel} -} - -func (k QuitConfirmKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.Select, k.Cancel}} -} +// Keys is a global default keymap for tests and default state. +var Keys = config.DefaultKeyMap() diff --git a/internal/tui/keys_test.go b/internal/tui/keys_test.go index 55ccd371..ff602404 100644 --- a/internal/tui/keys_test.go +++ b/internal/tui/keys_test.go @@ -1,10 +1,14 @@ package tui import ( + "os" "reflect" + "runtime" "testing" + "time" "charm.land/bubbles/v2/key" + "github.com/SurgeDM/Surge/internal/config" ) type helperKeyMap interface { @@ -87,3 +91,100 @@ func TestCategoryManagerKeyMap_AllKeysInHelp(t *testing.T) { func TestQuitConfirmKeyMap_AllKeysInHelp(t *testing.T) { testKeyMapInHelp(t, "QuitConfirm", Keys.QuitConfirm, nil) } + +func TestDuplicateKeyMap_AllKeysInHelp(t *testing.T) { + testKeyMapInHelp(t, "Duplicate", Keys.Duplicate, nil) +} + +func TestExtensionKeyMap_AllKeysInHelp(t *testing.T) { + testKeyMapInHelp(t, "Extension", Keys.Extension, nil) +} + +func TestSettingsEditorKeyMap_AllKeysInHelp(t *testing.T) { + testKeyMapInHelp(t, "SettingsEditor", Keys.SettingsEditor, nil) +} + +func TestBatchConfirmKeyMap_AllKeysInHelp(t *testing.T) { + testKeyMapInHelp(t, "BatchConfirm", Keys.BatchConfirm, nil) +} + +func TestUpdateKeyMap_AllKeysInHelp(t *testing.T) { + testKeyMapInHelp(t, "Update", Keys.Update, nil) +} + +func TestBugReportKeyMap_AllKeysInHelp(t *testing.T) { + testKeyMapInHelp(t, "BugReport", Keys.BugReport, nil) +} + +func TestDynamicKeyMapReloading(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping on Windows: GetSurgeDir uses %APPDATA% and does not honor XDG_CONFIG_HOME") + } + + tmpDir, err := os.MkdirTemp("", "surge-tui-keymap-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + // Override configuration directory + oldXDG := os.Getenv("XDG_CONFIG_HOME") + defer func() { + _ = os.Setenv("XDG_CONFIG_HOME", oldXDG) + }() + _ = os.Setenv("XDG_CONFIG_HOME", tmpDir) + + err = config.EnsureDirs() + if err != nil { + t.Fatalf("Failed to ensure directories: %v", err) + } + + // 1. Initialize keymap and verify default state + km, err := config.LoadKeyMap() + if err != nil { + t.Fatalf("Failed to load keymap: %v", err) + } + + m := RootModel{ + keys: km, + lastKeyMapModTime: time.Now().Add(-10 * time.Second), // Ensure modTime is older + lastConfigCheckTime: time.Now().Add(-2 * time.Second), // Ensure check triggers + } + + if len(m.keys.Dashboard.ToggleHelp.Keys()) != 1 || m.keys.Dashboard.ToggleHelp.Keys()[0] != "/" { + t.Errorf("Expected default ToggleHelp key '/', got %v", m.keys.Dashboard.ToggleHelp.Keys()) + } + + // 2. Simulate user editing keymap.json on disk + customKeyMap := config.DefaultKeyMap() + customKeyMap.Dashboard.ToggleHelp = key.NewBinding( + key.WithKeys("ctrl+x"), + key.WithHelp("ctrl+x", "keybindings"), + ) + + // Save custom keymap to temp directory + err = config.SaveKeyMap(customKeyMap) + if err != nil { + t.Fatalf("Failed to save custom keymap: %v", err) + } + + // Update modTime on disk to simulate fresh write in the past + keymapPath := config.GetKeyMapConfigPath() + now := time.Now() + err = os.Chtimes(keymapPath, now, now) + if err != nil { + t.Fatalf("Failed to set file times: %v", err) + } + + // 3. Trigger TUI update loop and assert dynamic reload + res, _ := m.Update(struct{}{}) + updatedModel := res.(RootModel) + + // Ensure the new custom keybinding was hot-reloaded dynamically + toggleHelpKeys := updatedModel.keys.Dashboard.ToggleHelp.Keys() + if len(toggleHelpKeys) != 1 || toggleHelpKeys[0] != "ctrl+x" { + t.Errorf("Expected dynamic reload to update ToggleHelp key to 'ctrl+x', got %v", toggleHelpKeys) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 1c7d77ee..ccd4d237 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -156,6 +156,7 @@ type RootModel struct { StartupConfigWarnings []string // Config validation warnings to emit on first render SettingsActiveTab int // Active category tab (0-3) SettingsSelectedRow int // Selected setting within current tab + SettingsFocusedPane int // Focused settings pane (0 = Tabs, 1 = List) SettingsIsEditing bool // Whether currently editing a value SettingsInput textinput.Model // Input for editing string/int values settingsError string // Current validation error in settings @@ -193,7 +194,9 @@ type RootModel struct { bugReportIncludeLatestLog bool // Keybindings - keys KeyMap + keys *config.KeyMap + lastKeyMapModTime time.Time + lastConfigCheckTime time.Time // Server port for display ServerPort int @@ -292,11 +295,23 @@ func InitialRootModel(serverPort int, currentVersion string, service core.Downlo settings = config.DefaultSettings() } + keys, _ := config.LoadKeyMap() + if keys == nil { + keys = config.DefaultKeyMap() + } + var keyMapModTime time.Time + if info, err := os.Stat(config.GetKeyMapConfigPath()); err == nil { + keyMapModTime = info.ModTime() + } + // Capture any config warnings produced during load so Init() can surface // them in the activity log once the viewport is ready. var startupConfigWarnings []string if len(settings.StartupWarnings) > 0 { - startupConfigWarnings = append([]string(nil), settings.StartupWarnings...) + startupConfigWarnings = append(startupConfigWarnings, settings.StartupWarnings...) + } + if len(keys.StartupWarnings) > 0 { + startupConfigWarnings = append(startupConfigWarnings, keys.StartupWarnings...) } // Override AutoResume if CLI flag provided @@ -440,6 +455,9 @@ func InitialRootModel(serverPort int, currentVersion string, service core.Downlo PWD: pwd, Settings: settings, StartupConfigWarnings: startupConfigWarnings, + SettingsActiveTab: 0, + SettingsSelectedRow: 0, + SettingsFocusedPane: 1, SpeedHistory: make([]float64, GraphHistoryPoints), // 60 points of history (30s at 0.5s interval) logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)), // Default size, will be resized logEntries: make([]string, 0), @@ -447,7 +465,9 @@ func InitialRootModel(serverPort int, currentVersion string, service core.Downlo searchInput: searchInput, urlUpdateInput: urlUpdateInput, catMgrInputs: [4]textinput.Model{catNameInput, catDescInput, catPatternInput, catPathInput}, - keys: Keys, + keys: keys, + lastKeyMapModTime: keyMapModTime, + lastConfigCheckTime: time.Now(), ServerPort: serverPort, CurrentVersion: currentVersion, CurrentCommit: commitValue, diff --git a/internal/tui/settings_navigation_test.go b/internal/tui/settings_navigation_test.go new file mode 100644 index 00000000..33ebe43a --- /dev/null +++ b/internal/tui/settings_navigation_test.go @@ -0,0 +1,122 @@ +package tui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/SurgeDM/Surge/internal/config" +) + +func TestSettingsNavigation_VimStylePaneTransitions(t *testing.T) { + // 1. Initialize RootModel with default keymap and settings + keys := config.DefaultKeyMap() + settings := config.DefaultSettings() + + m := RootModel{ + state: SettingsState, + keys: keys, + Settings: settings, + SettingsActiveTab: 0, + SettingsSelectedRow: 0, + SettingsFocusedPane: 1, // Start with List focused + } + + // 2. Press "k" (Up) at row 0 -> should transition focus up to Tab bar + upMsg := tea.KeyPressMsg{Code: 'k', Text: "k"} + updated, _ := m.Update(upMsg) + m = updated.(RootModel) + + if m.SettingsFocusedPane != 0 { + t.Errorf("Expected focus to transition to Tab bar (0) when pressing Up on first row, got %d", m.SettingsFocusedPane) + } + + // 3. Press "l" (NextTab/right) while focused on Tab bar -> should shift active tab to 1 + rightMsg := tea.KeyPressMsg{Code: 'l', Text: "l"} + updated, _ = m.Update(rightMsg) + m = updated.(RootModel) + + if m.SettingsActiveTab != 1 { + t.Errorf("Expected active tab to shift to 1 when pressing NextTab on tab bar, got %d", m.SettingsActiveTab) + } + if m.SettingsFocusedPane != 0 { + t.Errorf("Expected focus to remain on Tab bar after shifting tab, got %d", m.SettingsFocusedPane) + } + + // 4. Press "j" (Down) while focused on Tab bar -> should transition focus down to Settings List (row 0) + downMsg := tea.KeyPressMsg{Code: 'j', Text: "j"} + updated, _ = m.Update(downMsg) + m = updated.(RootModel) + + if m.SettingsFocusedPane != 1 { + t.Errorf("Expected focus to transition to List (1) when pressing Down on tab bar, got %d", m.SettingsFocusedPane) + } + if m.SettingsSelectedRow != 0 { + t.Errorf("Expected selected row to be reset to 0 upon entering list, got %d", m.SettingsSelectedRow) + } + + // 5. Press "j" (Down) while focused on List -> should move selection to row 1 + updated, _ = m.Update(downMsg) + m = updated.(RootModel) + + if m.SettingsSelectedRow != 1 { + t.Errorf("Expected selected row to move to 1, got %d", m.SettingsSelectedRow) + } + if m.SettingsFocusedPane != 1 { + t.Errorf("Expected focus to remain on List (1), got %d", m.SettingsFocusedPane) + } + + // 6. Press "h" (PrevTab/left) while focused on List -> should change tab back to 0 + leftMsg := tea.KeyPressMsg{Code: 'h', Text: "h"} + updated, _ = m.Update(leftMsg) + m = updated.(RootModel) + + if m.SettingsActiveTab != 0 { + t.Errorf("Expected tab to switch to 0, got %d", m.SettingsActiveTab) + } + + // 7. Go up to tabs again + m.SettingsSelectedRow = 0 + updated, _ = m.Update(upMsg) + m = updated.(RootModel) + if m.SettingsFocusedPane != 0 { + t.Fatalf("Failed to refocus tab bar") + } + + // 8. Press "enter" (Edit/confirm) while on tabs -> should shift focus to list + enterMsg := tea.KeyPressMsg{Code: tea.KeyEnter} + updated, _ = m.Update(enterMsg) + m = updated.(RootModel) + if m.SettingsFocusedPane != 1 { + t.Errorf("Expected enter key on tabs to shift focus to settings list, got %d", m.SettingsFocusedPane) + } +} + +func TestSettingsNavigation_ResetAndBrowseGuards(t *testing.T) { + keys := config.DefaultKeyMap() + settings := config.DefaultSettings() + + m := RootModel{ + state: SettingsState, + keys: keys, + Settings: settings, + SettingsActiveTab: 0, + SettingsSelectedRow: 0, + SettingsFocusedPane: 0, // Tabs focused + } + + // Verify "r" (Reset) is ignored when tabs are focused + resetMsg := tea.KeyPressMsg{Code: 'r', Text: "r"} + updated, _ := m.Update(resetMsg) + m2 := updated.(RootModel) + if m2.SettingsFocusedPane != 0 { + t.Errorf("Expected Reset key to be ignored when tabs are focused") + } + + // Verify Tab (Browse) is ignored when tabs are focused + tabMsg := tea.KeyPressMsg{Code: tea.KeyTab} + updated, _ = m.Update(tabMsg) + m3 := updated.(RootModel) + if m3.SettingsFocusedPane != 0 { + t.Errorf("Expected Browse key to be ignored when tabs are focused") + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go index ad20865f..825e8624 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,6 +1,9 @@ package tui import ( + "os" + "time" + "github.com/SurgeDM/Surge/internal/config" "github.com/SurgeDM/Surge/internal/utils" @@ -49,11 +52,33 @@ func (m RootModel) updatePaste(msg tea.PasteMsg) (tea.Model, tea.Cmd) { // Update handles messages and updates the model func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Dynamically reload keymap.json on the fly if it is updated on disk + if time.Since(m.lastConfigCheckTime) > 1*time.Second { + m.lastConfigCheckTime = time.Now() + if info, err := os.Stat(config.GetKeyMapConfigPath()); err == nil { + if info.ModTime().After(m.lastKeyMapModTime) { + preLoadModTime := info.ModTime() + if newKeys, err := config.LoadKeyMap(); err == nil && newKeys != nil { + m.keys = newKeys + if postInfo, postErr := os.Stat(config.GetKeyMapConfigPath()); postErr == nil { + m.lastKeyMapModTime = postInfo.ModTime() + } else { + m.lastKeyMapModTime = preLoadModTime + } + utils.Debug("TUI: dynamically reloaded keymap.json from disk") + } + } + } + } if m.Settings == nil { m.Settings = config.DefaultSettings() } + if m.keys == nil { + m.keys = config.DefaultKeyMap() + } + if m.shuttingDown { switch msg := msg.(type) { case shutdownCompleteMsg: diff --git a/internal/tui/update_modals.go b/internal/tui/update_modals.go index 38c7f4d6..19afa65b 100644 --- a/internal/tui/update_modals.go +++ b/internal/tui/update_modals.go @@ -186,6 +186,26 @@ func (m RootModel) updateBugReportTarget(msg tea.KeyPressMsg) (tea.Model, tea.Cm return m, nil } + m, decision, handled := m.handleYesNoSelection(msg) + if handled { + switch decision { + case yesNoYes: + m.bugReportIncludeSystemInfo = true + m.bugReportIncludeLatestLog = true + m.quitConfirmFocused = 0 + m.state = BugReportSystemDetailsState + return m, nil + case yesNoNo: + reportURL := bugreport.ExtensionBugReportURL() + m = m.tryOpenBugReportURL(reportURL) + m = m.resetBugReportFlow() + return m, nil + case yesNoCancel: + m = m.resetBugReportFlow() + return m, nil + } + } + if key.Matches(msg, m.keys.BugReport.Core) { m.bugReportIncludeSystemInfo = true m.bugReportIncludeLatestLog = true diff --git a/internal/tui/update_settings.go b/internal/tui/update_settings.go index a7b33b8c..64fc9a6b 100644 --- a/internal/tui/update_settings.go +++ b/internal/tui/update_settings.go @@ -80,6 +80,17 @@ func (m RootModel) updateSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.SettingsBaseline = nil return m, nil } + if key.Matches(msg, m.keys.Settings.ReportBug) { + // Save settings and exit before going to bug report + _ = m.persistSettings() + m.SettingsBaseline = nil + + m.quitConfirmFocused = 0 + m.bugReportIncludeSystemInfo = true + m.bugReportIncludeLatestLog = true + m.state = BugReportTargetState + return m, nil + } tabBindings := []key.Binding{ m.keys.Settings.Tab1, m.keys.Settings.Tab2, @@ -98,7 +109,7 @@ func (m RootModel) updateSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } } - // Tab Navigation + // Tab Navigation (NextTab/PrevTab) - always switches active tab if key.Matches(msg, m.keys.Settings.NextTab) { m.SettingsActiveTab = (m.SettingsActiveTab + 1) % categoryCount m.SettingsSelectedRow = 0 @@ -112,8 +123,41 @@ func (m RootModel) updateSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } + // Up/Down navigation & pane switching between tabs and lists + if m.SettingsFocusedPane == 0 { // Tabs focused + if key.Matches(msg, m.keys.Settings.Down) { + m.SettingsFocusedPane = 1 // Focus settings list + m.SettingsSelectedRow = 0 + m.settingsError = "" + return m, nil + } + } else { // List focused + if key.Matches(msg, m.keys.Settings.Up) { + if m.SettingsSelectedRow > 0 { + m.SettingsSelectedRow-- + m.settingsError = "" + } else { + // At the very top row, go up to focus the Tabs! + m.SettingsFocusedPane = 0 + m.settingsError = "" + } + return m, nil + } + if key.Matches(msg, m.keys.Settings.Down) { + maxRow := m.getSettingsCount() - 1 + if m.SettingsSelectedRow < maxRow { + m.SettingsSelectedRow++ + m.settingsError = "" + } + return m, nil + } + } + // Open file browser for default_download_dir or theme_path if key.Matches(msg, m.keys.Settings.Browse) { + if m.SettingsFocusedPane == 0 { + return m, nil // Can't browse when tabs are focused + } settingKey := m.getCurrentSettingKey() switch settingKey { case "default_download_dir": @@ -144,26 +188,6 @@ func (m RootModel) updateSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } - // Back tab - not currently bound, ignoring or could use Shift+Tab manual check if really needed - // For now, we rely on Tab (Browse) to cycle. - - // Up/Down navigation - if key.Matches(msg, m.keys.Settings.Up) { - if m.SettingsSelectedRow > 0 { - m.SettingsSelectedRow-- - m.settingsError = "" - } - return m, nil - } - if key.Matches(msg, m.keys.Settings.Down) { - maxRow := m.getSettingsCount() - 1 - if m.SettingsSelectedRow < maxRow { - m.SettingsSelectedRow++ - m.settingsError = "" - } - return m, nil - } - // Edit / Toggle if key.Matches(msg, m.keys.Settings.Edit) { // Categories tab → open Category Manager @@ -173,6 +197,13 @@ func (m RootModel) updateSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } + if m.SettingsFocusedPane == 0 { + m.SettingsFocusedPane = 1 + m.SettingsSelectedRow = 0 + m.settingsError = "" + return m, nil + } + settingKey := m.getCurrentSettingKey() // Prevent editing ignored settings if settingKey == "max_global_connections" { @@ -231,6 +262,9 @@ func (m RootModel) updateSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // Reset if key.Matches(msg, m.keys.Settings.Reset) { + if m.SettingsFocusedPane == 0 { + return m, nil // Can't reset when tabs are focused + } settingKey := m.getCurrentSettingKey() if settingKey == "max_global_connections" { return m, nil diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index a9ab4bd1..76e7ac21 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -282,8 +282,9 @@ func TestUpdate_SettingsIgnoresMissingFourthTab(t *testing.T) { func TestUpdate_DashboardWithNilSettingsDoesNotPanic(t *testing.T) { m := RootModel{ - state: DashboardState, - list: NewDownloadList(80, 20), + state: DashboardState, + list: NewDownloadList(80, 20), + inputs: newInputModels(), } updated, _ := m.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) diff --git a/internal/tui/view.go b/internal/tui/view.go index 55c4c0ac..f1f20c86 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -235,14 +235,18 @@ func (m RootModel) View() tea.View { if m.state == BugReportTargetState { w, h := GetDynamicModalDimensions(m.width, m.height, 40, 8, 64, 12) modal := components.ConfirmationModal{ - Title: "Bug Report", - Message: "What would you like to report?", - Detail: "1) Surge Core (CLI/TUI/server)\n2) Browser Extension", - Keys: m.keys.BugReport, - Help: m.help, - BorderColor: colors.Cyan(), - Width: w, - Height: h, + Title: "Bug Report", + Message: "What would you like to report?", + Detail: "Surge Core includes CLI/TUI/server components.", + Keys: m.keys.BugReport, + Help: m.help, + BorderColor: colors.Cyan(), + Width: w, + Height: h, + ShowYesNoButtons: true, + YesNoFocused: m.quitConfirmFocused, + YesLabel: "Surge Core", + NoLabel: "Extension", } box := modal.RenderWithBtopBox(renderBtopBox, PaneTitleStyle) return m.wrapView(m.renderModalWithOverlay(box)) diff --git a/internal/tui/view_settings.go b/internal/tui/view_settings.go index 96dd90d0..b547389b 100644 --- a/internal/tui/view_settings.go +++ b/internal/tui/view_settings.go @@ -177,7 +177,16 @@ func (m RootModel) renderSettingsTabBar(categories []string, activeTab int, maxW return tabs } - settingsActiveTab := lipgloss.NewStyle().Foreground(colors.Magenta()) + var settingsActiveTab lipgloss.Style + if m.SettingsFocusedPane == 0 { + settingsActiveTab = lipgloss.NewStyle(). + Foreground(lipgloss.Color("0")). + Background(colors.Magenta()). + Bold(true) + } else { + settingsActiveTab = lipgloss.NewStyle().Foreground(colors.Magenta()) + } + tryBars := []string{ components.RenderNumberedTabBar(makeTabs(false), activeTab, settingsActiveTab, TabStyle), components.RenderTabBar(makeTabs(false), activeTab, settingsActiveTab, TabStyle), @@ -191,8 +200,14 @@ func (m RootModel) renderSettingsTabBar(categories []string, activeTab int, maxW } fallback := fmt.Sprintf("[%d/%d] %s", activeTab+1, len(categories), categories[activeTab]) - return lipgloss.NewStyle(). - Foreground(colors.Gray()). + fallbackStyle := lipgloss.NewStyle().Foreground(colors.Gray()) + if m.SettingsFocusedPane == 0 { + fallbackStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("0")). + Background(colors.Magenta()). + Bold(true) + } + return fallbackStyle. Width(maxWidth). Align(lipgloss.Center). Render(fallback) @@ -241,7 +256,7 @@ func formatSettingsBlock(content string, width, rows int) string { return strings.Join(lines, "\n") } -func renderSettingsListViewport(settingsMeta []config.SettingMeta, selectedRow, rows, innerWidth int) string { +func (m RootModel) renderSettingsListViewport(settingsMeta []config.SettingMeta, selectedRow, rows, innerWidth int) string { if rows < 1 { rows = 1 } @@ -284,15 +299,25 @@ func renderSettingsListViewport(settingsMeta []config.SettingMeta, selectedRow, prefix := " " style := lipgloss.NewStyle().Foreground(colors.LightGray()) if idx == selectedRow { - prefix = "\u25b8 " - style = lipgloss.NewStyle().Foreground(colors.Magenta()).Bold(true) + if m.SettingsFocusedPane == 1 { + prefix = "\u25b8 " + style = lipgloss.NewStyle().Foreground(colors.Magenta()).Bold(true) + } else { + prefix = " " + style = lipgloss.NewStyle().Foreground(colors.Gray()).Bold(true) + } } if meta.Key == "max_global_connections" { style = lipgloss.NewStyle().Foreground(colors.ThemeColor("#aaaaaa", "238")) if idx == selectedRow { - prefix = "# " - style = lipgloss.NewStyle().Foreground(colors.Gray()) + if m.SettingsFocusedPane == 1 { + prefix = "# " + style = lipgloss.NewStyle().Foreground(colors.Gray()) + } else { + prefix = " " + style = lipgloss.NewStyle().Foreground(colors.ThemeColor("#777777", "236")) + } } } @@ -421,7 +446,7 @@ func (m RootModel) renderSettingsTwoColumn(settingsMeta []config.SettingMeta, se if listRows < 1 { listRows = 1 } - listContent := renderSettingsListViewport(settingsMeta, selectedRow, listRows, leftWidth-(BoxStyle.GetHorizontalFrameSize()*2)) + listContent := m.renderSettingsListViewport(settingsMeta, selectedRow, listRows, leftWidth-(BoxStyle.GetHorizontalFrameSize()*2)) listBox := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(colors.Gray()). @@ -475,7 +500,7 @@ func (m RootModel) renderSettingsCompact(settingsMeta []config.SettingMeta, sele } } - list := renderSettingsListViewport(settingsMeta, selectedRow, listRows, innerWidth) + list := m.renderSettingsListViewport(settingsMeta, selectedRow, listRows, innerWidth) detail := m.renderSettingsDetailBlock(settingsMeta, selectedRow, settingsValues, innerWidth, detailRows) divider := lipgloss.NewStyle().Foreground(colors.Gray()).Render(strings.Repeat("\u2500", innerWidth))