Merge pull request #11 from qingfengzxr/main

feat: add configurable key bindings with ESC quit disable option
This commit is contained in:
Guillaume Archambault 2025-10-04 17:00:56 +02:00 committed by GitHub
commit 5986659048
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 416 additions and 3 deletions

66
CONFIG.md Normal file
View File

@ -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.

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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,

View File

@ -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 {