diff --git a/README.md b/README.md index 30c87bd..4ea78a0 100644 --- a/README.md +++ b/README.md @@ -73,11 +73,18 @@ go run main.go sample.db ### SQL Query Mode - **Advanced Text Editing**: Full readline-style editing controls -- **Cursor Movement**: `←/→` arrows, `Ctrl+←/→` for word navigation -- **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) +- **Dual Focus Mode**: Switch between query input and results with `Tab` +- **Query Input Focus**: + - **Cursor Movement**: `←/→` arrows, `Ctrl+←/→` for word navigation + - **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) + - `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) -- `Enter`: Execute query - `Esc`: Return to table list - `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 10. **Primary Key Detection**: Uses primary keys for reliable row updates 11. **Screen-Aware Display**: Content automatically fits terminal size -12. **SQL Query Execution**: Execute custom SQL queries with advanced text editing -13. **Error Handling**: Displays database errors gracefully -14. **Responsive UI**: Clean, styled interface that adapts to terminal size -15. **Column Information**: Shows column names and handles NULL values -16. **Navigation**: Intuitive keyboard shortcuts for all operations -17. **Dynamic Column Width**: Columns adjust to terminal width +12. **SQL Query Execution**: Execute custom SQL queries with advanced text editing and dual-focus mode +13. **Query Results Navigation**: Navigate and interact with query results just like table data +14. **Error Handling**: Displays database errors gracefully +15. **Responsive UI**: Clean, styled interface that adapts to terminal size +16. **Column Information**: Shows column names and handles NULL values +17. **Navigation**: Intuitive keyboard shortcuts for all operations +18. **Dynamic Column Width**: Columns adjust to terminal width ## Navigation Flow diff --git a/main.go b/main.go index 5702d13..295049d 100644 --- a/main.go +++ b/main.go @@ -18,14 +18,16 @@ const ( // Custom message types type ( - switchToTableListMsg struct{} - switchToTableDataMsg struct{ tableIndex int } - switchToRowDetailMsg struct{ rowIndex int } - switchToEditCellMsg struct{ rowIndex, colIndex int } - switchToQueryMsg struct{} - refreshDataMsg struct{} - updateCellMsg struct{ rowIndex, colIndex int; value string } - executeQueryMsg struct{ query string } + switchToTableListMsg struct{} + switchToTableDataMsg struct{ tableIndex int } + switchToRowDetailMsg struct{ rowIndex int } + switchToRowDetailFromQueryMsg struct{ rowIndex int } + switchToEditCellMsg struct{ rowIndex, colIndex int } + switchToQueryMsg struct{} + returnToQueryMsg struct{} // Return to query mode from row detail + refreshDataMsg struct{} + updateCellMsg struct{ rowIndex, colIndex int; value string } + executeQueryMsg struct{ query string } ) // Common interface for all models @@ -378,6 +380,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.currentView = newRowDetailModel(m.getSharedData(), msg.rowIndex) return m, nil + case switchToRowDetailFromQueryMsg: + rowDetail := newRowDetailModel(m.getSharedData(), msg.rowIndex) + rowDetail.fromQuery = true + m.currentView = rowDetail + return m, nil + case switchToEditCellMsg: m.currentView = newEditCellModel(m.getSharedData(), msg.rowIndex, msg.colIndex) return m, nil @@ -386,6 +394,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.currentView = newQueryModel(m.getSharedData()) 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: shared := m.getSharedData() if err := shared.loadTableData(); err != nil { diff --git a/query.go b/query.go index 0f3a80c..eb45f62 100644 --- a/query.go +++ b/query.go @@ -9,16 +9,20 @@ import ( // Query Model type queryModel struct { - shared *sharedData - queryInput string - cursorPos int - results [][]string - columns []string + shared *sharedData + queryInput string + cursorPos int + results [][]string + columns []string + focusOnInput bool // true = input focused, false = results focused + selectedRow int } func newQueryModel(shared *sharedData) *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": return m, func() tea.Msg { return switchToTableListMsg{} } - case "enter": - if err := m.executeQuery(); err != nil { - // Handle error - could set an error field + 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": + if m.focusOnInput { + // Execute query when input is focused + if err := m.executeQuery(); err != nil { + // 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 case "left": if m.cursorPos > 0 { @@ -104,6 +144,36 @@ func (m *queryModel) handleInput(msg tea.KeyMsg) (subModel, tea.Cmd) { 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 func (m *queryModel) wordLeft(pos int) int { if pos == 0 { @@ -188,6 +258,11 @@ func (m *queryModel) executeQuery() error { 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 } @@ -202,23 +277,40 @@ func (m *queryModel) View() string { content.WriteString(titleStyle.Render("SQL Query")) content.WriteString("\n\n") - // Display query with cursor - content.WriteString("Query: ") - if m.cursorPos <= len(m.queryInput) { - before := m.queryInput[:m.cursorPos] - after := m.queryInput[m.cursorPos:] - content.WriteString(before) - content.WriteString("█") // Block cursor - content.WriteString(after) + // Display query with cursor and focus indicator + if m.focusOnInput { + content.WriteString("Query: ") + } else { + content.WriteString(helpStyle.Render("Query: ")) + } + + if m.focusOnInput { + if m.cursorPos <= len(m.queryInput) { + before := m.queryInput[:m.cursorPos] + after := m.queryInput[m.cursorPos:] + content.WriteString(before) + content.WriteString("█") // Block cursor + content.WriteString(after) + } else { + content.WriteString(m.queryInput) + content.WriteString("█") + } } else { content.WriteString(m.queryInput) - content.WriteString("█") } content.WriteString("\n\n") 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 - visibleRows := m.getVisibleRowCount() + visibleRows := m.getVisibleRowCount() - 2 // Account for results header displayRows := min(len(m.results), visibleRows) // Show query results @@ -256,7 +348,13 @@ func (m *queryModel) View() string { } dataRow.WriteString(fmt.Sprintf("%-*s", colWidth, truncateString(cell, colWidth))) } - content.WriteString(normalStyle.Render(dataRow.String())) + + // 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("\n") } @@ -267,7 +365,11 @@ func (m *queryModel) View() string { } 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() } \ No newline at end of file diff --git a/row_detail.go b/row_detail.go index f8c280c..359986c 100644 --- a/row_detail.go +++ b/row_detail.go @@ -12,6 +12,7 @@ type rowDetailModel struct { shared *sharedData rowIndex int selectedCol int + fromQuery bool // true if came from query results } func newRowDetailModel(shared *sharedData, rowIndex int) *rowDetailModel { @@ -19,6 +20,7 @@ func newRowDetailModel(shared *sharedData, rowIndex int) *rowDetailModel { shared: shared, rowIndex: rowIndex, selectedCol: 0, + fromQuery: false, // default to false, will be set by caller if needed } } @@ -37,8 +39,12 @@ func (m *rowDetailModel) Update(msg tea.Msg) (subModel, tea.Cmd) { func (m *rowDetailModel) handleNavigation(msg tea.KeyMsg) (subModel, tea.Cmd) { switch msg.String() { case "esc": - return m, func() tea.Msg { - return switchToTableDataMsg{tableIndex: m.shared.selectedTable} + if m.fromQuery { + return m, func() tea.Msg { return returnToQueryMsg{} } + } else { + return m, func() tea.Msg { + return switchToTableDataMsg{tableIndex: m.shared.selectedTable} + } } case "enter":