From 120cd6c009cab5b6a694d23bb907ea97923dbe63 Mon Sep 17 00:00:00 2001 From: zxr Date: Mon, 29 Sep 2025 11:05:26 +0800 Subject: [PATCH] feat: add configurable key bindings with ESC quit disable option - Add unified application configuration system with JSON config file - Implement configurable quit keys (default: "q", "ctrl+c") - Add disable_esc_quit option for vim users to prevent accidental exits - Auto-create default config file at ~/.config/sshm/config.json - Maintain backward compatibility (ESC quit enabled by default) - Include comprehensive tests and documentation --- CONFIG.md | 66 ++++++++++ internal/config/keybindings.go | 146 ++++++++++++++++++++++ internal/config/keybindings_test.go | 181 ++++++++++++++++++++++++++++ internal/ui/model.go | 3 + internal/ui/tui.go | 10 ++ internal/ui/update.go | 13 +- 6 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 CONFIG.md create mode 100644 internal/config/keybindings.go create mode 100644 internal/config/keybindings_test.go diff --git a/CONFIG.md b/CONFIG.md new file mode 100644 index 0000000..47e1b4d --- /dev/null +++ b/CONFIG.md @@ -0,0 +1,66 @@ +# SSHM Configuration + +SSHM supports configurable key bindings through a configuration file located at: +- Linux/macOS: `~/.config/sshm/config.json` +- Windows: `%APPDATA%\sshm\config.json` + +## Configuration Options + +### Key Bindings + +The key bindings section allows you to customize how you exit the application. + +#### Example Configuration + +```json +{ + "key_bindings": { + "quit_keys": ["q", "ctrl+c"], + "disable_esc_quit": true + } +} +``` + +#### Options + +- **quit_keys**: Array of keys that will quit the application. Default: `["q", "ctrl+c"]` +- **disable_esc_quit**: Boolean flag to disable ESC key from quitting the application. Default: `false` + +## For Vim Users + +If you're a vim user and frequently press ESC accidentally causing the application to quit, set `disable_esc_quit` to `true`: + +```json +{ + "key_bindings": { + "quit_keys": ["q", "ctrl+c"], + "disable_esc_quit": true + } +} +``` + +With this configuration: +- ESC will no longer quit the application +- You can still quit using 'q' or Ctrl+C +- All other functionality remains the same + +## Default Configuration + +If no configuration file exists, SSHM will create one with these defaults: + +```json +{ + "key_bindings": { + "quit_keys": ["q", "ctrl+c"], + "disable_esc_quit": false + } +} +``` + +This ensures backward compatibility - ESC will continue to work as a quit key by default. + +## Configuration Location + +The configuration file will be automatically created when you first run SSHM. You can manually edit it to customize the key bindings to your preference. + +If you encounter any issues with the configuration file, you can delete it and SSHM will recreate it with default settings on the next run. \ No newline at end of file diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go new file mode 100644 index 0000000..0f2d40d --- /dev/null +++ b/internal/config/keybindings.go @@ -0,0 +1,146 @@ +package config + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// KeyBindings represents configurable key bindings for the application +type KeyBindings struct { + // Quit keys - keys that will quit the application + QuitKeys []string `json:"quit_keys"` + + // DisableEscQuit - if true, ESC key won't quit the application (useful for vim users) + DisableEscQuit bool `json:"disable_esc_quit"` +} + +// AppConfig represents the main application configuration +type AppConfig struct { + KeyBindings KeyBindings `json:"key_bindings"` +} + +// GetDefaultKeyBindings returns the default key bindings configuration +func GetDefaultKeyBindings() KeyBindings { + return KeyBindings{ + QuitKeys: []string{"q", "ctrl+c"}, // Default keeps current behavior minus ESC + DisableEscQuit: false, // Default to false for backward compatibility + } +} + +// GetDefaultAppConfig returns the default application configuration +func GetDefaultAppConfig() AppConfig { + return AppConfig{ + KeyBindings: GetDefaultKeyBindings(), + } +} + +// GetAppConfigPath returns the path to the application config file +func GetAppConfigPath() (string, error) { + configDir, err := GetSSHMConfigDir() + if err != nil { + return "", err + } + + return filepath.Join(configDir, "config.json"), nil +} + +// LoadAppConfig loads the application configuration from file +// If the file doesn't exist, it returns the default configuration +func LoadAppConfig() (*AppConfig, error) { + configPath, err := GetAppConfigPath() + if err != nil { + return nil, err + } + + // If config file doesn't exist, return default config and create the file + if _, err := os.Stat(configPath); os.IsNotExist(err) { + defaultConfig := GetDefaultAppConfig() + + // Create config directory if it doesn't exist + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return nil, err + } + + // Save default config to file + if err := SaveAppConfig(&defaultConfig); err != nil { + // If we can't save, just return the default config without erroring + // This allows the app to work even if config file can't be created + return &defaultConfig, nil + } + + return &defaultConfig, nil + } + + // Read existing config file + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + var config AppConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, err + } + + // Validate and fill in missing fields with defaults + config = mergeWithDefaults(config) + + return &config, nil +} + +// SaveAppConfig saves the application configuration to file +func SaveAppConfig(config *AppConfig) error { + if config == nil { + return errors.New("config cannot be nil") + } + + configPath, err := GetAppConfigPath() + if err != nil { + return err + } + + // Create config directory if it doesn't exist + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + return os.WriteFile(configPath, data, 0644) +} + +// mergeWithDefaults ensures all required fields are set with defaults if missing +func mergeWithDefaults(config AppConfig) AppConfig { + defaults := GetDefaultAppConfig() + + // If QuitKeys is empty, use defaults + if len(config.KeyBindings.QuitKeys) == 0 { + config.KeyBindings.QuitKeys = defaults.KeyBindings.QuitKeys + } + + return config +} + +// ShouldQuitOnKey checks if the given key should trigger quit based on configuration +func (kb *KeyBindings) ShouldQuitOnKey(key string) bool { + // Special handling for ESC key + if key == "esc" { + return !kb.DisableEscQuit + } + + // Check if key is in the quit keys list + for _, quitKey := range kb.QuitKeys { + if quitKey == key { + return true + } + } + + return false +} \ No newline at end of file diff --git a/internal/config/keybindings_test.go b/internal/config/keybindings_test.go new file mode 100644 index 0000000..620b92f --- /dev/null +++ b/internal/config/keybindings_test.go @@ -0,0 +1,181 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestDefaultKeyBindings(t *testing.T) { + kb := GetDefaultKeyBindings() + + // Test default configuration + if kb.DisableEscQuit { + t.Error("Default configuration should allow ESC to quit (backward compatibility)") + } + + // Test default quit keys + expectedQuitKeys := []string{"q", "ctrl+c"} + if len(kb.QuitKeys) != len(expectedQuitKeys) { + t.Errorf("Expected %d quit keys, got %d", len(expectedQuitKeys), len(kb.QuitKeys)) + } + + for i, expected := range expectedQuitKeys { + if i >= len(kb.QuitKeys) || kb.QuitKeys[i] != expected { + t.Errorf("Expected quit key %s, got %s", expected, kb.QuitKeys[i]) + } + } +} + +func TestShouldQuitOnKey(t *testing.T) { + tests := []struct { + name string + keyBindings KeyBindings + key string + expectedResult bool + }{ + { + name: "Default config - ESC should quit", + keyBindings: KeyBindings{ + QuitKeys: []string{"q", "ctrl+c"}, + DisableEscQuit: false, + }, + key: "esc", + expectedResult: true, + }, + { + name: "Disabled ESC quit - ESC should not quit", + keyBindings: KeyBindings{ + QuitKeys: []string{"q", "ctrl+c"}, + DisableEscQuit: true, + }, + key: "esc", + expectedResult: false, + }, + { + name: "Q key should quit", + keyBindings: KeyBindings{ + QuitKeys: []string{"q", "ctrl+c"}, + DisableEscQuit: true, + }, + key: "q", + expectedResult: true, + }, + { + name: "Ctrl+C should quit", + keyBindings: KeyBindings{ + QuitKeys: []string{"q", "ctrl+c"}, + DisableEscQuit: true, + }, + key: "ctrl+c", + expectedResult: true, + }, + { + name: "Other keys should not quit", + keyBindings: KeyBindings{ + QuitKeys: []string{"q", "ctrl+c"}, + DisableEscQuit: true, + }, + key: "enter", + expectedResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.keyBindings.ShouldQuitOnKey(tt.key) + if result != tt.expectedResult { + t.Errorf("ShouldQuitOnKey(%q) = %v, expected %v", tt.key, result, tt.expectedResult) + } + }) + } +} + +func TestAppConfigBasics(t *testing.T) { + // Test default config creation + defaultConfig := GetDefaultAppConfig() + + if defaultConfig.KeyBindings.DisableEscQuit { + t.Error("Default configuration should allow ESC to quit") + } + + expectedQuitKeys := []string{"q", "ctrl+c"} + if len(defaultConfig.KeyBindings.QuitKeys) != len(expectedQuitKeys) { + t.Errorf("Expected %d quit keys, got %d", len(expectedQuitKeys), len(defaultConfig.KeyBindings.QuitKeys)) + } +} + +func TestMergeWithDefaults(t *testing.T) { + // Test config with missing QuitKeys + incompleteConfig := AppConfig{ + KeyBindings: KeyBindings{ + DisableEscQuit: true, + // QuitKeys is missing + }, + } + + mergedConfig := mergeWithDefaults(incompleteConfig) + + // Should preserve DisableEscQuit + if !mergedConfig.KeyBindings.DisableEscQuit { + t.Error("Should preserve DisableEscQuit as true") + } + + // Should fill in default QuitKeys + expectedQuitKeys := []string{"q", "ctrl+c"} + if len(mergedConfig.KeyBindings.QuitKeys) != len(expectedQuitKeys) { + t.Errorf("Expected %d quit keys, got %d", len(expectedQuitKeys), len(mergedConfig.KeyBindings.QuitKeys)) + } +} + +func TestSaveAndLoadAppConfigIntegration(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "sshm_test") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create a custom config file directly in temp directory + configPath := filepath.Join(tempDir, "config.json") + + customConfig := AppConfig{ + KeyBindings: KeyBindings{ + QuitKeys: []string{"q"}, + DisableEscQuit: true, + }, + } + + // Save config directly to file + data, err := json.MarshalIndent(customConfig, "", " ") + if err != nil { + t.Fatalf("Failed to marshal config: %v", err) + } + + err = os.WriteFile(configPath, data, 0644) + if err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Read and unmarshal config + readData, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("Failed to read config file: %v", err) + } + + var loadedConfig AppConfig + err = json.Unmarshal(readData, &loadedConfig) + if err != nil { + t.Fatalf("Failed to unmarshal config: %v", err) + } + + // Verify the loaded config matches what we saved + if !loadedConfig.KeyBindings.DisableEscQuit { + t.Error("DisableEscQuit should be true") + } + + if len(loadedConfig.KeyBindings.QuitKeys) != 1 || loadedConfig.KeyBindings.QuitKeys[0] != "q" { + t.Errorf("Expected quit keys to be ['q'], got %v", loadedConfig.KeyBindings.QuitKeys) + } +} \ No newline at end of file diff --git a/internal/ui/model.go b/internal/ui/model.go index a47e4ab..da6e7e9 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -80,6 +80,9 @@ type Model struct { sortMode SortMode configFile string // Path to the SSH config file + // Application configuration + appConfig *config.AppConfig + // Version update information updateInfo *version.UpdateInfo currentVersion string diff --git a/internal/ui/tui.go b/internal/ui/tui.go index bf1b652..cdc44a1 100644 --- a/internal/ui/tui.go +++ b/internal/ui/tui.go @@ -17,6 +17,15 @@ import ( // NewModel creates a new TUI model with the given SSH hosts func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model { + // Load application configuration + appConfig, err := config.LoadAppConfig() + if err != nil { + // Log the error but continue with default configuration + fmt.Printf("Warning: Could not load application config: %v, using defaults\n", err) + defaultConfig := config.GetDefaultAppConfig() + appConfig = &defaultConfig + } + // Initialize the history manager historyManager, err := history.NewHistoryManager() if err != nil { @@ -39,6 +48,7 @@ func NewModel(hosts []config.SSHHost, configFile, currentVersion string) Model { sortMode: SortByName, configFile: configFile, currentVersion: currentVersion, + appConfig: appConfig, styles: styles, width: 80, height: 24, diff --git a/internal/ui/update.go b/internal/ui/update.go index 3928c04..f633212 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -445,8 +445,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { var cmd tea.Cmd + key := msg.String() - switch msg.String() { + switch key { case "esc", "ctrl+c": if m.deleteMode { // Exit delete mode @@ -455,10 +456,16 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.table.Focus() return m, nil } - return m, tea.Quit + // Use configurable key bindings for quit + if m.appConfig != nil && m.appConfig.KeyBindings.ShouldQuitOnKey(key) { + return m, tea.Quit + } case "q": if !m.searchMode && !m.deleteMode { - return m, tea.Quit + // Use configurable key bindings for quit + if m.appConfig != nil && m.appConfig.KeyBindings.ShouldQuitOnKey(key) { + return m, tea.Quit + } } case "/", "ctrl+f": if !m.searchMode && !m.deleteMode {