update layout

This commit is contained in:
2025-07-13 20:40:18 -07:00
parent 460917ceca
commit d7c9c055a0
13 changed files with 1080 additions and 443 deletions

2
go.mod
View File

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

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

View File

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

View File

@@ -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:
switch msg.String() {
case "esc":
return m, func() tea.Msg { return SwitchToRowDetailMsg{RowIndex: m.rowIndex} }
case "enter": case tea.KeyMsg:
switch {
case key.Matches(msg, m.keyMap.Save):
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
}

View 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},
}
}

View File

@@ -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,10 +451,10 @@ 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
View 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},
}
}

View File

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

View 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},
}
}

View File

@@ -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,8 +440,8 @@ 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()
} }

View 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},
}
}

View File

@@ -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,8 +406,8 @@ 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()
} }

View 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},
}
}