diff --git a/internal/app/query.go b/internal/app/query.go index 8d9fe43..d421a47 100644 --- a/internal/app/query.go +++ b/internal/app/query.go @@ -19,6 +19,7 @@ type QueryModel struct { columns []string err error blinkState bool + gPressed bool } func NewQueryModel(shared *SharedData) *QueryModel { @@ -106,13 +107,35 @@ func (m *QueryModel) handleQueryInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *QueryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc", "q": + m.gPressed = false return m, func() tea.Msg { return SwitchToTableListMsg{} } + case "g": + if m.gPressed { + // Second g - go to beginning + m.selectedRow = 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.results) > 0 { + m.selectedRow = len(m.results) - 1 + } + m.gPressed = false + return m, nil + case "i": + m.gPressed = false m.FocusOnInput = true return m, nil case "enter": + m.gPressed = false if len(m.results) > 0 { return m, func() tea.Msg { return SwitchToRowDetailFromQueryMsg{RowIndex: m.selectedRow} @@ -120,14 +143,20 @@ func (m *QueryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd } case "up", "k": + m.gPressed = false if m.selectedRow > 0 { m.selectedRow-- } case "down", "j": + m.gPressed = false if m.selectedRow < len(m.results)-1 { m.selectedRow++ } + + default: + // Any other key resets the g state + m.gPressed = false } return m, nil } @@ -460,7 +489,7 @@ func (m *QueryModel) View() string { if m.FocusOnInput { content.WriteString(HelpStyle.Render("enter: execute • esc: back • ctrl+w: delete word • ctrl+arrows: word nav")) } else { - content.WriteString(HelpStyle.Render("↑/↓: navigate • enter: details • i: edit query • q: back")) + content.WriteString(HelpStyle.Render("↑/↓: navigate • enter: details • i: edit query • gg/G: first/last • q: back")) } return content.String() diff --git a/internal/app/row_detail.go b/internal/app/row_detail.go index d625387..273cf2e 100644 --- a/internal/app/row_detail.go +++ b/internal/app/row_detail.go @@ -12,6 +12,7 @@ type RowDetailModel struct { rowIndex int selectedCol int FromQuery bool + gPressed bool } func NewRowDetailModel(shared *SharedData, rowIndex int) *RowDetailModel { @@ -31,12 +32,33 @@ func (m *RowDetailModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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} } + 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} @@ -44,14 +66,20 @@ func (m *RowDetailModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } 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, nil @@ -84,7 +112,7 @@ func (m *RowDetailModel) View() string { } content.WriteString("\n") - content.WriteString(HelpStyle.Render("↑/↓: navigate columns • e: edit • q: back")) + content.WriteString(HelpStyle.Render("↑/↓: navigate columns • e: edit • gg/G: first/last • q: back")) return content.String() } diff --git a/internal/app/table_data.go b/internal/app/table_data.go index d448c1b..404da69 100644 --- a/internal/app/table_data.go +++ b/internal/app/table_data.go @@ -12,6 +12,7 @@ type TableDataModel struct { selectedRow int searchInput string searching bool + gPressed bool } func NewTableDataModel(shared *SharedData) *TableDataModel { @@ -58,9 +59,11 @@ func (m *TableDataModel) handleSearchInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q": + m.gPressed = false return m, func() tea.Msg { return SwitchToTableListMsg{} } case "esc": + m.gPressed = false if m.searchInput != "" { // Clear search filter m.searchInput = "" @@ -69,7 +72,32 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, func() tea.Msg { return SwitchToTableListMsg{} } + case "g": + if m.gPressed { + // Second g - go to absolute beginning + m.Shared.CurrentPage = 0 + m.Shared.LoadTableData() + m.filterData() + m.selectedRow = 0 + m.gPressed = false + } else { + // First g - wait for second g + m.gPressed = true + } + return m, nil + + case "G": + // Go to absolute end + maxPage := (m.Shared.TotalRows - 1) / PageSize + m.Shared.CurrentPage = maxPage + m.Shared.LoadTableData() + m.filterData() + m.selectedRow = len(m.Shared.FilteredData) - 1 + m.gPressed = false + return m, nil + case "enter": + m.gPressed = false if len(m.Shared.FilteredData) > 0 { return m, func() tea.Msg { return SwitchToRowDetailMsg{RowIndex: m.selectedRow} @@ -77,29 +105,50 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "/": + m.gPressed = false m.searching = true m.searchInput = "" return m, nil case "s": + m.gPressed = false return m, func() tea.Msg { return SwitchToQueryMsg{} } case "r": + m.gPressed = false if err := m.Shared.LoadTableData(); err == nil { m.filterData() } case "up", "k": + m.gPressed = false if m.selectedRow > 0 { m.selectedRow-- + } else if m.Shared.CurrentPage > 0 { + // At top of current page, go to previous page + m.Shared.CurrentPage-- + m.Shared.LoadTableData() + m.filterData() + m.selectedRow = len(m.Shared.FilteredData) - 1 // Go to last row of previous page } case "down", "j": + m.gPressed = false if m.selectedRow < len(m.Shared.FilteredData)-1 { m.selectedRow++ + } else { + // At bottom of current page, try to go to next page + maxPage := (m.Shared.TotalRows - 1) / PageSize + if m.Shared.CurrentPage < maxPage { + m.Shared.CurrentPage++ + m.Shared.LoadTableData() + m.filterData() + m.selectedRow = 0 // Go to first row of next page + } } case "left", "h": + m.gPressed = false if m.Shared.CurrentPage > 0 { m.Shared.CurrentPage-- m.Shared.LoadTableData() @@ -107,12 +156,17 @@ func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "right", "l": + m.gPressed = false maxPage := (m.Shared.TotalRows - 1) / PageSize if m.Shared.CurrentPage < maxPage { m.Shared.CurrentPage++ m.Shared.LoadTableData() m.selectedRow = 0 } + + default: + // Any other key resets the g state + m.gPressed = false } return m, nil } @@ -178,10 +232,21 @@ func (m *TableDataModel) View() string { content.WriteString(TitleStyle.Render(headerRow)) content.WriteString("\n") - // Show data rows + // Show data rows with scrolling within current page visibleCount := Max(1, m.Shared.Height-10) + totalRows := len(m.Shared.FilteredData) startIdx := 0 - endIdx := Min(len(m.Shared.FilteredData), visibleCount) + + // If there are more rows than can fit on screen, scroll the view + 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 + } + } + + endIdx := Min(totalRows, startIdx+visibleCount) for i := startIdx; i < endIdx; i++ { row := m.Shared.FilteredData[i] @@ -206,7 +271,7 @@ 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 • q: back")) + content.WriteString(HelpStyle.Render("↑/↓: navigate • ←/→: page • /: search • enter: details • s: SQL • r: refresh • gg/G: first/last • q: back")) } return content.String() diff --git a/internal/app/table_list.go b/internal/app/table_list.go index 103d43e..a57251a 100644 --- a/internal/app/table_list.go +++ b/internal/app/table_list.go @@ -13,6 +13,7 @@ type TableListModel struct { searching bool selectedTable int currentPage int + gPressed bool } func NewTableListModel(shared *SharedData) *TableListModel { @@ -59,12 +60,47 @@ func (m *TableListModel) handleSearchInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { + case "esc": + if m.searchInput != "" { + // Clear search filter + m.searchInput = "" + m.filterTables() + m.gPressed = false + return m, nil + } + // If no filter, escape does nothing (could exit app but that's handled at higher level) + m.gPressed = false + return m, nil + case "/": m.searching = true m.searchInput = "" + m.gPressed = false + return m, nil + + case "g": + if m.gPressed { + // Second g - go to beginning + m.selectedTable = 0 + m.currentPage = 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.FilteredTables) > 0 { + m.selectedTable = len(m.Shared.FilteredTables) - 1 + m.adjustPage() + } + m.gPressed = false return m, nil case "enter": + m.gPressed = false if len(m.Shared.FilteredTables) > 0 { return m, func() tea.Msg { return SwitchToTableDataMsg{TableIndex: m.selectedTable} @@ -72,32 +108,38 @@ func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "s": + m.gPressed = false return m, func() tea.Msg { return SwitchToQueryMsg{} } case "r": + m.gPressed = false if err := m.Shared.LoadTables(); err == nil { m.filterTables() } case "up", "k": + m.gPressed = false if m.selectedTable > 0 { m.selectedTable-- m.adjustPage() } case "down", "j": + m.gPressed = false if m.selectedTable < len(m.Shared.FilteredTables)-1 { m.selectedTable++ m.adjustPage() } case "left", "h": + m.gPressed = false if m.currentPage > 0 { m.currentPage-- m.selectedTable = m.currentPage * m.getVisibleCount() } case "right", "l": + m.gPressed = false maxPage := (len(m.Shared.FilteredTables) - 1) / m.getVisibleCount() if m.currentPage < maxPage { m.currentPage++ @@ -106,6 +148,10 @@ func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.selectedTable = len(m.Shared.FilteredTables) - 1 } } + + default: + // Any other key resets the g state + m.gPressed = false } return m, nil } @@ -190,7 +236,7 @@ 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 • ctrl+c: quit")) + content.WriteString(HelpStyle.Render("↑/↓: navigate • ←/→: page • /: search • enter: view • s: SQL • r: refresh • gg/G: first/last • ctrl+c: quit")) } return content.String()