From e400f48693c375658b3910188c1b7c91a081e724 Mon Sep 17 00:00:00 2001 From: "E.Babiker" Date: Tue, 12 May 2026 23:10:37 +0300 Subject: [PATCH 01/20] add vim navigation keys to the settings interface --- internal/tui/keys.go | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index c83268ec..0a84b5d5 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -296,8 +296,8 @@ var Keys = KeyMap{ key.WithHelp("enter", "use current"), ), GotoHome: key.NewBinding( - key.WithKeys("h", "H"), - key.WithHelp("h", "home"), + key.WithKeys("H"), + key.WithHelp("H", "home"), ), Back: key.NewBinding( key.WithKeys("left"), @@ -312,8 +312,8 @@ var Keys = KeyMap{ key.WithHelp(".", "select highlighted"), ), Cancel: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), + key.WithKeys("esc", "q"), + key.WithHelp("esc/q", "cancel"), ), }, Duplicate: DuplicateKeyMap{ @@ -374,12 +374,12 @@ var Keys = KeyMap{ key.WithHelp("5", "extension"), ), NextTab: key.NewBinding( - key.WithKeys("right"), - key.WithHelp("\u2192", "next tab"), + key.WithKeys("right", "l"), + key.WithHelp("\u2192/l", "next tab"), ), PrevTab: key.NewBinding( - key.WithKeys("left"), - key.WithHelp("\u2190", "prev tab"), + key.WithKeys("left", "h"), + key.WithHelp("\u2190/h", "prev tab"), ), Browse: key.NewBinding( key.WithKeys("tab"), @@ -390,20 +390,20 @@ var Keys = KeyMap{ key.WithHelp("enter", "edit"), ), Up: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("\u2191", "up"), + key.WithKeys("up", "k"), + key.WithHelp("\u2191/k", "up"), ), Down: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("\u2193", "down"), + 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"), - key.WithHelp("esc", "save & close"), + key.WithKeys("esc", "q"), + key.WithHelp("esc/q", "save & close"), ), }, SettingsEditor: SettingsEditorKeyMap{ @@ -450,19 +450,19 @@ var Keys = KeyMap{ key.WithHelp("2", "extension report"), ), Cancel: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), + key.WithKeys("esc", "q"), + key.WithHelp("esc/q", "cancel"), ), }, CategoryMgr: CategoryManagerKeyMap{ - Up: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")), - Down: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")), + 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"), key.WithHelp("esc", "save & close")), + Close: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc/q", "save & close")), }, QuitConfirm: QuitConfirmKeyMap{ Left: key.NewBinding( From f286772c64ab539812261bce47a235ba230da2cb Mon Sep 17 00:00:00 2001 From: "E.Babiker" Date: Tue, 12 May 2026 23:49:38 +0300 Subject: [PATCH 02/20] Fix reviw comments --- internal/tui/keys.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 0a84b5d5..9d0cb73d 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -296,8 +296,8 @@ var Keys = KeyMap{ key.WithHelp("enter", "use current"), ), GotoHome: key.NewBinding( - key.WithKeys("H"), - key.WithHelp("H", "home"), + key.WithKeys("h", "H"), + key.WithHelp("h", "home"), ), Back: key.NewBinding( key.WithKeys("left"), @@ -312,8 +312,8 @@ var Keys = KeyMap{ key.WithHelp(".", "select highlighted"), ), Cancel: key.NewBinding( - key.WithKeys("esc", "q"), - key.WithHelp("esc/q", "cancel"), + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), ), }, Duplicate: DuplicateKeyMap{ From d9413e8cfd4c9c27d012a35ffa941467fb197382 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Wed, 13 May 2026 21:13:16 +0530 Subject: [PATCH 03/20] implement discussed keybinds --- internal/tui/keys.go | 41 ++++++++++++++++++--------------- internal/tui/keys_test.go | 24 +++++++++++++++++++ internal/tui/update_settings.go | 7 ++++++ 3 files changed, 54 insertions(+), 18 deletions(-) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 9d0cb73d..2770695a 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -89,19 +89,20 @@ type ExtensionKeyMap struct { // 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 + 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 @@ -176,8 +177,8 @@ var Keys = KeyMap{ key.WithHelp("←", "prev tab"), ), Add: key.NewBinding( - key.WithKeys("a"), - key.WithHelp("a", "add download"), + key.WithKeys("a", "n"), + key.WithHelp("a/n", "add download"), ), BatchImport: key.NewBinding( key.WithKeys("b", "B"), @@ -213,7 +214,7 @@ var Keys = KeyMap{ ), ReportBug: key.NewBinding( key.WithKeys("?"), - key.WithHelp("?", "report bug"), + key.WithHelp("shift+?", "report bug"), ), OpenFile: key.NewBinding( key.WithKeys("o"), @@ -297,7 +298,7 @@ var Keys = KeyMap{ ), GotoHome: key.NewBinding( key.WithKeys("h", "H"), - key.WithHelp("h", "home"), + key.WithHelp("h/H", "home"), ), Back: key.NewBinding( key.WithKeys("left"), @@ -405,6 +406,10 @@ var Keys = KeyMap{ key.WithKeys("esc", "q"), key.WithHelp("esc/q", "save & close"), ), + ReportBug: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("shift+?", "report bug"), + ), }, SettingsEditor: SettingsEditorKeyMap{ Confirm: key.NewBinding( @@ -541,7 +546,7 @@ func (k SettingsKeyMap) ShortHelp() []key.Binding { 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}, + {k.PrevTab, k.NextTab, k.Up, k.Down, k.Edit, k.Reset, k.Browse, k.ReportBug, k.Close}, } } diff --git a/internal/tui/keys_test.go b/internal/tui/keys_test.go index 55ccd371..03e0df77 100644 --- a/internal/tui/keys_test.go +++ b/internal/tui/keys_test.go @@ -87,3 +87,27 @@ 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) +} diff --git a/internal/tui/update_settings.go b/internal/tui/update_settings.go index a7b33b8c..ddfb7fe6 100644 --- a/internal/tui/update_settings.go +++ b/internal/tui/update_settings.go @@ -80,6 +80,13 @@ 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) { + 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, From cf26b6991b7612124a7136bd79002a063d2e7d50 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Wed, 13 May 2026 21:39:01 +0530 Subject: [PATCH 04/20] feat: implement interactive bug report modal and add Vim-style navigation keybindings --- internal/tui/keys.go | 36 ++++++++++++++++----------------- internal/tui/update_modals.go | 20 ++++++++++++++++++ internal/tui/update_settings.go | 4 ++++ internal/tui/view.go | 20 ++++++++++-------- 4 files changed, 54 insertions(+), 26 deletions(-) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 2770695a..247044f8 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -169,12 +169,12 @@ var Keys = KeyMap{ key.WithHelp("e", "done tab"), ), NextTab: key.NewBinding( - key.WithKeys("tab", "right"), - key.WithHelp("tab/→", "next tab"), + key.WithKeys("tab", "right", "l"), + key.WithHelp("tab/→/l", "next tab"), ), PrevTab: key.NewBinding( - key.WithKeys("left"), - key.WithHelp("←", "prev tab"), + key.WithKeys("shift+tab", "left", "h"), + key.WithHelp("←/h", "prev tab"), ), Add: key.NewBinding( key.WithKeys("a", "n"), @@ -237,12 +237,12 @@ var Keys = KeyMap{ key.WithHelp("t", "pin tab"), ), Up: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("\u2191", "up"), + key.WithKeys("up", "k"), + key.WithHelp("\u2191/k", "up"), ), Down: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("\u2193", "down"), + key.WithKeys("down", "j"), + key.WithHelp("\u2193/j", "down"), ), LogUp: key.NewBinding( key.WithKeys("up"), @@ -319,16 +319,16 @@ var Keys = KeyMap{ }, Duplicate: DuplicateKeyMap{ Continue: key.NewBinding( - key.WithKeys("c", "C"), - key.WithHelp("c", "continue"), + key.WithKeys("c", "C", "enter"), + key.WithHelp("c/enter", "continue"), ), Focus: key.NewBinding( - key.WithKeys("f", "F"), - key.WithHelp("f", "focus existing"), + key.WithKeys("f", "F", "down", "j"), + key.WithHelp("f/j", "focus existing"), ), Cancel: key.NewBinding( - key.WithKeys("x", "X", "esc"), - key.WithHelp("x", "cancel"), + key.WithKeys("x", "X", "esc", "q"), + key.WithHelp("x/q", "cancel"), ), }, Extension: ExtensionKeyMap{ @@ -341,12 +341,12 @@ var Keys = KeyMap{ key.WithHelp("tab", "browse path"), ), Next: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("\u2193", "next field"), + key.WithKeys("down", "j", "tab"), + key.WithHelp("\u2193/j", "next field"), ), Prev: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("\u2191", "prev field"), + key.WithKeys("up", "k", "shift+tab"), + key.WithHelp("\u2191/k", "prev field"), ), Cancel: key.NewBinding( key.WithKeys("esc"), 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 ddfb7fe6..0b21f349 100644 --- a/internal/tui/update_settings.go +++ b/internal/tui/update_settings.go @@ -81,6 +81,10 @@ func (m RootModel) updateSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { 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 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)) From 002d97b35384c4f516a676cf1417caa29e50f02b Mon Sep 17 00:00:00 2001 From: "E.Babiker" Date: Thu, 14 May 2026 19:36:40 +0300 Subject: [PATCH 05/20] foundation for loading the keymaps --- internal/config/keymaps.go | 735 +++++++++++++++++++++++++++++++++++++ 1 file changed, 735 insertions(+) create mode 100644 internal/config/keymaps.go diff --git a/internal/config/keymaps.go b/internal/config/keymaps.go new file mode 100644 index 00000000..ef03f946 --- /dev/null +++ b/internal/config/keymaps.go @@ -0,0 +1,735 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + + "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 +} + +// 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 tui.KeyMap for configuration. +type KeyMapConfig struct { + Dashboard map[string]KeyBindingConfig `json:"dashboard"` + Input map[string]KeyBindingConfig `json:"input"` + FilePicker map[string]KeyBindingConfig `json:"filePicker"` + Duplicate map[string]KeyBindingConfig `json:"duplicate"` + Extension map[string]KeyBindingConfig `json:"extension"` + Settings map[string]KeyBindingConfig `json:"settings"` + SettingsEditor map[string]KeyBindingConfig `json:"settingsEditor"` + BatchConfirm map[string]KeyBindingConfig `json:"batchConfirm"` + Update map[string]KeyBindingConfig `json:"update"` + BugReport map[string]KeyBindingConfig `json:"bugReport"` + CategoryMgr map[string]KeyBindingConfig `json:"categoryMgr"` + QuitConfirm map[string]KeyBindingConfig `json:"quitConfirm"` +} + +// GetKeyMapConfigPath returns the path to the Keymaps JSON file. +func GetKeyMapConfigPath() string { + return filepath.Join(GetSurgeDir(), "keymap.json") +} + +// LoadKeyMapConfig loads the keymap configuration from file. +func LoadKeyMapConfig() (*KeyMapConfig, error) { + path := GetKeyMapConfigPath() + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil, nil // No config file yet + } + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg KeyMapConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +// SaveKeyMapConfig saves the keymap configuration to file. +func SaveKeyMapConfig(cfg *KeyMapConfig) error { + path := GetKeyMapConfigPath() + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// Validate checks keymap for missing or invalid bindings and fills with defaults. +func (k *KeyMap) Validate() { + defaults := GetDefaultKeyMap() + if k == nil { + return + } + // Add per-section validation as needed, e.g.: + if reflect.ValueOf(k.Dashboard) == reflect.Zero(reflect.TypeFor[DashboardKeyMap]()) { + k.Dashboard = defaults.Dashboard + } + if reflect.ValueOf(k.Input) == reflect.Zero(reflect.TypeFor[InputKeyMap]()) { + k.Input = defaults.Input + } + if reflect.ValueOf(k.FilePicker) == reflect.Zero(reflect.TypeFor[FilePickerKeyMap]()) { + k.FilePicker = defaults.FilePicker + } + if reflect.ValueOf(k.Duplicate) == reflect.Zero(reflect.TypeFor[DuplicateKeyMap]()) { + k.Duplicate = defaults.Duplicate + } + if reflect.ValueOf(k.Extension) == reflect.Zero(reflect.TypeFor[ExtensionKeyMap]()) { + k.Extension = defaults.Extension + } + if reflect.ValueOf(k.Settings) == reflect.Zero(reflect.TypeFor[SettingsKeyMap]()) { + k.Settings = defaults.Settings + } + if reflect.ValueOf(k.SettingsEditor) == reflect.Zero(reflect.TypeFor[SettingsEditorKeyMap]()) { + k.SettingsEditor = defaults.SettingsEditor + } + if reflect.ValueOf(k.BatchConfirm) == reflect.Zero(reflect.TypeFor[BatchConfirmKeyMap]()) { + k.BatchConfirm = defaults.BatchConfirm + } + if reflect.ValueOf(k.Update) == reflect.Zero(reflect.TypeFor[UpdateKeyMap]()) { + k.Update = defaults.Update + } + if reflect.ValueOf(k.BugReport) == reflect.Zero(reflect.TypeFor[BugReportKeyMap]()) { + k.BugReport = defaults.BugReport + } + if reflect.ValueOf(k.CategoryMgr) == reflect.Zero(reflect.TypeFor[CategoryManagerKeyMap]()) { + k.CategoryMgr = defaults.CategoryMgr + } + if reflect.ValueOf(k.QuitConfirm) == reflect.Zero(reflect.TypeFor[QuitConfirmKeyMap]()) { + k.QuitConfirm = defaults.QuitConfirm + } +} + +func GetDefaultKeyMap() *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"), + 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"), + ), + }, + } +} + +// LoadKeyMap loads settings from disk. Returns defaults if file doesn't exist +// or if the JSON is corrupt, so the application can always start. +func LoadKeyMap() (*KeyMap, error) { + path := GetKeyMapConfigPath() + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return GetDefaultKeyMap(), nil + } + return nil, err + } + + keymap := GetDefaultKeyMap() // Start with defaults to fill any missing fields + if err := json.Unmarshal(data, keymap); err != nil { + utils.Debug("Warning: corrupt keymap file %s: %v \u2014 using defaults", path, err) + defaults := GetDefaultKeyMap() + defaults.StartupWarnings = append(defaults.StartupWarnings, + fmt.Sprintf("Config: settings file is corrupt (%v) — all settings reset to defaults", err)) + return defaults, nil + } + + // Validate settings and roll back individual invalid fields to defaults + keymap.Validate() + + return keymap, 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.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}} +} From 46687151806c62b6d45c518c27068c8cb97a12b3 Mon Sep 17 00:00:00 2001 From: "E.Babiker" Date: Thu, 14 May 2026 19:57:38 +0300 Subject: [PATCH 06/20] feat: keymaps JSON config in SurgeDir for Customizing the keys functionality functioning key config file fix: Dashboard view remove old key --- internal/config/keymaps.go | 220 +++++++----- internal/config/keymaps_test.go | 106 ++++++ internal/tui/keys.go | 594 +------------------------------- internal/tui/model.go | 14 +- internal/tui/update.go | 4 + internal/tui/update_test.go | 5 +- log | 2 + 7 files changed, 267 insertions(+), 678 deletions(-) create mode 100644 internal/config/keymaps_test.go create mode 100644 log diff --git a/internal/config/keymaps.go b/internal/config/keymaps.go index ef03f946..0080924b 100644 --- a/internal/config/keymaps.go +++ b/internal/config/keymaps.go @@ -171,20 +171,20 @@ type KeyBindingConfig struct { Help string `json:"help"` } -// KeyMapConfig mirrors the structure of tui.KeyMap for configuration. +// 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:"filePicker"` + 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:"settingsEditor"` - BatchConfirm map[string]KeyBindingConfig `json:"batchConfirm"` + 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:"bugReport"` - CategoryMgr map[string]KeyBindingConfig `json:"categoryMgr"` - QuitConfirm map[string]KeyBindingConfig `json:"quitConfirm"` + 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. @@ -192,79 +192,162 @@ func GetKeyMapConfigPath() string { return filepath.Join(GetSurgeDir(), "keymap.json") } -// LoadKeyMapConfig loads the keymap configuration from file. -func LoadKeyMapConfig() (*KeyMapConfig, error) { +// LoadKeyMap loads the keymap configuration from file. +func LoadKeyMap() (*KeyMap, error) { path := GetKeyMapConfigPath() - if _, err := os.Stat(path); os.IsNotExist(err) { - return nil, nil // No config file yet - } data, err := os.ReadFile(path) if err != nil { + if os.IsNotExist(err) { + defaults := DefaultKeyMap() + 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 { - return nil, err + utils.Debug("Warning: corrupt keymap file %s: %v \u2014 using defaults", path, err) + defaults := DefaultKeyMap() + defaults.StartupWarnings = append(defaults.StartupWarnings, + fmt.Sprintf("Config: keymap file is corrupt (%v) — all keybindings reset to defaults", err)) + return defaults, nil } - return &cfg, nil + + keymap := DefaultKeyMap() + keymap.ApplyConfig(&cfg) + SaveKeyMap(keymap) + return keymap, nil } -// SaveKeyMapConfig saves the keymap configuration to file. -func SaveKeyMapConfig(cfg *KeyMapConfig) error { +// 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 } - return os.WriteFile(path, data, 0644) + + // 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) } -// Validate checks keymap for missing or invalid bindings and fills with defaults. -func (k *KeyMap) Validate() { - defaults := GetDefaultKeyMap() - if k == nil { +// ApplyConfig applies configuration from KeyMapConfig to KeyMap. +func (k *KeyMap) ApplyConfig(cfg *KeyMapConfig) { + if cfg == nil { return } - // Add per-section validation as needed, e.g.: - if reflect.ValueOf(k.Dashboard) == reflect.Zero(reflect.TypeFor[DashboardKeyMap]()) { - k.Dashboard = defaults.Dashboard - } - if reflect.ValueOf(k.Input) == reflect.Zero(reflect.TypeFor[InputKeyMap]()) { - k.Input = defaults.Input - } - if reflect.ValueOf(k.FilePicker) == reflect.Zero(reflect.TypeFor[FilePickerKeyMap]()) { - k.FilePicker = defaults.FilePicker - } - if reflect.ValueOf(k.Duplicate) == reflect.Zero(reflect.TypeFor[DuplicateKeyMap]()) { - k.Duplicate = defaults.Duplicate - } - if reflect.ValueOf(k.Extension) == reflect.Zero(reflect.TypeFor[ExtensionKeyMap]()) { - k.Extension = defaults.Extension - } - if reflect.ValueOf(k.Settings) == reflect.Zero(reflect.TypeFor[SettingsKeyMap]()) { - k.Settings = defaults.Settings - } - if reflect.ValueOf(k.SettingsEditor) == reflect.Zero(reflect.TypeFor[SettingsEditorKeyMap]()) { - k.SettingsEditor = defaults.SettingsEditor - } - if reflect.ValueOf(k.BatchConfirm) == reflect.Zero(reflect.TypeFor[BatchConfirmKeyMap]()) { - k.BatchConfirm = defaults.BatchConfirm + + 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 := bCfg.Keys[0] // this should be `helpKey := strings.Join(bCfg.Keys, "/")` but now it will break the help view of the Dashboard + newBinding := key.NewBinding( + key.WithKeys(bCfg.Keys...), + key.WithHelp(helpKey, helpDesc), + ) + field.Set(reflect.ValueOf(newBinding)) + } + } + } + } } - if reflect.ValueOf(k.Update) == reflect.Zero(reflect.TypeFor[UpdateKeyMap]()) { - k.Update = defaults.Update + + 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 } - if reflect.ValueOf(k.BugReport) == reflect.Zero(reflect.TypeFor[BugReportKeyMap]()) { - k.BugReport = defaults.BugReport + + 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), } - if reflect.ValueOf(k.CategoryMgr) == reflect.Zero(reflect.TypeFor[CategoryManagerKeyMap]()) { - k.CategoryMgr = defaults.CategoryMgr +} + +// Validate checks keymap for missing or invalid bindings and fills with defaults. +func (k *KeyMap) Validate() { + defaults := DefaultKeyMap() + if k == nil { + return } - if reflect.ValueOf(k.QuitConfirm) == reflect.Zero(reflect.TypeFor[QuitConfirmKeyMap]()) { - k.QuitConfirm = defaults.QuitConfirm + + v := reflect.ValueOf(k).Elem() + dV := reflect.ValueOf(defaults).Elem() + t := v.Type() + + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + fieldType := t.Field(i).Type + if fieldType.Kind() == reflect.Struct && fieldType != reflect.TypeFor[[]string]() { + if reflect.DeepEqual(field.Interface(), reflect.Zero(fieldType).Interface()) { + field.Set(dV.Field(i)) + } + } } } -func GetDefaultKeyMap() *KeyMap { +func DefaultKeyMap() *KeyMap { return &KeyMap{ Dashboard: DashboardKeyMap{ TabQueued: key.NewBinding( @@ -598,37 +681,10 @@ func GetDefaultKeyMap() *KeyMap { key.WithHelp("n/esc", "cancel"), ), }, + StartupWarnings: nil, } } -// LoadKeyMap loads settings from disk. Returns defaults if file doesn't exist -// or if the JSON is corrupt, so the application can always start. -func LoadKeyMap() (*KeyMap, error) { - path := GetKeyMapConfigPath() - - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return GetDefaultKeyMap(), nil - } - return nil, err - } - - keymap := GetDefaultKeyMap() // Start with defaults to fill any missing fields - if err := json.Unmarshal(data, keymap); err != nil { - utils.Debug("Warning: corrupt keymap file %s: %v \u2014 using defaults", path, err) - defaults := GetDefaultKeyMap() - defaults.StartupWarnings = append(defaults.StartupWarnings, - fmt.Sprintf("Config: settings file is corrupt (%v) — all settings reset to defaults", err)) - return defaults, nil - } - - // Validate settings and roll back individual invalid fields to defaults - keymap.Validate() - - return keymap, nil -} - // ShortHelp returns keybindings to show in the mini help view func (k DashboardKeyMap) ShortHelp() []key.Binding { return []key.Binding{k.ToggleHelp, k.ReportBug} diff --git a/internal/config/keymaps_test.go b/internal/config/keymaps_test.go new file mode 100644 index 00000000..279150a8 --- /dev/null +++ b/internal/config/keymaps_test.go @@ -0,0 +1,106 @@ +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 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") + } +} 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/model.go b/internal/tui/model.go index 1c7d77ee..a546c262 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -193,7 +193,7 @@ type RootModel struct { bugReportIncludeLatestLog bool // Keybindings - keys KeyMap + keys *config.KeyMap // Server port for display ServerPort int @@ -292,11 +292,19 @@ func InitialRootModel(serverPort int, currentVersion string, service core.Downlo settings = config.DefaultSettings() } + keys, _ := config.LoadKeyMap() + if keys == nil { + keys = config.DefaultKeyMap() + } + // 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 @@ -447,7 +455,7 @@ 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, ServerPort: serverPort, CurrentVersion: currentVersion, CurrentCommit: commitValue, diff --git a/internal/tui/update.go b/internal/tui/update.go index ad20865f..ee71f232 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -54,6 +54,10 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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_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/log b/log new file mode 100644 index 00000000..5f0c2e7b --- /dev/null +++ b/log @@ -0,0 +1,2 @@ +swrn : + \ No newline at end of file From f4c38d3a2254704478c52046d74b004421d8121a Mon Sep 17 00:00:00 2001 From: "E.Babiker" Date: Thu, 14 May 2026 22:33:06 +0300 Subject: [PATCH 07/20] resolve: remove the log file and the unnecessary saving for the file. --- internal/config/keymaps.go | 1 - log | 2 -- 2 files changed, 3 deletions(-) delete mode 100644 log diff --git a/internal/config/keymaps.go b/internal/config/keymaps.go index 0080924b..bf0bcd8f 100644 --- a/internal/config/keymaps.go +++ b/internal/config/keymaps.go @@ -217,7 +217,6 @@ func LoadKeyMap() (*KeyMap, error) { keymap := DefaultKeyMap() keymap.ApplyConfig(&cfg) - SaveKeyMap(keymap) return keymap, nil } diff --git a/log b/log deleted file mode 100644 index 5f0c2e7b..00000000 --- a/log +++ /dev/null @@ -1,2 +0,0 @@ -swrn : - \ No newline at end of file From d7337e41393dd52cd37cc0e1a8005fa92d238f8d Mon Sep 17 00:00:00 2001 From: "E.Babiker" Date: Fri, 15 May 2026 01:01:58 +0300 Subject: [PATCH 08/20] resolve: add todo to display all the keymaps in the help --- internal/config/keymaps.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/keymaps.go b/internal/config/keymaps.go index bf0bcd8f..0c6a1181 100644 --- a/internal/config/keymaps.go +++ b/internal/config/keymaps.go @@ -263,7 +263,7 @@ func (k *KeyMap) ApplyConfig(cfg *KeyMapConfig) { if helpDesc == "" { helpDesc = field.Interface().(key.Binding).Help().Desc } - helpKey := bCfg.Keys[0] // this should be `helpKey := strings.Join(bCfg.Keys, "/")` but now it will break the help view of the Dashboard + helpKey := bCfg.Keys[0] // TODO: `helpKey := strings.Join(bCfg.Keys, "/")` but now it will break the help view of the Dashboard newBinding := key.NewBinding( key.WithKeys(bCfg.Keys...), key.WithHelp(helpKey, helpDesc), From e71bc60ca06adbec3740f693e85071c3da8e7f10 Mon Sep 17 00:00:00 2001 From: "E.Babiker" Date: Fri, 15 May 2026 01:41:21 +0300 Subject: [PATCH 09/20] fix: recreate the keymap file when there and err of parsing it --- internal/config/keymaps.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/internal/config/keymaps.go b/internal/config/keymaps.go index 0c6a1181..15748850 100644 --- a/internal/config/keymaps.go +++ b/internal/config/keymaps.go @@ -194,13 +194,13 @@ func GetKeyMapConfigPath() string { // 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) { - defaults := DefaultKeyMap() utils.Debug("Warning: Created New %s file \u2014 using defaults", path) - SaveKeyMap(defaults) + err = SaveKeyMap(defaults) return defaults, nil } return nil, err @@ -209,15 +209,14 @@ func LoadKeyMap() (*KeyMap, error) { 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 := DefaultKeyMap() defaults.StartupWarnings = append(defaults.StartupWarnings, - fmt.Sprintf("Config: keymap file is corrupt (%v) — all keybindings reset to defaults", err)) - return defaults, nil + fmt.Sprintf("Config: keymap file is corrupt (%v) — all keybindings reset to defaults & rewrite the file", err)) + err = SaveKeyMap(defaults) + return defaults, err } - keymap := DefaultKeyMap() - keymap.ApplyConfig(&cfg) - return keymap, nil + defaults.ApplyConfig(&cfg) + return defaults, nil } // SaveKeyMap saves the keymap configuration to file. From bb32b10921b45dbf786f7f04923c1d18bcb7886f Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Mon, 18 May 2026 12:26:23 +0530 Subject: [PATCH 10/20] docs/refactor: update SETTINGS.md for keymap.json config and fix linter warning in keymaps config and tests --- docs/SETTINGS.md | 38 ++++++++++++++++++++++++++++++++- internal/config/keymaps.go | 2 +- internal/config/keymaps_test.go | 4 ++-- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index 248395f5..cbad4c1b 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -42,6 +42,42 @@ 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.* ## Directory Structure @@ -49,7 +85,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 index 4447aa2e..1b1a7812 100644 --- a/internal/config/keymaps.go +++ b/internal/config/keymaps.go @@ -201,7 +201,7 @@ func LoadKeyMap() (*KeyMap, error) { if err != nil { if os.IsNotExist(err) { utils.Debug("Warning: Created New %s file \u2014 using defaults", path) - err = SaveKeyMap(defaults) + _ = SaveKeyMap(defaults) return defaults, nil } return nil, err diff --git a/internal/config/keymaps_test.go b/internal/config/keymaps_test.go index 279150a8..d7b60c0e 100644 --- a/internal/config/keymaps_test.go +++ b/internal/config/keymaps_test.go @@ -54,11 +54,11 @@ func TestSaveAndLoadKeyMap(t *testing.T) { if err != nil { t.Fatal(err) } - defer os.RemoveAll(tmpDir) + 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{ From db229120baa3d60a91efe9a34ec230ce397cb2d2 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Mon, 18 May 2026 12:30:28 +0530 Subject: [PATCH 11/20] fix(tui): remove shadowed l and h keys from Dashboard NextTab and PrevTab to restore Log and Help toggle functionality --- internal/config/keymaps.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/config/keymaps.go b/internal/config/keymaps.go index 1b1a7812..b2ce38d7 100644 --- a/internal/config/keymaps.go +++ b/internal/config/keymaps.go @@ -362,12 +362,12 @@ func DefaultKeyMap() *KeyMap { key.WithHelp("e", "done tab"), ), NextTab: key.NewBinding( - key.WithKeys("tab", "right", "l"), - key.WithHelp("tab/→/l", "next tab"), + key.WithKeys("tab", "right"), + key.WithHelp("tab/→", "next tab"), ), PrevTab: key.NewBinding( - key.WithKeys("shift+tab", "left", "h"), - key.WithHelp("shift+tab/←/h", "prev tab"), + key.WithKeys("shift+tab", "left"), + key.WithHelp("shift+tab/←", "prev tab"), ), Add: key.NewBinding( key.WithKeys("a", "n"), From 8677b22aa07d7c36fd6552dd40d15febde13e771 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Mon, 18 May 2026 12:33:32 +0530 Subject: [PATCH 12/20] fix(tui): clean up ReportBug help text representation and add robust unit tests --- internal/config/keymaps.go | 4 ++-- internal/config/keymaps_test.go | 37 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/internal/config/keymaps.go b/internal/config/keymaps.go index b2ce38d7..12bb5b11 100644 --- a/internal/config/keymaps.go +++ b/internal/config/keymaps.go @@ -407,7 +407,7 @@ func DefaultKeyMap() *KeyMap { ), ReportBug: key.NewBinding( key.WithKeys("?"), - key.WithHelp("shift+?", "report bug"), + key.WithHelp("?", "bug report"), ), OpenFile: key.NewBinding( key.WithKeys("o"), @@ -601,7 +601,7 @@ func DefaultKeyMap() *KeyMap { ), ReportBug: key.NewBinding( key.WithKeys("?"), - key.WithHelp("shift+?", "report bug"), + key.WithHelp("?", "bug report"), ), }, SettingsEditor: SettingsEditorKeyMap{ diff --git a/internal/config/keymaps_test.go b/internal/config/keymaps_test.go index d7b60c0e..44fce852 100644 --- a/internal/config/keymaps_test.go +++ b/internal/config/keymaps_test.go @@ -104,3 +104,40 @@ func TestValidateKeyMap(t *testing.T) { 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] != "h" { + t.Errorf("Expected Dashboard.ToggleHelp default keys to be ['h'], got %v", toggleHelpKeys) + } + if km.Dashboard.ToggleHelp.Help().Key != "h" { + t.Errorf("Expected Dashboard.ToggleHelp help key to be 'h', 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) + } +} From ccb9c1a8666310f790b2e80ad9c2623408ae5960 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Mon, 18 May 2026 12:55:31 +0530 Subject: [PATCH 13/20] feat: implement dynamic keymap hot-reloading from disk and add "/" as a default toggle help binding --- internal/config/keymaps.go | 5 ++- internal/config/keymaps_test.go | 4 +- internal/tui/keys_test.go | 72 +++++++++++++++++++++++++++++++++ internal/tui/model.go | 10 ++++- internal/tui/update.go | 16 ++++++++ 5 files changed, 103 insertions(+), 4 deletions(-) diff --git a/internal/config/keymaps.go b/internal/config/keymaps.go index 12bb5b11..c37c1513 100644 --- a/internal/config/keymaps.go +++ b/internal/config/keymaps.go @@ -217,6 +217,9 @@ func LoadKeyMap() (*KeyMap, error) { } defaults.ApplyConfig(&cfg) + // 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 } @@ -402,7 +405,7 @@ func DefaultKeyMap() *KeyMap { key.WithHelp("l", "toggle log"), ), ToggleHelp: key.NewBinding( - key.WithKeys("h"), + key.WithKeys("h", "/"), key.WithHelp("h", "keybindings"), ), ReportBug: key.NewBinding( diff --git a/internal/config/keymaps_test.go b/internal/config/keymaps_test.go index 44fce852..a48a44df 100644 --- a/internal/config/keymaps_test.go +++ b/internal/config/keymaps_test.go @@ -110,8 +110,8 @@ func TestReportBugAndToggleHelpKeymaps(t *testing.T) { // 1. Dashboard.ToggleHelp toggleHelpKeys := km.Dashboard.ToggleHelp.Keys() - if len(toggleHelpKeys) != 1 || toggleHelpKeys[0] != "h" { - t.Errorf("Expected Dashboard.ToggleHelp default keys to be ['h'], got %v", toggleHelpKeys) + if len(toggleHelpKeys) != 2 || toggleHelpKeys[0] != "h" || toggleHelpKeys[1] != "/" { + t.Errorf("Expected Dashboard.ToggleHelp default keys to be ['h', '/'], got %v", toggleHelpKeys) } if km.Dashboard.ToggleHelp.Help().Key != "h" { t.Errorf("Expected Dashboard.ToggleHelp help key to be 'h', got %q", km.Dashboard.ToggleHelp.Help().Key) diff --git a/internal/tui/keys_test.go b/internal/tui/keys_test.go index 03e0df77..dfb49ca2 100644 --- a/internal/tui/keys_test.go +++ b/internal/tui/keys_test.go @@ -1,10 +1,13 @@ package tui import ( + "os" "reflect" "testing" + "time" "charm.land/bubbles/v2/key" + "github.com/SurgeDM/Surge/internal/config" ) type helperKeyMap interface { @@ -111,3 +114,72 @@ func TestUpdateKeyMap_AllKeysInHelp(t *testing.T) { func TestBugReportKeyMap_AllKeysInHelp(t *testing.T) { testKeyMapInHelp(t, "BugReport", Keys.BugReport, nil) } + +func TestDynamicKeyMapReloading(t *testing.T) { + 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()) != 2 || m.keys.Dashboard.ToggleHelp.Keys()[0] != "h" { + t.Errorf("Expected default ToggleHelp key 'h', 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 a546c262..1f68b3cb 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -193,7 +193,9 @@ type RootModel struct { bugReportIncludeLatestLog bool // Keybindings - keys *config.KeyMap + keys *config.KeyMap + lastKeyMapModTime time.Time + lastConfigCheckTime time.Time // Server port for display ServerPort int @@ -296,6 +298,10 @@ func InitialRootModel(serverPort int, currentVersion string, service core.Downlo 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. @@ -456,6 +462,8 @@ func InitialRootModel(serverPort int, currentVersion string, service core.Downlo urlUpdateInput: urlUpdateInput, catMgrInputs: [4]textinput.Model{catNameInput, catDescInput, catPatternInput, catPathInput}, keys: keys, + lastKeyMapModTime: keyMapModTime, + lastConfigCheckTime: time.Now(), ServerPort: serverPort, CurrentVersion: currentVersion, CurrentCommit: commitValue, diff --git a/internal/tui/update.go b/internal/tui/update.go index ee71f232..5e303dfa 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,6 +52,19 @@ 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) { + m.lastKeyMapModTime = info.ModTime() + if newKeys, err := config.LoadKeyMap(); err == nil && newKeys != nil { + m.keys = newKeys + utils.Debug("TUI: dynamically reloaded keymap.json from disk") + } + } + } + } if m.Settings == nil { m.Settings = config.DefaultSettings() From 607e2c80186b8e292b99a149ab5006da7e98db4c Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Mon, 18 May 2026 13:11:19 +0530 Subject: [PATCH 14/20] fix: update keymap modification timestamp and resolve struct field validation logic --- internal/config/keymaps.go | 2 +- internal/tui/update.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/config/keymaps.go b/internal/config/keymaps.go index c37c1513..aed0226a 100644 --- a/internal/config/keymaps.go +++ b/internal/config/keymaps.go @@ -341,7 +341,7 @@ func (k *KeyMap) Validate() { for i := 0; i < v.NumField(); i++ { field := v.Field(i) fieldType := t.Field(i).Type - if fieldType.Kind() == reflect.Struct && fieldType != reflect.TypeFor[[]string]() { + if fieldType.Kind() == reflect.Struct { if reflect.DeepEqual(field.Interface(), reflect.Zero(fieldType).Interface()) { field.Set(dV.Field(i)) } diff --git a/internal/tui/update.go b/internal/tui/update.go index 5e303dfa..5bb6d4f8 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -61,6 +61,9 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if newKeys, err := config.LoadKeyMap(); err == nil && newKeys != nil { m.keys = newKeys utils.Debug("TUI: dynamically reloaded keymap.json from disk") + if postInfo, postErr := os.Stat(config.GetKeyMapConfigPath()); postErr == nil { + m.lastKeyMapModTime = postInfo.ModTime() + } } } } From 4860ae5c62a0b04766d59f8194bf3751e31e4022 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Mon, 18 May 2026 13:30:46 +0530 Subject: [PATCH 15/20] refactor: update default dashboard keybindings to include vim-style navigation and simplify toggle help key --- internal/config/keymaps.go | 20 ++++++++++---------- internal/config/keymaps_test.go | 8 ++++---- internal/tui/keys_test.go | 4 ++-- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/internal/config/keymaps.go b/internal/config/keymaps.go index aed0226a..7d4feebc 100644 --- a/internal/config/keymaps.go +++ b/internal/config/keymaps.go @@ -365,16 +365,16 @@ func DefaultKeyMap() *KeyMap { key.WithHelp("e", "done tab"), ), NextTab: key.NewBinding( - key.WithKeys("tab", "right"), - key.WithHelp("tab/→", "next tab"), + key.WithKeys("tab", "right", "l"), + key.WithHelp("tab/→/l", "next tab"), ), PrevTab: key.NewBinding( - key.WithKeys("shift+tab", "left"), - key.WithHelp("shift+tab/←", "prev tab"), + key.WithKeys("shift+tab", "left", "h"), + key.WithHelp("shift+tab/←/h", "prev tab"), ), Add: key.NewBinding( - key.WithKeys("a", "n"), - key.WithHelp("a/n", "add download"), + key.WithKeys("n"), + key.WithHelp("n", "add download"), ), BatchImport: key.NewBinding( key.WithKeys("b", "B"), @@ -401,12 +401,12 @@ func DefaultKeyMap() *KeyMap { key.WithHelp("s", "settings"), ), Log: key.NewBinding( - key.WithKeys("l"), - key.WithHelp("l", "toggle log"), + key.WithKeys("a"), + key.WithHelp("a", "toggle log"), ), ToggleHelp: key.NewBinding( - key.WithKeys("h", "/"), - key.WithHelp("h", "keybindings"), + key.WithKeys("/"), + key.WithHelp("/", "keybindings"), ), ReportBug: key.NewBinding( key.WithKeys("?"), diff --git a/internal/config/keymaps_test.go b/internal/config/keymaps_test.go index a48a44df..a16246be 100644 --- a/internal/config/keymaps_test.go +++ b/internal/config/keymaps_test.go @@ -110,11 +110,11 @@ func TestReportBugAndToggleHelpKeymaps(t *testing.T) { // 1. Dashboard.ToggleHelp toggleHelpKeys := km.Dashboard.ToggleHelp.Keys() - if len(toggleHelpKeys) != 2 || toggleHelpKeys[0] != "h" || toggleHelpKeys[1] != "/" { - t.Errorf("Expected Dashboard.ToggleHelp default keys to be ['h', '/'], got %v", toggleHelpKeys) + if len(toggleHelpKeys) != 1 || toggleHelpKeys[0] != "/" { + t.Errorf("Expected Dashboard.ToggleHelp default keys to be ['/'], got %v", toggleHelpKeys) } - if km.Dashboard.ToggleHelp.Help().Key != "h" { - t.Errorf("Expected Dashboard.ToggleHelp help key to be 'h', got %q", km.Dashboard.ToggleHelp.Help().Key) + 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 diff --git a/internal/tui/keys_test.go b/internal/tui/keys_test.go index dfb49ca2..e72fc9c0 100644 --- a/internal/tui/keys_test.go +++ b/internal/tui/keys_test.go @@ -148,8 +148,8 @@ func TestDynamicKeyMapReloading(t *testing.T) { lastConfigCheckTime: time.Now().Add(-2 * time.Second), // Ensure check triggers } - if len(m.keys.Dashboard.ToggleHelp.Keys()) != 2 || m.keys.Dashboard.ToggleHelp.Keys()[0] != "h" { - t.Errorf("Expected default ToggleHelp key 'h', got %v", m.keys.Dashboard.ToggleHelp.Keys()) + 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 From 93a8db7d2efb372b020ea1b1a3b8e40ee97d3cbb Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Mon, 18 May 2026 13:43:37 +0530 Subject: [PATCH 16/20] fix: improve health check logic, bitmap safety, TUI config loading, and keymap validation and display --- docs/SETTINGS.md | 8 +++ internal/config/keymaps.go | 32 +++++++++- internal/engine/concurrent/health.go | 4 +- internal/engine/concurrent/health_test.go | 64 ++++++++++++++++++++ internal/engine/types/config.go | 6 +- internal/engine/types/config_convert_test.go | 30 ++++++++- internal/engine/types/progress.go | 18 ++++-- internal/tui/update.go | 7 +-- 8 files changed, 150 insertions(+), 19 deletions(-) diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index cbad4c1b..8310e2bb 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -79,6 +79,14 @@ The `keymap.json` file is structured into nested sections matching each TUI stat *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 Surge follows OS conventions for storing its files. Below is a breakdown of every directory it uses and where to find it on each platform. diff --git a/internal/config/keymaps.go b/internal/config/keymaps.go index 7d4feebc..30ba1f03 100644 --- a/internal/config/keymaps.go +++ b/internal/config/keymaps.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "charm.land/bubbles/v2/key" "github.com/SurgeDM/Surge/internal/utils" @@ -266,7 +267,7 @@ func (k *KeyMap) ApplyConfig(cfg *KeyMapConfig) { if helpDesc == "" { helpDesc = field.Interface().(key.Binding).Help().Desc } - helpKey := bCfg.Keys[0] // TODO: `helpKey := strings.Join(bCfg.Keys, "/")` but now it will break the help view of the Dashboard + helpKey := strings.Join(bCfg.Keys, "/") newBinding := key.NewBinding( key.WithKeys(bCfg.Keys...), key.WithHelp(helpKey, helpDesc), @@ -327,7 +328,7 @@ func (k *KeyMap) ToConfig() *KeyMapConfig { } } -// Validate checks keymap for missing or invalid bindings and fills with defaults. +// 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 { @@ -340,10 +341,35 @@ func (k *KeyMap) Validate() { 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(dV.Field(i)) + 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)) + } + } + } } } } diff --git a/internal/engine/concurrent/health.go b/internal/engine/concurrent/health.go index d396a307..b5551921 100644 --- a/internal/engine/concurrent/health.go +++ b/internal/engine/concurrent/health.go @@ -68,7 +68,7 @@ func (d *ConcurrentDownloader) checkWorkerHealth() { // Check for absolute stall: no data received for StallTimeout // This catches dead connections that the relative speed check misses lastActivity := active.LastActivity.Load() - if lastActivity > 0 { + if stallTimeout > 0 && lastActivity > 0 { timeSinceData := now.Sub(time.Unix(0, lastActivity)) if timeSinceData >= stallTimeout { utils.Debug("Health: Worker %d stalled (no data for %v), cancelling", @@ -85,7 +85,7 @@ func (d *ConcurrentDownloader) checkWorkerHealth() { if meanSpeed > 0 { workerSpeed := active.GetSpeed() threshold := d.Runtime.GetSlowWorkerThreshold() - isBelowThreshold := workerSpeed > 0 && workerSpeed < threshold*meanSpeed + isBelowThreshold := threshold > 0 && workerSpeed > 0 && workerSpeed < threshold*meanSpeed if isBelowThreshold { utils.Debug("Health: Worker %d slow (%.2f KB/s vs mean %.2f KB/s), cancelling", diff --git a/internal/engine/concurrent/health_test.go b/internal/engine/concurrent/health_test.go index 1abb5603..7a63b860 100644 --- a/internal/engine/concurrent/health_test.go +++ b/internal/engine/concurrent/health_test.go @@ -181,3 +181,67 @@ func TestHealth_StallDetection(t *testing.T) { t.Error("Stalled worker should have been cancelled") } } + +func TestHealth_ZeroStallTimeoutDisablesStallDetection(t *testing.T) { + runtime := &types.RuntimeConfig{ + SlowWorkerThreshold: 0.5, + SlowWorkerGracePeriod: 0, + StallTimeout: 0, // Disabled + } + state := types.NewProgressState("test", 1000) + d := NewConcurrentDownloader("test", nil, state, runtime) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + now := time.Now() + + stalledCtx, stalledCancel := context.WithCancel(ctx) + active := &ActiveTask{ + StartTime: now.Add(-10 * time.Second), + Cancel: stalledCancel, + } + 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 <-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/config.go b/internal/engine/types/config.go index ed6d1b01..7dc10b70 100644 --- a/internal/engine/types/config.go +++ b/internal/engine/types/config.go @@ -156,7 +156,7 @@ func (r *RuntimeConfig) GetDialHedgeCount() int { // GetSlowWorkerThreshold returns configured value or default func (r *RuntimeConfig) GetSlowWorkerThreshold() float64 { - if r == nil || r.SlowWorkerThreshold <= 0 { + if r == nil || r.SlowWorkerThreshold < 0 { return SlowWorkerThreshold } return r.SlowWorkerThreshold @@ -164,7 +164,7 @@ func (r *RuntimeConfig) GetSlowWorkerThreshold() float64 { // GetSlowWorkerGracePeriod returns configured value or default func (r *RuntimeConfig) GetSlowWorkerGracePeriod() time.Duration { - if r == nil || r.SlowWorkerGracePeriod <= 0 { + if r == nil || r.SlowWorkerGracePeriod < 0 { return SlowWorkerGrace } return r.SlowWorkerGracePeriod @@ -172,7 +172,7 @@ func (r *RuntimeConfig) GetSlowWorkerGracePeriod() time.Duration { // GetStallTimeout returns configured value or default func (r *RuntimeConfig) GetStallTimeout() time.Duration { - if r == nil || r.StallTimeout <= 0 { + if r == nil || r.StallTimeout < 0 { return StallTimeout } return r.StallTimeout diff --git a/internal/engine/types/config_convert_test.go b/internal/engine/types/config_convert_test.go index a1f971e4..7f25e440 100644 --- a/internal/engine/types/config_convert_test.go +++ b/internal/engine/types/config_convert_test.go @@ -109,6 +109,7 @@ func TestConvertRuntimeConfig_Exhaustive(t *testing.T) { result := ConvertRuntimeConfig(input) + // 1. Verify that every field in result is non-zero when populated on input v := reflect.ValueOf(*result) typeOfS := v.Type() @@ -116,9 +117,36 @@ func TestConvertRuntimeConfig_Exhaustive(t *testing.T) { 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 converted RuntimeConfig. Did you forget to map it in ConvertRuntimeConfig?", fieldName) } } + + // 2. Perform reciprocal reflection checks to ensure both structs remain fully synchronized. + configType := reflect.TypeOf(config.RuntimeConfig{}) + typesType := reflect.TypeOf(RuntimeConfig{}) + + // Map of fields in config.RuntimeConfig that are expected to be dropped/not mapped + ignoredFields := map[string]bool{ + "MaxConcurrentProbes": true, + } + + for i := 0; i < configType.NumField(); i++ { + fieldName := configType.Field(i).Name + if ignoredFields[fieldName] { + continue + } + // Ensure field exists in types.RuntimeConfig + if _, found := typesType.FieldByName(fieldName); !found { + t.Errorf("Field %q in config.RuntimeConfig has no matching field in types.RuntimeConfig", fieldName) + } + } + + // Conversely, ensure all fields in types.RuntimeConfig exist in config.RuntimeConfig + for i := 0; i < typesType.NumField(); i++ { + fieldName := typesType.Field(i).Name + if _, found := configType.FieldByName(fieldName); !found { + t.Errorf("Field %q in types.RuntimeConfig has no matching field in config.RuntimeConfig", fieldName) + } + } } diff --git a/internal/engine/types/progress.go b/internal/engine/types/progress.go index b6bf53e7..05cdc840 100644 --- a/internal/engine/types/progress.go +++ b/internal/engine/types/progress.go @@ -319,17 +319,23 @@ func (ps *ProgressState) RestoreBitmap(bitmap []byte, actualChunkSize int64) { ps.mu.Lock() defer ps.mu.Unlock() - if len(bitmap) == 0 || actualChunkSize <= 0 { + if len(bitmap) == 0 || actualChunkSize <= 0 || ps.TotalSize <= 0 { return } - //utils.Debug("RestoreBitmap: Len=%d, ChunkSize=%d", len(bitmap), actualChunkSize) + // Recalculate width + numChunks := int((ps.TotalSize + actualChunkSize - 1) / actualChunkSize) + bytesNeeded := (numChunks + 3) / 4 - ps.ChunkBitmap = bitmap - ps.ActualChunkSize = actualChunkSize + // Safety check: ensure the bitmap size matches what we expect + if len(bitmap) < bytesNeeded { + return + } - // Recalculate width - numChunks := int((ps.TotalSize + ps.ActualChunkSize - 1) / ps.ActualChunkSize) + // Deep copy to prevent mutation hazard of caller's backing array + ps.ChunkBitmap = make([]byte, bytesNeeded) + copy(ps.ChunkBitmap, bitmap) + ps.ActualChunkSize = actualChunkSize ps.BitmapWidth = numChunks // Re-initialize progress tracking (will be filled by RecalculateProgress) diff --git a/internal/tui/update.go b/internal/tui/update.go index 5bb6d4f8..e76d65f9 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -57,13 +57,12 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.lastConfigCheckTime = time.Now() if info, err := os.Stat(config.GetKeyMapConfigPath()); err == nil { if info.ModTime().After(m.lastKeyMapModTime) { - m.lastKeyMapModTime = info.ModTime() + // Use the pre-load ModTime as the watermark to avoid TOCTOU + preLoadModTime := info.ModTime() if newKeys, err := config.LoadKeyMap(); err == nil && newKeys != nil { m.keys = newKeys + m.lastKeyMapModTime = preLoadModTime utils.Debug("TUI: dynamically reloaded keymap.json from disk") - if postInfo, postErr := os.Stat(config.GetKeyMapConfigPath()); postErr == nil { - m.lastKeyMapModTime = postInfo.ModTime() - } } } } From eba34ccf2453436e3d72ebcd4c22a33efd88beec Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Mon, 18 May 2026 14:04:05 +0530 Subject: [PATCH 17/20] chore: skip dynamic keymap tests on Windows and validate defaults during initialization --- internal/config/keymaps.go | 1 + internal/tui/keys_test.go | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/internal/config/keymaps.go b/internal/config/keymaps.go index 30ba1f03..77b55feb 100644 --- a/internal/config/keymaps.go +++ b/internal/config/keymaps.go @@ -218,6 +218,7 @@ func LoadKeyMap() (*KeyMap, error) { } 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) diff --git a/internal/tui/keys_test.go b/internal/tui/keys_test.go index e72fc9c0..ff602404 100644 --- a/internal/tui/keys_test.go +++ b/internal/tui/keys_test.go @@ -3,6 +3,7 @@ package tui import ( "os" "reflect" + "runtime" "testing" "time" @@ -116,6 +117,10 @@ func TestBugReportKeyMap_AllKeysInHelp(t *testing.T) { } 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) From cb21ad2977bb6e6d25df97524d47a47b7b9cf2f3 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Mon, 18 May 2026 14:15:52 +0530 Subject: [PATCH 18/20] chore: format files using go fmt --- internal/config/settings.go | 22 +++++++++++----------- internal/config/settings_test.go | 2 +- internal/engine/types/accuracy_test.go | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) 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 13d851dd..2347adf1 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -489,7 +489,7 @@ func TestToRuntimeConfig_Exhaustive(t *testing.T) { ignoredNetworkFields := map[string]bool{ "MaxConcurrentDownloads": true, "MaxConcurrentProbes": true, - "MaxConnectionsPerHost": true, + "MaxConnectionsPerHost": true, } for i := 0; i < networkType.NumField(); i++ { 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 { From cec8c74ae6d741aea579f5dd2c278925013783b9 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Mon, 18 May 2026 14:23:30 +0530 Subject: [PATCH 19/20] refactor: remove redundant bitmap size validation in progress type update --- internal/engine/types/progress.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/engine/types/progress.go b/internal/engine/types/progress.go index f32bb9e4..73673044 100644 --- a/internal/engine/types/progress.go +++ b/internal/engine/types/progress.go @@ -341,11 +341,6 @@ func (ps *ProgressState) RestoreBitmap(bitmap []byte, actualChunkSize int64) { return } - // Safety check: ensure the bitmap size matches what we expect - if len(bitmap) < bytesNeeded { - return - } - // Deep copy to prevent mutation hazard of caller's backing array ps.ChunkBitmap = make([]byte, bytesNeeded) copy(ps.ChunkBitmap, bitmap) From 5da3bdaa663cb5683ae1d9c637550833fdeb1833 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Mon, 18 May 2026 14:30:16 +0530 Subject: [PATCH 20/20] feat(tui): add vim-style h/j/k/l settings navigation and fix keymap hot-reload watermark loop --- internal/tui/model.go | 4 + internal/tui/settings_navigation_test.go | 122 +++++++++++++++++++++++ internal/tui/update.go | 7 +- internal/tui/update_settings.go | 65 ++++++++---- internal/tui/view_settings.go | 45 +++++++-- 5 files changed, 210 insertions(+), 33 deletions(-) create mode 100644 internal/tui/settings_navigation_test.go diff --git a/internal/tui/model.go b/internal/tui/model.go index 1f68b3cb..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 @@ -454,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), 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 e76d65f9..825e8624 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -57,11 +57,14 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.lastConfigCheckTime = time.Now() if info, err := os.Stat(config.GetKeyMapConfigPath()); err == nil { if info.ModTime().After(m.lastKeyMapModTime) { - // Use the pre-load ModTime as the watermark to avoid TOCTOU preLoadModTime := info.ModTime() if newKeys, err := config.LoadKeyMap(); err == nil && newKeys != nil { m.keys = newKeys - m.lastKeyMapModTime = preLoadModTime + 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") } } diff --git a/internal/tui/update_settings.go b/internal/tui/update_settings.go index 0b21f349..64fc9a6b 100644 --- a/internal/tui/update_settings.go +++ b/internal/tui/update_settings.go @@ -109,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 @@ -123,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": @@ -155,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 @@ -184,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" { @@ -242,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/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))