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/6] 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 da4f58d6fc967c56a5d10690a3ef29a75ed94aa4 Mon Sep 17 00:00:00 2001 From: Michel Belleau Date: Sat, 20 Sep 2025 08:50:19 -0400 Subject: [PATCH 2/6] feat(config): track origin for hosts (SourceFile, Readonly) to distinguish included entries --- internal/core/domain/server.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/core/domain/server.go b/internal/core/domain/server.go index c23b301..5f51f1a 100644 --- a/internal/core/domain/server.go +++ b/internal/core/domain/server.go @@ -27,6 +27,9 @@ type Server struct { LastSeen time.Time PinnedAt time.Time SSHCount int + // Origin metadata + SourceFile string + Readonly bool // Additional SSH config fields // Connection and proxy settings From 783bff2f82742195a0a9810704d8d5fae329d617 Mon Sep 17 00:00:00 2001 From: Michel Belleau Date: Sat, 20 Sep 2025 08:50:49 -0400 Subject: [PATCH 3/6] feat(parser): add recursive Include resolver and aggregate hosts with main-first precedence --- .../adapters/data/ssh_config_file/include.go | 271 ++++++++++++++++++ .../adapters/data/ssh_config_file/mapper.go | 39 --- .../data/ssh_config_file/mapper_include.go | 64 +++++ .../ssh_config_file/ssh_config_file_repo.go | 3 +- 4 files changed, 336 insertions(+), 41 deletions(-) create mode 100644 internal/adapters/data/ssh_config_file/include.go create mode 100644 internal/adapters/data/ssh_config_file/mapper_include.go diff --git a/internal/adapters/data/ssh_config_file/include.go b/internal/adapters/data/ssh_config_file/include.go new file mode 100644 index 0000000..5c5d0a0 --- /dev/null +++ b/internal/adapters/data/ssh_config_file/include.go @@ -0,0 +1,271 @@ +// 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 ssh_config_file + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Adembc/lazyssh/internal/core/domain" + "github.com/kevinburke/ssh_config" +) + +// loadAllServers loads servers from the main ssh config file and recursively +// from non-commented Include directives, returning a flat, de-duplicated list. +// Main config takes precedence on alias conflicts. +func (r *Repository) loadAllServers() ([]domain.Server, error) { //nolint:unparam // kept for symmetry and future enhancements + mainPath := expandTilde(r.configPath) + absMain, err := filepath.Abs(mainPath) + if err != nil { + absMain = mainPath + } + + files := []string{absMain} + visited := map[string]struct{}{absMain: {}} + + included, err := r.resolveIncludes(absMain, visited) + if err != nil { + r.logger.Warnf("failed to resolve includes: %v", err) + } + files = append(files, included...) + + seen := make(map[string]struct{}, 64) + all := make([]domain.Server, 0, 64) + + for i, f := range files { + cfg, err := r.decodeConfigAt(f) + if err != nil { + r.logger.Warnf("failed to decode %s: %v", f, err) + continue + } + isMain := i == 0 + servers := r.toDomainServersFromConfig(cfg, f, isMain) + for _, s := range servers { + if _, ok := seen[s.Alias]; ok { + continue + } + seen[s.Alias] = struct{}{} + all = append(all, s) + } + } + return all, nil +} + +// resolveIncludes parses a config file for non-commented Include directives, +// supports multiple patterns per line, globs, tilde-expansion and relative paths. +// It returns a depth-first ordered list of unique absolute file paths. +func (r *Repository) resolveIncludes(filePath string, visited map[string]struct{}) ([]string, error) { + fp := expandTilde(filePath) + if !filepath.IsAbs(fp) { + if ap, err := filepath.Abs(fp); err == nil { + fp = ap + } + } + + f, err := r.fileSystem.Open(fp) + if err != nil { + // If the including file can't be read, treat as no includes + return []string{}, nil + } + defer func() { + _ = f.Close() + }() + + baseDir := filepath.Dir(fp) + results := make([]string, 0) + added := make(map[string]struct{}) + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Strip inline comments (only when '#' is not in quotes) + line = stripInlineComment(line) + if line == "" { + continue + } + // skip commented lines + if strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + + // Tokenize respecting quotes + fields := splitFieldsRespectQuotes(line) + if len(fields) == 0 { + continue + } + + // Look for "Include" + if !strings.EqualFold(fields[0], "Include") { + continue + } + patterns := fields[1:] + for _, pat := range patterns { + p := unquote(strings.TrimSpace(pat)) + if p == "" { + continue + } + p = expandTilde(p) + if !filepath.IsAbs(p) { + p = filepath.Join(baseDir, p) + } + globbed, gerr := filepath.Glob(p) + if gerr != nil || len(globbed) == 0 { + // OpenSSH ignores unmatched includes + continue + } + for _, m := range globbed { + child := m + if !filepath.IsAbs(child) { + if ap, err := filepath.Abs(child); err == nil { + child = ap + } + } + if _, ok := visited[child]; ok { + continue + } + visited[child] = struct{}{} + if _, ok := added[child]; !ok { + results = append(results, child) + added[child] = struct{}{} + } + // Recurse + sub, _ := r.resolveIncludes(child, visited) + for _, s := range sub { + if _, ok := added[s]; !ok { + results = append(results, s) + added[s] = struct{}{} + } + } + } + } + } + + if err := scanner.Err(); err != nil { + return results, fmt.Errorf("scanner error: %w", err) + } + return results, nil +} + +// decodeConfigAt decodes a single ssh config file at the given absolute path. +func (r *Repository) decodeConfigAt(path string) (*ssh_config.Config, error) { + rc, err := r.fileSystem.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = rc.Close() }() + + return ssh_config.Decode(rc) +} + +func expandTilde(p string) string { + if p == "" { + return p + } + if p[0] != '~' { + return p + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return p + } + if p == "~" { + return home + } + if strings.HasPrefix(p, "~/") { + return filepath.Join(home, p[2:]) + } + // We don't support ~user syntax; return as-is + return p +} + +func unquote(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} + +// stripInlineComment removes an unquoted '#' and everything after it. +func stripInlineComment(s string) string { + inSingle := false + inDouble := false + for i := 0; i < len(s); i++ { + switch s[i] { + case '\'': + if !inDouble { + inSingle = !inSingle + } + case '"': + if !inSingle { + inDouble = !inDouble + } + case '#': + if !inSingle && !inDouble { + return strings.TrimSpace(s[:i]) + } + } + } + return strings.TrimSpace(s) +} + +// splitFieldsRespectQuotes splits a line into fields while preserving quoted segments. +func splitFieldsRespectQuotes(s string) []string { + var fields []string + var b strings.Builder + inSingle := false + inDouble := false + + flush := func() { + if b.Len() > 0 { + fields = append(fields, b.String()) + b.Reset() + } + } + + for i := 0; i < len(s); i++ { + ch := s[i] + switch ch { + case ' ', '\t': + if inSingle || inDouble { + b.WriteByte(ch) + } else { + flush() + } + case '\'': + if !inDouble { + inSingle = !inSingle + } + b.WriteByte(ch) + case '"': + if !inSingle { + inDouble = !inDouble + } + b.WriteByte(ch) + default: + b.WriteByte(ch) + } + } + flush() + return fields +} diff --git a/internal/adapters/data/ssh_config_file/mapper.go b/internal/adapters/data/ssh_config_file/mapper.go index f8a31f4..8611c06 100644 --- a/internal/adapters/data/ssh_config_file/mapper.go +++ b/internal/adapters/data/ssh_config_file/mapper.go @@ -23,45 +23,6 @@ import ( "github.com/kevinburke/ssh_config" ) -// toDomainServer converts ssh_config.Config to a slice of domain.Server. -func (r *Repository) toDomainServer(cfg *ssh_config.Config) []domain.Server { - servers := make([]domain.Server, 0, len(cfg.Hosts)) - for _, host := range cfg.Hosts { - - aliases := make([]string, 0, len(host.Patterns)) - - for _, pattern := range host.Patterns { - alias := pattern.String() - // Skip if alias contains wildcards (not a concrete Host) - if strings.ContainsAny(alias, "!*?[]") { - continue - } - aliases = append(aliases, alias) - } - if len(aliases) == 0 { - continue - } - server := domain.Server{ - Alias: aliases[0], - Aliases: aliases, - Port: 22, - IdentityFiles: []string{}, - } - - for _, node := range host.Nodes { - kvNode, ok := node.(*ssh_config.KV) - if !ok { - continue - } - - r.mapKVToServer(&server, kvNode) - } - - servers = append(servers, server) - } - - return servers -} // mapKVToServer maps an ssh_config.KV node to the corresponding fields in domain.Server. func (r *Repository) mapKVToServer(server *domain.Server, kvNode *ssh_config.KV) { diff --git a/internal/adapters/data/ssh_config_file/mapper_include.go b/internal/adapters/data/ssh_config_file/mapper_include.go new file mode 100644 index 0000000..3279f3b --- /dev/null +++ b/internal/adapters/data/ssh_config_file/mapper_include.go @@ -0,0 +1,64 @@ +// 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 ssh_config_file + +import ( + "strings" + + "github.com/Adembc/lazyssh/internal/core/domain" + "github.com/kevinburke/ssh_config" +) + +// toDomainServersFromConfig converts ssh_config.Config to a slice of domain.Server, +// setting origin metadata (SourceFile, Readonly). +func (r *Repository) toDomainServersFromConfig(cfg *ssh_config.Config, origin string, isMain bool) []domain.Server { + servers := make([]domain.Server, 0, len(cfg.Hosts)) + for _, host := range cfg.Hosts { + + aliases := make([]string, 0, len(host.Patterns)) + for _, pattern := range host.Patterns { + alias := pattern.String() + // Skip patterns with wildcards + if strings.ContainsAny(alias, "!*?[]") { + continue + } + aliases = append(aliases, alias) + } + if len(aliases) == 0 { + continue + } + + server := domain.Server{ + Alias: aliases[0], + Aliases: aliases, + Port: 22, + IdentityFiles: []string{}, + + SourceFile: origin, + Readonly: !isMain, + } + + for _, node := range host.Nodes { + kvNode, ok := node.(*ssh_config.KV) + if !ok { + continue + } + r.mapKVToServer(&server, kvNode) + } + + servers = append(servers, server) + } + return servers +} diff --git a/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go b/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go index 37e8004..0c70a30 100644 --- a/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go +++ b/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go @@ -54,12 +54,11 @@ func NewRepositoryWithFS(logger *zap.SugaredLogger, configPath string, metaDataP // ListServers returns all servers matching the query pattern. // Empty query returns all servers. func (r *Repository) ListServers(query string) ([]domain.Server, error) { - cfg, err := r.loadConfig() + servers, err := r.loadAllServers() if err != nil { return nil, fmt.Errorf("failed to load config: %w", err) } - servers := r.toDomainServer(cfg) metadata, err := r.metadataManager.loadAll() if err != nil { r.logger.Warnf("Failed to load metadata: %v", err) From 52c8f37f454e28eb1f976c116423f82f642cb7e0 Mon Sep 17 00:00:00 2001 From: Michel Belleau Date: Sat, 20 Sep 2025 08:51:28 -0400 Subject: [PATCH 4/6] feat(ui): mark included hosts with origin icon and enforce read-only (block edit/delete/tag); show source file in details --- internal/adapters/ui/handlers.go | 12 ++++++++++++ internal/adapters/ui/server_details.go | 4 ++-- internal/adapters/ui/utils.go | 11 ++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 7b1becd..24c00f7 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -127,6 +127,10 @@ func (t *tui) handleCopyCommand() { func (t *tui) handleTagsEdit() { if server, ok := t.serverList.GetSelectedServer(); ok { + if server.Readonly { + t.showStatusTempColor(fmt.Sprintf("Read-only: %s is defined in %s", server.Alias, server.SourceFile), "#FFCC66") + return + } t.showEditTagsForm(server) } } @@ -192,6 +196,10 @@ 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 defined in %s (cannot edit here)", server.Alias, server.SourceFile), "#FFCC66") + return + } form := NewServerForm(ServerFormEdit, &server). SetApp(t.app). SetVersionInfo(t.version, t.commit). @@ -226,6 +234,10 @@ 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 defined in %s (cannot delete here)", server.Alias, server.SourceFile), "#FF6B6B") + return + } t.showDeleteConfirmModal(server) } } diff --git a/internal/adapters/ui/server_details.go b/internal/adapters/ui/server_details.go index 8e0a634..152e24c 100644 --- a/internal/adapters/ui/server_details.go +++ b/internal/adapters/ui/server_details.go @@ -83,10 +83,10 @@ 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 Last SSH: %s\n SSH Count: [white]%d[-]\n Source: [white]%s[-]\n Read-only: [white]%t[-]\n", aliasText, hostText, userText, portText, serverKey, tagsText, pinnedStr, - lastSeen, server.SSHCount) + lastSeen, server.SSHCount, server.SourceFile, server.Readonly) // Advanced settings section (only show non-empty fields) // Organized by logical grouping for better readability diff --git a/internal/adapters/ui/utils.go b/internal/adapters/ui/utils.go index 95a3996..874c7ba 100644 --- a/internal/adapters/ui/utils.go +++ b/internal/adapters/ui/utils.go @@ -78,10 +78,19 @@ func pinnedIcon(pinnedAt time.Time) string { return "πŸ“Œ" // pinned } +func originIcon(s domain.Server) string { + if s.Readonly { + return "πŸ”—" + } + return "🏠" +} + func formatServerLine(s domain.Server) (primary, secondary string) { icon := cellPad(pinnedIcon(s.PinnedAt), 2) // Use a consistent color for alias; the icon reflects pinning - primary = fmt.Sprintf("%s [white::b]%-12s[-] [#AAAAAA]%-18s[-] [#888888]Last SSH: %s[-] %s", icon, s.Alias, s.Host, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags)) + // Append an origin icon on the right: 🏠 for main file, πŸ”— for included (read-only) + primary = fmt.Sprintf("%s [white::b]%-12s[-] [#AAAAAA]%-18s[-] [#888888]Last SSH: %s[-] %s %s", + icon, s.Alias, s.Host, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags), originIcon(s)) secondary = "" return } From 0af108edf76a25f5acf00577243592e31f3304d0 Mon Sep 17 00:00:00 2001 From: Michel Belleau Date: Sun, 21 Sep 2025 00:05:34 -0400 Subject: [PATCH 5/6] feat: add debounced search functionality for improved performance - Add timer-based debouncing to SearchBar to prevent excessive searches - Search now waits 300ms after user stops typing before executing - Cancel pending search timers when user presses Enter/Escape - Add configurable search delay with SetSearchDelay method - Add cleanup method to stop pending search timers - Dramatically improves performance with large server lists and included files --- internal/adapters/ui/search_bar.go | 42 ++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/internal/adapters/ui/search_bar.go b/internal/adapters/ui/search_bar.go index 7b03c90..4beb6e5 100644 --- a/internal/adapters/ui/search_bar.go +++ b/internal/adapters/ui/search_bar.go @@ -15,19 +15,24 @@ package ui import ( + "time" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) type SearchBar struct { *tview.InputField - onSearch func(string) - onEscape func() + onSearch func(string) + onEscape func() + searchTimer *time.Timer + searchDelay time.Duration } func NewSearchBar() *SearchBar { search := &SearchBar{ - InputField: tview.NewInputField(), + InputField: tview.NewInputField(), + searchDelay: 300 * time.Millisecond, // 300ms default delay } search.build() return search @@ -45,12 +50,24 @@ func (s *SearchBar) build() { SetTitleColor(tcell.Color250) s.InputField.SetChangedFunc(func(text string) { - if s.onSearch != nil { - s.onSearch(text) + // Cancel any existing timer + if s.searchTimer != nil { + s.searchTimer.Stop() } + // Start new timer with debounced search + s.searchTimer = time.AfterFunc(s.searchDelay, func() { + if s.onSearch != nil { + s.onSearch(text) + } + }) }) s.InputField.SetDoneFunc(func(key tcell.Key) { + // Cancel any pending search when done (user pressed Enter/Esc) + if s.searchTimer != nil { + s.searchTimer.Stop() + s.searchTimer = nil + } if key == tcell.KeyEsc || key == tcell.KeyEnter { if s.onEscape != nil { s.onEscape() @@ -68,3 +85,18 @@ func (s *SearchBar) OnEscape(fn func()) *SearchBar { s.onEscape = fn return s } + +// SetSearchDelay sets the debouncing delay for search operations. +// delay specifies how long to wait after user stops typing before executing the search. +func (s *SearchBar) SetSearchDelay(delay time.Duration) *SearchBar { + s.searchDelay = delay + return s +} + +// Stop stops any pending search timer. Should be called when the search bar is no longer needed. +func (s *SearchBar) Stop() { + if s.searchTimer != nil { + s.searchTimer.Stop() + s.searchTimer = nil + } +} From 6e126ba75a2285a67512f0b64df82414fa078746 Mon Sep 17 00:00:00 2001 From: Michel Belleau Date: Sun, 21 Sep 2025 00:12:38 -0400 Subject: [PATCH 6/6] fix: immediate search execution when Enter pressed during fast typing - Add OnEnter callback to SearchBar for handling Enter key presses - When Enter is pressed while typing fast, perform search immediately - Prevents scenario where search is ineffective due to debouncing timer - Cancels any pending debounced search timer when Enter is pressed - Maintains normal flow: search execution -> hide search bar -> Enter connects to selected server --- .serena/.gitignore | 1 + .../memories/code_style_and_conventions.md | 66 +++++++++++++ .serena/memories/project_overview.md | 34 +++++++ .serena/memories/suggested_commands.md | 51 ++++++++++ .serena/memories/task_completion_workflow.md | 93 +++++++++++++++++++ .serena/project.yml | 67 +++++++++++++ internal/adapters/ui/handlers.go | 13 +++ internal/adapters/ui/search_bar.go | 13 ++- internal/adapters/ui/tui.go | 3 +- 9 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 .serena/.gitignore create mode 100644 .serena/memories/code_style_and_conventions.md create mode 100644 .serena/memories/project_overview.md create mode 100644 .serena/memories/suggested_commands.md create mode 100644 .serena/memories/task_completion_workflow.md create mode 100644 .serena/project.yml diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/memories/code_style_and_conventions.md b/.serena/memories/code_style_and_conventions.md new file mode 100644 index 0000000..4945d56 --- /dev/null +++ b/.serena/memories/code_style_and_conventions.md @@ -0,0 +1,66 @@ +# Code Style and Conventions for Lazyssh + +## Go Code Style +- **Formatting**: Use `gofumpt` (advanced gofmt) for formatting +- **Imports**: Standard library first, then third-party packages, then local packages +- **Naming**: Use Go naming conventions (PascalCase for exported, camelCase for unexported) +- **Comments**: Use complete sentences starting with name of declared entity +- **Error Handling**: Return errors explicitly, use error wrapping +- **Context**: Pass context.Context for cancellable operations + +## File Organization +- **Entry Point**: `cmd/main.go` with cobra root command +- **Clean Architecture**: + - `internal/core/` - domain entities and services + - `internal/adapters/` - UI and data adapters + - Repository pattern for data persistence +- **Configuration**: Use dependency injection pattern + +## Documentation +- **README**: Comprehensive with screenshots and installation instructions +- **License**: Apache License 2.0 +- **Headers**: All Go files require copyright header + +## Pull Request Conventions +- **Semantic PRs**: Use conventional commits format + - `feat:` new features + - `fix:` bug fixes + - `improve:` UX improvements + - `refactor:` code changes + - `docs:` documentation + - `test:` adding tests + - `ci:` CI/CD changes + - `chore:` maintenance + - `revert:` reverts +- **Scopes**: ui, cli, config, parser (optional) + +## Testing +- **Unit Tests**: Race detection (`-race` flag) +- **Coverage**: Generate HTML coverage reports +- **Benchmarks**: Use standard Go benchmark format +- **Test Files**: Place alongside source `_test.go` + +## Linting & Quality +- **golangci-lint**: Comprehensive linter suite +- **Enabled Linters**: ~25 linters including staticcheck, revive, gocritic +- **Disabled Rules**: Some relaxations for internal packages and tests +- **Copyright Headers**: Enforced via goheader linter +- **Spelling**: US English locale + +## Architecture Patterns +- **Hexagonal Architecture** (Ports & Adapters) +- **Dependency Injection** for testability +- **Repository Pattern** for data access +- **Service Layer** for business logic +- **Logging**: Structured logging with zap + +## SSH Integration +- **Security First**: Non-destructive config writes with backups +- **Atomic Writes**: Temporary files then rename to prevent corruption +- **Multiple Backups**: Original backup + rotate 10 timestamped backups +- **Permissions**: Preserve file permissions on SSH config + +## Internationalization +- **Language**: English US (misspell locale: US) +- **Error Messages**: Clear and actionable +- **UI Text**: Consistent terminology \ No newline at end of file diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md new file mode 100644 index 0000000..5b0ae02 --- /dev/null +++ b/.serena/memories/project_overview.md @@ -0,0 +1,34 @@ +# Lazyssh Project Overview + +## Purpose +Lazyssh is a terminal-based, interactive SSH server manager built in Go, inspired by tools like lazydocker and k9s. It provides a clean, keyboard-driven UI for navigating, connecting to, managing, and transferring files between local machine and servers defined in ~/.ssh/config. No more remembering IP addresses or running long scp commands. + +## Tech Stack +- **Language**: Go 1.24.6 +- **Architecture**: Hexagonal/Clean Architecture +- **UI Framework**: tview + tcell (TUI - Terminal User Interface) +- **CLI Framework**: Cobra +- **Configuration Parser**: ssh_config (fork) +- **Logging**: Zap +- **Clipboard**: atotto/clipboard + +## Key Features +- πŸ“œ SSH config parsing and management +- βž• Add/edit/delete server entries via TUI +- πŸ” Fuzzy search by alias, IP, or tags +- 🏷 Server tagging and filtering +- ️pinning and sorting options +- πŸ“ Server ping testing +- πŸ”— Port forwarding and advanced SSH options +- πŸ”‘ SSH key management and deployment +- πŸ“ File transfer capabilities (planned) + +## Project Structure +- `cmd/` - Application entry point +- `internal/adapters/` - UI and data adapters +- `internal/core/` - Domain logic and services +- `internal/logger/` - Logging infrastructure +- `docs/` - Documentation and screenshots + +## Development System +Running on macOS (Darwin) with standard unix utilities. \ No newline at end of file diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md new file mode 100644 index 0000000..7b35a4a --- /dev/null +++ b/.serena/memories/suggested_commands.md @@ -0,0 +1,51 @@ +# Important Commands for Lazyssh Development + +## Development Workflow + +### Basic Operations +- `git status` - Check current repository state +- `git add .` - Stage all changes +- `git commit -m "message"` - Commit changes +- `git push origin main` - Push to remote +- `git pull origin main` - Pull latest changes + +### Building & Running +- `make build` - Build the binary with quality checks +- `make run` - Run the application from source code +- `./bin/lazyssh` - Run the built binary +- `make build-all` - Build binaries for all platforms (Linux/Mac/Windows) + +### Code Quality & Testing +- `make quality` - Run all quality checks (fmt, vet, lint) +- `make fmt` - Format code with gofumpt and go fmt +- `make lint` - Run golangci-lint +- `make lint-fix` - Run golangci-lint with automatic fixes +- `make test` - Run unit tests with race detection and coverage +- `make coverage` - Generate and open coverage report +- `make benchmark` - Run benchmark tests + +### Dependency Management +- `go mod download` - Download dependencies +- `go mod verify` - Verify dependencies +- `go mod tidy` - Clean up dependencies +- `make deps` - Download and verify dependencies +- `make update-deps` - Update all dependencies to latest + +### Maintenance +- `make clean` - Clean build artifacts and caches +- `make version` - Display version information +- `make help` - Show all available make targets + +### Quick Commands +- `make run-race` - Run with race detector enabled +- `make test-verbose` - Run tests with verbose output +- `make check` - Run staticcheck analyzer + +### System Prerequisites (macOS) +- `brew install go` - Install Go +- `go install golang.org/x/tools/gopls@latest` - Install language server +- Standard unix tools: `ls`, `cd`, `grep`, `find`, `mkdir`, `cp`, `mv` + +## Semantic PRs +Use semantic PR titles: `feat(scope): description`, `fix(scope): description`, `refactor(scope): description` +Allowed scopes: ui, cli, config, parser \ No newline at end of file diff --git a/.serena/memories/task_completion_workflow.md b/.serena/memories/task_completion_workflow.md new file mode 100644 index 0000000..b953508 --- /dev/null +++ b/.serena/memories/task_completion_workflow.md @@ -0,0 +1,93 @@ +# What to do when a development task is completed + +## Pre-Commit Quality Checks (Mandatory) +Always run these before committing changes: + +```bash +# Run all quality checks +make quality + +# This executes: +# - make fmt (formatting with gofumpt + gofmt) +# - make vet (static analysis) +# - make lint (golangci-lint with all configured linters) +``` + +## Testing (Mandatory) +```bash +# Run tests with race detection and coverage +make test + +# Generate coverage report if needed +make coverage +``` + +## Build Verification +```bash +# Ensure the code builds successfully +make build + +# Test that the application runs +make run +``` + +## Linting & Static Analysis +```bash +# Fix any auto-fixable linting issues +make lint-fix + +# Run staticcheck analyzer +make check +``` + +## Dependency Management +```bash +# Keep dependencies clean +go mod tidy +``` + +## Commit Message Format +Use semantic commit messages: +- `feat(ui): add server ping functionality` +- `fix(config): handle empty SSH config files` +- `improve(performance): optimize server list rendering` +- `refactor(core): extract server parsing logic` +- `test(services): add unit tests for server validation` +- `docs: update installation instructions` +- `ci: add automated testing workflow` +- `chore: update copyright headers` + +## Code Review Checklist +- [ ] All quality checks pass (`make quality`) +- [ ] Tests pass with coverage maintained +- [ ] No new linting violations +- [ ] Code follows established patterns and conventions +- [ ] Security implications considered (especially SSH handling) +- [ ] Performance impact assessed +- [ ] Documentation updated if needed +- [ ] Changelog updated for user-facing changes + +## SSH Configuration Safety +When modifying SSH functionality: +- [ ] Backups are properly created +- [ ] Atomic writes are used (temp file then rename) +- [ ] File permissions are preserved +- [ ] Error handling includes rollback scenarios +- [ ] No exposure of sensitive data (private keys, passwords) + +## Final Verification +```bash +# One final build and test +make clean && make build +./bin/lazyssh --help # Verify binary works +``` + +## Staging and Push +```bash +# Stage and commit +git add . +make quality # Final check +make test # Final test +git commit -m "feat(description): your semantic message" +git push origin main +``` \ No newline at end of file diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..3d000d4 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,67 @@ +# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby) +# * For C, use cpp +# * For JavaScript, use typescript +# Special requirements: +# * csharp: Requires the presence of a .sln file in the project folder. +language: go + +# whether to use the project's gitignore file to ignore files +# Added on 2025-04-07 +ignore_all_files_in_gitignore: true +# list of additional paths to ignore +# same syntax as gitignore, so you can use * and ** +# Was previously called `ignored_dirs`, please update your config if you are using that. +# Added (renamed) on 2025-04-07 +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +project_name: "lazyssh" diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 24c00f7..7c8881f 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -171,6 +171,19 @@ func (t *tui) handleSearchToggle() { t.showSearchBar() } +func (t *tui) handleSearchEnter(query string) { + // Perform immediate search (same as handleSearchInput) + filtered, _ := t.serverService.ListServers(query) + sortServersForUI(filtered, t.sortMode) + t.serverList.UpdateServers(filtered) + if len(filtered) == 0 { + t.details.ShowEmpty() + } + + // Hide search bar and return focus to server list + t.hideSearchBar() +} + func (t *tui) handleServerConnect() { if server, ok := t.serverList.GetSelectedServer(); ok { diff --git a/internal/adapters/ui/search_bar.go b/internal/adapters/ui/search_bar.go index 4beb6e5..02659a5 100644 --- a/internal/adapters/ui/search_bar.go +++ b/internal/adapters/ui/search_bar.go @@ -25,6 +25,7 @@ type SearchBar struct { *tview.InputField onSearch func(string) onEscape func() + onEnter func(string) searchTimer *time.Timer searchDelay time.Duration } @@ -68,7 +69,12 @@ func (s *SearchBar) build() { s.searchTimer.Stop() s.searchTimer = nil } - if key == tcell.KeyEsc || key == tcell.KeyEnter { + if key == tcell.KeyEnter { + if s.onEnter != nil { + text := s.InputField.GetText() + s.onEnter(text) + } + } else if key == tcell.KeyEsc { if s.onEscape != nil { s.onEscape() } @@ -86,6 +92,11 @@ func (s *SearchBar) OnEscape(fn func()) *SearchBar { return s } +func (s *SearchBar) OnEnter(fn func(string)) *SearchBar { + s.onEnter = fn + return s +} + // SetSearchDelay sets the debouncing delay for search operations. // delay specifies how long to wait after user stops typing before executing the search. func (s *SearchBar) SetSearchDelay(delay time.Duration) *SearchBar { diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go index 6046071..6c4afd0 100644 --- a/internal/adapters/ui/tui.go +++ b/internal/adapters/ui/tui.go @@ -93,7 +93,8 @@ func (t *tui) buildComponents() *tui { t.header = NewAppHeader(t.version, t.commit, RepoURL) t.searchBar = NewSearchBar(). OnSearch(t.handleSearchInput). - OnEscape(t.hideSearchBar) + OnEscape(t.hideSearchBar). + OnEnter(t.handleSearchEnter) t.hintBar = NewHintBar() t.serverList = NewServerList(). OnSelectionChange(t.handleServerSelectionChange)