Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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