Merge pull request #274 from fjmoralesp/main

feat: add custom keymaps config
This commit is contained in:
Jorge Rojas
2026-02-17 14:17:24 -04:00
committed by GitHub
5 changed files with 590 additions and 39 deletions
+161 -39
View File
@@ -374,56 +374,178 @@ Note: Undefined environment variables will be replaced with an empty string.
## Keybindings
### Global
### Custom Keybindings
| Key | Action |
| --------- | ------------------------------ |
| q | Quit |
| CTRL + e | Open SQL editor |
| Backspace | Return to connection selection |
| ? | Show keybindings popup |
You can customize keybindings by adding a `[keymap.<Group>]` section to your `config.toml` file. Each entry maps a command name to a key.
### Table
```toml
[keymap.Home]
SwitchToEditorView = "i"
Quit = "Esc"
| Key | Action |
| -------- | ------------------------------------ |
| c | Edit table cell |
| d | Delete row |
| o | Add row |
| / | Focus the filter input or SQL editor |
| CTRL + s | Commit changes |
| > | Next page |
| < | Previous page |
| K | Sort ASC |
| J | Sort DESC |
| H | Focus tree panel |
| { | Focus previous tab |
| } | Focus next tab |
| X | Close current tab |
| R | Refresh the current table |
| E | Export to CSV |
[keymap.Tree]
GotoTop = "t"
Search = "Ctrl-F"
```
### Tree
For single character keys, use the character directly (e.g., `"q"`, `"G"`, `"1"`, `"/"`). For special keys, use the [tcell key name](https://github.com/gdamore/tcell/blob/v2.7.4/key.go#L83) (e.g., `"Enter"`, `"Esc"`, `"Ctrl-S"`). Only key names defined in tcell are supported.
| Key | Action |
| ------ | ------------------------------ |
| L | Focus table panel |
| G | Focus last database tree node |
| g | Focus first database tree node |
| CTRL+u | Scroll 5 items up |
| CTRL+d | Scroll 5 items down |
Group names are case-insensitive (`Home`, `home`, and `HOME` all work).
### SQL Editor
Available groups: `Home`, `Connection`, `Tree`, `TreeFilter`, `Table`, `Editor`, `Sidebar`, `QueryPreview`, `QueryHistory`, `JSONViewer`.
| Key | Action |
| ------------ | --------------------------------- |
| CTRL + R | Run the SQL statement |
| CTRL + Space | Open external editor (Linux only) |
### Default Keybindings
#### Home
| Default Key | Command | Description |
| --- | --- | --- |
| L | MoveRight | Focus table |
| H | MoveLeft | Focus tree |
| Ctrl-E | SwitchToEditorView | Open SQL editor |
| Ctrl-S | Save | Execute pending changes |
| q | Quit | Quit |
| Backspace | SwitchToConnectionsView | Switch to connections list |
| ? | HelpPopup | Help |
| Ctrl-P | SearchGlobal | Global search |
| Ctrl-_ | ToggleQueryHistory | Toggle query history modal |
| T | ToggleTree | Toggle file tree |
#### Connection
| Default Key | Command | Description |
| --- | --- | --- |
| n | NewConnection | Create a new database connection |
| c | Connect | Connect to database |
| Enter | Connect | Connect to database |
| e | EditConnection | Edit a database connection |
| d | DeleteConnection | Delete a database connection |
| q | Quit | Quit |
#### Tree
| Default Key | Command | Description |
| --- | --- | --- |
| g | GotoTop | Go to top |
| G | GotoBottom | Go to bottom |
| Enter | Execute | Open |
| j | MoveDown | Go down |
| Down | MoveDown | Go down |
| Ctrl-U | PagePrev | Go page up |
| Ctrl-D | PageNext | Go page down |
| k | MoveUp | Go up |
| Up | MoveUp | Go up |
| / | Search | Search |
| n | NextFoundNode | Go to next found node |
| N | PreviousFoundNode | Go to previous found node |
| p | PreviousFoundNode | Go to previous found node |
| P | NextFoundNode | Go to next found node |
| c | TreeCollapseAll | Collapse all |
| e | ExpandAll | Expand all |
| R | Refresh | Refresh tree |
#### Tree Filter
| Default Key | Command | Description |
| --- | --- | --- |
| Esc | UnfocusTreeFilter | Unfocus tree filter |
| Enter | CommitTreeFilter | Commit tree filter search |
#### Table
| Default Key | Command | Description |
| --- | --- | --- |
| / | Search | Search |
| c | Edit | Change cell |
| d | Delete | Delete row |
| w | GotoNext | Go to next cell |
| b | GotoPrev | Go to previous cell |
| $ | GotoEnd | Go to last cell |
| 0 | GotoStart | Go to first cell |
| y | Copy | Copy cell value to clipboard |
| o | AppendNewRow | Append new row |
| O | DuplicateRow | Duplicate row |
| J | SortDesc | Sort descending |
| R | Refresh | Refresh the current table |
| K | SortAsc | Sort ascending |
| C | SetValue | Toggle value menu (NULL, EMPTY, DEFAULT) |
| [ | TabPrev | Switch to previous tab |
| ] | TabNext | Switch to next tab |
| { | TabFirst | Switch to first tab |
| } | TabLast | Switch to last tab |
| X | TabClose | Close tab |
| > | PageNext | Switch to next page |
| < | PagePrev | Switch to previous page |
| 1 | RecordsMenu | Switch to records menu |
| 2 | ColumnsMenu | Switch to columns menu |
| 3 | ConstraintsMenu | Switch to constraints menu |
| 4 | ForeignKeysMenu | Switch to foreign keys menu |
| 5 | IndexesMenu | Switch to indexes menu |
| S | ToggleSidebar | Toggle sidebar |
| s | FocusSidebar | Focus sidebar |
| Z | ShowRowJSONViewer | Toggle JSON viewer for row |
| z | ShowCellJSONViewer | Toggle JSON viewer for cell |
| E | ExportCSV | Export to CSV |
#### Editor
| Default Key | Command | Description |
| --- | --- | --- |
| Ctrl-R | Execute | Execute query |
| Esc | UnfocusEditor | Unfocus editor |
| Ctrl-Space | OpenInExternalEditor | Open in external editor |
Specific editor for lazysql can be set by `$SQL_EDITOR`.
Specific terminal for opening editor can be set by `$SQL_TERMINAL`
#### Sidebar
| Default Key | Command | Description |
| --- | --- | --- |
| s | UnfocusSidebar | Focus table |
| S | ToggleSidebar | Toggle sidebar |
| j | MoveDown | Focus next field |
| k | MoveUp | Focus previous field |
| g | GotoStart | Focus first field |
| G | GotoEnd | Focus last field |
| c | Edit | Edit field |
| Enter | CommitEdit | Add edit to pending changes |
| Esc | DiscardEdit | Discard edit |
| C | SetValue | Toggle value menu (NULL, EMPTY, DEFAULT) |
| y | Copy | Copy value to clipboard |
#### Query Preview
| Default Key | Command | Description |
| --- | --- | --- |
| Ctrl-S | Save | Execute queries |
| q | Quit | Quit |
| y | Copy | Copy query to clipboard |
| d | Delete | Delete query |
#### Query History
| Default Key | Command | Description |
| --- | --- | --- |
| s | Save | Save query |
| d | Delete | Delete query |
| q | Quit | Quit |
| y | Copy | Copy query to clipboard |
| / | Search | Search |
| Ctrl-_ | ToggleQueryHistory | Toggle query history modal |
| [ | TabPrev | Switch to previous tab |
| ] | TabNext | Switch to next tab |
#### JSON Viewer
| Default Key | Command | Description |
| --- | --- | --- |
| Z | ShowRowJSONViewer | Toggle JSON viewer |
| z | ShowCellJSONViewer | Toggle JSON viewer |
| y | Copy | Copy value to clipboard |
| w | ToggleJSONViewerWrap | Toggle word wrap |
## Example connection URLs
```
@@ -448,7 +570,7 @@ odbc+postgres://user:pass@localhost:port/dbname?option1=
- [ ] Support for NoSQL databases
- [ ] Columns and indexes creation through TUI
- [x] Table tree input filter
- [ ] Custom keybindings
- [x] Custom keybindings
- [x] Show keybindings on a modal
- [x] Rewrite row `create`, `update` and `delete` logic
+5
View File
@@ -17,6 +17,7 @@ type Config struct {
ConfigFile string
AppConfig *models.AppConfig `toml:"application"`
Connections []models.Connection `toml:"database"`
Keymaps models.KeymapConfig `toml:"keymap"`
}
func defaultConfig() *Config {
@@ -73,6 +74,10 @@ func LoadConfig(configFile string) error {
App.config.Connections[i].URL = parseConfigURL(&conn)
}
if err := ApplyKeymapConfig(App.config.Keymaps); err != nil {
return err
}
return nil
}
+72
View File
@@ -1,10 +1,14 @@
package app
import (
"fmt"
"strings"
"github.com/gdamore/tcell/v2"
cmd "github.com/jorgerojas26/lazysql/commands"
"github.com/jorgerojas26/lazysql/keymap"
"github.com/jorgerojas26/lazysql/models"
)
// local alias added for clarity purpose
@@ -176,3 +180,71 @@ var Keymaps = KeymapSystem{
},
},
}
var keyNameToCode = func() map[string]tcell.Key {
keys := make(map[string]tcell.Key, len(tcell.KeyNames))
for code, name := range tcell.KeyNames {
keys[name] = code
}
return keys
}()
func parseKeyString(s string) (Key, error) {
runes := []rune(s)
if len(runes) == 1 {
return Key{Char: runes[0]}, nil
}
if code, ok := keyNameToCode[s]; ok {
return Key{Code: code}, nil
}
return Key{}, fmt.Errorf("unknown key: %s", s)
}
func setBindings(bindings map[string]string, group Map, groupName string) (Map, error) {
for cmdName, keyStr := range bindings {
key, err := parseKeyString(keyStr)
if err != nil {
return nil, fmt.Errorf("invalid key %q for command %s in group %s: %w", keyStr, cmdName, groupName, err)
}
found := false
for index, bind := range group {
if bind.Cmd.String() == cmdName {
group[index].Key = key
found = true
break
}
}
if !found {
return nil, fmt.Errorf("command %s not found in group %s", cmdName, groupName)
}
}
return group, nil
}
func ApplyKeymapConfig(keymaps models.KeymapConfig) error {
if len(keymaps) == 0 {
return nil
}
for groupName, bindings := range keymaps {
groupKey := strings.ToLower(groupName)
group, ok := Keymaps.Groups[groupKey]
if !ok {
return fmt.Errorf("unknown keymap group: %s", groupName)
}
updated, err := setBindings(bindings, group, groupName)
if err != nil {
return err
}
Keymaps.Groups[groupKey] = updated
}
return nil
}
+350
View File
@@ -0,0 +1,350 @@
package app
import (
"testing"
"github.com/gdamore/tcell/v2"
cmd "github.com/jorgerojas26/lazysql/commands"
"github.com/jorgerojas26/lazysql/models"
)
func TestParseKeyString(t *testing.T) {
tests := []struct {
name string
input string
want Key
wantErr bool
}{
{
name: "single lowercase char",
input: "q",
want: Key{Char: 'q'},
},
{
name: "single uppercase char",
input: "G",
want: Key{Char: 'G'},
},
{
name: "single digit",
input: "1",
want: Key{Char: '1'},
},
{
name: "special char slash",
input: "/",
want: Key{Char: '/'},
},
{
name: "special key Ctrl+S",
input: "Ctrl-S",
want: Key{Code: tcell.KeyCtrlS},
},
{
name: "special key Enter",
input: "Enter",
want: Key{Code: tcell.KeyEnter},
},
{
name: "special key Esc",
input: "Esc",
want: Key{Code: tcell.KeyEscape},
},
{
name: "unknown key returns error",
input: "NonExistentKey",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseKeyString(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("parseKeyString(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
if !tt.wantErr && got != tt.want {
t.Errorf("parseKeyString(%q) = %+v, want %+v", tt.input, got, tt.want)
}
})
}
}
func TestSetBindings(t *testing.T) {
t.Run("rebinds existing command", func(t *testing.T) {
group := Map{
{Key: Key{Char: 'q'}, Cmd: cmd.Quit},
{Key: Key{Char: 'j'}, Cmd: cmd.MoveDown},
}
bindings := map[string]string{
"Quit": "x",
}
updated, err := setBindings(bindings, group, "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated[0].Key.Char != 'x' {
t.Errorf("expected Quit to be rebound to 'x', got %+v", updated[0].Key)
}
if updated[1].Key.Char != 'j' {
t.Errorf("expected MoveDown to remain 'j', got %+v", updated[1].Key)
}
})
t.Run("rebinds to special key", func(t *testing.T) {
group := Map{
{Key: Key{Char: 'q'}, Cmd: cmd.Quit},
}
bindings := map[string]string{
"Quit": "Esc",
}
updated, err := setBindings(bindings, group, "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated[0].Key.Code != tcell.KeyEscape {
t.Errorf("expected Quit to be rebound to Esc, got %+v", updated[0].Key)
}
})
t.Run("unknown command returns error", func(t *testing.T) {
group := Map{
{Key: Key{Char: 'q'}, Cmd: cmd.Quit},
}
bindings := map[string]string{
"FakeCommand": "x",
}
_, err := setBindings(bindings, group, "test")
if err == nil {
t.Fatal("expected error for unknown command, got nil")
}
})
t.Run("invalid key returns error", func(t *testing.T) {
group := Map{
{Key: Key{Char: 'q'}, Cmd: cmd.Quit},
}
bindings := map[string]string{
"Quit": "BadKey",
}
_, err := setBindings(bindings, group, "test")
if err == nil {
t.Fatal("expected error for invalid key, got nil")
}
})
t.Run("multiple bindings", func(t *testing.T) {
group := Map{
{Key: Key{Char: 'q'}, Cmd: cmd.Quit},
{Key: Key{Char: 'j'}, Cmd: cmd.MoveDown},
{Key: Key{Char: 'k'}, Cmd: cmd.MoveUp},
}
bindings := map[string]string{
"Quit": "x",
"MoveUp": "p",
}
updated, err := setBindings(bindings, group, "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated[0].Key.Char != 'x' {
t.Errorf("expected Quit rebound to 'x', got %+v", updated[0].Key)
}
if updated[1].Key.Char != 'j' {
t.Errorf("expected MoveDown unchanged at 'j', got %+v", updated[1].Key)
}
if updated[2].Key.Char != 'p' {
t.Errorf("expected MoveUp rebound to 'p', got %+v", updated[2].Key)
}
})
}
func saveKeymaps() map[string]Map {
saved := make(map[string]Map, len(Keymaps.Groups))
for k, v := range Keymaps.Groups {
cp := make(Map, len(v))
copy(cp, v)
saved[k] = cp
}
return saved
}
func restoreKeymaps(saved map[string]Map) {
for k, v := range saved {
Keymaps.Groups[k] = v
}
}
func TestApplyKeymapConfig(t *testing.T) {
t.Run("nil config is no-op", func(t *testing.T) {
err := ApplyKeymapConfig(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("empty config is no-op", func(t *testing.T) {
err := ApplyKeymapConfig(models.KeymapConfig{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("valid config rebinds key", func(t *testing.T) {
saved := saveKeymaps()
defer restoreKeymaps(saved)
cfg := models.KeymapConfig{
"home": {"Quit": "x"},
}
err := ApplyKeymapConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
group := Keymaps.Groups[HomeGroup]
for _, bind := range group {
if bind.Cmd == cmd.Quit {
if bind.Key.Char != 'x' {
t.Errorf("expected Quit rebound to 'x', got %+v", bind.Key)
}
return
}
}
t.Error("Quit command not found in home group")
})
t.Run("case insensitive group name", func(t *testing.T) {
saved := saveKeymaps()
defer restoreKeymaps(saved)
cfg := models.KeymapConfig{
"Home": {"Quit": "x"},
}
err := ApplyKeymapConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
group := Keymaps.Groups[HomeGroup]
for _, bind := range group {
if bind.Cmd == cmd.Quit {
if bind.Key.Char != 'x' {
t.Errorf("expected Quit rebound to 'x', got %+v", bind.Key)
}
return
}
}
t.Error("Quit command not found in home group")
})
t.Run("unknown group returns error", func(t *testing.T) {
cfg := models.KeymapConfig{
"nonexistent": {"Quit": "x"},
}
err := ApplyKeymapConfig(cfg)
if err == nil {
t.Fatal("expected error for unknown group, got nil")
}
})
t.Run("unknown command in group returns error", func(t *testing.T) {
cfg := models.KeymapConfig{
"home": {"FakeCommand": "x"},
}
err := ApplyKeymapConfig(cfg)
if err == nil {
t.Fatal("expected error for unknown command, got nil")
}
})
t.Run("invalid key string returns error", func(t *testing.T) {
cfg := models.KeymapConfig{
"home": {"Quit": "SuperBadKey"},
}
err := ApplyKeymapConfig(cfg)
if err == nil {
t.Fatal("expected error for invalid key, got nil")
}
})
t.Run("rebinds special key in table group", func(t *testing.T) {
saved := saveKeymaps()
defer restoreKeymaps(saved)
cfg := models.KeymapConfig{
"table": {"Search": "Ctrl-F"},
}
err := ApplyKeymapConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
group := Keymaps.Groups[TableGroup]
for _, bind := range group {
if bind.Cmd == cmd.Search {
if bind.Key.Code != tcell.KeyCtrlF {
t.Errorf("expected Search rebound to Ctrl-F, got %+v", bind.Key)
}
return
}
}
t.Error("Search command not found in table group")
})
t.Run("multiple groups and bindings", func(t *testing.T) {
saved := saveKeymaps()
defer restoreKeymaps(saved)
cfg := models.KeymapConfig{
"home": {"Quit": "x"},
"tree": {"GotoTop": "t"},
}
err := ApplyKeymapConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
homeGroup := Keymaps.Groups[HomeGroup]
for _, bind := range homeGroup {
if bind.Cmd == cmd.Quit {
if bind.Key.Char != 'x' {
t.Errorf("home: expected Quit rebound to 'x', got %+v", bind.Key)
}
break
}
}
treeGroup := Keymaps.Groups[TreeGroup]
for _, bind := range treeGroup {
if bind.Cmd == cmd.GotoTop {
if bind.Key.Char != 't' {
t.Errorf("tree: expected GotoTop rebound to 't', got %+v", bind.Key)
}
break
}
}
})
}
+2
View File
@@ -39,6 +39,8 @@ type Connection struct {
Commands []*Command
}
type KeymapConfig map[string]map[string]string
type Command struct {
Command string
WaitForPort string