diff --git a/.github/assets/info.png b/.github/assets/info.png new file mode 100644 index 0000000..dd095c7 Binary files /dev/null and b/.github/assets/info.png differ diff --git a/Cargo.lock b/Cargo.lock index 6104bfa..133d54b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,7 +878,7 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "e2s" -version = "0.1.3" +version = "0.1.4" dependencies = [ "aws-config", "aws-sdk-ec2", diff --git a/Cargo.toml b/Cargo.toml index c7b9e02..4144456 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "e2s" -version = "0.1.3" +version = "0.1.4" edition = "2021" # Github Repo diff --git a/README.md b/README.md index 416c218..4220931 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,95 @@ -# πŸš€ EC2 TUI -A blazingly fast Terminal User Interface (TUI) for managing AWS EC2 instances, built with Rust. Seamlessly list your EC2 instances and SSH into them with just a few keystrokes. +# e2s β€” EC2 SSH TUI Manager + +A **blazingly fast Terminal User Interface (TUI)** for managing AWS EC2 instances, built with **Rust**. +Browse, filter, and SSH into your EC2 instances **without leaving the terminal**. ![EC2 TUI Demo](./.github/assets/e2s.png) + + +--- + +## Features +* **Interactive EC2 Dashboard** + Instantly list and navigate all your EC2 instances in a clean, responsive TUI. -## ✨ Features +* **One-Keystroke SSH Access** + SSH into instances using your existing local SSH keysβ€”no copy-pasting needed. -- πŸ“‹ **Interactive Instance List** - View all your EC2 instances in a beautiful TUI -- πŸ” **Quick SSH Access** - Connect to instances using your local SSH keys -- πŸ‘€ **Multi-User Support** - Configure multiple SSH users for different distributions -- βš™οΈ **Configurable** - Easy TOML-based configuration -- ⚑ **Fast & Lightweight** - Built with Rust for optimal performance +* **Multi-User SSH Profiles** + Easily configure multiple SSH users (e.g. `ec2-user`, `ubuntu`, `admin`) for different AMIs. + +* **TOML-Based Configuration** + Simple, readable configuration with sensible defaults. + +* **Fast & Lightweight** + Written in Rust for high performance, low memory usage, and instant startup. + +--- -## πŸ“¦ Installation -[Documentation](https://github.com/sandeshgrangdan/e2s/blob/main/USAGES.md) +## Installation & Usage -## 🀝 Contributing +Get **e2s** up and running in minutes and start connecting to your EC2 instances effortlessly. -Contributions are welcome! Feel free to open issues or submit pull requests on [GitHub](https://github.com/sandeshgrangdan/e2s). +**Guides** +- πŸ‘‰ **[Installation & Usage Guide](https://github.com/sandeshgrangdan/e2s/blob/main/docs/USAGES.md)** + Learn how to install, run, and use `e2s` effectively. +- πŸ‘‰ **[Configuration Guide](https://github.com/sandeshgrangdan/e2s/blob/main/docs/CONFIGURATION.md)** + Customize SSH users, keys, terminal preferences, and more. + +> ⚠️ **Before you start:** +> Ensure your **AWS credentials** and **SSH keys** are properly configured. + +--- + +## How It Works + +1. Fetches EC2 metadata using AWS SDK +2. Displays instances in an interactive TUI +3. Allows SSH access based on instance metadata and your config +4. Keeps everything inside your terminalβ€”no browser needed + +--- -## πŸ“ License +## Contributing -This project is open source and available under the MIT License. +Contributions are **very welcome** +Whether it’s a bug fix, feature request, or documentation improvement: -## πŸ› Issues & Support +* Open an issue +* Submit a pull request +* Share ideas and feedback -If you encounter any issues or have questions, please [open an issue](https://github.com/sandeshgrangdan/e2s/issues) on GitHub. +πŸ‘‰ [GitHub Repository](https://github.com/sandeshgrangdan/e2s) --- -Made with ❀️ and Rust +## Issues & Support + +If you encounter bugs or have questions, please open an issue here: +πŸ‘‰ [Issues](https://github.com/sandeshgrangdan/e2s/issues) + +--- + +## License + +This project is licensed under the **MIT License**. +See the [LICENSE](./LICENSE) file for details. + +--- + +## Acknowledgements + +Built with: + +* πŸ¦€ **Rust** +* 🎨 Terminal UI libraries +* ☁️ AWS SDK + +--- + +**Made with ❀️ and Rust by Sandesh** + diff --git a/USAGES.md b/USAGES.md deleted file mode 100644 index d9bb9dd..0000000 --- a/USAGES.md +++ /dev/null @@ -1,97 +0,0 @@ -## πŸ“¦ Installation - -### Linux & macOS - -```bash -curl --proto '=https' --tlsv1.2 -LsSf https://github.com/sandeshgrangdan/e2s/releases/download/v0.1.2/e2s-installer.sh | sh -``` - -### Windows - -```powershell -powershell -c "irm https://github.com/sandeshgrangdan/e2s/releases/download/v0.1.2/e2s-installer.ps1 | iex" -``` - -### Cargo - -```bash -cargo install e2s -``` - -## πŸ”§ Prerequisites - -Before using EC2 TUI, ensure you have: - -1. **SSH Keys Setup** - Your SSH keys must be configured in `~/.ssh/` directory -2. **AWS Credentials** - Valid AWS credentials configured (via AWS CLI or environment variables) -3. **Network Access** - Ability to reach your EC2 instances via SSH - -## βš™οΈ [Optional] Configuration - -EC2 TUI uses a TOML configuration file located at `~/.config/e2s/config.toml`. - -### Example Configuration - -```toml -[users] -# Default user - will be auto-selected when app starts -# If not specified, the first user in the list will be selected -default_user = "ubuntu" - -# Additional SSH users for different EC2 instances -# Common users for different Linux distributions: -# - Amazon Linux: ec2-user -# - Ubuntu: ubuntu -# - Debian: admin or debian -# - CentOS/RHEL: centos or ec2-user -# - Rocky Linux: rocky -additional_users = [ - "admin", - "root", - "centos", - "debian", - "fedora", - "rocky", - "azureuser", # For Azure VMs - "bitnami", # For Bitnami instances -] - -[keys] -# Use just the filename if the key is in ~/.ssh/ -default_key = "dev-key.pem" -# OR use the full path: -# default_key = "/home/sandesh/.ssh/eclat-dev1.pem" - -# Additional keys from other locations (optional) -additional_keys = [ - "/home/sandesh/custom/another_key.pem", - "~/Documents/keys/work_key" -] -``` - -### Configuration Options - -- **`default_user`** - The SSH user that will be pre-selected when the application starts -- **`additional_users`** - List of additional SSH users to choose from when connecting to instances - -## 🎯 Usage - -Simply run the command: - -```bash -e2s -``` - -The TUI will launch and display all your EC2 instances. Navigate through the list and select an instance to SSH into it. - -## πŸ—ΊοΈ Common SSH Users by Distribution - -| Distribution | Default User | -|-------------|--------------| -| Amazon Linux | `ec2-user` | -| Ubuntu | `ubuntu` | -| Debian | `admin` or `debian` | -| CentOS/RHEL | `centos` or `ec2-user` | -| Rocky Linux | `rocky` | -| Fedora | `fedora` | -| Bitnami AMIs | `bitnami` | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..54d521b --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,357 @@ +# e2s Configuration Guide + +This guide covers all configuration options available in e2s. + +## Configuration File Location + +The configuration file is located at: +- **Linux/macOS:** `~/.config/e2s/config.toml` +- **Windows:** `%APPDATA%\e2s\config.toml` + +## Quick Start + +e2s works without any configuration using sensible defaults. Create a config file only if you want to customize behavior. + +To create a configuration file: + +```bash +mkdir -p ~/.config/e2s +touch ~/.config/e2s/config.toml +``` + +## Complete Configuration Example + +```toml +(optional) +connect_mode = "ssm" # ssm | private | public + # Connection mode: ssm (AWS SSM), private (private IP SSH), public (public IP SSH) + +[users] +# Default user - will be auto-selected when app starts +# If not specified, the first user in the list will be selected +default_user = "ubuntu" + +# Additional SSH users for different EC2 instances +# These will appear as options in the user selection +additional_users = [ + "ec2-user", # Amazon Linux + "admin", # Debian + "ubuntu", # Ubuntu + "centos", # CentOS + "debian", # Debian + "fedora", # Fedora + "rocky", # Rocky Linux + "almalinux", # AlmaLinux + "root", # Root access (use carefully) + "azureuser", # Azure VMs + "bitnami", # Bitnami instances +] + +[keys] +# Default SSH key - can be just filename or full path +# If just filename, e2s will look in ~/.ssh/ directory +default_key = "dev-key.pem" + +# Alternative: Use full path +# default_key = "/home/username/.ssh/my-key.pem" + +# Additional keys from other locations (optional) +# Useful if you store keys in multiple directories +additional_keys = [ + "/home/username/work/production-key.pem", + "~/Documents/keys/staging-key.pem", + "/opt/secure/keys/backup-key.pem", +] + +[terminal] +# Specify which terminal emulator to use +# If not specified, uses same TUI terminal +emulator = "alacritty" +``` + +## Configuration Sections + +### `connect_mode` (optional) – Method used to connect to instances +* **Available options:** + + * `ssm` – Connect using AWS Systems Manager (recommended; works for private and public instances) + * `private` – Connect via SSH using the private IP (**default**) + * `public` – Connect via SSH using the public IP + +### `[users]` (optional) - SSH User Configuration + +Controls which SSH users are available for connecting to instances. + +#### `default_user` +- **Type:** String +- **Default:** First user in `additional_users` list +- **Description:** The SSH user that will be pre-selected when the application starts + +```toml +default_user = "ubuntu" +``` + +#### `additional_users` +- **Type:** Array of strings +- **Default:** Empty list +- **Description:** List of SSH usernames that can be selected when connecting to instances + +```toml +additional_users = ["ec2-user", "ubuntu", "admin"] +``` + +**Common Users by Distribution:** + +| Distribution | User(s) | +|--------------|---------| +| Amazon Linux 2023 | `ec2-user` | +| Amazon Linux 2 | `ec2-user` | +| Ubuntu | `ubuntu` | +| Debian | `admin`, `debian` | +| CentOS Stream | `centos` | +| RHEL | `ec2-user` | +| Rocky Linux | `rocky` | +| AlmaLinux | `almalinux` | +| Fedora | `fedora` | +| SUSE/openSUSE | `ec2-user` | +| Bitnami | `bitnami` | + +### `[keys]` (optional) - SSH Key Configuration + +Manages SSH private keys used for authentication. + +#### `default_key` +- **Type:** String +- **Default:** Auto-detect from `~/.ssh/` +- **Description:** The SSH key that will be tried first when connecting + +**Option 1: Filename only** (key must be in `~/.ssh/`) +```toml +default_key = "my-key.pem" +``` + +**Option 2: Full path** +```toml +default_key = "/home/username/.ssh/production-key.pem" +``` + +**Option 3: Tilde expansion** +```toml +default_key = "~/Documents/keys/my-key.pem" +``` + +#### `additional_keys` +- **Type:** Array of strings +- **Default:** Empty list +- **Description:** Additional SSH keys to try if the default key fails + +```toml +additional_keys = [ + "/opt/keys/backup-key.pem", + "~/secure/emergency-key.pem", +] +``` + +**Key Requirements:** +- Keys must have correct permissions (`chmod 400` or `chmod 600`) +- Supported formats: PEM, OpenSSH +- Both RSA and Ed25519 keys are supported + +### `[terminal]` - Terminal Emulator Configuration + +Specifies which terminal emulator to use for SSH connections. + +#### `emulator` +- **Type:** String +- **Default:** Not set (uses same TUI terminal) +- **Description:** The terminal emulator to launch for SSH sessions + +**Behavior:** + +**Option 1: No emulator specified** (default) +```toml +# [terminal] section omitted or emulator not set +``` +- SSH sessions launch directly in the same terminal window +- You stay within the TUI interface +- After SSH disconnection, you automatically return to the TUI +- **Best for:** Quick connections, staying in one terminal + +**Option 2: Emulator specified** +```toml +[terminal] +emulator = "iterm2" +``` +- SSH sessions launch in a new terminal window +- The TUI remains open in the original terminal +- Each connection opens a separate window +- **Best for:** Multiple simultaneous connections, keeping TUI visible + +#### Supported Terminal Emulators + +| Terminal | Platform | Description | +|----------|----------|-------------| +| `ghostty` | Linux, macOS | Modern, fast GPU-accelerated terminal | +| `alacritty` | All | GPU-accelerated, cross-platform | +| `kitty` | Linux, macOS | GPU-accelerated with advanced features | +| `wezterm` | All | GPU-accelerated with multiplexing | +| `gnome-terminal` | Linux | GNOME desktop default | +| `konsole` | Linux | KDE desktop default | +| `terminator` | Linux | Multiple terminals in one window | +| `tilix` | Linux | Tiling terminal emulator | +| `xterm` | Linux | Classic, lightweight | +| `foot` | Linux | Lightweight Wayland terminal | +| `rio` | All | GPU-accelerated, written in Rust | +| `iterm2` | macOS | Feature-rich macOS terminal | +| `terminal.app` | macOS | macOS default terminal | + +**Note:** If you don't specify a terminal emulator, SSH sessions will run in the same terminal as the TUI, and you'll return to the TUI after disconnecting. + +## Configuration Examples + +### Example 1: AWS Multi-Environment Setup + +```toml +[users] +default_user = "ec2-user" +additional_users = ["ubuntu", "admin", "centos"] + +[keys] +default_key = "production-key.pem" +additional_keys = [ + "~/.ssh/staging-key.pem", + "~/.ssh/development-key.pem", +] + +[terminal] +emulator = "kitty" +``` + +### Example 2: Ubuntu-Focused Setup + +```toml +[users] +default_user = "ubuntu" +additional_users = ["admin", "root"] + +[keys] +default_key = "ubuntu-default.pem" + +[terminal] +emulator = "alacritty" +``` + +### Example 3: Mixed Cloud Provider Setup + +```toml +[users] +default_user = "ec2-user" +additional_users = [ + "ubuntu", + "admin", + "azureuser", # Azure VMs + "opc", # Oracle Cloud +] + +[keys] +default_key = "aws-main.pem" +additional_keys = [ + "/keys/azure-key.pem", + "/keys/oracle-key.pem", +] +``` + +### Example 4: Minimal Configuration (In-TUI SSH) + +```toml +[users] +default_user = "ubuntu" +``` + +This minimal configuration: +- Uses Ubuntu as the default SSH user +- SSH sessions run in the same terminal (no new windows) +- Returns to TUI after disconnecting from SSH +- Auto-detects SSH keys from `~/.ssh/` + +### Example 5: New Window SSH Sessions + +```toml +[users] +default_user = "ec2-user" + +[terminal] +emulator = "kitty" +``` + +This configuration: +- Opens each SSH session in a new Kitty terminal window +- Keeps the TUI visible in the original terminal +- Allows multiple simultaneous connections + +## πŸ” Troubleshooting Configuration + +### Config File Not Found +**Solution:** e2s will use defaults if no config file exists. This is normal and expected. + +### Key Permission Errors +```bash +# Fix key permissions +chmod 400 ~/.ssh/your-key.pem +``` + +### User Authentication Failures +1. Verify the username matches your instance's AMI +2. Check the instance's cloud-init logs: `sudo cat /var/log/cloud-init.log` +3. Try common usernames for your distribution (see table above) + +### Terminal Not Launching +1. If no terminal is configured, SSH runs in the same window (this is expected behavior) +2. To use a separate terminal window, add the `[terminal]` section to your config +3. If a terminal is configured but not launching: + - Verify the terminal is installed: `which kitty` + - Check the terminal is in your system's PATH + - Try a different terminal emulator + +## πŸ”§ Advanced Tips + +### Using Multiple Configurations +You can maintain different configs and switch between them: + +```bash +# Use custom config location +export E2S_CONFIG="/path/to/custom/config.toml" +e2s +``` + +### Key Path Best Practices +1. Store production keys in a secure location (`/opt/secure/keys/`) +2. Set restrictive directory permissions (`chmod 700`) +3. Use descriptive key names (`prod-web-servers.pem`, `staging-db.pem`) + +### Region-Specific Configuration +By default, e2s scans only your default AWS region. To scan specific regions: + +```bash +# Scan a specific region +e2s --region us-west-2 + +# Use a specific AWS profile +e2s --profile production + +# Combine both +e2s --profile production --region eu-central-1 + +# Help +e2s --help +``` + +To change your default region permanently: +```bash +aws configure set region us-east-1 +``` + +#πŸ“š Related Documentation + +- [AWS EC2 SSH Best Practices](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html) +- [SSH Key Permissions Guide](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/connection-prereqs.html) \ No newline at end of file diff --git a/docs/USAGES.md b/docs/USAGES.md new file mode 100644 index 0000000..7f25b03 --- /dev/null +++ b/docs/USAGES.md @@ -0,0 +1,154 @@ +# EC2 TUI (e2s) + +`e2s` is a fast terminal-based tool for discovering AWS EC2 instances and connecting to them via SSH with minimal setup. + +This document focuses on installation and configuration to help you get started quickly. + +### Linux & macOS + +```bash +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/sandeshgrangdan/e2s/releases/download/v0.1.4/e2s-installer.sh | sh +``` + +### Windows + +```powershell +powershell -c "irm https://github.com/sandeshgrangdan/e2s/releases/download/v0.1.4/e2s-installer.ps1 | iex" +``` + +### Cargo + +```bash +cargo install e2s +``` + +## πŸ”§ Prerequisites + +Before using e2s, ensure you have: + +1. **SSH Keys** - Your SSH private keys configured in the `~/.ssh/` directory +2. **AWS Credentials** - Valid AWS credentials configured via: + - AWS CLI (`aws configure`) + - Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) + - IAM role (for EC2 instances) +3. **Network Access** - Security groups allowing SSH access (port 22) to your instances +4. **Permissions** - AWS IAM permissions to describe EC2 instances + +## Quick Start + +Launch the TUI with a single command: + +```bash +e2s +``` + +### Command Line Options + +```bash +# Use default AWS profile and region +e2s + +# Specify AWS profile +e2s --profile production + +# Specify AWS region +e2s --region us-west-2 + +# Combine profile and region +e2s --profile staging --region eu-west-1 + +# Show help +e2s --help +``` + +**Available Options:** +- `-p, --profile ` - Name of your AWS profile (default: uses AWS default profile) +- `-r, --region ` - AWS region to scan (default: uses AWS default region from config) +- `-h, --help` - Display help information +- `-V, --version` - Show version number + +**Note:** By default, e2s scans only your default AWS region. To scan other regions, specify them explicitly using the `--region` flag. + +### Basic Usage + +1. **Launch** - Run `e2s` to see EC2 instances in your default region +2. **Navigate** - Use arrow keys (↑/↓) to move through the instance list +3. **Connect** - Press `Enter` to SSH into the selected instance +4. **Filter** - Start typing to search/filter instances by name or ID +5. **Quit** - Press `q` or `Esc` to exit and return to your terminal + +## Configuration + +e2s works out of the box with sensible defaults, but you can customize its behavior through a configuration file. + +**Configuration Location:** `~/.config/e2s/config.toml` + +For detailed configuration options and examples, see [CONFIGURATION.md](https://github.com/sandeshgrangdan/e2s/blob/main/docs/CONFIGURATION.md). + +### Quick Configuration Example + +```toml +[users] +default_user = "ubuntu" +additional_users = ["ec2-user", "admin", "centos"] + +[keys] +default_key = "my-key.pem" + +[terminal] +emulator = "alacritty" +``` + +## Common SSH Users by Distribution + +| Distribution | Default SSH User | +|--------------|------------------| +| Amazon Linux 2023 | `ec2-user` | +| Amazon Linux 2 | `ec2-user` | +| Ubuntu | `ubuntu` | +| Debian | `admin` or `debian` | +| CentOS/RHEL | `centos` or `ec2-user` | +| Rocky Linux | `rocky` | +| AlmaLinux | `almalinux` | +| Fedora | `fedora` | +| SUSE/openSUSE | `ec2-user` | +| Bitnami AMIs | `bitnami` | + +## Troubleshooting + +### Connection Issues + +**Problem:** "Permission denied (publickey)" +- Ensure the correct SSH key is being used +- Verify the key has correct permissions (`chmod 400 key.pem`) +- Check that you're using the correct username for your instance's AMI + +**Problem:** "Connection timeout" +- Verify security group allows SSH (port 22) from your IP +- Check that the instance is in a running state +- Ensure the instance has a public IP or you're on the same VPC + +**Problem:** "No instances found" +- Verify AWS credentials are configured correctly +- Check that you have EC2 describe permissions +- Ensure you're looking at the correct AWS region + +### Getting Help + +- Check the [Configuration Guide](https://github.com/sandeshgrangdan/e2s/blob/main/docs/CONFIGURATION.md) for setup issues +- Review your AWS credentials: `aws ec2 describe-instances` +- Verify SSH key permissions: `ls -la ~/.ssh/` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +[Add your license here] + +## πŸ”— Links + +- [GitHub Repository](https://github.com/sandeshgrangdan/e2s) +- [Issue Tracker](https://github.com/sandeshgrangdan/e2s/issues) +- [Configuration Guide](https://github.com/sandeshgrangdan/e2s/blob/main/docs/CONFIGURATION.md) \ No newline at end of file diff --git a/e2s.png b/e2s.png deleted file mode 100644 index 019e9a9..0000000 Binary files a/e2s.png and /dev/null differ diff --git a/src/app.rs b/src/app.rs index fa70462..7da0a22 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,8 +3,8 @@ use clap::Parser; use rand::Rng; use crate::app::{ - aws::ec2::{ConnectMode, Ec2Client}, - input::{ssh_keys::SshKeys, ssh_user::SshUsers}, + aws::ec2::{ConnectMode, ConnectModeConfig, Ec2Client}, + input::{ssh_keys::SshKeys, ssh_user::SshUsers, terminal::Config as TerminalConfig}, }; use input::user_input::{ InputMode::{self, Editing, Normal}, @@ -20,7 +20,7 @@ pub mod input; pub enum SelectedTab { List, Details, -} +} // ANCHOR_END: action /// A TUI for managing AWS EC2 SSH access @@ -44,13 +44,15 @@ pub struct App { pub show_help: bool, pub input_mode: InputMode, pub items: Vec, + pub selected_item: Option, pub display_items: Vec, pub state: TableState, pub ec2_client: Ec2Client, pub ssh_keys: SshKeys, pub ssh_user: SshUsers, - pub connect_mode: ConnectMode, - + pub mode: ConnectMode, + pub terminal: TerminalConfig, + pub loading: bool, } // ANCHOR: application_impl @@ -67,11 +69,14 @@ impl App { items: Vec::new(), // items: data_vec, display_items: Vec::new(), + selected_item: None, state: TableState::default().with_selected(0), ec2_client: Ec2Client::None, ssh_keys: SshKeys::load(), ssh_user: SshUsers::load(), - connect_mode: ConnectMode::Public, + terminal: TerminalConfig::load(), + mode: ConnectModeConfig::load(), + loading: false, } } diff --git a/src/app/aws/ec2.rs b/src/app/aws/ec2.rs index fcef9eb..90d6e16 100644 --- a/src/app/aws/ec2.rs +++ b/src/app/aws/ec2.rs @@ -7,6 +7,7 @@ use aws_config::{ use fakeit::internet; use itertools::Itertools; use rand::{seq::SliceRandom, Rng}; +use serde::{Deserialize, Serialize}; use aws_sdk_ec2::Client; @@ -45,13 +46,43 @@ impl Data { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] pub enum ConnectMode { Public, Private, Ssm, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectModeConfig { + pub connect_mode: Option, +} + +impl ConnectModeConfig { + pub fn load() -> ConnectMode { + // Load the full config + let config: Option = crate::utils::config::load_config(); + + if let Some(cfg) = config { + if let Some(mode) = cfg.connect_mode { + match mode.to_lowercase().as_str() { + "public" => ConnectMode::Public, + "private" => ConnectMode::Private, + "ssm" => ConnectMode::Ssm, + _ => ConnectMode::Private, + } + } else { + eprintln!("No Connect Mode config found, using defaults"); + ConnectMode::Private + } + } else { + eprintln!("No Connect Mode config found, using defaults"); + ConnectMode::Private + } + } +} + impl ConnectMode { pub fn next(self) -> Self { match self { @@ -272,63 +303,351 @@ impl App { }) .cloned() .collect() - } - } + }; - pub async fn ssh(&mut self) -> io::Result<()> { if let Some(selected) = self.state.selected() { if let Some(item) = self.display_items.get(selected) { - let mut cmd = match self.connect_mode { - ConnectMode::Public | ConnectMode::Private => { - let key_path = match &self.ssh_keys.selected_key { - Some(key) => key, - None => { - eprintln!("No SSH key selected."); - return Ok(()); - } - }; - let user = self - .ssh_user - .selected_user - .as_deref() - .unwrap_or("ec2-user"); - let ip = match self.connect_mode { - ConnectMode::Public => &item.public_ipv4, - ConnectMode::Private => &item.private_ipv4, - _ => unreachable!(), - }; - let mut ssh_cmd = Command::new("ssh"); - ssh_cmd.args(["-i", key_path]); - ssh_cmd.arg(format!("{}@{}", user, ip)); - ssh_cmd + self.selected_item = Some(item.clone()); + } else { + self.selected_item = None; + } + }; + } + + pub async fn ssh(&mut self) -> io::Result<()> { + if let Some(item) = &self.selected_item { + let mut cmd = match self.mode { + ConnectMode::Public | ConnectMode::Private => { + let key_path = match &self.ssh_keys.selected_key { + Some(key) => key, + None => { + eprintln!("No SSH key selected."); + return Ok(()); + } + }; + let user = self.ssh_user.selected_user.as_deref().unwrap_or("ec2-user"); + let ip = match self.mode { + ConnectMode::Public => &item.public_ipv4, + ConnectMode::Private => &item.private_ipv4, + _ => unreachable!(), + }; + let mut ssh_cmd = Command::new("ssh"); + ssh_cmd.args(["-i", key_path]); + ssh_cmd.arg(format!("{}@{}", user, ip)); + ssh_cmd + } + ConnectMode::Ssm => { + let mut ssm_cmd = Command::new("aws"); + ssm_cmd.args(["ssm", "start-session", "--target", &item.instance_id]); + if self.args.region != *"None" { + ssm_cmd.args(["--region", &self.args.region]); } - ConnectMode::Ssm => { - let mut ssm_cmd = Command::new("aws"); - ssm_cmd.args([ - "ssm", - "start-session", - "--target", - &item.instance_id, - ]); - - if self.args.region != *"None" { - ssm_cmd.args(["--region", &self.args.region]); - } - - if self.args.profile != *"None" { - ssm_cmd.args(["--profile", &self.args.profile]); - } - ssm_cmd + if self.args.profile != *"None" { + ssm_cmd.args(["--profile", &self.args.profile]); } - }; + ssm_cmd + } + }; - let status = cmd.status()?; + // Spawn the process and wait for it to complete + let mut child = cmd + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn()?; - if !status.success() { - eprintln!("Failed to launch SSH session."); - } + let status = child.wait()?; + + if !status.success() { + eprintln!("Failed to launch session."); } } Ok(()) } + + pub async fn ssh_in_new_window(&self, emulator: &str) -> io::Result<()> { + if let Some(item) = &self.selected_item { + let ssh_command = match self.mode { + ConnectMode::Public | ConnectMode::Private => { + let key_path = match &self.ssh_keys.selected_key { + Some(key) => key, + None => { + eprintln!("No SSH key selected."); + return Ok(()); + } + }; + let user = self.ssh_user.selected_user.as_deref().unwrap_or("ec2-user"); + let ip = match self.mode { + ConnectMode::Public => &item.public_ipv4, + ConnectMode::Private => &item.private_ipv4, + _ => unreachable!(), + }; + format!("ssh -i {} {}@{}", key_path, user, ip) + } + ConnectMode::Ssm => { + let mut cmd = format!("aws ssm start-session --target {}", item.instance_id); + if self.args.region != *"None" { + cmd.push_str(&format!(" --region {}", self.args.region)); + } + if self.args.profile != *"None" { + cmd.push_str(&format!(" --profile {}", self.args.profile)); + } + cmd + } + }; + + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); + let shell_name = std::path::Path::new(&shell) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("bash"); + + let exec_command = format!("{}; exec {}", ssh_command, shell); + + // Detect OS and WSL + let is_macos = cfg!(target_os = "macos"); + let is_wsl = std::path::Path::new("/proc/version").exists() + && std::fs::read_to_string("/proc/version") + .map(|s| { + s.to_lowercase().contains("microsoft") || s.to_lowercase().contains("wsl") + }) + .unwrap_or(false); + + let mut command = match emulator.to_lowercase().as_str() { + "iterm2" | "iterm" if is_macos => { + // macOS iTerm2 - use AppleScript + let applescript = format!( + r#"tell application "iTerm" + create window with default profile + tell current session of current window + write text "{}" + end tell + end tell"#, + exec_command.replace("\\", "\\\\").replace("\"", "\\\"") + ); + let mut cmd = Command::new("osascript"); + cmd.arg("-e").arg(&applescript); + cmd + } + "terminal" if is_macos => { + // macOS Terminal.app + let applescript = format!( + r#"tell application "Terminal" + do script "{}" + activate + end tell"#, + exec_command.replace("\\", "\\\\").replace("\"", "\\\"") + ); + let mut cmd = Command::new("osascript"); + cmd.arg("-e").arg(&applescript); + cmd + } + "ghostty" => { + if is_macos { + // Try CLI first + if Command::new("ghostty").arg("--version").output().is_ok() { + // CLI is available - use it directly + let mut cmd = Command::new("ghostty"); + cmd.arg("-e").arg(&shell_name).arg("-c").arg(&exec_command); + cmd + } else { + // CLI not available - use AppleScript to control Ghostty + let applescript = format!( + r#"tell application "Ghostty" + activate + end tell + delay 0.3 + tell application "System Events" + keystroke "t" using {{command down}} + delay 0.2 + keystroke "{}" + keystroke return + end tell"#, + exec_command.replace("\\", "\\\\").replace("\"", "\\\"") + ); + let mut cmd = Command::new("osascript"); + cmd.arg("-e").arg(&applescript); + cmd + } + } else if is_wsl { + // WSL - use Windows executable + let mut cmd = Command::new("ghostty.exe"); + cmd.arg("-e").arg(&shell_name).arg("-c").arg(&exec_command); + cmd + } else { + // Linux - standard CLI + let mut cmd = Command::new("ghostty"); + cmd.arg("-e").arg(&shell_name).arg("-c").arg(&exec_command); + cmd + } + } + "warp" if is_macos => { + // Warp on macOS - limited CLI support + let applescript = format!( + r#"tell application "Warp" + activate + end tell + delay 0.5 + tell application "System Events" + keystroke "t" using {{command down}} + delay 0.2 + keystroke "{}" + keystroke return + end tell"#, + exec_command.replace("\\", "\\\\").replace("\"", "\\\"") + ); + let mut cmd = Command::new("osascript"); + cmd.arg("-e").arg(&applescript); + cmd + } + "alacritty" => { + if is_wsl { + let mut cmd = Command::new("alacritty.exe"); + cmd.arg("-e").arg(&shell_name).arg("-c").arg(&exec_command); + cmd + } else { + let mut cmd = Command::new("alacritty"); + cmd.arg("-e").arg(&shell_name).arg("-c").arg(&exec_command); + cmd + } + } + "kitty" => { + if is_wsl { + let mut cmd = Command::new("kitty.exe"); + cmd.arg(&shell_name).arg("-c").arg(&exec_command); + cmd + } else { + let mut cmd = Command::new("kitty"); + cmd.arg(&shell_name).arg("-c").arg(&exec_command); + cmd + } + } + "wezterm" => { + if is_wsl { + // WSL - call Windows wezterm.exe + let mut cmd = Command::new("wezterm.exe"); + cmd.arg("start") + .arg("--cwd") + .arg(".") + .arg("--") + .arg("wsl.exe") + .arg("-e") + .arg(&shell_name) + .arg("-c") + .arg(&exec_command); + cmd + } else if is_macos || cfg!(target_os = "linux") { + // macOS and Linux + let mut cmd = Command::new("wezterm"); + cmd.arg("start") + .arg(&shell_name) + .arg("-c") + .arg(&exec_command); + cmd + } else { + let mut cmd = Command::new("wezterm"); + cmd.arg("start") + .arg(&shell_name) + .arg("-c") + .arg(&exec_command); + cmd + } + } + "hyper" => { + if is_macos { + let mut cmd = Command::new("open"); + cmd.arg("-a").arg("Hyper"); + cmd + } else { + let mut cmd = Command::new("hyper"); + cmd.arg("-e").arg(&exec_command); + cmd + } + } + "tabby" => { + if is_macos { + let mut cmd = Command::new("open"); + cmd.arg("-a").arg("Tabby"); + cmd + } else { + let mut cmd = Command::new("tabby"); + cmd + } + } + "tilix" => { + // Linux only + let mut cmd = Command::new("tilix"); + cmd.arg("-e").arg(&exec_command); + cmd + } + "terminator" => { + // Linux only + let mut cmd = Command::new("terminator"); + cmd.arg("-e").arg(&exec_command); + cmd + } + "gnome-terminal" => { + // Linux only (mostly) + let mut cmd = Command::new("gnome-terminal"); + cmd.arg("--").arg(&shell_name).arg("-c").arg(&exec_command); + cmd + } + "konsole" => { + // Linux only (KDE) + let mut cmd = Command::new("konsole"); + cmd.arg("-e").arg(&shell_name).arg("-c").arg(&exec_command); + cmd + } + "xterm" => { + // Works on both, but primarily Linux + let mut cmd = Command::new("xterm"); + cmd.arg("-e").arg(&shell_name).arg("-c").arg(&exec_command); + cmd + } + "rxvt" | "urxvt" => { + // Linux only + let mut cmd = Command::new(emulator); + cmd.arg("-e").arg(&shell_name).arg("-c").arg(&exec_command); + cmd + } + "st" => { + // Simple Terminal (Linux) + let mut cmd = Command::new("st"); + cmd.arg("-e").arg(&shell_name).arg("-c").arg(&exec_command); + cmd + } + "foot" => { + // Wayland terminal (Linux) + let mut cmd = Command::new("foot"); + cmd.arg(&shell_name).arg("-c").arg(&exec_command); + cmd + } + "windows-terminal" | "wt" if is_wsl => { + // Windows Terminal from WSL + let mut cmd = Command::new("wt.exe"); + cmd.arg("--") + .arg("wsl.exe") + .arg("~") + .arg("-e") + .arg(&shell_name) + .arg("-l") + .arg("-c") + .arg(&ssh_command); + cmd + } + _ => { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("Unknown or unsupported terminal emulator: {}", emulator), + )); + } + }; + + let _ = command.spawn().map(|_| ()).map_err(|e| { + eprintln!("Failed to open terminal '{}': {}", emulator, e); + e + }); + } + Ok(()) + } } diff --git a/src/app/aws/mod.rs b/src/app/aws/mod.rs index 79333f5..9984627 100644 --- a/src/app/aws/mod.rs +++ b/src/app/aws/mod.rs @@ -1,2 +1 @@ pub mod ec2; - diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 631105c..0bbd4f9 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -1,3 +1,4 @@ pub mod ssh_keys; pub mod ssh_user; +pub mod terminal; pub mod user_input; diff --git a/src/app/input/ssh_keys.rs b/src/app/input/ssh_keys.rs index 2ae21b6..e7612d7 100644 --- a/src/app/input/ssh_keys.rs +++ b/src/app/input/ssh_keys.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +use crate::utils::config::load_config; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { #[serde(default)] @@ -36,13 +38,15 @@ impl SshKeys { let mut keys = Self::get_ssh_private_keys(); let mut default_key: Option = None; + let config: Option = load_config(); + // Try to load config from ~/.config/e2s/config.toml - if let Some(config) = Self::load_config() { + if let Some(config) = config { // Add additional keys from config (full paths) for key_path in config.keys.additional_keys { // Expand home directory if path starts with ~ let expanded_path = Self::expand_home_dir(&key_path); - + // Verify the key file exists and is a private key if let Ok(path) = PathBuf::from(&expanded_path).canonicalize() { if path.is_file() && Self::is_private_key(&path) { @@ -80,10 +84,7 @@ impl SshKeys { // If no default key from config, use first key let selected_key = default_key.or_else(|| keys.first().cloned()); - SshKeys { - keys, - selected_key, - } + SshKeys { keys, selected_key } } fn expand_home_dir(path: &str) -> String { @@ -95,25 +96,6 @@ impl SshKeys { path.to_string() } - fn load_config() -> Option { - let config_path = Self::get_config_path()?; - - if !config_path.exists() { - return None; - } - - let content = fs::read_to_string(config_path).ok()?; - toml::from_str(&content).ok() - } - - fn get_config_path() -> Option { - let mut path = dirs::home_dir()?; - path.push(".config"); - path.push("e2s"); - path.push("config.toml"); - Some(path) - } - /// Move to the next key (wraps around to the beginning) pub fn next(&mut self) { if self.keys.is_empty() { @@ -162,34 +144,6 @@ impl SshKeys { self.keys.len() } - /// Create a sample config file - pub fn create_sample_config() -> std::io::Result<()> { - let config_path = Self::get_config_path().ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found") - })?; - - // Create directory if it doesn't exist - if let Some(parent) = config_path.parent() { - fs::create_dir_all(parent)?; - } - - let sample_config = Config { - keys: KeyConfig { - default_key: Some("id_rsa".to_string()), - additional_keys: vec![ - "/home/user/custom/my_key".to_string(), - "~/Documents/keys/work_key".to_string(), - ], - }, - }; - - let toml_string = toml::to_string_pretty(&sample_config) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - - fs::write(config_path, toml_string)?; - Ok(()) - } - fn get_ssh_private_keys() -> Vec { let ssh_dir = match dirs::home_dir() { Some(mut path) => { @@ -225,7 +179,8 @@ impl SshKeys { || filename == "known_hosts" || filename == "config" || filename == "authorized_keys" - || filename.starts_with('.') // Skip hidden files like .ssh + || filename.starts_with('.') + // Skip hidden files like .ssh { continue; } @@ -252,4 +207,4 @@ impl SshKeys { false } } -} \ No newline at end of file +} diff --git a/src/app/input/ssh_user.rs b/src/app/input/ssh_user.rs index 3b51449..9ffbd61 100644 --- a/src/app/input/ssh_user.rs +++ b/src/app/input/ssh_user.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; + +use crate::utils::config::load_config; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { @@ -36,8 +36,10 @@ impl SshUsers { let mut users = vec!["ec2-user".to_string(), "ubuntu".to_string()]; let mut default_user: Option = None; + let config: Option = load_config(); + // Try to load config from ~/.config/ec2/config.toml - if let Some(config) = Self::load_config() { + if let Some(config) = config { // Add additional users from config for user in config.users.additional_users { if !users.contains(&user) { @@ -62,25 +64,6 @@ impl SshUsers { } } - fn load_config() -> Option { - let config_path = Self::get_config_path()?; - - if !config_path.exists() { - return None; - } - - let content = fs::read_to_string(config_path).ok()?; - toml::from_str(&content).ok() - } - - fn get_config_path() -> Option { - let mut path = dirs::home_dir()?; - path.push(".config"); - path.push("e2s"); - path.push("config.toml"); - Some(path) - } - /// Move to the next user (wraps around to the beginning) pub fn next(&mut self) { if self.users.is_empty() { @@ -128,30 +111,4 @@ impl SshUsers { pub fn len(&self) -> usize { self.users.len() } - - /// Create a sample config file - pub fn create_sample_config() -> std::io::Result<()> { - let config_path = Self::get_config_path().ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found") - })?; - - // Create directory if it doesn't exist - if let Some(parent) = config_path.parent() { - fs::create_dir_all(parent)?; - } - - let sample_config = Config { - users: UserConfig { - default_user: Some("ec2-user".to_string()), - additional_users: vec!["admin".to_string(), "root".to_string()], - }, - }; - - let toml_string = toml::to_string_pretty(&sample_config) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - - fs::write(config_path, toml_string)?; - Ok(()) - } } - diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs new file mode 100644 index 0000000..7877e16 --- /dev/null +++ b/src/app/input/terminal.rs @@ -0,0 +1,93 @@ +use serde::Deserialize; +use std::io; +use std::process::Command; + +use crate::utils::config::load_config; + +#[derive(Debug, Deserialize, Default, Clone)] +pub struct Config { + #[serde(default)] + pub terminal: TerminalConfig, + // ... other config fields +} + +#[derive(Debug, Deserialize, Default, Clone)] +pub struct TerminalConfig { + pub emulator: Option, + pub shell: Option, +} + +impl Config { + pub fn load() -> Self { + let config: Option = load_config(); + + if let Some(cfg) = config { + cfg + } else { + eprintln!("No terminal config found, using defaults"); + Config::default() + } + } + + /// Get terminal emulator from config or auto-detect + pub fn get_terminal_emulator(&self) -> io::Result { + if let Some(emulator) = &self.terminal.emulator { + // Validate that the specified emulator exists + if Self::is_terminal_available(emulator) { + return Ok(emulator.clone()); + } else { + eprintln!( + "Warning: Configured terminal '{}' not found, attempting auto-detect", + emulator + ); + } + } + + // Auto-detect available terminal + Self::detect_terminal() + } + + fn is_terminal_available(emulator: &str) -> bool { + Command::new("which") + .arg(emulator) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) + } + + fn detect_terminal() -> io::Result { + let terminals = [ + "ghostty", // Modern, fast GPU-accelerated + "alacritty", // Popular GPU-accelerated + "kitty", // Feature-rich GPU-accelerated + "wezterm", // GPU-accelerated with multiplexing + "rio", // Rust-based GPU-accelerated + "foot", // Lightweight Wayland + "gnome-terminal", // GNOME default + "konsole", // KDE default + "terminator", // Multiple terminals + "tilix", // Tiling terminal + "xterm", // Classic fallback + ]; + + for terminal in &terminals { + if Self::is_terminal_available(terminal) { + return Ok(terminal.to_string()); + } + } + + Err(io::Error::new( + io::ErrorKind::NotFound, + "No supported terminal emulator found. Please install one of: ghostty, alacritty, kitty, wezterm, gnome-terminal, konsole, terminator, tilix, xterm, foot, rio", + )) + } + + pub fn get_shell(&self) -> String { + if let Some(shell) = &self.terminal.shell { + return shell.clone(); + } + + // Detect from environment + std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()) + } +} diff --git a/src/main.rs b/src/main.rs index 57a391b..e27528b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,9 @@ pub mod tui; pub mod ui; pub mod update; +/// Utility functions and helpers. +pub mod utils; + /// Application updater. // ANCHOR_END: declare_mods use app::App; diff --git a/src/tui.rs b/src/tui.rs index 8dc9c1d..091e729 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,11 +1,15 @@ // ANCHOR: all // ANCHOR: tui_imports -use std::{io, panic}; +use std::{ + io::{self, stdout}, + panic, +}; use color_eyre::Result; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture}, + execute, terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -87,15 +91,15 @@ impl Tui { Ok(()) } - pub fn init_ec2(&mut self) -> Result<()> { + pub fn init_ec2_ssh(&mut self) -> Result<()> { ratatui::restore(); self.terminal.show_cursor()?; Ok(()) } - pub fn exit_ec2(&mut self) -> Result<()> { + pub fn exit_ec2_ssh(&mut self) -> Result<()> { self.terminal = ratatui::init(); - + self.terminal.clear()?; Ok(()) } // ANCHOR_END: tui_exit diff --git a/src/ui/ui_block/footer.rs b/src/ui/ui_block/footer.rs index d4db49d..353052e 100644 --- a/src/ui/ui_block/footer.rs +++ b/src/ui/ui_block/footer.rs @@ -7,23 +7,122 @@ use ratatui::{ pub fn render(app: &mut App, f: &mut Frame, layout: Rect) { let footer_text = Line::from(vec![ - Span::styled("[s]", Style::default().fg(Color::Cyan)), - Span::styled(" SSH into selected ", Style::default().fg(Color::Gray)), - Span::styled("[p]", Style::default().fg(Color::Cyan)), - Span::styled( - " Toggle Private/Public IP ", - Style::default().fg(Color::Gray), - ), - Span::styled("[/]", Style::default().fg(Color::Cyan)), - Span::styled(" Filter ", Style::default().fg(Color::Gray)), - Span::styled("[j/k]", Style::default().fg(Color::Cyan)), - Span::styled(" Navigate ", Style::default().fg(Color::Gray)), - Span::styled("[h/l]", Style::default().fg(Color::Cyan)), - Span::styled(" Switch SSH Key ", Style::default().fg(Color::Gray)), - Span::styled("[n/m]", Style::default().fg(Color::Cyan)), - Span::styled(" Switch SSH user ", Style::default().fg(Color::Gray)), - Span::styled("[Ctrl-c:]", Style::default().fg(Color::Cyan)), - Span::styled(" Quit", Style::default().fg(Color::Gray)), + Span::styled( + "[s/Enter]->", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " Shell ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "| ", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "[p]->", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " Mode (Pvt/Pub/SSM) ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "| ", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "[/]->", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " Filter ", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "| ", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "[j/k]->", + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " Navigate ", + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "| ", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "[h/l]", + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " SSH Key ", + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "| ", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "[n/m]", + Style::default() + .fg(Color::LightRed) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " User ", + Style::default() + .fg(Color::LightRed) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "| ", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "[Ctrl-c]", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::styled( + " Quit", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), ]); let footer = Paragraph::new(footer_text).alignment(Alignment::Left); diff --git a/src/ui/ui_block/header.rs b/src/ui/ui_block/header.rs index 884888a..38493d9 100644 --- a/src/ui/ui_block/header.rs +++ b/src/ui/ui_block/header.rs @@ -1,10 +1,11 @@ use ratatui::{ prelude::*, style::{Color, Style}, + text::{Span, Text}, widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap}, }; -use crate::app::{App, aws::ec2::ConnectMode}; +use crate::app::{aws::ec2::ConnectMode, App}; pub fn render(app: &mut App, f: &mut Frame, layout: Rect) { let title_block = Block::default() @@ -13,7 +14,7 @@ pub fn render(app: &mut App, f: &mut Frame, layout: Rect) { .padding(Padding::new(2, 1, 0, 0)) .border_type(BorderType::Plain); - let ssh_from_private = match app.connect_mode { + let ssh_from_private = match app.mode { ConnectMode::Private => "Private", ConnectMode::Public => "Public", ConnectMode::Ssm => "SSM", @@ -27,18 +28,61 @@ pub fn render(app: &mut App, f: &mut Frame, layout: Rect) { let user = app.ssh_user.selected_user.as_deref().unwrap_or("ec2-user"); - let title = Paragraph::new(Text::styled( - format!( - " e2s - EC2 SSH Selector ({}) | Region: {} | SSH Key: {} | User: {}", - ssh_from_private, app.args.region, selected_ssh_key, user - ), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - )) - .block(title_block) - .style(Style::default().bg(Color::Reset)) - .alignment(Alignment::Left); + let mut title_text = vec![ + Span::styled( + "e2s - EC2 SSH Selector (", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + ssh_from_private, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + ") | Region: ", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + &app.args.region, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " | SSH Key: ", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + &selected_ssh_key, + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " | User: ", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + user, + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ), + ]; + + let title = Paragraph::new(Line::from(title_text)) + .block(title_block) + .style(Style::default().bg(Color::Reset)) + .alignment(Alignment::Left); f.render_widget(title, layout); } diff --git a/src/ui/ui_block/instances_table.rs b/src/ui/ui_block/instances_table.rs index 2e4d5d0..510618c 100644 --- a/src/ui/ui_block/instances_table.rs +++ b/src/ui/ui_block/instances_table.rs @@ -1,57 +1,113 @@ use crate::app::App; use ratatui::{ prelude::*, - style::Style, - widgets::{Block, Borders, Cell, HighlightSpacing, Padding, Paragraph, Row, Table}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Cell, Row, Table}, }; pub fn render(app: &mut App, f: &mut Frame, layout: Rect) { - let title = if app.search.1.input.is_empty() { - "EC2 Instances".to_string() + let instance_name = app + .selected_item + .as_ref() + .map(|item| item.name.clone()) + .unwrap_or_else(|| String::from("None")); + + let title = if app.loading { + Line::from(vec![Span::styled( + "Loading ", + Style::default().fg(Color::Rgb(135, 206, 250)).bold(), + )]) + } else if app.search.1.input.is_empty() { + Line::from(vec![ + Span::styled("╭─ ", Style::default().fg(Color::Rgb(100, 149, 237))), + Span::styled( + "EC2 Instances ", + Style::default().fg(Color::Rgb(135, 206, 250)).bold(), + ), + Span::styled("β”‚ ", Style::default().fg(Color::DarkGray)), + Span::styled("Selected: ", Style::default().fg(Color::Gray)), + Span::styled( + instance_name, + Style::default().fg(Color::Rgb(255, 182, 193)).bold(), + ), + Span::styled(" ", Style::default().fg(Color::Rgb(100, 149, 237))), + ]) } else { - format!("[/] {}", app.search.1.input.clone()) + Line::from(vec![ + Span::styled("╭─ ", Style::default().fg(Color::Rgb(100, 149, 237))), + Span::styled("πŸ” ", Style::default().fg(Color::Yellow)), + Span::styled( + &app.search.1.input, + Style::default().fg(Color::Rgb(144, 238, 144)).bold(), + ), + Span::styled(" β”‚ ", Style::default().fg(Color::DarkGray)), + Span::styled("EC2: ", Style::default().fg(Color::Gray)), + Span::styled( + instance_name, + Style::default().fg(Color::Rgb(255, 182, 193)).bold(), + ), + Span::styled(" ", Style::default().fg(Color::Rgb(100, 149, 237))), + ]) }; let header_cells = [ - "Name", - "Status", - "Private IP", - "Key Group", - "AMI", - "Public IP", - "Instance ID", + ("Name", Color::Rgb(173, 216, 230)), + ("Status", Color::Rgb(255, 218, 185)), + ("Private IP", Color::Rgb(221, 160, 221)), + ("Key Group", Color::Rgb(152, 251, 152)), + ("AMI", Color::Rgb(255, 222, 173)), + ("Public IP", Color::Rgb(176, 196, 222)), + ("Instance ID", Color::Rgb(255, 192, 203)), ] .iter() - .map(|h| { - Cell::from(*h).style( + .map(|(name, color)| { + Cell::from(*name).style( Style::default() - .fg(Color::Gray) - .add_modifier(Modifier::BOLD), + .fg(*color) + .bold() + .add_modifier(Modifier::UNDERLINED), ) }); - let header = Row::new(header_cells).height(1).bottom_margin(0); + let header = Row::new(header_cells).height(1).bottom_margin(1); + + let rows = app.display_items.iter().enumerate().map(|(idx, item)| { + let (status_style, status_icon) = match item.status.to_lowercase().as_str() { + "running" => (Style::default().fg(Color::Rgb(144, 238, 144)).bold(), "●"), + "stopped" => (Style::default().fg(Color::Rgb(255, 99, 71)).bold(), "β– "), + "terminated" => (Style::default().fg(Color::Rgb(128, 128, 128)), "βœ•"), + "pending" => (Style::default().fg(Color::Rgb(255, 215, 0)).bold(), "◐"), + "stopping" => (Style::default().fg(Color::Rgb(255, 165, 0)).bold(), "β—Ž"), + _ => (Style::default().fg(Color::White), "β—‹"), + }; - let rows = app.display_items.iter().map(|item| { - let status_style = if item.status.to_lowercase() == "running" { - Style::default().fg(Color::Green) + // Alternate row background for better readability + let base_style = if idx % 2 == 0 { + Style::default().fg(Color::Rgb(220, 220, 220)) } else { - Style::default().fg(Color::Red) + Style::default().fg(Color::Rgb(200, 200, 200)) }; let cells = vec![ - Cell::from(item.name.clone()).style(Style::default().fg(Color::Gray)), - Cell::from(item.status.clone()).style(status_style), - Cell::from(item.private_ipv4.clone()).style(Style::default().fg(Color::Gray)), - Cell::from(item.key_group.clone()).style(Style::default().fg(Color::Gray)), - Cell::from(item.ami_id.clone()).style(Style::default().fg(Color::Gray)), - Cell::from(item.public_ipv4.clone()).style(Style::default().fg(Color::Gray)), - Cell::from(item.instance_id.clone()).style(Style::default().fg(Color::Gray)), + Cell::from(format!(" {}", item.name)).style(base_style.fg(Color::Rgb(173, 216, 230))), + Cell::from(format!("{} {}", status_icon, item.status)).style(status_style), + Cell::from(item.private_ipv4.as_str()).style(base_style.fg(Color::Rgb(221, 160, 221))), + Cell::from(item.key_group.as_str()).style(base_style.fg(Color::Rgb(152, 251, 152))), + Cell::from(item.ami_id.as_str()).style(base_style.fg(Color::Rgb(255, 222, 173))), + Cell::from(item.public_ipv4.as_str()).style(base_style.fg(Color::Rgb(176, 196, 222))), + Cell::from(item.instance_id.as_str()).style(base_style.fg(Color::Rgb(255, 192, 203))), ]; Row::new(cells).height(1) }); - let bar = Span::styled(" >> ", Style::default().fg(Color::Cyan)); + + let bar = Span::styled( + " β–Ά ", + Style::default() + .fg(Color::Rgb(100, 200, 255)) + .bold() + .add_modifier(Modifier::RAPID_BLINK), + ); let table = Table::new( rows, @@ -69,80 +125,18 @@ pub fn render(app: &mut App, f: &mut Frame, layout: Rect) { .block( Block::default() .title(title) - .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM) - .border_type(ratatui::widgets::BorderType::Plain), + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Rgb(100, 149, 237))) + .border_type(ratatui::widgets::BorderType::Rounded), ) .highlight_symbol(Text::from(vec![bar.into()])) - // .highlight_symbol(">> ") .row_highlight_style( Style::default() - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD), + .bg(Color::Rgb(47, 79, 79)) + .fg(Color::Rgb(255, 255, 255)) + .bold() + .add_modifier(Modifier::ITALIC), ); f.render_stateful_widget(table, layout, &mut app.state); - - // let header_style = Style::default() - // .fg(app.colors.header_fg) - // .bg(app.colors.header_bg); - // let selected_row_style = Style::default() - // .add_modifier(Modifier::REVERSED) - // .fg(app.colors.selected_row_style_fg); - // let selected_col_style = Style::default().fg(app.colors.selected_column_style_fg); - // let selected_cell_style = Style::default() - // .add_modifier(Modifier::REVERSED) - // .fg(app.colors.selected_cell_style_fg); - - // let header_cells = ["Name", "Instance ID", "IP", "Status"] - // .iter() - // .map(|h| Cell::from(*h).style(Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD))); - - // let header = Row::new(header_cells) - // .height(1) - // .bottom_margin(0); - // // let header = ["Name", "Instance Id", "IP", "Status"] - // // .into_iter() - // // .map(Cell::from) - // // .map(|h| Cell::from(*h).style(Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD))); - // // // .collect::() - // // // .style(header_style) - // // // .height(1); - - // let rows = app.items.iter().enumerate().map(|(i, data)| { - // let color = match i % 2 { - // 0 => app.colors.normal_row_color, - // _ => app.colors.alt_row_color, - // }; - // let item = data.ref_array(); - // item.into_iter() - // .map(|content| Cell::from(Text::from(format!("\n{content}\n")))) - // .collect::() - // .style(Style::new().fg(app.colors.row_fg).bg(color)) - // .height(3) - // }); - // //let bar = " β–ˆ>> "; - // let bar = Span::styled(">>", Style::default().fg(Color::Cyan)); - // let t = Table::new( - // rows, - // [ - // // + 1 is for padding. - // Constraint::Length(app.longest_item_lens.0 + 1), - // Constraint::Min(app.longest_item_lens.1), - // Constraint::Min(app.longest_item_lens.2), - // Constraint::Min(app.longest_item_lens.3), - // ], - // ) - // .header(header) - // .block(Block::default().borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)) - // // The selection arrow ">> " - // // .row_highlight_style(selected_row_style) - // // .column_highlight_style(selected_col_style) - // // .cell_highlight_style(selected_cell_style) - // .highlight_symbol(Text::from(vec!["".into(), bar.into(), "".into()])) - // // .bg(app.colors.buffer_bg) - - // // .highlight_spacing(HighlightSpacing::Always) - // ; - - // f.render_stateful_widget(t, layout, &mut app.state); } diff --git a/src/ui/ui_block/loader.rs b/src/ui/ui_block/loader.rs new file mode 100644 index 0000000..88321ba --- /dev/null +++ b/src/ui/ui_block/loader.rs @@ -0,0 +1,44 @@ +use ratatui::{ + prelude::*, + style::{Color, Style}, + text::{Span, Text}, + widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap}, +}; + +use crate::app::App; + +pub fn render(app: &mut App, f: &mut Frame, layout: Rect) { + let loading_block = Block::default() + .borders(Borders::ALL) + .style(Style::default().bg(Color::Reset)) + .padding(Padding::new(2, 1, 0, 0)) + .border_type(BorderType::Plain); + + let loading_text = vec![ + Span::styled( + "⏳ Loading ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "Fetching EC2 Data", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "...", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ]; + + let loading = Paragraph::new(Line::from(loading_text)) + .block(loading_block) + .style(Style::default().bg(Color::Reset)) + .alignment(Alignment::Left); + + f.render_widget(loading, layout); +} diff --git a/src/ui/ui_block/mod.rs b/src/ui/ui_block/mod.rs index 991db48..ce09d47 100644 --- a/src/ui/ui_block/mod.rs +++ b/src/ui/ui_block/mod.rs @@ -2,5 +2,6 @@ pub mod footer; pub mod header; pub mod help; pub mod instances_table; +pub mod loader; pub mod search; pub mod welcome; diff --git a/src/update.rs b/src/update.rs index db88424..03f31d7 100644 --- a/src/update.rs +++ b/src/update.rs @@ -1,17 +1,23 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use crate::app::input::user_input::{InputMode, UserInput}; -use crate::app::{App, SelectedTab}; +use crate::app::input::user_input::InputMode; +use crate::app::App; use crate::tui::Tui; -async fn open_editor(app: &mut App, tui: &mut Tui) { - let _ = tui.init_ec2(); +async fn handle_ssh(app: &mut App, tui: &mut Tui) { + if let Some(emulator) = &app.terminal.terminal.emulator { + if let Err(e) = app.ssh_in_new_window(emulator).await { + eprintln!("Error SSH to the server on new terminal: {}", e); + } + } else { + let _ = tui.init_ec2_ssh(); - if let Err(e) = app.ssh().await { - eprintln!("Error SSH to the server: {}", e); - } + if let Err(e) = app.ssh().await { + eprintln!("Error SSH to the server: {}", e); + } - let _ = tui.exit_ec2(); + let _ = tui.exit_ec2_ssh(); + } } pub async fn update(app: &mut App, key_event: KeyEvent, tui: &mut Tui) { @@ -23,7 +29,9 @@ pub async fn update(app: &mut App, key_event: KeyEvent, tui: &mut Tui) { app.quit(); } KeyCode::Char('r') | KeyCode::Char('R') => { + app.loading = true; app.fetch_ec2_data().await; + app.loading = false; } KeyCode::Down | KeyCode::Char('j') => { app.ec2_next(); @@ -47,12 +55,12 @@ pub async fn update(app: &mut App, key_event: KeyEvent, tui: &mut Tui) { app.ssh_user.previous(); } KeyCode::Char('p') => { - app.connect_mode.toggle(); + app.mode.toggle(); } KeyCode::Char('?') => { app.show_help = !app.show_help; } - KeyCode::Char('s') | KeyCode::Enter => open_editor(app, tui).await, + KeyCode::Char('s') | KeyCode::Enter => handle_ssh(app, tui).await, _ => {} }, InputMode::Editing if key_event.kind == KeyEventKind::Press && app.search.0 => { diff --git a/src/utils/config.rs b/src/utils/config.rs new file mode 100644 index 0000000..b21f944 --- /dev/null +++ b/src/utils/config.rs @@ -0,0 +1,36 @@ +use serde::de::DeserializeOwned; +use std::path::PathBuf; +use std::{env, fs}; + +pub fn load_config() -> Option { + let config_path = get_config_path()?; + + if !config_path.exists() { + return None; + } + + let content = fs::read_to_string(config_path).ok()?; + toml::from_str(&content).ok() +} + +pub fn get_config_path() -> Option { + if let Ok(custom) = env::var("E2S_CONFIG") { + // Expand tilde if present + let path = if custom.starts_with("~/") { + let home = dirs::home_dir()?; + home.join(&custom[2..]) // Skip the "~/" part + } else if custom == "~" { + dirs::home_dir()? + } else { + PathBuf::from(custom) + }; + + return Some(path); + } + + let mut path = dirs::home_dir()?; + path.push(".config"); + path.push("e2s"); + path.push("config.toml"); + Some(path) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..ef68c36 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod config;