fix(backend): resolve table header jumping

This commit is contained in:
pycook
2025-08-11 16:11:46 +08:00
parent 5e5e9396af
commit c22a16192f
3 changed files with 307 additions and 114 deletions

View File

@@ -0,0 +1,73 @@
package assetlist
import (
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/veops/oneterm/internal/sshsrv/colors"
)
// FilterModel represents the filter input model
type FilterModel struct {
textInput textinput.Model
active bool
}
// NewFilter creates a new filter model
func NewFilter() FilterModel {
ti := textinput.New()
ti.Placeholder = "Type to filter..."
ti.CharLimit = 50
ti.Width = 20 // Reduce width to avoid jumping
ti.Prompt = ">" // Simple prompt
ti.PromptStyle = lipgloss.NewStyle().Foreground(colors.PrimaryColor)
ti.TextStyle = lipgloss.NewStyle().Foreground(colors.TextPrimary)
return FilterModel{
textInput: ti,
active: false,
}
}
// Active returns whether the filter is active
func (f FilterModel) Active() bool {
return f.active
}
// Value returns the current filter value
func (f FilterModel) Value() string {
return f.textInput.Value()
}
// SetActive sets the filter active state
func (f *FilterModel) SetActive(active bool) {
f.active = active
if active {
f.textInput.Focus()
f.textInput.Reset() // Clear any previous input
} else {
f.textInput.Blur()
f.textInput.Reset()
}
}
// Update handles filter input updates
func (f FilterModel) Update(msg tea.Msg) (FilterModel, tea.Cmd) {
if !f.active {
return f, nil
}
var cmd tea.Cmd
f.textInput, cmd = f.textInput.Update(msg)
return f, cmd
}
// View renders the filter input
func (f FilterModel) View() string {
if !f.active {
return ""
}
// Just return the textinput view as-is
return f.textInput.View()
}

View File

@@ -9,7 +9,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/samber/lo"
"github.com/veops/oneterm/internal/sshsrv/icons"
)
@@ -94,15 +94,16 @@ type Asset struct {
// Model represents the asset list table model
type Model struct {
table table.Model
assets []Asset
table table.Model
assets []Asset
filteredAssets []Asset
filter string
width int
height int
focused bool
keyMap TableKeyMap
showHelp bool
filter string
filterModel FilterModel
width int
height int
focused bool
keyMap TableKeyMap
showHelp bool
}
// New creates a new asset list table
@@ -114,7 +115,7 @@ func New(assets map[string][3]int, width, height int) Model {
if len(parts) >= 2 {
protocol := parts[0]
userHost := parts[1]
// Parse user@host format
var user, host string
if idx := strings.Index(userHost, "@"); idx > 0 {
@@ -124,7 +125,7 @@ func New(assets map[string][3]int, width, height int) Model {
user = "unknown"
host = userHost
}
// Extract port if present
port := ""
if len(parts) > 2 {
@@ -134,7 +135,7 @@ func New(assets map[string][3]int, width, height int) Model {
host = strings.TrimSuffix(host, ":"+port)
}
}
assetList = append(assetList, Asset{
Protocol: protocol,
Command: cmd,
@@ -145,18 +146,18 @@ func New(assets map[string][3]int, width, height int) Model {
})
}
}
// Assets are stored in the order they were found
// Create table columns
columns := []table.Column{
{Title: "Protocol", Width: 12}, // Increased for emoji + text
{Title: "Protocol", Width: 12}, // Increased for emoji + text
{Title: "User", Width: 15},
{Title: "Host", Width: 25},
{Title: "Port", Width: 8},
{Title: "Command", Width: 40},
}
// Create table rows
rows := make([]table.Row, len(assetList))
for i, asset := range assetList {
@@ -169,28 +170,32 @@ func New(assets map[string][3]int, width, height int) Model {
asset.Command,
}
}
// Create table
// Calculate viewport height based on terminal size
// We need to account for:
// - Title: 1 line
// - Table header: 3 lines (with borders)
// - Help text: 2 lines
// - Borders and padding: 4 lines
// Total overhead: ~10 lines
viewportHeight := len(assetList) // Show all rows if possible
// Table height includes header (1) + separator (1) + data rows
viewportHeight := len(assetList) + 2
if viewportHeight < 3 {
viewportHeight = 3 // At least header + separator + 1 data row
}
// Calculate max available height (account for UI overhead)
// Overhead: title(1) + header(2) + borders(2) + help(2) = 7 lines
maxViewportHeight := height - 10
if maxViewportHeight > 5 && viewportHeight > maxViewportHeight {
if maxViewportHeight < 5 {
maxViewportHeight = 5 // Minimum usable height
}
// Use actual row count for small sets, cap for large sets
if viewportHeight > maxViewportHeight {
viewportHeight = maxViewportHeight
}
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(viewportHeight),
)
// Style the table with primary colors
s := table.DefaultStyles()
s.Header = s.Header.
@@ -201,11 +206,12 @@ func New(assets map[string][3]int, width, height int) Model {
Foreground(lipgloss.Color("#2f54eb")) // Primary color
s.Selected = selectedStyle
t.SetStyles(s)
return Model{
table: t,
assets: assetList,
filteredAssets: assetList,
filterModel: NewFilter(),
width: width,
height: height,
focused: false,
@@ -222,16 +228,73 @@ func (m Model) Init() tea.Cmd {
// Update handles messages
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
// Handle filter input first if active
if m.filterModel.Active() {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEscape:
// Exit filter mode
m.filterModel.SetActive(false)
m.filter = ""
m.updateFilter()
return m, nil
case tea.KeyEnter:
// Connect to selected asset if available
if m.table.SelectedRow() != nil && m.table.Cursor() < len(m.filteredAssets) {
selected := m.filteredAssets[m.table.Cursor()]
m.filterModel.SetActive(false)
return m, connectCmd(selected)
}
// Otherwise just apply filter and exit filter mode
m.filter = m.filterModel.Value()
m.updateFilter()
m.filterModel.SetActive(false)
return m, nil
case tea.KeyUp, tea.KeyDown, tea.KeyPgUp, tea.KeyPgDown:
// Allow navigation keys to pass through to table
m.table, cmd = m.table.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
// For all other keys (including 'q'), update filter input
prevFilter := m.filter
m.filterModel, cmd = m.filterModel.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
// Live filter update only if changed
newFilter := m.filterModel.Value()
if newFilter != prevFilter {
m.filter = newFilter
m.updateFilter()
// Only reset cursor on first character or significant change
if prevFilter == "" && newFilter != "" {
// First character typed - reset to top
m.table.GotoTop()
}
}
return m, tea.Batch(cmds...)
default:
// Let filter handle other messages
m.filterModel, cmd = m.filterModel.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
}
switch msg := msg.(type) {
case tea.KeyMsg:
if m.filter != "" && msg.Type == tea.KeyEscape {
// Clear filter
m.filter = ""
m.updateFilter()
return m, nil
}
switch {
case key.Matches(msg, m.keyMap.Enter):
// Return selected asset for connection
@@ -239,14 +302,25 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
selected := m.filteredAssets[m.table.Cursor()]
return m, connectCmd(selected)
}
case key.Matches(msg, m.keyMap.Back):
return m, backCmd()
case key.Matches(msg, m.keyMap.Filter):
// Start filtering mode
return m, startFilterCmd()
if m.filter != "" {
// Clear existing filter
m.filter = ""
m.updateFilter()
// Reset cursor to top after clearing filter
m.table.GotoTop()
} else {
// Start filtering mode
m.filterModel.SetActive(true)
// Initialize filter to empty to prepare for input
m.filter = ""
}
return m, nil
case key.Matches(msg, m.keyMap.Up),
key.Matches(msg, m.keyMap.Down),
key.Matches(msg, m.keyMap.PageUp),
@@ -257,15 +331,25 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.table, cmd = m.table.Update(msg)
return m, cmd
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
// Update table height based on new window size
// Account for overhead (title, help, borders, etc.)
newHeight := len(m.filteredAssets)
// Table height includes header + separator + data rows
newHeight := len(m.filteredAssets) + 2
if newHeight < 3 {
newHeight = 3 // At least header + separator + 1 data row
}
// Calculate max available height
maxHeight := msg.Height - 10
if maxHeight > 5 && newHeight > maxHeight {
if maxHeight < 5 {
maxHeight = 5 // Minimum usable height
}
// Use actual row count for small sets, cap for large sets
if newHeight > maxHeight {
newHeight = maxHeight
}
m.table.SetHeight(newHeight)
@@ -273,23 +357,27 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// Update table for other messages
m.table, cmd = m.table.Update(msg)
}
return m, cmd
}
// View renders the table
func (m Model) View() string {
// Title
resetCursor := "\r\033[0G"
title := titleStyle.Render("🗂️ Available Assets")
// Filter indicator
// Filter indicator or input
filterInfo := ""
if m.filter != "" {
if m.filterModel.Active() {
// Show filter input
filterInfo = " " + m.filterModel.View()
} else if m.filter != "" {
// Show active filter
filterInfo = lipgloss.NewStyle().
Foreground(lipgloss.Color("#8c8c8c")). // Secondary text
Render(fmt.Sprintf(" (filtered: %s)", m.filter))
}
// Asset count
count := fmt.Sprintf("%d assets", len(m.filteredAssets))
if m.filter != "" && len(m.filteredAssets) != len(m.assets) {
@@ -298,31 +386,24 @@ func (m Model) View() string {
countStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#8c8c8c")). // Secondary text
Render(count)
// Help text
help := m.renderHelp()
// Combine all elements
header := lipgloss.JoinHorizontal(lipgloss.Left, title, filterInfo, " ", countStyle)
// Use the table component's view directly
tableView := m.table.View()
// Apply base style with proper width
// Don't apply height constraint, let the table manage its own viewport
tableBox := baseStyle.
Width(m.width - 2).
Render(tableView)
// Build the final view with proper spacing
var output strings.Builder
output.WriteString(header)
output.WriteString("\n")
output.WriteString(tableBox)
output.WriteString("\n")
output.WriteString(help)
return output.String()
result := resetCursor + header + "\n" + tableBox + "\n" + help
return result
}
// Helper functions
@@ -339,8 +420,9 @@ func (m *Model) updateFilter() {
strings.Contains(strings.ToLower(a.Protocol), filter)
})
}
// Update table rows
rows := make([]table.Row, len(m.filteredAssets))
for i, asset := range m.filteredAssets {
icon := icons.GetProtocolIcon(asset.Protocol)
@@ -353,11 +435,22 @@ func (m *Model) updateFilter() {
}
}
m.table.SetRows(rows)
// Update table height if needed after filtering
newHeight := len(m.filteredAssets)
// Update table height to show all filtered results when possible
// Table height includes header + separator + data rows
newHeight := len(m.filteredAssets) + 2
if newHeight < 3 {
newHeight = 3 // At least header + separator + 1 data row
}
// Calculate max available height
maxHeight := m.height - 10
if maxHeight > 5 && newHeight > maxHeight {
if maxHeight < 5 {
maxHeight = 5 // Minimum usable height
}
// Use actual row count for small sets, cap for large sets
if newHeight > maxHeight {
newHeight = maxHeight
}
m.table.SetHeight(newHeight)
@@ -367,16 +460,31 @@ func (m Model) renderHelp() string {
if !m.showHelp {
return ""
}
helpItems := []string{
"↑/↓ navigate",
"enter connect",
"/ filter",
"esc back",
"pgup/pgdn scroll",
"g/G top/bottom",
var helpItems []string
if m.filterModel.Active() {
// Show filter-specific help
helpItems = []string{
"type to filter",
"↑/↓ navigate",
"enter connect",
"esc cancel",
}
} else {
// Show normal help
helpItems = []string{
"↑/↓ navigate",
"enter connect",
"/ filter",
"esc back",
"pgup/pgdn scroll",
"g/G top/bottom",
}
if m.filter != "" {
helpItems[2] = "/ clear filter"
}
}
return lipgloss.NewStyle().
Foreground(lipgloss.Color("#8c8c8c")). // Secondary text
Padding(1, 0, 0, 0).
@@ -395,19 +503,11 @@ func connectCmd(asset Asset) tea.Cmd {
}
}
type backMsg struct{}
type BackMsg struct{}
func backCmd() tea.Cmd {
return func() tea.Msg {
return backMsg{}
}
}
type startFilterMsg struct{}
func startFilterCmd() tea.Cmd {
return func() tea.Msg {
return startFilterMsg{}
return BackMsg{}
}
}
@@ -423,4 +523,9 @@ func (m Model) GetSelectedAsset() *Asset {
func (m *Model) SetFocus(focused bool) {
m.focused = focused
m.table.SetCursor(0)
}
}
// IsFilterActive returns whether the filter is active
func (m Model) IsFilterActive() bool {
return m.filterModel.Active()
}

View File

@@ -32,7 +32,6 @@ import (
"github.com/veops/oneterm/internal/sshsrv/icons"
"github.com/veops/oneterm/internal/sshsrv/textinput"
"github.com/veops/oneterm/pkg/cache"
"github.com/veops/oneterm/pkg/errors"
"github.com/veops/oneterm/pkg/logger"
)
@@ -155,25 +154,29 @@ func (m *view) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle table mode
if m.mode == modeTable {
// Let table handle the message first
m.assetTable, tableCmd = m.assetTable.Update(msg)
// Check for special messages after table has processed them
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.Type == tea.KeyEsc || msg.String() == "q" {
// Exit table mode
m.mode = modeCLI
return m, nil
}
case assetlist.ConnectMsg:
// Handle connection from table
m.mode = modeCLI
cmd := msg.Asset.Command
return m, m.handleConnectionCommand(cmd)
case tea.WindowSizeMsg:
// Handle window resize in table mode
m.assetTable, tableCmd = m.assetTable.Update(msg)
return m, tableCmd
case assetlist.BackMsg:
// Exit table mode when Back is triggered
m.mode = modeCLI
return m, tea.Printf("\r%s", prompt)
case tea.KeyMsg:
// Only exit on Esc/q if filter is NOT active
if !m.assetTable.IsFilterActive() && (msg.Type == tea.KeyEsc || msg.String() == "q") {
// Exit table mode
m.mode = modeCLI
return m, tea.Printf("\r%s", prompt)
}
}
m.assetTable, tableCmd = m.assetTable.Update(msg)
return m, tableCmd
}
@@ -218,8 +221,20 @@ func (m *view) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.ClearScreen
case cmd == "list" || cmd == "ls" || cmd == "table":
pty, _, _ := m.Sess.Pty()
m.assetTable = assetlist.New(m.combines, pty.Window.Width, pty.Window.Height)
// Ensure we have reasonable default dimensions if pty size is not available
width := pty.Window.Width
height := pty.Window.Height
if width <= 0 {
width = 80 // Standard terminal width
}
if height <= 0 {
height = 24 // Standard terminal height
}
m.assetTable = assetlist.New(m.combines, width, height)
m.mode = modeTable
// Send a window size message to ensure consistent initial state
sizeMsg := tea.WindowSizeMsg{Width: width, Height: height}
m.assetTable, _ = m.assetTable.Update(sizeMsg)
return m, tea.ClearScreen
}
@@ -330,11 +345,7 @@ func (m *view) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case errMsg:
if msg != nil {
str := msg.Error()
if ae, ok := msg.(*errors.ApiError); ok {
str = errors.Err2Msg[ae.Code].One
}
return m, tea.Printf(" [ERROR] %s\n\n", errStyle.Render(str))
return m, tea.Printf(" [ERROR] %s\n\n%s", errStyle.Render(msg.Error()), prompt)
}
}
@@ -342,7 +353,6 @@ func (m *view) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg, ok := msg.(tea.KeyMsg); ok && msg.Type == tea.KeyRunes {
m.suggestionIdx = 0
m.selectedSugg = ""
}
m.textinput, tiCmd = m.textinput.Update(msg)
@@ -356,7 +366,12 @@ func (m *view) View() string {
}
if m.mode == modeTable {
return m.assetTable.View()
tableOutput := m.assetTable.View()
lines := strings.Split(tableOutput, "\n")
for i := range lines {
lines[i] = strings.TrimPrefix(lines[i], " ")
}
return strings.Join(lines, "\n")
}
suggestionView := m.smartSuggestionView()
@@ -541,7 +556,7 @@ func (m *view) assetOverview() string {
Foreground(colors.PrimaryColor2).
PaddingTop(1)
return tipStyle.Render("→ Type 'table' for interactive mode or start typing to connect")
return tipStyle.Render("→ Type 'ls' for interactive mode or start typing to connect")
}
func (m *view) refresh() {