refactor server repo

This commit is contained in:
Adem Baccara
2025-08-25 11:59:21 +01:00
parent adc843f1cf
commit b3d343cf2e
11 changed files with 647 additions and 448 deletions
-1
View File
@@ -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"
@@ -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)
}
+119
View File
@@ -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
}
@@ -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)
}
+144
View File
@@ -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
}
@@ -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)
}
+60
View File
@@ -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)
}
}
+49 -28
View File
@@ -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)
-1
View File
@@ -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, " ")
+1 -1
View File
@@ -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"
)
-7
View File
@@ -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.