diff --git a/backend/internal/sshsrv/assetlist/filter.go b/backend/internal/sshsrv/assetlist/filter.go new file mode 100644 index 0000000..a9351cb --- /dev/null +++ b/backend/internal/sshsrv/assetlist/filter.go @@ -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() +} \ No newline at end of file diff --git a/backend/internal/sshsrv/assetlist/table.go b/backend/internal/sshsrv/assetlist/table.go index 54b6b5d..9c73b23 100644 --- a/backend/internal/sshsrv/assetlist/table.go +++ b/backend/internal/sshsrv/assetlist/table.go @@ -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) -} \ No newline at end of file +} + +// IsFilterActive returns whether the filter is active +func (m Model) IsFilterActive() bool { + return m.filterModel.Active() +} diff --git a/backend/internal/sshsrv/view.go b/backend/internal/sshsrv/view.go index 73c0881..5095b9c 100644 --- a/backend/internal/sshsrv/view.go +++ b/backend/internal/sshsrv/view.go @@ -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() {