From ac15fa594dde913a6a6497de2b987b26d64f45f5 Mon Sep 17 00:00:00 2001 From: Akram hossain Date: Sun, 28 Jun 2026 20:56:19 +0600 Subject: [PATCH] feat: upgrade SSH implementation to support private keys, ssh-agent, and host key verification --- README.md | 3 +- config.go => config/config.go | 93 +- config/config_test.go | 189 +++ docs/CONTRIBUTING.md | 53 + main.go | 1319 +--------------- ssh.go | 153 -- ssh/ssh.go | 338 ++++ sftp_browser.go => tui/sftp_browser.go | 13 +- .../sftp_browser_test.go | 3 +- styles.go => tui/styles.go | 2 +- tui/tui.go | 1358 +++++++++++++++++ 11 files changed, 2016 insertions(+), 1508 deletions(-) rename config.go => config/config.go (64%) create mode 100644 config/config_test.go create mode 100644 docs/CONTRIBUTING.md delete mode 100644 ssh.go create mode 100644 ssh/ssh.go rename sftp_browser.go => tui/sftp_browser.go (98%) rename sftp_browser_test.go => tui/sftp_browser_test.go (99%) rename styles.go => tui/styles.go (99%) create mode 100644 tui/tui.go diff --git a/README.md b/README.md index 6e8338d..091bd63 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Toggle active panel focus between the **File Browser (Top)** and the **SSH Termi ## 🤝 Contributing -Contributions, bug reports, and pull requests are welcome! +Contributions, bug reports, and pull requests are welcome! 1. Fork the Project. 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`). 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`). @@ -149,3 +149,4 @@ Contributions, bug reports, and pull requests are welcome! 5. Open a Pull Request. --- + diff --git a/config.go b/config/config.go similarity index 64% rename from config.go rename to config/config.go index dac4723..6933084 100644 --- a/config.go +++ b/config/config.go @@ -1,4 +1,4 @@ -package main +package config import ( "crypto/aes" @@ -14,12 +14,14 @@ import ( ) type Server struct { - Alias string `json:"alias"` - Host string `json:"host"` // Encrypted base64 - User string `json:"user"` // Encrypted base64 - Password string `json:"password"` // Encrypted base64 - Port int `json:"port"` - ProjectPath string `json:"project_path"` // Plaintext local directory + Alias string `json:"alias"` + Host string `json:"host"` // Encrypted base64 + User string `json:"user"` // Encrypted base64 + Password string `json:"password"` // Encrypted base64 + Port int `json:"port"` + ProjectPath string `json:"project_path"` // Plaintext local directory + PrivateKeyPath string `json:"private_key_path"` // Encrypted base64 + UseSSHAgent bool `json:"use_ssh_agent"` } const ( @@ -28,7 +30,12 @@ const ( keyFileName = "secret.key" ) +var ConfigDirOverride string + func getConfigDir() (string, error) { + if ConfigDirOverride != "" { + return ConfigDirOverride, nil + } home, err := os.UserHomeDir() if err != nil { return "", err @@ -73,8 +80,11 @@ func getOrGenerateKey() ([]byte, error) { return key, nil } -// encrypt encrypts plaintext using AES-256-GCM. -func encrypt(plaintext string, key []byte) (string, error) { +// Encrypt encrypts plaintext using AES-256-GCM (exported for testing). +func Encrypt(plaintext string, key []byte) (string, error) { + if len(key) != 32 { + return "", errors.New("invalid key size, expected 32 bytes") + } if plaintext == "" { return "", nil } @@ -98,8 +108,11 @@ func encrypt(plaintext string, key []byte) (string, error) { return base64.StdEncoding.EncodeToString(ciphertext), nil } -// decrypt decrypts AES-256-GCM ciphertext. -func decrypt(ciphertextBase64 string, key []byte) (string, error) { +// Decrypt decrypts AES-256-GCM ciphertext (exported for testing). +func Decrypt(ciphertextBase64 string, key []byte) (string, error) { + if len(key) != 32 { + return "", errors.New("invalid key size, expected 32 bytes") + } if ciphertextBase64 == "" { return "", nil } @@ -162,26 +175,32 @@ func LoadServers() ([]Server, error) { servers := make([]Server, len(rawServers)) for i, s := range rawServers { - decHost, err := decrypt(s.Host, key) + decHost, err := Decrypt(s.Host, key) if err != nil { return nil, fmt.Errorf("failed to decrypt host for %s: %w", s.Alias, err) } - decUser, err := decrypt(s.User, key) + decUser, err := Decrypt(s.User, key) if err != nil { return nil, fmt.Errorf("failed to decrypt user for %s: %w", s.Alias, err) } - decPass, err := decrypt(s.Password, key) + decPass, err := Decrypt(s.Password, key) if err != nil { return nil, fmt.Errorf("failed to decrypt password for %s: %w", s.Alias, err) } + decKeyPath, err := Decrypt(s.PrivateKeyPath, key) + if err != nil { + return nil, fmt.Errorf("failed to decrypt private key path for %s: %w", s.Alias, err) + } servers[i] = Server{ - Alias: s.Alias, - Host: decHost, - User: decUser, - Password: decPass, - Port: s.Port, - ProjectPath: s.ProjectPath, + Alias: s.Alias, + Host: decHost, + User: decUser, + Password: decPass, + Port: s.Port, + ProjectPath: s.ProjectPath, + PrivateKeyPath: decKeyPath, + UseSSHAgent: s.UseSSHAgent, } } @@ -206,26 +225,32 @@ func SaveServers(servers []Server) error { encryptedServers := make([]Server, len(servers)) for i, s := range servers { - encHost, err := encrypt(s.Host, key) + encHost, err := Encrypt(s.Host, key) if err != nil { return err } - encUser, err := encrypt(s.User, key) + encUser, err := Encrypt(s.User, key) if err != nil { return err } - encPass, err := encrypt(s.Password, key) + encPass, err := Encrypt(s.Password, key) + if err != nil { + return err + } + encKeyPath, err := Encrypt(s.PrivateKeyPath, key) if err != nil { return err } encryptedServers[i] = Server{ - Alias: s.Alias, - Host: encHost, - User: encUser, - Password: encPass, - Port: s.Port, - ProjectPath: s.ProjectPath, + Alias: s.Alias, + Host: encHost, + User: encUser, + Password: encPass, + Port: s.Port, + ProjectPath: s.ProjectPath, + PrivateKeyPath: encKeyPath, + UseSSHAgent: s.UseSSHAgent, } } @@ -235,5 +260,13 @@ func SaveServers(servers []Server) error { } configPath := filepath.Join(dir, configFileName) - return os.WriteFile(configPath, data, 0600) + tempPath := configPath + ".tmp" + if err := os.WriteFile(tempPath, data, 0600); err != nil { + return err + } + if err := os.Rename(tempPath, configPath); err != nil { + _ = os.Remove(tempPath) + return err + } + return nil } diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..dd0e837 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,189 @@ +package config + +import ( + "crypto/rand" + "io" + "os" + "path/filepath" + "testing" +) + +func TestEncryptDecrypt(t *testing.T) { + key := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + t.Fatalf("failed to generate random key: %v", err) + } + + tests := []string{ + "hello world", + "my-secure-password-123!", + "", + "a", + "very long string with spaces and special characters: @#$%^&*()_+{}|:<>?", + } + + for _, original := range tests { + ciphertext, err := Encrypt(original, key) + if err != nil { + t.Errorf("Encrypt(%q) failed: %v", original, err) + continue + } + + // Decrypt the ciphertext and check if it matches the original plaintext + decrypted, err := Decrypt(ciphertext, key) + if err != nil { + t.Errorf("Decrypt(%q) failed: %v", ciphertext, err) + continue + } + + if decrypted != original { + t.Errorf("Encrypt/Decrypt mismatch: got %q, want %q", decrypted, original) + } + } +} + +func TestDecryptInvalidCiphertext(t *testing.T) { + key := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + t.Fatalf("failed to generate random key: %v", err) + } + + invalidCiphertexts := []string{ + "not-base64-encoded-!!!", + "YQ==", // Base64 for "a", too short to contain GCM nonce + } + + for _, ct := range invalidCiphertexts { + _, err := Decrypt(ct, key) + if err == nil { + t.Errorf("expected error decrypting invalid ciphertext %q, got nil", ct) + } + } +} + +func TestDecryptInvalidKeyLength(t *testing.T) { + invalidKeys := [][]byte{ + nil, + make([]byte, 16), + make([]byte, 24), + make([]byte, 31), + make([]byte, 33), + } + + for _, key := range invalidKeys { + _, err := Encrypt("test", key) + if err == nil { + t.Errorf("expected error encrypting with key size %d, got nil", len(key)) + } + + _, err = Decrypt("dGVzdA==", key) + if err == nil { + t.Errorf("expected error decrypting with key size %d, got nil", len(key)) + } + } +} + +func TestSaveAndLoadServers(t *testing.T) { + // Create a temp directory for configuration files + tempDir, err := os.MkdirTemp("", "sshmanager_config_test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Override config directory for testing + ConfigDirOverride = tempDir + defer func() { + ConfigDirOverride = "" + }() + + originalServers := []Server{ + { + Alias: "test-password", + Host: "1.1.1.1", + User: "root", + Password: "p@ssword", + Port: 22, + ProjectPath: "/home/user/project1", + PrivateKeyPath: "", + UseSSHAgent: false, + }, + { + Alias: "test-key", + Host: "2.2.2.2", + User: "ubuntu", + Password: "keypassphrase", + Port: 2222, + ProjectPath: "", + PrivateKeyPath: "~/.ssh/id_ed25519", + UseSSHAgent: false, + }, + { + Alias: "test-agent", + Host: "3.3.3.3", + User: "admin", + Password: "", + Port: 22, + ProjectPath: "/tmp", + PrivateKeyPath: "", + UseSSHAgent: true, + }, + } + + // Save servers + err = SaveServers(originalServers) + if err != nil { + t.Fatalf("SaveServers failed: %v", err) + } + + // Verify that the files were created + serversJsonPath := filepath.Join(tempDir, "servers.json") + if _, err := os.Stat(serversJsonPath); os.IsNotExist(err) { + t.Errorf("expected servers.json to exist at %q", serversJsonPath) + } + + secretKeyPath := filepath.Join(tempDir, "secret.key") + if _, err := os.Stat(secretKeyPath); os.IsNotExist(err) { + t.Errorf("expected secret.key to exist at %q", secretKeyPath) + } + + // Load servers back + loadedServers, err := LoadServers() + if err != nil { + t.Fatalf("LoadServers failed: %v", err) + } + + if len(loadedServers) != len(originalServers) { + t.Fatalf("loaded server count mismatch: got %d, want %d", len(loadedServers), len(originalServers)) + } + + for i := range originalServers { + got := loadedServers[i] + want := originalServers[i] + + if got.Alias != want.Alias { + t.Errorf("server[%d].Alias mismatch: got %q, want %q", i, got.Alias, want.Alias) + } + if got.Host != want.Host { + t.Errorf("server[%d].Host mismatch: got %q, want %q", i, got.Host, want.Host) + } + if got.User != want.User { + t.Errorf("server[%d].User mismatch: got %q, want %q", i, got.User, want.User) + } + if got.Password != want.Password { + t.Errorf("server[%d].Password mismatch: got %q, want %q", i, got.Password, want.Password) + } + if got.Port != want.Port { + t.Errorf("server[%d].Port mismatch: got %d, want %d", i, got.Port, want.Port) + } + if got.ProjectPath != want.ProjectPath { + t.Errorf("server[%d].ProjectPath mismatch: got %q, want %q", i, got.ProjectPath, want.ProjectPath) + } + if got.PrivateKeyPath != want.PrivateKeyPath { + t.Errorf("server[%d].PrivateKeyPath mismatch: got %q, want %q", i, got.PrivateKeyPath, want.PrivateKeyPath) + } + if got.UseSSHAgent != want.UseSSHAgent { + t.Errorf("server[%d].UseSSHAgent mismatch: got %t, want %t", i, got.UseSSHAgent, want.UseSSHAgent) + } + } +} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..4dd2f99 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing to SSH & SFTP Manager + +Thank you for your interest in contributing to the project! This guide will help you understand the architecture, package structure, local setup, and style guidelines. + +--- + +## 📂 Project Architecture + +The project has been refactored into modular Go packages to keep the codebase maintainable, testable, and clean. + +### Package Structure +* **`main.go`**: The entrypoint of the application. It bootstraps and runs the application by calling `tui.Run()`. +* **`config/`**: Manages server profiles storage (JSON), AES-256-GCM encryption/decryption of credentials, and atomic file I/O operations. +* **`ssh/`**: Contains core SSH logic including PTY setup, SSH agent connections, private key parsing, keep-alive heartbeats, and TOFU host key verification. +* **`tui/`**: Implements the user interface using the Charm Bracelet Bubble Tea framework: + * `sftp_browser.go`: Handles concurrent file transfers (upload/download queue and progress). + * `styles.go`: Defines the Lipgloss stylesheets, colors, and layout configurations. + * `tui.go`: Main UI state machine, layout, keybindings, and form rendering. + +--- + +## 🛠️ Local Development Setup + +### Requirements +* Go 1.22 or higher. + +### Steps +1. **Clone the Repository**: + ```bash + git clone https://github.com/your-username/sshmanager.git + cd sshmanager + ``` +2. **Install Dependencies**: + ```bash + go mod download + ``` +3. **Run Locally**: + ```bash + go run main.go + ``` +4. **Run Unit Tests**: + ```bash + go test -v ./... + ``` + +--- + +## 🎨 UI & Styling Guidelines + +We use `lipgloss` for styling our terminal elements. Please adhere to the following design guidelines: +1. **Color Palette**: Use consistent Catppuccin-inspired color tokens (e.g. Purple for local, Blue for remote, Red for errors). +2. **No Hardcoded Styles**: Define layout borders, paddings, and colors as Lipgloss styles in `tui/styles.go` instead of embedding them directly in functional components. +3. **Responsive Layout**: Maintain proper calculations for panel widths and terminal heights using the window size messages (`tea.WindowSizeMsg`). diff --git a/main.go b/main.go index f9ef4d4..45e4d2f 100644 --- a/main.go +++ b/main.go @@ -2,1327 +2,14 @@ package main import ( "fmt" - "image/color" "os" - "path/filepath" - "strconv" - "strings" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - uv "github.com/charmbracelet/ultraviolet" + "sshmanager/tui" ) -type AppState int - -const ( - StateServerList AppState = iota - StateServerForm - StateSFTPBrowser -) - -type FormMode int - -const ( - ModeAdd FormMode = iota - ModeEdit -) - -type MainModel struct { - state AppState - servers []Server - selectedIdx int - errorMsg string - successMsg string - cwd string // current working directory - - // Form fields - formMode FormMode - formInputs []textinput.Model - formActiveField int - editingServer int // Index of server being edited - - // Confirm delete field - confirmDelete bool - - // SFTP Browser field - sftpBrowser *SFTPBrowser - - // Terminal dimensions - width int - height int -} - -func (m *MainModel) initForm(mode FormMode, server *Server) { - m.formMode = mode - m.formInputs = make([]textinput.Model, 6) - m.formActiveField = 0 - - for i := range m.formInputs { - t := textinput.New() - t.CharLimit = 128 - m.formInputs[i] = t - } - - m.formInputs[0].Placeholder = "my-vps" - m.formInputs[1].Placeholder = "192.168.1.1" - m.formInputs[2].Placeholder = "22" - m.formInputs[3].Placeholder = "root" - m.formInputs[4].Placeholder = "password" - m.formInputs[4].EchoMode = textinput.EchoPassword - m.formInputs[5].Placeholder = "/path/to/my/project" - - if mode == ModeEdit && server != nil { - m.formInputs[0].SetValue(server.Alias) - m.formInputs[1].SetValue(server.Host) - m.formInputs[2].SetValue(strconv.Itoa(server.Port)) - m.formInputs[3].SetValue(server.User) - m.formInputs[4].SetValue(server.Password) - m.formInputs[5].SetValue(server.ProjectPath) - } else { - m.formInputs[2].SetValue("22") - m.formInputs[3].SetValue("root") - m.formInputs[5].SetValue(m.cwd) // Prepopulate with cwd - } - - m.formInputs[0].Focus() -} - -func (m MainModel) Init() tea.Cmd { - return nil -} - -type sshFinishedMsg struct { - err error -} - -func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - - if m.state == StateSFTPBrowser && m.sftpBrowser != nil { - m.sftpBrowser.width = msg.Width - m.sftpBrowser.height = msg.Height - m.handleTerminalResize() - } - return m, nil - - case sshFinishedMsg: - if msg.err != nil { - m.errorMsg = fmt.Sprintf("SSH connection closed with error: %v", msg.err) - } else { - m.successMsg = "SSH connection closed successfully." - } - return m, nil - - case sftpConnectedMsg: - if m.state == StateSFTPBrowser && m.sftpBrowser != nil { - m.sftpBrowser.sshClient = msg.sshClient - m.sftpBrowser.sftpClient = msg.sftpClient - m.sftpBrowser.localDir = msg.localDir - m.sftpBrowser.remoteDir = msg.remoteDir - m.sftpBrowser.localItems = msg.localItems - m.sftpBrowser.remoteItems = msg.remoteItems - m.sftpBrowser.terminalSession = msg.terminalSession - m.sftpBrowser.terminalIn = msg.terminalIn - m.sftpBrowser.terminalEmu = msg.terminalEmu - m.sftpBrowser.terminalRedraw = msg.redrawChan - m.sftpBrowser.isBusy = false - m.sftpBrowser.statusMsg = "Connected. Tab to switch local/remote, Ctrl+T to toggle terminal focus." - m.sftpBrowser.initialized = true - - if msg.redrawChan != nil { - return m, listenToTerminalRedraw(msg.redrawChan) - } - } - return m, nil - - case terminalRedrawMsg: - if m.state == StateSFTPBrowser && m.sftpBrowser != nil && m.sftpBrowser.terminalRedraw != nil { - return m, listenToTerminalRedraw(m.sftpBrowser.terminalRedraw) - } - return m, nil - - case sftpErrorMsg: - if m.state == StateSFTPBrowser && m.sftpBrowser != nil { - m.sftpBrowser.err = msg.err - m.sftpBrowser.isBusy = false - m.sftpBrowser.statusMsg = "" - m.errorMsg = msg.err.Error() - } - return m, nil - - case sftpListMsg: - if m.state == StateSFTPBrowser && m.sftpBrowser != nil { - m.sftpBrowser.isBusy = false - if strings.HasPrefix(m.sftpBrowser.statusMsg, "Reading") { - m.sftpBrowser.statusMsg = "" - } - if msg.panel == LocalPanel { - m.sftpBrowser.localItems = msg.items - if m.sftpBrowser.localIdx >= len(msg.items) { - m.sftpBrowser.localIdx = 0 - } - } else { - m.sftpBrowser.remoteItems = msg.items - if m.sftpBrowser.remoteIdx >= len(msg.items) { - m.sftpBrowser.remoteIdx = 0 - } - } - } - return m, nil - - case sftpProgressMsg: - if m.state == StateSFTPBrowser && m.sftpBrowser != nil { - m.sftpBrowser.lastProgress = msg - if msg.Done { - m.sftpBrowser.isBusy = false - if msg.Err != nil { - m.sftpBrowser.statusMsg = fmt.Sprintf("Error: %v", msg.Err) - } else { - m.sftpBrowser.statusMsg = msg.Message - } - return m, m.refreshSFTP() - } else { - m.sftpBrowser.statusMsg = msg.Message - return m, listenToProgress(m.sftpBrowser.progressChan) - } - } - return m, nil - - case spinner.TickMsg: - if m.state == StateSFTPBrowser && m.sftpBrowser != nil { - m.sftpBrowser.spinner, cmd = m.sftpBrowser.spinner.Update(msg) - return m, cmd - } - } - - // Route based on active state - switch m.state { - case StateServerList: - return m.updateServerList(msg) - case StateServerForm: - return m.updateServerForm(msg) - case StateSFTPBrowser: - return m.updateSFTPBrowser(msg) - } - - return m, nil -} - -func (m *MainModel) refreshSFTP() tea.Cmd { - return tea.Batch( - func() tea.Msg { - items, err := getLocalItems(m.sftpBrowser.localDir) - if err != nil { - return sftpErrorMsg{err: err} - } - return sftpListMsg{panel: LocalPanel, dir: m.sftpBrowser.localDir, items: items} - }, - func() tea.Msg { - items, err := getRemoteItems(m.sftpBrowser.sftpClient, m.sftpBrowser.remoteDir) - if err != nil { - return sftpErrorMsg{err: err} - } - return sftpListMsg{panel: RemotePanel, dir: m.sftpBrowser.remoteDir, items: items} - }, - ) -} - -func (m MainModel) updateServerList(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - // Clear notifications on keypress - m.errorMsg = "" - m.successMsg = "" - - if m.confirmDelete { - switch msg.String() { - case "y", "Y": - m.confirmDelete = false - if len(m.servers) > 0 { - alias := m.servers[m.selectedIdx].Alias - m.servers = append(m.servers[:m.selectedIdx], m.servers[m.selectedIdx+1:]...) - if err := SaveServers(m.servers); err != nil { - m.errorMsg = "Failed to save configuration: " + err.Error() - } else { - m.successMsg = fmt.Sprintf("Removed server '%s'.", alias) - } - if m.selectedIdx >= len(m.servers) && m.selectedIdx > 0 { - m.selectedIdx = len(m.servers) - 1 - } - } - default: - m.confirmDelete = false - } - return m, nil - } - - switch msg.String() { - case "left", "h": - if m.selectedIdx > 0 { - m.selectedIdx-- - } - - case "right", "l": - if m.selectedIdx < len(m.servers)-1 { - m.selectedIdx++ - } - - case "up", "k": - numCols := m.getGridCols() - if m.selectedIdx >= numCols { - m.selectedIdx -= numCols - } else { - m.selectedIdx = 0 - } - - case "down", "j": - numCols := m.getGridCols() - if m.selectedIdx+numCols < len(m.servers) { - m.selectedIdx += numCols - } else if m.selectedIdx < len(m.servers)-1 { - m.selectedIdx = len(m.servers) - 1 - } - - case "enter", "c", "u": - if len(m.servers) == 0 { - return m, nil - } - server := m.servers[m.selectedIdx] - m.sftpBrowser = NewSFTPBrowser(server) - m.sftpBrowser.width = m.width - m.sftpBrowser.height = m.height - m.state = StateSFTPBrowser - return m, tea.Batch( - m.sftpBrowser.spinner.Tick, - connectSFTP(server, m.width, m.height), - ) - - case "a": - m.state = StateServerForm - m.initForm(ModeAdd, nil) - - case "e": - if len(m.servers) == 0 { - return m, nil - } - m.state = StateServerForm - m.editingServer = m.selectedIdx - server := m.servers[m.selectedIdx] - m.initForm(ModeEdit, &server) - - case "d", "backspace": - if len(m.servers) > 0 { - m.confirmDelete = true - } - - case "q", "ctrl+c": - return m, tea.Quit - } - } - return m, nil -} - -func (m MainModel) updateServerForm(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "esc": - m.state = StateServerList - return m, nil - - case "tab", "down": - m.formInputs[m.formActiveField].Blur() - m.formActiveField = (m.formActiveField + 1) % len(m.formInputs) - m.formInputs[m.formActiveField].Focus() - return m, nil - - case "shift+tab", "up": - m.formInputs[m.formActiveField].Blur() - m.formActiveField = (m.formActiveField - 1 + len(m.formInputs)) % len(m.formInputs) - m.formInputs[m.formActiveField].Focus() - return m, nil - - case "enter": - // Validate fields - alias := strings.TrimSpace(m.formInputs[0].Value()) - host := strings.TrimSpace(m.formInputs[1].Value()) - portStr := strings.TrimSpace(m.formInputs[2].Value()) - user := strings.TrimSpace(m.formInputs[3].Value()) - password := m.formInputs[4].Value() - projectPath := strings.TrimSpace(m.formInputs[5].Value()) - - if alias == "" || host == "" || user == "" || password == "" { - m.errorMsg = "Alias, Host, Username, and Password are required fields." - return m, nil - } - - port, err := strconv.Atoi(portStr) - if err != nil || port <= 0 || port > 65535 { - m.errorMsg = "Invalid port number. Must be between 1 and 65535." - return m, nil - } - - // Add or update - newServer := Server{ - Alias: alias, - Host: host, - User: user, - Password: password, - Port: port, - ProjectPath: projectPath, - } - - if m.formMode == ModeAdd { - m.servers = append(m.servers, newServer) - m.selectedIdx = len(m.servers) - 1 - m.successMsg = fmt.Sprintf("Added server '%s' successfully.", alias) - } else { - m.servers[m.editingServer] = newServer - m.selectedIdx = m.editingServer - m.successMsg = fmt.Sprintf("Updated server '%s' successfully.", alias) - } - - if err := SaveServers(m.servers); err != nil { - m.errorMsg = "Failed to save configuration: " + err.Error() - } - - m.state = StateServerList - return m, nil - } - } - - // Route keys to active textinput - var cmd tea.Cmd - m.formInputs[m.formActiveField], cmd = m.formInputs[m.formActiveField].Update(msg) - return m, cmd -} - -func (m MainModel) updateSFTPBrowser(msg tea.Msg) (tea.Model, tea.Cmd) { - b := m.sftpBrowser - if b == nil { - m.state = StateServerList - return m, nil - } - - switch msg := msg.(type) { - case tea.KeyMsg: - // Clear transfer status notifications on keypress - if !b.isBusy && b.activePanel != TerminalPanel { - if strings.HasPrefix(b.statusMsg, "Successfully") || strings.HasPrefix(b.statusMsg, "Error") { - b.statusMsg = "" - } - } - - if b.deleteConfirm { - switch msg.String() { - case "y", "Y": - b.deleteConfirm = false - b.isBusy = true - b.statusMsg = fmt.Sprintf("Deleting %s...", b.deleteItem.Name) - isLocal := b.activePanel == LocalPanel - path := b.deletePath - name := b.deleteItem.Name - return m, func() tea.Msg { - var err error - if isLocal { - err = os.RemoveAll(path) - } else { - err = removeRemoteAll(b.sftpClient, b.sshClient, path) - } - if err != nil { - return sftpProgressMsg{ - Done: true, - Err: fmt.Errorf("failed to delete %s: %w", name, err), - } - } - return sftpProgressMsg{ - Done: true, - Message: fmt.Sprintf("Successfully deleted %s", name), - } - } - case "n", "N", "esc", "q": - b.deleteConfirm = false - return m, nil - } - return m, nil - } - - // Global resize key bindings (available even in terminal panel via ctrl+up/down) - switch msg.String() { - case "ctrl+up": - if b.activePanel == TerminalPanel { - if b.terminalHeightPct < 80 { - b.terminalHeightPct += 5 - m.handleTerminalResize() - } - } else { - // Make file panels taller -> make terminal shorter - if b.terminalHeightPct > 15 { - b.terminalHeightPct -= 5 - m.handleTerminalResize() - } - } - return m, nil - case "ctrl+down": - if b.activePanel == TerminalPanel { - if b.terminalHeightPct > 15 { - b.terminalHeightPct -= 5 - m.handleTerminalResize() - } - } else { - // Make file panels shorter -> make terminal taller - if b.terminalHeightPct < 80 { - b.terminalHeightPct += 5 - m.handleTerminalResize() - } - } - return m, nil - case "ctrl+right": - if b.localWidthPct < 80 { - b.localWidthPct += 5 - } - return m, nil - case "ctrl+left": - if b.localWidthPct > 20 { - b.localWidthPct -= 5 - } - return m, nil - } - - if b.activePanel == TerminalPanel { - if msg.Type == tea.KeyCtrlT { - b.activePanel = LocalPanel - return m, nil - } - - inputStr := keyMsgToTerminalInput(msg) - if inputStr != "" && b.terminalIn != nil { - b.terminalIn.Write([]byte(inputStr)) - } - return m, nil - } - - if b.isBusy { - // Disable inputs while transfer or connection is running - if msg.String() == "ctrl+c" { - // Allow exit - b.isBusy = false - if b.sftpClient != nil { - b.sftpClient.Close() - } - if b.sshClient != nil { - b.sshClient.Close() - } - m.state = StateServerList - } - return m, nil - } - - switch msg.String() { - case "+", "=", "]": - if b.activePanel == LocalPanel { - if b.localWidthPct < 80 { - b.localWidthPct += 5 - } - } else if b.activePanel == RemotePanel { - if b.localWidthPct > 20 { - b.localWidthPct -= 5 - } - } - return m, nil - - case "-", "[": - if b.activePanel == LocalPanel { - if b.localWidthPct > 20 { - b.localWidthPct -= 5 - } - } else if b.activePanel == RemotePanel { - if b.localWidthPct < 80 { - b.localWidthPct += 5 - } - } - return m, nil - - case "q", "esc": - if b.sftpClient != nil { - b.sftpClient.Close() - } - if b.sshClient != nil { - b.sshClient.Close() - } - if b.terminalSession != nil { - b.terminalSession.Close() - } - m.state = StateServerList - return m, nil - - case "ctrl+t": - if b.terminalEmu != nil { - b.activePanel = TerminalPanel - } - return m, nil - - case "tab", "left", "right": - if b.activePanel == LocalPanel { - b.activePanel = RemotePanel - } else { - b.activePanel = LocalPanel - } - - case "up", "k": - if b.activePanel == LocalPanel { - if b.localIdx > 0 { - b.localIdx-- - } - } else { - if b.remoteIdx > 0 { - b.remoteIdx-- - } - } - - case "down", "j": - if b.activePanel == LocalPanel { - if b.localIdx < len(b.localItems)-1 { - b.localIdx++ - } - } else { - if b.remoteIdx < len(b.remoteItems)-1 { - b.remoteIdx++ - } - } - - case "backspace": - if b.activePanel == LocalPanel { - parent := filepath.Dir(b.localDir) - if parent != b.localDir { - b.localDir = parent - items, err := getLocalItems(b.localDir) - if err == nil { - b.localItems = items - b.localIdx = 0 - } - } - } else { - parent := remoteDirUp(b.remoteDir) - if parent != b.remoteDir { - b.remoteDir = parent - b.isBusy = true - b.statusMsg = "Reading remote directory..." - return m, func() tea.Msg { - items, err := getRemoteItems(b.sftpClient, b.remoteDir) - if err != nil { - return sftpErrorMsg{err: err} - } - return sftpListMsg{panel: RemotePanel, dir: b.remoteDir, items: items} - } - } - } - - case "enter": - if b.activePanel == LocalPanel { - if len(b.localItems) == 0 { - return m, nil - } - item := b.localItems[b.localIdx] - if item.IsDir { - if item.Name == ".." { - b.localDir = filepath.Dir(b.localDir) - } else { - b.localDir = filepath.Join(b.localDir, item.Name) - } - items, err := getLocalItems(b.localDir) - if err == nil { - b.localItems = items - b.localIdx = 0 - } - } - } else { - if len(b.remoteItems) == 0 { - return m, nil - } - item := b.remoteItems[b.remoteIdx] - if item.IsDir { - if item.Name == ".." { - b.remoteDir = remoteDirUp(b.remoteDir) - } else { - b.remoteDir = remoteJoin(b.remoteDir, item.Name) - } - b.isBusy = true - b.statusMsg = "Reading remote directory..." - return m, func() tea.Msg { - items, err := getRemoteItems(b.sftpClient, b.remoteDir) - if err != nil { - return sftpErrorMsg{err: err} - } - return sftpListMsg{panel: RemotePanel, dir: b.remoteDir, items: items} - } - } - } - - case "u": // Upload file from local to remote - if len(b.localItems) == 0 { - return m, nil - } - item := b.localItems[b.localIdx] - if item.Name == ".." { - return m, nil - } - - localPath := filepath.Join(b.localDir, item.Name) - remotePath := remoteJoin(b.remoteDir, item.Name) - - b.isBusy = true - b.statusMsg = fmt.Sprintf("Uploading %s...", item.Name) - return m, b.startUpload(localPath, remotePath, item.Name) - - case "d": // Download file from remote to local - if len(b.remoteItems) == 0 { - return m, nil - } - item := b.remoteItems[b.remoteIdx] - if item.Name == ".." { - return m, nil - } - - remotePath := remoteJoin(b.remoteDir, item.Name) - localPath := filepath.Join(b.localDir, item.Name) - - b.isBusy = true - b.statusMsg = fmt.Sprintf("Downloading %s...", item.Name) - return m, b.startDownload(remotePath, localPath, item.Name) - - case "x", "delete": - if b.activePanel == LocalPanel { - if len(b.localItems) == 0 { - return m, nil - } - item := b.localItems[b.localIdx] - if item.Name == ".." { - return m, nil - } - b.deleteConfirm = true - b.deleteItem = item - b.deletePath = filepath.Join(b.localDir, item.Name) - } else { - if len(b.remoteItems) == 0 { - return m, nil - } - item := b.remoteItems[b.remoteIdx] - if item.Name == ".." { - return m, nil - } - b.deleteConfirm = true - b.deleteItem = item - b.deletePath = remoteJoin(b.remoteDir, item.Name) - } - return m, nil - } - } - - return m, nil -} - -func (m MainModel) View() string { - switch m.state { - case StateServerList: - return m.viewServerList() - case StateServerForm: - return m.viewServerForm() - case StateSFTPBrowser: - b := m.sftpBrowser - if b != nil { - if b.deleteConfirm { - return m.renderDeleteConfirmModal(b) - } - if b.isBusy { - return m.renderProgressModal(b) - } - } - return m.viewSFTPBrowser() - } - return "" -} - -func (m MainModel) renderDeleteConfirmModal(b *SFTPBrowser) string { - content := fmt.Sprintf( - "%s\n\n%s\n%s\n\n%s", - styleModalTitle.Render("■ CONFIRM DELETE"), - styleModalProgressMsg.Render("Are you sure you want to permanently delete:"), - lipgloss.NewStyle().Foreground(colorRed).Bold(true).Render(b.deleteItem.Name), - lipgloss.NewStyle().Foreground(colorMuted).Render("[y] Yes, Delete [n/esc] Cancel"), - ) - modalBox := styleModal.Render(content) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modalBox) -} - -func (m MainModel) renderProgressModal(b *SFTPBrowser) string { - title := "■ SYSTEM BUSY" - msg := b.statusMsg - var progressView string - - if strings.HasPrefix(b.statusMsg, "Connecting") { - title = "■ CONNECTING TO VPS" - msg = fmt.Sprintf("Establishing connection to %s...\nPlease wait.", b.Server.Alias) - progressView = fmt.Sprintf("\n%s\n", b.spinner.View()) - } else if strings.HasPrefix(b.statusMsg, "Reading") { - title = "■ READING DIRECTORY" - msg = fmt.Sprintf("Fetching remote file list...\nPath: %s", b.remoteDir) - progressView = fmt.Sprintf("\n%s\n", b.spinner.View()) - } else if strings.HasPrefix(b.statusMsg, "Deleting") { - title = "■ DELETING ITEM" - progressView = fmt.Sprintf("\n%s\n", b.spinner.View()) - } else if strings.HasPrefix(b.statusMsg, "Uploading") { - title = "■ UPLOADING FILE(S)" - bar := drawProgressBar(b.lastProgress.Percent, 44) - progressView = fmt.Sprintf("\n%s %.1f%%\n", lipgloss.NewStyle().Foreground(colorPurple).Render(bar), b.lastProgress.Percent*100) - } else if strings.HasPrefix(b.statusMsg, "Downloading") { - title = "■ DOWNLOADING FILE(S)" - bar := drawProgressBar(b.lastProgress.Percent, 44) - progressView = fmt.Sprintf("\n%s %.1f%%\n", lipgloss.NewStyle().Foreground(colorPurple).Render(bar), b.lastProgress.Percent*100) - } else { - // Generic busy fallback - progressView = fmt.Sprintf("\n%s\n", b.spinner.View()) - } - - content := fmt.Sprintf( - "%s\n\n%s\n%s\n\n%s", - styleModalTitle.Render(title), - styleModalProgressMsg.Render(msg), - progressView, - lipgloss.NewStyle().Foreground(colorMuted).Render("ctrl+c to cancel / interrupt"), - ) - modalBox := styleModal.Render(content) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modalBox) -} - -func (m MainModel) viewServerList() string { - var b strings.Builder - - b.WriteString(styleTitle.Render("■ SSH MANAGER")) - b.WriteString(styleSubtitle.Render("Select a server to connect or manage files")) - - // Display notifications - if m.errorMsg != "" { - b.WriteString("\n") - b.WriteString(styleErrorMsg.Render("✖ " + m.errorMsg)) - b.WriteString("\n") - } else if m.successMsg != "" { - b.WriteString("\n") - b.WriteString(styleStatusMsg.Render("✔ " + m.successMsg)) - b.WriteString("\n") - } else { - b.WriteString("\n\n") - } - - if len(m.servers) == 0 { - b.WriteString(styleHelp.Render("No servers saved yet.\nPress 'a' to add a server, or 'q' to quit.")) - return b.String() - } - - var cards []string - for i, s := range m.servers { - var cardContent strings.Builder - - // Determine if server matches the current folder and calculate space for alias - matchTag := "" - aliasSpace := 34 - if s.ProjectPath != "" && s.ProjectPath == m.cwd { - matchTag = " [CURRENT PROJECT]" - aliasSpace = 34 - len(matchTag) - } - - truncatedAlias := truncateMiddle(s.Alias, aliasSpace) - cardContent.WriteString(fmt.Sprintf("%s%s\n", styleServerAlias.Render(truncatedAlias), styleStatusMsg.Render(matchTag))) - - sshInfo := fmt.Sprintf("%s@%s:%d", s.User, s.Host, s.Port) - truncatedSSHInfo := truncateMiddle(sshInfo, 29) - cardContent.WriteString(styleServerDetails.Render("SSH: " + truncatedSSHInfo + "\n")) - - if s.ProjectPath != "" { - truncatedPath := truncateMiddle(s.ProjectPath, 28) - cardContent.WriteString(styleServerDetails.Render("Path: ") + styleProjectPath.Render(truncatedPath)) - } else { - cardContent.WriteString(styleServerDetails.Render("Path: (none)")) - } - - var renderedCard string - if i == m.selectedIdx { - renderedCard = styleServerCardSelected.Render(cardContent.String()) - } else { - renderedCard = styleServerCard.Render(cardContent.String()) - } - cards = append(cards, renderedCard) - } - - // Layout in a grid/columns - numCols := m.getGridCols() - var rows []string - for i := 0; i < len(cards); i += numCols { - end := i + numCols - if end > len(cards) { - end = len(cards) - } - row := lipgloss.JoinHorizontal(lipgloss.Top, cards[i:end]...) - rows = append(rows, row) - } - grid := lipgloss.JoinVertical(lipgloss.Left, rows...) - b.WriteString(grid) - b.WriteString("\n") - - if m.confirmDelete { - b.WriteString(styleErrorMsg.Render("\nConfirm deletion of selected server? [y/N]: ")) - b.WriteString("\n") - } else { - b.WriteString(styleHelp.Render("enter/c: connect • u: file transfer (sftp) • a: add • e: edit • d/backspace: delete • q: quit")) - } - - return b.String() -} - -func (m MainModel) viewServerForm() string { - var b strings.Builder - - titleText := "ADD NEW SERVER" - if m.formMode == ModeEdit { - titleText = "EDIT SERVER CONFIGURATION" - } - - var formContent strings.Builder - formContent.WriteString(styleFormTitle.Render(titleText)) - formContent.WriteString("\n\n") - - labels := []string{"Alias:", "Host / IP:", "Port:", "Username:", "Password:", "Project Path:"} - for i, input := range m.formInputs { - label := styleFormLabel.Render(labels[i]) - var inputStr string - if i == m.formActiveField { - inputStr = styleFormInputActive.Render(input.View()) - } else { - inputStr = styleFormInputMuted.Render(input.View()) - } - formContent.WriteString(fmt.Sprintf("%s %s\n", label, inputStr)) - } - - if m.errorMsg != "" { - formContent.WriteString("\n") - formContent.WriteString(styleErrorMsg.Render("✖ " + m.errorMsg)) - formContent.WriteString("\n") - } - - formContent.WriteString(styleHelp.Render("\ntab/down: next field • up: prev field • enter: save • esc: cancel")) - - b.WriteString(styleFormBorder.Render(formContent.String())) - return b.String() -} - -func (m MainModel) viewSFTPBrowser() string { - b := m.sftpBrowser - if b == nil { - return "" - } - - var builder strings.Builder - builder.WriteString(styleTitle.Render(fmt.Sprintf("■ FILE MANAGER & TERMINAL — %s", b.Server.Alias))) - - statusText := b.statusMsg - if b.isBusy { - statusText = fmt.Sprintf("%s %s", b.spinner.View(), b.statusMsg) - } - - if b.err != nil { - builder.WriteString("\n") - builder.WriteString(styleErrorMsg.Render("✖ " + b.err.Error())) - builder.WriteString("\n") - } else { - builder.WriteString("\n") - builder.WriteString(styleStatusMsg.Render(statusText)) - builder.WriteString("\n") - builder.WriteString("\n") - } - - // Calculate heights - availHeight := m.height - 8 - if availHeight < 10 { - availHeight = 10 - } - termHeight := availHeight * b.terminalHeightPct / 100 - if termHeight < 5 { - termHeight = 5 - } - if termHeight > availHeight-6 { - termHeight = availHeight - 6 - } - panelHeight := availHeight - termHeight - if panelHeight < 6 { - panelHeight = 6 - } - - // Calculate panel widths based on localWidthPct - availWidth := m.width - 10 - if availWidth < 40 { - availWidth = 40 - } - leftPanelWidth := availWidth * b.localWidthPct / 100 - rightPanelWidth := availWidth - leftPanelWidth - - if leftPanelWidth < 20 { - leftPanelWidth = 20 - } - if rightPanelWidth < 20 { - rightPanelWidth = 20 - } - - // Render Local Panel - var localLines []string - localLines = append(localLines, lipgloss.NewStyle().Bold(true).Foreground(colorPurple).Render("■ LOCAL DIRECTORY:")) - localLines = append(localLines, styleServerDetails.Render(b.localDir)) - localLines = append(localLines, "") - - if b.initialized { - localLines = append(localLines, renderFileList(b.localItems, b.localIdx, panelHeight)...) - } else { - localLines = append(localLines, " Connecting...") - } - - localView := lipgloss.JoinVertical(lipgloss.Left, localLines...) - leftStyle := styleFilePanel.Copy().Width(leftPanelWidth).Height(panelHeight) - if b.activePanel == LocalPanel { - leftStyle = styleFilePanelActive.Copy().Width(leftPanelWidth).Height(panelHeight) - } - leftPanel := leftStyle.Render(localView) - - // Render Remote Panel - var remoteLines []string - remoteLines = append(remoteLines, lipgloss.NewStyle().Bold(true).Foreground(colorBlue).Render("■ REMOTE VPS DIRECTORY:")) - remoteLines = append(remoteLines, styleServerDetails.Render(b.remoteDir)) - remoteLines = append(remoteLines, "") - - if b.initialized { - remoteLines = append(remoteLines, renderFileList(b.remoteItems, b.remoteIdx, panelHeight)...) - } else { - remoteLines = append(remoteLines, " Connecting...") - } - - remoteView := lipgloss.JoinVertical(lipgloss.Left, remoteLines...) - rightStyle := styleFilePanel.Copy().Width(rightPanelWidth).Height(panelHeight) - if b.activePanel == RemotePanel { - rightStyle = styleFilePanelActive.Copy().Width(rightPanelWidth).Height(panelHeight) - } - rightPanel := rightStyle.Render(remoteView) - - panes := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel) - builder.WriteString(panes) - builder.WriteString("\n\n") - - // Render Terminal Panel - var termContent string - if b.terminalEmu != nil { - if b.activePanel == TerminalPanel { - pos := b.terminalEmu.CursorPosition() - w, h := b.terminalEmu.Width(), b.terminalEmu.Height() - if pos.X >= 0 && pos.X < w && pos.Y >= 0 && pos.Y < h { - oldCell := b.terminalEmu.CellAt(pos.X, pos.Y) - var oldCellClone *uv.Cell - if oldCell != nil { - oldCellClone = oldCell.Clone() - } - - // Create block cursor style - cursorCell := &uv.Cell{ - Content: " ", - Width: 1, - } - if oldCell != nil { - if oldCell.Content != "" { - cursorCell.Content = oldCell.Content - } - cursorCell.Style = oldCell.Style - } - cursorCell.Style.Bg = color.RGBA{R: 203, G: 166, B: 247, A: 255} - cursorCell.Style.Fg = color.RGBA{R: 0, G: 0, B: 0, A: 255} - cursorCell.Style.Attrs &^= uv.AttrReverse - - b.terminalEmu.SetCell(pos.X, pos.Y, cursorCell) - termContent = b.terminalEmu.Render() - b.terminalEmu.SetCell(pos.X, pos.Y, oldCellClone) - } else { - termContent = b.terminalEmu.Render() - } - } else { - termContent = b.terminalEmu.Render() - } - } else { - termContent = "Initializing remote terminal session..." - } - - termWidth := m.width - 8 - if termWidth < 20 { - termWidth = 20 - } - - termStyle := styleTerminalPanel.Copy().Width(termWidth).Height(termHeight) - if b.activePanel == TerminalPanel { - termStyle = styleTerminalPanelActive.Copy().Width(termWidth).Height(termHeight) - } - - termLines := []string{ - lipgloss.NewStyle().Bold(true).Foreground(colorPurple).Render("■ REMOTE SSH TERMINAL (Ctrl+T to toggle focus):"), - termContent, - } - termView := termStyle.Render(lipgloss.JoinVertical(lipgloss.Left, termLines...)) - builder.WriteString(termView) - builder.WriteString("\n\n") - - var helpText string - if b.activePanel == TerminalPanel { - helpText = "ctrl+t: focus files • ctrl+up/down: resize height • ctrl+c: interrupt • ctrl+d: close • q/esc: disconnect & exit" - } else { - helpText = "tab/←/→: switch panel • ctrl+t: focus term • +/-/[]: resize • up/down: select • enter: open • backspace: up • u: upload • d: download • x/del: delete • q/esc: exit" - } - builder.WriteString(styleHelp.Render(helpText)) - - return builder.String() -} - -func renderFileList(items []FileItem, selectedIdx int, height int) []string { - if len(items) == 0 { - return []string{" (empty directory)"} - } - - maxLines := height - 4 - if maxLines <= 0 { - maxLines = 10 - } - - start := 0 - if selectedIdx >= maxLines { - start = selectedIdx - maxLines + 1 - } - - end := start + maxLines - if end > len(items) { - end = len(items) - } - - var lines []string - for idx := start; idx < end; idx++ { - item := items[idx] - name := item.Name - if item.IsDir && name != ".." { - name += "/" - } - - var icon string - if item.IsDir { - icon = styleIconDir.Render("■") - } else { - icon = styleIconFile.Render("•") - } - - var rendered string - if idx == selectedIdx { - prefix := lipgloss.NewStyle().Foreground(colorPurple).Bold(true).Render("→ ") - if item.IsDir { - rendered = prefix + icon + " " + styleFileSelected.Render(name) - } else { - rendered = prefix + icon + " " + styleFileSelected.Render(fmt.Sprintf("%s (%s)", name, formatSize(item.Size))) - } - } else { - prefix := " " - if item.IsDir { - rendered = prefix + icon + " " + styleFileDir.Render(name) - } else { - rendered = prefix + icon + " " + styleFileRegular.Render(fmt.Sprintf("%s (%s)", name, formatSize(item.Size))) - } - } - lines = append(lines, rendered) - } - return lines -} - func main() { - servers, err := LoadServers() - if err != nil { - fmt.Printf("Error loading servers: %v\n", err) - os.Exit(1) - } - - cwd, _ := os.Getwd() - - initialIdx := 0 - // Try to match current working directory with a server's ProjectPath - for i, s := range servers { - if s.ProjectPath != "" && s.ProjectPath == cwd { - initialIdx = i - break - } - } - - m := MainModel{ - state: StateServerList, - servers: servers, - selectedIdx: initialIdx, - cwd: cwd, - width: 80, - height: 24, - } - - p := tea.NewProgram(m, tea.WithAltScreen()) - if _, err := p.Run(); err != nil { - fmt.Printf("Alas, terminal application failed: %v\n", err) + if err := tui.Run(); err != nil { + fmt.Printf("Error: %v\n", err) os.Exit(1) } } - -func drawProgressBar(percent float64, width int) string { - if percent < 0 { - percent = 0 - } - if percent > 1 { - percent = 1 - } - - barWidth := width - 8 - if barWidth <= 0 { - return "" - } - - filledLength := int(percent * float64(barWidth)) - emptyLength := barWidth - filledLength - - filled := strings.Repeat("█", filledLength) - empty := strings.Repeat("░", emptyLength) - - return fmt.Sprintf("[%s%s] %3.0f%%", filled, empty, percent*100) -} - -func keyMsgToTerminalInput(msg tea.KeyMsg) string { - switch msg.Type { - case tea.KeyRunes: - return string(msg.Runes) - case tea.KeySpace: - return " " - case tea.KeyEnter: - return "\r" - case tea.KeyBackspace: - return "\x7f" - case tea.KeyTab: - return "\t" - case tea.KeyEscape: - return "\x1b" - case tea.KeyUp: - return "\x1b[A" - case tea.KeyDown: - return "\x1b[B" - case tea.KeyRight: - return "\x1b[C" - case tea.KeyLeft: - return "\x1b[D" - case tea.KeyCtrlA: - return "\x01" - case tea.KeyCtrlB: - return "\x02" - case tea.KeyCtrlC: - return "\x03" - case tea.KeyCtrlD: - return "\x04" - case tea.KeyCtrlE: - return "\x05" - case tea.KeyCtrlF: - return "\x06" - case tea.KeyCtrlG: - return "\x07" - case tea.KeyCtrlH: - return "\x08" - case tea.KeyCtrlJ: - return "\x0a" - case tea.KeyCtrlK: - return "\x0b" - case tea.KeyCtrlL: - return "\x0c" - case tea.KeyCtrlN: - return "\x0e" - case tea.KeyCtrlO: - return "\x0f" - case tea.KeyCtrlP: - return "\x10" - case tea.KeyCtrlQ: - return "\x11" - case tea.KeyCtrlR: - return "\x12" - case tea.KeyCtrlS: - return "\x13" - case tea.KeyCtrlT: - return "" - case tea.KeyCtrlU: - return "\x15" - case tea.KeyCtrlV: - return "\x16" - case tea.KeyCtrlW: - return "\x17" - case tea.KeyCtrlX: - return "\x18" - case tea.KeyCtrlY: - return "\x19" - case tea.KeyCtrlZ: - return "\x1a" - } - return "" -} - -func (m *MainModel) handleTerminalResize() { - b := m.sftpBrowser - if b == nil { - return - } - availHeight := m.height - 8 - if availHeight < 10 { - availHeight = 10 - } - termHeight := availHeight * b.terminalHeightPct / 100 - if termHeight < 5 { - termHeight = 5 - } - if termHeight > availHeight-6 { - termHeight = availHeight - 6 - } - termWidth := m.width - 8 - if termWidth < 20 { - termWidth = 20 - } - if b.terminalEmu != nil { - b.terminalEmu.Resize(termWidth, termHeight) - } - if b.terminalSession != nil { - _ = b.terminalSession.WindowChange(termHeight, termWidth) - } -} - -func (m MainModel) getGridCols() int { - cardWidth := 38 - cardGap := 2 - numCols := m.width / (cardWidth + cardGap) - if numCols <= 0 { - return 1 - } - return numCols -} - -func truncateMiddle(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - if maxLen <= 5 { - return "..." - } - half := (maxLen - 3) / 2 - return s[:half] + "..." + s[len(s)-half:] -} diff --git a/ssh.go b/ssh.go deleted file mode 100644 index 7eae617..0000000 --- a/ssh.go +++ /dev/null @@ -1,153 +0,0 @@ -package main - -import ( - "fmt" - "io" - "net" - "os" - "os/signal" - "syscall" - "time" - - "golang.org/x/crypto/ssh" - "golang.org/x/term" -) - -// SSHCommand implements tea.ExecCommand to support running a live terminal -// inside the Bubble Tea execution lifecycle. -type SSHCommand struct { - Host string - Port int - User string - Password string - stdin io.Reader - stdout io.Writer - stderr io.Writer -} - -func NewSSHCommand(server Server) *SSHCommand { - return &SSHCommand{ - Host: server.Host, - Port: server.Port, - User: server.User, - Password: server.Password, - } -} - -func (s *SSHCommand) SetStdin(r io.Reader) { s.stdin = r } -func (s *SSHCommand) SetStdout(w io.Writer) { s.stdout = w } -func (s *SSHCommand) SetStderr(w io.Writer) { s.stderr = w } - -func (s *SSHCommand) Run() error { - config := &ssh.ClientConfig{ - User: s.User, - Auth: []ssh.AuthMethod{ - ssh.Password(s.Password), - }, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - Timeout: 10 * time.Second, - } - - addr := fmt.Sprintf("%s:%d", s.Host, s.Port) - client, err := ssh.Dial("tcp", addr, config) - if err != nil { - return fmt.Errorf("failed to dial: %w", err) - } - defer client.Close() - - session, err := client.NewSession() - if err != nil { - return fmt.Errorf("failed to create session: %w", err) - } - defer session.Close() - - // Get file descriptor for stdin to set terminal to raw mode - var fd int - if f, ok := s.stdin.(*os.File); ok { - fd = int(f.Fd()) - } else { - fd = int(os.Stdin.Fd()) - } - - // Make raw terminal state so keys are sent directly to SSH - oldState, err := term.MakeRaw(fd) - if err != nil { - return fmt.Errorf("failed to make raw: %w", err) - } - defer func() { - _ = term.Restore(fd, oldState) - }() - - width, height, err := term.GetSize(fd) - if err != nil { - width, height = 80, 40 - } - - modes := ssh.TerminalModes{ - ssh.ECHO: 1, // Enable echo - ssh.TTY_OP_ISPEED: 14400, // input speed - ssh.TTY_OP_OSPEED: 14400, // output speed - } - - termEnv := os.Getenv("TERM") - if termEnv == "" { - termEnv = "xterm-256color" - } - - if err := session.RequestPty(termEnv, height, width, modes); err != nil { - return fmt.Errorf("failed to request pty: %w", err) - } - - // Route session pipes - session.Stdin = s.stdin - session.Stdout = s.stdout - session.Stderr = s.stderr - - // Handle window resizing (SIGWINCH) - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGWINCH) - defer signal.Stop(sigChan) - - go func() { - for range sigChan { - w, h, err := term.GetSize(fd) - if err == nil { - _ = session.WindowChange(h, w) - } - } - }() - - // Start remote shell - if err := session.Shell(); err != nil { - return fmt.Errorf("failed to start shell: %w", err) - } - - // Wait for connection to terminate - if err := session.Wait(); err != nil { - // Ignore EOF or standard exit exit codes - if err.Error() != "EOF" { - return err - } - } - - return nil -} - -// GetSSHClient establishes an SSH client connection (useful for SFTP). -func GetSSHClient(server Server) (*ssh.Client, error) { - config := &ssh.ClientConfig{ - User: server.User, - Auth: []ssh.AuthMethod{ - ssh.Password(server.Password), - }, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - Timeout: 10 * time.Second, - } - - addr := net.JoinHostPort(server.Host, fmt.Sprintf("%d", server.Port)) - client, err := ssh.Dial("tcp", addr, config) - if err != nil { - return nil, err - } - return client, nil -} diff --git a/ssh/ssh.go b/ssh/ssh.go new file mode 100644 index 0000000..4df5b3f --- /dev/null +++ b/ssh/ssh.go @@ -0,0 +1,338 @@ +package ssh + +import ( + "errors" + "fmt" + "io" + "net" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + "golang.org/x/crypto/ssh/knownhosts" + "golang.org/x/term" + + "sshmanager/config" +) + +// SSHCommand implements tea.ExecCommand to support running a live terminal +// inside the Bubble Tea execution lifecycle. +type SSHCommand struct { + Host string + Port int + User string + Password string + PrivateKeyPath string + UseSSHAgent bool + stdin io.Reader + stdout io.Writer + stderr io.Writer +} + +func NewSSHCommand(server config.Server) *SSHCommand { + return &SSHCommand{ + Host: server.Host, + Port: server.Port, + User: server.User, + Password: server.Password, + PrivateKeyPath: server.PrivateKeyPath, + UseSSHAgent: server.UseSSHAgent, + } +} + +func (s *SSHCommand) SetStdin(r io.Reader) { s.stdin = r } +func (s *SSHCommand) SetStdout(w io.Writer) { s.stdout = w } +func (s *SSHCommand) SetStderr(w io.Writer) { s.stderr = w } + +func (s *SSHCommand) Run() error { + auths := []ssh.AuthMethod{} + + // 1. Try SSH Agent + if s.UseSSHAgent { + if agentClient, err := connectSSHAgent(); err == nil { + auths = append(auths, ssh.PublicKeysCallback(agentClient.Signers)) + } + } + + // 2. Try Private Key + if s.PrivateKeyPath != "" { + signer, err := loadPrivateKey(s.PrivateKeyPath, s.Password) + if err == nil { + auths = append(auths, ssh.PublicKeys(signer)) + } else { + return fmt.Errorf("failed to load private key: %w", err) + } + } + + // 3. Fallback to Password + if s.Password != "" && (s.PrivateKeyPath == "" || len(auths) == 0) { + auths = append(auths, ssh.Password(s.Password)) + } + + configData := &ssh.ClientConfig{ + User: s.User, + Auth: auths, + HostKeyCallback: mustGetHostKeyCallback(), + Timeout: 10 * time.Second, + } + + addr := fmt.Sprintf("%s:%d", s.Host, s.Port) + client, err := ssh.Dial("tcp", addr, configData) + if err != nil { + return fmt.Errorf("failed to dial: %w", err) + } + defer client.Close() + + // Start Keep-Alive to maintain connection stability + startSSHKeepAlive(client) + + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("failed to create session: %w", err) + } + defer session.Close() + + // Get file descriptor for stdin to set terminal to raw mode + var fd int + if f, ok := s.stdin.(*os.File); ok { + fd = int(f.Fd()) + } else { + fd = int(os.Stdin.Fd()) + } + + // Make raw terminal state so keys are sent directly to SSH + oldState, err := term.MakeRaw(fd) + if err != nil { + return fmt.Errorf("failed to make raw: %w", err) + } + defer func() { + _ = term.Restore(fd, oldState) + }() + + width, height, err := term.GetSize(fd) + if err != nil { + width, height = 80, 40 + } + + modes := ssh.TerminalModes{ + ssh.ECHO: 1, // Enable echo + ssh.TTY_OP_ISPEED: 14400, // input speed + ssh.TTY_OP_OSPEED: 14400, // output speed + } + + termEnv := os.Getenv("TERM") + if termEnv == "" { + termEnv = "xterm-256color" + } + + if err := session.RequestPty(termEnv, height, width, modes); err != nil { + return fmt.Errorf("failed to request pty: %w", err) + } + + // Route session pipes + session.Stdin = s.stdin + session.Stdout = s.stdout + session.Stderr = s.stderr + + // Handle window resizing (SIGWINCH) + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGWINCH) + defer signal.Stop(sigChan) + + go func() { + for range sigChan { + w, h, err := term.GetSize(fd) + if err == nil { + _ = session.WindowChange(h, w) + } + } + }() + + // Start remote shell + if err := session.Shell(); err != nil { + return fmt.Errorf("failed to start shell: %w", err) + } + + // Wait for connection to terminate + if err := session.Wait(); err != nil { + // Ignore EOF or standard exit exit codes + if err.Error() != "EOF" { + return err + } + } + + return nil +} + +// GetSSHClient establishes an SSH client connection (useful for SFTP). +func GetSSHClient(server config.Server) (*ssh.Client, error) { + auths := []ssh.AuthMethod{} + + // 1. Try SSH Agent + if server.UseSSHAgent { + if agentClient, err := connectSSHAgent(); err == nil { + auths = append(auths, ssh.PublicKeysCallback(agentClient.Signers)) + } + } + + // 2. Try Private Key + if server.PrivateKeyPath != "" { + signer, err := loadPrivateKey(server.PrivateKeyPath, server.Password) + if err == nil { + auths = append(auths, ssh.PublicKeys(signer)) + } else { + return nil, fmt.Errorf("failed to load private key: %w", err) + } + } + + // 3. Fallback to Password + if server.Password != "" && (server.PrivateKeyPath == "" || len(auths) == 0) { + auths = append(auths, ssh.Password(server.Password)) + } + + configData := &ssh.ClientConfig{ + User: server.User, + Auth: auths, + HostKeyCallback: mustGetHostKeyCallback(), + Timeout: 10 * time.Second, + } + + addr := net.JoinHostPort(server.Host, fmt.Sprintf("%d", server.Port)) + client, err := ssh.Dial("tcp", addr, configData) + if err != nil { + return nil, err + } + + // Start Keep-Alive to maintain connection stability + startSSHKeepAlive(client) + + return client, nil +} + +// connectSSHAgent connects to the system's ssh-agent daemon. +func connectSSHAgent() (agent.Agent, error) { + socket := os.Getenv("SSH_AUTH_SOCK") + if socket == "" { + return nil, errors.New("SSH_AUTH_SOCK environment variable not set") + } + conn, err := net.Dial("unix", socket) + if err != nil { + return nil, err + } + return agent.NewClient(conn), nil +} + +// loadPrivateKey loads a private key from disk with optional passphrase decryption. +func loadPrivateKey(path string, passphrase string) (ssh.Signer, error) { + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err == nil { + path = filepath.Join(home, path[2:]) + } + } + + keyBytes, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + if passphrase != "" { + signer, err := ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(passphrase)) + if err == nil { + return signer, nil + } + // Fallback to unencrypted key in case a passphrase was provided but key is not encrypted + } + return ssh.ParsePrivateKey(keyBytes) +} + +// getHostKeyCallback returns a secure host key verifier that implements TOFU (Trust On First Use). +func getHostKeyCallback() (ssh.HostKeyCallback, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + sshDir := filepath.Join(home, ".ssh") + if err := os.MkdirAll(sshDir, 0700); err != nil { + return nil, err + } + knownHostsPath := filepath.Join(sshDir, "known_hosts") + + // Ensure the file exists + f, err := os.OpenFile(knownHostsPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return nil, err + } + f.Close() + + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + cb, err := knownhosts.New(knownHostsPath) + if err != nil { + return err + } + + err = cb(hostname, remote, key) + if err == nil { + return nil + } + + var keyErr *knownhosts.KeyError + if errors.As(err, &keyErr) { + if len(keyErr.Want) == 0 { + // Host not found (TOFU behavior) -> Add to known_hosts + // Ensure existing known_hosts file ends with a newline to avoid formatting corruption + if data, err := os.ReadFile(knownHostsPath); err == nil && len(data) > 0 { + if data[len(data)-1] != '\n' { + if fk, err := os.OpenFile(knownHostsPath, os.O_WRONLY|os.O_APPEND, 0600); err == nil { + _, _ = fk.WriteString("\n") + fk.Close() + } + } + } + + line := knownhosts.Line([]string{knownhosts.Normalize(remote.String()), knownhosts.Normalize(hostname)}, key) + f, err := os.OpenFile(knownHostsPath, os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return fmt.Errorf("failed to open known_hosts: %w", err) + } + defer f.Close() + if _, err := f.WriteString(line); err != nil { + return fmt.Errorf("failed to write known_hosts: %w", err) + } + return nil + } + // Host found but key mismatches -> Potential hijack or MITM + return fmt.Errorf("SECURITY WARNING: Host key verification failed for %s. Key has changed!", hostname) + } + + return err + }, nil +} + +// mustGetHostKeyCallback is a helper that falls back to insecure check ONLY if known_hosts cannot be loaded. +func mustGetHostKeyCallback() ssh.HostKeyCallback { + cb, err := getHostKeyCallback() + if err != nil { + return ssh.InsecureIgnoreHostKey() + } + return cb +} + +// startSSHKeepAlive sends keepalive requests to the server periodically to prevent idle timeouts. +func startSSHKeepAlive(client *ssh.Client) { + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + _, _, err := client.SendRequest("keepalive@openssh.com", true, nil) + if err != nil { + return + } + } + }() +} diff --git a/sftp_browser.go b/tui/sftp_browser.go similarity index 98% rename from sftp_browser.go rename to tui/sftp_browser.go index d29ec60..9aa5bda 100644 --- a/sftp_browser.go +++ b/tui/sftp_browser.go @@ -1,4 +1,4 @@ -package main +package tui import ( "crypto/sha256" @@ -18,6 +18,9 @@ import ( "github.com/charmbracelet/x/vt" "github.com/pkg/sftp" "golang.org/x/crypto/ssh" + + "sshmanager/config" + sshcmd "sshmanager/ssh" ) type Panel int @@ -35,7 +38,7 @@ type FileItem struct { } type SFTPBrowser struct { - Server Server + Server config.Server sshClient *ssh.Client sftpClient *sftp.Client activePanel Panel @@ -857,9 +860,9 @@ func downloadTasksConcurrent(sshClient *ssh.Client, sftpClient *sftp.Client, tas return nil } -func connectSFTP(server Server, width, height int) tea.Cmd { +func connectSFTP(server config.Server, width, height int) tea.Cmd { return func() tea.Msg { - sshClient, err := GetSSHClient(server) + sshClient, err := sshcmd.GetSSHClient(server) if err != nil { return sftpErrorMsg{err: fmt.Errorf("SSH connection failed: %w", err)} } @@ -1094,7 +1097,7 @@ func (b *SFTPBrowser) startDownload(remotePath, localPath, filename string) tea. } // Initialise the browser -func NewSFTPBrowser(server Server) *SFTPBrowser { +func NewSFTPBrowser(server config.Server) *SFTPBrowser { s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) diff --git a/sftp_browser_test.go b/tui/sftp_browser_test.go similarity index 99% rename from sftp_browser_test.go rename to tui/sftp_browser_test.go index df4168f..2c37622 100644 --- a/sftp_browser_test.go +++ b/tui/sftp_browser_test.go @@ -1,4 +1,4 @@ -package main +package tui import ( "os" @@ -168,4 +168,3 @@ func TestDiscoverUploadTasksDirectory(t *testing.T) { t.Errorf("expected file task to not be IsDir and have size 5") } } - diff --git a/styles.go b/tui/styles.go similarity index 99% rename from styles.go rename to tui/styles.go index cac5cd3..3c1a1c0 100644 --- a/styles.go +++ b/tui/styles.go @@ -1,4 +1,4 @@ -package main +package tui import "github.com/charmbracelet/lipgloss" diff --git a/tui/tui.go b/tui/tui.go new file mode 100644 index 0000000..2a47280 --- /dev/null +++ b/tui/tui.go @@ -0,0 +1,1358 @@ +package tui + +import ( + "fmt" + "image/color" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + uv "github.com/charmbracelet/ultraviolet" + + "sshmanager/config" +) + +type AppState int + +const ( + StateServerList AppState = iota + StateServerForm + StateSFTPBrowser +) + +type FormMode int + +const ( + ModeAdd FormMode = iota + ModeEdit +) + +type MainModel struct { + state AppState + servers []config.Server + selectedIdx int + errorMsg string + successMsg string + cwd string // current working directory + + // Form fields + formMode FormMode + formInputs []textinput.Model + formActiveField int + editingServer int // Index of server being edited + + // Confirm delete field + confirmDelete bool + + // SFTP Browser field + sftpBrowser *SFTPBrowser + + // Terminal dimensions + width int + height int +} + +func (m *MainModel) initForm(mode FormMode, server *config.Server) { + m.formMode = mode + m.formInputs = make([]textinput.Model, 8) + m.formActiveField = 0 + + for i := range m.formInputs { + t := textinput.New() + t.CharLimit = 128 + m.formInputs[i] = t + } + + m.formInputs[0].Placeholder = "my-vps" + m.formInputs[1].Placeholder = "192.168.1.1" + m.formInputs[2].Placeholder = "22" + m.formInputs[3].Placeholder = "root" + m.formInputs[4].Placeholder = "password (or passphrase if using key)" + m.formInputs[4].EchoMode = textinput.EchoPassword + m.formInputs[5].Placeholder = "/path/to/my/project" + m.formInputs[6].Placeholder = "/home/user/.ssh/id_rsa (optional)" + m.formInputs[7].Placeholder = "n (y/n to use SSH Agent)" + + if mode == ModeEdit && server != nil { + m.formInputs[0].SetValue(server.Alias) + m.formInputs[1].SetValue(server.Host) + m.formInputs[2].SetValue(strconv.Itoa(server.Port)) + m.formInputs[3].SetValue(server.User) + m.formInputs[4].SetValue(server.Password) + m.formInputs[5].SetValue(server.ProjectPath) + m.formInputs[6].SetValue(server.PrivateKeyPath) + if server.UseSSHAgent { + m.formInputs[7].SetValue("y") + } else { + m.formInputs[7].SetValue("n") + } + } else { + m.formInputs[2].SetValue("22") + m.formInputs[3].SetValue("root") + m.formInputs[5].SetValue(m.cwd) // Prepopulate with cwd + m.formInputs[7].SetValue("n") + } + + m.formInputs[0].Focus() +} + +func (m MainModel) Init() tea.Cmd { + return nil +} + +type sshFinishedMsg struct { + err error +} + +func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + if m.state == StateSFTPBrowser && m.sftpBrowser != nil { + m.sftpBrowser.width = msg.Width + m.sftpBrowser.height = msg.Height + m.handleTerminalResize() + } + return m, nil + + case sshFinishedMsg: + if msg.err != nil { + m.errorMsg = fmt.Sprintf("SSH connection closed with error: %v", msg.err) + } else { + m.successMsg = "SSH connection closed successfully." + } + return m, nil + + case sftpConnectedMsg: + if m.state == StateSFTPBrowser && m.sftpBrowser != nil { + m.sftpBrowser.sshClient = msg.sshClient + m.sftpBrowser.sftpClient = msg.sftpClient + m.sftpBrowser.localDir = msg.localDir + m.sftpBrowser.remoteDir = msg.remoteDir + m.sftpBrowser.localItems = msg.localItems + m.sftpBrowser.remoteItems = msg.remoteItems + m.sftpBrowser.terminalSession = msg.terminalSession + m.sftpBrowser.terminalIn = msg.terminalIn + m.sftpBrowser.terminalEmu = msg.terminalEmu + m.sftpBrowser.terminalRedraw = msg.redrawChan + m.sftpBrowser.isBusy = false + m.sftpBrowser.statusMsg = "Connected. Tab to switch local/remote, Ctrl+T to toggle terminal focus." + m.sftpBrowser.initialized = true + + if msg.redrawChan != nil { + return m, listenToTerminalRedraw(msg.redrawChan) + } + } + return m, nil + + case terminalRedrawMsg: + if m.state == StateSFTPBrowser && m.sftpBrowser != nil && m.sftpBrowser.terminalRedraw != nil { + return m, listenToTerminalRedraw(m.sftpBrowser.terminalRedraw) + } + return m, nil + + case sftpErrorMsg: + if m.state == StateSFTPBrowser && m.sftpBrowser != nil { + m.sftpBrowser.err = msg.err + m.sftpBrowser.isBusy = false + m.sftpBrowser.statusMsg = "" + m.errorMsg = msg.err.Error() + } + return m, nil + + case sftpListMsg: + if m.state == StateSFTPBrowser && m.sftpBrowser != nil { + m.sftpBrowser.isBusy = false + if strings.HasPrefix(m.sftpBrowser.statusMsg, "Reading") { + m.sftpBrowser.statusMsg = "" + } + if msg.panel == LocalPanel { + m.sftpBrowser.localItems = msg.items + if m.sftpBrowser.localIdx >= len(msg.items) { + m.sftpBrowser.localIdx = 0 + } + } else { + m.sftpBrowser.remoteItems = msg.items + if m.sftpBrowser.remoteIdx >= len(msg.items) { + m.sftpBrowser.remoteIdx = 0 + } + } + } + return m, nil + + case sftpProgressMsg: + if m.state == StateSFTPBrowser && m.sftpBrowser != nil { + m.sftpBrowser.lastProgress = msg + if msg.Done { + m.sftpBrowser.isBusy = false + if msg.Err != nil { + m.sftpBrowser.statusMsg = fmt.Sprintf("Error: %v", msg.Err) + } else { + m.sftpBrowser.statusMsg = msg.Message + } + return m, m.refreshSFTP() + } else { + m.sftpBrowser.statusMsg = msg.Message + return m, listenToProgress(m.sftpBrowser.progressChan) + } + } + return m, nil + + case spinner.TickMsg: + if m.state == StateSFTPBrowser && m.sftpBrowser != nil { + m.sftpBrowser.spinner, cmd = m.sftpBrowser.spinner.Update(msg) + return m, cmd + } + } + + // Route based on active state + switch m.state { + case StateServerList: + return m.updateServerList(msg) + case StateServerForm: + return m.updateServerForm(msg) + case StateSFTPBrowser: + return m.updateSFTPBrowser(msg) + } + + return m, nil +} + +func (m *MainModel) refreshSFTP() tea.Cmd { + return tea.Batch( + func() tea.Msg { + items, err := getLocalItems(m.sftpBrowser.localDir) + if err != nil { + return sftpErrorMsg{err: err} + } + return sftpListMsg{panel: LocalPanel, dir: m.sftpBrowser.localDir, items: items} + }, + func() tea.Msg { + items, err := getRemoteItems(m.sftpBrowser.sftpClient, m.sftpBrowser.remoteDir) + if err != nil { + return sftpErrorMsg{err: err} + } + return sftpListMsg{panel: RemotePanel, dir: m.sftpBrowser.remoteDir, items: items} + }, + ) +} + +func (m MainModel) updateServerList(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + // Clear notifications on keypress + m.errorMsg = "" + m.successMsg = "" + + if m.confirmDelete { + switch msg.String() { + case "y", "Y": + m.confirmDelete = false + if len(m.servers) > 0 { + alias := m.servers[m.selectedIdx].Alias + m.servers = append(m.servers[:m.selectedIdx], m.servers[m.selectedIdx+1:]...) + if err := config.SaveServers(m.servers); err != nil { + m.errorMsg = "Failed to save configuration: " + err.Error() + } else { + m.successMsg = fmt.Sprintf("Removed server '%s'.", alias) + } + if m.selectedIdx >= len(m.servers) && m.selectedIdx > 0 { + m.selectedIdx = len(m.servers) - 1 + } + } + default: + m.confirmDelete = false + } + return m, nil + } + + switch msg.String() { + case "left", "h": + if m.selectedIdx > 0 { + m.selectedIdx-- + } + + case "right", "l": + if m.selectedIdx < len(m.servers)-1 { + m.selectedIdx++ + } + + case "up", "k": + numCols := m.getGridCols() + if m.selectedIdx >= numCols { + m.selectedIdx -= numCols + } else { + m.selectedIdx = 0 + } + + case "down", "j": + numCols := m.getGridCols() + if m.selectedIdx+numCols < len(m.servers) { + m.selectedIdx += numCols + } else if m.selectedIdx < len(m.servers)-1 { + m.selectedIdx = len(m.servers) - 1 + } + + case "enter", "c", "u": + if len(m.servers) == 0 { + return m, nil + } + server := m.servers[m.selectedIdx] + m.sftpBrowser = NewSFTPBrowser(server) + m.sftpBrowser.width = m.width + m.sftpBrowser.height = m.height + m.state = StateSFTPBrowser + return m, tea.Batch( + m.sftpBrowser.spinner.Tick, + connectSFTP(server, m.width, m.height), + ) + + case "a": + m.state = StateServerForm + m.initForm(ModeAdd, nil) + + case "e": + if len(m.servers) == 0 { + return m, nil + } + m.state = StateServerForm + m.editingServer = m.selectedIdx + server := m.servers[m.selectedIdx] + m.initForm(ModeEdit, &server) + + case "d", "backspace": + if len(m.servers) > 0 { + m.confirmDelete = true + } + + case "q", "ctrl+c": + return m, tea.Quit + } + } + return m, nil +} + +func (m MainModel) updateServerForm(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc": + m.state = StateServerList + return m, nil + + case "tab", "down": + m.formInputs[m.formActiveField].Blur() + m.formActiveField = (m.formActiveField + 1) % len(m.formInputs) + m.formInputs[m.formActiveField].Focus() + return m, nil + + case "shift+tab", "up": + m.formInputs[m.formActiveField].Blur() + m.formActiveField = (m.formActiveField - 1 + len(m.formInputs)) % len(m.formInputs) + m.formInputs[m.formActiveField].Focus() + return m, nil + + case "enter": + // Validate fields + alias := strings.TrimSpace(m.formInputs[0].Value()) + host := strings.TrimSpace(m.formInputs[1].Value()) + portStr := strings.TrimSpace(m.formInputs[2].Value()) + user := strings.TrimSpace(m.formInputs[3].Value()) + password := m.formInputs[4].Value() + projectPath := strings.TrimSpace(m.formInputs[5].Value()) + privateKeyPath := strings.TrimSpace(m.formInputs[6].Value()) + useSSHAgentVal := strings.ToLower(strings.TrimSpace(m.formInputs[7].Value())) + useSSHAgent := useSSHAgentVal == "y" || useSSHAgentVal == "yes" || useSSHAgentVal == "true" + + if alias == "" || host == "" || user == "" { + m.errorMsg = "Alias, Host, and Username are required fields." + return m, nil + } + + if !useSSHAgent && privateKeyPath == "" && password == "" { + m.errorMsg = "Password is required when not using SSH Agent or Private Key." + return m, nil + } + + port, err := strconv.Atoi(portStr) + if err != nil || port <= 0 || port > 65535 { + m.errorMsg = "Invalid port number. Must be between 1 and 65535." + return m, nil + } + + // Add or update + newServer := config.Server{ + Alias: alias, + Host: host, + User: user, + Password: password, + Port: port, + ProjectPath: projectPath, + PrivateKeyPath: privateKeyPath, + UseSSHAgent: useSSHAgent, + } + + if m.formMode == ModeAdd { + m.servers = append(m.servers, newServer) + m.selectedIdx = len(m.servers) - 1 + m.successMsg = fmt.Sprintf("Added server '%s' successfully.", alias) + } else { + m.servers[m.editingServer] = newServer + m.selectedIdx = m.editingServer + m.successMsg = fmt.Sprintf("Updated server '%s' successfully.", alias) + } + + if err := config.SaveServers(m.servers); err != nil { + m.errorMsg = "Failed to save configuration: " + err.Error() + } + + m.state = StateServerList + return m, nil + } + } + + // Route keys to active textinput + var cmd tea.Cmd + m.formInputs[m.formActiveField], cmd = m.formInputs[m.formActiveField].Update(msg) + return m, cmd +} + +func (m MainModel) updateSFTPBrowser(msg tea.Msg) (tea.Model, tea.Cmd) { + b := m.sftpBrowser + if b == nil { + m.state = StateServerList + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + // Clear transfer status notifications on keypress + if !b.isBusy && b.activePanel != TerminalPanel { + if strings.HasPrefix(b.statusMsg, "Successfully") || strings.HasPrefix(b.statusMsg, "Error") { + b.statusMsg = "" + } + } + + if b.deleteConfirm { + switch msg.String() { + case "y", "Y": + b.deleteConfirm = false + b.isBusy = true + b.statusMsg = fmt.Sprintf("Deleting %s...", b.deleteItem.Name) + isLocal := b.activePanel == LocalPanel + path := b.deletePath + name := b.deleteItem.Name + return m, func() tea.Msg { + var err error + if isLocal { + err = os.RemoveAll(path) + } else { + err = removeRemoteAll(b.sftpClient, b.sshClient, path) + } + if err != nil { + return sftpProgressMsg{ + Done: true, + Err: fmt.Errorf("failed to delete %s: %w", name, err), + } + } + return sftpProgressMsg{ + Done: true, + Message: fmt.Sprintf("Successfully deleted %s", name), + } + } + case "n", "N", "esc", "q": + b.deleteConfirm = false + return m, nil + } + return m, nil + } + + // Global resize key bindings (available even in terminal panel via ctrl+up/down) + switch msg.String() { + case "ctrl+up": + if b.activePanel == TerminalPanel { + if b.terminalHeightPct < 80 { + b.terminalHeightPct += 5 + m.handleTerminalResize() + } + } else { + // Make file panels taller -> make terminal shorter + if b.terminalHeightPct > 15 { + b.terminalHeightPct -= 5 + m.handleTerminalResize() + } + } + return m, nil + case "ctrl+down": + if b.activePanel == TerminalPanel { + if b.terminalHeightPct > 15 { + b.terminalHeightPct -= 5 + m.handleTerminalResize() + } + } else { + // Make file panels shorter -> make terminal taller + if b.terminalHeightPct < 80 { + b.terminalHeightPct += 5 + m.handleTerminalResize() + } + } + return m, nil + case "ctrl+right": + if b.localWidthPct < 80 { + b.localWidthPct += 5 + } + return m, nil + case "ctrl+left": + if b.localWidthPct > 20 { + b.localWidthPct -= 5 + } + return m, nil + } + + if b.activePanel == TerminalPanel { + if msg.Type == tea.KeyCtrlT { + b.activePanel = LocalPanel + return m, nil + } + + inputStr := keyMsgToTerminalInput(msg) + if inputStr != "" && b.terminalIn != nil { + b.terminalIn.Write([]byte(inputStr)) + } + return m, nil + } + + if b.isBusy { + // Disable inputs while transfer or connection is running + if msg.String() == "ctrl+c" { + // Allow exit + b.isBusy = false + if b.terminalSession != nil { + b.terminalSession.Close() + } + if b.sftpClient != nil { + b.sftpClient.Close() + } + if b.sshClient != nil { + b.sshClient.Close() + } + m.state = StateServerList + } + return m, nil + } + + switch msg.String() { + case "+", "=", "]": + if b.activePanel == LocalPanel { + if b.localWidthPct < 80 { + b.localWidthPct += 5 + } + } else if b.activePanel == RemotePanel { + if b.localWidthPct > 20 { + b.localWidthPct -= 5 + } + } + return m, nil + + case "-", "[": + if b.activePanel == LocalPanel { + if b.localWidthPct > 20 { + b.localWidthPct -= 5 + } + } else if b.activePanel == RemotePanel { + if b.localWidthPct < 80 { + b.localWidthPct += 5 + } + } + return m, nil + + case "q", "esc": + if b.terminalSession != nil { + b.terminalSession.Close() + } + if b.sftpClient != nil { + b.sftpClient.Close() + } + if b.sshClient != nil { + b.sshClient.Close() + } + m.state = StateServerList + return m, nil + + case "ctrl+t": + if b.terminalEmu != nil { + b.activePanel = TerminalPanel + } + return m, nil + + case "tab", "left", "right": + if b.activePanel == LocalPanel { + b.activePanel = RemotePanel + } else { + b.activePanel = LocalPanel + } + + case "up", "k": + if b.activePanel == LocalPanel { + if b.localIdx > 0 { + b.localIdx-- + } + } else { + if b.remoteIdx > 0 { + b.remoteIdx-- + } + } + + case "down", "j": + if b.activePanel == LocalPanel { + if b.localIdx < len(b.localItems)-1 { + b.localIdx++ + } + } else { + if b.remoteIdx < len(b.remoteItems)-1 { + b.remoteIdx++ + } + } + + case "backspace": + if b.activePanel == LocalPanel { + parent := filepath.Dir(b.localDir) + if parent != b.localDir { + b.localDir = parent + items, err := getLocalItems(b.localDir) + if err == nil { + b.localItems = items + b.localIdx = 0 + } + } + } else { + parent := remoteDirUp(b.remoteDir) + if parent != b.remoteDir { + b.remoteDir = parent + b.isBusy = true + b.statusMsg = "Reading remote directory..." + return m, func() tea.Msg { + items, err := getRemoteItems(b.sftpClient, b.remoteDir) + if err != nil { + return sftpErrorMsg{err: err} + } + return sftpListMsg{panel: RemotePanel, dir: b.remoteDir, items: items} + } + } + } + + case "enter": + if b.activePanel == LocalPanel { + if len(b.localItems) == 0 { + return m, nil + } + item := b.localItems[b.localIdx] + if item.IsDir { + if item.Name == ".." { + b.localDir = filepath.Dir(b.localDir) + } else { + b.localDir = filepath.Join(b.localDir, item.Name) + } + items, err := getLocalItems(b.localDir) + if err == nil { + b.localItems = items + b.localIdx = 0 + } + } + } else { + if len(b.remoteItems) == 0 { + return m, nil + } + item := b.remoteItems[b.remoteIdx] + if item.IsDir { + if item.Name == ".." { + b.remoteDir = remoteDirUp(b.remoteDir) + } else { + b.remoteDir = remoteJoin(b.remoteDir, item.Name) + } + b.isBusy = true + b.statusMsg = "Reading remote directory..." + return m, func() tea.Msg { + items, err := getRemoteItems(b.sftpClient, b.remoteDir) + if err != nil { + return sftpErrorMsg{err: err} + } + return sftpListMsg{panel: RemotePanel, dir: b.remoteDir, items: items} + } + } + } + + case "u": // Upload file from local to remote + if len(b.localItems) == 0 { + return m, nil + } + item := b.localItems[b.localIdx] + if item.Name == ".." { + return m, nil + } + + localPath := filepath.Join(b.localDir, item.Name) + remotePath := remoteJoin(b.remoteDir, item.Name) + + b.isBusy = true + b.statusMsg = fmt.Sprintf("Uploading %s...", item.Name) + return m, b.startUpload(localPath, remotePath, item.Name) + + case "d": // Download file from remote to local + if len(b.remoteItems) == 0 { + return m, nil + } + item := b.remoteItems[b.remoteIdx] + if item.Name == ".." { + return m, nil + } + + remotePath := remoteJoin(b.remoteDir, item.Name) + localPath := filepath.Join(b.localDir, item.Name) + + b.isBusy = true + b.statusMsg = fmt.Sprintf("Downloading %s...", item.Name) + return m, b.startDownload(remotePath, localPath, item.Name) + + case "x", "delete": + if b.activePanel == LocalPanel { + if len(b.localItems) == 0 { + return m, nil + } + item := b.localItems[b.localIdx] + if item.Name == ".." { + return m, nil + } + b.deleteConfirm = true + b.deleteItem = item + b.deletePath = filepath.Join(b.localDir, item.Name) + } else { + if len(b.remoteItems) == 0 { + return m, nil + } + item := b.remoteItems[b.remoteIdx] + if item.Name == ".." { + return m, nil + } + b.deleteConfirm = true + b.deleteItem = item + b.deletePath = remoteJoin(b.remoteDir, item.Name) + } + return m, nil + } + } + + return m, nil +} + +func (m MainModel) View() string { + switch m.state { + case StateServerList: + return m.viewServerList() + case StateServerForm: + return m.viewServerForm() + case StateSFTPBrowser: + b := m.sftpBrowser + if b != nil { + if b.deleteConfirm { + return m.renderDeleteConfirmModal(b) + } + if b.isBusy { + return m.renderProgressModal(b) + } + } + return m.viewSFTPBrowser() + } + return "" +} + +func (m MainModel) renderDeleteConfirmModal(b *SFTPBrowser) string { + content := fmt.Sprintf( + "%s\n\n%s\n%s\n\n%s", + styleModalTitle.Render("■ CONFIRM DELETE"), + styleModalProgressMsg.Render("Are you sure you want to permanently delete:"), + lipgloss.NewStyle().Foreground(colorRed).Bold(true).Render(b.deleteItem.Name), + lipgloss.NewStyle().Foreground(colorMuted).Render("[y] Yes, Delete [n/esc] Cancel"), + ) + modalBox := styleModal.Render(content) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modalBox) +} + +func (m MainModel) renderProgressModal(b *SFTPBrowser) string { + title := "■ SYSTEM BUSY" + msg := b.statusMsg + var progressView string + + if strings.HasPrefix(b.statusMsg, "Connecting") { + title = "■ CONNECTING TO VPS" + msg = fmt.Sprintf("Establishing connection to %s...\nPlease wait.", b.Server.Alias) + progressView = fmt.Sprintf("\n%s\n", b.spinner.View()) + } else if strings.HasPrefix(b.statusMsg, "Reading") { + title = "■ READING DIRECTORY" + msg = fmt.Sprintf("Fetching remote file list...\nPath: %s", b.remoteDir) + progressView = fmt.Sprintf("\n%s\n", b.spinner.View()) + } else if strings.HasPrefix(b.statusMsg, "Deleting") { + title = "■ DELETING ITEM" + progressView = fmt.Sprintf("\n%s\n", b.spinner.View()) + } else if strings.HasPrefix(b.statusMsg, "Uploading") { + title = "■ UPLOADING FILE(S)" + bar := drawProgressBar(b.lastProgress.Percent, 44) + progressView = fmt.Sprintf("\n%s %.1f%%\n", lipgloss.NewStyle().Foreground(colorPurple).Render(bar), b.lastProgress.Percent*100) + } else if strings.HasPrefix(b.statusMsg, "Downloading") { + title = "■ DOWNLOADING FILE(S)" + bar := drawProgressBar(b.lastProgress.Percent, 44) + progressView = fmt.Sprintf("\n%s %.1f%%\n", lipgloss.NewStyle().Foreground(colorPurple).Render(bar), b.lastProgress.Percent*100) + } else { + // Generic busy fallback + progressView = fmt.Sprintf("\n%s\n", b.spinner.View()) + } + + content := fmt.Sprintf( + "%s\n\n%s\n%s\n\n%s", + styleModalTitle.Render(title), + styleModalProgressMsg.Render(msg), + progressView, + lipgloss.NewStyle().Foreground(colorMuted).Render("ctrl+c to cancel / interrupt"), + ) + modalBox := styleModal.Render(content) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modalBox) +} + +func (m MainModel) viewServerList() string { + var b strings.Builder + + b.WriteString(styleTitle.Render("■ SSH MANAGER")) + b.WriteString(styleSubtitle.Render("Select a server to connect or manage files")) + + // Display notifications + if m.errorMsg != "" { + b.WriteString("\n") + b.WriteString(styleErrorMsg.Render("✖ " + m.errorMsg)) + b.WriteString("\n") + } else if m.successMsg != "" { + b.WriteString("\n") + b.WriteString(styleStatusMsg.Render("✔ " + m.successMsg)) + b.WriteString("\n") + } else { + b.WriteString("\n\n") + } + + if len(m.servers) == 0 { + b.WriteString(styleHelp.Render("No servers saved yet.\nPress 'a' to add a server, or 'q' to quit.")) + return b.String() + } + + var cards []string + for i, s := range m.servers { + var cardContent strings.Builder + + // Determine if server matches the current folder and calculate space for alias + matchTag := "" + aliasSpace := 34 + if s.ProjectPath != "" && s.ProjectPath == m.cwd { + matchTag = " [CURRENT PROJECT]" + aliasSpace = 34 - len(matchTag) + } + + truncatedAlias := truncateMiddle(s.Alias, aliasSpace) + cardContent.WriteString(fmt.Sprintf("%s%s\n", styleServerAlias.Render(truncatedAlias), styleStatusMsg.Render(matchTag))) + + sshInfo := fmt.Sprintf("%s@%s:%d", s.User, s.Host, s.Port) + truncatedSSHInfo := truncateMiddle(sshInfo, 29) + cardContent.WriteString(styleServerDetails.Render("SSH: " + truncatedSSHInfo + "\n")) + + if s.ProjectPath != "" { + truncatedPath := truncateMiddle(s.ProjectPath, 28) + cardContent.WriteString(styleServerDetails.Render("Path: ") + styleProjectPath.Render(truncatedPath)) + } else { + cardContent.WriteString(styleServerDetails.Render("Path: (none)")) + } + + var renderedCard string + if i == m.selectedIdx { + renderedCard = styleServerCardSelected.Render(cardContent.String()) + } else { + renderedCard = styleServerCard.Render(cardContent.String()) + } + cards = append(cards, renderedCard) + } + + // Layout in a grid/columns + numCols := m.getGridCols() + var rows []string + for i := 0; i < len(cards); i += numCols { + end := i + numCols + if end > len(cards) { + end = len(cards) + } + row := lipgloss.JoinHorizontal(lipgloss.Top, cards[i:end]...) + rows = append(rows, row) + } + grid := lipgloss.JoinVertical(lipgloss.Left, rows...) + b.WriteString(grid) + b.WriteString("\n") + + if m.confirmDelete { + b.WriteString(styleErrorMsg.Render("\nConfirm deletion of selected server? [y/N]: ")) + b.WriteString("\n") + } else { + b.WriteString(styleHelp.Render("enter/c: connect • u: file transfer (sftp) • a: add • e: edit • d/backspace: delete • q: quit")) + } + + return b.String() +} + +func (m MainModel) viewServerForm() string { + var b strings.Builder + + titleText := "ADD NEW SERVER" + if m.formMode == ModeEdit { + titleText = "EDIT SERVER CONFIGURATION" + } + + var formContent strings.Builder + formContent.WriteString(styleFormTitle.Render(titleText)) + formContent.WriteString("\n\n") + + labels := []string{ + "Alias:", + "Host / IP:", + "Port:", + "Username:", + "Password / Passphrase:", + "Project Path:", + "Private Key Path:", + "Use SSH Agent (y/n):", + } + for i, input := range m.formInputs { + label := styleFormLabel.Render(labels[i]) + var inputStr string + if i == m.formActiveField { + inputStr = styleFormInputActive.Render(input.View()) + } else { + inputStr = styleFormInputMuted.Render(input.View()) + } + formContent.WriteString(fmt.Sprintf("%s %s\n", label, inputStr)) + } + + if m.errorMsg != "" { + formContent.WriteString("\n") + formContent.WriteString(styleErrorMsg.Render("✖ " + m.errorMsg)) + formContent.WriteString("\n") + } + + formContent.WriteString(styleHelp.Render("\ntab/down: next field • up: prev field • enter: save • esc: cancel")) + + b.WriteString(styleFormBorder.Render(formContent.String())) + return b.String() +} + +func (m MainModel) viewSFTPBrowser() string { + b := m.sftpBrowser + if b == nil { + return "" + } + + var builder strings.Builder + builder.WriteString(styleTitle.Render(fmt.Sprintf("■ FILE MANAGER & TERMINAL — %s", b.Server.Alias))) + + statusText := b.statusMsg + if b.isBusy { + statusText = fmt.Sprintf("%s %s", b.spinner.View(), b.statusMsg) + } + + if b.err != nil { + builder.WriteString("\n") + builder.WriteString(styleErrorMsg.Render("✖ " + b.err.Error())) + builder.WriteString("\n") + } else { + builder.WriteString("\n") + builder.WriteString(styleStatusMsg.Render(statusText)) + builder.WriteString("\n") + builder.WriteString("\n") + } + + // Calculate heights + availHeight := m.height - 8 + if availHeight < 10 { + availHeight = 10 + } + termHeight := availHeight * b.terminalHeightPct / 100 + if termHeight < 5 { + termHeight = 5 + } + if termHeight > availHeight-6 { + termHeight = availHeight - 6 + } + panelHeight := availHeight - termHeight + if panelHeight < 6 { + panelHeight = 6 + } + + // Calculate panel widths based on localWidthPct + availWidth := m.width - 10 + if availWidth < 40 { + availWidth = 40 + } + leftPanelWidth := availWidth * b.localWidthPct / 100 + rightPanelWidth := availWidth - leftPanelWidth + + if leftPanelWidth < 20 { + leftPanelWidth = 20 + } + if rightPanelWidth < 20 { + rightPanelWidth = 20 + } + + // Render Local Panel + var localLines []string + localLines = append(localLines, lipgloss.NewStyle().Bold(true).Foreground(colorPurple).Render("■ LOCAL DIRECTORY:")) + localLines = append(localLines, styleServerDetails.Render(b.localDir)) + localLines = append(localLines, "") + + if b.initialized { + localLines = append(localLines, renderFileList(b.localItems, b.localIdx, panelHeight)...) + } else { + localLines = append(localLines, " Connecting...") + } + + localView := lipgloss.JoinVertical(lipgloss.Left, localLines...) + leftStyle := styleFilePanel.Copy().Width(leftPanelWidth).Height(panelHeight) + if b.activePanel == LocalPanel { + leftStyle = styleFilePanelActive.Copy().Width(leftPanelWidth).Height(panelHeight) + } + leftPanel := leftStyle.Render(localView) + + // Render Remote Panel + var remoteLines []string + remoteLines = append(remoteLines, lipgloss.NewStyle().Bold(true).Foreground(colorBlue).Render("■ REMOTE VPS DIRECTORY:")) + remoteLines = append(remoteLines, styleServerDetails.Render(b.remoteDir)) + remoteLines = append(remoteLines, "") + + if b.initialized { + remoteLines = append(remoteLines, renderFileList(b.remoteItems, b.remoteIdx, panelHeight)...) + } else { + remoteLines = append(remoteLines, " Connecting...") + } + + remoteView := lipgloss.JoinVertical(lipgloss.Left, remoteLines...) + rightStyle := styleFilePanel.Copy().Width(rightPanelWidth).Height(panelHeight) + if b.activePanel == RemotePanel { + rightStyle = styleFilePanelActive.Copy().Width(rightPanelWidth).Height(panelHeight) + } + rightPanel := rightStyle.Render(remoteView) + + panes := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel) + builder.WriteString(panes) + builder.WriteString("\n\n") + + // Render Terminal Panel + var termContent string + if b.terminalEmu != nil { + if b.activePanel == TerminalPanel { + pos := b.terminalEmu.CursorPosition() + w, h := b.terminalEmu.Width(), b.terminalEmu.Height() + if pos.X >= 0 && pos.X < w && pos.Y >= 0 && pos.Y < h { + oldCell := b.terminalEmu.CellAt(pos.X, pos.Y) + var oldCellClone *uv.Cell + if oldCell != nil { + oldCellClone = oldCell.Clone() + } + + // Create block cursor style + cursorCell := &uv.Cell{ + Content: " ", + Width: 1, + } + if oldCell != nil { + if oldCell.Content != "" { + cursorCell.Content = oldCell.Content + } + cursorCell.Style = oldCell.Style + } + cursorCell.Style.Bg = color.RGBA{R: 203, G: 166, B: 247, A: 255} + cursorCell.Style.Fg = color.RGBA{R: 0, G: 0, B: 0, A: 255} + cursorCell.Style.Attrs &^= uv.AttrReverse + + b.terminalEmu.SetCell(pos.X, pos.Y, cursorCell) + termContent = b.terminalEmu.Render() + b.terminalEmu.SetCell(pos.X, pos.Y, oldCellClone) + } else { + termContent = b.terminalEmu.Render() + } + } else { + termContent = b.terminalEmu.Render() + } + } else { + termContent = "Initializing remote terminal session..." + } + + termWidth := m.width - 8 + if termWidth < 20 { + termWidth = 20 + } + + termStyle := styleTerminalPanel.Copy().Width(termWidth).Height(termHeight) + if b.activePanel == TerminalPanel { + termStyle = styleTerminalPanelActive.Copy().Width(termWidth).Height(termHeight) + } + + termLines := []string{ + lipgloss.NewStyle().Bold(true).Foreground(colorPurple).Render("■ REMOTE SSH TERMINAL (Ctrl+T to toggle focus):"), + termContent, + } + termView := termStyle.Render(lipgloss.JoinVertical(lipgloss.Left, termLines...)) + builder.WriteString(termView) + builder.WriteString("\n\n") + + var helpText string + if b.activePanel == TerminalPanel { + helpText = "ctrl+t: focus files • ctrl+up/down: resize height • ctrl+c: interrupt • ctrl+d: close • q/esc: disconnect & exit" + } else { + helpText = "tab/←/→: switch panel • ctrl+t: focus term • +/-/[]: resize • up/down: select • enter: open • backspace: up • u: upload • d: download • x/del: delete • q/esc: exit" + } + builder.WriteString(styleHelp.Render(helpText)) + + return builder.String() +} + +func renderFileList(items []FileItem, selectedIdx int, height int) []string { + if len(items) == 0 { + return []string{" (empty directory)"} + } + + maxLines := height - 4 + if maxLines <= 0 { + maxLines = 10 + } + + start := 0 + if selectedIdx >= maxLines { + start = selectedIdx - maxLines + 1 + } + + end := start + maxLines + if end > len(items) { + end = len(items) + } + + var lines []string + for idx := start; idx < end; idx++ { + item := items[idx] + name := item.Name + if item.IsDir && name != ".." { + name += "/" + } + + var icon string + if item.IsDir { + icon = styleIconDir.Render("■") + } else { + icon = styleIconFile.Render("•") + } + + var rendered string + if idx == selectedIdx { + prefix := lipgloss.NewStyle().Foreground(colorPurple).Bold(true).Render("→ ") + if item.IsDir { + rendered = prefix + icon + " " + styleFileSelected.Render(name) + } else { + rendered = prefix + icon + " " + styleFileSelected.Render(fmt.Sprintf("%s (%s)", name, formatSize(item.Size))) + } + } else { + prefix := " " + if item.IsDir { + rendered = prefix + icon + " " + styleFileDir.Render(name) + } else { + rendered = prefix + icon + " " + styleFileRegular.Render(fmt.Sprintf("%s (%s)", name, formatSize(item.Size))) + } + } + lines = append(lines, rendered) + } + return lines +} + +func Run() error { + servers, err := config.LoadServers() + if err != nil { + return fmt.Errorf("error loading servers: %w", err) + } + + cwd, _ := os.Getwd() + + initialIdx := 0 + // Try to match current working directory with a server's ProjectPath + for i, s := range servers { + if s.ProjectPath != "" && s.ProjectPath == cwd { + initialIdx = i + break + } + } + + m := MainModel{ + state: StateServerList, + servers: servers, + selectedIdx: initialIdx, + cwd: cwd, + width: 80, + height: 24, + } + + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err = p.Run() + return err +} + +func drawProgressBar(percent float64, width int) string { + if percent < 0 { + percent = 0 + } + if percent > 1 { + percent = 1 + } + + barWidth := width - 8 + if barWidth <= 0 { + return "" + } + + filledLength := int(percent * float64(barWidth)) + emptyLength := barWidth - filledLength + + filled := strings.Repeat("█", filledLength) + empty := strings.Repeat("░", emptyLength) + + return fmt.Sprintf("[%s%s] %3.0f%%", filled, empty, percent*100) +} + +func keyMsgToTerminalInput(msg tea.KeyMsg) string { + switch msg.Type { + case tea.KeyRunes: + return string(msg.Runes) + case tea.KeySpace: + return " " + case tea.KeyEnter: + return "\r" + case tea.KeyBackspace: + return "\x7f" + case tea.KeyTab: + return "\t" + case tea.KeyEscape: + return "\x1b" + case tea.KeyUp: + return "\x1b[A" + case tea.KeyDown: + return "\x1b[B" + case tea.KeyRight: + return "\x1b[C" + case tea.KeyLeft: + return "\x1b[D" + case tea.KeyCtrlA: + return "\x01" + case tea.KeyCtrlB: + return "\x02" + case tea.KeyCtrlC: + return "\x03" + case tea.KeyCtrlD: + return "\x04" + case tea.KeyCtrlE: + return "\x05" + case tea.KeyCtrlF: + return "\x06" + case tea.KeyCtrlG: + return "\x07" + case tea.KeyCtrlH: + return "\x08" + case tea.KeyCtrlJ: + return "\x0a" + case tea.KeyCtrlK: + return "\x0b" + case tea.KeyCtrlL: + return "\x0c" + case tea.KeyCtrlN: + return "\x0e" + case tea.KeyCtrlO: + return "\x0f" + case tea.KeyCtrlP: + return "\x10" + case tea.KeyCtrlQ: + return "\x11" + case tea.KeyCtrlR: + return "\x12" + case tea.KeyCtrlS: + return "\x13" + case tea.KeyCtrlT: + return "" + case tea.KeyCtrlU: + return "\x15" + case tea.KeyCtrlV: + return "\x16" + case tea.KeyCtrlW: + return "\x17" + case tea.KeyCtrlX: + return "\x18" + case tea.KeyCtrlY: + return "\x19" + case tea.KeyCtrlZ: + return "\x1a" + } + return "" +} + +func (m *MainModel) handleTerminalResize() { + b := m.sftpBrowser + if b == nil { + return + } + availHeight := m.height - 8 + if availHeight < 10 { + availHeight = 10 + } + termHeight := availHeight * b.terminalHeightPct / 100 + if termHeight < 5 { + termHeight = 5 + } + if termHeight > availHeight-6 { + termHeight = availHeight - 6 + } + termWidth := m.width - 8 + if termWidth < 20 { + termWidth = 20 + } + if b.terminalEmu != nil { + b.terminalEmu.Resize(termWidth, termHeight) + } + if b.terminalSession != nil { + _ = b.terminalSession.WindowChange(termHeight, termWidth) + } +} + +func (m MainModel) getGridCols() int { + cardWidth := 38 + cardGap := 2 + numCols := m.width / (cardWidth + cardGap) + if numCols <= 0 { + return 1 + } + return numCols +} + +func truncateMiddle(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen <= 5 { + return "..." + } + half := (maxLen - 3) / 2 + return s[:half] + "..." + s[len(s)-half:] +}