mirror of
				https://github.com/veops/oneterm.git
				synced 2025-10-31 10:56:29 +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
	 pycook
					pycook