sshkeeper: fix 4 UX issues
1. Forward type selector: visible radio items (1.Local 2.Remote 3.SOCKS) with descriptions 2. Forward list: column header row (NAME/TYPE/LISTEN/TARGET/ON) 3. Forward delete: confirmation dialog before deletion 4. Server route column: → icon for via/chain, spaces for direct
This commit is contained in:
parent
6cf281c349
commit
087d7ba0dc
|
|
@ -193,6 +193,7 @@ const (
|
||||||
screenForwardList
|
screenForwardList
|
||||||
screenForwardForm
|
screenForwardForm
|
||||||
screenTunnelManager
|
screenTunnelManager
|
||||||
|
screenConfirm
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Result type — returned from TUI to caller ---
|
// --- Result type — returned from TUI to caller ---
|
||||||
|
|
@ -234,8 +235,9 @@ type tuiModel struct {
|
||||||
actionMenu *actionMenuModel
|
actionMenu *actionMenuModel
|
||||||
forwardScreen *forwardScreenModel
|
forwardScreen *forwardScreenModel
|
||||||
forwardForm *forwardFormModel
|
forwardForm *forwardFormModel
|
||||||
|
confirmMsg string
|
||||||
|
confirmAction func() tea.Cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(servers []*model.Server) *tuiModel {
|
func New(servers []*model.Server) *tuiModel {
|
||||||
items := make([]list.Item, len(servers))
|
items := make([]list.Item, len(servers))
|
||||||
for i, s := range servers {
|
for i, s := range servers {
|
||||||
|
|
@ -355,8 +357,10 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.forwardScreen.err = msg.err
|
m.forwardScreen.err = msg.err
|
||||||
} else {
|
} else {
|
||||||
m.forwardScreen.forwards = msg.forwards
|
m.forwardScreen.list = msg.forwards
|
||||||
m.forwardScreen.rebuildList()
|
if len(msg.forwards) > 0 && m.forwardScreen.selected < 0 {
|
||||||
|
m.forwardScreen.selected = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
@ -367,10 +371,22 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case forwardDeleteConfirmMsg:
|
||||||
|
// Show confirmation screen
|
||||||
|
m.confirmMsg = fmt.Sprintf("Delete forward %q?", msg.name)
|
||||||
|
m.confirmAction = func() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
return forwardDeletedMsg{id: msg.id, err: DeleteForward(msg.id)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.screen = screenConfirm
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case forwardEditSignal:
|
case forwardEditSignal:
|
||||||
if m.forwardScreen != nil {
|
if m.forwardScreen != nil {
|
||||||
if item, ok := m.forwardScreen.list.SelectedItem().(forwardListItem); ok {
|
if m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) {
|
||||||
m.forwardForm = newForwardEditModel(m.forwardScreen.serverID, item.forward, m.width, m.height)
|
fwd := m.forwardScreen.list[m.forwardScreen.selected]
|
||||||
|
m.forwardForm = newForwardEditModel(m.forwardScreen.serverID, fwd, m.width, m.height)
|
||||||
m.screen = screenForwardForm
|
m.screen = screenForwardForm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -498,6 +514,8 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m.updateForwardForm(msg)
|
return m.updateForwardForm(msg)
|
||||||
case screenTunnelManager:
|
case screenTunnelManager:
|
||||||
return m.updateTunnelManager(msg)
|
return m.updateTunnelManager(msg)
|
||||||
|
case screenConfirm:
|
||||||
|
return m.updateConfirm(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -972,6 +990,9 @@ func (m *tuiModel) View() string {
|
||||||
if m.tunnelScreen != nil {
|
if m.tunnelScreen != nil {
|
||||||
b.WriteString(m.tunnelScreen.View())
|
b.WriteString(m.tunnelScreen.View())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case screenConfirm:
|
||||||
|
b.WriteString(m.viewConfirm())
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
|
|
@ -1132,8 +1153,16 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
case tea.KeyCtrlD:
|
case tea.KeyCtrlD:
|
||||||
if m.forwardScreen != nil {
|
if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) {
|
||||||
return m, m.forwardScreen.deleteSelected()
|
fwd := m.forwardScreen.list[m.forwardScreen.selected]
|
||||||
|
m.confirmMsg = fmt.Sprintf("Delete forward %q?", fwd.Name)
|
||||||
|
m.confirmAction = func() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
return forwardDeletedMsg{id: fwd.ID, err: DeleteForward(fwd.ID)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.screen = screenConfirm
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
case tea.KeyCtrlE, tea.KeyEnter:
|
case tea.KeyCtrlE, tea.KeyEnter:
|
||||||
if m.forwardScreen != nil {
|
if m.forwardScreen != nil {
|
||||||
|
|
@ -1148,14 +1177,30 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
case "d", "D":
|
case "d", "D":
|
||||||
if m.forwardScreen != nil {
|
if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) {
|
||||||
return m, m.forwardScreen.deleteSelected()
|
fwd := m.forwardScreen.list[m.forwardScreen.selected]
|
||||||
|
m.confirmMsg = fmt.Sprintf("Delete forward %q?", fwd.Name)
|
||||||
|
m.confirmAction = func() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
return forwardDeletedMsg{id: fwd.ID, err: DeleteForward(fwd.ID)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.screen = screenConfirm
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case tea.KeyDown:
|
||||||
|
if m.forwardScreen != nil && m.forwardScreen.selected < len(m.forwardScreen.list)-1 {
|
||||||
|
m.forwardScreen.selected++
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyUp:
|
||||||
|
if m.forwardScreen != nil && m.forwardScreen.selected > 0 {
|
||||||
|
m.forwardScreen.selected--
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
var cmd tea.Cmd
|
return m, nil
|
||||||
m.forwardScreen.list, cmd = m.forwardScreen.list.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
|
@ -1189,6 +1234,52 @@ func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.Type {
|
||||||
|
case tea.KeyEsc:
|
||||||
|
m.screen = screenList
|
||||||
|
m.confirmMsg = ""
|
||||||
|
m.confirmAction = nil
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyEnter:
|
||||||
|
if m.confirmAction != nil {
|
||||||
|
action := m.confirmAction
|
||||||
|
m.confirmMsg = ""
|
||||||
|
m.confirmAction = nil
|
||||||
|
return m, action()
|
||||||
|
}
|
||||||
|
case tea.KeyRunes:
|
||||||
|
switch msg.String() {
|
||||||
|
case "y", "Y":
|
||||||
|
if m.confirmAction != nil {
|
||||||
|
action := m.confirmAction
|
||||||
|
m.confirmMsg = ""
|
||||||
|
m.confirmAction = nil
|
||||||
|
return m, action()
|
||||||
|
}
|
||||||
|
case "n", "N":
|
||||||
|
m.screen = screenList
|
||||||
|
m.confirmMsg = ""
|
||||||
|
m.confirmAction = nil
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) viewConfirm() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(titleStyle.Render("Confirm"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(" " + m.confirmMsg)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(renderHelp([]helpItem{
|
||||||
|
{Key: "Enter / Y", Action: "yes"},
|
||||||
|
{Key: "Esc / N", Action: "no"},
|
||||||
|
}, m.width))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
if msg.Type == tea.KeyEsc {
|
if msg.Type == tea.KeyEsc {
|
||||||
m.screen = screenForwardList
|
m.screen = screenForwardList
|
||||||
|
|
@ -1249,6 +1340,12 @@ func (m *tuiModel) viewServerList() string {
|
||||||
}
|
}
|
||||||
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
|
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
|
||||||
routeStr := server.Route.DisplaySummary(target)
|
routeStr := server.Route.DisplaySummary(target)
|
||||||
|
// Add visual icon prefix based on connection type
|
||||||
|
if len(server.Route.Hops) == 0 {
|
||||||
|
routeStr = " " + routeStr // direct
|
||||||
|
} else {
|
||||||
|
routeStr = "→ " + routeStr // via/chain
|
||||||
|
}
|
||||||
// If too long, collapse middle hops
|
// If too long, collapse middle hops
|
||||||
if len(routeStr) > 34 && len(server.Route.Hops) > 2 {
|
if len(routeStr) > 34 && len(server.Route.Hops) > 2 {
|
||||||
first := server.Route.Hops[0]
|
first := server.Route.Hops[0]
|
||||||
|
|
@ -1256,7 +1353,7 @@ func (m *tuiModel) viewServerList() string {
|
||||||
if !first.IsProfile {
|
if !first.IsProfile {
|
||||||
firstName = first.Raw
|
firstName = first.Raw
|
||||||
}
|
}
|
||||||
routeStr = fmt.Sprintf("%s → … → %s", firstName, truncate(target, 34-len(firstName)-6))
|
routeStr = fmt.Sprintf("→ %s → … → %s", firstName, truncate(target, 34-len(firstName)-8))
|
||||||
}
|
}
|
||||||
group := server.GroupName
|
group := server.GroupName
|
||||||
if group == "" {
|
if group == "" {
|
||||||
|
|
|
||||||
|
|
@ -704,8 +704,8 @@ func TestForwardSaveSuccessReturnsToList(t *testing.T) {
|
||||||
|
|
||||||
// Create both forwardScreen and forwardForm to simulate real flow
|
// Create both forwardScreen and forwardForm to simulate real flow
|
||||||
m.forwardScreen = newForwardScreenModel(server.ID, server.Alias, m.width, m.height)
|
m.forwardScreen = newForwardScreenModel(server.ID, server.Alias, m.width, m.height)
|
||||||
m.forwardScreen.forwards = []*model.Forward{}
|
m.forwardScreen.list = []*model.Forward{}
|
||||||
m.forwardScreen.rebuildList()
|
m.forwardScreen.selected = 0
|
||||||
m.forwardForm = newForwardFormModel(server.ID, m.width, m.height)
|
m.forwardForm = newForwardFormModel(server.ID, m.width, m.height)
|
||||||
m.forwardForm.serverID = server.ID
|
m.forwardForm.serverID = server.ID
|
||||||
m.screen = screenForwardForm
|
m.screen = screenForwardForm
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,12 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
"github.com/charmbracelet/bubbletea"
|
"github.com/charmbracelet/bubbletea"
|
||||||
"github.com/mirivlad/sshkeeper/internal/model"
|
"github.com/mirivlad/sshkeeper/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Forward type selector items ---
|
// --- Forward type items ---
|
||||||
|
|
||||||
type forwardTypeItem struct {
|
type forwardTypeItem struct {
|
||||||
value model.ForwardType
|
value model.ForwardType
|
||||||
|
|
@ -19,67 +18,27 @@ type forwardTypeItem struct {
|
||||||
description string
|
description string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i forwardTypeItem) Title() string { return i.label }
|
|
||||||
func (i forwardTypeItem) Description() string { return i.description }
|
|
||||||
func (i forwardTypeItem) FilterValue() string { return i.label }
|
|
||||||
|
|
||||||
// --- Forward list screen model ---
|
// --- Forward list screen model ---
|
||||||
|
|
||||||
type forwardScreenModel struct {
|
type forwardScreenModel struct {
|
||||||
serverID int64
|
serverID int64
|
||||||
serverAlias string
|
serverAlias string
|
||||||
list list.Model
|
list []*model.Forward
|
||||||
forwards []*model.Forward
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
err error
|
err error
|
||||||
|
selected int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newForwardScreenModel(serverID int64, serverAlias string, w, h int) *forwardScreenModel {
|
func newForwardScreenModel(serverID int64, serverAlias string, w, h int) *forwardScreenModel {
|
||||||
l := list.New([]list.Item{}, list.NewDefaultDelegate(), w, h-6)
|
|
||||||
l.Title = "Port Forwards — " + serverAlias
|
|
||||||
l.SetShowStatusBar(false)
|
|
||||||
l.SetFilteringEnabled(false)
|
|
||||||
l.Styles.Title = titleStyle
|
|
||||||
|
|
||||||
return &forwardScreenModel{
|
return &forwardScreenModel{
|
||||||
serverID: serverID,
|
serverID: serverID,
|
||||||
serverAlias: serverAlias,
|
serverAlias: serverAlias,
|
||||||
list: l,
|
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type forwardListItem struct {
|
|
||||||
forward *model.Forward
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i forwardListItem) Title() string {
|
|
||||||
name := i.forward.Name
|
|
||||||
if name == "" {
|
|
||||||
name = i.forward.ForwardListen()
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%-20s %-8s %-20s %-20s %s",
|
|
||||||
truncate(name, 20),
|
|
||||||
i.forward.Type,
|
|
||||||
truncate(i.forward.ForwardListen(), 20),
|
|
||||||
truncate(i.forward.ForwardTarget(), 20),
|
|
||||||
map[bool]string{true: "yes", false: "no"}[i.forward.Enabled],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
func (i forwardListItem) Description() string {
|
|
||||||
return i.forward.ForwardHumanExplanation("")
|
|
||||||
}
|
|
||||||
func (i forwardListItem) FilterValue() string {
|
|
||||||
return i.forward.Name + " " + string(i.forward.Type) + " " + i.forward.ForwardListen() + " " + i.forward.ForwardTarget()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *forwardScreenModel) SetServerAlias(alias string) {
|
|
||||||
m.serverAlias = alias
|
|
||||||
m.list.Title = "Port Forwards — " + alias
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *forwardScreenModel) loadForwards() tea.Cmd {
|
func (m *forwardScreenModel) loadForwards() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if ListForwards == nil {
|
if ListForwards == nil {
|
||||||
|
|
@ -90,38 +49,88 @@ func (m *forwardScreenModel) loadForwards() tea.Cmd {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *forwardScreenModel) rebuildList() {
|
func (m *forwardScreenModel) deleteSelected() tea.Cmd {
|
||||||
items := make([]list.Item, len(m.forwards))
|
if m.selected < 0 || m.selected >= len(m.list) {
|
||||||
for i, f := range m.forwards {
|
return nil
|
||||||
items[i] = forwardListItem{forward: f}
|
}
|
||||||
|
f := m.list[m.selected]
|
||||||
|
return func() tea.Msg {
|
||||||
|
return forwardDeleteConfirmMsg{id: f.ID, name: f.Name}
|
||||||
}
|
}
|
||||||
m.list.SetItems(items)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *forwardScreenModel) deleteSelected() tea.Cmd {
|
func (m *forwardScreenModel) confirmDelete() tea.Cmd {
|
||||||
if item, ok := m.list.SelectedItem().(forwardListItem); ok && DeleteForward != nil {
|
if m.selected < 0 || m.selected >= len(m.list) {
|
||||||
f := item.forward
|
return nil
|
||||||
return func() tea.Msg {
|
}
|
||||||
return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)}
|
f := m.list[m.selected]
|
||||||
}
|
return func() tea.Msg {
|
||||||
|
return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *forwardScreenModel) editSelected() tea.Cmd {
|
func (m *forwardScreenModel) editSelected() tea.Cmd {
|
||||||
// Return signal to open edit form
|
if m.selected < 0 || m.selected >= len(m.list) {
|
||||||
if _, ok := m.list.SelectedItem().(forwardListItem); ok {
|
return nil
|
||||||
return func() tea.Msg {
|
}
|
||||||
return forwardEditSignal{}
|
return func() tea.Msg {
|
||||||
}
|
return forwardEditSignal{}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *forwardScreenModel) View() string {
|
func (m *forwardScreenModel) View() string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(m.list.View())
|
|
||||||
|
b.WriteString(titleStyle.Render("Port Forwards — " + m.serverAlias))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
if len(m.list) == 0 {
|
||||||
|
b.WriteString(helpStyle.Render(" No port forwards configured. Press Ctrl+A to add one."))
|
||||||
|
b.WriteString("\n")
|
||||||
|
} else {
|
||||||
|
// Column header
|
||||||
|
b.WriteString(listHeaderStyle.Render(fmt.Sprintf(" %-22s %-8s %-20s %-20s %s",
|
||||||
|
"NAME", "TYPE", "LISTEN", "TARGET", "ON")))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
for i, f := range m.list {
|
||||||
|
name := f.Name
|
||||||
|
if name == "" {
|
||||||
|
name = f.ForwardListen()
|
||||||
|
}
|
||||||
|
enabled := "yes"
|
||||||
|
if !f.Enabled {
|
||||||
|
enabled = "no"
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf(" %-22s %-8s %-20s %-20s %s",
|
||||||
|
truncate(name, 22),
|
||||||
|
f.Type,
|
||||||
|
truncate(f.ForwardListen(), 20),
|
||||||
|
truncate(f.ForwardTarget(), 20),
|
||||||
|
enabled,
|
||||||
|
)
|
||||||
|
style := normalStyle
|
||||||
|
if i == m.selected {
|
||||||
|
style = selectedRowStyle
|
||||||
|
}
|
||||||
|
b.WriteString(style.Render(line))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Details for selected
|
||||||
|
if m.selected >= 0 && m.selected < len(m.list) {
|
||||||
|
f := m.list[m.selected]
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(sectionStyle.Render("Selected"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" %s\n", f.ForwardHumanExplanation(m.serverAlias)))
|
||||||
|
for _, arg := range f.ForwardSSHArgs() {
|
||||||
|
b.WriteString(fmt.Sprintf(" %s\n", arg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
b.WriteString(renderHelp([]helpItem{
|
b.WriteString(renderHelp([]helpItem{
|
||||||
{Key: "Ctrl+A (a)", Action: "add"},
|
{Key: "Ctrl+A (a)", Action: "add"},
|
||||||
{Key: "Ctrl+E/Enter", Action: "edit"},
|
{Key: "Ctrl+E/Enter", Action: "edit"},
|
||||||
|
|
@ -142,15 +151,20 @@ type forwardFormModel struct {
|
||||||
focusIdx int
|
focusIdx int
|
||||||
err error
|
err error
|
||||||
saved bool
|
saved bool
|
||||||
typeList list.Model
|
|
||||||
showList bool
|
|
||||||
currentType model.ForwardType
|
currentType model.ForwardType
|
||||||
nameInput textinput.Model
|
nameInput textinput.Model
|
||||||
descInput textinput.Model
|
descInput textinput.Model
|
||||||
|
typeIdx int // 0=local, 1=remote, 2=socks
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var forwardTypes = []forwardTypeItem{
|
||||||
|
{value: model.ForwardLocal, label: "Local", description: "port on my machine → service on SSH server"},
|
||||||
|
{value: model.ForwardRemote, label: "Remote", description: "port on SSH server → service on my machine"},
|
||||||
|
{value: model.ForwardDynamic, label: "SOCKS", description: "local dynamic SOCKS proxy through SSH"},
|
||||||
|
}
|
||||||
|
|
||||||
func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
|
func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
|
||||||
nameInput := textinput.New()
|
nameInput := textinput.New()
|
||||||
nameInput.Placeholder = "Local PostgreSQL"
|
nameInput.Placeholder = "Local PostgreSQL"
|
||||||
|
|
@ -161,34 +175,19 @@ func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
|
||||||
descInput.CharLimit = 256
|
descInput.CharLimit = 256
|
||||||
|
|
||||||
inputs := make([]textinput.Model, 4)
|
inputs := make([]textinput.Model, 4)
|
||||||
labels := []string{"Listen Address", "Listen Port", "Target Host", "Target Port"}
|
|
||||||
placeholders := []string{"127.0.0.1", "15432", "127.0.0.1", "5432"}
|
placeholders := []string{"127.0.0.1", "15432", "127.0.0.1", "5432"}
|
||||||
for i := range labels {
|
for i := range inputs {
|
||||||
inputs[i] = textinput.New()
|
inputs[i] = textinput.New()
|
||||||
inputs[i].Placeholder = placeholders[i]
|
inputs[i].Placeholder = placeholders[i]
|
||||||
inputs[i].CharLimit = 128
|
inputs[i].CharLimit = 128
|
||||||
}
|
}
|
||||||
|
|
||||||
typeItems := []list.Item{
|
|
||||||
forwardTypeItem{value: model.ForwardLocal, label: "Local", description: "port on my machine → service reachable from SSH server"},
|
|
||||||
forwardTypeItem{value: model.ForwardRemote, label: "Remote", description: "port on SSH server → service on my machine"},
|
|
||||||
forwardTypeItem{value: model.ForwardDynamic, label: "SOCKS", description: "local dynamic SOCKS proxy through SSH"},
|
|
||||||
}
|
|
||||||
|
|
||||||
typeList := list.New(typeItems, list.NewDefaultDelegate(), 50, 6)
|
|
||||||
typeList.Title = "Select forward type"
|
|
||||||
typeList.SetShowStatusBar(false)
|
|
||||||
typeList.SetShowHelp(false)
|
|
||||||
typeList.SetFilteringEnabled(false)
|
|
||||||
typeList.Styles.Title = titleStyle
|
|
||||||
|
|
||||||
return &forwardFormModel{
|
return &forwardFormModel{
|
||||||
serverID: serverID,
|
serverID: serverID,
|
||||||
inputs: inputs,
|
inputs: inputs,
|
||||||
labels: labels,
|
|
||||||
focusIdx: 0,
|
focusIdx: 0,
|
||||||
typeList: typeList,
|
|
||||||
currentType: model.ForwardLocal,
|
currentType: model.ForwardLocal,
|
||||||
|
typeIdx: 0,
|
||||||
nameInput: nameInput,
|
nameInput: nameInput,
|
||||||
descInput: descInput,
|
descInput: descInput,
|
||||||
width: w,
|
width: w,
|
||||||
|
|
@ -203,24 +202,26 @@ func newForwardEditModel(serverID int64, fwd *model.Forward, w, h int) *forwardF
|
||||||
fm.nameInput.SetValue(fwd.Name)
|
fm.nameInput.SetValue(fwd.Name)
|
||||||
fm.descInput.SetValue(fwd.Description)
|
fm.descInput.SetValue(fwd.Description)
|
||||||
fm.currentType = fwd.Type
|
fm.currentType = fwd.Type
|
||||||
|
fm.typeIdx = typeIndex(fwd.Type)
|
||||||
switch fwd.Type {
|
|
||||||
case model.ForwardLocal:
|
|
||||||
fm.typeList.Select(0)
|
|
||||||
case model.ForwardRemote:
|
|
||||||
fm.typeList.Select(1)
|
|
||||||
case model.ForwardDynamic:
|
|
||||||
fm.typeList.Select(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
fm.inputs[0].SetValue(fwd.LocalAddr)
|
fm.inputs[0].SetValue(fwd.LocalAddr)
|
||||||
fm.inputs[1].SetValue(strconv.Itoa(fwd.LocalPort))
|
fm.inputs[1].SetValue(strconv.Itoa(fwd.LocalPort))
|
||||||
fm.inputs[2].SetValue(fwd.RemoteAddr)
|
fm.inputs[2].SetValue(fwd.RemoteAddr)
|
||||||
fm.inputs[3].SetValue(strconv.Itoa(fwd.RemotePort))
|
fm.inputs[3].SetValue(strconv.Itoa(fwd.RemotePort))
|
||||||
|
|
||||||
return fm
|
return fm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func typeIndex(t model.ForwardType) int {
|
||||||
|
switch t {
|
||||||
|
case model.ForwardLocal:
|
||||||
|
return 0
|
||||||
|
case model.ForwardRemote:
|
||||||
|
return 1
|
||||||
|
case model.ForwardDynamic:
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func (fm *forwardFormModel) Init() tea.Cmd {
|
func (fm *forwardFormModel) Init() tea.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -228,11 +229,11 @@ func (fm *forwardFormModel) Init() tea.Cmd {
|
||||||
func (fm *forwardFormModel) visibleFields() []int {
|
func (fm *forwardFormModel) visibleFields() []int {
|
||||||
switch fm.currentType {
|
switch fm.currentType {
|
||||||
case model.ForwardLocal:
|
case model.ForwardLocal:
|
||||||
return []int{0, 1, 2, 3} // listen addr/port, target host/port
|
return []int{0, 1, 2, 3}
|
||||||
case model.ForwardRemote:
|
case model.ForwardRemote:
|
||||||
return []int{0, 1, 2, 3} // remote listen addr/port, local target host/port
|
return []int{0, 1, 2, 3}
|
||||||
case model.ForwardDynamic:
|
case model.ForwardDynamic:
|
||||||
return []int{0, 1} // listen addr/port only
|
return []int{0, 1}
|
||||||
default:
|
default:
|
||||||
return []int{0, 1, 2, 3}
|
return []int{0, 1, 2, 3}
|
||||||
}
|
}
|
||||||
|
|
@ -241,14 +242,13 @@ func (fm *forwardFormModel) visibleFields() []int {
|
||||||
func (fm *forwardFormModel) labelForField(idx int) string {
|
func (fm *forwardFormModel) labelForField(idx int) string {
|
||||||
switch fm.currentType {
|
switch fm.currentType {
|
||||||
case model.ForwardLocal:
|
case model.ForwardLocal:
|
||||||
return fm.labels[idx]
|
return []string{"Listen Address", "Listen Port", "Target Host", "Target Port"}[idx]
|
||||||
case model.ForwardRemote:
|
case model.ForwardRemote:
|
||||||
labels := []string{"Remote Listen Addr", "Remote Listen Port", "Local Target Host", "Local Target Port"}
|
return []string{"Remote Listen Addr", "Remote Listen Port", "Local Target Host", "Local Target Port"}[idx]
|
||||||
return labels[idx]
|
|
||||||
case model.ForwardDynamic:
|
case model.ForwardDynamic:
|
||||||
return fm.labels[idx]
|
return []string{"Listen Address", "Listen Port"}[idx]
|
||||||
default:
|
default:
|
||||||
return fm.labels[idx]
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,32 +260,12 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return fm, nil
|
return fm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if fm.showList {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch msg.Type {
|
|
||||||
case tea.KeyEsc:
|
|
||||||
fm.showList = false
|
|
||||||
return fm, nil
|
|
||||||
case tea.KeyEnter:
|
|
||||||
if item, ok := fm.typeList.SelectedItem().(forwardTypeItem); ok {
|
|
||||||
fm.currentType = item.value
|
|
||||||
}
|
|
||||||
fm.showList = false
|
|
||||||
return fm, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var cmd tea.Cmd
|
|
||||||
fm.typeList, cmd = fm.typeList.Update(msg)
|
|
||||||
return fm, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case tea.KeyTab:
|
case tea.KeyTab:
|
||||||
fm.focusIdx++
|
fm.focusIdx++
|
||||||
total := 2 + len(fm.visibleFields()) + 1 // name + desc + fields + save btn
|
total := 2 + 3 + len(fm.visibleFields()) + 1 // name + desc + type(3) + fields + save
|
||||||
if fm.focusIdx >= total {
|
if fm.focusIdx >= total {
|
||||||
fm.focusIdx = 0
|
fm.focusIdx = 0
|
||||||
}
|
}
|
||||||
|
|
@ -294,18 +274,21 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
case tea.KeyShiftTab:
|
case tea.KeyShiftTab:
|
||||||
fm.focusIdx--
|
fm.focusIdx--
|
||||||
if fm.focusIdx < 0 {
|
if fm.focusIdx < 0 {
|
||||||
total := 2 + len(fm.visibleFields()) + 1
|
total := 2 + 3 + len(fm.visibleFields()) + 1
|
||||||
fm.focusIdx = total - 1
|
fm.focusIdx = total - 1
|
||||||
}
|
}
|
||||||
fm.updateFocus()
|
fm.updateFocus()
|
||||||
return fm, nil
|
return fm, nil
|
||||||
case tea.KeyRunes:
|
case tea.KeyEnter:
|
||||||
if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 0 {
|
// Check if on type selector
|
||||||
fm.showList = true
|
if fm.focusIdx >= 2 && fm.focusIdx < 2+3 {
|
||||||
|
fm.typeIdx = fm.focusIdx - 2
|
||||||
|
fm.currentType = forwardTypes[fm.typeIdx].value
|
||||||
|
fm.focusIdx++
|
||||||
|
fm.updateFocus()
|
||||||
return fm, nil
|
return fm, nil
|
||||||
}
|
}
|
||||||
case tea.KeyEnter:
|
if fm.focusIdx == 2+3+len(fm.visibleFields()) {
|
||||||
if fm.focusIdx == 2+len(fm.visibleFields()) {
|
|
||||||
return fm, fm.runSave()
|
return fm, fm.runSave()
|
||||||
}
|
}
|
||||||
fm.focusIdx++
|
fm.focusIdx++
|
||||||
|
|
@ -315,7 +298,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return fm, nil
|
return fm, nil
|
||||||
case tea.KeyDown:
|
case tea.KeyDown:
|
||||||
fm.focusIdx++
|
fm.focusIdx++
|
||||||
total := 2 + len(fm.visibleFields()) + 1
|
total := 2 + 3 + len(fm.visibleFields()) + 1
|
||||||
if fm.focusIdx >= total {
|
if fm.focusIdx >= total {
|
||||||
fm.focusIdx = 0
|
fm.focusIdx = 0
|
||||||
}
|
}
|
||||||
|
|
@ -324,11 +307,32 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
case tea.KeyUp:
|
case tea.KeyUp:
|
||||||
fm.focusIdx--
|
fm.focusIdx--
|
||||||
if fm.focusIdx < 0 {
|
if fm.focusIdx < 0 {
|
||||||
total := 2 + len(fm.visibleFields()) + 1
|
total := 2 + 3 + len(fm.visibleFields()) + 1
|
||||||
fm.focusIdx = total - 1
|
fm.focusIdx = total - 1
|
||||||
}
|
}
|
||||||
fm.updateFocus()
|
fm.updateFocus()
|
||||||
return fm, nil
|
return fm, nil
|
||||||
|
case tea.KeyRunes:
|
||||||
|
// Direct number key to select type
|
||||||
|
if len(msg.Runes) == 1 {
|
||||||
|
switch msg.Runes[0] {
|
||||||
|
case '1':
|
||||||
|
fm.typeIdx = 0
|
||||||
|
fm.currentType = model.ForwardLocal
|
||||||
|
fm.updateFocus()
|
||||||
|
return fm, nil
|
||||||
|
case '2':
|
||||||
|
fm.typeIdx = 1
|
||||||
|
fm.currentType = model.ForwardRemote
|
||||||
|
fm.updateFocus()
|
||||||
|
return fm, nil
|
||||||
|
case '3':
|
||||||
|
fm.typeIdx = 2
|
||||||
|
fm.currentType = model.ForwardDynamic
|
||||||
|
fm.updateFocus()
|
||||||
|
return fm, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,8 +348,8 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return fm, cmd
|
return fm, cmd
|
||||||
}
|
}
|
||||||
visible := fm.visibleFields()
|
visible := fm.visibleFields()
|
||||||
if fm.focusIdx >= 2 && fm.focusIdx < 2+len(visible) {
|
if fm.focusIdx >= 2+3 && fm.focusIdx < 2+3+len(visible) {
|
||||||
fieldIdx := visible[fm.focusIdx-2]
|
fieldIdx := visible[fm.focusIdx-(2+3)]
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
fm.inputs[fieldIdx], cmd = fm.inputs[fieldIdx].Update(msg)
|
fm.inputs[fieldIdx], cmd = fm.inputs[fieldIdx].Update(msg)
|
||||||
return fm, cmd
|
return fm, cmd
|
||||||
|
|
@ -364,7 +368,7 @@ func (fm *forwardFormModel) updateFocus() {
|
||||||
fm.inputs[i].Prompt = blurredStyle.Render(fm.labelForField(i) + ": ")
|
fm.inputs[i].Prompt = blurredStyle.Render(fm.labelForField(i) + ": ")
|
||||||
}
|
}
|
||||||
|
|
||||||
total := 2 + len(fm.visibleFields()) + 1
|
total := 2 + 3 + len(fm.visibleFields()) + 1
|
||||||
switch {
|
switch {
|
||||||
case fm.focusIdx == 0:
|
case fm.focusIdx == 0:
|
||||||
fm.nameInput.Focus()
|
fm.nameInput.Focus()
|
||||||
|
|
@ -372,9 +376,11 @@ func (fm *forwardFormModel) updateFocus() {
|
||||||
case fm.focusIdx == 1:
|
case fm.focusIdx == 1:
|
||||||
fm.descInput.Focus()
|
fm.descInput.Focus()
|
||||||
fm.descInput.Prompt = focusedStyle.Render("Description> ")
|
fm.descInput.Prompt = focusedStyle.Render("Description> ")
|
||||||
case fm.focusIdx >= 2 && fm.focusIdx < total-1:
|
case fm.focusIdx >= 2 && fm.focusIdx < 2+3:
|
||||||
|
// Type selector focused — no input to focus
|
||||||
|
case fm.focusIdx >= 2+3 && fm.focusIdx < total-1:
|
||||||
visible := fm.visibleFields()
|
visible := fm.visibleFields()
|
||||||
fieldIdx := visible[fm.focusIdx-2]
|
fieldIdx := visible[fm.focusIdx-(2+3)]
|
||||||
fm.inputs[fieldIdx].Focus()
|
fm.inputs[fieldIdx].Focus()
|
||||||
fm.inputs[fieldIdx].Prompt = focusedStyle.Render(fm.labelForField(fieldIdx) + "> ")
|
fm.inputs[fieldIdx].Prompt = focusedStyle.Render(fm.labelForField(fieldIdx) + "> ")
|
||||||
}
|
}
|
||||||
|
|
@ -466,26 +472,28 @@ func (fm *forwardFormModel) View() string {
|
||||||
b.WriteString(titleStyle.Render(title))
|
b.WriteString(titleStyle.Render(title))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
// Type selector
|
|
||||||
typeLabel := fmt.Sprintf("Type: %s (/ to change)", fm.currentType)
|
|
||||||
if fm.focusIdx == 0 {
|
|
||||||
typeLabel = focusedStyle.Render("Type> " + fmt.Sprintf("%s (/ to change)", fm.currentType))
|
|
||||||
}
|
|
||||||
b.WriteString(typeLabel)
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
if fm.showList {
|
|
||||||
b.WriteString("\n" + fm.typeList.View() + "\n")
|
|
||||||
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width))
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name
|
// Name
|
||||||
b.WriteString(fm.nameInput.View())
|
b.WriteString(fm.nameInput.View())
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
b.WriteString(fm.descInput.View())
|
b.WriteString(fm.descInput.View())
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Type selector — visible radio items
|
||||||
|
b.WriteString(sectionStyle.Render("Type"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
for i, t := range forwardTypes {
|
||||||
|
prefix := " "
|
||||||
|
style := normalStyle
|
||||||
|
if i == fm.typeIdx {
|
||||||
|
prefix = "▸ "
|
||||||
|
style = selectedRowStyle
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf("%s%d. %-8s %s", prefix, i+1, t.label, t.description)
|
||||||
|
b.WriteString(style.Render(line))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
// Dynamic fields based on type
|
// Dynamic fields based on type
|
||||||
|
|
@ -519,7 +527,7 @@ func (fm *forwardFormModel) View() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save button
|
// Save button
|
||||||
total := 2 + len(visible) + 1
|
total := 2 + 3 + len(visible) + 1
|
||||||
button := "\n[ Save ]"
|
button := "\n[ Save ]"
|
||||||
if fm.focusIdx == total-1 {
|
if fm.focusIdx == total-1 {
|
||||||
button = selectedStyle.Render(button)
|
button = selectedStyle.Render(button)
|
||||||
|
|
@ -537,7 +545,7 @@ func (fm *forwardFormModel) View() string {
|
||||||
b.WriteString(renderHelp([]helpItem{
|
b.WriteString(renderHelp([]helpItem{
|
||||||
{Key: "Tab/↓", Action: "next"},
|
{Key: "Tab/↓", Action: "next"},
|
||||||
{Key: "↑", Action: "prev"},
|
{Key: "↑", Action: "prev"},
|
||||||
{Key: "/", Action: "change type"},
|
{Key: "1/2/3", Action: "select type"},
|
||||||
{Key: "Enter", Action: "save"},
|
{Key: "Enter", Action: "save"},
|
||||||
{Key: "Esc", Action: "back"},
|
{Key: "Esc", Action: "back"},
|
||||||
}, fm.width))
|
}, fm.width))
|
||||||
|
|
@ -547,3 +555,9 @@ func (fm *forwardFormModel) View() string {
|
||||||
|
|
||||||
// forwardEditSignal is sent when user wants to edit a forward
|
// forwardEditSignal is sent when user wants to edit a forward
|
||||||
type forwardEditSignal struct{}
|
type forwardEditSignal struct{}
|
||||||
|
|
||||||
|
// forwardDeleteConfirmMsg asks for confirmation before deleting
|
||||||
|
type forwardDeleteConfirmMsg struct {
|
||||||
|
id int64
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue