Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion internal/adapters/ui/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
case 'x':
t.handleStopForwarding()
return nil
case 'K':
if t.isActiveListFocused() {
t.handleKillActiveSessions()
}
return nil
case 'j':
t.handleNavigateDown()
return nil
Expand Down Expand Up @@ -172,6 +177,8 @@ func (t *tui) handleSearchInput(query string) {
filtered, _ := t.serverService.ListServers(query)
sortServersForUI(filtered, t.sortMode)
t.serverList.UpdateServers(filtered)
active, _ := t.serverService.ListActiveSessions(query)
t.activeList.UpdateServers(active)
if len(filtered) == 0 {
t.details.ShowEmpty()
}
Expand Down Expand Up @@ -235,6 +242,19 @@ func (t *tui) handleServerSelectionChange(server domain.Server) {
}

func (t *tui) handleServerAdd() {
if t.isActiveListFocused() {
if server, ok := t.activeList.GetSelectedServer(); ok {
form := NewServerForm(ServerFormAdd, nil).
SetPrefill(&server).
SetApp(t.app).
SetVersionInfo(t.version, t.commit).
OnSave(t.handleServerSave).
OnCancel(t.handleFormCancel)
t.app.SetRoot(form, true)
return
}
}

form := NewServerForm(ServerFormAdd, nil).
SetApp(t.app).
SetVersionInfo(t.version, t.commit).
Expand All @@ -244,6 +264,10 @@ func (t *tui) handleServerAdd() {
}

func (t *tui) handleServerEdit() {
if t.isActiveListFocused() {
t.showStatusTemp("Edit disabled for active sessions. Use 'a' to add.")
return
}
if server, ok := t.serverList.GetSelectedServer(); ok {
form := NewServerForm(ServerFormEdit, &server).
SetApp(t.app).
Expand All @@ -258,7 +282,15 @@ func (t *tui) handleServerSave(server domain.Server, original *domain.Server) {
var err error
if original != nil {
// Edit mode
err = t.serverService.UpdateServer(*original, server)
base := *original
if resolved, ok, resolveErr := t.serverService.ResolveConfigServer(*original); resolveErr != nil {
err = resolveErr
} else if ok {
base = resolved
}
if err == nil {
err = t.serverService.UpdateServer(base, server)
}
} else {
// Add mode
err = t.serverService.AddServer(server)
Expand Down Expand Up @@ -332,9 +364,17 @@ func (t *tui) handleRefreshBackground() {
})
return
}
active, activeErr := t.serverService.ListActiveSessions(q)
if activeErr != nil {
t.app.QueueUpdateDraw(func() {
t.showStatusTempColor(fmt.Sprintf("Active refresh failed: %v", activeErr), "#FF6B6B")
})
return
}
sortServersForUI(servers, t.sortMode)
t.app.QueueUpdateDraw(func() {
t.serverList.UpdateServers(servers)
t.activeList.UpdateServers(active)
// Try to restore selection if still valid
if prevIdx >= 0 && prevIdx < t.serverList.List.GetItemCount() {
t.serverList.SetCurrentItem(prevIdx)
Expand Down Expand Up @@ -581,6 +621,8 @@ func (t *tui) refreshServerList() {
filtered, _ := t.serverService.ListServers(query)
sortServersForUI(filtered, t.sortMode)
t.serverList.UpdateServers(filtered)
active, _ := t.serverService.ListActiveSessions(query)
t.activeList.UpdateServers(active)
}

func (t *tui) returnToMain() {
Expand Down Expand Up @@ -629,3 +671,28 @@ func (t *tui) handleStopForwarding() {
}()
}
}

// Terminate active SSH sessions for the selected server.
func (t *tui) handleKillActiveSessions() {
if server, ok := t.activeList.GetSelectedServer(); ok {
go func(selected domain.Server) {
count, err := t.serverService.KillActiveSessions(selected)
t.app.QueueUpdateDraw(func() {
if err != nil {
t.showStatusTempColor("Failed to terminate SSH sessions: "+err.Error(), "#FF6B6B")
} else {
t.showStatusTemp(fmt.Sprintf("Terminated %d SSH session(s) for %s", count, selected.Alias))
}
t.refreshServerList()
})
}(server)
}
}

func (t *tui) isActiveListFocused() bool {
if t.app == nil || t.activeList == nil {
return false
}
focus := t.app.GetFocus()
return focus == t.activeList || focus == t.activeList.List
}
160 changes: 88 additions & 72 deletions internal/adapters/ui/server_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ type ServerForm struct {
tabAbbrev map[string]string // Abbreviated tab names for narrow views
mode ServerFormMode
original *domain.Server
prefill *domain.Server
onSave func(domain.Server, *domain.Server)
onCancel func()
app *tview.Application // Reference to app for showing modals
Expand Down Expand Up @@ -113,6 +114,11 @@ func NewServerForm(mode ServerFormMode, original *domain.Server) *ServerForm {
return form
}

func (sf *ServerForm) SetPrefill(server *domain.Server) *ServerForm {
sf.prefill = server
return sf
}

func (sf *ServerForm) build() {
// Create header
sf.header = NewAppHeader(sf.version, sf.commit, RepoURL)
Expand Down Expand Up @@ -1065,78 +1071,13 @@ func (sf *ServerForm) validateAllFields() bool {
// getDefaultValues returns default form values based on mode
func (sf *ServerForm) getDefaultValues() ServerFormData {
if sf.mode == ServerFormEdit && sf.original != nil {
return ServerFormData{
Alias: sf.original.Alias,
Host: sf.original.Host,
User: sf.original.User,
Port: fmt.Sprint(sf.original.Port),
Key: strings.Join(sf.original.IdentityFiles, ", "),
Tags: strings.Join(sf.original.Tags, ", "),
ProxyJump: sf.original.ProxyJump,
ProxyCommand: sf.original.ProxyCommand,
RemoteCommand: sf.original.RemoteCommand,
RequestTTY: sf.original.RequestTTY,
SessionType: sf.original.SessionType,
ConnectTimeout: sf.original.ConnectTimeout,
ConnectionAttempts: sf.original.ConnectionAttempts,
BindAddress: sf.original.BindAddress,
BindInterface: sf.original.BindInterface,
AddressFamily: sf.original.AddressFamily,
ExitOnForwardFailure: sf.original.ExitOnForwardFailure,
IPQoS: sf.original.IPQoS,
// Hostname canonicalization
CanonicalizeHostname: sf.original.CanonicalizeHostname,
CanonicalDomains: sf.original.CanonicalDomains,
CanonicalizeFallbackLocal: sf.original.CanonicalizeFallbackLocal,
CanonicalizeMaxDots: sf.original.CanonicalizeMaxDots,
CanonicalizePermittedCNAMEs: sf.original.CanonicalizePermittedCNAMEs,
GatewayPorts: sf.original.GatewayPorts,
LocalForward: strings.Join(sf.original.LocalForward, ", "),
RemoteForward: strings.Join(sf.original.RemoteForward, ", "),
DynamicForward: strings.Join(sf.original.DynamicForward, ", "),
ClearAllForwardings: sf.original.ClearAllForwardings,
// Public key
PubkeyAuthentication: sf.original.PubkeyAuthentication,
IdentitiesOnly: sf.original.IdentitiesOnly,
// SSH Agent
AddKeysToAgent: sf.original.AddKeysToAgent,
IdentityAgent: sf.original.IdentityAgent,
// Password & Interactive
PasswordAuthentication: sf.original.PasswordAuthentication,
KbdInteractiveAuthentication: sf.original.KbdInteractiveAuthentication,
NumberOfPasswordPrompts: sf.original.NumberOfPasswordPrompts,
// Advanced
PreferredAuthentications: sf.original.PreferredAuthentications,
ForwardAgent: sf.original.ForwardAgent,
ForwardX11: sf.original.ForwardX11,
ForwardX11Trusted: sf.original.ForwardX11Trusted,
ControlMaster: sf.original.ControlMaster,
ControlPath: sf.original.ControlPath,
ControlPersist: sf.original.ControlPersist,
ServerAliveInterval: sf.original.ServerAliveInterval,
ServerAliveCountMax: sf.original.ServerAliveCountMax,
Compression: sf.original.Compression,
TCPKeepAlive: sf.original.TCPKeepAlive,
BatchMode: sf.original.BatchMode,
StrictHostKeyChecking: sf.original.StrictHostKeyChecking,
UserKnownHostsFile: sf.original.UserKnownHostsFile,
HostKeyAlgorithms: sf.original.HostKeyAlgorithms,
PubkeyAcceptedAlgorithms: sf.original.PubkeyAcceptedAlgorithms,
HostbasedAcceptedAlgorithms: sf.original.HostbasedAcceptedAlgorithms,
MACs: sf.original.MACs,
Ciphers: sf.original.Ciphers,
KexAlgorithms: sf.original.KexAlgorithms,
VerifyHostKeyDNS: sf.original.VerifyHostKeyDNS,
UpdateHostKeys: sf.original.UpdateHostKeys,
HashKnownHosts: sf.original.HashKnownHosts,
VisualHostKey: sf.original.VisualHostKey,
LocalCommand: sf.original.LocalCommand,
PermitLocalCommand: sf.original.PermitLocalCommand,
EscapeChar: sf.original.EscapeChar,
SendEnv: strings.Join(sf.original.SendEnv, ", "),
SetEnv: strings.Join(sf.original.SetEnv, ", "),
LogLevel: sf.original.LogLevel,
}
return serverToFormData(*sf.original)
}
if sf.mode == ServerFormAdd && sf.prefill != nil {
data := serverToFormData(*sf.prefill)
data.Alias = ""
data.Tags = ""
return data
}
// For new servers, use empty values instead of SSH defaults
// SSH defaults will be applied by the SSH client if values are not specified
Expand Down Expand Up @@ -1236,6 +1177,81 @@ func (sf *ServerForm) getDefaultValues() ServerFormData {
}
}

func serverToFormData(server domain.Server) ServerFormData {
return ServerFormData{
Alias: server.Alias,
Host: server.Host,
User: server.User,
Port: fmt.Sprint(server.Port),
Key: strings.Join(server.IdentityFiles, ", "),
Tags: strings.Join(server.Tags, ", "),
ProxyJump: server.ProxyJump,
ProxyCommand: server.ProxyCommand,
RemoteCommand: server.RemoteCommand,
RequestTTY: server.RequestTTY,
SessionType: server.SessionType,
ConnectTimeout: server.ConnectTimeout,
ConnectionAttempts: server.ConnectionAttempts,
BindAddress: server.BindAddress,
BindInterface: server.BindInterface,
AddressFamily: server.AddressFamily,
ExitOnForwardFailure: server.ExitOnForwardFailure,
IPQoS: server.IPQoS,
// Hostname canonicalization
CanonicalizeHostname: server.CanonicalizeHostname,
CanonicalDomains: server.CanonicalDomains,
CanonicalizeFallbackLocal: server.CanonicalizeFallbackLocal,
CanonicalizeMaxDots: server.CanonicalizeMaxDots,
CanonicalizePermittedCNAMEs: server.CanonicalizePermittedCNAMEs,
GatewayPorts: server.GatewayPorts,
LocalForward: strings.Join(server.LocalForward, ", "),
RemoteForward: strings.Join(server.RemoteForward, ", "),
DynamicForward: strings.Join(server.DynamicForward, ", "),
ClearAllForwardings: server.ClearAllForwardings,
// Public key
PubkeyAuthentication: server.PubkeyAuthentication,
IdentitiesOnly: server.IdentitiesOnly,
// SSH Agent
AddKeysToAgent: server.AddKeysToAgent,
IdentityAgent: server.IdentityAgent,
// Password & Interactive
PasswordAuthentication: server.PasswordAuthentication,
KbdInteractiveAuthentication: server.KbdInteractiveAuthentication,
NumberOfPasswordPrompts: server.NumberOfPasswordPrompts,
// Advanced
PreferredAuthentications: server.PreferredAuthentications,
ForwardAgent: server.ForwardAgent,
ForwardX11: server.ForwardX11,
ForwardX11Trusted: server.ForwardX11Trusted,
ControlMaster: server.ControlMaster,
ControlPath: server.ControlPath,
ControlPersist: server.ControlPersist,
ServerAliveInterval: server.ServerAliveInterval,
ServerAliveCountMax: server.ServerAliveCountMax,
Compression: server.Compression,
TCPKeepAlive: server.TCPKeepAlive,
BatchMode: server.BatchMode,
StrictHostKeyChecking: server.StrictHostKeyChecking,
UserKnownHostsFile: server.UserKnownHostsFile,
HostKeyAlgorithms: server.HostKeyAlgorithms,
PubkeyAcceptedAlgorithms: server.PubkeyAcceptedAlgorithms,
HostbasedAcceptedAlgorithms: server.HostbasedAcceptedAlgorithms,
MACs: server.MACs,
Ciphers: server.Ciphers,
KexAlgorithms: server.KexAlgorithms,
VerifyHostKeyDNS: server.VerifyHostKeyDNS,
UpdateHostKeys: server.UpdateHostKeys,
HashKnownHosts: server.HashKnownHosts,
VisualHostKey: server.VisualHostKey,
LocalCommand: server.LocalCommand,
PermitLocalCommand: server.PermitLocalCommand,
EscapeChar: server.EscapeChar,
SendEnv: strings.Join(server.SendEnv, ", "),
SetEnv: strings.Join(server.SetEnv, ", "),
LogLevel: server.LogLevel,
}
}

// createBasicForm creates the Basic configuration tab
func (sf *ServerForm) createBasicForm() {
form := tview.NewForm()
Expand Down
10 changes: 9 additions & 1 deletion internal/adapters/ui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type tui struct {
header *AppHeader
searchBar *SearchBar
serverList *ServerList
activeList *ServerList
details *ServerDetails
statusBar *tview.TextView

Expand Down Expand Up @@ -98,6 +99,10 @@ func (t *tui) buildComponents() *tui {
t.serverList = NewServerList().
OnSelectionChange(t.handleServerSelectionChange).
OnReturnToSearch(t.handleReturnToSearch)
t.activeList = NewServerList().
OnSelectionChange(t.handleServerSelectionChange).
OnReturnToSearch(t.handleReturnToSearch)
t.activeList.List.SetTitle(" Active Sessions (K: Kill) ")
t.details = NewServerDetails()
t.statusBar = NewStatusBar()

Expand All @@ -110,7 +115,8 @@ func (t *tui) buildComponents() *tui {
func (t *tui) buildLayout() *tui {
t.left = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(t.searchBar, 3, 0, false).
AddItem(t.serverList, 0, 1, true)
AddItem(t.serverList, 0, 1, true).
AddItem(t.activeList, 10, 0, false)

right := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(t.details, 0, 1, false)
Expand All @@ -136,6 +142,8 @@ func (t *tui) loadInitialData() *tui {
sortServersForUI(servers, t.sortMode)
t.updateListTitle()
t.serverList.UpdateServers(servers)
active, _ := t.serverService.ListActiveSessions("")
t.activeList.UpdateServers(active)

return t
}
Expand Down
1 change: 1 addition & 0 deletions internal/core/domain/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type Server struct {
LastSeen time.Time
PinnedAt time.Time
SSHCount int
ActivePID int

// Additional SSH config fields
// Connection and proxy settings
Expand Down
3 changes: 3 additions & 0 deletions internal/core/ports/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (

type ServerService interface {
ListServers(query string) ([]domain.Server, error)
ListActiveSessions(query string) ([]domain.Server, error)
UpdateServer(server domain.Server, newServer domain.Server) error
AddServer(server domain.Server) error
DeleteServer(server domain.Server) error
Expand All @@ -31,5 +32,7 @@ type ServerService interface {
StartForward(alias string, extraArgs []string) (int, error)
StopForwarding(alias string) error
IsForwarding(alias string) bool
KillActiveSessions(server domain.Server) (int, error)
ResolveConfigServer(server domain.Server) (domain.Server, bool, error)
Ping(server domain.Server) (bool, time.Duration, error)
}
Loading
Loading