Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ It lets you browse, search, connect to, edit, and organize the hosts defined in
### Fast Navigation
- Fuzzy search by alias, IP, user, or tag
- Press `Enter` to connect to the selected host immediately
- See currently running local SSH sessions in a read-only panel
- Organize hosts with tags such as `prod`, `dev`, and `test`
- Sort by alias or last connection time, with reversible order

Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ lazyssh 是一个运行在终端中的交互式 SSH 管理器,灵感来自 `la
### 快速导航
- 支持按别名、IP、用户或标签进行模糊搜索
- 选中主机后按回车即可直接 SSH 登录
- 只读展示当前本机正在运行的 SSH 会话
- 支持用标签组织主机,例如 `prod`、`dev`、`test`
- 支持按别名或最近连接时间排序,并可反转排序方向

Expand Down
2 changes: 1 addition & 1 deletion cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func main() {
}

metaDataFile := filepath.Join(home, ".lazyssh", "metadata.json")
var serverRepo = ssh_config_file.NewRepositoryWithWritePath(log, configPath, managedConfigPath, metaDataFile)
serverRepo := ssh_config_file.NewRepositoryWithWritePath(log, configPath, managedConfigPath, metaDataFile)
if sshConfigReadOnly {
serverRepo = ssh_config_file.NewReadOnlyRepository(log, configPath, metaDataFile)
}
Expand Down
83 changes: 83 additions & 0 deletions internal/adapters/ui/active_sessions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ui

import (
"fmt"
"strings"

"github.com/rivo/tview"
"github.com/urzeye/lazyssh/internal/core/domain"
)

const maxActiveSessionsShown = 4

type ActiveSessions struct {
*tview.TextView
}

func NewActiveSessions() *ActiveSessions {
sessions := &ActiveSessions{
TextView: tview.NewTextView(),
}
sessions.build()
return sessions
}

func (as *ActiveSessions) build() {
as.TextView.SetDynamicColors(true).
SetWrap(false).
SetScrollable(false).
SetBorder(true).
SetTitle(" Active Sessions ").
SetTitleAlign(tview.AlignCenter).
SetBorderColor(CurrentTheme.Border).
SetTitleColor(CurrentTheme.Title)
as.SetSessions(nil)
}

func (as *ActiveSessions) SetSessions(sessions []domain.ActiveSession) {
as.SetTitle(fmt.Sprintf(" Active Sessions: %d ", len(sessions)))
if len(sessions) == 0 {
as.SetText(colorText(CurrentTheme.DimText, "No active SSH sessions"))
return
}

lines := make([]string, 0, min(len(sessions), maxActiveSessionsShown)+1)
for index, session := range sessions {
if index >= maxActiveSessionsShown {
lines = append(lines, colorText(CurrentTheme.DimText, fmt.Sprintf("+%d more", len(sessions)-index)))
break
}
lines = append(lines, formatActiveSessionLine(session))
}
as.SetText(strings.Join(lines, "\n"))
}

func formatActiveSessionLine(session domain.ActiveSession) string {
alias := session.Alias
if alias == "" {
alias = session.Destination
}

parts := []string{
colorText(CurrentTheme.MetaText, fmt.Sprintf("%d", session.PID)),
colorText(CurrentTheme.AliasText, tview.Escape(alias)),
}
if len(session.Forwards) > 0 {
parts = append(parts, colorText(CurrentTheme.HostText, strings.Join(session.Forwards, ", ")))
}
return strings.Join(parts, " ")
}
53 changes: 53 additions & 0 deletions internal/adapters/ui/active_sessions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ui

import (
"strings"
"testing"

"github.com/urzeye/lazyssh/internal/core/domain"
)

func TestActiveSessionsSetSessionsShowsCountAndLimit(t *testing.T) {
view := NewActiveSessions()
view.SetSessions([]domain.ActiveSession{
{PID: 1, Alias: "alpha"},
{PID: 2, Alias: "beta"},
{PID: 3, Alias: "gamma"},
{PID: 4, Alias: "delta"},
{PID: 5, Alias: "epsilon"},
})

if got := view.GetTitle(); got != " Active Sessions: 5 " {
t.Fatalf("title = %q, want count title", got)
}
text := view.GetText(false)
if !strings.Contains(text, "alpha") || !strings.Contains(text, "+1 more") {
t.Fatalf("text = %q, want first session and overflow indicator", text)
}
if strings.Contains(text, "epsilon") {
t.Fatalf("text = %q, should not include hidden overflow session", text)
}
}

func TestActiveSessionsSetSessionsShowsEmptyState(t *testing.T) {
view := NewActiveSessions()
view.SetSessions(nil)

if text := view.GetText(false); !strings.Contains(text, "No active SSH sessions") {
t.Fatalf("text = %q, want empty state", text)
}
}
17 changes: 16 additions & 1 deletion internal/adapters/ui/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,19 +408,27 @@ func (t *tui) handleRefreshBackground() {
})
return
}
sessions, sessionsErr := t.serverService.ListActiveSessions()
if strings.TrimSpace(q) == "" {
sortServersForUI(servers, t.sortMode)
}
t.app.QueueUpdateDraw(func() {
t.serverList.UpdateServers(servers)
if sessionsErr == nil && t.sessions != nil {
t.sessions.SetSessions(sessions)
}
// Try to restore selection if still valid
if prevIdx >= 0 && prevIdx < t.serverList.List.GetItemCount() {
t.serverList.SetCurrentItem(prevIdx)
if srv, ok := t.serverList.GetSelectedServer(); ok {
t.details.UpdateServer(srv)
}
}
t.showStatusTemp(fmt.Sprintf("Refreshed %d servers", len(servers)))
if sessionsErr != nil {
t.showStatusTempColor(fmt.Sprintf("Refreshed %d servers; active sessions failed", len(servers)), CurrentTheme.Warning)
return
}
t.showStatusTemp(fmt.Sprintf("Refreshed %d servers, %d active sessions", len(servers), len(sessions)))
})
}(currentIdx, query)
}
Expand Down Expand Up @@ -648,6 +656,9 @@ func (t *tui) showSearchBar() {
t.left.Clear()
t.left.AddItem(t.searchBar, 3, 0, true)
t.left.AddItem(t.serverList, 0, 1, false)
if t.sessions != nil {
t.left.AddItem(t.sessions, 6, 0, false)
}
t.searchVisible = true
if t.app != nil {
t.app.SetFocus(t.searchBar)
Expand All @@ -666,6 +677,9 @@ func (t *tui) hideSearchBar() {
t.left.Clear()
t.left.AddItem(t.hintBar, 1, 0, false)
t.left.AddItem(t.serverList, 0, 1, true)
if t.sessions != nil {
t.left.AddItem(t.sessions, 6, 0, false)
}
t.searchVisible = false
if t.app != nil {
t.app.SetFocus(t.serverList)
Expand All @@ -686,6 +700,7 @@ func (t *tui) refreshServerList() {
sortServersForUI(filtered, t.sortMode)
}
t.serverList.UpdateServers(filtered)
t.loadActiveSessions()
}

func (t *tui) returnToMain() {
Expand Down
19 changes: 18 additions & 1 deletion internal/adapters/ui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type tui struct {
hintBar *tview.TextView
searchBar *SearchBar
serverList *ServerList
sessions *ActiveSessions
details *ServerDetails
statusBar *tview.TextView

Expand Down Expand Up @@ -123,6 +124,7 @@ func (t *tui) buildComponents() *tui {
t.serverList = NewServerList().
OnSelectionChange(t.handleServerSelectionChange).
OnReturnToSearch(t.handleReturnToSearch)
t.sessions = NewActiveSessions()
t.details = NewServerDetails()
t.statusBar = NewStatusBar()

Expand Down Expand Up @@ -153,7 +155,8 @@ func (t *tui) loadPreferences() *tui {
func (t *tui) buildLayout() *tui {
t.left = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(t.hintBar, 1, 0, false).
AddItem(t.serverList, 0, 1, true)
AddItem(t.serverList, 0, 1, true).
AddItem(t.sessions, 6, 0, false)

right := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(t.details, 0, 1, false)
Expand All @@ -175,6 +178,7 @@ func (t *tui) bindEvents() {

func (t *tui) loadInitialData() {
t.loadServerList("")
t.loadActiveSessions()
Comment thread
urzeye marked this conversation as resolved.
}

func (t *tui) loadServerList(query string) {
Expand All @@ -186,6 +190,18 @@ func (t *tui) loadServerList(query string) {
t.serverList.UpdateServers(servers)
}

func (t *tui) loadActiveSessions() {
if t.sessions == nil {
return
}
sessions, err := t.serverService.ListActiveSessions()
if err != nil {
t.sessions.SetText(colorText(CurrentTheme.Error, "Failed to load active sessions"))
return
}
t.sessions.SetSessions(sessions)
}

func (t *tui) updateListTitle() {
if t.serverList != nil {
t.serverList.SetTitle(" 1 Servers — Sort: " + t.sortMode.String() + " ")
Expand Down Expand Up @@ -258,6 +274,7 @@ func (t *tui) restoreMainUIState(state mainUIState) {
}

t.loadServerList(state.query)
t.loadActiveSessions()
t.restoreSelectedServer(state)
t.restoreFocus(state)
}
Expand Down
8 changes: 8 additions & 0 deletions internal/core/domain/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,11 @@ type Server struct {
// Debugging settings
LogLevel string
}

type ActiveSession struct {
PID int
Alias string
Destination string
Command string
Forwards []string
}
1 change: 1 addition & 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() ([]domain.ActiveSession, error)
UpdateServer(server domain.Server, newServer domain.Server) error
AddServer(server domain.Server) error
DeleteServer(server domain.Server) error
Expand Down
Loading
Loading