mirror of
https://github.com/veops/oneterm.git
synced 2025-10-30 02:21:51 +08:00
feat(backend): major UX improvements for SSH service interface
This commit is contained in:
@@ -49,11 +49,10 @@ require (
|
|||||||
gorm.io/plugin/soft_delete v1.2.1
|
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 (
|
require (
|
||||||
github.com/Azure/azure-pipeline-go v0.2.3 // indirect
|
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/andybalholm/cascadia v1.3.3 // indirect
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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/term v0.1.1 // indirect
|
||||||
github.com/charmbracelet/x/windows v0.1.0 // indirect
|
github.com/charmbracelet/x/windows v0.1.0 // indirect
|
||||||
github.com/clbanning/mxj v1.8.4 // 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/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/go-ini/ini v1.67.0 // 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/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.15.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/rs/xid v1.6.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/time v0.6.0 // indirect
|
golang.org/x/time v0.6.0 // indirect
|
||||||
|
|||||||
@@ -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.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.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.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.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 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
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.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.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 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
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.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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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.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.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.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.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 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
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.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.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
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.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.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.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.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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
|
|||||||
426
backend/internal/sshsrv/assetlist/table.go
Normal file
426
backend/internal/sshsrv/assetlist/table.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
102
backend/internal/sshsrv/colors/theme.go
Normal file
102
backend/internal/sshsrv/colors/theme.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/fatih/color"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/getwe/figlet4go"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gliderlabs/ssh"
|
"github.com/gliderlabs/ssh"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -72,22 +72,56 @@ func signer() ssh.Signer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func banner() string {
|
func banner() string {
|
||||||
str := "ONETERM"
|
// Professional blue theme gradient
|
||||||
ascii := figlet4go.NewAsciiRender()
|
gradient1 := lipgloss.NewStyle().Foreground(lipgloss.Color("#3F75FF")) // Bright primary
|
||||||
colors := [...]color.Attribute{
|
gradient2 := lipgloss.NewStyle().Foreground(lipgloss.Color("#2f54eb")) // Primary color
|
||||||
color.FgMagenta,
|
gradient3 := lipgloss.NewStyle().Foreground(lipgloss.Color("#7f97fa")) // Light primary
|
||||||
color.FgYellow,
|
versionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8c8c8c")).Italic(true)
|
||||||
color.FgBlue,
|
bannerText := `
|
||||||
color.FgCyan,
|
██████╗ ███╗ ██╗███████╗████████╗███████╗██████╗ ███╗ ███╗
|
||||||
color.FgRed,
|
██╔═══██╗████╗ ██║██╔════╝╚══██╔══╝██╔════╝██╔══██╗████╗ ████║
|
||||||
color.FgWhite,
|
██║ ██║██╔██╗ ██║█████╗ ██║ █████╗ ██████╔╝██╔████╔██║
|
||||||
color.FgGreen,
|
██║ ██║██║╚██╗██║██╔══╝ ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║
|
||||||
|
╚██████╔╝██║ ╚████║███████╗ ██║ ███████╗██║ ██║██║ ╚═╝ ██║
|
||||||
|
╚═════╝ ╚═╝ ╚═══╝╚══════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝`
|
||||||
|
|
||||||
|
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))
|
tagline := lipgloss.NewStyle().
|
||||||
for i := range options.FontColor {
|
Foreground(lipgloss.Color("#2f54eb")).
|
||||||
options.FontColor[i] = colors[i%len(colors)]
|
Bold(true).
|
||||||
}
|
PaddingLeft(15).
|
||||||
renderStr, _ := ascii.RenderOpts(str, options)
|
Render("✨ Enterprise Bastion Host Solution")
|
||||||
return renderStr
|
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
67
backend/internal/sshsrv/icons/icons.go
Normal file
67
backend/internal/sshsrv/icons/icons.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,7 +13,6 @@ import (
|
|||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/charmbracelet/lipgloss/table"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gliderlabs/ssh"
|
"github.com/gliderlabs/ssh"
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
@@ -27,6 +27,9 @@ import (
|
|||||||
"github.com/veops/oneterm/internal/repository"
|
"github.com/veops/oneterm/internal/repository"
|
||||||
"github.com/veops/oneterm/internal/service"
|
"github.com/veops/oneterm/internal/service"
|
||||||
"github.com/veops/oneterm/internal/session"
|
"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/internal/sshsrv/textinput"
|
||||||
"github.com/veops/oneterm/pkg/cache"
|
"github.com/veops/oneterm/pkg/cache"
|
||||||
"github.com/veops/oneterm/pkg/errors"
|
"github.com/veops/oneterm/pkg/errors"
|
||||||
@@ -35,20 +38,22 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
prompt = "> "
|
prompt = "> "
|
||||||
hotPink = lipgloss.Color("#FF06B7")
|
|
||||||
darkGray = lipgloss.Color("#767676")
|
|
||||||
hisCmdsFmt = "hiscmds-%d"
|
hisCmdsFmt = "hiscmds-%d"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errStyle = lipgloss.NewStyle().Foreground(hotPink)
|
errStyle = colors.ErrorStyle
|
||||||
hintStyle = lipgloss.NewStyle().Foreground(darkGray)
|
hintStyle = colors.HintStyle
|
||||||
|
warningStyle = colors.WarningStyle
|
||||||
hiddenBorder = lipgloss.HiddenBorder()
|
hiddenBorder = lipgloss.HiddenBorder()
|
||||||
|
|
||||||
p2p = map[string]int{
|
p2p = map[string]int{
|
||||||
"ssh": 22,
|
"ssh": 22,
|
||||||
"redis": 6379,
|
"redis": 6379,
|
||||||
"mysql": 3306,
|
"mysql": 3306,
|
||||||
|
"mongodb": 27017,
|
||||||
|
"postgresql": 5432,
|
||||||
|
"telnet": 23,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,53 +67,68 @@ type keymap struct{}
|
|||||||
|
|
||||||
func (k keymap) ShortHelp() []key.Binding {
|
func (k keymap) ShortHelp() []key.Binding {
|
||||||
return []key.Binding{
|
return []key.Binding{
|
||||||
key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")),
|
key.NewBinding(key.WithKeys("up/down"), key.WithHelp("↑/↓", "navigate suggestions")),
|
||||||
key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")),
|
key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "auto-complete")),
|
||||||
key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "complete")),
|
|
||||||
key.NewBinding(key.WithKeys("f5"), key.WithHelp("F5", "refresh")),
|
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 {
|
func (k keymap) FullHelp() [][]key.Binding {
|
||||||
return [][]key.Binding{k.ShortHelp()}
|
return [][]key.Binding{k.ShortHelp()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type viewMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
modeCLI viewMode = iota
|
||||||
|
modeTable
|
||||||
|
)
|
||||||
|
|
||||||
type view struct {
|
type view struct {
|
||||||
Ctx *gin.Context
|
Ctx *gin.Context
|
||||||
Sess ssh.Session
|
Sess ssh.Session
|
||||||
currentUser *acl.Session
|
currentUser *acl.Session
|
||||||
textinput textinput.Model
|
textinput textinput.Model
|
||||||
cmds []string
|
assetTable assetlist.Model
|
||||||
cmdsIdx int
|
cmds []string
|
||||||
combines map[string][3]int
|
cmdsIdx int
|
||||||
connecting bool
|
combines map[string][3]int
|
||||||
help help.Model
|
connecting bool
|
||||||
keys keymap
|
help help.Model
|
||||||
r io.ReadCloser
|
keys keymap
|
||||||
w io.WriteCloser
|
r io.ReadCloser
|
||||||
gctx context.Context
|
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 {
|
func initialView(ctx *gin.Context, sess ssh.Session, r io.ReadCloser, w io.WriteCloser, gctx context.Context) *view {
|
||||||
currentUser, _ := acl.GetSessionFromCtx(ctx)
|
currentUser, _ := acl.GetSessionFromCtx(ctx)
|
||||||
|
|
||||||
ti := textinput.New()
|
ti := textinput.New()
|
||||||
ti.Placeholder = "ssh"
|
ti.Placeholder = "Type 'help' or start with 'ssh user@host'..."
|
||||||
ti.Focus()
|
ti.Focus()
|
||||||
ti.Prompt = prompt
|
ti.Prompt = prompt
|
||||||
ti.ShowSuggestions = true
|
ti.ShowSuggestions = true
|
||||||
ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
|
ti.PromptStyle = colors.PrimaryStyle
|
||||||
ti.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
|
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{
|
v := view{
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
Sess: sess,
|
Sess: sess,
|
||||||
currentUser: currentUser,
|
currentUser: currentUser,
|
||||||
textinput: ti,
|
textinput: ti,
|
||||||
cmds: []string{},
|
cmds: []string{},
|
||||||
help: help.New(),
|
help: help.New(),
|
||||||
r: r,
|
r: r,
|
||||||
w: w,
|
w: w,
|
||||||
gctx: gctx,
|
gctx: gctx,
|
||||||
|
mode: modeCLI,
|
||||||
|
suggestionIdx: 0,
|
||||||
}
|
}
|
||||||
v.refresh()
|
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 {
|
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) {
|
func (m *view) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var (
|
var (
|
||||||
hisCmd tea.Cmd
|
hisCmd tea.Cmd
|
||||||
tiCmd 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) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.Type {
|
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
|
return m, tea.Quit
|
||||||
case tea.KeyEnter:
|
case tea.KeyEnter:
|
||||||
|
// Use selected suggestion if one is selected, otherwise use typed value
|
||||||
cmd := m.textinput.Value()
|
cmd := m.textinput.Value()
|
||||||
|
if m.selectedSugg != "" {
|
||||||
|
cmd = m.selectedSugg
|
||||||
|
}
|
||||||
m.textinput.Reset()
|
m.textinput.Reset()
|
||||||
|
m.selectedSugg = ""
|
||||||
|
m.suggestionIdx = 0
|
||||||
if cmd == "" {
|
if cmd == "" {
|
||||||
return m, tea.Batch(tea.Printf(prompt))
|
return m, tea.Batch(tea.Printf(prompt))
|
||||||
}
|
}
|
||||||
hisCmd = tea.Printf("> %s", cmd)
|
hisCmd = tea.Printf("🚀 %s", cmd)
|
||||||
m.cmds = append(m.cmds, cmd)
|
m.cmds = append(m.cmds, cmd)
|
||||||
ln := len(m.cmds)
|
ln := len(m.cmds)
|
||||||
if ln > 100 {
|
if ln > 100 {
|
||||||
m.cmds = m.cmds[ln-100 : ln]
|
m.cmds = m.cmds[ln-100 : ln]
|
||||||
}
|
}
|
||||||
m.cmdsIdx = len(m.cmds)
|
m.cmdsIdx = len(m.cmds)
|
||||||
if cmd == "exit" {
|
|
||||||
return m, tea.Sequence(hisCmd, tea.Quit)
|
switch {
|
||||||
} else if p, ok := lo.Find(lo.Keys(p2p), func(item string) bool { return strings.HasPrefix(cmd, item) }); ok {
|
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()
|
pty, _, _ := m.Sess.Pty()
|
||||||
m.Ctx.Request.URL.RawQuery = fmt.Sprintf("w=%d&h=%d", pty.Window.Width, pty.Window.Height)
|
m.assetTable = assetlist.New(m.combines, pty.Window.Width, pty.Window.Height)
|
||||||
m.Ctx.Params = nil
|
m.mode = modeTable
|
||||||
m.Ctx.Params = append(m.Ctx.Params, gin.Param{Key: "account_id", Value: cast.ToString(m.combines[cmd][0])})
|
return m, tea.ClearScreen
|
||||||
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()
|
// Try to handle as connection command
|
||||||
m.connecting = true
|
if connectionCmd := m.handleConnectionCommand(cmd); connectionCmd != nil {
|
||||||
return m, tea.Sequence(hisCmd, tea.Exec(&connector{Ctx: m.Ctx, Sess: m.Sess, Vw: m, gctx: m.gctx}, func(err error) tea.Msg {
|
return m, tea.Sequence(
|
||||||
m.connecting = false
|
hisCmd,
|
||||||
return err
|
connectionCmd,
|
||||||
}), tea.Printf("%s", prompt), func() tea.Msg {
|
)
|
||||||
m.textinput.ClearMatched()
|
} else {
|
||||||
return nil
|
var suggestion string
|
||||||
}, m.magicn)
|
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:
|
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)
|
ln := len(m.cmds)
|
||||||
if ln <= 0 {
|
if ln <= 0 {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.cmdsIdx = max(0, m.cmdsIdx-1)
|
m.cmdsIdx = max(0, m.cmdsIdx-1)
|
||||||
m.textinput.SetValue(m.cmds[m.cmdsIdx])
|
m.textinput.SetValue(m.cmds[m.cmdsIdx])
|
||||||
|
m.suggestionIdx = 0
|
||||||
|
m.selectedSugg = ""
|
||||||
case tea.KeyDown:
|
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)
|
ln := len(m.cmds)
|
||||||
m.cmdsIdx++
|
m.cmdsIdx++
|
||||||
if m.cmdsIdx >= ln {
|
if m.cmdsIdx >= ln {
|
||||||
@@ -178,8 +295,38 @@ func (m *view) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
} else {
|
} else {
|
||||||
m.textinput.SetValue(m.cmds[m.cmdsIdx])
|
m.textinput.SetValue(m.cmds[m.cmdsIdx])
|
||||||
}
|
}
|
||||||
|
m.suggestionIdx = 0
|
||||||
|
m.selectedSugg = ""
|
||||||
case tea.KeyF5:
|
case tea.KeyF5:
|
||||||
m.refresh()
|
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:
|
case errMsg:
|
||||||
if msg != nil {
|
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))
|
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)
|
m.textinput, tiCmd = m.textinput.Update(msg)
|
||||||
|
|
||||||
return m, tea.Batch(hisCmd, tiCmd)
|
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 {
|
func (m *view) View() string {
|
||||||
if m.connecting {
|
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(
|
return fmt.Sprintf(
|
||||||
"%s\n %s\n%s",
|
"%s\n %s\n%s%s",
|
||||||
m.textinput.View(),
|
m.textinput.View(),
|
||||||
m.help.View(m.keys),
|
m.help.View(m.keys),
|
||||||
hintStyle.Render(m.possible()),
|
suggestionView,
|
||||||
|
m.assetOverview(),
|
||||||
) + "\n\n"
|
) + "\n\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *view) possible() string {
|
func (m *view) smartSuggestionView() string {
|
||||||
ss := m.textinput.MatchedSuggestions()
|
// Get all suggestions and filter them ourselves for better matching
|
||||||
ln := len(ss)
|
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 {
|
if ln <= 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
ss = append(ss[:min(ln, 15)], lo.Ternary(ln > 15, fmt.Sprintf("%d more...", ln-15), ""))
|
|
||||||
mw := 0
|
if ln > 20 {
|
||||||
for _, s := range ss {
|
countStyle := lipgloss.NewStyle().
|
||||||
mw = max(mw, lipgloss.Width(s))
|
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()
|
pty, _, _ := m.Sess.Pty()
|
||||||
n := 1
|
m.Ctx.Request.URL.RawQuery = fmt.Sprintf("w=%d&h=%d", pty.Window.Width, pty.Window.Height)
|
||||||
for i := 2; i*mw+(i+1)*1 < pty.Window.Width; i++ {
|
m.Ctx.Params = nil
|
||||||
n = i
|
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).
|
if len(m.combines) == 0 {
|
||||||
StyleFunc(func(row, col int) lipgloss.Style { return hintStyle }).
|
return warningStyle.Render("\n ⚠ No accessible assets found. Check your permissions.")
|
||||||
Rows(lo.Chunk(ss, n)...)
|
}
|
||||||
return tb.Render()
|
|
||||||
|
// 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() {
|
func (m *view) refresh() {
|
||||||
@@ -284,7 +598,10 @@ func (m *view) refresh() {
|
|||||||
}
|
}
|
||||||
k := fmt.Sprintf("%s %s@%s", protocol, account.Name, asset.Name)
|
k := fmt.Sprintf("%s %s@%s", protocol, account.Name, asset.Name)
|
||||||
port := cast.ToInt(ss[1])
|
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)
|
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 {
|
type connector struct {
|
||||||
Ctx *gin.Context
|
Ctx *gin.Context
|
||||||
Sess ssh.Session
|
Sess ssh.Session
|
||||||
|
|||||||
Reference in New Issue
Block a user