From b3d343cf2e0db7ee8f42775d1627a56cdfd4f846 Mon Sep 17 00:00:00 2001 From: Adem Baccara <71262172+Adembc@users.noreply.github.com> Date: Mon, 25 Aug 2025 11:59:21 +0100 Subject: [PATCH] refactor server repo --- cmd/main.go | 1 - .../adapters/data/file/metadata_manager.go | 141 ++++++ internal/adapters/data/file/parser.go | 119 +++++ .../adapters/data/file/server_file_repo.go | 410 ------------------ internal/adapters/data/file/server_repo.go | 144 ++++++ .../adapters/data/file/ssh_config_manager.go | 133 ++++++ internal/adapters/data/file/writer.go | 60 +++ internal/adapters/ui/handlers.go | 77 ++-- internal/adapters/ui/server_details.go | 1 - internal/adapters/ui/tui.go | 2 +- internal/adapters/ui/utils.go | 7 - 11 files changed, 647 insertions(+), 448 deletions(-) create mode 100644 internal/adapters/data/file/metadata_manager.go create mode 100644 internal/adapters/data/file/parser.go delete mode 100644 internal/adapters/data/file/server_file_repo.go create mode 100644 internal/adapters/data/file/server_repo.go create mode 100644 internal/adapters/data/file/ssh_config_manager.go create mode 100644 internal/adapters/data/file/writer.go diff --git a/cmd/main.go b/cmd/main.go index f4539a2..6b9cc2c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -21,7 +21,6 @@ import ( "time" "github.com/Adembc/lazyssh/internal/adapters/data/file" - "github.com/Adembc/lazyssh/internal/logger" "github.com/Adembc/lazyssh/internal/adapters/ui" diff --git a/internal/adapters/data/file/metadata_manager.go b/internal/adapters/data/file/metadata_manager.go new file mode 100644 index 0000000..0046247 --- /dev/null +++ b/internal/adapters/data/file/metadata_manager.go @@ -0,0 +1,141 @@ +// 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 file + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/Adembc/lazyssh/internal/core/domain" +) + +type ServerMetadata struct { + Tags []string `json:"tags,omitempty"` + LastSeen string `json:"last_seen,omitempty"` + PinnedAt string `json:"pinned_at,omitempty"` + SSHCount int `json:"ssh_count,omitempty"` +} + +type metadataManager struct { + filePath string +} + +func newMetadataManager(filePath string) *metadataManager { + return &metadataManager{filePath: filePath} +} + +func (m *metadataManager) loadAll() (map[string]ServerMetadata, error) { + metadata := make(map[string]ServerMetadata) + + if _, err := os.Stat(m.filePath); os.IsNotExist(err) { + return metadata, nil + } + + data, err := os.ReadFile(m.filePath) + if err != nil { + return nil, err + } + + if len(data) == 0 { + return metadata, nil + } + + if err := json.Unmarshal(data, &metadata); err != nil { + return nil, fmt.Errorf("failed to parse metadata JSON: %w", err) + } + + return metadata, nil +} + +func (m *metadataManager) saveAll(metadata map[string]ServerMetadata) error { + if err := m.ensureDirectory(); err != nil { + return err + } + + data, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return err + } + + return os.WriteFile(m.filePath, data, 0o600) +} + +func (m *metadataManager) updateServer(server domain.Server) error { + metadata, err := m.loadAll() + if err != nil { + metadata = make(map[string]ServerMetadata) + } + + serverMeta := ServerMetadata{ + Tags: server.Tags, + LastSeen: server.LastSeen.Format(time.RFC3339), + } + + if !server.PinnedAt.IsZero() { + serverMeta.PinnedAt = server.PinnedAt.Format(time.RFC3339) + } + + metadata[server.Alias] = serverMeta + return m.saveAll(metadata) +} + +func (m *metadataManager) deleteServer(alias string) error { + metadata, err := m.loadAll() + if err != nil { + return nil // Nothing to delete if we can't load + } + + delete(metadata, alias) + return m.saveAll(metadata) +} + +func (m *metadataManager) setPinned(alias string, pinned bool) error { + metadata, err := m.loadAll() + if err != nil { + metadata = make(map[string]ServerMetadata) + } + + meta := metadata[alias] + if pinned { + meta.PinnedAt = time.Now().Format(time.RFC3339) + } else { + meta.PinnedAt = "" + } + + metadata[alias] = meta + return m.saveAll(metadata) +} + +func (m *metadataManager) recordSSH(alias string) error { + metadata, err := m.loadAll() + if err != nil { + metadata = make(map[string]ServerMetadata) + } + + meta := metadata[alias] + meta.LastSeen = time.Now().Format(time.RFC3339) + meta.SSHCount++ + + metadata[alias] = meta + return m.saveAll(metadata) +} + +func (m *metadataManager) ensureDirectory() error { + dir := filepath.Dir(m.filePath) + return os.MkdirAll(dir, 0o750) +} diff --git a/internal/adapters/data/file/parser.go b/internal/adapters/data/file/parser.go new file mode 100644 index 0000000..8d2e0f5 --- /dev/null +++ b/internal/adapters/data/file/parser.go @@ -0,0 +1,119 @@ +// 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 file + +import ( + "bufio" + "io" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/Adembc/lazyssh/internal/core/domain" +) + +const ( + sshConfigAliasField = "host" + sshConfigIPField = "hostname" + sshConfigUserField = "user" + sshConfigPortField = "port" + sshConfigKeyField = "identityfile" +) + +type SSHConfigParser struct{} + +func (p *SSHConfigParser) Parse(reader io.Reader) ([]domain.Server, error) { + servers := make([]domain.Server, 0) + var currentServer *domain.Server + + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if p.shouldSkipLine(line) { + continue + } + + key, value := p.parseKeyValue(line) + if key == "" { + continue + } + + switch key { + case sshConfigAliasField: + if currentServer != nil { + servers = append(servers, *currentServer) + } + currentServer = &domain.Server{ + Alias: value, + Port: DefaultPort, + } + case sshConfigIPField: + if currentServer != nil { + currentServer.Host = value + } + case sshConfigUserField: + if currentServer != nil { + currentServer.User = value + } + case sshConfigPortField: + if currentServer != nil { + currentServer.Port = p.parsePort(value) + } + case sshConfigKeyField: + if currentServer != nil { + currentServer.Key = p.expandPath(value) + } + } + } + + if currentServer != nil { + servers = append(servers, *currentServer) + } + + return servers, scanner.Err() +} + +func (p *SSHConfigParser) shouldSkipLine(line string) bool { + return line == "" || strings.HasPrefix(line, "#") +} + +func (p *SSHConfigParser) parseKeyValue(line string) (string, string) { + parts := strings.Fields(line) + if len(parts) < 2 { + return "", "" + } + + key := strings.ToLower(parts[0]) + value := strings.Join(parts[1:], " ") + return key, value +} + +func (p *SSHConfigParser) parsePort(value string) int { + if port, err := strconv.Atoi(value); err == nil { + return port + } + return DefaultPort +} + +func (p *SSHConfigParser) expandPath(path string) string { + if strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, path[2:]) + } + } + return path +} diff --git a/internal/adapters/data/file/server_file_repo.go b/internal/adapters/data/file/server_file_repo.go deleted file mode 100644 index f958705..0000000 --- a/internal/adapters/data/file/server_file_repo.go +++ /dev/null @@ -1,410 +0,0 @@ -// 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 file - -import ( - "bufio" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/Adembc/lazyssh/internal/core/domain" -) - -const ( - ManagedByComment = "# Managed by lazyssh" - DefaultPort = 22 -) - -type ServerMetadata struct { - Tags []string `json:"tags,omitempty"` - LastSeen string `json:"last_seen,omitempty"` - PinnedAt string `json:"pinned_at,omitempty"` - SSHCount int `json:"ssh_count,omitempty"` -} - -type serverRepo struct { - sshConfigFilePath string - metaDataFilePath string - logger *zap.SugaredLogger -} - -func NewServerRepo(logger *zap.SugaredLogger, sshPath, metaDataPath string) *serverRepo { - return &serverRepo{logger: logger, sshConfigFilePath: sshPath, metaDataFilePath: metaDataPath} -} - -func (s serverRepo) ListServers(query string) ([]domain.Server, error) { - servers, err := s.parseSSHConfig() - if err != nil { - return nil, fmt.Errorf("failed to parse SSH config: %w", err) - } - - metadata, err := s.loadMetadata() - if err != nil { - // Log error but continue without metadata - metadata = make(map[string]ServerMetadata) - } - - // Merge metadata with servers - for i, server := range servers { - servers[i].LastSeen = time.Time{} - - if meta, exists := metadata[server.Alias]; exists { - servers[i].Tags = meta.Tags - servers[i].SSHCount = meta.SSHCount - if meta.LastSeen != "" { - if lastSeen, err := time.Parse(time.RFC3339, meta.LastSeen); err == nil { - servers[i].LastSeen = lastSeen - } - } - if meta.PinnedAt != "" { - if pinnedAt, err := time.Parse(time.RFC3339, meta.PinnedAt); err == nil { - servers[i].PinnedAt = pinnedAt - } - } - } - } - - // Filter by query if provided - if query != "" { - filtered := make([]domain.Server, 0) - queryLower := strings.ToLower(query) - for _, server := range servers { - if strings.Contains(strings.ToLower(server.Alias), queryLower) || - strings.Contains(strings.ToLower(server.Host), queryLower) || - strings.Contains(strings.ToLower(server.User), queryLower) { - filtered = append(filtered, server) - continue - } - for _, tag := range server.Tags { - if strings.Contains(strings.ToLower(tag), queryLower) { - filtered = append(filtered, server) - break - } - } - } - return filtered, nil - } - - return servers, nil -} - -func (s serverRepo) UpdateServer(server domain.Server, newServer domain.Server) error { - servers, err := s.parseSSHConfig() - if err != nil { - return fmt.Errorf("failed to parse SSH config: %w", err) - } - - // Find and update the server - found := false - for i, srv := range servers { - if srv.Alias == server.Alias { - servers[i] = newServer - found = true - break - } - } - - if !found { - return fmt.Errorf("server with alias '%s' not found", server.Alias) - } - - // Write back to SSH config - if err := s.writeSSHConfig(servers); err != nil { - return fmt.Errorf("failed to write SSH config: %w", err) - } - - // Update metadata - return s.updateMetadata(newServer) -} - -func (s serverRepo) AddServer(server domain.Server) error { - servers, err := s.parseSSHConfig() - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to parse SSH config: %w", err) - } - - // Check if server already exists - for _, srv := range servers { - if srv.Alias == server.Alias { - return fmt.Errorf("server with alias '%s' already exists", server.Alias) - } - } - - // Add new server - servers = append(servers, server) - - // Write to SSH config - if err := s.writeSSHConfig(servers); err != nil { - return fmt.Errorf("failed to write SSH config: %w", err) - } - - // Update metadata - return s.updateMetadata(server) -} - -func (s serverRepo) DeleteServer(server domain.Server) error { - servers, err := s.parseSSHConfig() - if err != nil { - return fmt.Errorf("failed to parse SSH config: %w", err) - } - - // Find and remove the server - found := false - newServers := make([]domain.Server, 0, len(servers)) - for _, srv := range servers { - if srv.Alias != server.Alias { - newServers = append(newServers, srv) - } else { - found = true - } - } - - if !found { - return fmt.Errorf("server with alias '%s' not found", server.Alias) - } - - // Write back to SSH config - if err := s.writeSSHConfig(newServers); err != nil { - return fmt.Errorf("failed to write SSH config: %w", err) - } - - // Remove from metadata - return s.removeFromMetadata(server.Alias) -} - -func (s serverRepo) parseSSHConfig() ([]domain.Server, error) { - file, err := os.Open(s.sshConfigFilePath) - if err != nil { - - if os.IsNotExist(err) { - return []domain.Server{}, nil - } - return nil, err - } - defer file.Close() - - var servers []domain.Server - var currentServer *domain.Server - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - - // Skip empty lines and comments - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - // Split key-value pairs - parts := strings.Fields(line) - if len(parts) < 2 { - continue - } - - key := strings.ToLower(parts[0]) - value := strings.Join(parts[1:], " ") - - switch key { - case "host": - // Save previous server if exists - if currentServer != nil { - servers = append(servers, *currentServer) - } - // Start new server - currentServer = &domain.Server{ - Alias: value, - Port: DefaultPort, // Default SSH port - } - case "hostname": - if currentServer != nil { - currentServer.Host = value - } - case "user": - if currentServer != nil { - currentServer.User = value - } - case "port": - if currentServer != nil { - if port, err := strconv.Atoi(value); err == nil { - currentServer.Port = port - } - } - case "identityfile": - if currentServer != nil { - // Expand ~ to home directory - if strings.HasPrefix(value, "~/") { - if home, err := os.UserHomeDir(); err == nil { - value = filepath.Join(home, value[2:]) - } - } - currentServer.Key = value - } - } - } - - if currentServer != nil { - servers = append(servers, *currentServer) - } - - return servers, scanner.Err() -} - -func (s serverRepo) writeSSHConfig(servers []domain.Server) error { - // Create directory if it doesn't exist - dir := filepath.Dir(s.sshConfigFilePath) - if err := os.MkdirAll(dir, 0o700); err != nil { - return err - } - - file, err := os.Create(s.sshConfigFilePath) - if err != nil { - return err - } - defer file.Close() - - writer := bufio.NewWriter(file) - defer writer.Flush() - - for i, server := range servers { - if i > 0 { - writer.WriteString("\n") - } - - fmt.Fprintf(writer, "%s\n", ManagedByComment) - fmt.Fprintf(writer, "Host %s\n", server.Alias) - - if server.Host != "" { - fmt.Fprintf(writer, " HostName %s\n", server.Host) - } - - if server.User != "" { - fmt.Fprintf(writer, " User %s\n", server.User) - } - - if server.Port != 0 && server.Port != DefaultPort { - fmt.Fprintf(writer, " Port %d\n", server.Port) - } - - if server.Key != "" { - fmt.Fprintf(writer, " IdentityFile %s\n", server.Key) - } - } - - return nil -} - -func (s serverRepo) loadMetadata() (map[string]ServerMetadata, error) { - metadata := make(map[string]ServerMetadata) - - if _, err := os.Stat(s.metaDataFilePath); os.IsNotExist(err) { - return metadata, nil - } - - data, err := os.ReadFile(s.metaDataFilePath) - if err != nil { - return nil, err - } - - if len(data) == 0 { - return metadata, nil - } - - if err := json.Unmarshal(data, &metadata); err != nil { - return nil, fmt.Errorf("failed to parse metadata JSON: %w", err) - } - - return metadata, nil -} - -func (s serverRepo) updateMetadata(server domain.Server) error { - metadata, err := s.loadMetadata() - if err != nil { - metadata = make(map[string]ServerMetadata) - } - - serverMeta := ServerMetadata{ - Tags: server.Tags, - LastSeen: server.LastSeen.Format(time.RFC3339), - } - - // Only set PinnedAt if it is not zero - if !server.PinnedAt.IsZero() { - serverMeta.PinnedAt = server.PinnedAt.Format(time.RFC3339) - } - - metadata[server.Alias] = serverMeta - - return s.saveMetadata(metadata) -} - -func (s serverRepo) removeFromMetadata(alias string) error { - metadata, err := s.loadMetadata() - if err != nil { - return nil // If we can't load metadata, there's nothing to remove - } - - delete(metadata, alias) - return s.saveMetadata(metadata) -} - -func (s serverRepo) saveMetadata(metadata map[string]ServerMetadata) error { - // Create directory if it doesn't exist - dir := filepath.Dir(s.metaDataFilePath) - if err := os.MkdirAll(dir, 0o750); err != nil { - return err - } - - data, err := json.MarshalIndent(metadata, "", " ") - if err != nil { - return err - } - - return os.WriteFile(s.metaDataFilePath, data, 0o600) -} - -// SetPinned updates the pinned state for a server alias by writing pinned_at to metadata. -func (s serverRepo) SetPinned(alias string, pinned bool) error { - metadata, err := s.loadMetadata() - if err != nil { - metadata = make(map[string]ServerMetadata) - } - m := metadata[alias] - if pinned { - m.PinnedAt = time.Now().Format(time.RFC3339) - } else { - m.PinnedAt = "" - } - metadata[alias] = m - return s.saveMetadata(metadata) -} - -// RecordSSH updates last_seen and increments ssh_count for an alias after successful SSH. -func (s serverRepo) RecordSSH(alias string) error { - metadata, err := s.loadMetadata() - if err != nil { - metadata = make(map[string]ServerMetadata) - } - m := metadata[alias] - m.LastSeen = time.Now().Format(time.RFC3339) - m.SSHCount++ - metadata[alias] = m - return s.saveMetadata(metadata) -} diff --git a/internal/adapters/data/file/server_repo.go b/internal/adapters/data/file/server_repo.go new file mode 100644 index 0000000..aed37e6 --- /dev/null +++ b/internal/adapters/data/file/server_repo.go @@ -0,0 +1,144 @@ +// 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 file + +import ( + "fmt" + "strings" + "time" + + "github.com/Adembc/lazyssh/internal/core/domain" + "go.uber.org/zap" +) + +type serverRepo struct { + sshConfigManager *sshConfigManager + metadataManager *metadataManager + logger *zap.SugaredLogger +} + +func NewServerRepo(logger *zap.SugaredLogger, sshPath, metaDataPath string) *serverRepo { + return &serverRepo{ + sshConfigManager: newSSHConfigManager(sshPath), + metadataManager: newMetadataManager(metaDataPath), + logger: logger, + } +} + +func (s *serverRepo) ListServers(query string) ([]domain.Server, error) { + servers, err := s.sshConfigManager.parseServers() + if err != nil { + return nil, fmt.Errorf("failed to parse SSH config: %w", err) + } + + metadata, err := s.metadataManager.loadAll() + if err != nil { + s.logger.Warnf("Failed to load metadata: %v", err) + metadata = make(map[string]ServerMetadata) + } + + servers = s.mergeMetadata(servers, metadata) + + if query != "" { + servers = s.filterServers(servers, query) + } + + return servers, nil +} + +func (s *serverRepo) UpdateServer(server domain.Server, newServer domain.Server) error { + if err := s.sshConfigManager.updateServer(server.Alias, newServer); err != nil { + return fmt.Errorf("failed to update SSH config: %w", err) + } + + return s.metadataManager.updateServer(newServer) +} + +func (s *serverRepo) AddServer(server domain.Server) error { + if err := s.sshConfigManager.addServer(server); err != nil { + return fmt.Errorf("failed to add to SSH config: %w", err) + } + + return s.metadataManager.updateServer(server) +} + +func (s *serverRepo) DeleteServer(server domain.Server) error { + if err := s.sshConfigManager.deleteServer(server.Alias); err != nil { + return fmt.Errorf("failed to delete from SSH config: %w", err) + } + + return s.metadataManager.deleteServer(server.Alias) +} + +func (s *serverRepo) SetPinned(alias string, pinned bool) error { + return s.metadataManager.setPinned(alias, pinned) +} + +func (s *serverRepo) RecordSSH(alias string) error { + return s.metadataManager.recordSSH(alias) +} + +func (s *serverRepo) mergeMetadata(servers []domain.Server, metadata map[string]ServerMetadata) []domain.Server { + for i, server := range servers { + servers[i].LastSeen = time.Time{} + + if meta, exists := metadata[server.Alias]; exists { + servers[i].Tags = meta.Tags + servers[i].SSHCount = meta.SSHCount + + if meta.LastSeen != "" { + if lastSeen, err := time.Parse(time.RFC3339, meta.LastSeen); err == nil { + servers[i].LastSeen = lastSeen + } + } + + if meta.PinnedAt != "" { + if pinnedAt, err := time.Parse(time.RFC3339, meta.PinnedAt); err == nil { + servers[i].PinnedAt = pinnedAt + } + } + } + } + return servers +} + +func (s *serverRepo) filterServers(servers []domain.Server, query string) []domain.Server { + queryLower := strings.ToLower(query) + filtered := make([]domain.Server, 0) + + for _, server := range servers { + if s.matchesQuery(server, queryLower) { + filtered = append(filtered, server) + } + } + + return filtered +} + +func (s *serverRepo) matchesQuery(server domain.Server, queryLower string) bool { + if strings.Contains(strings.ToLower(server.Alias), queryLower) || + strings.Contains(strings.ToLower(server.Host), queryLower) || + strings.Contains(strings.ToLower(server.User), queryLower) { + return true + } + + for _, tag := range server.Tags { + if strings.Contains(strings.ToLower(tag), queryLower) { + return true + } + } + + return false +} diff --git a/internal/adapters/data/file/ssh_config_manager.go b/internal/adapters/data/file/ssh_config_manager.go new file mode 100644 index 0000000..d57ee29 --- /dev/null +++ b/internal/adapters/data/file/ssh_config_manager.go @@ -0,0 +1,133 @@ +// 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 file + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/Adembc/lazyssh/internal/core/domain" +) + +const ( + ManagedByComment = "# Managed by lazyssh" + DefaultPort = 22 +) + +type sshConfigManager struct { + filePath string +} + +func newSSHConfigManager(filePath string) *sshConfigManager { + return &sshConfigManager{filePath: filePath} +} + +func (m *sshConfigManager) parseServers() ([]domain.Server, error) { + file, err := os.Open(m.filePath) + if err != nil { + if os.IsNotExist(err) { + return []domain.Server{}, nil + } + return nil, err + } + defer file.Close() + + parser := &SSHConfigParser{} + return parser.Parse(file) +} + +func (m *sshConfigManager) writeServers(servers []domain.Server) error { + if err := m.ensureDirectory(); err != nil { + return err + } + + file, err := os.Create(m.filePath) + if err != nil { + return err + } + defer file.Close() + + writer := &SSHConfigWriter{} + return writer.Write(file, servers) +} + +func (m *sshConfigManager) addServer(server domain.Server) error { + servers, err := m.parseServers() + if err != nil { + return err + } + + // Check for duplicates + for _, srv := range servers { + if srv.Alias == server.Alias { + return fmt.Errorf("server with alias '%s' already exists", server.Alias) + } + } + + servers = append(servers, server) + return m.writeServers(servers) +} + +func (m *sshConfigManager) updateServer(alias string, newServer domain.Server) error { + servers, err := m.parseServers() + if err != nil { + return err + } + + found := false + for i, srv := range servers { + if srv.Alias == alias { + servers[i] = newServer + found = true + break + } + } + + if !found { + return fmt.Errorf("server with alias '%s' not found", alias) + } + + return m.writeServers(servers) +} + +func (m *sshConfigManager) deleteServer(alias string) error { + servers, err := m.parseServers() + if err != nil { + return err + } + + newServers := make([]domain.Server, 0, len(servers)) + found := false + + for _, srv := range servers { + if srv.Alias != alias { + newServers = append(newServers, srv) + } else { + found = true + } + } + + if !found { + return fmt.Errorf("server with alias '%s' not found", alias) + } + + return m.writeServers(newServers) +} + +func (m *sshConfigManager) ensureDirectory() error { + dir := filepath.Dir(m.filePath) + return os.MkdirAll(dir, 0o700) +} diff --git a/internal/adapters/data/file/writer.go b/internal/adapters/data/file/writer.go new file mode 100644 index 0000000..6f20b49 --- /dev/null +++ b/internal/adapters/data/file/writer.go @@ -0,0 +1,60 @@ +// 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 file + +import ( + "bufio" + "fmt" + "io" + + "github.com/Adembc/lazyssh/internal/core/domain" +) + +type SSHConfigWriter struct{} + +func (w *SSHConfigWriter) Write(writer io.Writer, servers []domain.Server) error { + bufWriter := bufio.NewWriter(writer) + defer bufWriter.Flush() + + for i, server := range servers { + if i > 0 { + bufWriter.WriteString("\n") + } + w.writeServer(bufWriter, server) + } + + return nil +} + +func (w *SSHConfigWriter) writeServer(writer *bufio.Writer, server domain.Server) { + fmt.Fprintf(writer, "%s\n", ManagedByComment) + fmt.Fprintf(writer, "Host %s\n", server.Alias) + + if server.Host != "" { + fmt.Fprintf(writer, " HostName %s\n", server.Host) + } + + if server.User != "" { + fmt.Fprintf(writer, " User %s\n", server.User) + } + + if server.Port != 0 && server.Port != DefaultPort { + fmt.Fprintf(writer, " Port %d\n", server.Port) + } + + if server.Key != "" { + fmt.Fprintf(writer, " IdentityFile %s\n", server.Key) + } +} diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index e49cad2..12cba46 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -37,7 +37,7 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey { switch event.Rune() { case 'q': - t.app.Stop() + t.handleQuit() return nil case '/': t.handleSearchToggle() @@ -52,41 +52,19 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey { t.handleServerDelete() return nil case 'p': - if server, ok := t.serverList.GetSelectedServer(); ok { - pinned := server.PinnedAt.IsZero() - _ = t.serverService.SetPinned(server.Alias, pinned) - t.refreshServerList() - } + t.handleServerPin() return nil case 's': - - t.sortMode = t.sortMode.ToggleField() - t.showStatusTemp("Sort: " + t.sortMode.String()) - t.updateListTitle() - t.refreshServerList() + t.handleSortToggle() return nil case 'S': - - t.sortMode = t.sortMode.Reverse() - t.showStatusTemp("Sort: " + t.sortMode.String()) - t.updateListTitle() - t.refreshServerList() + t.handleSortReverse() return nil case 'c': - if server, ok := t.serverList.GetSelectedServer(); ok { - cmd := BuildSSHCommand(server) - if err := clipboard.WriteAll(cmd); err == nil { - t.showStatusTemp("Copied: " + cmd) - } else { - t.showStatusTemp("Failed to copy to clipboard") - } - } + t.handleCopyCommand() return nil case 't': - if server, ok := t.serverList.GetSelectedServer(); ok { - // Quick edit tags for current server - t.showEditTagsForm(server) - } + t.handleTagsEdit() return nil case '?': t.handleHelpShow() @@ -101,6 +79,49 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey { return event } +func (t *tui) handleQuit() { + t.app.Stop() +} + +func (t *tui) handleServerPin() { + if server, ok := t.serverList.GetSelectedServer(); ok { + pinned := server.PinnedAt.IsZero() + _ = t.serverService.SetPinned(server.Alias, pinned) + t.refreshServerList() + } +} + +func (t *tui) handleSortToggle() { + t.sortMode = t.sortMode.ToggleField() + t.showStatusTemp("Sort: " + t.sortMode.String()) + t.updateListTitle() + t.refreshServerList() +} + +func (t *tui) handleSortReverse() { + t.sortMode = t.sortMode.Reverse() + t.showStatusTemp("Sort: " + t.sortMode.String()) + t.updateListTitle() + t.refreshServerList() +} + +func (t *tui) handleCopyCommand() { + if server, ok := t.serverList.GetSelectedServer(); ok { + cmd := BuildSSHCommand(server) + if err := clipboard.WriteAll(cmd); err == nil { + t.showStatusTemp("Copied: " + cmd) + } else { + t.showStatusTemp("Failed to copy to clipboard") + } + } +} + +func (t *tui) handleTagsEdit() { + if server, ok := t.serverList.GetSelectedServer(); ok { + t.showEditTagsForm(server) + } +} + func (t *tui) handleSearchInput(query string) { filtered, _ := t.serverService.ListServers(query) sortServersForUI(filtered, t.sortMode) diff --git a/internal/adapters/ui/server_details.go b/internal/adapters/ui/server_details.go index 03a30ba..8186678 100644 --- a/internal/adapters/ui/server_details.go +++ b/internal/adapters/ui/server_details.go @@ -51,7 +51,6 @@ func renderTagChips(tags []string) string { } chips := make([]string, 0, len(tags)) for _, t := range tags { - // Foreground black on a bluish background to resemble a tag/chip. chips = append(chips, fmt.Sprintf("[black:#5FAFFF] %s [-:-:-]", t)) } return strings.Join(chips, " ") diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go index 3119393..494ca32 100644 --- a/internal/adapters/ui/tui.go +++ b/internal/adapters/ui/tui.go @@ -17,10 +17,10 @@ package ui import ( "time" + "github.com/gdamore/tcell/v2" "go.uber.org/zap" "github.com/Adembc/lazyssh/internal/core/ports" - "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) diff --git a/internal/adapters/ui/utils.go b/internal/adapters/ui/utils.go index d008df3..59a3cca 100644 --- a/internal/adapters/ui/utils.go +++ b/internal/adapters/ui/utils.go @@ -23,13 +23,6 @@ import ( "github.com/mattn/go-runewidth" ) -func joinTags(tags []string) string { - if len(tags) == 0 { - return "-" - } - return strings.Join(tags, ",") -} - // renderTagBadgesForList renders up to two colored tag chips for the server list. // If there are more tags, it appends a subtle gray "+N" badge. Returns an empty // string when there are no tags to avoid cluttering the list.