diff --git a/README.md b/README.md index cadbb38..26903b9 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | diff --git a/README.zh-CN.md b/README.zh-CN.md index 282cd22..9599c24 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -14,6 +14,7 @@ - 兼容带引号的 `Host "example-name"` 条目,避免因为别名格式导致列表展示、删除或连接行为异常 - 搜索改为更实用的模糊相关性排序,按别名、主机、用户、标签综合匹配 - 增强了日常使用体验:可折叠搜索栏、`0/1/2` 面板切换、复制 Host、记住排序方式 +- 支持暗色 / 亮色主题,并可通过 `T` 切换和记住偏好 - 合并了一批高价值修复:输入框退格恢复正常、鼠标点击后 `j/k` 仍可导航、用户名支持 `@` 和 `:`、列表别名对齐更稳定 - 配置写回更适合 dotfiles / 多机同步:保留符号链接不被写坏,`IdentityFile` 会尽量写成 `~/.ssh/...` 这种可移植格式 @@ -230,6 +231,7 @@ make run | `p` | 置顶 / 取消置顶 | | `s` | 切换排序字段 | | `S` | 反转排序顺序 | +| `T` | 切换暗色 / 亮色主题 | | `0` / `1` / `2` | 聚焦搜索栏 / 主机列表 / 详情面板 | | `q` | 退出 | diff --git a/internal/adapters/data/ssh_config_file/crud.go b/internal/adapters/data/ssh_config_file/crud.go index 506d0a0..a186c21 100644 --- a/internal/adapters/data/ssh_config_file/crud.go +++ b/internal/adapters/data/ssh_config_file/crud.go @@ -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 ( diff --git a/internal/adapters/data/ssh_config_file/include.go b/internal/adapters/data/ssh_config_file/include.go index 55b44fe..dd5e800 100644 --- a/internal/adapters/data/ssh_config_file/include.go +++ b/internal/adapters/data/ssh_config_file/include.go @@ -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 diff --git a/internal/adapters/data/ssh_config_file/mapper.go b/internal/adapters/data/ssh_config_file/mapper.go index 5eface8..bc87550 100644 --- a/internal/adapters/data/ssh_config_file/mapper.go +++ b/internal/adapters/data/ssh_config_file/mapper.go @@ -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{ diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 99a07ca..fe4a0d2 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -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 @@ -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) @@ -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) @@ -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 } @@ -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). @@ -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) @@ -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) } }) }() @@ -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 } @@ -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 } } @@ -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 @@ -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)) @@ -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. @@ -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) } diff --git a/internal/adapters/ui/header.go b/internal/adapters/ui/header.go index 45f7bd2..95af808 100644 --- a/internal/adapters/ui/header.go +++ b/internal/adapters/ui/header.go @@ -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) @@ -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 } @@ -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 @@ -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 } diff --git a/internal/adapters/ui/hint_bar.go b/internal/adapters/ui/hint_bar.go index 76655c2..aad80a3 100644 --- a/internal/adapters/ui/hint_bar.go +++ b/internal/adapters/ui/hint_bar.go @@ -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 } diff --git a/internal/adapters/ui/search_bar.go b/internal/adapters/ui/search_bar.go index 74a1b47..c66e4d0 100644 --- a/internal/adapters/ui/search_bar.go +++ b/internal/adapters/ui/search_bar.go @@ -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 { diff --git a/internal/adapters/ui/server_details.go b/internal/adapters/ui/server_details.go index dc788ed..e3a7d60 100644 --- a/internal/adapters/ui/server_details.go +++ b/internal/adapters/ui/server_details.go @@ -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 { @@ -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. @@ -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, " ") } @@ -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 @@ -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) } } } diff --git a/internal/adapters/ui/server_form.go b/internal/adapters/ui/server_form.go index f3cc898..81f9b5f 100644 --- a/internal/adapters/ui/server_form.go +++ b/internal/adapters/ui/server_form.go @@ -20,9 +20,9 @@ import ( "strconv" "strings" - "github.com/urzeye/lazyssh/internal/core/domain" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + "github.com/urzeye/lazyssh/internal/core/domain" ) // sshDefaults is now replaced by SSHFieldDefaults in defaults.go @@ -131,8 +131,8 @@ func (sf *ServerForm) build() { sf.formPanel.SetBorder(true). SetTitle(" " + sf.titleForMode() + " "). SetTitleAlign(tview.AlignCenter). - SetBorderColor(tcell.Color238). - SetTitleColor(tcell.Color250) + SetBorderColor(CurrentTheme.Border). + SetTitleColor(CurrentTheme.Title) sf.formPanel.AddItem(sf.tabBar, 1, 0, false). AddItem(sf.pages, 0, 1, true) @@ -170,9 +170,13 @@ func (sf *ServerForm) build() { // Create hint bar with same background as main screen's status bar hintBar := tview.NewTextView().SetDynamicColors(true) - hintBar.SetBackgroundColor(tcell.Color235) + hintBar.SetBackgroundColor(CurrentTheme.StatusBackground) hintBar.SetTextAlign(tview.AlignCenter) - hintBar.SetText("[white]^H/^L[-] Navigate • [white]^S[-] Save • [white]Esc[-] Cancel") + hintBar.SetText( + keyText("^H/^L") + " Navigate • " + + keyText("^S") + " Save • " + + keyText("Esc") + " Cancel", + ) // Setup main container - header at top, hint bar at bottom sf.Flex.AddItem(sf.header, 2, 0, false). @@ -1962,7 +1966,7 @@ func (sf *ServerForm) handleSave() bool { // Reset title and border (validation already done above) sf.formPanel.SetTitle(" " + sf.titleForMode() + " ") - sf.formPanel.SetBorderColor(tcell.Color238) + sf.formPanel.SetBorderColor(CurrentTheme.Border) server := sf.dataToServer(data) if sf.onSave != nil { diff --git a/internal/adapters/ui/server_list.go b/internal/adapters/ui/server_list.go index df929ef..02fa02c 100644 --- a/internal/adapters/ui/server_list.go +++ b/internal/adapters/ui/server_list.go @@ -15,10 +15,10 @@ package ui import ( - "github.com/urzeye/lazyssh/internal/core/domain" "github.com/gdamore/tcell/v2" "github.com/mattn/go-runewidth" "github.com/rivo/tview" + "github.com/urzeye/lazyssh/internal/core/domain" ) type ServerList struct { @@ -42,11 +42,11 @@ func (sl *ServerList) build() { sl.List.SetBorder(true). SetTitle(" 1 Servers "). SetTitleAlign(tview.AlignCenter). - SetBorderColor(tcell.Color238). - SetTitleColor(tcell.Color250) + SetBorderColor(CurrentTheme.Border). + SetTitleColor(CurrentTheme.Title) sl.List. - SetSelectedBackgroundColor(tcell.Color24). - SetSelectedTextColor(tcell.Color255). + SetSelectedBackgroundColor(CurrentTheme.SelectedBackground). + SetSelectedTextColor(CurrentTheme.SelectedText). SetHighlightFullLine(true) sl.List.SetChangedFunc(func(index int, mainText string, secondaryText string, shortcut rune) { @@ -107,6 +107,24 @@ func (sl *ServerList) GetSelectedServer() (domain.Server, bool) { return domain.Server{}, false } +func (sl *ServerList) SelectServerByAlias(alias string) bool { + for index, server := range sl.servers { + if server.Alias == alias { + sl.List.SetCurrentItem(index) + return true + } + } + return false +} + +func (sl *ServerList) SelectIndex(index int) bool { + if index < 0 || index >= len(sl.servers) { + return false + } + sl.List.SetCurrentItem(index) + return true +} + func (sl *ServerList) OnSelection(fn func(server domain.Server)) *ServerList { sl.onSelection = fn return sl diff --git a/internal/adapters/ui/server_list_test.go b/internal/adapters/ui/server_list_test.go new file mode 100644 index 0000000..e409250 --- /dev/null +++ b/internal/adapters/ui/server_list_test.go @@ -0,0 +1,51 @@ +// 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 ( + "testing" + + "github.com/urzeye/lazyssh/internal/core/domain" +) + +func TestServerListSelectServerByAlias(t *testing.T) { + list := NewServerList() + list.UpdateServers([]domain.Server{ + {Alias: "alpha"}, + {Alias: "beta"}, + {Alias: "gamma"}, + }) + + if !list.SelectServerByAlias("beta") { + t.Fatalf("SelectServerByAlias() = false, want true") + } + + selected, ok := list.GetSelectedServer() + if !ok { + t.Fatalf("GetSelectedServer() ok = false, want true") + } + if selected.Alias != "beta" { + t.Fatalf("selected alias = %q, want beta", selected.Alias) + } +} + +func TestServerListSelectIndexRejectsOutOfRange(t *testing.T) { + list := NewServerList() + list.UpdateServers([]domain.Server{{Alias: "alpha"}}) + + if list.SelectIndex(3) { + t.Fatalf("SelectIndex() = true, want false") + } +} diff --git a/internal/adapters/ui/settings.go b/internal/adapters/ui/settings.go index fbc00a7..d8debd1 100644 --- a/internal/adapters/ui/settings.go +++ b/internal/adapters/ui/settings.go @@ -29,7 +29,8 @@ type settingsManager struct { } type uiSettings struct { - SortMode SortMode `json:"sort_mode,omitempty"` + SortMode SortMode `json:"sort_mode,omitempty"` + ThemeMode ThemeMode `json:"theme_mode,omitempty"` } func newSettingsManager(logger *zap.SugaredLogger) *settingsManager { @@ -76,6 +77,37 @@ func (m *settingsManager) SaveSortMode(mode SortMode) error { return m.save(settings) } +func (m *settingsManager) LoadThemeMode() (ThemeMode, error) { + if m == nil { + return ThemeDark, errors.New("nil settings manager") + } + + settings, err := m.load() + if err != nil { + return ThemeDark, err + } + + if !settings.ThemeMode.valid() { + return ThemeDark, nil + } + + return settings.ThemeMode, nil +} + +func (m *settingsManager) SaveThemeMode(mode ThemeMode) error { + if m == nil { + return errors.New("nil settings manager") + } + + settings, err := m.load() + if err != nil { + return err + } + + settings.ThemeMode = mode + return m.save(settings) +} + func (m *settingsManager) load() (uiSettings, error) { var settings uiSettings diff --git a/internal/adapters/ui/settings_test.go b/internal/adapters/ui/settings_test.go new file mode 100644 index 0000000..7768197 --- /dev/null +++ b/internal/adapters/ui/settings_test.go @@ -0,0 +1,41 @@ +// 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 ( + "path/filepath" + "testing" + + "go.uber.org/zap" +) + +func TestSettingsManagerPersistsThemeMode(t *testing.T) { + manager := &settingsManager{ + filePath: filepath.Join(t.TempDir(), "settings.json"), + logger: zap.NewNop().Sugar(), + } + + if err := manager.SaveThemeMode(ThemeLight); err != nil { + t.Fatalf("SaveThemeMode() error = %v", err) + } + + mode, err := manager.LoadThemeMode() + if err != nil { + t.Fatalf("LoadThemeMode() error = %v", err) + } + if mode != ThemeLight { + t.Fatalf("LoadThemeMode() = %q, want %q", mode, ThemeLight) + } +} diff --git a/internal/adapters/ui/status_bar.go b/internal/adapters/ui/status_bar.go index b9eabd9..f2ac2ba 100644 --- a/internal/adapters/ui/status_bar.go +++ b/internal/adapters/ui/status_bar.go @@ -15,17 +15,37 @@ package ui import ( - "github.com/gdamore/tcell/v2" + "strings" + "github.com/rivo/tview" ) func DefaultStatusText() string { - return "[white]0[-] Search • [white]1[-] Servers • [white]2[-] Details • [white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]f[-] Forward • [white]x[-] Stop Forward • [white]c[-] Copy SSH • [white]h[-] Copy Host • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit" + items := []string{ + keyText("0") + " Search", + keyText("1") + " Servers", + keyText("2") + " Details", + keyText("↑↓") + " Navigate", + keyText("Enter") + " SSH", + keyText("T") + " Theme", + keyText("f") + " Forward", + keyText("x") + " Stop Forward", + keyText("c") + " Copy SSH", + keyText("h") + " Copy Host", + keyText("a") + " Add", + keyText("e") + " Edit", + keyText("g") + " Ping", + keyText("d") + " Delete", + keyText("p") + " Pin/Unpin", + keyText("/") + " Search", + keyText("q") + " Quit", + } + return strings.Join(items, " • ") } func NewStatusBar() *tview.TextView { status := tview.NewTextView().SetDynamicColors(true) - status.SetBackgroundColor(tcell.Color235) + status.SetBackgroundColor(CurrentTheme.StatusBackground) status.SetTextAlign(tview.AlignCenter) status.SetText(DefaultStatusText()) return status diff --git a/internal/adapters/ui/theme.go b/internal/adapters/ui/theme.go new file mode 100644 index 0000000..201de43 --- /dev/null +++ b/internal/adapters/ui/theme.go @@ -0,0 +1,159 @@ +// 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 "github.com/gdamore/tcell/v2" + +type ThemeMode string + +const ( + ThemeDark ThemeMode = "dark" + ThemeLight ThemeMode = "light" +) + +type Theme struct { + Mode ThemeMode + + PrimitiveBackground tcell.Color + ContrastBackground tcell.Color + HeaderBackground tcell.Color + InputBackground tcell.Color + StatusBackground tcell.Color + Border tcell.Color + Title tcell.Color + PrimaryText tcell.Color + SecondaryText tcell.Color + TertiaryText tcell.Color + Graphics tcell.Color + SelectedBackground tcell.Color + SelectedText tcell.Color + + KeyText string + AliasText string + HostText string + MetaText string + DimText string + TagText string + TagBack string + Accent string + AccentStrong string + Success string + Warning string + Error string + Separator string +} + +var CurrentTheme = themeForMode(ThemeDark) + +func (m ThemeMode) String() string { + switch m { + case ThemeDark: + return "Dark" + case ThemeLight: + return "Light" + default: + return "Dark" + } +} + +func (m ThemeMode) Toggle() ThemeMode { + if m == ThemeDark { + return ThemeLight + } + return ThemeDark +} + +func (m ThemeMode) valid() bool { + return m == ThemeDark || m == ThemeLight +} + +func SetCurrentTheme(mode ThemeMode) { + if !mode.valid() { + mode = ThemeDark + } + CurrentTheme = themeForMode(mode) +} + +func themeForMode(mode ThemeMode) Theme { + if mode == ThemeLight { + return Theme{ + Mode: ThemeLight, + PrimitiveBackground: tcell.Color255, + ContrastBackground: tcell.Color252, + HeaderBackground: tcell.Color253, + InputBackground: tcell.Color254, + StatusBackground: tcell.Color252, + Border: tcell.Color245, + Title: tcell.Color238, + PrimaryText: tcell.Color235, + SecondaryText: tcell.Color240, + TertiaryText: tcell.Color243, + Graphics: tcell.Color245, + SelectedBackground: tcell.Color117, + SelectedText: tcell.Color232, + KeyText: "#111111", + AliasText: "#111111", + HostText: "#444444", + MetaText: "#666666", + DimText: "#777777", + TagText: "black", + TagBack: "#9AD0FF", + Accent: "#0A66C2", + AccentStrong: "#075A9E", + Success: "#1F8A4C", + Warning: "#9A6700", + Error: "#C62828", + Separator: "#CCCCCC", + } + } + + return Theme{ + Mode: ThemeDark, + PrimitiveBackground: tcell.Color232, + ContrastBackground: tcell.Color235, + HeaderBackground: tcell.Color234, + InputBackground: tcell.Color233, + StatusBackground: tcell.Color235, + Border: tcell.Color238, + Title: tcell.Color250, + PrimaryText: tcell.Color252, + SecondaryText: tcell.Color245, + TertiaryText: tcell.Color245, + Graphics: tcell.Color238, + SelectedBackground: tcell.Color24, + SelectedText: tcell.Color255, + KeyText: "white", + AliasText: "white", + HostText: "#AAAAAA", + MetaText: "#888888", + DimText: "#8A8A8A", + TagText: "black", + TagBack: "#5FAFFF", + Accent: "#55AAFF", + AccentStrong: "#55D7FF", + Success: "#A0FFA0", + Warning: "#FFCC66", + Error: "#FF6B6B", + Separator: "#444444", + } +} + +func colorText(color, text string) string { + return "[" + color + "]" + text + "[-]" +} + +func keyText(text string) string { + return colorText(CurrentTheme.KeyText, text) +} diff --git a/internal/adapters/ui/theme_test.go b/internal/adapters/ui/theme_test.go new file mode 100644 index 0000000..55593c2 --- /dev/null +++ b/internal/adapters/ui/theme_test.go @@ -0,0 +1,33 @@ +// 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 "testing" + +func TestThemeModeToggle(t *testing.T) { + if got := ThemeDark.Toggle(); got != ThemeLight { + t.Fatalf("ThemeDark.Toggle() = %q, want %q", got, ThemeLight) + } + if got := ThemeLight.Toggle(); got != ThemeDark { + t.Fatalf("ThemeLight.Toggle() = %q, want %q", got, ThemeDark) + } +} + +func TestSetCurrentThemeFallsBackToDark(t *testing.T) { + SetCurrentTheme(ThemeMode("unknown")) + if CurrentTheme.Mode != ThemeDark { + t.Fatalf("CurrentTheme.Mode = %q, want %q", CurrentTheme.Mode, ThemeDark) + } +} diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go index 7011ad3..1d9699f 100644 --- a/internal/adapters/ui/tui.go +++ b/internal/adapters/ui/tui.go @@ -15,11 +15,12 @@ package ui import ( - "github.com/gdamore/tcell/v2" + "strings" + "go.uber.org/zap" - "github.com/urzeye/lazyssh/internal/core/ports" "github.com/rivo/tview" + "github.com/urzeye/lazyssh/internal/core/ports" ) type App interface { @@ -48,9 +49,24 @@ type tui struct { content *tview.Flex sortMode SortMode + themeMode ThemeMode searchVisible bool } +type mainUIState struct { + query string + selectedAlias string + selectedIndex int + searchVisible bool + focus string +} + +const ( + focusSearch = "search" + focusServerList = "server-list" + focusDetails = "details" +) + func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit string) App { return &tui{ logger: logger, @@ -59,6 +75,8 @@ func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit s version: version, commit: commit, settings: newSettingsManager(logger), + sortMode: SortByAliasAsc, + themeMode: ThemeDark, } } @@ -69,7 +87,8 @@ func (t *tui) Run() error { } }() t.app.EnableMouse(true) - t.initializeTheme().buildComponents().loadPreferences().buildLayout().bindEvents().loadInitialData() + t.loadPreferences().initializeTheme().buildComponents().buildLayout().bindEvents() + t.loadInitialData() t.app.SetRoot(t.root, true) t.logger.Infow("starting TUI application", "version", t.version, "commit", t.commit) if err := t.app.Run(); err != nil { @@ -80,14 +99,15 @@ func (t *tui) Run() error { } func (t *tui) initializeTheme() *tui { - tview.Styles.PrimitiveBackgroundColor = tcell.Color232 - tview.Styles.ContrastBackgroundColor = tcell.Color235 - tview.Styles.BorderColor = tcell.Color238 - tview.Styles.TitleColor = tcell.Color250 - tview.Styles.PrimaryTextColor = tcell.Color252 - tview.Styles.TertiaryTextColor = tcell.Color245 - tview.Styles.SecondaryTextColor = tcell.Color245 - tview.Styles.GraphicsColor = tcell.Color238 + SetCurrentTheme(t.themeMode) + tview.Styles.PrimitiveBackgroundColor = CurrentTheme.PrimitiveBackground + tview.Styles.ContrastBackgroundColor = CurrentTheme.ContrastBackground + tview.Styles.BorderColor = CurrentTheme.Border + tview.Styles.TitleColor = CurrentTheme.Title + tview.Styles.PrimaryTextColor = CurrentTheme.PrimaryText + tview.Styles.TertiaryTextColor = CurrentTheme.TertiaryText + tview.Styles.SecondaryTextColor = CurrentTheme.SecondaryText + tview.Styles.GraphicsColor = CurrentTheme.Graphics return t } @@ -106,8 +126,6 @@ func (t *tui) buildComponents() *tui { t.details = NewServerDetails() t.statusBar = NewStatusBar() - // default sort mode - t.sortMode = SortByAliasAsc t.searchVisible = false return t @@ -123,6 +141,11 @@ func (t *tui) loadPreferences() *tui { } else { t.logger.Warnw("failed to load sort mode preference", "error", err) } + if mode, err := t.settings.LoadThemeMode(); err == nil { + t.themeMode = mode + } else { + t.logger.Warnw("failed to load theme preference", "error", err) + } return t } @@ -146,18 +169,21 @@ func (t *tui) buildLayout() *tui { return t } -func (t *tui) bindEvents() *tui { +func (t *tui) bindEvents() { t.root.SetInputCapture(t.handleGlobalKeys) - return t } -func (t *tui) loadInitialData() *tui { - servers, _ := t.serverService.ListServers("") - sortServersForUI(servers, t.sortMode) +func (t *tui) loadInitialData() { + t.loadServerList("") +} + +func (t *tui) loadServerList(query string) { + servers, _ := t.serverService.ListServers(query) + if strings.TrimSpace(query) == "" { + sortServersForUI(servers, t.sortMode) + } t.updateListTitle() t.serverList.UpdateServers(servers) - - return t } func (t *tui) updateListTitle() { @@ -175,3 +201,93 @@ func (t *tui) persistSortMode() { t.logger.Warnw("failed to save sort mode preference", "error", err) } } + +func (t *tui) persistThemeMode() { + if t.settings == nil { + return + } + + if err := t.settings.SaveThemeMode(t.themeMode); err != nil { + t.logger.Warnw("failed to save theme preference", "error", err) + } +} + +func (t *tui) rebuildMainUI() { + state := t.captureMainUIState() + t.initializeTheme().buildComponents().buildLayout().bindEvents() + t.restoreMainUIState(state) + if t.app != nil { + t.app.SetRoot(t.root, true) + } +} + +func (t *tui) captureMainUIState() mainUIState { + state := mainUIState{selectedIndex: -1} + if t.searchBar != nil { + state.query = t.searchBar.GetText() + } + if t.serverList != nil { + state.selectedIndex = t.serverList.GetCurrentItem() + if server, ok := t.serverList.GetSelectedServer(); ok { + state.selectedAlias = server.Alias + } + } + state.searchVisible = t.searchVisible + + if t.app != nil { + focus := t.app.GetFocus() + switch { + case focus == t.searchBar: + state.focus = focusSearch + case t.isServerListFocused(): + state.focus = focusServerList + case focus == t.details: + state.focus = focusDetails + } + } + + return state +} + +func (t *tui) restoreMainUIState(state mainUIState) { + if state.searchVisible { + t.showSearchBar() + } + if t.searchBar != nil && state.query != "" { + t.searchBar.SetText(state.query) + } + + t.loadServerList(state.query) + t.restoreSelectedServer(state) + t.restoreFocus(state) +} + +func (t *tui) restoreSelectedServer(state mainUIState) { + if t.serverList == nil { + return + } + if state.selectedAlias != "" && t.serverList.SelectServerByAlias(state.selectedAlias) { + return + } + _ = t.serverList.SelectIndex(state.selectedIndex) +} + +func (t *tui) restoreFocus(state mainUIState) { + if t.app == nil { + return + } + switch state.focus { + case focusSearch: + if t.searchVisible && t.searchBar != nil { + t.app.SetFocus(t.searchBar) + } + case focusDetails: + if t.details != nil { + t.app.SetFocus(t.details) + } + case focusServerList: + if t.serverList != nil { + t.app.SetFocus(t.serverList) + } + } +} diff --git a/internal/adapters/ui/utils.go b/internal/adapters/ui/utils.go index 99d1622..75ea977 100644 --- a/internal/adapters/ui/utils.go +++ b/internal/adapters/ui/utils.go @@ -21,8 +21,8 @@ import ( "strings" "time" - "github.com/urzeye/lazyssh/internal/core/domain" "github.com/mattn/go-runewidth" + "github.com/urzeye/lazyssh/internal/core/domain" ) // IsForwarding is an optional hook supplied by TUI to indicate active forwarding per alias. @@ -54,11 +54,10 @@ func renderTagBadgesForList(tags []string) string { } parts := make([]string, 0, len(shown)+1) for _, t := range shown { - // Light blue background chip, similar to details view. - parts = append(parts, fmt.Sprintf("[black:#5FAFFF] %s [-:-:-]", t)) + parts = append(parts, fmt.Sprintf("[%s:%s] %s [-:-:-]", CurrentTheme.TagText, CurrentTheme.TagBack, t)) } if extra := len(tags) - len(shown); extra > 0 { - parts = append(parts, fmt.Sprintf("[#8A8A8A]+%d[-]", extra)) + parts = append(parts, fmt.Sprintf("[%s]+%d[-]", CurrentTheme.DimText, extra)) } return strings.Join(parts, " ") } @@ -98,7 +97,7 @@ func formatServerLine(s domain.Server, maxAliasWidth int) (primary, secondary st } fCol := cellPad(fGlyph, 2) if isFwd { - fCol = "[#A0FFA0]" + fCol + "[-]" + fCol = colorText(CurrentTheme.Success, fCol) } // Calculate the actual display width of the alias aliasWidth := runewidth.StringWidth(s.Alias) @@ -109,11 +108,14 @@ func formatServerLine(s domain.Server, maxAliasWidth int) (primary, secondary st } // Use a consistent color for alias; host/IP fixed width; then forwarding column primary = fmt.Sprintf( - "%s [white::b]%s[-] [#AAAAAA]%-18s[-] %s [#888888]Last SSH: %-8s[-] %s %s", + "%s [%s::b]%s[-] [%s]%-18s[-] %s [%s]Last SSH: %-8s[-] %s %s", icon, + CurrentTheme.AliasText, paddedAlias, + CurrentTheme.HostText, s.Host, fCol, + CurrentTheme.MetaText, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags), originIcon(s),