enable swapping focus into the table

This commit is contained in:
2025-07-11 23:24:25 -07:00
parent e79d20f7ce
commit c32d200c15
4 changed files with 176 additions and 41 deletions

View File

@@ -73,11 +73,18 @@ go run main.go sample.db
### SQL Query Mode ### SQL Query Mode
- **Advanced Text Editing**: Full readline-style editing controls - **Advanced Text Editing**: Full readline-style editing controls
- **Dual Focus Mode**: Switch between query input and results with `Tab`
- **Query Input Focus**:
- **Cursor Movement**: `←/→` arrows, `Ctrl+←/→` for word navigation - **Cursor Movement**: `←/→` arrows, `Ctrl+←/→` for word navigation
- **Line Navigation**: `Home`/`Ctrl+A` (start), `End`/`Ctrl+E` (end) - **Line Navigation**: `Home`/`Ctrl+A` (start), `End`/`Ctrl+E` (end)
- **Deletion**: `Backspace`, `Delete`/`Ctrl+D`, `Ctrl+W` (word), `Ctrl+K` (to end), `Ctrl+U` (to start) - **Deletion**: `Backspace`, `Delete`/`Ctrl+D`, `Ctrl+W` (word), `Ctrl+K` (to end), `Ctrl+U` (to start)
- Type your SQL query (all keys work as input, no conflicts with navigation)
- `Enter`: Execute query - `Enter`: Execute query
- `Tab`: Switch focus to results (when available)
- **Results Focus**:
- `↑/↓` or `k/j`: Navigate between result rows
- `Enter`: View selected row in detail modal
- `Tab`: Switch focus back to query input
- Type your SQL query (all keys work as input, no conflicts with navigation)
- `Esc`: Return to table list - `Esc`: Return to table list
- `q` or `Ctrl+C`: Quit - `q` or `Ctrl+C`: Quit
@@ -94,12 +101,13 @@ go run main.go sample.db
9. **Text Wrapping**: Long values are automatically wrapped in edit and detail views 9. **Text Wrapping**: Long values are automatically wrapped in edit and detail views
10. **Primary Key Detection**: Uses primary keys for reliable row updates 10. **Primary Key Detection**: Uses primary keys for reliable row updates
11. **Screen-Aware Display**: Content automatically fits terminal size 11. **Screen-Aware Display**: Content automatically fits terminal size
12. **SQL Query Execution**: Execute custom SQL queries with advanced text editing 12. **SQL Query Execution**: Execute custom SQL queries with advanced text editing and dual-focus mode
13. **Error Handling**: Displays database errors gracefully 13. **Query Results Navigation**: Navigate and interact with query results just like table data
14. **Responsive UI**: Clean, styled interface that adapts to terminal size 14. **Error Handling**: Displays database errors gracefully
15. **Column Information**: Shows column names and handles NULL values 15. **Responsive UI**: Clean, styled interface that adapts to terminal size
16. **Navigation**: Intuitive keyboard shortcuts for all operations 16. **Column Information**: Shows column names and handles NULL values
17. **Dynamic Column Width**: Columns adjust to terminal width 17. **Navigation**: Intuitive keyboard shortcuts for all operations
18. **Dynamic Column Width**: Columns adjust to terminal width
## Navigation Flow ## Navigation Flow

19
main.go
View File

@@ -21,8 +21,10 @@ type (
switchToTableListMsg struct{} switchToTableListMsg struct{}
switchToTableDataMsg struct{ tableIndex int } switchToTableDataMsg struct{ tableIndex int }
switchToRowDetailMsg struct{ rowIndex int } switchToRowDetailMsg struct{ rowIndex int }
switchToRowDetailFromQueryMsg struct{ rowIndex int }
switchToEditCellMsg struct{ rowIndex, colIndex int } switchToEditCellMsg struct{ rowIndex, colIndex int }
switchToQueryMsg struct{} switchToQueryMsg struct{}
returnToQueryMsg struct{} // Return to query mode from row detail
refreshDataMsg struct{} refreshDataMsg struct{}
updateCellMsg struct{ rowIndex, colIndex int; value string } updateCellMsg struct{ rowIndex, colIndex int; value string }
executeQueryMsg struct{ query string } executeQueryMsg struct{ query string }
@@ -378,6 +380,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.currentView = newRowDetailModel(m.getSharedData(), msg.rowIndex) m.currentView = newRowDetailModel(m.getSharedData(), msg.rowIndex)
return m, nil return m, nil
case switchToRowDetailFromQueryMsg:
rowDetail := newRowDetailModel(m.getSharedData(), msg.rowIndex)
rowDetail.fromQuery = true
m.currentView = rowDetail
return m, nil
case switchToEditCellMsg: case switchToEditCellMsg:
m.currentView = newEditCellModel(m.getSharedData(), msg.rowIndex, msg.colIndex) m.currentView = newEditCellModel(m.getSharedData(), msg.rowIndex, msg.colIndex)
return m, nil return m, nil
@@ -386,6 +394,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.currentView = newQueryModel(m.getSharedData()) m.currentView = newQueryModel(m.getSharedData())
return m, nil return m, nil
case returnToQueryMsg:
// Return to query mode, preserving the query state if possible
if queryView, ok := m.currentView.(*queryModel); ok {
// If we're already in query mode, just switch focus back to results
queryView.focusOnInput = false
} else {
// Create new query model
m.currentView = newQueryModel(m.getSharedData())
}
return m, nil
case refreshDataMsg: case refreshDataMsg:
shared := m.getSharedData() shared := m.getSharedData()
if err := shared.loadTableData(); err != nil { if err := shared.loadTableData(); err != nil {

108
query.go
View File

@@ -14,11 +14,15 @@ type queryModel struct {
cursorPos int cursorPos int
results [][]string results [][]string
columns []string columns []string
focusOnInput bool // true = input focused, false = results focused
selectedRow int
} }
func newQueryModel(shared *sharedData) *queryModel { func newQueryModel(shared *sharedData) *queryModel {
return &queryModel{ return &queryModel{
shared: shared, shared: shared,
focusOnInput: true, // Start with input focused
selectedRow: 0,
} }
} }
@@ -39,11 +43,47 @@ func (m *queryModel) handleInput(msg tea.KeyMsg) (subModel, tea.Cmd) {
case "esc": case "esc":
return m, func() tea.Msg { return switchToTableListMsg{} } return m, func() tea.Msg { return switchToTableListMsg{} }
case "tab":
// Switch focus between input and results
if len(m.results) > 0 {
m.focusOnInput = !m.focusOnInput
if !m.focusOnInput {
// Reset row selection when switching to results
m.selectedRow = 0
}
}
return m, nil
case "enter": case "enter":
if m.focusOnInput {
// Execute query when input is focused
if err := m.executeQuery(); err != nil { if err := m.executeQuery(); err != nil {
// Handle error - could set an error field // Handle error - could set an error field
} }
} else {
// View row detail when results are focused
if len(m.results) > 0 && m.selectedRow < len(m.results) {
// Convert query results to shared data format for row detail view
m.shared.filteredData = m.results
m.shared.columns = m.columns
return m, func() tea.Msg {
return switchToRowDetailFromQueryMsg{rowIndex: m.selectedRow}
}
}
}
return m, nil
}
// Handle input-specific controls
if m.focusOnInput {
return m.handleInputControls(msg)
} else {
return m.handleResultsNavigation(msg)
}
}
func (m *queryModel) handleInputControls(msg tea.KeyMsg) (subModel, tea.Cmd) {
switch msg.String() {
// Cursor movement // Cursor movement
case "left": case "left":
if m.cursorPos > 0 { if m.cursorPos > 0 {
@@ -104,6 +144,36 @@ func (m *queryModel) handleInput(msg tea.KeyMsg) (subModel, tea.Cmd) {
return m, nil return m, nil
} }
func (m *queryModel) handleResultsNavigation(msg tea.KeyMsg) (subModel, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.selectedRow > 0 {
m.selectedRow--
}
case "down", "j":
if m.selectedRow < len(m.results)-1 {
m.selectedRow++
}
case "home":
m.selectedRow = 0
case "end":
if len(m.results) > 0 {
m.selectedRow = len(m.results) - 1
}
// Page navigation
case "page_up":
m.selectedRow = max(0, m.selectedRow-10)
case "page_down":
m.selectedRow = min(len(m.results)-1, m.selectedRow+10)
}
return m, nil
}
// Helper functions for word navigation // Helper functions for word navigation
func (m *queryModel) wordLeft(pos int) int { func (m *queryModel) wordLeft(pos int) int {
if pos == 0 { if pos == 0 {
@@ -188,6 +258,11 @@ func (m *queryModel) executeQuery() error {
m.results = append(m.results, row) m.results = append(m.results, row)
} }
// Reset selection when new results are loaded
m.selectedRow = 0
// Keep focus on input after executing query
m.focusOnInput = true
return nil return nil
} }
@@ -202,8 +277,14 @@ func (m *queryModel) View() string {
content.WriteString(titleStyle.Render("SQL Query")) content.WriteString(titleStyle.Render("SQL Query"))
content.WriteString("\n\n") content.WriteString("\n\n")
// Display query with cursor // Display query with cursor and focus indicator
if m.focusOnInput {
content.WriteString("Query: ") content.WriteString("Query: ")
} else {
content.WriteString(helpStyle.Render("Query: "))
}
if m.focusOnInput {
if m.cursorPos <= len(m.queryInput) { if m.cursorPos <= len(m.queryInput) {
before := m.queryInput[:m.cursorPos] before := m.queryInput[:m.cursorPos]
after := m.queryInput[m.cursorPos:] after := m.queryInput[m.cursorPos:]
@@ -214,11 +295,22 @@ func (m *queryModel) View() string {
content.WriteString(m.queryInput) content.WriteString(m.queryInput)
content.WriteString("█") content.WriteString("█")
} }
} else {
content.WriteString(m.queryInput)
}
content.WriteString("\n\n") content.WriteString("\n\n")
if len(m.results) > 0 { if len(m.results) > 0 {
// Show results header with focus indicator
if !m.focusOnInput {
content.WriteString(titleStyle.Render("Results (focused)"))
} else {
content.WriteString(helpStyle.Render("Results"))
}
content.WriteString("\n")
// Limit rows to fit screen // Limit rows to fit screen
visibleRows := m.getVisibleRowCount() visibleRows := m.getVisibleRowCount() - 2 // Account for results header
displayRows := min(len(m.results), visibleRows) displayRows := min(len(m.results), visibleRows)
// Show query results // Show query results
@@ -256,7 +348,13 @@ func (m *queryModel) View() string {
} }
dataRow.WriteString(fmt.Sprintf("%-*s", colWidth, truncateString(cell, colWidth))) dataRow.WriteString(fmt.Sprintf("%-*s", colWidth, truncateString(cell, colWidth)))
} }
// Highlight selected row when results are focused
if !m.focusOnInput && i == m.selectedRow {
content.WriteString(selectedStyle.Render(dataRow.String()))
} else {
content.WriteString(normalStyle.Render(dataRow.String())) content.WriteString(normalStyle.Render(dataRow.String()))
}
content.WriteString("\n") content.WriteString("\n")
} }
@@ -267,7 +365,11 @@ func (m *queryModel) View() string {
} }
content.WriteString("\n") content.WriteString("\n")
content.WriteString(helpStyle.Render("enter: execute • ←/→: move cursor • ctrl+←/→: word nav • home/end: line nav • ctrl+w/k/u: delete • esc: back")) if m.focusOnInput {
content.WriteString(helpStyle.Render("enter: execute • tab: focus results • ←/→: cursor • ctrl+←/→: word nav • home/end: line nav • esc: back"))
} else {
content.WriteString(helpStyle.Render("↑/↓: select row • enter: view row • tab: focus input • esc: back"))
}
return content.String() return content.String()
} }

View File

@@ -12,6 +12,7 @@ type rowDetailModel struct {
shared *sharedData shared *sharedData
rowIndex int rowIndex int
selectedCol int selectedCol int
fromQuery bool // true if came from query results
} }
func newRowDetailModel(shared *sharedData, rowIndex int) *rowDetailModel { func newRowDetailModel(shared *sharedData, rowIndex int) *rowDetailModel {
@@ -19,6 +20,7 @@ func newRowDetailModel(shared *sharedData, rowIndex int) *rowDetailModel {
shared: shared, shared: shared,
rowIndex: rowIndex, rowIndex: rowIndex,
selectedCol: 0, selectedCol: 0,
fromQuery: false, // default to false, will be set by caller if needed
} }
} }
@@ -37,9 +39,13 @@ func (m *rowDetailModel) Update(msg tea.Msg) (subModel, tea.Cmd) {
func (m *rowDetailModel) handleNavigation(msg tea.KeyMsg) (subModel, tea.Cmd) { func (m *rowDetailModel) handleNavigation(msg tea.KeyMsg) (subModel, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "esc": case "esc":
if m.fromQuery {
return m, func() tea.Msg { return returnToQueryMsg{} }
} else {
return m, func() tea.Msg { return m, func() tea.Msg {
return switchToTableDataMsg{tableIndex: m.shared.selectedTable} return switchToTableDataMsg{tableIndex: m.shared.selectedTable}
} }
}
case "enter": case "enter":
if len(m.shared.filteredData) > 0 && m.rowIndex < len(m.shared.filteredData) && if len(m.shared.filteredData) > 0 && m.rowIndex < len(m.shared.filteredData) &&