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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This repository is an enhanced fork of the original [`lazyssh`](https://github.c
- Proper handling of quoted entries such as `Host "example-name"`
- Better fuzzy search relevance across alias, hostname, user, and tags
- Daily workflow improvements such as a collapsible search bar, `0/1/2` panel switching, copy-host support, and persisted sort mode
- Dark/light theme support with a persisted `T` toggle
- A set of high-value fixes: backspace works correctly in inputs, `j/k` navigation survives mouse clicks, usernames support `@` and `:`, and alias alignment is more stable
- Safer config writes for dotfiles and multi-machine sync: symlinks are preserved and `IdentityFile` is normalized to portable `~/.ssh/...` paths whenever possible

Expand Down Expand Up @@ -230,6 +231,7 @@ make run
| `p` | Pin or unpin a host |
| `s` | Switch the sort field |
| `S` | Reverse the sort order |
| `T` | Toggle dark/light theme |
| `0` / `1` / `2` | Focus the search bar / host list / details panel |
| `q` | Quit |

Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- 兼容带引号的 `Host "example-name"` 条目,避免因为别名格式导致列表展示、删除或连接行为异常
- 搜索改为更实用的模糊相关性排序,按别名、主机、用户、标签综合匹配
- 增强了日常使用体验:可折叠搜索栏、`0/1/2` 面板切换、复制 Host、记住排序方式
- 支持暗色 / 亮色主题,并可通过 `T` 切换和记住偏好
- 合并了一批高价值修复:输入框退格恢复正常、鼠标点击后 `j/k` 仍可导航、用户名支持 `@` 和 `:`、列表别名对齐更稳定
- 配置写回更适合 dotfiles / 多机同步:保留符号链接不被写坏,`IdentityFile` 会尽量写成 `~/.ssh/...` 这种可移植格式

Expand Down Expand Up @@ -230,6 +231,7 @@ make run
| `p` | 置顶 / 取消置顶 |
| `s` | 切换排序字段 |
| `S` | 反转排序顺序 |
| `T` | 切换暗色 / 亮色主题 |
| `0` / `1` / `2` | 聚焦搜索栏 / 主机列表 / 详情面板 |
| `q` | 退出 |

Expand Down
2 changes: 1 addition & 1 deletion internal/adapters/data/ssh_config_file/crud.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import (
"path/filepath"
"strings"

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

const (
Expand Down
2 changes: 1 addition & 1 deletion internal/adapters/data/ssh_config_file/include.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ import (
"path/filepath"
"strings"

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

// loadAllServers loads servers from the main SSH config and all recursively
Expand Down
2 changes: 1 addition & 1 deletion internal/adapters/data/ssh_config_file/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import (
"strings"
"time"

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

var effectiveSingleValueKeys = []string{
Expand Down
40 changes: 25 additions & 15 deletions internal/adapters/ui/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
case 'S':
t.handleSortReverse()
return nil
case 'T':
t.handleThemeToggle()
return nil
case 'c':
t.handleCopyCommand()
return nil
Expand Down Expand Up @@ -142,6 +145,13 @@ func (t *tui) handleSortReverse() {
t.refreshServerList()
}

func (t *tui) handleThemeToggle() {
t.themeMode = t.themeMode.Toggle()
t.persistThemeMode()
t.rebuildMainUI()
t.showStatusTemp("Theme: " + t.themeMode.String())
}

func (t *tui) handleCopyCommand() {
if server, ok := t.serverList.GetSelectedServer(); ok {
cmd := BuildSSHCommand(server)
Expand All @@ -167,7 +177,7 @@ func (t *tui) handleCopyHost() {
func (t *tui) handleTagsEdit() {
if server, ok := t.serverList.GetSelectedServer(); ok {
if server.Readonly {
t.showStatusTempColor(fmt.Sprintf("Read-only: %s is managed in %s", server.Alias, server.SourceFile), "#FFCC66")
t.showStatusTempColor(fmt.Sprintf("Read-only: %s is managed in %s", server.Alias, server.SourceFile), CurrentTheme.Warning)
return
}
t.showEditTagsForm(server)
Expand Down Expand Up @@ -289,7 +299,7 @@ func (t *tui) handleServerSelectionChange(server domain.Server) {

func (t *tui) handleServerAdd() {
if t.serverService.IsReadOnly() {
t.showStatusTempColor("Read-only mode: SSH config changes are disabled", "#FFCC66")
t.showStatusTempColor("Read-only mode: SSH config changes are disabled", CurrentTheme.Warning)
return
}

Expand All @@ -304,7 +314,7 @@ func (t *tui) handleServerAdd() {
func (t *tui) handleServerEdit() {
if server, ok := t.serverList.GetSelectedServer(); ok {
if server.Readonly {
t.showStatusTempColor(fmt.Sprintf("Read-only: %s is managed in %s", server.Alias, server.SourceFile), "#FFCC66")
t.showStatusTempColor(fmt.Sprintf("Read-only: %s is managed in %s", server.Alias, server.SourceFile), CurrentTheme.Warning)
return
}
form := NewServerForm(ServerFormEdit, &server).
Expand Down Expand Up @@ -342,7 +352,7 @@ func (t *tui) handleServerSave(server domain.Server, original *domain.Server) {
func (t *tui) handleServerDelete() {
if server, ok := t.serverList.GetSelectedServer(); ok {
if server.Readonly {
t.showStatusTempColor(fmt.Sprintf("Read-only: %s is managed in %s", server.Alias, server.SourceFile), "#FF6B6B")
t.showStatusTempColor(fmt.Sprintf("Read-only: %s is managed in %s", server.Alias, server.SourceFile), CurrentTheme.Error)
return
}
t.showDeleteConfirmModal(server)
Expand All @@ -362,13 +372,13 @@ func (t *tui) handlePingSelected() {
up, dur, err := t.serverService.Ping(server)
t.app.QueueUpdateDraw(func() {
if err != nil {
t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN (%v)", alias, err), "#FF6B6B")
t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN (%v)", alias, err), CurrentTheme.Error)
return
}
if up {
t.showStatusTempColor(fmt.Sprintf("Ping %s: UP (%s)", alias, dur), "#A0FFA0")
t.showStatusTempColor(fmt.Sprintf("Ping %s: UP (%s)", alias, dur), CurrentTheme.Success)
} else {
t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN", alias), "#FF6B6B")
t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN", alias), CurrentTheme.Error)
}
})
}()
Expand All @@ -394,7 +404,7 @@ func (t *tui) handleRefreshBackground() {
servers, err := t.serverService.ListServers(q)
if err != nil {
t.app.QueueUpdateDraw(func() {
t.showStatusTempColor(fmt.Sprintf("Refresh failed: %v", err), "#FF6B6B")
t.showStatusTempColor(fmt.Sprintf("Refresh failed: %v", err), CurrentTheme.Error)
})
return
}
Expand Down Expand Up @@ -557,12 +567,12 @@ func (t *tui) showPortForwardForm(server domain.Server) {

form.AddButton("Start", func() {
if err := validatePort(portVal); err != nil {
t.showStatusTempColor("Invalid port: "+err.Error(), "#FF6B6B")
t.showStatusTempColor("Invalid port: "+err.Error(), CurrentTheme.Error)
return
}
if bindAddrVal != "" {
if err := validateBindAddress(bindAddrVal); err != nil {
t.showStatusTempColor("Invalid bind address: "+err.Error(), "#FF6B6B")
t.showStatusTempColor("Invalid bind address: "+err.Error(), CurrentTheme.Error)
return
}
}
Expand All @@ -577,11 +587,11 @@ func (t *tui) showPortForwardForm(server domain.Server) {
args = append(args, "-D", spec)
} else {
if err := validateHost(hostVal); err != nil {
t.showStatusTempColor("Invalid host: "+err.Error(), "#FF6B6B")
t.showStatusTempColor("Invalid host: "+err.Error(), CurrentTheme.Error)
return
}
if err := validatePort(hostPortVal); err != nil {
t.showStatusTempColor("Invalid host port: "+err.Error(), "#FF6B6B")
t.showStatusTempColor("Invalid host port: "+err.Error(), CurrentTheme.Error)
return
}
spec := portVal + ":" + hostVal + ":" + hostPortVal
Expand All @@ -604,7 +614,7 @@ func (t *tui) showPortForwardForm(server domain.Server) {
pid, err := t.serverService.StartForward(alias, args)
t.app.QueueUpdateDraw(func() {
if err != nil {
t.showStatusTempColor("Forward failed: "+err.Error(), "#FF6B6B")
t.showStatusTempColor("Forward failed: "+err.Error(), CurrentTheme.Error)
} else {
t.refreshServerList()
t.showStatusTemp(fmt.Sprintf("Port forwarding started (pid %d)", pid))
Expand Down Expand Up @@ -687,7 +697,7 @@ func (t *tui) showStatusTemp(msg string) {
if t.statusBar == nil {
return
}
t.showStatusTempColor(msg, "#A0FFA0")
t.showStatusTempColor(msg, CurrentTheme.Success)
}

// showStatusTempColor displays a temporary colored message in the status bar and restores default text after 2s.
Expand Down Expand Up @@ -715,7 +725,7 @@ func (t *tui) handleStopForwarding() {
err := t.serverService.StopForwarding(alias)
t.app.QueueUpdateDraw(func() {
if err != nil {
t.showStatusTempColor("Failed to stop forwarding: "+err.Error(), "#FF6B6B")
t.showStatusTempColor("Failed to stop forwarding: "+err.Error(), CurrentTheme.Error)
} else {
t.showStatusTemp("Stopped forwarding for " + alias)
}
Expand Down
18 changes: 11 additions & 7 deletions internal/adapters/ui/header.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func NewAppHeader(version, gitCommit, repoURL string) *AppHeader {
}

func (h *AppHeader) build() {
headerBg := tcell.Color234
headerBg := CurrentTheme.HeaderBackground

left := h.buildLeftSection(headerBg)
center := h.buildCenterSection(headerBg)
Expand All @@ -64,7 +64,8 @@ func (h *AppHeader) buildLeftSection(bg tcell.Color) *tview.TextView {
SetDynamicColors(true).
SetTextAlign(tview.AlignLeft)
left.SetBackgroundColor(bg)
stylizedName := "🚀 [#FFFFFF::b]lazy[-][#55D7FF::b]ssh[-]"
stylizedName := "🚀 [" + CurrentTheme.AliasText + "::b]lazy[-]" +
"[" + CurrentTheme.AccentStrong + "::b]ssh[-]"
left.SetText(stylizedName)
return left
}
Expand All @@ -78,10 +79,10 @@ func (h *AppHeader) buildCenterSection(bg tcell.Color) *tview.TextView {
commit := shortCommit(h.gitCommit)

// Build tag-like chips for version, commit, and build time
versionTag := makeTag(h.version, "#22C55E") // green
versionTag := makeTag(h.version, CurrentTheme.Success)
commitTag := ""
if commit != "" {
commitTag = makeTag(commit, "#A78BFA") // violet
commitTag = makeTag(commit, CurrentTheme.AccentStrong)
}

text := versionTag
Expand All @@ -99,14 +100,17 @@ func (h *AppHeader) buildRightSection(bg tcell.Color) *tview.TextView {
SetTextAlign(tview.AlignRight)
right.SetBackgroundColor(bg)
currentTime := time.Now().Format("Mon, 02 Jan 2006 15:04")
right.SetText("[#55AAFF::u]🔗 " + h.repoURL + "[-] [#AAAAAA]• " + currentTime + "[-]")
right.SetText(
"[" + CurrentTheme.Accent + "::u]🔗 " + h.repoURL + "[-] " +
colorText(CurrentTheme.HostText, "• "+currentTime),
)
return right
}

func (h *AppHeader) createSeparator() *tview.TextView {
separator := tview.NewTextView().SetDynamicColors(true)
separator.SetBackgroundColor(tcell.Color235)
separator.SetText("[#444444]" + strings.Repeat("─", 200) + "[-]")
separator.SetBackgroundColor(CurrentTheme.StatusBackground)
separator.SetText(colorText(CurrentTheme.Separator, strings.Repeat("─", 200)))
return separator
}

Expand Down
12 changes: 6 additions & 6 deletions internal/adapters/ui/hint_bar.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@

package ui

import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
import "github.com/rivo/tview"

func NewHintBar() *tview.TextView {
hint := tview.NewTextView().SetDynamicColors(true)
hint.SetBackgroundColor(tcell.Color233)
hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] or [::b]0[-:-:b] to search • [::b]1[-:-:b] Servers • [::b]2[-:-:b] Details[-]")
hint.SetBackgroundColor(CurrentTheme.InputBackground)
hint.SetText(
colorText(CurrentTheme.HostText, "Press ") +
"[::b]/[-:-:b] or [::b]0[-:-:b] to search • [::b]1[-:-:b] Servers • [::b]2[-:-:b] Details[-]",
)
return hint
}
8 changes: 4 additions & 4 deletions internal/adapters/ui/search_bar.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ func NewSearchBar() *SearchBar {

func (s *SearchBar) build() {
s.InputField.SetLabel(" 🔍 Search: ").
SetFieldBackgroundColor(tcell.Color233).
SetFieldTextColor(tcell.Color252).
SetFieldBackgroundColor(CurrentTheme.InputBackground).
SetFieldTextColor(CurrentTheme.PrimaryText).
SetFieldWidth(30).
SetBorder(true).
SetTitle(" 0 Search ").
SetTitleAlign(tview.AlignCenter).
SetBorderColor(tcell.Color238).
SetTitleColor(tcell.Color250)
SetBorderColor(CurrentTheme.Border).
SetTitleColor(CurrentTheme.Title)

s.InputField.SetChangedFunc(func(text string) {
if s.onSearch != nil {
Expand Down
40 changes: 30 additions & 10 deletions internal/adapters/ui/server_details.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@ import (
"fmt"
"strings"

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

type ServerDetails struct {
Expand All @@ -41,8 +40,8 @@ func (sd *ServerDetails) build() {
SetBorder(true).
SetTitle(" 2 Details ").
SetTitleAlign(tview.AlignCenter).
SetBorderColor(tcell.Color238).
SetTitleColor(tcell.Color250)
SetBorderColor(CurrentTheme.Border).
SetTitleColor(CurrentTheme.Title)
}

// renderTagChips builds colored tag chips for details view.
Expand All @@ -52,7 +51,7 @@ func renderTagChips(tags []string) string {
}
chips := make([]string, 0, len(tags))
for _, t := range tags {
chips = append(chips, fmt.Sprintf("[black:#5FAFFF] %s [-:-:-]", t))
chips = append(chips, fmt.Sprintf("[%s:%s] %s [-:-:-]", CurrentTheme.TagText, CurrentTheme.TagBack, t))
}
return strings.Join(chips, " ")
}
Expand Down Expand Up @@ -89,12 +88,33 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) {
if server.Port == 0 {
portText = ""
}
valueColor := CurrentTheme.AliasText

text := fmt.Sprintf(
"[::b]%s[-]\n\n[::b]Basic Settings:[-]\n Host: [white]%s[-]\n User: [white]%s[-]\n Port: [white]%s[-]\n Key: [white]%s[-]\n Source: [white]%s[-]\n Editable: [white]%s[-]\n Tags: %s\n Pinned: [white]%s[-]\n Last SSH: %s\n SSH Count: [white]%d[-]\n",
aliasText, hostText, userText, portText,
serverKey, sourceText, editableText, tagsText, pinnedStr,
lastSeen, server.SSHCount)
"[::b]%s[-]\n\n"+
"[::b]Basic Settings:[-]\n"+
" Host: [%s]%s[-]\n"+
" User: [%s]%s[-]\n"+
" Port: [%s]%s[-]\n"+
" Key: [%s]%s[-]\n"+
" Source: [%s]%s[-]\n"+
" Editable: [%s]%s[-]\n"+
" Tags: %s\n"+
" Pinned: [%s]%s[-]\n"+
" Last SSH: %s\n"+
" SSH Count: [%s]%d[-]\n",
aliasText,
valueColor, hostText,
valueColor, userText,
valueColor, portText,
valueColor, serverKey,
valueColor, sourceText,
valueColor, editableText,
tagsText,
valueColor, pinnedStr,
lastSeen,
valueColor, server.SSHCount,
)

// Advanced settings section (only show non-empty fields)
// Organized by logical grouping for better readability
Expand Down Expand Up @@ -211,7 +231,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) {
for _, field := range group.fields {
if field.value != "" {
hasAdvanced = true
advancedText += fmt.Sprintf(" %s: [white]%s[-]\n", field.name, field.value)
advancedText += fmt.Sprintf(" %s: [%s]%s[-]\n", field.name, valueColor, field.value)
}
}
}
Expand Down
Loading
Loading