From 87c7f40d2dfdad50887036bbeaf77968993c3a5c Mon Sep 17 00:00:00 2001 From: Adem Baccara <71262172+Adembc@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:31:49 +0100 Subject: [PATCH 1/2] ci: add action to enforce semantic PR titles and run tests (#55) --- .github/workflows/go.yml | 39 ++++++++++++++++++++++ .github/workflows/semantic-prs.yml | 43 +++++++++++++++++++++++++ .golangci.yml | 9 +++++- README.md | 29 +++++++++++++++++ internal/adapters/ui/validation_test.go | 25 ++++++++++++++ 5 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/go.yml create mode 100644 .github/workflows/semantic-prs.yml diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..d12a903 --- /dev/null +++ b/.github/workflows/go.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/semantic-prs.yml b/.github/workflows/semantic-prs.yml new file mode 100644 index 0000000..78d04fe --- /dev/null +++ b/.github/workflows/semantic-prs.yml @@ -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 \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index e44d3ac..b723bc9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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 diff --git a/README.md b/README.md index 8b05377..4b50d7f 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,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 diff --git a/internal/adapters/ui/validation_test.go b/internal/adapters/ui/validation_test.go index 9968372..de33639 100644 --- a/internal/adapters/ui/validation_test.go +++ b/internal/adapters/ui/validation_test.go @@ -15,6 +15,8 @@ package ui import ( + "os" + "path/filepath" "testing" ) @@ -134,6 +136,29 @@ func TestValidateBindAddress(t *testing.T) { } func TestValidateKeyPaths(t *testing.T) { + // Prepare an isolated HOME with a mock .ssh folder and key files + oldHome := os.Getenv("HOME") + t.Cleanup(func() { + _ = os.Setenv("HOME", oldHome) + }) + + tempHome := t.TempDir() + sshDir := filepath.Join(tempHome, ".ssh") + if err := os.MkdirAll(sshDir, 0o755); err != nil { + t.Fatalf("failed to create temp .ssh dir: %v", err) + } + + shouldExistFiles := []string{"id_rsa", "id_ed25519"} + for _, name := range shouldExistFiles { + p := filepath.Join(sshDir, name) + if err := os.WriteFile(p, []byte("test"), 0o644); err != nil { + t.Fatalf("failed to create mock key file %s: %v", p, err) + } + } + if err := os.Setenv("HOME", tempHome); err != nil { + t.Fatalf("failed to set HOME: %v", err) + } + tests := []struct { name string keys string From b62342b3dd5f5c4e153f825bb5c9aea617dfd15b Mon Sep 17 00:00:00 2001 From: Michel Belleau Date: Sat, 20 Sep 2025 09:44:34 -0400 Subject: [PATCH 2/2] feat(ui): add fuzzy subsequence search scoring for '/' search; rank results by relevance in service --- internal/adapters/ui/handlers.go | 12 +- internal/core/services/server_service.go | 197 +++++++++++++++++++++-- 2 files changed, 197 insertions(+), 12 deletions(-) diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 7b1becd..4461743 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -156,7 +156,9 @@ func (t *tui) handleNavigateUp() { func (t *tui) handleSearchInput(query string) { filtered, _ := t.serverService.ListServers(query) - sortServersForUI(filtered, t.sortMode) + if strings.TrimSpace(query) == "" { + sortServersForUI(filtered, t.sortMode) + } t.serverList.UpdateServers(filtered) if len(filtered) == 0 { t.details.ShowEmpty() @@ -279,7 +281,9 @@ func (t *tui) handleRefreshBackground() { }) return } - sortServersForUI(servers, t.sortMode) + if strings.TrimSpace(q) == "" { + sortServersForUI(servers, t.sortMode) + } t.app.QueueUpdateDraw(func() { t.serverList.UpdateServers(servers) // Try to restore selection if still valid @@ -398,7 +402,9 @@ func (t *tui) refreshServerList() { query = t.searchBar.InputField.GetText() } filtered, _ := t.serverService.ListServers(query) - sortServersForUI(filtered, t.sortMode) + if strings.TrimSpace(query) == "" { + sortServersForUI(filtered, t.sortMode) + } t.serverList.UpdateServers(filtered) } diff --git a/internal/core/services/server_service.go b/internal/core/services/server_service.go index c01b18b..6bdd909 100644 --- a/internal/core/services/server_service.go +++ b/internal/core/services/server_service.go @@ -25,6 +25,7 @@ import ( "strconv" "strings" "time" + "unicode" "github.com/Adembc/lazyssh/internal/core/domain" "github.com/Adembc/lazyssh/internal/core/ports" @@ -44,28 +45,206 @@ func NewServerService(logger *zap.SugaredLogger, sr ports.ServerRepository) port } } -// ListServers returns a list of servers sorted with pinned on top. +// ListServers returns servers. With empty query, keep pinned-first default ordering. +// With non-empty query, perform fuzzy subsequence matching and rank by relevance. func (s *serverService) ListServers(query string) ([]domain.Server, error) { - servers, err := s.serverRepository.ListServers(query) + q := strings.TrimSpace(query) + if q == "" { + servers, err := s.serverRepository.ListServers("") + if err != nil { + s.logger.Errorw("failed to list servers", "error", err) + return nil, err + } + // Sort: pinned first (PinnedAt non-zero), then by PinnedAt desc, then by Alias asc. + sort.SliceStable(servers, func(i, j int) bool { + pi := !servers[i].PinnedAt.IsZero() + pj := !servers[j].PinnedAt.IsZero() + if pi != pj { + return pi + } + if pi && pj { + return servers[i].PinnedAt.After(servers[j].PinnedAt) + } + ai := strings.ToLower(servers[i].Alias) + aj := strings.ToLower(servers[j].Alias) + if ai != aj { + return ai < aj + } + return servers[i].Alias < servers[j].Alias + }) + return servers, nil + } + + // Non-empty query: fetch all and rank via fuzzy scoring. + all, err := s.serverRepository.ListServers("") if err != nil { s.logger.Errorw("failed to list servers", "error", err) return nil, err } - // Sort: pinned first (PinnedAt non-zero), then by PinnedAt desc, then by Alias asc. - sort.SliceStable(servers, func(i, j int) bool { - pi := !servers[i].PinnedAt.IsZero() - pj := !servers[j].PinnedAt.IsZero() + type scored struct { + srv domain.Server + score int + } + results := make([]scored, 0, len(all)) + for _, srv := range all { + score := computeServerScore(srv, q) + if score > 0 { + results = append(results, scored{srv: srv, score: score}) + } + } + + sort.Slice(results, func(i, j int) bool { + if results[i].score != results[j].score { + return results[i].score > results[j].score + } + pi := !results[i].srv.PinnedAt.IsZero() + pj := !results[j].srv.PinnedAt.IsZero() if pi != pj { return pi } if pi && pj { - return servers[i].PinnedAt.After(servers[j].PinnedAt) + if !results[i].srv.PinnedAt.Equal(results[j].srv.PinnedAt) { + return results[i].srv.PinnedAt.After(results[j].srv.PinnedAt) + } + } + ai := strings.ToLower(results[i].srv.Alias) + aj := strings.ToLower(results[j].srv.Alias) + if ai != aj { + return ai < aj } - return servers[i].Alias < servers[j].Alias + return results[i].srv.Alias < results[j].srv.Alias }) - return servers, nil + out := make([]domain.Server, 0, len(results)) + for _, r := range results { + out = append(out, r.srv) + } + return out, nil +} + +func computeServerScore(srv domain.Server, q string) int { + best := 0 + fields := []string{ + srv.Alias, + srv.Host, + srv.User, + } + if len(srv.Aliases) > 0 { + fields = append(fields, strings.Join(srv.Aliases, " ")) + } + if len(srv.Tags) > 0 { + fields = append(fields, strings.Join(srv.Tags, " ")) + } + for _, f := range fields { + if f == "" { + continue + } + if sc := fuzzyScore(q, f); sc > best { + best = sc + } + } + return best +} + +// fuzzyScore computes a VS Code–like fuzzy subsequence score for q against s. +// Returns 0 if q is not a subsequence of s (case-insensitive). +func fuzzyScore(q, s string) int { + if q == "" || s == "" { + return 0 + } + + // Preprocess to run subsequence on lowercased forms, but keep original for case bonuses. + ql := []rune(strings.ToLower(q)) + sl := []rune(strings.ToLower(s)) + sr := []rune(s) + + positions := make([]int, 0, len(ql)) + si := 0 + for qi := 0; qi < len(ql); qi++ { + found := -1 + for ; si < len(sl); si++ { + if sl[si] == ql[qi] { + found = si + positions = append(positions, si) + si++ + break + } + } + if found == -1 { + return 0 // not a subsequence + } + } + + if len(positions) == 0 { + return 0 + } + + score := 0 + + // Base: +1 per matched char + score += len(positions) + + // Early start bonus + startIdx := positions[0] + if startIdx < 20 { + score += (20 - startIdx) + } + + // Adjacency bonus and gap penalty + totalGapPenalty := 0 + for i := 1; i < len(positions); i++ { + if positions[i] == positions[i-1]+1 { + score += 5 + } else { + gap := positions[i] - positions[i-1] - 1 + if gap > 0 { + totalGapPenalty += gap + } + } + } + if totalGapPenalty > 15 { + totalGapPenalty = 15 + } + score -= totalGapPenalty + + // Word boundary bonus and case-sensitive bonus + for idx, pos := range positions { + var prev rune + if pos > 0 { + prev = sr[pos-1] + } + curr := sr[pos] + if isWordBoundary(prev, curr, pos) { + if pos == 0 { + score += 8 + } else { + score += 6 + } + } + // Case bonus if rune matches exactly (same case) at this position. + if idx < len([]rune(q)) { + if []rune(q)[idx] == curr { + score += 1 + } + } + } + + return score +} + +func isWordBoundary(prev, curr rune, idx int) bool { + if idx == 0 { + return true + } + if prev == '-' || prev == '_' || prev == '.' || prev == '/' || unicode.IsSpace(prev) { + return true + } + // camelCase boundary: previous is lower and current is upper + if unicode.IsLower(prev) && unicode.IsUpper(curr) { + return true + } + return false } // validateServer performs core validation of server fields.