From 5e5e9396af550b218eb29b4c11f2f97dbe7f264d Mon Sep 17 00:00:00 2001 From: pycook Date: Mon, 11 Aug 2025 00:03:00 +0800 Subject: [PATCH] feat(backend): major UX improvements for SSH service interface --- backend/go.mod | 5 +- backend/go.sum | 13 +- backend/internal/sshsrv/assetlist/table.go | 426 ++++++++++++++++ backend/internal/sshsrv/colors/theme.go | 102 ++++ backend/internal/sshsrv/handler.go | 72 ++- backend/internal/sshsrv/icons/icons.go | 67 +++ backend/internal/sshsrv/view.go | 545 ++++++++++++++++++--- 7 files changed, 1117 insertions(+), 113 deletions(-) create mode 100644 backend/internal/sshsrv/assetlist/table.go create mode 100644 backend/internal/sshsrv/colors/theme.go create mode 100644 backend/internal/sshsrv/icons/icons.go diff --git a/backend/go.mod b/backend/go.mod index 59f8e38..a7b4a6e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -49,11 +49,10 @@ require ( gorm.io/plugin/soft_delete v1.2.1 ) -require github.com/stretchr/testify v1.9.0 +require github.com/PuerkitoBio/goquery v1.10.3 require ( github.com/Azure/azure-pipeline-go v0.2.3 // indirect - github.com/PuerkitoBio/goquery v1.10.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -62,7 +61,6 @@ require ( github.com/charmbracelet/x/term v0.1.1 // indirect github.com/charmbracelet/x/windows v0.1.0 // indirect github.com/clbanning/mxj v1.8.4 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-ini/ini v1.67.0 // indirect @@ -82,7 +80,6 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.2 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rs/xid v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/time v0.6.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index a8ce540..03290f5 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -314,8 +314,6 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= @@ -342,8 +340,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= @@ -353,8 +349,6 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= @@ -377,8 +371,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= -golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= @@ -392,10 +384,9 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= -golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= -golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -408,8 +399,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= diff --git a/backend/internal/sshsrv/assetlist/table.go b/backend/internal/sshsrv/assetlist/table.go new file mode 100644 index 0000000..54b6b5d --- /dev/null +++ b/backend/internal/sshsrv/assetlist/table.go @@ -0,0 +1,426 @@ +package assetlist + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/samber/lo" + + "github.com/veops/oneterm/internal/sshsrv/icons" +) + +// Styles for the table using primary color palette +var ( + baseStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7f97fa")) // Light primary for borders + + titleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#2f54eb")). // Primary color + Bold(true). + Padding(0, 1) + + selectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ffffff")). // White text + Background(lipgloss.Color("#3F75FF")). // Bright primary background + Bold(true) +) + +// KeyMap for table navigation +type TableKeyMap struct { + Up key.Binding + Down key.Binding + PageUp key.Binding + PageDown key.Binding + Home key.Binding + End key.Binding + Enter key.Binding + Back key.Binding + Filter key.Binding +} + +var DefaultTableKeyMap = TableKeyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup", "ctrl+u"), + key.WithHelp("pgup", "page up"), + ), + PageDown: key.NewBinding( + key.WithKeys("pgdown", "ctrl+d"), + key.WithHelp("pgdn", "page down"), + ), + Home: key.NewBinding( + key.WithKeys("home", "g"), + key.WithHelp("home/g", "first"), + ), + End: key.NewBinding( + key.WithKeys("end", "G"), + key.WithHelp("end/G", "last"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "connect"), + ), + Back: key.NewBinding( + key.WithKeys("esc", "q"), + key.WithHelp("esc/q", "back"), + ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), +} + +// Asset represents a connection asset +type Asset struct { + Protocol string + Command string + User string + Host string + Port string + Info [3]int // [accountId, assetId, port] +} + +// Model represents the asset list table model +type Model struct { + table table.Model + assets []Asset + filteredAssets []Asset + filter string + width int + height int + focused bool + keyMap TableKeyMap + showHelp bool +} + +// New creates a new asset list table +func New(assets map[string][3]int, width, height int) Model { + // Convert assets map to structured list + assetList := make([]Asset, 0, len(assets)) + for cmd, info := range assets { + parts := strings.Fields(cmd) + 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 { + user = userHost[:idx] + host = userHost[idx+1:] + } else { + user = "unknown" + host = userHost + } + + // Extract port if present + port := "" + if len(parts) > 2 { + // Format: "protocol user@host:port" + if idx := strings.LastIndex(parts[len(parts)-1], ":"); idx > 0 { + port = parts[len(parts)-1][idx+1:] + host = strings.TrimSuffix(host, ":"+port) + } + } + + assetList = append(assetList, Asset{ + Protocol: protocol, + Command: cmd, + User: user, + Host: host, + Port: port, + Info: info, + }) + } + } + + // Assets are stored in the order they were found + + // Create table columns + columns := []table.Column{ + {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 { + icon := icons.GetProtocolIcon(asset.Protocol) + rows[i] = table.Row{ + fmt.Sprintf("%s %s", icon, strings.ToUpper(asset.Protocol)), + asset.User, + asset.Host, + lo.Ternary(asset.Port != "", asset.Port, icons.GetDefaultPort(asset.Protocol)), + 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 + maxViewportHeight := height - 10 + if maxViewportHeight > 5 && 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. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#b1c9ff")). // Light accent + BorderBottom(true). + Bold(true). + Foreground(lipgloss.Color("#2f54eb")) // Primary color + s.Selected = selectedStyle + t.SetStyles(s) + + return Model{ + table: t, + assets: assetList, + filteredAssets: assetList, + width: width, + height: height, + focused: false, + keyMap: DefaultTableKeyMap, + showHelp: true, + } +} + +// Init initializes the model +func (m Model) Init() tea.Cmd { + return nil +} + +// Update handles messages +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmd tea.Cmd + + 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 + if m.table.SelectedRow() != nil && m.table.Cursor() < len(m.filteredAssets) { + 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() + + case key.Matches(msg, m.keyMap.Up), + key.Matches(msg, m.keyMap.Down), + key.Matches(msg, m.keyMap.PageUp), + key.Matches(msg, m.keyMap.PageDown), + key.Matches(msg, m.keyMap.Home), + key.Matches(msg, m.keyMap.End): + // Let the table handle all navigation keys + 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) + maxHeight := msg.Height - 10 + if maxHeight > 5 && newHeight > maxHeight { + newHeight = maxHeight + } + m.table.SetHeight(newHeight) + default: + // 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 + title := titleStyle.Render("šŸ—‚ļø Available Assets") + + // Filter indicator + filterInfo := "" + if m.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) { + count = fmt.Sprintf("%d of %d assets", len(m.filteredAssets), len(m.assets)) + } + 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() +} + +// Helper functions + +func (m *Model) updateFilter() { + if m.filter == "" { + m.filteredAssets = m.assets + } else { + filter := strings.ToLower(m.filter) + m.filteredAssets = lo.Filter(m.assets, func(a Asset, _ int) bool { + return strings.Contains(strings.ToLower(a.Command), filter) || + strings.Contains(strings.ToLower(a.Host), filter) || + strings.Contains(strings.ToLower(a.User), filter) || + 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) + rows[i] = table.Row{ + fmt.Sprintf("%s %s", icon, strings.ToUpper(asset.Protocol)), + asset.User, + asset.Host, + lo.Ternary(asset.Port != "", asset.Port, icons.GetDefaultPort(asset.Protocol)), + asset.Command, + } + } + m.table.SetRows(rows) + + // Update table height if needed after filtering + newHeight := len(m.filteredAssets) + maxHeight := m.height - 10 + if maxHeight > 5 && newHeight > maxHeight { + newHeight = maxHeight + } + m.table.SetHeight(newHeight) +} + +func (m Model) renderHelp() string { + if !m.showHelp { + return "" + } + + helpItems := []string{ + "↑/↓ navigate", + "enter connect", + "/ filter", + "esc back", + "pgup/pgdn scroll", + "g/G top/bottom", + } + + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8c8c8c")). // Secondary text + Padding(1, 0, 0, 0). + Render(strings.Join(helpItems, " • ")) +} + +// Commands + +type ConnectMsg struct { + Asset Asset +} + +func connectCmd(asset Asset) tea.Cmd { + return func() tea.Msg { + return ConnectMsg{Asset: asset} + } +} + +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{} + } +} + +// GetSelectedAsset returns the currently selected asset +func (m Model) GetSelectedAsset() *Asset { + if m.table.SelectedRow() != nil && m.table.Cursor() < len(m.filteredAssets) { + return &m.filteredAssets[m.table.Cursor()] + } + return nil +} + +// SetFocus sets the focus state of the table +func (m *Model) SetFocus(focused bool) { + m.focused = focused + m.table.SetCursor(0) +} \ No newline at end of file diff --git a/backend/internal/sshsrv/colors/theme.go b/backend/internal/sshsrv/colors/theme.go new file mode 100644 index 0000000..4cfa682 --- /dev/null +++ b/backend/internal/sshsrv/colors/theme.go @@ -0,0 +1,102 @@ +package colors + +import "github.com/charmbracelet/lipgloss" + +// Primary color palette - Professional blue theme +var ( + // Primary colors + PrimaryColor = lipgloss.Color("#2f54eb") // Main brand color + PrimaryColor2 = lipgloss.Color("#7f97fa") // Lighter primary + PrimaryColor3 = lipgloss.Color("#ebeff8") // Very light background + PrimaryColor4 = lipgloss.Color("#e1efff") // Light background + PrimaryColor5 = lipgloss.Color("#f0f5ff") // Subtle background + PrimaryColor6 = lipgloss.Color("#f9fbff") // Almost white + PrimaryColor7 = lipgloss.Color("#f7f8fa") // Neutral light + PrimaryColor8 = lipgloss.Color("#b1c9ff") // Light accent + PrimaryColor9 = lipgloss.Color("#3F75FF") // Bright primary + + // Semantic colors + SuccessColor = lipgloss.Color("#52c41a") // Green for success + WarningColor = lipgloss.Color("#faad14") // Orange for warning + ErrorColor = lipgloss.Color("#f5222d") // Red for errors + InfoColor = lipgloss.Color("#1890ff") // Blue for info + + // Text colors (optimized for dark terminals) + TextPrimary = lipgloss.Color("#E0E0E0") // Light gray for main text + TextSecondary = lipgloss.Color("#8c8c8c") // Medium gray for secondary text + TextDisabled = lipgloss.Color("#5c5c5c") // Dark gray for disabled text + TextInverse = lipgloss.Color("#ffffff") // White text on dark bg + + // Protocol-specific colors (using primary palette) + SSHColor = PrimaryColor9 // Bright blue for SSH + MySQLColor = PrimaryColor // Deep blue for MySQL + RedisColor = lipgloss.Color("#DC382D") // Keep Redis brand red + MongoDBColor = lipgloss.Color("#4DB33D") // Keep MongoDB brand green + PostgreSQLColor = PrimaryColor2 // Light blue for PostgreSQL + TelnetColor = PrimaryColor8 // Soft blue for Telnet +) + +// Styles using the color palette +var ( + // Text styles + PrimaryStyle = lipgloss.NewStyle().Foreground(PrimaryColor) + SecondaryStyle = lipgloss.NewStyle().Foreground(PrimaryColor2) + AccentStyle = lipgloss.NewStyle().Foreground(PrimaryColor9).Bold(true) + + // Status styles + SuccessStyle = lipgloss.NewStyle().Foreground(SuccessColor).Bold(true) + WarningStyle = lipgloss.NewStyle().Foreground(WarningColor) + ErrorStyle = lipgloss.NewStyle().Foreground(ErrorColor).Bold(true) + InfoStyle = lipgloss.NewStyle().Foreground(InfoColor) + + // UI element styles + TitleStyle = lipgloss.NewStyle(). + Foreground(PrimaryColor). + Bold(true). + Underline(true) + + SubtitleStyle = lipgloss.NewStyle(). + Foreground(PrimaryColor2). + Bold(true) + + HintStyle = lipgloss.NewStyle(). + Foreground(TextSecondary). + Italic(true) + + HighlightStyle = lipgloss.NewStyle(). + Foreground(TextInverse). + Background(PrimaryColor9). + Bold(true) + + // Banner gradient styles + GradientStyle1 = lipgloss.NewStyle().Foreground(PrimaryColor9) + GradientStyle2 = lipgloss.NewStyle().Foreground(PrimaryColor) + GradientStyle3 = lipgloss.NewStyle().Foreground(PrimaryColor2) +) + +// GetProtocolColor returns the appropriate color for each protocol +func GetProtocolColor(protocol string) lipgloss.Color { + switch protocol { + case "ssh": + return SSHColor + case "mysql": + return MySQLColor + case "redis": + return RedisColor + case "mongodb": + return MongoDBColor + case "postgresql": + return PostgreSQLColor + case "telnet": + return TelnetColor + default: + return TextSecondary + } +} + +// GetProtocolStyle returns a styled protocol name +func GetProtocolStyle(protocol string) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(GetProtocolColor(protocol)). + Bold(true) +} \ No newline at end of file diff --git a/backend/internal/sshsrv/handler.go b/backend/internal/sshsrv/handler.go index afac789..333f937 100644 --- a/backend/internal/sshsrv/handler.go +++ b/backend/internal/sshsrv/handler.go @@ -5,10 +5,10 @@ import ( "io" "net/http" "net/url" + "strings" tea "github.com/charmbracelet/bubbletea" - "github.com/fatih/color" - "github.com/getwe/figlet4go" + "github.com/charmbracelet/lipgloss" "github.com/gin-gonic/gin" "github.com/gliderlabs/ssh" "go.uber.org/zap" @@ -72,22 +72,56 @@ func signer() ssh.Signer { } func banner() string { - str := "ONETERM" - ascii := figlet4go.NewAsciiRender() - colors := [...]color.Attribute{ - color.FgMagenta, - color.FgYellow, - color.FgBlue, - color.FgCyan, - color.FgRed, - color.FgWhite, - color.FgGreen, + // Professional blue theme gradient + gradient1 := lipgloss.NewStyle().Foreground(lipgloss.Color("#3F75FF")) // Bright primary + gradient2 := lipgloss.NewStyle().Foreground(lipgloss.Color("#2f54eb")) // Primary color + gradient3 := lipgloss.NewStyle().Foreground(lipgloss.Color("#7f97fa")) // Light primary + versionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8c8c8c")).Italic(true) + bannerText := ` + ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā•— + ā–ˆā–ˆā•”ā•ā•ā•ā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā•ā•ā•ā•šā•ā•ā–ˆā–ˆā•”ā•ā•ā•ā–ˆā–ˆā•”ā•ā•ā•ā•ā•ā–ˆā–ˆā•”ā•ā•ā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā•‘ + ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā–ˆā–ˆā•— ā–ˆā–ˆā•‘ā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•‘ ā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•”ā•ā–ˆā–ˆā•”ā–ˆā–ˆā–ˆā–ˆā•”ā–ˆā–ˆā•‘ + ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā•‘ā•šā–ˆā–ˆā•—ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā• ā–ˆā–ˆā•‘ ā–ˆā–ˆā•”ā•ā•ā• ā–ˆā–ˆā•”ā•ā•ā–ˆā–ˆā•—ā–ˆā–ˆā•‘ā•šā–ˆā–ˆā•”ā•ā–ˆā–ˆā•‘ + ā•šā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•”ā•ā–ˆā–ˆā•‘ ā•šā–ˆā–ˆā–ˆā–ˆā•‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•‘ ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā•‘ ā•šā•ā• ā–ˆā–ˆā•‘ + ā•šā•ā•ā•ā•ā•ā• ā•šā•ā• ā•šā•ā•ā•ā•ā•šā•ā•ā•ā•ā•ā•ā• ā•šā•ā• ā•šā•ā•ā•ā•ā•ā•ā•ā•šā•ā• ā•šā•ā•ā•šā•ā• ā•šā•ā•` + + lines := strings.Split(bannerText, "\n") + var result strings.Builder + + for i, line := range lines { + if line == "" { + result.WriteString("\n") + continue + } + + var style lipgloss.Style + switch { + case i <= 2: + style = gradient1 + case i <= 4: + style = gradient2 + default: + style = gradient3 + } + result.WriteString(style.Render(line)) + result.WriteString("\n") } - options := figlet4go.NewRenderOptions() - options.FontColor = make([]color.Attribute, len(str)) - for i := range options.FontColor { - options.FontColor[i] = colors[i%len(colors)] - } - renderStr, _ := ascii.RenderOpts(str, options) - return renderStr + + tagline := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#2f54eb")). + Bold(true). + PaddingLeft(15). + Render("✨ Enterprise Bastion Host Solution") + + version := versionStyle. + PaddingLeft(25). + Render("v2.0.0") + + result.WriteString("\n") + result.WriteString(tagline) + result.WriteString(" ") + result.WriteString(version) + result.WriteString("\n") + + return result.String() } diff --git a/backend/internal/sshsrv/icons/icons.go b/backend/internal/sshsrv/icons/icons.go new file mode 100644 index 0000000..cfb2b9c --- /dev/null +++ b/backend/internal/sshsrv/icons/icons.go @@ -0,0 +1,67 @@ +package icons + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/veops/oneterm/internal/sshsrv/colors" +) + +// GetProtocolIcon returns just the icon character for each protocol +func GetProtocolIcon(protocol string) string { + switch protocol { + case "ssh": + return "ā–¶" + case "mysql": + return "ā—†" + case "redis": + return "⚔" + case "mongodb": + return "ā—‰" + case "postgresql": + return "ā–£" + case "telnet": + return "ā—Ž" + default: + return "ā—" + } +} + +// GetStyledProtocolIcon returns a styled icon for each protocol +func GetStyledProtocolIcon(protocol string) string { + icon := GetProtocolIcon(protocol) + switch protocol { + case "ssh": + return lipgloss.NewStyle().Foreground(colors.SSHColor).Render(icon) + case "mysql": + return lipgloss.NewStyle().Foreground(colors.MySQLColor).Render(icon) + case "redis": + return lipgloss.NewStyle().Foreground(colors.RedisColor).Render(icon) + case "mongodb": + return lipgloss.NewStyle().Foreground(colors.MongoDBColor).Render(icon) + case "postgresql": + return lipgloss.NewStyle().Foreground(colors.PostgreSQLColor).Render(icon) + case "telnet": + return lipgloss.NewStyle().Foreground(colors.TelnetColor).Render(icon) + default: + return lipgloss.NewStyle().Foreground(colors.TextSecondary).Render(icon) + } +} + +// GetDefaultPort returns the default port for each protocol +func GetDefaultPort(protocol string) string { + switch protocol { + case "ssh": + return "22" + case "mysql": + return "3306" + case "redis": + return "6379" + case "mongodb": + return "27017" + case "postgresql": + return "5432" + case "telnet": + return "23" + default: + return "" + } +} \ No newline at end of file diff --git a/backend/internal/sshsrv/view.go b/backend/internal/sshsrv/view.go index 3e4a09e..73c0881 100644 --- a/backend/internal/sshsrv/view.go +++ b/backend/internal/sshsrv/view.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "sort" "strings" "time" @@ -12,7 +13,6 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" "github.com/gin-gonic/gin" "github.com/gliderlabs/ssh" "github.com/samber/lo" @@ -27,6 +27,9 @@ import ( "github.com/veops/oneterm/internal/repository" "github.com/veops/oneterm/internal/service" "github.com/veops/oneterm/internal/session" + "github.com/veops/oneterm/internal/sshsrv/assetlist" + "github.com/veops/oneterm/internal/sshsrv/colors" + "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" @@ -35,20 +38,22 @@ import ( const ( prompt = "> " - hotPink = lipgloss.Color("#FF06B7") - darkGray = lipgloss.Color("#767676") hisCmdsFmt = "hiscmds-%d" ) var ( - errStyle = lipgloss.NewStyle().Foreground(hotPink) - hintStyle = lipgloss.NewStyle().Foreground(darkGray) + errStyle = colors.ErrorStyle + hintStyle = colors.HintStyle + warningStyle = colors.WarningStyle hiddenBorder = lipgloss.HiddenBorder() p2p = map[string]int{ - "ssh": 22, - "redis": 6379, - "mysql": 3306, + "ssh": 22, + "redis": 6379, + "mysql": 3306, + "mongodb": 27017, + "postgresql": 5432, + "telnet": 23, } ) @@ -62,53 +67,68 @@ type keymap struct{} func (k keymap) ShortHelp() []key.Binding { return []key.Binding{ - key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")), - key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")), - key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "complete")), + key.NewBinding(key.WithKeys("up/down"), key.WithHelp("↑/↓", "navigate suggestions")), + key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "auto-complete")), key.NewBinding(key.WithKeys("f5"), key.WithHelp("F5", "refresh")), - key.NewBinding(key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc/ctrl+c", "quit")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "connect")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "quit")), } } func (k keymap) FullHelp() [][]key.Binding { return [][]key.Binding{k.ShortHelp()} } +type viewMode int + +const ( + modeCLI viewMode = iota + modeTable +) + type view struct { - Ctx *gin.Context - Sess ssh.Session - currentUser *acl.Session - textinput textinput.Model - cmds []string - cmdsIdx int - combines map[string][3]int - connecting bool - help help.Model - keys keymap - r io.ReadCloser - w io.WriteCloser - gctx context.Context + Ctx *gin.Context + Sess ssh.Session + currentUser *acl.Session + textinput textinput.Model + assetTable assetlist.Model + cmds []string + cmdsIdx int + combines map[string][3]int + connecting bool + help help.Model + keys keymap + r io.ReadCloser + w io.WriteCloser + gctx context.Context + mode viewMode + suggestionIdx int // Track current suggestion selection + selectedSugg string // Store the selected suggestion text } func initialView(ctx *gin.Context, sess ssh.Session, r io.ReadCloser, w io.WriteCloser, gctx context.Context) *view { currentUser, _ := acl.GetSessionFromCtx(ctx) ti := textinput.New() - ti.Placeholder = "ssh" + ti.Placeholder = "Type 'help' or start with 'ssh user@host'..." ti.Focus() ti.Prompt = prompt ti.ShowSuggestions = true - ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) - ti.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) + ti.PromptStyle = colors.PrimaryStyle + ti.Cursor.Style = colors.AccentStyle + // Disable Tab for AcceptSuggestion to handle it ourselves + ti.KeyMap.AcceptSuggestion = key.NewBinding(key.WithKeys("ctrl+x")) // Use a key that won't be pressed v := view{ - Ctx: ctx, - Sess: sess, - currentUser: currentUser, - textinput: ti, - cmds: []string{}, - help: help.New(), - r: r, - w: w, - gctx: gctx, + Ctx: ctx, + Sess: sess, + currentUser: currentUser, + textinput: ti, + cmds: []string{}, + help: help.New(), + r: r, + w: w, + gctx: gctx, + mode: modeCLI, + suggestionIdx: 0, } v.refresh() @@ -116,60 +136,157 @@ func initialView(ctx *gin.Context, sess ssh.Session, r io.ReadCloser, w io.Write } func (m *view) Init() tea.Cmd { - return tea.Println(banner()) + welcomeStyle := colors.AccentStyle + exampleStyle := colors.HintStyle + + return tea.Batch( + tea.Println(banner()), + tea.Printf("\n %s\n\n", welcomeStyle.Render("→ Welcome to OneTerm! Start typing or use 'table' to browse assets")), + tea.Printf(" %s\n", exampleStyle.Render("Examples: ssh admin@server1, mysql db@prod, redis cache@redis")), + ) } func (m *view) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( - hisCmd tea.Cmd - tiCmd tea.Cmd + hisCmd tea.Cmd + tiCmd tea.Cmd + tableCmd tea.Cmd ) + // Handle table mode + if m.mode == modeTable { + 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 + } + + m.assetTable, tableCmd = m.assetTable.Update(msg) + return m, tableCmd + } + + // Handle CLI mode switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { - case tea.KeyCtrlC, tea.KeyEsc: + case tea.KeyCtrlC: + // Clear current input like in terminal, don't quit + m.textinput.Reset() + m.suggestionIdx = 0 + m.selectedSugg = "" + return m, tea.Printf("\n%s", prompt) + case tea.KeyEsc: return m, tea.Quit case tea.KeyEnter: + // Use selected suggestion if one is selected, otherwise use typed value cmd := m.textinput.Value() + if m.selectedSugg != "" { + cmd = m.selectedSugg + } m.textinput.Reset() + m.selectedSugg = "" + m.suggestionIdx = 0 if cmd == "" { return m, tea.Batch(tea.Printf(prompt)) } - hisCmd = tea.Printf("> %s", cmd) + hisCmd = tea.Printf("šŸš€ %s", cmd) m.cmds = append(m.cmds, cmd) ln := len(m.cmds) if ln > 100 { m.cmds = m.cmds[ln-100 : ln] } m.cmdsIdx = len(m.cmds) - if cmd == "exit" { - return m, tea.Sequence(hisCmd, tea.Quit) - } else if p, ok := lo.Find(lo.Keys(p2p), func(item string) bool { return strings.HasPrefix(cmd, item) }); ok { + + switch { + case cmd == "exit" || cmd == "quit" || cmd == `\q`: + return m, tea.Sequence(tea.Printf("šŸ‘‹ Goodbye!"), tea.Quit) + case cmd == "help" || cmd == `\h` || cmd == `\?`: + return m, tea.Sequence(hisCmd, tea.Printf(m.helpText()), tea.Printf("%s", prompt)) + case cmd == "clear" || cmd == `\c`: + return m, tea.ClearScreen + case cmd == "list" || cmd == "ls" || cmd == "table": pty, _, _ := m.Sess.Pty() - m.Ctx.Request.URL.RawQuery = fmt.Sprintf("w=%d&h=%d", pty.Window.Width, pty.Window.Height) - m.Ctx.Params = nil - m.Ctx.Params = append(m.Ctx.Params, gin.Param{Key: "account_id", Value: cast.ToString(m.combines[cmd][0])}) - m.Ctx.Params = append(m.Ctx.Params, gin.Param{Key: "asset_id", Value: cast.ToString(m.combines[cmd][1])}) - m.Ctx.Params = append(m.Ctx.Params, gin.Param{Key: "protocol", Value: fmt.Sprintf("%s:%d", p, m.combines[cmd][2])}) - m.Ctx = m.Ctx.Copy() - m.connecting = true - return m, tea.Sequence(hisCmd, tea.Exec(&connector{Ctx: m.Ctx, Sess: m.Sess, Vw: m, gctx: m.gctx}, func(err error) tea.Msg { - m.connecting = false - return err - }), tea.Printf("%s", prompt), func() tea.Msg { - m.textinput.ClearMatched() - return nil - }, m.magicn) + m.assetTable = assetlist.New(m.combines, pty.Window.Width, pty.Window.Height) + m.mode = modeTable + return m, tea.ClearScreen + } + + // Try to handle as connection command + if connectionCmd := m.handleConnectionCommand(cmd); connectionCmd != nil { + return m, tea.Sequence( + hisCmd, + connectionCmd, + ) + } else { + var suggestion string + if strings.Contains(cmd, "@") { + suggestion = "\nšŸ’Ŗ Try: ssh " + cmd + " (if connecting via SSH)" + } else { + suggestion = "\nšŸ’Ŗ Available commands: ssh, mysql, redis, mongodb, postgresql, telnet, help, list, exit" + } + return m, tea.Sequence( + hisCmd, + tea.Printf(" %s %s%s\n\n", + errStyle.Render("āš ļø Unknown command:"), + cmd, + hintStyle.Render(suggestion), + ), + tea.Printf("%s", prompt), + ) } case tea.KeyUp: + // If we have suggestions and input is not empty, navigate suggestions + input := m.textinput.Value() + if len(input) > 0 { + suggestions := m.getFilteredSuggestions(input) + if len(suggestions) > 0 { + if m.suggestionIdx > 0 { + m.suggestionIdx-- + if m.suggestionIdx < len(suggestions) { + m.selectedSugg = suggestions[m.suggestionIdx] + } + } + return m, nil + } + } + // Otherwise navigate command history ln := len(m.cmds) if ln <= 0 { return m, nil } m.cmdsIdx = max(0, m.cmdsIdx-1) m.textinput.SetValue(m.cmds[m.cmdsIdx]) + m.suggestionIdx = 0 + m.selectedSugg = "" case tea.KeyDown: + // If we have suggestions and input is not empty, navigate suggestions + input := m.textinput.Value() + if len(input) > 0 { + suggestions := m.getFilteredSuggestions(input) + if len(suggestions) > 0 { + limit := min(8, len(suggestions)) + if m.suggestionIdx < limit-1 { + m.suggestionIdx++ + if m.suggestionIdx < len(suggestions) { + m.selectedSugg = suggestions[m.suggestionIdx] + } + } + return m, nil + } + } + // Otherwise navigate command history ln := len(m.cmds) m.cmdsIdx++ if m.cmdsIdx >= ln { @@ -178,8 +295,38 @@ func (m *view) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.textinput.SetValue(m.cmds[m.cmdsIdx]) } + m.suggestionIdx = 0 + m.selectedSugg = "" case tea.KeyF5: m.refresh() + case tea.KeyTab: + // Auto-complete with common prefix or selected suggestion + input := m.textinput.Value() + if input == "" { + return m, nil + } + + suggestions := m.getFilteredSuggestions(input) + if len(suggestions) == 0 { + return m, nil + } + + if len(suggestions) == 1 { + // Single match - complete fully + m.textinput.SetValue(suggestions[0]) + m.textinput.CursorEnd() // Move cursor to end + m.selectedSugg = "" + m.suggestionIdx = 0 + } else { + // Multiple matches - complete to common prefix + commonPrefix := m.findCommonPrefix(suggestions) + if len(commonPrefix) > len(input) { + m.textinput.SetValue(commonPrefix) + m.textinput.CursorEnd() // Move cursor to end + m.selectedSugg = "" + m.suggestionIdx = 0 + } + } } case errMsg: if msg != nil { @@ -190,6 +337,14 @@ func (m *view) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Printf(" [ERROR] %s\n\n", errStyle.Render(str)) } } + + // Reset suggestion index and selected when typing + if msg, ok := msg.(tea.KeyMsg); ok && msg.Type == tea.KeyRunes { + m.suggestionIdx = 0 + m.selectedSugg = "" + + } + m.textinput, tiCmd = m.textinput.Update(msg) return m, tea.Batch(hisCmd, tiCmd) @@ -197,37 +352,196 @@ func (m *view) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *view) View() string { if m.connecting { - return "\n\n" + return "\n šŸ”„ Connecting...\n\n" } + + if m.mode == modeTable { + return m.assetTable.View() + } + + suggestionView := m.smartSuggestionView() + return fmt.Sprintf( - "%s\n %s\n%s", + "%s\n %s\n%s%s", m.textinput.View(), m.help.View(m.keys), - hintStyle.Render(m.possible()), + suggestionView, + m.assetOverview(), ) + "\n\n" } -func (m *view) possible() string { - ss := m.textinput.MatchedSuggestions() - ln := len(ss) +func (m *view) smartSuggestionView() string { + // Get all suggestions and filter them ourselves for better matching + input := strings.ToLower(m.textinput.Value()) + if input == "" { + return "" + } + + // Use our consistent filtered suggestions function + matches := m.getFilteredSuggestions(input) + ln := len(matches) if ln <= 0 { return "" } - ss = append(ss[:min(ln, 15)], lo.Ternary(ln > 15, fmt.Sprintf("%d more...", ln-15), "")) - mw := 0 - for _, s := range ss { - mw = max(mw, lipgloss.Width(s)) + + if ln > 20 { + countStyle := lipgloss.NewStyle(). + Foreground(colors.TextSecondary). + Italic(true) + return "\n " + countStyle.Render(fmt.Sprintf("%d matches found. Keep typing to filter...", ln)) + "\n" } + + // Clean and validate matches before displaying + cleanMatches := make([]string, 0, len(matches)) + for _, match := range matches { + match = strings.TrimSpace(match) + // Only filter out truly empty matches + if match != "" { + cleanMatches = append(cleanMatches, match) + } + } + + if len(cleanMatches) == 0 { + return "" + } + + limit := min(8, len(cleanMatches)) + displaySuggestions := cleanMatches[:limit] + + // Ensure suggestion index is within bounds + if m.suggestionIdx >= limit { + m.suggestionIdx = limit - 1 + } + + var result strings.Builder + suggestTitle := colors.SubtitleStyle + result.WriteString("\n " + suggestTitle.Render("Suggestions:") + "\n") + + // Render each suggestion + for i, suggestion := range displaySuggestions { + // Get protocol for icon + parts := strings.Fields(suggestion) + protocol := "unknown" + if len(parts) > 0 { + protocol = parts[0] + } + icon := icons.GetStyledProtocolIcon(protocol) + + // Render with appropriate style + if i == m.suggestionIdx { + selectedStyle := colors.HighlightStyle + result.WriteString(fmt.Sprintf(" → %s %s\n", icon, selectedStyle.Render(suggestion))) + } else { + // Use a lighter color for non-selected suggestions on dark background + normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + result.WriteString(fmt.Sprintf(" %s %s\n", icon, normalStyle.Render(suggestion))) + } + } + + // Show count if there are more suggestions + if len(cleanMatches) > limit { + moreStyle := lipgloss.NewStyle(). + Foreground(colors.TextSecondary). + Italic(true) + result.WriteString(" " + moreStyle.Render(fmt.Sprintf("... +%d more", len(cleanMatches)-limit)) + "\n") + } + + return result.String() +} + +func (m *view) helpText() string { + return fmt.Sprintf(`%s + +%s + • ssh user@host - Connect via SSH + • mysql user@host - Connect to MySQL database + • redis user@host - Connect to Redis server + • mongodb user@host - Connect to MongoDB database + • postgresql user@host - Connect to PostgreSQL database + • telnet user@host - Connect via Telnet + • list/ls/table - Show assets in interactive table + • help or \h or \? - Show this help message + • clear or \c - Clear screen + • exit/quit or \q - Exit OneTerm + +%s + • Use ↑/↓ arrows to browse command history + • Press Tab to autocomplete connection names + • Press Ctrl+C to clear current input + • Press F5 to refresh asset list + +`, + colors.TitleStyle.Render("🌟 OneTerm Help"), + hintStyle.Render("šŸ“ Available Commands:"), + hintStyle.Render("āŒØļø Keyboard Shortcuts:"), + ) +} + +func (m *view) handleConnectionCommand(cmd string) tea.Cmd { + // Check if this is a valid connection command + if _, exists := m.combines[cmd]; !exists { + return nil + } + + // Extract protocol from command + p, ok := lo.Find(lo.Keys(p2p), func(item string) bool { return strings.HasPrefix(cmd, item) }) + if !ok { + return nil + } + + // Setup connection parameters pty, _, _ := m.Sess.Pty() - n := 1 - for i := 2; i*mw+(i+1)*1 < pty.Window.Width; i++ { - n = i + m.Ctx.Request.URL.RawQuery = fmt.Sprintf("w=%d&h=%d", pty.Window.Width, pty.Window.Height) + m.Ctx.Params = nil + m.Ctx.Params = append(m.Ctx.Params, gin.Param{Key: "account_id", Value: cast.ToString(m.combines[cmd][0])}) + m.Ctx.Params = append(m.Ctx.Params, gin.Param{Key: "asset_id", Value: cast.ToString(m.combines[cmd][1])}) + m.Ctx.Params = append(m.Ctx.Params, gin.Param{Key: "protocol", Value: fmt.Sprintf("%s:%d", p, m.combines[cmd][2])}) + m.Ctx = m.Ctx.Copy() + m.connecting = true + + return tea.Sequence( + tea.Printf("šŸ”Œ Establishing connection to %s...\n", cmd), + tea.Exec(&connector{Ctx: m.Ctx, Sess: m.Sess, Vw: m, gctx: m.gctx}, func(err error) tea.Msg { + m.connecting = false + if err != nil { + return errMsg(fmt.Errorf("āŒ Connection failed: %v", err)) + } + return nil + }), + tea.Printf("%s", prompt), + func() tea.Msg { + m.textinput.ClearMatched() + return nil + }, + m.magicn, + ) +} + +func (m *view) assetOverview() string { + if len(m.textinput.Value()) > 0 { + return "" // Hide overview when user is typing } - tb := table.New(). - Border(hiddenBorder). - StyleFunc(func(row, col int) lipgloss.Style { return hintStyle }). - Rows(lo.Chunk(ss, n)...) - return tb.Render() + + if len(m.combines) == 0 { + return warningStyle.Render("\n ⚠ No accessible assets found. Check your permissions.") + } + + // Group assets by protocol for better organization + protocolGroups := make(map[string][]string) + for cmd := range m.combines { + parts := strings.Split(cmd, " ") + if len(parts) > 0 { + protocol := parts[0] + protocolGroups[protocol] = append(protocolGroups[protocol], cmd) + } + } + + // Provide a better tip with modern styling + tipStyle := lipgloss.NewStyle(). + Foreground(colors.PrimaryColor2). + PaddingTop(1) + + return tipStyle.Render("→ Type 'table' for interactive mode or start typing to connect") } func (m *view) refresh() { @@ -284,7 +598,10 @@ func (m *view) refresh() { } k := fmt.Sprintf("%s %s@%s", protocol, account.Name, asset.Name) port := cast.ToInt(ss[1]) - m.combines[lo.Ternary(port == defaultPort, k, fmt.Sprintf("%s:%s", k, ss[1]))] = [3]int{account.Id, asset.Id, port} + // Ensure we're not creating empty or malformed keys + if k != "" && len(k) > 3 { + m.combines[lo.Ternary(port == defaultPort, k, fmt.Sprintf("%s:%s", k, ss[1]))] = [3]int{account.Id, asset.Id, port} + } } } } @@ -322,6 +639,78 @@ func (m *view) RecordHisCmd() { cache.RC.Expire(m.Ctx, k, time.Hour*24*30) } +// getFilteredSuggestions returns suggestions that match the input +func (m *view) getFilteredSuggestions(input string) []string { + if input == "" { + return nil + } + + inputLower := strings.ToLower(input) + var matches []string + for cmd := range m.combines { + // Clean any potential issues with the command string + cmd = strings.TrimSpace(cmd) + if cmd == "" { + continue + } + + if strings.HasPrefix(strings.ToLower(cmd), inputLower) { + // Ensure we're not adding empty or malformed entries + if len(cmd) > len(inputLower) { + matches = append(matches, cmd) + } + } + } + + // Sort matches for consistent ordering + sort.Strings(matches) + + // Remove any duplicates (shouldn't happen but just in case) + if len(matches) > 1 { + unique := make([]string, 0, len(matches)) + prev := "" + for _, m := range matches { + if m != prev { + unique = append(unique, m) + prev = m + } + } + matches = unique + } + + return matches +} + +// findCommonPrefix finds the longest common prefix among suggestions +func (m *view) findCommonPrefix(suggestions []string) string { + if len(suggestions) == 0 { + return "" + } + if len(suggestions) == 1 { + return suggestions[0] + } + + // Start with the first suggestion + prefix := suggestions[0] + + // Compare with each other suggestion + for _, s := range suggestions[1:] { + // Find common prefix between current prefix and this suggestion + i := 0 + minLen := min(len(prefix), len(s)) + for i < minLen && strings.EqualFold(string(prefix[i:i+1]), string(s[i:i+1])) { + i++ + } + prefix = prefix[:i] + + if len(prefix) == 0 { + return "" + } + } + + return prefix +} + type connector struct { Ctx *gin.Context Sess ssh.Session