From d5ffac399086ba1a2a91e79ec84528285befa278 Mon Sep 17 00:00:00 2001 From: urzeye Date: Thu, 4 Jun 2026 00:12:36 +0800 Subject: [PATCH] feat(ui): auto-refresh active sessions --- README.md | 2 +- README.zh-CN.md | 2 +- internal/adapters/ui/active_sessions.go | 5 +- internal/adapters/ui/active_sessions_test.go | 15 ++++++ internal/adapters/ui/tui.go | 56 ++++++++++++++++++++ 5 files changed, 77 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7c4c609..7aba798 100644 --- a/README.md +++ b/README.md @@ -38,7 +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 +- See currently running local SSH sessions in an auto-refreshing read-only panel - Organize hosts with tags such as `prod`, `dev`, and `test` - Sort by alias or last connection time, with reversible order diff --git a/README.zh-CN.md b/README.zh-CN.md index 21ddcc2..b87ecff 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,7 +38,7 @@ lazyssh 是一个运行在终端中的交互式 SSH 管理器,灵感来自 `la ### 快速导航 - 支持按别名、IP、用户或标签进行模糊搜索 - 选中主机后按回车即可直接 SSH 登录 -- 只读展示当前本机正在运行的 SSH 会话 +- 在自动刷新的只读面板中展示当前本机正在运行的 SSH 会话 - 支持用标签组织主机,例如 `prod`、`dev`、`test` - 支持按别名或最近连接时间排序,并可反转排序方向 diff --git a/internal/adapters/ui/active_sessions.go b/internal/adapters/ui/active_sessions.go index 4711130..e3eb9c6 100644 --- a/internal/adapters/ui/active_sessions.go +++ b/internal/adapters/ui/active_sessions.go @@ -76,8 +76,11 @@ func formatActiveSessionLine(session domain.ActiveSession) string { colorText(CurrentTheme.MetaText, fmt.Sprintf("%d", session.PID)), colorText(CurrentTheme.AliasText, tview.Escape(alias)), } + if session.Destination != "" && session.Destination != alias { + parts = append(parts, colorText(CurrentTheme.DimText, tview.Escape(session.Destination))) + } if len(session.Forwards) > 0 { - parts = append(parts, colorText(CurrentTheme.HostText, strings.Join(session.Forwards, ", "))) + parts = append(parts, colorText(CurrentTheme.HostText, "fwd "+strings.Join(session.Forwards, ", "))) } return strings.Join(parts, " ") } diff --git a/internal/adapters/ui/active_sessions_test.go b/internal/adapters/ui/active_sessions_test.go index 409c40a..60e715a 100644 --- a/internal/adapters/ui/active_sessions_test.go +++ b/internal/adapters/ui/active_sessions_test.go @@ -51,3 +51,18 @@ func TestActiveSessionsSetSessionsShowsEmptyState(t *testing.T) { t.Fatalf("text = %q, want empty state", text) } } + +func TestFormatActiveSessionLineShowsDestinationAndForwards(t *testing.T) { + line := formatActiveSessionLine(domain.ActiveSession{ + PID: 12345, + Alias: "example.com", + Destination: "ops@example.com", + Forwards: []string{"8080:localhost:80"}, + }) + + for _, want := range []string{"12345", "example.com", "ops@example.com", "fwd 8080:localhost:80"} { + if !strings.Contains(line, want) { + t.Fatalf("line = %q, want it to contain %q", line, want) + } + } +} diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go index a1f0891..14a77a5 100644 --- a/internal/adapters/ui/tui.go +++ b/internal/adapters/ui/tui.go @@ -16,6 +16,7 @@ package ui import ( "strings" + "time" "go.uber.org/zap" @@ -52,6 +53,8 @@ type tui struct { sortMode SortMode themeMode ThemeMode searchVisible bool + + activeSessionsRefreshStop chan struct{} } type mainUIState struct { @@ -68,6 +71,8 @@ const ( focusDetails = "details" ) +const activeSessionsRefreshInterval = 10 * time.Second + func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit string) App { return &tui{ logger: logger, @@ -91,6 +96,8 @@ func (t *tui) Run() error { t.loadPreferences().initializeTheme().buildComponents().buildLayout().bindEvents() t.loadInitialData() t.app.SetRoot(t.root, true) + t.startActiveSessionsRefresh() + defer t.stopActiveSessionsRefresh() t.logger.Infow("starting TUI application", "version", t.version, "commit", t.commit) if err := t.app.Run(); err != nil { t.logger.Errorw("application run error", "error", err) @@ -202,6 +209,55 @@ func (t *tui) loadActiveSessions() { t.sessions.SetSessions(sessions) } +func (t *tui) startActiveSessionsRefresh() { + if t.app == nil || t.serverService == nil || t.activeSessionsRefreshStop != nil { + return + } + + stop := make(chan struct{}) + t.activeSessionsRefreshStop = stop + + go func() { + ticker := time.NewTicker(activeSessionsRefreshInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + t.refreshActiveSessionsFromBackground() + case <-stop: + return + } + } + }() +} + +func (t *tui) stopActiveSessionsRefresh() { + if t.activeSessionsRefreshStop == nil { + return + } + close(t.activeSessionsRefreshStop) + t.activeSessionsRefreshStop = nil +} + +func (t *tui) refreshActiveSessionsFromBackground() { + sessions, err := t.serverService.ListActiveSessions() + if t.app == nil { + return + } + + t.app.QueueUpdateDraw(func() { + if t.sessions == nil { + return + } + 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() + " ")