mirror of
https://github.com/taigrr/teaqlite.git
synced 2026-04-02 04:59:03 -07:00
update layout
This commit is contained in:
2
go.mod
2
go.mod
@@ -3,6 +3,7 @@ module github.com/taigrr/teaqlite
|
|||||||
go 1.24.5
|
go 1.24.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.6
|
github.com/charmbracelet/bubbletea v1.3.6
|
||||||
github.com/charmbracelet/fang v0.3.0
|
github.com/charmbracelet/fang v0.3.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
@@ -11,6 +12,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.3.1 // indirect
|
github.com/charmbracelet/colorprofile v0.3.1 // indirect
|
||||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 // indirect
|
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 // indirect
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -1,7 +1,11 @@
|
|||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||||
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
|
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
|
||||||
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
||||||
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
|
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
|
||||||
@@ -20,8 +24,8 @@ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250711012602-b1f986320f7e h1:s
|
|||||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250711012602-b1f986320f7e/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
|
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250711012602-b1f986320f7e/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
|
||||||
github.com/charmbracelet/x/exp/color v0.0.0-20250711012602-b1f986320f7e h1:D0tltuLCSvxMznOpQg7f3MArp8ImU0zALbakI47ffkw=
|
github.com/charmbracelet/x/exp/color v0.0.0-20250711012602-b1f986320f7e h1:D0tltuLCSvxMznOpQg7f3MArp8ImU0zALbakI47ffkw=
|
||||||
github.com/charmbracelet/x/exp/color v0.0.0-20250711012602-b1f986320f7e/go.mod h1:hk/GyTELmEgX54pBAOHcFvH8Xed53JWo/g8kJXFo/PI=
|
github.com/charmbracelet/x/exp/color v0.0.0-20250711012602-b1f986320f7e/go.mod h1:hk/GyTELmEgX54pBAOHcFvH8Xed53JWo/g8kJXFo/PI=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
_ "modernc.org/sqlite" // Import SQLite driver
|
_ "modernc.org/sqlite" // Import SQLite driver
|
||||||
)
|
)
|
||||||
@@ -15,6 +17,35 @@ const (
|
|||||||
PageSize = 20
|
PageSize = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var lastID int64
|
||||||
|
|
||||||
|
func nextID() int {
|
||||||
|
return int(atomic.AddInt64(&lastID, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common message types
|
||||||
|
type blinkMsg struct{}
|
||||||
|
|
||||||
|
// KeyMap defines the keybindings for the application
|
||||||
|
type AppKeyMap struct {
|
||||||
|
Quit key.Binding
|
||||||
|
Suspend key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultAppKeyMap returns the default keybindings
|
||||||
|
func DefaultAppKeyMap() AppKeyMap {
|
||||||
|
return AppKeyMap{
|
||||||
|
Quit: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+c"),
|
||||||
|
key.WithHelp("ctrl+c", "quit"),
|
||||||
|
),
|
||||||
|
Suspend: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+z"),
|
||||||
|
key.WithHelp("ctrl+z", "suspend"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Custom message types
|
// Custom message types
|
||||||
type (
|
type (
|
||||||
SwitchToTableListMsg struct{}
|
SwitchToTableListMsg struct{}
|
||||||
@@ -45,6 +76,26 @@ type Model struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
err error
|
err error
|
||||||
|
keyMap AppKeyMap
|
||||||
|
focused bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option is a functional option for configuring the Model
|
||||||
|
type Option func(*Model)
|
||||||
|
|
||||||
|
// WithKeyMap sets the key map for the application
|
||||||
|
func WithKeyMap(km AppKeyMap) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.keyMap = km
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDimensions sets the initial dimensions
|
||||||
|
func WithDimensions(width, height int) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.width = width
|
||||||
|
m.height = height
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SharedData that all models need access to
|
// SharedData that all models need access to
|
||||||
@@ -478,18 +529,42 @@ func Max(a, b int) int {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitialModel(db *sql.DB) *Model {
|
func InitialModel(db *sql.DB, opts ...Option) *Model {
|
||||||
shared := NewSharedData(db)
|
shared := NewSharedData(db)
|
||||||
if err := shared.LoadTables(); err != nil {
|
if err := shared.LoadTables(); err != nil {
|
||||||
return &Model{err: err}
|
return &Model{err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Model{
|
m := &Model{
|
||||||
db: db,
|
db: db,
|
||||||
currentView: NewTableListModel(shared),
|
currentView: NewTableListModel(shared),
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 24,
|
height: 24,
|
||||||
|
keyMap: DefaultAppKeyMap(),
|
||||||
|
focused: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply options
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus sets the focus state of the application
|
||||||
|
func (m *Model) Focus() {
|
||||||
|
m.focused = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur removes focus from the application
|
||||||
|
func (m *Model) Blur() {
|
||||||
|
m.focused = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focused returns the focus state
|
||||||
|
func (m Model) Focused() bool {
|
||||||
|
return m.focused
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) Init() tea.Cmd {
|
func (m *Model) Init() tea.Cmd {
|
||||||
@@ -497,6 +572,10 @@ func (m *Model) Init() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if !m.focused {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
@@ -509,10 +588,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
// Add similar updates for other model types as needed
|
// Add similar updates for other model types as needed
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if msg.String() == "ctrl+c" {
|
switch {
|
||||||
|
case key.Matches(msg, m.keyMap.Quit):
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
case key.Matches(msg, m.keyMap.Suspend):
|
||||||
if msg.String() == "ctrl+z" {
|
|
||||||
return m, tea.Suspend
|
return m, tea.Suspend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,21 +3,34 @@ package app
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/bubbles/help"
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EditCellModel struct {
|
type EditCellModel struct {
|
||||||
Shared *SharedData
|
Shared *SharedData
|
||||||
rowIndex int
|
rowIndex int
|
||||||
colIndex int
|
colIndex int
|
||||||
value string
|
input textinput.Model
|
||||||
cursor int
|
|
||||||
blinkState bool
|
blinkState bool
|
||||||
|
keyMap EditCellKeyMap
|
||||||
|
help help.Model
|
||||||
|
focused bool
|
||||||
|
id int
|
||||||
}
|
}
|
||||||
|
|
||||||
type blinkMsg struct{}
|
// EditCellOption is a functional option for configuring EditCellModel
|
||||||
|
type EditCellOption func(*EditCellModel)
|
||||||
|
|
||||||
|
// WithEditCellKeyMap sets the key map
|
||||||
|
func WithEditCellKeyMap(km EditCellKeyMap) EditCellOption {
|
||||||
|
return func(m *EditCellModel) {
|
||||||
|
m.keyMap = km
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func blinkCmd() tea.Cmd {
|
func blinkCmd() tea.Cmd {
|
||||||
return tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg {
|
return tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg {
|
||||||
@@ -25,85 +38,104 @@ func blinkCmd() tea.Cmd {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEditCellModel(shared *SharedData, rowIndex, colIndex int) *EditCellModel {
|
func NewEditCellModel(shared *SharedData, rowIndex, colIndex int, opts ...EditCellOption) *EditCellModel {
|
||||||
value := ""
|
value := ""
|
||||||
if rowIndex < len(shared.FilteredData) && colIndex < len(shared.FilteredData[rowIndex]) {
|
if rowIndex < len(shared.FilteredData) && colIndex < len(shared.FilteredData[rowIndex]) {
|
||||||
value = shared.FilteredData[rowIndex][colIndex]
|
value = shared.FilteredData[rowIndex][colIndex]
|
||||||
}
|
}
|
||||||
|
|
||||||
return &EditCellModel{
|
input := textinput.New()
|
||||||
|
input.SetValue(value)
|
||||||
|
input.Width = 50
|
||||||
|
input.Focus()
|
||||||
|
|
||||||
|
m := &EditCellModel{
|
||||||
Shared: shared,
|
Shared: shared,
|
||||||
rowIndex: rowIndex,
|
rowIndex: rowIndex,
|
||||||
colIndex: colIndex,
|
colIndex: colIndex,
|
||||||
value: value,
|
input: input,
|
||||||
cursor: len(value),
|
|
||||||
blinkState: true,
|
blinkState: true,
|
||||||
|
keyMap: DefaultEditCellKeyMap(),
|
||||||
|
help: help.New(),
|
||||||
|
focused: true,
|
||||||
|
id: nextID(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply options
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID returns the unique ID of the model
|
||||||
|
func (m EditCellModel) ID() int {
|
||||||
|
return m.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus sets the focus state
|
||||||
|
func (m *EditCellModel) Focus() {
|
||||||
|
m.focused = true
|
||||||
|
m.input.Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur removes focus
|
||||||
|
func (m *EditCellModel) Blur() {
|
||||||
|
m.focused = false
|
||||||
|
m.input.Blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focused returns the focus state
|
||||||
|
func (m EditCellModel) Focused() bool {
|
||||||
|
return m.focused
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *EditCellModel) Init() tea.Cmd {
|
func (m *EditCellModel) Init() tea.Cmd {
|
||||||
return blinkCmd()
|
return tea.Batch(
|
||||||
|
textinput.Blink,
|
||||||
|
blinkCmd(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *EditCellModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *EditCellModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if !m.focused {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case blinkMsg:
|
case blinkMsg:
|
||||||
m.blinkState = !m.blinkState
|
m.blinkState = !m.blinkState
|
||||||
return m, blinkCmd()
|
cmds = append(cmds, blinkCmd())
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.String() {
|
switch {
|
||||||
case "esc":
|
case key.Matches(msg, m.keyMap.Save):
|
||||||
return m, func() tea.Msg { return SwitchToRowDetailMsg{RowIndex: m.rowIndex} }
|
|
||||||
|
|
||||||
case "enter":
|
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
return UpdateCellMsg{
|
return UpdateCellMsg{
|
||||||
RowIndex: m.rowIndex,
|
RowIndex: m.rowIndex,
|
||||||
ColIndex: m.colIndex,
|
ColIndex: m.colIndex,
|
||||||
Value: m.value,
|
Value: m.input.Value(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "backspace":
|
case key.Matches(msg, m.keyMap.Cancel):
|
||||||
if m.cursor > 0 {
|
return m, func() tea.Msg {
|
||||||
m.value = m.value[:m.cursor-1] + m.value[m.cursor:]
|
return SwitchToRowDetailMsg{RowIndex: m.rowIndex}
|
||||||
m.cursor--
|
|
||||||
}
|
|
||||||
|
|
||||||
case "left":
|
|
||||||
if m.cursor > 0 {
|
|
||||||
m.cursor--
|
|
||||||
}
|
|
||||||
|
|
||||||
case "right":
|
|
||||||
if m.cursor < len(m.value) {
|
|
||||||
m.cursor++
|
|
||||||
}
|
|
||||||
|
|
||||||
case "home", "ctrl+a":
|
|
||||||
m.cursor = 0
|
|
||||||
|
|
||||||
case "end", "ctrl+e":
|
|
||||||
m.cursor = len(m.value)
|
|
||||||
|
|
||||||
case "ctrl+left":
|
|
||||||
m.cursor = m.wordLeft(m.value, m.cursor)
|
|
||||||
|
|
||||||
case "ctrl+right":
|
|
||||||
m.cursor = m.wordRight(m.value, m.cursor)
|
|
||||||
|
|
||||||
case "ctrl+w":
|
|
||||||
m.deleteWordLeft()
|
|
||||||
|
|
||||||
default:
|
|
||||||
if len(msg.String()) == 1 {
|
|
||||||
m.value = m.value[:m.cursor] + msg.String() + m.value[m.cursor:]
|
|
||||||
m.cursor++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
|
||||||
|
// Update the input for all other messages
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.input, cmd = m.input.Update(msg)
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *EditCellModel) View() string {
|
func (m *EditCellModel) View() string {
|
||||||
@@ -112,89 +144,9 @@ func (m *EditCellModel) View() string {
|
|||||||
columnName = m.Shared.Columns[m.colIndex]
|
columnName = m.Shared.Columns[m.colIndex]
|
||||||
}
|
}
|
||||||
|
|
||||||
content := TitleStyle.Render(fmt.Sprintf("Edit Cell: %s", columnName)) + "\n\n"
|
content := fmt.Sprintf("%s\n\n", TitleStyle.Render(fmt.Sprintf("Edit Cell: %s", columnName)))
|
||||||
|
content += fmt.Sprintf("Value: %s\n\n", m.input.View())
|
||||||
// Display value with properly positioned cursor like bubbles textinput
|
content += m.help.View(m.keyMap)
|
||||||
content += "Value: "
|
|
||||||
value := m.value
|
|
||||||
pos := m.cursor
|
|
||||||
|
|
||||||
// Text before cursor
|
|
||||||
if pos > 0 {
|
|
||||||
content += value[:pos]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cursor and character at cursor position
|
|
||||||
if pos < len(value) {
|
|
||||||
// Cursor over existing character
|
|
||||||
char := string(value[pos])
|
|
||||||
if m.blinkState {
|
|
||||||
content += SelectedStyle.Render(char) // Highlight the character
|
|
||||||
} else {
|
|
||||||
content += char
|
|
||||||
}
|
|
||||||
// Text after cursor
|
|
||||||
if pos+1 < len(value) {
|
|
||||||
content += value[pos+1:]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Cursor at end of text
|
|
||||||
if m.blinkState {
|
|
||||||
content += "|"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
content += "\n\n"
|
|
||||||
content += HelpStyle.Render("enter: save • esc: cancel • ctrl+w: delete word • ctrl+arrows: word nav")
|
|
||||||
|
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
// wordLeft finds the position of the start of the word to the left of the cursor
|
|
||||||
func (m *EditCellModel) wordLeft(text string, pos int) int {
|
|
||||||
if pos == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move left past any whitespace
|
|
||||||
for pos > 0 && unicode.IsSpace(rune(text[pos-1])) {
|
|
||||||
pos--
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move left past the current word
|
|
||||||
for pos > 0 && !unicode.IsSpace(rune(text[pos-1])) {
|
|
||||||
pos--
|
|
||||||
}
|
|
||||||
|
|
||||||
return pos
|
|
||||||
}
|
|
||||||
|
|
||||||
// wordRight finds the position of the start of the word to the right of the cursor
|
|
||||||
func (m *EditCellModel) wordRight(text string, pos int) int {
|
|
||||||
if pos >= len(text) {
|
|
||||||
return len(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move right past the current word
|
|
||||||
for pos < len(text) && !unicode.IsSpace(rune(text[pos])) {
|
|
||||||
pos++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move right past any whitespace
|
|
||||||
for pos < len(text) && unicode.IsSpace(rune(text[pos])) {
|
|
||||||
pos++
|
|
||||||
}
|
|
||||||
|
|
||||||
return pos
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteWordLeft deletes the word to the left of the cursor
|
|
||||||
func (m *EditCellModel) deleteWordLeft() {
|
|
||||||
if m.cursor == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newPos := m.wordLeft(m.value, m.cursor)
|
|
||||||
m.value = m.value[:newPos] + m.value[m.cursor:]
|
|
||||||
m.cursor = newPos
|
|
||||||
}
|
|
||||||
|
|||||||
77
internal/app/edit_cell_keys.go
Normal file
77
internal/app/edit_cell_keys.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/bubbles/key"
|
||||||
|
|
||||||
|
// EditCellKeyMap defines keybindings for the edit cell view
|
||||||
|
type EditCellKeyMap struct {
|
||||||
|
Save key.Binding
|
||||||
|
Cancel key.Binding
|
||||||
|
CursorLeft key.Binding
|
||||||
|
CursorRight key.Binding
|
||||||
|
WordLeft key.Binding
|
||||||
|
WordRight key.Binding
|
||||||
|
LineStart key.Binding
|
||||||
|
LineEnd key.Binding
|
||||||
|
DeleteWord key.Binding
|
||||||
|
DeleteChar key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultEditCellKeyMap returns the default keybindings for edit cell
|
||||||
|
func DefaultEditCellKeyMap() EditCellKeyMap {
|
||||||
|
return EditCellKeyMap{
|
||||||
|
Save: key.NewBinding(
|
||||||
|
key.WithKeys("enter"),
|
||||||
|
key.WithHelp("enter", "save"),
|
||||||
|
),
|
||||||
|
Cancel: key.NewBinding(
|
||||||
|
key.WithKeys("esc"),
|
||||||
|
key.WithHelp("esc", "cancel"),
|
||||||
|
),
|
||||||
|
CursorLeft: key.NewBinding(
|
||||||
|
key.WithKeys("left"),
|
||||||
|
key.WithHelp("←", "cursor left"),
|
||||||
|
),
|
||||||
|
CursorRight: key.NewBinding(
|
||||||
|
key.WithKeys("right"),
|
||||||
|
key.WithHelp("→", "cursor right"),
|
||||||
|
),
|
||||||
|
WordLeft: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+left"),
|
||||||
|
key.WithHelp("ctrl+←", "word left"),
|
||||||
|
),
|
||||||
|
WordRight: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+right"),
|
||||||
|
key.WithHelp("ctrl+→", "word right"),
|
||||||
|
),
|
||||||
|
LineStart: key.NewBinding(
|
||||||
|
key.WithKeys("home", "ctrl+a"),
|
||||||
|
key.WithHelp("home/ctrl+a", "line start"),
|
||||||
|
),
|
||||||
|
LineEnd: key.NewBinding(
|
||||||
|
key.WithKeys("end", "ctrl+e"),
|
||||||
|
key.WithHelp("end/ctrl+e", "line end"),
|
||||||
|
),
|
||||||
|
DeleteWord: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+w"),
|
||||||
|
key.WithHelp("ctrl+w", "delete word"),
|
||||||
|
),
|
||||||
|
DeleteChar: key.NewBinding(
|
||||||
|
key.WithKeys("backspace"),
|
||||||
|
key.WithHelp("backspace", "delete char"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortHelp returns keybindings to be shown in the mini help view
|
||||||
|
func (k EditCellKeyMap) ShortHelp() []key.Binding {
|
||||||
|
return []key.Binding{k.Save, k.Cancel}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullHelp returns keybindings for the expanded help view
|
||||||
|
func (k EditCellKeyMap) FullHelp() [][]key.Binding {
|
||||||
|
return [][]key.Binding{
|
||||||
|
{k.Save, k.Cancel},
|
||||||
|
{k.CursorLeft, k.CursorRight, k.WordLeft, k.WordRight},
|
||||||
|
{k.LineStart, k.LineEnd, k.DeleteWord, k.DeleteChar},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,15 +4,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/bubbles/help"
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/textarea"
|
||||||
)
|
)
|
||||||
|
|
||||||
type QueryModel struct {
|
type QueryModel struct {
|
||||||
Shared *SharedData
|
Shared *SharedData
|
||||||
query string
|
queryInput textarea.Model
|
||||||
cursor int
|
|
||||||
FocusOnInput bool
|
FocusOnInput bool
|
||||||
selectedRow int
|
selectedRow int
|
||||||
results [][]string
|
results [][]string
|
||||||
@@ -20,30 +21,95 @@ type QueryModel struct {
|
|||||||
err error
|
err error
|
||||||
blinkState bool
|
blinkState bool
|
||||||
gPressed bool
|
gPressed bool
|
||||||
|
keyMap QueryKeyMap
|
||||||
|
help help.Model
|
||||||
|
focused bool
|
||||||
|
id int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQueryModel(shared *SharedData) *QueryModel {
|
// QueryOption is a functional option for configuring QueryModel
|
||||||
return &QueryModel{
|
type QueryOption func(*QueryModel)
|
||||||
Shared: shared,
|
|
||||||
FocusOnInput: true,
|
// WithQueryKeyMap sets the key map
|
||||||
selectedRow: 0,
|
func WithQueryKeyMap(km QueryKeyMap) QueryOption {
|
||||||
blinkState: true,
|
return func(m *QueryModel) {
|
||||||
|
m.keyMap = km
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewQueryModel(shared *SharedData, opts ...QueryOption) *QueryModel {
|
||||||
|
queryInput := textarea.New()
|
||||||
|
queryInput.Placeholder = "Enter SQL query..."
|
||||||
|
queryInput.SetWidth(60)
|
||||||
|
queryInput.SetHeight(3)
|
||||||
|
queryInput.Focus()
|
||||||
|
|
||||||
|
m := &QueryModel{
|
||||||
|
Shared: shared,
|
||||||
|
queryInput: queryInput,
|
||||||
|
FocusOnInput: true,
|
||||||
|
selectedRow: 0,
|
||||||
|
blinkState: true,
|
||||||
|
keyMap: DefaultQueryKeyMap(),
|
||||||
|
help: help.New(),
|
||||||
|
focused: true,
|
||||||
|
id: nextID(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply options
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID returns the unique ID of the model
|
||||||
|
func (m QueryModel) ID() int {
|
||||||
|
return m.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus sets the focus state
|
||||||
|
func (m *QueryModel) Focus() {
|
||||||
|
m.focused = true
|
||||||
|
if m.FocusOnInput {
|
||||||
|
m.queryInput.Focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur removes focus
|
||||||
|
func (m *QueryModel) Blur() {
|
||||||
|
m.focused = false
|
||||||
|
m.queryInput.Blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focused returns the focus state
|
||||||
|
func (m QueryModel) Focused() bool {
|
||||||
|
return m.focused
|
||||||
|
}
|
||||||
|
|
||||||
func (m *QueryModel) Init() tea.Cmd {
|
func (m *QueryModel) Init() tea.Cmd {
|
||||||
return tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg {
|
return tea.Batch(
|
||||||
return blinkMsg{}
|
textarea.Blink,
|
||||||
})
|
tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg {
|
||||||
|
return blinkMsg{}
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if !m.focused {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case blinkMsg:
|
case blinkMsg:
|
||||||
m.blinkState = !m.blinkState
|
m.blinkState = !m.blinkState
|
||||||
return m, tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg {
|
cmds = append(cmds, tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg {
|
||||||
return blinkMsg{}
|
return blinkMsg{}
|
||||||
})
|
}))
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if m.FocusOnInput {
|
if m.FocusOnInput {
|
||||||
@@ -51,66 +117,44 @@ func (m *QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m.handleResultsNavigation(msg)
|
return m.handleResultsNavigation(msg)
|
||||||
}
|
}
|
||||||
return m, nil
|
|
||||||
|
// Update query input for non-key messages when focused on input
|
||||||
|
if m.FocusOnInput {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.queryInput, cmd = m.queryInput.Update(msg)
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *QueryModel) handleQueryInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *QueryModel) handleQueryInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch {
|
||||||
case "esc":
|
case key.Matches(msg, m.keyMap.Escape):
|
||||||
return m, func() tea.Msg { return SwitchToTableListClearMsg{} }
|
return m, func() tea.Msg { return SwitchToTableListClearMsg{} }
|
||||||
|
|
||||||
case "enter":
|
case key.Matches(msg, m.keyMap.Execute):
|
||||||
if strings.TrimSpace(m.query) != "" {
|
if strings.TrimSpace(m.queryInput.Value()) != "" {
|
||||||
return m, m.executeQuery()
|
return m, m.executeQuery()
|
||||||
}
|
}
|
||||||
|
|
||||||
case "backspace":
|
|
||||||
if m.cursor > 0 {
|
|
||||||
m.query = m.query[:m.cursor-1] + m.query[m.cursor:]
|
|
||||||
m.cursor--
|
|
||||||
}
|
|
||||||
|
|
||||||
case "left":
|
|
||||||
if m.cursor > 0 {
|
|
||||||
m.cursor--
|
|
||||||
}
|
|
||||||
|
|
||||||
case "right":
|
|
||||||
if m.cursor < len(m.query) {
|
|
||||||
m.cursor++
|
|
||||||
}
|
|
||||||
|
|
||||||
case "home", "ctrl+a":
|
|
||||||
m.cursor = 0
|
|
||||||
|
|
||||||
case "end", "ctrl+e":
|
|
||||||
m.cursor = len(m.query)
|
|
||||||
|
|
||||||
case "ctrl+left":
|
|
||||||
m.cursor = m.wordLeft(m.query, m.cursor)
|
|
||||||
|
|
||||||
case "ctrl+right":
|
|
||||||
m.cursor = m.wordRight(m.query, m.cursor)
|
|
||||||
|
|
||||||
case "ctrl+w":
|
|
||||||
m.deleteWordLeft()
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
if len(msg.String()) == 1 {
|
var cmd tea.Cmd
|
||||||
m.query = m.query[:m.cursor] + msg.String() + m.query[m.cursor:]
|
m.queryInput, cmd = m.queryInput.Update(msg)
|
||||||
m.cursor++
|
return m, cmd
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *QueryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *QueryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch {
|
||||||
case "esc", "q":
|
case key.Matches(msg, m.keyMap.Escape), key.Matches(msg, m.keyMap.Back):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
return m, func() tea.Msg { return SwitchToTableListClearMsg{} }
|
return m, func() tea.Msg { return SwitchToTableListClearMsg{} }
|
||||||
|
|
||||||
case "g":
|
case key.Matches(msg, m.keyMap.GoToStart):
|
||||||
if m.gPressed {
|
if m.gPressed {
|
||||||
// Second g - go to beginning
|
// Second g - go to beginning
|
||||||
m.selectedRow = 0
|
m.selectedRow = 0
|
||||||
@@ -121,7 +165,7 @@ func (m *QueryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "G":
|
case key.Matches(msg, m.keyMap.GoToEnd):
|
||||||
// Go to end
|
// Go to end
|
||||||
if len(m.results) > 0 {
|
if len(m.results) > 0 {
|
||||||
m.selectedRow = len(m.results) - 1
|
m.selectedRow = len(m.results) - 1
|
||||||
@@ -129,12 +173,13 @@ func (m *QueryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd
|
|||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "i":
|
case key.Matches(msg, m.keyMap.EditQuery):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
m.FocusOnInput = true
|
m.FocusOnInput = true
|
||||||
|
m.queryInput.Focus()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "enter":
|
case key.Matches(msg, m.keyMap.Enter):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
if len(m.results) > 0 {
|
if len(m.results) > 0 {
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
@@ -142,13 +187,13 @@ func (m *QueryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "up", "k":
|
case key.Matches(msg, m.keyMap.Up):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
if m.selectedRow > 0 {
|
if m.selectedRow > 0 {
|
||||||
m.selectedRow--
|
m.selectedRow--
|
||||||
}
|
}
|
||||||
|
|
||||||
case "down", "j":
|
case key.Matches(msg, m.keyMap.Down):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
if m.selectedRow < len(m.results)-1 {
|
if m.selectedRow < len(m.results)-1 {
|
||||||
m.selectedRow++
|
m.selectedRow++
|
||||||
@@ -273,7 +318,7 @@ func (m *QueryModel) getTablePrimaryKeys(tableName string) []string {
|
|||||||
func (m *QueryModel) executeQuery() tea.Cmd {
|
func (m *QueryModel) executeQuery() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
// Modify query to always include ID columns if it's a SELECT statement
|
// Modify query to always include ID columns if it's a SELECT statement
|
||||||
modifiedQuery := m.ensureIDColumns(m.query)
|
modifiedQuery := m.ensureIDColumns(m.queryInput.Value())
|
||||||
|
|
||||||
rows, err := m.Shared.DB.Query(modifiedQuery)
|
rows, err := m.Shared.DB.Query(modifiedQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -334,59 +379,11 @@ func (m *QueryModel) handleQueryCompletion(msg QueryCompletedMsg) {
|
|||||||
m.Shared.IsQueryResult = true
|
m.Shared.IsQueryResult = true
|
||||||
|
|
||||||
m.FocusOnInput = false
|
m.FocusOnInput = false
|
||||||
|
m.queryInput.Blur()
|
||||||
m.selectedRow = 0
|
m.selectedRow = 0
|
||||||
m.err = nil
|
m.err = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// wordLeft finds the position of the start of the word to the left of the cursor
|
|
||||||
func (m *QueryModel) wordLeft(text string, pos int) int {
|
|
||||||
if pos == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move left past any whitespace
|
|
||||||
for pos > 0 && unicode.IsSpace(rune(text[pos-1])) {
|
|
||||||
pos--
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move left past the current word
|
|
||||||
for pos > 0 && !unicode.IsSpace(rune(text[pos-1])) {
|
|
||||||
pos--
|
|
||||||
}
|
|
||||||
|
|
||||||
return pos
|
|
||||||
}
|
|
||||||
|
|
||||||
// wordRight finds the position of the start of the word to the right of the cursor
|
|
||||||
func (m *QueryModel) wordRight(text string, pos int) int {
|
|
||||||
if pos >= len(text) {
|
|
||||||
return len(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move right past the current word
|
|
||||||
for pos < len(text) && !unicode.IsSpace(rune(text[pos])) {
|
|
||||||
pos++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move right past any whitespace
|
|
||||||
for pos < len(text) && unicode.IsSpace(rune(text[pos])) {
|
|
||||||
pos++
|
|
||||||
}
|
|
||||||
|
|
||||||
return pos
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteWordLeft deletes the word to the left of the cursor
|
|
||||||
func (m *QueryModel) deleteWordLeft() {
|
|
||||||
if m.cursor == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newPos := m.wordLeft(m.query, m.cursor)
|
|
||||||
m.query = m.query[:newPos] + m.query[m.cursor:]
|
|
||||||
m.cursor = newPos
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *QueryModel) View() string {
|
func (m *QueryModel) View() string {
|
||||||
var content strings.Builder
|
var content strings.Builder
|
||||||
|
|
||||||
@@ -394,41 +391,8 @@ func (m *QueryModel) View() string {
|
|||||||
content.WriteString("\n\n")
|
content.WriteString("\n\n")
|
||||||
|
|
||||||
// Query input
|
// Query input
|
||||||
content.WriteString("Query: ")
|
content.WriteString("Query:\n")
|
||||||
if m.FocusOnInput {
|
content.WriteString(m.queryInput.View())
|
||||||
// Display query with properly positioned cursor like bubbles textinput
|
|
||||||
query := m.query
|
|
||||||
pos := m.cursor
|
|
||||||
|
|
||||||
// Text before cursor
|
|
||||||
before := ""
|
|
||||||
if pos > 0 {
|
|
||||||
before = query[:pos]
|
|
||||||
}
|
|
||||||
content.WriteString(before)
|
|
||||||
|
|
||||||
// Cursor and character at cursor position
|
|
||||||
if pos < len(query) {
|
|
||||||
// Cursor over existing character
|
|
||||||
char := string(query[pos])
|
|
||||||
if m.blinkState {
|
|
||||||
content.WriteString(SelectedStyle.Render(char)) // Highlight the character
|
|
||||||
} else {
|
|
||||||
content.WriteString(char)
|
|
||||||
}
|
|
||||||
// Text after cursor
|
|
||||||
if pos+1 < len(query) {
|
|
||||||
content.WriteString(query[pos+1:])
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Cursor at end of text
|
|
||||||
if m.blinkState {
|
|
||||||
content.WriteString("|")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content.WriteString(m.query)
|
|
||||||
}
|
|
||||||
content.WriteString("\n\n")
|
content.WriteString("\n\n")
|
||||||
|
|
||||||
// Error display
|
// Error display
|
||||||
@@ -487,9 +451,9 @@ func (m *QueryModel) View() string {
|
|||||||
|
|
||||||
content.WriteString("\n")
|
content.WriteString("\n")
|
||||||
if m.FocusOnInput {
|
if m.FocusOnInput {
|
||||||
content.WriteString(HelpStyle.Render("enter: execute • esc: back • ctrl+w: delete word • ctrl+arrows: word nav"))
|
content.WriteString(HelpStyle.Render("enter: execute • esc: back"))
|
||||||
} else {
|
} else {
|
||||||
content.WriteString(HelpStyle.Render("↑/↓: navigate • enter: details • i: edit query • gg/G: first/last • q: back"))
|
content.WriteString(m.help.View(m.keyMap))
|
||||||
}
|
}
|
||||||
|
|
||||||
return content.String()
|
return content.String()
|
||||||
|
|||||||
114
internal/app/query_keys.go
Normal file
114
internal/app/query_keys.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/bubbles/key"
|
||||||
|
|
||||||
|
// QueryKeyMap defines keybindings for the query view
|
||||||
|
type QueryKeyMap struct {
|
||||||
|
// Input mode keys
|
||||||
|
Execute key.Binding
|
||||||
|
Escape key.Binding
|
||||||
|
CursorLeft key.Binding
|
||||||
|
CursorRight key.Binding
|
||||||
|
WordLeft key.Binding
|
||||||
|
WordRight key.Binding
|
||||||
|
LineStart key.Binding
|
||||||
|
LineEnd key.Binding
|
||||||
|
DeleteWord key.Binding
|
||||||
|
|
||||||
|
// Results mode keys
|
||||||
|
Up key.Binding
|
||||||
|
Down key.Binding
|
||||||
|
Enter key.Binding
|
||||||
|
EditQuery key.Binding
|
||||||
|
GoToStart key.Binding
|
||||||
|
GoToEnd key.Binding
|
||||||
|
Back key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultQueryKeyMap returns the default keybindings for query view
|
||||||
|
func DefaultQueryKeyMap() QueryKeyMap {
|
||||||
|
return QueryKeyMap{
|
||||||
|
// Input mode
|
||||||
|
Execute: key.NewBinding(
|
||||||
|
key.WithKeys("enter"),
|
||||||
|
key.WithHelp("enter", "execute query"),
|
||||||
|
),
|
||||||
|
Escape: key.NewBinding(
|
||||||
|
key.WithKeys("esc"),
|
||||||
|
key.WithHelp("esc", "back"),
|
||||||
|
),
|
||||||
|
CursorLeft: key.NewBinding(
|
||||||
|
key.WithKeys("left"),
|
||||||
|
key.WithHelp("←", "cursor left"),
|
||||||
|
),
|
||||||
|
CursorRight: key.NewBinding(
|
||||||
|
key.WithKeys("right"),
|
||||||
|
key.WithHelp("→", "cursor right"),
|
||||||
|
),
|
||||||
|
WordLeft: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+left"),
|
||||||
|
key.WithHelp("ctrl+←", "word left"),
|
||||||
|
),
|
||||||
|
WordRight: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+right"),
|
||||||
|
key.WithHelp("ctrl+→", "word right"),
|
||||||
|
),
|
||||||
|
LineStart: key.NewBinding(
|
||||||
|
key.WithKeys("home", "ctrl+a"),
|
||||||
|
key.WithHelp("home/ctrl+a", "line start"),
|
||||||
|
),
|
||||||
|
LineEnd: key.NewBinding(
|
||||||
|
key.WithKeys("end", "ctrl+e"),
|
||||||
|
key.WithHelp("end/ctrl+e", "line end"),
|
||||||
|
),
|
||||||
|
DeleteWord: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+w"),
|
||||||
|
key.WithHelp("ctrl+w", "delete word"),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Results mode
|
||||||
|
Up: key.NewBinding(
|
||||||
|
key.WithKeys("up", "k"),
|
||||||
|
key.WithHelp("↑/k", "up"),
|
||||||
|
),
|
||||||
|
Down: key.NewBinding(
|
||||||
|
key.WithKeys("down", "j"),
|
||||||
|
key.WithHelp("↓/j", "down"),
|
||||||
|
),
|
||||||
|
Enter: key.NewBinding(
|
||||||
|
key.WithKeys("enter"),
|
||||||
|
key.WithHelp("enter", "view details"),
|
||||||
|
),
|
||||||
|
EditQuery: key.NewBinding(
|
||||||
|
key.WithKeys("i"),
|
||||||
|
key.WithHelp("i", "edit query"),
|
||||||
|
),
|
||||||
|
GoToStart: key.NewBinding(
|
||||||
|
key.WithKeys("g"),
|
||||||
|
key.WithHelp("gg", "go to start"),
|
||||||
|
),
|
||||||
|
GoToEnd: key.NewBinding(
|
||||||
|
key.WithKeys("G"),
|
||||||
|
key.WithHelp("G", "go to end"),
|
||||||
|
),
|
||||||
|
Back: key.NewBinding(
|
||||||
|
key.WithKeys("q"),
|
||||||
|
key.WithHelp("q", "back"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortHelp returns keybindings to be shown in the mini help view
|
||||||
|
func (k QueryKeyMap) ShortHelp() []key.Binding {
|
||||||
|
return []key.Binding{k.Execute, k.Up, k.Down, k.Enter}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullHelp returns keybindings for the expanded help view
|
||||||
|
func (k QueryKeyMap) FullHelp() [][]key.Binding {
|
||||||
|
return [][]key.Binding{
|
||||||
|
{k.Execute, k.Escape, k.EditQuery, k.Back},
|
||||||
|
{k.Up, k.Down, k.Enter, k.GoToStart, k.GoToEnd},
|
||||||
|
{k.CursorLeft, k.CursorRight, k.WordLeft, k.WordRight},
|
||||||
|
{k.LineStart, k.LineEnd, k.DeleteWord},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/bubbles/help"
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RowDetailModel struct {
|
type RowDetailModel struct {
|
||||||
@@ -13,14 +15,60 @@ type RowDetailModel struct {
|
|||||||
selectedCol int
|
selectedCol int
|
||||||
FromQuery bool
|
FromQuery bool
|
||||||
gPressed bool
|
gPressed bool
|
||||||
|
keyMap RowDetailKeyMap
|
||||||
|
help help.Model
|
||||||
|
focused bool
|
||||||
|
id int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRowDetailModel(shared *SharedData, rowIndex int) *RowDetailModel {
|
// RowDetailOption is a functional option for configuring RowDetailModel
|
||||||
return &RowDetailModel{
|
type RowDetailOption func(*RowDetailModel)
|
||||||
|
|
||||||
|
// WithRowDetailKeyMap sets the key map
|
||||||
|
func WithRowDetailKeyMap(km RowDetailKeyMap) RowDetailOption {
|
||||||
|
return func(m *RowDetailModel) {
|
||||||
|
m.keyMap = km
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRowDetailModel(shared *SharedData, rowIndex int, opts ...RowDetailOption) *RowDetailModel {
|
||||||
|
m := &RowDetailModel{
|
||||||
Shared: shared,
|
Shared: shared,
|
||||||
rowIndex: rowIndex,
|
rowIndex: rowIndex,
|
||||||
selectedCol: 0,
|
selectedCol: 0,
|
||||||
|
FromQuery: false,
|
||||||
|
keyMap: DefaultRowDetailKeyMap(),
|
||||||
|
help: help.New(),
|
||||||
|
focused: true,
|
||||||
|
id: nextID(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply options
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID returns the unique ID of the model
|
||||||
|
func (m RowDetailModel) ID() int {
|
||||||
|
return m.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus sets the focus state
|
||||||
|
func (m *RowDetailModel) Focus() {
|
||||||
|
m.focused = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur removes focus
|
||||||
|
func (m *RowDetailModel) Blur() {
|
||||||
|
m.focused = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focused returns the focus state
|
||||||
|
func (m RowDetailModel) Focused() bool {
|
||||||
|
return m.focused
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *RowDetailModel) Init() tea.Cmd {
|
func (m *RowDetailModel) Init() tea.Cmd {
|
||||||
@@ -28,59 +76,66 @@ func (m *RowDetailModel) Init() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *RowDetailModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *RowDetailModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if !m.focused {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.String() {
|
return m.handleNavigation(msg)
|
||||||
case "q", "esc":
|
}
|
||||||
m.gPressed = false
|
return m, nil
|
||||||
if m.FromQuery {
|
}
|
||||||
return m, func() tea.Msg { return ReturnToQueryMsg{} }
|
|
||||||
}
|
|
||||||
return m, func() tea.Msg { return SwitchToTableDataMsg{TableIndex: m.Shared.SelectedTable} }
|
|
||||||
|
|
||||||
case "g":
|
func (m *RowDetailModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
if m.gPressed {
|
switch {
|
||||||
// Second g - go to beginning
|
case key.Matches(msg, m.keyMap.Escape), key.Matches(msg, m.keyMap.Back):
|
||||||
m.selectedCol = 0
|
m.gPressed = false
|
||||||
m.gPressed = false
|
if m.FromQuery {
|
||||||
} else {
|
return m, func() tea.Msg { return ReturnToQueryMsg{} }
|
||||||
// First g - wait for second g
|
|
||||||
m.gPressed = true
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case "G":
|
|
||||||
// Go to end
|
|
||||||
if len(m.Shared.Columns) > 0 {
|
|
||||||
m.selectedCol = len(m.Shared.Columns) - 1
|
|
||||||
}
|
|
||||||
m.gPressed = false
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case "e":
|
|
||||||
m.gPressed = false
|
|
||||||
if len(m.Shared.FilteredData) > m.rowIndex && len(m.Shared.Columns) > m.selectedCol {
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
return SwitchToEditCellMsg{RowIndex: m.rowIndex, ColIndex: m.selectedCol}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "up", "k":
|
|
||||||
m.gPressed = false
|
|
||||||
if m.selectedCol > 0 {
|
|
||||||
m.selectedCol--
|
|
||||||
}
|
|
||||||
|
|
||||||
case "down", "j":
|
|
||||||
m.gPressed = false
|
|
||||||
if m.selectedCol < len(m.Shared.Columns)-1 {
|
|
||||||
m.selectedCol++
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Any other key resets the g state
|
|
||||||
m.gPressed = false
|
|
||||||
}
|
}
|
||||||
|
return m, func() tea.Msg { return SwitchToTableDataMsg{TableIndex: m.Shared.SelectedTable} }
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keyMap.GoToStart):
|
||||||
|
if m.gPressed {
|
||||||
|
// Second g - go to beginning
|
||||||
|
m.selectedCol = 0
|
||||||
|
m.gPressed = false
|
||||||
|
} else {
|
||||||
|
// First g - wait for second g
|
||||||
|
m.gPressed = true
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keyMap.GoToEnd):
|
||||||
|
// Go to end
|
||||||
|
if len(m.Shared.Columns) > 0 {
|
||||||
|
m.selectedCol = len(m.Shared.Columns) - 1
|
||||||
|
}
|
||||||
|
m.gPressed = false
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keyMap.Enter):
|
||||||
|
m.gPressed = false
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
return SwitchToEditCellMsg{RowIndex: m.rowIndex, ColIndex: m.selectedCol}
|
||||||
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keyMap.Up):
|
||||||
|
m.gPressed = false
|
||||||
|
if m.selectedCol > 0 {
|
||||||
|
m.selectedCol--
|
||||||
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keyMap.Down):
|
||||||
|
m.gPressed = false
|
||||||
|
if m.selectedCol < len(m.Shared.Columns)-1 {
|
||||||
|
m.selectedCol++
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Any other key resets the g state
|
||||||
|
m.gPressed = false
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -92,27 +147,36 @@ func (m *RowDetailModel) View() string {
|
|||||||
content.WriteString("\n\n")
|
content.WriteString("\n\n")
|
||||||
|
|
||||||
if m.rowIndex >= len(m.Shared.FilteredData) {
|
if m.rowIndex >= len(m.Shared.FilteredData) {
|
||||||
content.WriteString("Invalid row index")
|
content.WriteString("Row not found")
|
||||||
return content.String()
|
return content.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show current row position
|
|
||||||
content.WriteString(fmt.Sprintf("Row %d of %d\n\n", m.rowIndex+1, len(m.Shared.FilteredData)))
|
|
||||||
|
|
||||||
row := m.Shared.FilteredData[m.rowIndex]
|
row := m.Shared.FilteredData[m.rowIndex]
|
||||||
|
|
||||||
|
// Show each column and its value
|
||||||
for i, col := range m.Shared.Columns {
|
for i, col := range m.Shared.Columns {
|
||||||
if i < len(row) {
|
if i >= len(row) {
|
||||||
if i == m.selectedCol {
|
break
|
||||||
content.WriteString(SelectedStyle.Render(fmt.Sprintf("> %s: %s", col, row[i])))
|
|
||||||
} else {
|
|
||||||
content.WriteString(NormalStyle.Render(fmt.Sprintf(" %s: %s", col, row[i])))
|
|
||||||
}
|
|
||||||
content.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
value := row[i]
|
||||||
|
if len(value) > 50 {
|
||||||
|
// Wrap long values
|
||||||
|
lines := WrapText(value, 50)
|
||||||
|
value = strings.Join(lines, "\n ")
|
||||||
|
}
|
||||||
|
|
||||||
|
line := fmt.Sprintf("%s: %s", col, value)
|
||||||
|
if i == m.selectedCol {
|
||||||
|
content.WriteString(SelectedStyle.Render("> " + line))
|
||||||
|
} else {
|
||||||
|
content.WriteString(NormalStyle.Render(" " + line))
|
||||||
|
}
|
||||||
|
content.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
content.WriteString("\n")
|
content.WriteString("\n")
|
||||||
content.WriteString(HelpStyle.Render("↑/↓: navigate columns • e: edit • gg/G: first/last • q: back"))
|
content.WriteString(m.help.View(m.keyMap))
|
||||||
|
|
||||||
return content.String()
|
return content.String()
|
||||||
}
|
}
|
||||||
61
internal/app/row_detail_keys.go
Normal file
61
internal/app/row_detail_keys.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/bubbles/key"
|
||||||
|
|
||||||
|
// RowDetailKeyMap defines keybindings for the row detail view
|
||||||
|
type RowDetailKeyMap struct {
|
||||||
|
Up key.Binding
|
||||||
|
Down key.Binding
|
||||||
|
Enter key.Binding
|
||||||
|
Escape key.Binding
|
||||||
|
Back key.Binding
|
||||||
|
GoToStart key.Binding
|
||||||
|
GoToEnd key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultRowDetailKeyMap returns the default keybindings for row detail
|
||||||
|
func DefaultRowDetailKeyMap() RowDetailKeyMap {
|
||||||
|
return RowDetailKeyMap{
|
||||||
|
Up: key.NewBinding(
|
||||||
|
key.WithKeys("up", "k"),
|
||||||
|
key.WithHelp("↑/k", "up"),
|
||||||
|
),
|
||||||
|
Down: key.NewBinding(
|
||||||
|
key.WithKeys("down", "j"),
|
||||||
|
key.WithHelp("↓/j", "down"),
|
||||||
|
),
|
||||||
|
Enter: key.NewBinding(
|
||||||
|
key.WithKeys("enter"),
|
||||||
|
key.WithHelp("enter", "edit cell"),
|
||||||
|
),
|
||||||
|
Escape: key.NewBinding(
|
||||||
|
key.WithKeys("esc"),
|
||||||
|
key.WithHelp("esc", "back"),
|
||||||
|
),
|
||||||
|
Back: key.NewBinding(
|
||||||
|
key.WithKeys("q"),
|
||||||
|
key.WithHelp("q", "back"),
|
||||||
|
),
|
||||||
|
GoToStart: key.NewBinding(
|
||||||
|
key.WithKeys("g"),
|
||||||
|
key.WithHelp("gg", "go to start"),
|
||||||
|
),
|
||||||
|
GoToEnd: key.NewBinding(
|
||||||
|
key.WithKeys("G"),
|
||||||
|
key.WithHelp("G", "go to end"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortHelp returns keybindings to be shown in the mini help view
|
||||||
|
func (k RowDetailKeyMap) ShortHelp() []key.Binding {
|
||||||
|
return []key.Binding{k.Up, k.Down, k.Enter, k.Back}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullHelp returns keybindings for the expanded help view
|
||||||
|
func (k RowDetailKeyMap) FullHelp() [][]key.Binding {
|
||||||
|
return [][]key.Binding{
|
||||||
|
{k.Up, k.Down, k.Enter},
|
||||||
|
{k.Escape, k.Back, k.GoToStart, k.GoToEnd},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,28 +6,92 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/bubbles/help"
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TableDataModel struct {
|
type TableDataModel struct {
|
||||||
Shared *SharedData
|
Shared *SharedData
|
||||||
selectedRow int
|
searchInput textinput.Model
|
||||||
searchInput string
|
|
||||||
searching bool
|
searching bool
|
||||||
|
selectedRow int
|
||||||
gPressed bool
|
gPressed bool
|
||||||
|
keyMap TableDataKeyMap
|
||||||
|
help help.Model
|
||||||
|
focused bool
|
||||||
|
id int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTableDataModel(shared *SharedData) *TableDataModel {
|
// TableDataOption is a functional option for configuring TableDataModel
|
||||||
return &TableDataModel{
|
type TableDataOption func(*TableDataModel)
|
||||||
Shared: shared,
|
|
||||||
selectedRow: 0,
|
// WithTableDataKeyMap sets the key map
|
||||||
|
func WithTableDataKeyMap(km TableDataKeyMap) TableDataOption {
|
||||||
|
return func(m *TableDataModel) {
|
||||||
|
m.keyMap = km
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewTableDataModel(shared *SharedData, opts ...TableDataOption) *TableDataModel {
|
||||||
|
searchInput := textinput.New()
|
||||||
|
searchInput.Placeholder = "Search rows..."
|
||||||
|
searchInput.CharLimit = 50
|
||||||
|
searchInput.Width = 30
|
||||||
|
|
||||||
|
m := &TableDataModel{
|
||||||
|
Shared: shared,
|
||||||
|
searchInput: searchInput,
|
||||||
|
selectedRow: 0,
|
||||||
|
keyMap: DefaultTableDataKeyMap(),
|
||||||
|
help: help.New(),
|
||||||
|
focused: true,
|
||||||
|
id: nextID(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply options
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID returns the unique ID of the model
|
||||||
|
func (m TableDataModel) ID() int {
|
||||||
|
return m.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus sets the focus state
|
||||||
|
func (m *TableDataModel) Focus() {
|
||||||
|
m.focused = true
|
||||||
|
if m.searching {
|
||||||
|
m.searchInput.Focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur removes focus
|
||||||
|
func (m *TableDataModel) Blur() {
|
||||||
|
m.focused = false
|
||||||
|
m.searchInput.Blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focused returns the focus state
|
||||||
|
func (m TableDataModel) Focused() bool {
|
||||||
|
return m.focused
|
||||||
|
}
|
||||||
|
|
||||||
func (m *TableDataModel) Init() tea.Cmd {
|
func (m *TableDataModel) Init() tea.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *TableDataModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *TableDataModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if !m.focused {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if m.searching {
|
if m.searching {
|
||||||
@@ -35,45 +99,57 @@ func (m *TableDataModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m.handleNavigation(msg)
|
return m.handleNavigation(msg)
|
||||||
}
|
}
|
||||||
return m, nil
|
|
||||||
|
// Update search input for non-key messages when searching
|
||||||
|
if m.searching {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
// Update filter when search input changes
|
||||||
|
m.filterData()
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *TableDataModel) handleSearchInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *TableDataModel) handleSearchInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch {
|
||||||
case "esc", "enter":
|
case key.Matches(msg, m.keyMap.Escape):
|
||||||
m.searching = false
|
m.searching = false
|
||||||
|
m.searchInput.Blur()
|
||||||
|
m.filterData()
|
||||||
|
case key.Matches(msg, m.keyMap.Enter):
|
||||||
|
m.searching = false
|
||||||
|
m.searchInput.Blur()
|
||||||
m.filterData()
|
m.filterData()
|
||||||
case "backspace":
|
|
||||||
if len(m.searchInput) > 0 {
|
|
||||||
m.searchInput = m.searchInput[:len(m.searchInput)-1]
|
|
||||||
m.filterData()
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
if len(msg.String()) == 1 {
|
var cmd tea.Cmd
|
||||||
m.searchInput += msg.String()
|
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||||
m.filterData()
|
m.filterData()
|
||||||
}
|
return m, cmd
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch {
|
||||||
case "q":
|
case key.Matches(msg, m.keyMap.Back):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
return m, func() tea.Msg { return SwitchToTableListClearMsg{} }
|
return m, func() tea.Msg { return SwitchToTableListClearMsg{} }
|
||||||
|
|
||||||
case "esc":
|
case key.Matches(msg, m.keyMap.Escape):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
if m.searchInput != "" {
|
if m.searchInput.Value() != "" {
|
||||||
// Clear search filter
|
// Clear search filter
|
||||||
m.searchInput = ""
|
m.searchInput.SetValue("")
|
||||||
m.filterData()
|
m.filterData()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
return m, func() tea.Msg { return SwitchToTableListClearMsg{} }
|
return m, func() tea.Msg { return SwitchToTableListClearMsg{} }
|
||||||
|
|
||||||
case "g":
|
case key.Matches(msg, m.keyMap.GoToStart):
|
||||||
if m.gPressed {
|
if m.gPressed {
|
||||||
// Second g - go to absolute beginning
|
// Second g - go to absolute beginning
|
||||||
m.Shared.CurrentPage = 0
|
m.Shared.CurrentPage = 0
|
||||||
@@ -87,7 +163,7 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "G":
|
case key.Matches(msg, m.keyMap.GoToEnd):
|
||||||
// Go to absolute end
|
// Go to absolute end
|
||||||
maxPage := (m.Shared.TotalRows - 1) / PageSize
|
maxPage := (m.Shared.TotalRows - 1) / PageSize
|
||||||
m.Shared.CurrentPage = maxPage
|
m.Shared.CurrentPage = maxPage
|
||||||
@@ -97,7 +173,7 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "enter":
|
case key.Matches(msg, m.keyMap.Enter):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
if len(m.Shared.FilteredData) > 0 {
|
if len(m.Shared.FilteredData) > 0 {
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
@@ -105,23 +181,24 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "/":
|
case key.Matches(msg, m.keyMap.Search):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
m.searching = true
|
m.searching = true
|
||||||
m.searchInput = ""
|
m.searchInput.SetValue("")
|
||||||
|
m.searchInput.Focus()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "s":
|
case key.Matches(msg, m.keyMap.SQLMode):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
return m, func() tea.Msg { return SwitchToQueryMsg{} }
|
return m, func() tea.Msg { return SwitchToQueryMsg{} }
|
||||||
|
|
||||||
case "r":
|
case key.Matches(msg, m.keyMap.Refresh):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
if err := m.Shared.LoadTableData(); err == nil {
|
if err := m.Shared.LoadTableData(); err == nil {
|
||||||
m.filterData()
|
m.filterData()
|
||||||
}
|
}
|
||||||
|
|
||||||
case "up", "k":
|
case key.Matches(msg, m.keyMap.Up):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
if m.selectedRow > 0 {
|
if m.selectedRow > 0 {
|
||||||
m.selectedRow--
|
m.selectedRow--
|
||||||
@@ -133,7 +210,7 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.selectedRow = len(m.Shared.FilteredData) - 1 // Go to last row of previous page
|
m.selectedRow = len(m.Shared.FilteredData) - 1 // Go to last row of previous page
|
||||||
}
|
}
|
||||||
|
|
||||||
case "down", "j":
|
case key.Matches(msg, m.keyMap.Down):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
if m.selectedRow < len(m.Shared.FilteredData)-1 {
|
if m.selectedRow < len(m.Shared.FilteredData)-1 {
|
||||||
m.selectedRow++
|
m.selectedRow++
|
||||||
@@ -148,7 +225,7 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "left", "h":
|
case key.Matches(msg, m.keyMap.Left):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
if m.Shared.CurrentPage > 0 {
|
if m.Shared.CurrentPage > 0 {
|
||||||
m.Shared.CurrentPage--
|
m.Shared.CurrentPage--
|
||||||
@@ -156,7 +233,7 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.selectedRow = 0
|
m.selectedRow = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
case "right", "l":
|
case key.Matches(msg, m.keyMap.Right):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
maxPage := (m.Shared.TotalRows - 1) / PageSize
|
maxPage := (m.Shared.TotalRows - 1) / PageSize
|
||||||
if m.Shared.CurrentPage < maxPage {
|
if m.Shared.CurrentPage < maxPage {
|
||||||
@@ -173,7 +250,8 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *TableDataModel) filterData() {
|
func (m *TableDataModel) filterData() {
|
||||||
if m.searchInput == "" {
|
searchValue := m.searchInput.Value()
|
||||||
|
if searchValue == "" {
|
||||||
m.Shared.FilteredData = make([][]string, len(m.Shared.TableData))
|
m.Shared.FilteredData = make([][]string, len(m.Shared.TableData))
|
||||||
copy(m.Shared.FilteredData, m.Shared.TableData)
|
copy(m.Shared.FilteredData, m.Shared.TableData)
|
||||||
} else {
|
} else {
|
||||||
@@ -184,7 +262,7 @@ func (m *TableDataModel) filterData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var matches []rowMatch
|
var matches []rowMatch
|
||||||
searchLower := strings.ToLower(m.searchInput)
|
searchLower := strings.ToLower(searchValue)
|
||||||
|
|
||||||
for _, row := range m.Shared.TableData {
|
for _, row := range m.Shared.TableData {
|
||||||
bestScore := 0
|
bestScore := 0
|
||||||
@@ -298,11 +376,11 @@ func (m *TableDataModel) View() string {
|
|||||||
content.WriteString("\n")
|
content.WriteString("\n")
|
||||||
|
|
||||||
if m.searching {
|
if m.searching {
|
||||||
content.WriteString("\nSearch: " + m.searchInput + "_")
|
content.WriteString("\nSearch: " + m.searchInput.View())
|
||||||
content.WriteString("\n")
|
content.WriteString("\n")
|
||||||
} else if m.searchInput != "" {
|
} else if m.searchInput.Value() != "" {
|
||||||
content.WriteString(fmt.Sprintf("\nFiltered by: %s (%d/%d rows)",
|
content.WriteString(fmt.Sprintf("\nFiltered by: %s (%d/%d rows)",
|
||||||
m.searchInput, len(m.Shared.FilteredData), len(m.Shared.TableData)))
|
m.searchInput.Value(), len(m.Shared.FilteredData), len(m.Shared.TableData)))
|
||||||
content.WriteString("\n")
|
content.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,9 +412,7 @@ func (m *TableDataModel) View() string {
|
|||||||
if totalRows > visibleCount && m.selectedRow >= visibleCount {
|
if totalRows > visibleCount && m.selectedRow >= visibleCount {
|
||||||
startIdx = m.selectedRow - visibleCount + 1
|
startIdx = m.selectedRow - visibleCount + 1
|
||||||
// Ensure we don't scroll past the end
|
// Ensure we don't scroll past the end
|
||||||
if startIdx > totalRows-visibleCount {
|
startIdx = min(startIdx, totalRows-visibleCount)
|
||||||
startIdx = totalRows - visibleCount
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
endIdx := Min(totalRows, startIdx+visibleCount)
|
endIdx := Min(totalRows, startIdx+visibleCount)
|
||||||
@@ -364,7 +440,7 @@ func (m *TableDataModel) View() string {
|
|||||||
if m.searching {
|
if m.searching {
|
||||||
content.WriteString(HelpStyle.Render("Type to search • enter/esc: finish search"))
|
content.WriteString(HelpStyle.Render("Type to search • enter/esc: finish search"))
|
||||||
} else {
|
} else {
|
||||||
content.WriteString(HelpStyle.Render("↑/↓: navigate • ←/→: page • /: search • enter: details • s: SQL • r: refresh • gg/G: first/last • q: back"))
|
content.WriteString(m.help.View(m.keyMap))
|
||||||
}
|
}
|
||||||
|
|
||||||
return content.String()
|
return content.String()
|
||||||
|
|||||||
87
internal/app/table_data_keys.go
Normal file
87
internal/app/table_data_keys.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/bubbles/key"
|
||||||
|
|
||||||
|
// TableDataKeyMap defines keybindings for the table data view
|
||||||
|
type TableDataKeyMap struct {
|
||||||
|
Up key.Binding
|
||||||
|
Down key.Binding
|
||||||
|
Left key.Binding
|
||||||
|
Right key.Binding
|
||||||
|
Enter key.Binding
|
||||||
|
Search key.Binding
|
||||||
|
Escape key.Binding
|
||||||
|
Back key.Binding
|
||||||
|
GoToStart key.Binding
|
||||||
|
GoToEnd key.Binding
|
||||||
|
Refresh key.Binding
|
||||||
|
SQLMode key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTableDataKeyMap returns the default keybindings for table data
|
||||||
|
func DefaultTableDataKeyMap() TableDataKeyMap {
|
||||||
|
return TableDataKeyMap{
|
||||||
|
Up: key.NewBinding(
|
||||||
|
key.WithKeys("up", "k"),
|
||||||
|
key.WithHelp("↑/k", "up"),
|
||||||
|
),
|
||||||
|
Down: key.NewBinding(
|
||||||
|
key.WithKeys("down", "j"),
|
||||||
|
key.WithHelp("↓/j", "down"),
|
||||||
|
),
|
||||||
|
Left: key.NewBinding(
|
||||||
|
key.WithKeys("left", "h"),
|
||||||
|
key.WithHelp("←/h", "prev page"),
|
||||||
|
),
|
||||||
|
Right: key.NewBinding(
|
||||||
|
key.WithKeys("right", "l"),
|
||||||
|
key.WithHelp("→/l", "next page"),
|
||||||
|
),
|
||||||
|
Enter: key.NewBinding(
|
||||||
|
key.WithKeys("enter"),
|
||||||
|
key.WithHelp("enter", "view details"),
|
||||||
|
),
|
||||||
|
Search: key.NewBinding(
|
||||||
|
key.WithKeys("/"),
|
||||||
|
key.WithHelp("/", "search"),
|
||||||
|
),
|
||||||
|
Escape: key.NewBinding(
|
||||||
|
key.WithKeys("esc"),
|
||||||
|
key.WithHelp("esc", "back/clear"),
|
||||||
|
),
|
||||||
|
Back: key.NewBinding(
|
||||||
|
key.WithKeys("q"),
|
||||||
|
key.WithHelp("q", "back to tables"),
|
||||||
|
),
|
||||||
|
GoToStart: key.NewBinding(
|
||||||
|
key.WithKeys("g"),
|
||||||
|
key.WithHelp("gg", "go to start"),
|
||||||
|
),
|
||||||
|
GoToEnd: key.NewBinding(
|
||||||
|
key.WithKeys("G"),
|
||||||
|
key.WithHelp("G", "go to end"),
|
||||||
|
),
|
||||||
|
Refresh: key.NewBinding(
|
||||||
|
key.WithKeys("r"),
|
||||||
|
key.WithHelp("r", "refresh"),
|
||||||
|
),
|
||||||
|
SQLMode: key.NewBinding(
|
||||||
|
key.WithKeys("s"),
|
||||||
|
key.WithHelp("s", "SQL mode"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortHelp returns keybindings to be shown in the mini help view
|
||||||
|
func (k TableDataKeyMap) ShortHelp() []key.Binding {
|
||||||
|
return []key.Binding{k.Up, k.Down, k.Enter, k.Search}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullHelp returns keybindings for the expanded help view
|
||||||
|
func (k TableDataKeyMap) FullHelp() [][]key.Binding {
|
||||||
|
return [][]key.Binding{
|
||||||
|
{k.Up, k.Down, k.Left, k.Right},
|
||||||
|
{k.Enter, k.Search, k.Escape, k.Back},
|
||||||
|
{k.GoToStart, k.GoToEnd, k.Refresh, k.SQLMode},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,23 +6,81 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/bubbles/help"
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TableListModel struct {
|
type TableListModel struct {
|
||||||
Shared *SharedData
|
Shared *SharedData
|
||||||
searchInput string
|
searchInput textinput.Model
|
||||||
searching bool
|
searching bool
|
||||||
selectedTable int
|
selectedTable int
|
||||||
currentPage int
|
currentPage int
|
||||||
gPressed bool
|
gPressed bool
|
||||||
|
keyMap TableListKeyMap
|
||||||
|
help help.Model
|
||||||
|
focused bool
|
||||||
|
id int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTableListModel(shared *SharedData) *TableListModel {
|
// TableListOption is a functional option for configuring TableListModel
|
||||||
return &TableListModel{
|
type TableListOption func(*TableListModel)
|
||||||
|
|
||||||
|
// WithTableListKeyMap sets the key map
|
||||||
|
func WithTableListKeyMap(km TableListKeyMap) TableListOption {
|
||||||
|
return func(m *TableListModel) {
|
||||||
|
m.keyMap = km
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTableListModel(shared *SharedData, opts ...TableListOption) *TableListModel {
|
||||||
|
searchInput := textinput.New()
|
||||||
|
searchInput.Placeholder = "Search tables..."
|
||||||
|
searchInput.CharLimit = 50
|
||||||
|
searchInput.Width = 30
|
||||||
|
|
||||||
|
m := &TableListModel{
|
||||||
Shared: shared,
|
Shared: shared,
|
||||||
|
searchInput: searchInput,
|
||||||
selectedTable: 0,
|
selectedTable: 0,
|
||||||
currentPage: 0,
|
currentPage: 0,
|
||||||
|
keyMap: DefaultTableListKeyMap(),
|
||||||
|
help: help.New(),
|
||||||
|
focused: true,
|
||||||
|
id: nextID(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply options
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID returns the unique ID of the model
|
||||||
|
func (m TableListModel) ID() int {
|
||||||
|
return m.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus sets the focus state
|
||||||
|
func (m *TableListModel) Focus() {
|
||||||
|
m.focused = true
|
||||||
|
if m.searching {
|
||||||
|
m.searchInput.Focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur removes focus
|
||||||
|
func (m *TableListModel) Blur() {
|
||||||
|
m.focused = false
|
||||||
|
m.searchInput.Blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focused returns the focus state
|
||||||
|
func (m TableListModel) Focused() bool {
|
||||||
|
return m.focused
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *TableListModel) Init() tea.Cmd {
|
func (m *TableListModel) Init() tea.Cmd {
|
||||||
@@ -30,6 +88,12 @@ func (m *TableListModel) Init() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *TableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *TableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if !m.focused {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if m.searching {
|
if m.searching {
|
||||||
@@ -37,41 +101,50 @@ func (m *TableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m.handleNavigation(msg)
|
return m.handleNavigation(msg)
|
||||||
}
|
}
|
||||||
return m, nil
|
|
||||||
|
// Update search input for non-key messages when searching
|
||||||
|
if m.searching {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
// Update filter when search input changes
|
||||||
|
m.filterTables()
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *TableListModel) handleSearchInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *TableListModel) handleSearchInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch {
|
||||||
case "esc":
|
case key.Matches(msg, m.keyMap.Escape):
|
||||||
m.searching = false
|
m.searching = false
|
||||||
|
m.searchInput.Blur()
|
||||||
// If there's an existing filter, clear it
|
// If there's an existing filter, clear it
|
||||||
if m.searchInput != "" {
|
if m.searchInput.Value() != "" {
|
||||||
m.searchInput = ""
|
m.searchInput.SetValue("")
|
||||||
m.filterTables()
|
m.filterTables()
|
||||||
}
|
}
|
||||||
case "enter":
|
case key.Matches(msg, m.keyMap.Enter):
|
||||||
m.searching = false
|
m.searching = false
|
||||||
|
m.searchInput.Blur()
|
||||||
m.filterTables()
|
m.filterTables()
|
||||||
case "backspace":
|
|
||||||
if len(m.searchInput) > 0 {
|
|
||||||
m.searchInput = m.searchInput[:len(m.searchInput)-1]
|
|
||||||
m.filterTables()
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
if len(msg.String()) == 1 {
|
var cmd tea.Cmd
|
||||||
m.searchInput += msg.String()
|
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||||
m.filterTables()
|
m.filterTables()
|
||||||
}
|
return m, cmd
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch {
|
||||||
case "esc":
|
case key.Matches(msg, m.keyMap.Escape):
|
||||||
if m.searchInput != "" {
|
if m.searchInput.Value() != "" {
|
||||||
// Clear search filter
|
// Clear search filter
|
||||||
m.searchInput = ""
|
m.searchInput.SetValue("")
|
||||||
m.filterTables()
|
m.filterTables()
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -80,13 +153,14 @@ func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "/":
|
case key.Matches(msg, m.keyMap.Search):
|
||||||
m.searching = true
|
m.searching = true
|
||||||
m.searchInput = ""
|
m.searchInput.SetValue("")
|
||||||
|
m.searchInput.Focus()
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "g":
|
case key.Matches(msg, m.keyMap.GoToStart):
|
||||||
if m.gPressed {
|
if m.gPressed {
|
||||||
// Second g - go to beginning
|
// Second g - go to beginning
|
||||||
m.selectedTable = 0
|
m.selectedTable = 0
|
||||||
@@ -98,7 +172,7 @@ func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "G":
|
case key.Matches(msg, m.keyMap.GoToEnd):
|
||||||
// Go to end
|
// Go to end
|
||||||
if len(m.Shared.FilteredTables) > 0 {
|
if len(m.Shared.FilteredTables) > 0 {
|
||||||
m.selectedTable = len(m.Shared.FilteredTables) - 1
|
m.selectedTable = len(m.Shared.FilteredTables) - 1
|
||||||
@@ -107,7 +181,7 @@ func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "enter":
|
case key.Matches(msg, m.keyMap.Enter):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
if len(m.Shared.FilteredTables) > 0 {
|
if len(m.Shared.FilteredTables) > 0 {
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
@@ -115,38 +189,38 @@ func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "s":
|
case key.Matches(msg, m.keyMap.SQLMode):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
return m, func() tea.Msg { return SwitchToQueryMsg{} }
|
return m, func() tea.Msg { return SwitchToQueryMsg{} }
|
||||||
|
|
||||||
case "r":
|
case key.Matches(msg, m.keyMap.Refresh):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
if err := m.Shared.LoadTables(); err == nil {
|
if err := m.Shared.LoadTables(); err == nil {
|
||||||
m.filterTables()
|
m.filterTables()
|
||||||
}
|
}
|
||||||
|
|
||||||
case "up", "k":
|
case key.Matches(msg, m.keyMap.Up):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
if m.selectedTable > 0 {
|
if m.selectedTable > 0 {
|
||||||
m.selectedTable--
|
m.selectedTable--
|
||||||
m.adjustPage()
|
m.adjustPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
case "down", "j":
|
case key.Matches(msg, m.keyMap.Down):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
if m.selectedTable < len(m.Shared.FilteredTables)-1 {
|
if m.selectedTable < len(m.Shared.FilteredTables)-1 {
|
||||||
m.selectedTable++
|
m.selectedTable++
|
||||||
m.adjustPage()
|
m.adjustPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
case "left", "h":
|
case key.Matches(msg, m.keyMap.Left):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
if m.currentPage > 0 {
|
if m.currentPage > 0 {
|
||||||
m.currentPage--
|
m.currentPage--
|
||||||
m.selectedTable = m.currentPage * m.getVisibleCount()
|
m.selectedTable = m.currentPage * m.getVisibleCount()
|
||||||
}
|
}
|
||||||
|
|
||||||
case "right", "l":
|
case key.Matches(msg, m.keyMap.Right):
|
||||||
m.gPressed = false
|
m.gPressed = false
|
||||||
maxPage := (len(m.Shared.FilteredTables) - 1) / m.getVisibleCount()
|
maxPage := (len(m.Shared.FilteredTables) - 1) / m.getVisibleCount()
|
||||||
if m.currentPage < maxPage {
|
if m.currentPage < maxPage {
|
||||||
@@ -165,7 +239,8 @@ func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *TableListModel) filterTables() {
|
func (m *TableListModel) filterTables() {
|
||||||
if m.searchInput == "" {
|
searchValue := m.searchInput.Value()
|
||||||
|
if searchValue == "" {
|
||||||
m.Shared.FilteredTables = make([]string, len(m.Shared.Tables))
|
m.Shared.FilteredTables = make([]string, len(m.Shared.Tables))
|
||||||
copy(m.Shared.FilteredTables, m.Shared.Tables)
|
copy(m.Shared.FilteredTables, m.Shared.Tables)
|
||||||
} else {
|
} else {
|
||||||
@@ -176,7 +251,7 @@ func (m *TableListModel) filterTables() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var matches []tableMatch
|
var matches []tableMatch
|
||||||
searchLower := strings.ToLower(m.searchInput)
|
searchLower := strings.ToLower(searchValue)
|
||||||
|
|
||||||
for _, table := range m.Shared.Tables {
|
for _, table := range m.Shared.Tables {
|
||||||
score := m.fuzzyScore(strings.ToLower(table), searchLower)
|
score := m.fuzzyScore(strings.ToLower(table), searchLower)
|
||||||
@@ -291,17 +366,17 @@ func (m *TableListModel) View() string {
|
|||||||
content.WriteString("\n")
|
content.WriteString("\n")
|
||||||
|
|
||||||
if m.searching {
|
if m.searching {
|
||||||
content.WriteString("\nSearch: " + m.searchInput + "_")
|
content.WriteString("\nSearch: " + m.searchInput.View())
|
||||||
content.WriteString("\n")
|
content.WriteString("\n")
|
||||||
} else if m.searchInput != "" {
|
} else if m.searchInput.Value() != "" {
|
||||||
content.WriteString(fmt.Sprintf("\nFiltered by: %s (%d/%d tables)",
|
content.WriteString(fmt.Sprintf("\nFiltered by: %s (%d/%d tables)",
|
||||||
m.searchInput, len(m.Shared.FilteredTables), len(m.Shared.Tables)))
|
m.searchInput.Value(), len(m.Shared.FilteredTables), len(m.Shared.Tables)))
|
||||||
content.WriteString("\n")
|
content.WriteString("\n")
|
||||||
}
|
}
|
||||||
content.WriteString("\n")
|
content.WriteString("\n")
|
||||||
|
|
||||||
if len(m.Shared.FilteredTables) == 0 {
|
if len(m.Shared.FilteredTables) == 0 {
|
||||||
if m.searchInput != "" {
|
if m.searchInput.Value() != "" {
|
||||||
content.WriteString("No tables match your search")
|
content.WriteString("No tables match your search")
|
||||||
} else {
|
} else {
|
||||||
content.WriteString("No tables found in database")
|
content.WriteString("No tables found in database")
|
||||||
@@ -331,7 +406,7 @@ func (m *TableListModel) View() string {
|
|||||||
if m.searching {
|
if m.searching {
|
||||||
content.WriteString(HelpStyle.Render("Type to search • enter/esc: finish search"))
|
content.WriteString(HelpStyle.Render("Type to search • enter/esc: finish search"))
|
||||||
} else {
|
} else {
|
||||||
content.WriteString(HelpStyle.Render("↑/↓: navigate • ←/→: page • /: search • enter: view • s: SQL • r: refresh • gg/G: first/last • ctrl+c: quit"))
|
content.WriteString(m.help.View(m.keyMap))
|
||||||
}
|
}
|
||||||
|
|
||||||
return content.String()
|
return content.String()
|
||||||
|
|||||||
82
internal/app/table_list_keys.go
Normal file
82
internal/app/table_list_keys.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/bubbles/key"
|
||||||
|
|
||||||
|
// TableListKeyMap defines keybindings for the table list view
|
||||||
|
type TableListKeyMap struct {
|
||||||
|
Up key.Binding
|
||||||
|
Down key.Binding
|
||||||
|
Left key.Binding
|
||||||
|
Right key.Binding
|
||||||
|
Enter key.Binding
|
||||||
|
Search key.Binding
|
||||||
|
Escape key.Binding
|
||||||
|
GoToStart key.Binding
|
||||||
|
GoToEnd key.Binding
|
||||||
|
Refresh key.Binding
|
||||||
|
SQLMode key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTableListKeyMap returns the default keybindings for table list
|
||||||
|
func DefaultTableListKeyMap() TableListKeyMap {
|
||||||
|
return TableListKeyMap{
|
||||||
|
Up: key.NewBinding(
|
||||||
|
key.WithKeys("up", "k"),
|
||||||
|
key.WithHelp("↑/k", "up"),
|
||||||
|
),
|
||||||
|
Down: key.NewBinding(
|
||||||
|
key.WithKeys("down", "j"),
|
||||||
|
key.WithHelp("↓/j", "down"),
|
||||||
|
),
|
||||||
|
Left: key.NewBinding(
|
||||||
|
key.WithKeys("left", "h"),
|
||||||
|
key.WithHelp("←/h", "prev page"),
|
||||||
|
),
|
||||||
|
Right: key.NewBinding(
|
||||||
|
key.WithKeys("right", "l"),
|
||||||
|
key.WithHelp("→/l", "next page"),
|
||||||
|
),
|
||||||
|
Enter: key.NewBinding(
|
||||||
|
key.WithKeys("enter"),
|
||||||
|
key.WithHelp("enter", "view table"),
|
||||||
|
),
|
||||||
|
Search: key.NewBinding(
|
||||||
|
key.WithKeys("/"),
|
||||||
|
key.WithHelp("/", "search"),
|
||||||
|
),
|
||||||
|
Escape: key.NewBinding(
|
||||||
|
key.WithKeys("esc"),
|
||||||
|
key.WithHelp("esc", "clear filter"),
|
||||||
|
),
|
||||||
|
GoToStart: key.NewBinding(
|
||||||
|
key.WithKeys("g"),
|
||||||
|
key.WithHelp("gg", "go to start"),
|
||||||
|
),
|
||||||
|
GoToEnd: key.NewBinding(
|
||||||
|
key.WithKeys("G"),
|
||||||
|
key.WithHelp("G", "go to end"),
|
||||||
|
),
|
||||||
|
Refresh: key.NewBinding(
|
||||||
|
key.WithKeys("r"),
|
||||||
|
key.WithHelp("r", "refresh"),
|
||||||
|
),
|
||||||
|
SQLMode: key.NewBinding(
|
||||||
|
key.WithKeys("s"),
|
||||||
|
key.WithHelp("s", "SQL mode"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortHelp returns keybindings to be shown in the mini help view
|
||||||
|
func (k TableListKeyMap) ShortHelp() []key.Binding {
|
||||||
|
return []key.Binding{k.Up, k.Down, k.Enter, k.Search}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullHelp returns keybindings for the expanded help view
|
||||||
|
func (k TableListKeyMap) FullHelp() [][]key.Binding {
|
||||||
|
return [][]key.Binding{
|
||||||
|
{k.Up, k.Down, k.Left, k.Right},
|
||||||
|
{k.Enter, k.Search, k.Escape, k.Refresh},
|
||||||
|
{k.GoToStart, k.GoToEnd, k.SQLMode},
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user