Skip to content
Open
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
39 changes: 39 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Go Build and Test

on:
pull_request:
branches:
- main
types:
- edited
- opened
- reopened
- synchronize
push:
branches:
- main

permissions:
contents: read
pull-requests: read

jobs:
build-and-test:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache-dependency-path: go.sum
cache: true

- name: Run make build
run: make build

- name: Run make test
run: make test
43 changes: 43 additions & 0 deletions .github/workflows/semantic-prs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Semantic PRs

on:
pull_request:
types:
- edited
- opened
- reopened
- synchronize

permissions:
pull-requests: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.number }}
cancel-in-progress: true

jobs:
validate_title:
name: Validate Title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5.5.3
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
with:
types: |
fix
feat
improve
refactor
revert
test
ci
docs
chore

scopes: |
ui
cli
config
parser
requireScope: false
9 changes: 8 additions & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ issues:
- dupl
- lll


# exclude some linters for the test directory and test files
- path: test/.*|.*_test\.go
linters:
- dupl
- errcheck
- goconst
- gocyclo
- gosec

linters:
disable-all: true
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ With lazyssh, you can quickly navigate, connect, manage, and transfer files betw
- ✏ Edit existing server entries directly from the UI with a tabbed interface.
- 🗑 Delete server entries safely.
- 📌 Pin / unpin servers to keep favorites at the top.
- 👻 Hide servers you rarely use and toggle their visibility on demand.
- 🏓 Ping server to check status.

### Quick Server Navigation
Expand Down Expand Up @@ -200,6 +201,35 @@ Contributions are welcome!

We love seeing the community make Lazyssh better 🚀

### Semantic Pull Requests

This repository enforces semantic PR titles via an automated GitHub Action. Please format your PR title as:

- type(scope): short descriptive subject
Notes:
- Scope is optional and should be one of: ui, cli, config, parser.

Allowed types in this repo:
- feat: a new feature
- fix: a bug fix
- improve: quality or UX improvements that are not a refactor or perf
- refactor: code change that neither fixes a bug nor adds a feature
- docs: documentation only changes
- test: adding or refactoring tests
- ci: CI/CD or automation changes
- chore: maintenance tasks, dependency bumps, non-code infra
- revert: reverts a previous commit

Examples:
- feat(ui): add server pinning and sorting options
- fix(parser): handle comments at end of Host blocks
- improve(cli): show friendly error when ssh binary missing
- refactor(config): simplify backup rotation logic
- docs: add installation instructions for Homebrew
- ci: cache Go toolchain and dependencies

Tip: If your PR touches multiple areas, pick the most relevant scope or omit the scope.

---

## ⭐ Support
Expand Down
2 changes: 2 additions & 0 deletions internal/adapters/data/ssh_config_file/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,10 +295,12 @@ func (r *Repository) mapDebugConfig(server *domain.Server, key, value string) bo
func (r *Repository) mergeMetadata(servers []domain.Server, metadata map[string]ServerMetadata) []domain.Server {
for i, server := range servers {
servers[i].LastSeen = time.Time{}
servers[i].Hidden = false

if meta, exists := metadata[server.Alias]; exists {
servers[i].Tags = meta.Tags
servers[i].SSHCount = meta.SSHCount
servers[i].Hidden = meta.Hidden

if meta.LastSeen != "" {
if lastSeen, err := time.Parse(time.RFC3339, meta.LastSeen); err == nil {
Expand Down
15 changes: 15 additions & 0 deletions internal/adapters/data/ssh_config_file/metadata_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type ServerMetadata struct {
LastSeen string `json:"last_seen,omitempty"`
PinnedAt string `json:"pinned_at,omitempty"`
SSHCount int `json:"ssh_count,omitempty"`
Hidden bool `json:"hidden,omitempty"`
}

type metadataManager struct {
Expand Down Expand Up @@ -149,6 +150,20 @@ func (m *metadataManager) setPinned(alias string, pinned bool) error {
return m.saveAll(metadata)
}

func (m *metadataManager) setHidden(alias string, hidden bool) error {
metadata, err := m.loadAll()
if err != nil {
m.logger.Errorw("failed to load metadata in setHidden", "path", m.filePath, "alias", alias, "hidden", hidden, "error", err)
return fmt.Errorf("load metadata: %w", err)
}

meta := metadata[alias]
meta.Hidden = hidden
metadata[alias] = meta

return m.saveAll(metadata)
}

func (m *metadataManager) recordSSH(alias string) error {
metadata, err := m.loadAll()
if err != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ func (r *Repository) SetPinned(alias string, pinned bool) error {
return r.metadataManager.setPinned(alias, pinned)
}

// SetHidden sets or unsets the hidden status of a server.
func (r *Repository) SetHidden(alias string, hidden bool) error {
return r.metadataManager.setHidden(alias, hidden)
}

// RecordSSH increments the SSH access count and updates the last seen timestamp for a server.
func (r *Repository) RecordSSH(alias string) error {
return r.metadataManager.recordSSH(alias)
Expand Down
63 changes: 54 additions & 9 deletions internal/adapters/ui/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
case 'p':
t.handleServerPin()
return nil
case 'h':
t.handleServerHideToggle()
return nil
case 'H':
t.handleToggleHiddenVisibility()
return nil
case 's':
t.handleSortToggle()
return nil
Expand Down Expand Up @@ -100,6 +106,33 @@ func (t *tui) handleServerPin() {
}
}

func (t *tui) handleServerHideToggle() {
if server, ok := t.serverList.GetSelectedServer(); ok {
hidden := !server.Hidden
if err := t.serverService.SetHidden(server.Alias, hidden); err != nil {
t.showStatusTempColor(fmt.Sprintf("Failed to update hidden state: %v", err), "#FF6B6B")
return
}
if hidden {
t.showStatusTemp("Hidden " + server.Alias)
} else {
t.showStatusTemp("Unhid " + server.Alias)
}
t.refreshServerList()
}
}

func (t *tui) handleToggleHiddenVisibility() {
t.showHidden = !t.showHidden
state := "off"
if t.showHidden {
state = "on"
}
t.updateListTitle()
t.refreshServerList()
t.showStatusTemp("Hidden hosts: " + state)
}

func (t *tui) handleSortToggle() {
t.sortMode = t.sortMode.ToggleField()
t.showStatusTemp("Sort: " + t.sortMode.String())
Expand Down Expand Up @@ -155,8 +188,11 @@ func (t *tui) handleNavigateUp() {
}

func (t *tui) handleSearchInput(query string) {
filtered, _ := t.serverService.ListServers(query)
sortServersForUI(filtered, t.sortMode)
filtered, _, err := t.fetchServers(query, t.showHidden, t.sortMode)
if err != nil {
t.showStatusTempColor(fmt.Sprintf("Search failed: %v", err), "#FF6B6B")
return
}
t.serverList.UpdateServers(filtered)
if len(filtered) == 0 {
t.details.ShowEmpty()
Expand Down Expand Up @@ -271,15 +307,14 @@ func (t *tui) handleRefreshBackground() {

t.showStatusTemp("Refreshing…")

go func(prevIdx int, q string) {
servers, err := t.serverService.ListServers(q)
go func(prevIdx int, q string, includeHidden bool, sortMode SortMode) {
servers, hiddenCount, err := t.fetchServers(q, includeHidden, sortMode)
if err != nil {
t.app.QueueUpdateDraw(func() {
t.showStatusTempColor(fmt.Sprintf("Refresh failed: %v", err), "#FF6B6B")
})
return
}
sortServersForUI(servers, t.sortMode)
t.app.QueueUpdateDraw(func() {
t.serverList.UpdateServers(servers)
// Try to restore selection if still valid
Expand All @@ -289,9 +324,13 @@ func (t *tui) handleRefreshBackground() {
t.details.UpdateServer(srv)
}
}
t.showStatusTemp(fmt.Sprintf("Refreshed %d servers", len(servers)))
message := fmt.Sprintf("Refreshed %d servers", len(servers))
if !t.showHidden && hiddenCount > 0 {
message = fmt.Sprintf("Refreshed %d servers (%d hidden)", len(servers), hiddenCount)
}
t.showStatusTemp(message)
})
}(currentIdx, query)
}(currentIdx, query, t.showHidden, t.sortMode)
}

// =============================================================================
Expand Down Expand Up @@ -397,9 +436,15 @@ func (t *tui) refreshServerList() {
if t.searchVisible {
query = t.searchBar.InputField.GetText()
}
filtered, _ := t.serverService.ListServers(query)
sortServersForUI(filtered, t.sortMode)
filtered, _, err := t.fetchServers(query, t.showHidden, t.sortMode)
if err != nil {
t.showStatusTempColor(fmt.Sprintf("Refresh failed: %v", err), "#FF6B6B")
return
}
t.serverList.UpdateServers(filtered)
if len(filtered) == 0 {
t.details.ShowEmpty()
}
}

func (t *tui) returnToMain() {
Expand Down
2 changes: 1 addition & 1 deletion internal/adapters/ui/hint_bar.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@ import (
func NewHintBar() *tview.TextView {
hint := tview.NewTextView().SetDynamicColors(true)
hint.SetBackgroundColor(tcell.Color233)
hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • c Copy SSH • g Ping • r Refresh • a Add • e Edit • t Tags • d Delete • p Pin/Unpin • s Sort[-]")
hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • c Copy SSH • g Ping • r Refresh • a Add • e Edit • t Tags • d Delete • p Pin/Unpin • h Hide • H Hidden view • s Sort[-]")
return hint
}
10 changes: 7 additions & 3 deletions internal/adapters/ui/server_details.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) {
if server.PinnedAt.IsZero() {
pinnedStr = "false"
}
hiddenStr := "false"
if server.Hidden {
hiddenStr = "true"
}
tagsText := renderTagChips(server.Tags)

// Basic information
Expand All @@ -83,9 +87,9 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) {
}

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 Tags: %s\n Pinned: [white]%s[-]\n Last SSH: %s\n SSH Count: [white]%d[-]\n",
"[::b]%s[-]\n\n[::b]Basic Settings:[-]\n Host: [white]%s[-]\n User: [white]%s[-]\n Port: [white]%s[-]\n Key: [white]%s[-]\n Tags: %s\n Pinned: [white]%s[-]\n Hidden: [white]%s[-]\n Last SSH: %s\n SSH Count: [white]%d[-]\n",
aliasText, hostText, userText, portText,
serverKey, tagsText, pinnedStr,
serverKey, tagsText, pinnedStr, hiddenStr,
lastSeen, server.SSHCount)

// Advanced settings section (only show non-empty fields)
Expand Down Expand Up @@ -213,7 +217,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) {
}

// Commands list
text += "\n[::b]Commands:[-]\n Enter: SSH connect\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin"
text += "\n[::b]Commands:[-]\n Enter: SSH connect\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin\n h: Hide/Unhide\n H: Toggle hidden view"

sd.TextView.SetText(text)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/adapters/ui/status_bar.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
)

func DefaultStatusText() string {
return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit"
return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]h[-] Hide • [white]H[-] Hidden view • [white]/[-] Search • [white]q[-] Quit"
}

func NewStatusBar() *tview.TextView {
Expand Down
Loading