mirror of
https://github.com/taigrr/teaqlite.git
synced 2026-04-02 04:59:03 -07:00
update layout
This commit is contained in:
@@ -5,8 +5,10 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
_ "modernc.org/sqlite" // Import SQLite driver
|
||||
)
|
||||
@@ -15,6 +17,35 @@ const (
|
||||
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
|
||||
type (
|
||||
SwitchToTableListMsg struct{}
|
||||
@@ -45,6 +76,26 @@ type Model struct {
|
||||
width int
|
||||
height int
|
||||
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
|
||||
@@ -478,18 +529,42 @@ func Max(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
func InitialModel(db *sql.DB) *Model {
|
||||
func InitialModel(db *sql.DB, opts ...Option) *Model {
|
||||
shared := NewSharedData(db)
|
||||
if err := shared.LoadTables(); err != nil {
|
||||
return &Model{err: err}
|
||||
}
|
||||
|
||||
return &Model{
|
||||
m := &Model{
|
||||
db: db,
|
||||
currentView: NewTableListModel(shared),
|
||||
width: 80,
|
||||
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 {
|
||||
@@ -497,6 +572,10 @@ func (m *Model) Init() tea.Cmd {
|
||||
}
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if !m.focused {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
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
|
||||
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
switch {
|
||||
case key.Matches(msg, m.keyMap.Quit):
|
||||
return m, tea.Quit
|
||||
}
|
||||
if msg.String() == "ctrl+z" {
|
||||
case key.Matches(msg, m.keyMap.Suspend):
|
||||
return m, tea.Suspend
|
||||
}
|
||||
|
||||
|
||||
@@ -3,21 +3,34 @@ package app
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
)
|
||||
|
||||
type EditCellModel struct {
|
||||
Shared *SharedData
|
||||
rowIndex int
|
||||
colIndex int
|
||||
value string
|
||||
cursor int
|
||||
input textinput.Model
|
||||
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 {
|
||||
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 := ""
|
||||
if rowIndex < len(shared.FilteredData) && colIndex < len(shared.FilteredData[rowIndex]) {
|
||||
value = shared.FilteredData[rowIndex][colIndex]
|
||||
}
|
||||
|
||||
return &EditCellModel{
|
||||
input := textinput.New()
|
||||
input.SetValue(value)
|
||||
input.Width = 50
|
||||
input.Focus()
|
||||
|
||||
m := &EditCellModel{
|
||||
Shared: shared,
|
||||
rowIndex: rowIndex,
|
||||
colIndex: colIndex,
|
||||
value: value,
|
||||
cursor: len(value),
|
||||
input: input,
|
||||
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 {
|
||||
return blinkCmd()
|
||||
return tea.Batch(
|
||||
textinput.Blink,
|
||||
blinkCmd(),
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
case blinkMsg:
|
||||
m.blinkState = !m.blinkState
|
||||
return m, blinkCmd()
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
return m, func() tea.Msg { return SwitchToRowDetailMsg{RowIndex: m.rowIndex} }
|
||||
cmds = append(cmds, blinkCmd())
|
||||
|
||||
case "enter":
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.keyMap.Save):
|
||||
return m, func() tea.Msg {
|
||||
return UpdateCellMsg{
|
||||
RowIndex: m.rowIndex,
|
||||
ColIndex: m.colIndex,
|
||||
Value: m.value,
|
||||
Value: m.input.Value(),
|
||||
}
|
||||
}
|
||||
|
||||
case "backspace":
|
||||
if m.cursor > 0 {
|
||||
m.value = m.value[:m.cursor-1] + m.value[m.cursor:]
|
||||
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++
|
||||
case key.Matches(msg, m.keyMap.Cancel):
|
||||
return m, func() tea.Msg {
|
||||
return SwitchToRowDetailMsg{RowIndex: m.rowIndex}
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -112,89 +144,9 @@ func (m *EditCellModel) View() string {
|
||||
columnName = m.Shared.Columns[m.colIndex]
|
||||
}
|
||||
|
||||
content := TitleStyle.Render(fmt.Sprintf("Edit Cell: %s", columnName)) + "\n\n"
|
||||
|
||||
// Display value with properly positioned cursor like bubbles textinput
|
||||
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")
|
||||
content := fmt.Sprintf("%s\n\n", TitleStyle.Render(fmt.Sprintf("Edit Cell: %s", columnName)))
|
||||
content += fmt.Sprintf("Value: %s\n\n", m.input.View())
|
||||
content += m.help.View(m.keyMap)
|
||||
|
||||
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"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
)
|
||||
|
||||
type QueryModel struct {
|
||||
Shared *SharedData
|
||||
query string
|
||||
cursor int
|
||||
queryInput textarea.Model
|
||||
FocusOnInput bool
|
||||
selectedRow int
|
||||
results [][]string
|
||||
@@ -20,30 +21,95 @@ type QueryModel struct {
|
||||
err error
|
||||
blinkState bool
|
||||
gPressed bool
|
||||
keyMap QueryKeyMap
|
||||
help help.Model
|
||||
focused bool
|
||||
id int
|
||||
}
|
||||
|
||||
func NewQueryModel(shared *SharedData) *QueryModel {
|
||||
return &QueryModel{
|
||||
Shared: shared,
|
||||
FocusOnInput: true,
|
||||
selectedRow: 0,
|
||||
blinkState: true,
|
||||
// QueryOption is a functional option for configuring QueryModel
|
||||
type QueryOption func(*QueryModel)
|
||||
|
||||
// WithQueryKeyMap sets the key map
|
||||
func WithQueryKeyMap(km QueryKeyMap) QueryOption {
|
||||
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 {
|
||||
return tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg {
|
||||
return blinkMsg{}
|
||||
})
|
||||
return tea.Batch(
|
||||
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) {
|
||||
if !m.focused {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case blinkMsg:
|
||||
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{}
|
||||
})
|
||||
}))
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.FocusOnInput {
|
||||
@@ -51,66 +117,44 @@ func (m *QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
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) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
switch {
|
||||
case key.Matches(msg, m.keyMap.Escape):
|
||||
return m, func() tea.Msg { return SwitchToTableListClearMsg{} }
|
||||
|
||||
case "enter":
|
||||
if strings.TrimSpace(m.query) != "" {
|
||||
case key.Matches(msg, m.keyMap.Execute):
|
||||
if strings.TrimSpace(m.queryInput.Value()) != "" {
|
||||
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:
|
||||
if len(msg.String()) == 1 {
|
||||
m.query = m.query[:m.cursor] + msg.String() + m.query[m.cursor:]
|
||||
m.cursor++
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.queryInput, cmd = m.queryInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *QueryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc", "q":
|
||||
switch {
|
||||
case key.Matches(msg, m.keyMap.Escape), key.Matches(msg, m.keyMap.Back):
|
||||
m.gPressed = false
|
||||
return m, func() tea.Msg { return SwitchToTableListClearMsg{} }
|
||||
|
||||
case "g":
|
||||
case key.Matches(msg, m.keyMap.GoToStart):
|
||||
if m.gPressed {
|
||||
// Second g - go to beginning
|
||||
m.selectedRow = 0
|
||||
@@ -121,7 +165,7 @@ func (m *QueryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "G":
|
||||
case key.Matches(msg, m.keyMap.GoToEnd):
|
||||
// Go to end
|
||||
if len(m.results) > 0 {
|
||||
m.selectedRow = len(m.results) - 1
|
||||
@@ -129,12 +173,13 @@ func (m *QueryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd
|
||||
m.gPressed = false
|
||||
return m, nil
|
||||
|
||||
case "i":
|
||||
case key.Matches(msg, m.keyMap.EditQuery):
|
||||
m.gPressed = false
|
||||
m.FocusOnInput = true
|
||||
m.queryInput.Focus()
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
case key.Matches(msg, m.keyMap.Enter):
|
||||
m.gPressed = false
|
||||
if len(m.results) > 0 {
|
||||
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
|
||||
if m.selectedRow > 0 {
|
||||
m.selectedRow--
|
||||
}
|
||||
|
||||
case "down", "j":
|
||||
case key.Matches(msg, m.keyMap.Down):
|
||||
m.gPressed = false
|
||||
if m.selectedRow < len(m.results)-1 {
|
||||
m.selectedRow++
|
||||
@@ -273,7 +318,7 @@ func (m *QueryModel) getTablePrimaryKeys(tableName string) []string {
|
||||
func (m *QueryModel) executeQuery() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// 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)
|
||||
if err != nil {
|
||||
@@ -334,59 +379,11 @@ func (m *QueryModel) handleQueryCompletion(msg QueryCompletedMsg) {
|
||||
m.Shared.IsQueryResult = true
|
||||
|
||||
m.FocusOnInput = false
|
||||
m.queryInput.Blur()
|
||||
m.selectedRow = 0
|
||||
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 {
|
||||
var content strings.Builder
|
||||
|
||||
@@ -394,41 +391,8 @@ func (m *QueryModel) View() string {
|
||||
content.WriteString("\n\n")
|
||||
|
||||
// Query input
|
||||
content.WriteString("Query: ")
|
||||
if m.FocusOnInput {
|
||||
// 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("Query:\n")
|
||||
content.WriteString(m.queryInput.View())
|
||||
content.WriteString("\n\n")
|
||||
|
||||
// Error display
|
||||
@@ -487,10 +451,10 @@ func (m *QueryModel) View() string {
|
||||
|
||||
content.WriteString("\n")
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
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"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
)
|
||||
|
||||
type RowDetailModel struct {
|
||||
@@ -13,14 +15,60 @@ type RowDetailModel struct {
|
||||
selectedCol int
|
||||
FromQuery bool
|
||||
gPressed bool
|
||||
keyMap RowDetailKeyMap
|
||||
help help.Model
|
||||
focused bool
|
||||
id int
|
||||
}
|
||||
|
||||
func NewRowDetailModel(shared *SharedData, rowIndex int) *RowDetailModel {
|
||||
return &RowDetailModel{
|
||||
// RowDetailOption is a functional option for configuring 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,
|
||||
rowIndex: rowIndex,
|
||||
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 {
|
||||
@@ -28,59 +76,66 @@ func (m *RowDetailModel) Init() tea.Cmd {
|
||||
}
|
||||
|
||||
func (m *RowDetailModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if !m.focused {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "esc":
|
||||
m.gPressed = false
|
||||
if m.FromQuery {
|
||||
return m, func() tea.Msg { return ReturnToQueryMsg{} }
|
||||
}
|
||||
return m, func() tea.Msg { return SwitchToTableDataMsg{TableIndex: m.Shared.SelectedTable} }
|
||||
return m.handleNavigation(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "g":
|
||||
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 "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
|
||||
func (m *RowDetailModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keyMap.Escape), key.Matches(msg, m.keyMap.Back):
|
||||
m.gPressed = false
|
||||
if m.FromQuery {
|
||||
return m, func() tea.Msg { return ReturnToQueryMsg{} }
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -92,27 +147,36 @@ func (m *RowDetailModel) View() string {
|
||||
content.WriteString("\n\n")
|
||||
|
||||
if m.rowIndex >= len(m.Shared.FilteredData) {
|
||||
content.WriteString("Invalid row index")
|
||||
content.WriteString("Row not found")
|
||||
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]
|
||||
|
||||
// Show each column and its value
|
||||
for i, col := range m.Shared.Columns {
|
||||
if i < len(row) {
|
||||
if i == m.selectedCol {
|
||||
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")
|
||||
if i >= len(row) {
|
||||
break
|
||||
}
|
||||
|
||||
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(HelpStyle.Render("↑/↓: navigate columns • e: edit • gg/G: first/last • q: back"))
|
||||
content.WriteString(m.help.View(m.keyMap))
|
||||
|
||||
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"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
)
|
||||
|
||||
type TableDataModel struct {
|
||||
Shared *SharedData
|
||||
selectedRow int
|
||||
searchInput string
|
||||
searchInput textinput.Model
|
||||
searching bool
|
||||
selectedRow int
|
||||
gPressed bool
|
||||
keyMap TableDataKeyMap
|
||||
help help.Model
|
||||
focused bool
|
||||
id int
|
||||
}
|
||||
|
||||
func NewTableDataModel(shared *SharedData) *TableDataModel {
|
||||
return &TableDataModel{
|
||||
Shared: shared,
|
||||
selectedRow: 0,
|
||||
// TableDataOption is a functional option for configuring TableDataModel
|
||||
type TableDataOption func(*TableDataModel)
|
||||
|
||||
// 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 {
|
||||
return nil
|
||||
}
|
||||
|
||||
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) {
|
||||
case tea.KeyMsg:
|
||||
if m.searching {
|
||||
@@ -35,45 +99,57 @@ func (m *TableDataModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
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) {
|
||||
switch msg.String() {
|
||||
case "esc", "enter":
|
||||
switch {
|
||||
case key.Matches(msg, m.keyMap.Escape):
|
||||
m.searching = false
|
||||
m.searchInput.Blur()
|
||||
m.filterData()
|
||||
case key.Matches(msg, m.keyMap.Enter):
|
||||
m.searching = false
|
||||
m.searchInput.Blur()
|
||||
m.filterData()
|
||||
case "backspace":
|
||||
if len(m.searchInput) > 0 {
|
||||
m.searchInput = m.searchInput[:len(m.searchInput)-1]
|
||||
m.filterData()
|
||||
}
|
||||
default:
|
||||
if len(msg.String()) == 1 {
|
||||
m.searchInput += msg.String()
|
||||
m.filterData()
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||
m.filterData()
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "q":
|
||||
switch {
|
||||
case key.Matches(msg, m.keyMap.Back):
|
||||
m.gPressed = false
|
||||
return m, func() tea.Msg { return SwitchToTableListClearMsg{} }
|
||||
|
||||
case "esc":
|
||||
case key.Matches(msg, m.keyMap.Escape):
|
||||
m.gPressed = false
|
||||
if m.searchInput != "" {
|
||||
if m.searchInput.Value() != "" {
|
||||
// Clear search filter
|
||||
m.searchInput = ""
|
||||
m.searchInput.SetValue("")
|
||||
m.filterData()
|
||||
return m, nil
|
||||
}
|
||||
return m, func() tea.Msg { return SwitchToTableListClearMsg{} }
|
||||
|
||||
case "g":
|
||||
case key.Matches(msg, m.keyMap.GoToStart):
|
||||
if m.gPressed {
|
||||
// Second g - go to absolute beginning
|
||||
m.Shared.CurrentPage = 0
|
||||
@@ -87,7 +163,7 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "G":
|
||||
case key.Matches(msg, m.keyMap.GoToEnd):
|
||||
// Go to absolute end
|
||||
maxPage := (m.Shared.TotalRows - 1) / PageSize
|
||||
m.Shared.CurrentPage = maxPage
|
||||
@@ -97,7 +173,7 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.gPressed = false
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
case key.Matches(msg, m.keyMap.Enter):
|
||||
m.gPressed = false
|
||||
if len(m.Shared.FilteredData) > 0 {
|
||||
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.searching = true
|
||||
m.searchInput = ""
|
||||
m.searchInput.SetValue("")
|
||||
m.searchInput.Focus()
|
||||
return m, nil
|
||||
|
||||
case "s":
|
||||
case key.Matches(msg, m.keyMap.SQLMode):
|
||||
m.gPressed = false
|
||||
return m, func() tea.Msg { return SwitchToQueryMsg{} }
|
||||
|
||||
case "r":
|
||||
case key.Matches(msg, m.keyMap.Refresh):
|
||||
m.gPressed = false
|
||||
if err := m.Shared.LoadTableData(); err == nil {
|
||||
m.filterData()
|
||||
}
|
||||
|
||||
case "up", "k":
|
||||
case key.Matches(msg, m.keyMap.Up):
|
||||
m.gPressed = false
|
||||
if m.selectedRow > 0 {
|
||||
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
|
||||
}
|
||||
|
||||
case "down", "j":
|
||||
case key.Matches(msg, m.keyMap.Down):
|
||||
m.gPressed = false
|
||||
if m.selectedRow < len(m.Shared.FilteredData)-1 {
|
||||
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
|
||||
if m.Shared.CurrentPage > 0 {
|
||||
m.Shared.CurrentPage--
|
||||
@@ -156,7 +233,7 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.selectedRow = 0
|
||||
}
|
||||
|
||||
case "right", "l":
|
||||
case key.Matches(msg, m.keyMap.Right):
|
||||
m.gPressed = false
|
||||
maxPage := (m.Shared.TotalRows - 1) / PageSize
|
||||
if m.Shared.CurrentPage < maxPage {
|
||||
@@ -173,7 +250,8 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m *TableDataModel) filterData() {
|
||||
if m.searchInput == "" {
|
||||
searchValue := m.searchInput.Value()
|
||||
if searchValue == "" {
|
||||
m.Shared.FilteredData = make([][]string, len(m.Shared.TableData))
|
||||
copy(m.Shared.FilteredData, m.Shared.TableData)
|
||||
} else {
|
||||
@@ -184,7 +262,7 @@ func (m *TableDataModel) filterData() {
|
||||
}
|
||||
|
||||
var matches []rowMatch
|
||||
searchLower := strings.ToLower(m.searchInput)
|
||||
searchLower := strings.ToLower(searchValue)
|
||||
|
||||
for _, row := range m.Shared.TableData {
|
||||
bestScore := 0
|
||||
@@ -298,11 +376,11 @@ func (m *TableDataModel) View() string {
|
||||
content.WriteString("\n")
|
||||
|
||||
if m.searching {
|
||||
content.WriteString("\nSearch: " + m.searchInput + "_")
|
||||
content.WriteString("\nSearch: " + m.searchInput.View())
|
||||
content.WriteString("\n")
|
||||
} else if m.searchInput != "" {
|
||||
} else if m.searchInput.Value() != "" {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -334,9 +412,7 @@ func (m *TableDataModel) View() string {
|
||||
if totalRows > visibleCount && m.selectedRow >= visibleCount {
|
||||
startIdx = m.selectedRow - visibleCount + 1
|
||||
// Ensure we don't scroll past the end
|
||||
if startIdx > totalRows-visibleCount {
|
||||
startIdx = totalRows - visibleCount
|
||||
}
|
||||
startIdx = min(startIdx, totalRows-visibleCount)
|
||||
}
|
||||
|
||||
endIdx := Min(totalRows, startIdx+visibleCount)
|
||||
@@ -364,8 +440,8 @@ func (m *TableDataModel) View() string {
|
||||
if m.searching {
|
||||
content.WriteString(HelpStyle.Render("Type to search • enter/esc: finish search"))
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
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"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
)
|
||||
|
||||
type TableListModel struct {
|
||||
Shared *SharedData
|
||||
searchInput string
|
||||
searchInput textinput.Model
|
||||
searching bool
|
||||
selectedTable int
|
||||
currentPage int
|
||||
gPressed bool
|
||||
keyMap TableListKeyMap
|
||||
help help.Model
|
||||
focused bool
|
||||
id int
|
||||
}
|
||||
|
||||
func NewTableListModel(shared *SharedData) *TableListModel {
|
||||
return &TableListModel{
|
||||
// TableListOption is a functional option for configuring 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,
|
||||
searchInput: searchInput,
|
||||
selectedTable: 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 {
|
||||
@@ -30,6 +88,12 @@ func (m *TableListModel) Init() 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) {
|
||||
case tea.KeyMsg:
|
||||
if m.searching {
|
||||
@@ -37,41 +101,50 @@ func (m *TableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
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) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
switch {
|
||||
case key.Matches(msg, m.keyMap.Escape):
|
||||
m.searching = false
|
||||
m.searchInput.Blur()
|
||||
// If there's an existing filter, clear it
|
||||
if m.searchInput != "" {
|
||||
m.searchInput = ""
|
||||
if m.searchInput.Value() != "" {
|
||||
m.searchInput.SetValue("")
|
||||
m.filterTables()
|
||||
}
|
||||
case "enter":
|
||||
case key.Matches(msg, m.keyMap.Enter):
|
||||
m.searching = false
|
||||
m.searchInput.Blur()
|
||||
m.filterTables()
|
||||
case "backspace":
|
||||
if len(m.searchInput) > 0 {
|
||||
m.searchInput = m.searchInput[:len(m.searchInput)-1]
|
||||
m.filterTables()
|
||||
}
|
||||
default:
|
||||
if len(msg.String()) == 1 {
|
||||
m.searchInput += msg.String()
|
||||
m.filterTables()
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||
m.filterTables()
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
if m.searchInput != "" {
|
||||
switch {
|
||||
case key.Matches(msg, m.keyMap.Escape):
|
||||
if m.searchInput.Value() != "" {
|
||||
// Clear search filter
|
||||
m.searchInput = ""
|
||||
m.searchInput.SetValue("")
|
||||
m.filterTables()
|
||||
m.gPressed = false
|
||||
return m, nil
|
||||
@@ -80,13 +153,14 @@ func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.gPressed = false
|
||||
return m, nil
|
||||
|
||||
case "/":
|
||||
case key.Matches(msg, m.keyMap.Search):
|
||||
m.searching = true
|
||||
m.searchInput = ""
|
||||
m.searchInput.SetValue("")
|
||||
m.searchInput.Focus()
|
||||
m.gPressed = false
|
||||
return m, nil
|
||||
|
||||
case "g":
|
||||
case key.Matches(msg, m.keyMap.GoToStart):
|
||||
if m.gPressed {
|
||||
// Second g - go to beginning
|
||||
m.selectedTable = 0
|
||||
@@ -98,7 +172,7 @@ func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "G":
|
||||
case key.Matches(msg, m.keyMap.GoToEnd):
|
||||
// Go to end
|
||||
if len(m.Shared.FilteredTables) > 0 {
|
||||
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
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
case key.Matches(msg, m.keyMap.Enter):
|
||||
m.gPressed = false
|
||||
if len(m.Shared.FilteredTables) > 0 {
|
||||
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
|
||||
return m, func() tea.Msg { return SwitchToQueryMsg{} }
|
||||
|
||||
case "r":
|
||||
case key.Matches(msg, m.keyMap.Refresh):
|
||||
m.gPressed = false
|
||||
if err := m.Shared.LoadTables(); err == nil {
|
||||
m.filterTables()
|
||||
}
|
||||
|
||||
case "up", "k":
|
||||
case key.Matches(msg, m.keyMap.Up):
|
||||
m.gPressed = false
|
||||
if m.selectedTable > 0 {
|
||||
m.selectedTable--
|
||||
m.adjustPage()
|
||||
}
|
||||
|
||||
case "down", "j":
|
||||
case key.Matches(msg, m.keyMap.Down):
|
||||
m.gPressed = false
|
||||
if m.selectedTable < len(m.Shared.FilteredTables)-1 {
|
||||
m.selectedTable++
|
||||
m.adjustPage()
|
||||
}
|
||||
|
||||
case "left", "h":
|
||||
case key.Matches(msg, m.keyMap.Left):
|
||||
m.gPressed = false
|
||||
if m.currentPage > 0 {
|
||||
m.currentPage--
|
||||
m.selectedTable = m.currentPage * m.getVisibleCount()
|
||||
}
|
||||
|
||||
case "right", "l":
|
||||
case key.Matches(msg, m.keyMap.Right):
|
||||
m.gPressed = false
|
||||
maxPage := (len(m.Shared.FilteredTables) - 1) / m.getVisibleCount()
|
||||
if m.currentPage < maxPage {
|
||||
@@ -165,7 +239,8 @@ func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m *TableListModel) filterTables() {
|
||||
if m.searchInput == "" {
|
||||
searchValue := m.searchInput.Value()
|
||||
if searchValue == "" {
|
||||
m.Shared.FilteredTables = make([]string, len(m.Shared.Tables))
|
||||
copy(m.Shared.FilteredTables, m.Shared.Tables)
|
||||
} else {
|
||||
@@ -176,7 +251,7 @@ func (m *TableListModel) filterTables() {
|
||||
}
|
||||
|
||||
var matches []tableMatch
|
||||
searchLower := strings.ToLower(m.searchInput)
|
||||
searchLower := strings.ToLower(searchValue)
|
||||
|
||||
for _, table := range m.Shared.Tables {
|
||||
score := m.fuzzyScore(strings.ToLower(table), searchLower)
|
||||
@@ -291,17 +366,17 @@ func (m *TableListModel) View() string {
|
||||
content.WriteString("\n")
|
||||
|
||||
if m.searching {
|
||||
content.WriteString("\nSearch: " + m.searchInput + "_")
|
||||
content.WriteString("\nSearch: " + m.searchInput.View())
|
||||
content.WriteString("\n")
|
||||
} else if m.searchInput != "" {
|
||||
} else if m.searchInput.Value() != "" {
|
||||
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")
|
||||
|
||||
if len(m.Shared.FilteredTables) == 0 {
|
||||
if m.searchInput != "" {
|
||||
if m.searchInput.Value() != "" {
|
||||
content.WriteString("No tables match your search")
|
||||
} else {
|
||||
content.WriteString("No tables found in database")
|
||||
@@ -331,8 +406,8 @@ func (m *TableListModel) View() string {
|
||||
if m.searching {
|
||||
content.WriteString(HelpStyle.Render("Type to search • enter/esc: finish search"))
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
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