├── version ├── .dumpignore ├── .tool-versions ├── .codeiumignore ├── src ├── ui │ ├── mod.rs │ ├── status_bar.rs │ ├── footer.rs │ └── hosts_list.rs ├── app │ ├── mod.rs │ ├── types.rs │ ├── keymap.rs │ ├── host.rs │ └── state.rs ├── sftp_logic │ ├── mod.rs │ ├── types.rs │ ├── state.rs │ ├── local.rs │ ├── remote.rs │ └── transfer.rs ├── models.rs ├── app_event.rs ├── config.rs ├── main.rs └── sftp_ui.rs ├── docs ├── preview_1.png ├── preview_2.png ├── keyboard_shortcuts.md └── hosts.toml ├── .gitignore ├── Cargo.toml ├── makefile ├── .github └── workflows │ └── release.yml ├── CHANGELOG.md ├── README.md ├── LICENSE └── Cargo.lock /version: -------------------------------------------------------------------------------- 1 | 0.8.0 -------------------------------------------------------------------------------- /.dumpignore: -------------------------------------------------------------------------------- 1 | 2 | docs/* 3 | logs/* -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | rust 1.87.0 2 | nodejs 20.17.0 3 | -------------------------------------------------------------------------------- /.codeiumignore: -------------------------------------------------------------------------------- 1 | 2 | /logs 3 | /target 4 | 5 | Cargo.lock -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod footer; 2 | pub mod hosts_list; 3 | pub mod status_bar; -------------------------------------------------------------------------------- /docs/preview_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangneeee/sshr/HEAD/docs/preview_1.png -------------------------------------------------------------------------------- /docs/preview_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangneeee/sshr/HEAD/docs/preview_2.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .windsurf/ 3 | .idea/ 4 | 5 | /logs 6 | 7 | /target 8 | 9 | source_dump.txt 10 | 11 | .env -------------------------------------------------------------------------------- /src/app/mod.rs: -------------------------------------------------------------------------------- 1 | mod host; 2 | mod state; 3 | pub mod keymap; 4 | pub mod types; 5 | 6 | pub use types::{App, InputMode, ActivePanel, FilteredHost}; 7 | -------------------------------------------------------------------------------- /src/sftp_logic/mod.rs: -------------------------------------------------------------------------------- 1 | //! SFTP module for handling local and remote file operations 2 | 3 | mod local; 4 | mod remote; 5 | mod state; 6 | mod transfer; 7 | pub mod types; 8 | 9 | pub use types::AppSftpState; 10 | pub use types::{FileItem, PanelSide}; -------------------------------------------------------------------------------- /src/models.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Serialize, Deserialize)] 4 | pub struct SshHost { 5 | pub alias: String, 6 | pub host: String, 7 | pub user: String, 8 | pub port: Option, 9 | pub description: Option, 10 | pub group: Option, 11 | } 12 | 13 | impl SshHost { 14 | pub fn new(alias: String, host: String, user: String) -> Self { 15 | Self { 16 | alias, 17 | host, 18 | user, 19 | port: Some(22), 20 | description: None, 21 | group: None, 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /src/app_event.rs: -------------------------------------------------------------------------------- 1 | use crate::sftp_logic::AppSftpState; 2 | 3 | #[derive(Debug, Clone)] 4 | pub enum SshEvent { 5 | Connecting, 6 | Connected, 7 | Error(String), 8 | Disconnected, 9 | } 10 | 11 | #[derive(Debug, Clone)] 12 | pub enum SftpEvent { 13 | Connecting, 14 | PreConnected(AppSftpState), 15 | Connected, 16 | #[allow(dead_code)] 17 | Disconnected, 18 | Error(String), 19 | } 20 | 21 | #[derive(Debug, Clone)] 22 | pub enum TransferEvent { 23 | UploadProgress(String, u64, u64), 24 | UploadComplete(String), 25 | UploadError(String, String), 26 | DownloadProgress(String, u64, u64), 27 | DownloadComplete(String), 28 | DownloadError(String, String), 29 | } 30 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sshr" 3 | version = "0.8.0" 4 | edition = "2021" 5 | description = "A TUI for managing and connecting to SSH hosts" 6 | 7 | [[bin]] 8 | name = "sshr" 9 | path = "src/main.rs" 10 | 11 | [profile.release] 12 | opt-level = 3 13 | lto = true 14 | codegen-units = 1 15 | strip = true 16 | panic = "abort" 17 | 18 | 19 | [dependencies] 20 | ratatui = "0.26.0" 21 | crossterm = { version = "0.27.0", features = ["event-stream"] } 22 | serde = { version = "1.0", features = ["derive"] } 23 | serde_json = "1.0" 24 | tracing = "0.1.40" 25 | arboard = "3.3.0" 26 | anyhow = "1.0.98" 27 | tokio = { version = "1.45.1", features = ["full", "process"] } 28 | tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 29 | dirs = "6.0.0" 30 | chrono = "0.4.41" 31 | clap = { version = "4.5.39", features = ["derive"] } 32 | toml = "0.8.22" 33 | open = "5.3.2" 34 | fuzzy-matcher = "0.3.7" 35 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | 2 | 3 | dev: 4 | RUST_LOG=debug cargo run 5 | 6 | release: 7 | cargo run --release 8 | 9 | build: 10 | cargo build --release 11 | 12 | publish: build 13 | @echo "Publishing sshr $(shell cat version) to GitHub" 14 | git tag v$(shell cat version) 15 | git push --tags 16 | @echo "sshr $(shell cat version) published to GitHub" 17 | 18 | publish-latest: build 19 | @echo "Publishing sshr latest to GitHub" 20 | git tag -d latest 21 | git tag latest 22 | git push --tags -f 23 | @echo "sshr latest published to GitHub" 24 | 25 | install: build 26 | @echo "Installing sshr to /usr/local/bin" 27 | @mkdir -p /usr/local/bin 28 | @cp target/release/sshr /usr/local/bin/ 29 | @chmod +x /usr/local/bin/sshr 30 | @echo "sshr installed successfully" 31 | 32 | uninstall: 33 | @echo "Removing sshr" 34 | @rm -f /usr/local/bin/sshr 35 | @echo "sshr uninstalled" 36 | 37 | install-brew: 38 | brew tap hoangneeee/sshr 39 | brew install sshr 40 | 41 | -------------------------------------------------------------------------------- /docs/keyboard_shortcuts.md: -------------------------------------------------------------------------------- 1 | # Keyboard Shortcuts 2 | 3 | ## Normal Mode 4 | 5 | | Key | Description | 6 | | --- | --- | 7 | | `q` | Quit | 8 | | `Enter` | Connect to selected host | 9 | | `s` | Switch to SEARCH mode | 10 | | `f` | Switch to SFTP mode | 11 | | `e` | Edit file config custom hosts | 12 | | `r` | Reload | 13 | | `j`, `↓` | Move down | 14 | | `k`, `↑` | Move up | 15 | 16 | ## Search Mode 17 | 18 | | Key | Description | 19 | | --- | --- | 20 | | `Esc` | Switch to Normal mode | 21 | | `Enter` | Connect to selected host | 22 | | `↓` | Move down | 23 | | `↑` | Move up | 24 | | `Backspace`, `Delete` | Clear search input | 25 | | `Esc` | Clear search input | 26 | 27 | ## SFTP Mode 28 | 29 | | Key | Description | 30 | | --- | --- | 31 | | `q` | Switch to Normal mode | 32 | | `Enter` | Open directory | 33 | | `↓` | Move down | 34 | | `↑` | Move up | 35 | | `Backspace` | Go back to parent directory | 36 | | `Tab` | Switch between local and remote directory | 37 | | `u` | Upload file | 38 | | `d` | Download file | 39 | | `r` | Reload | 40 | 41 | 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: ['v*', 'latest', 'stable'] 6 | 7 | jobs: 8 | build: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, macos-latest, windows-latest] 13 | include: 14 | - os: ubuntu-latest 15 | target: x86_64-unknown-linux-gnu 16 | ext: tar.gz 17 | - os: macos-latest 18 | target: x86_64-apple-darwin 19 | ext: tar.gz 20 | - os: windows-latest 21 | target: x86_64-pc-windows-msvc 22 | ext: zip 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | 27 | - name: Install Rust 28 | uses: actions-rs/toolchain@v1 29 | with: 30 | toolchain: stable 31 | target: ${{ matrix.target }} 32 | override: true 33 | 34 | - name: Build 35 | run: cargo build --release --target ${{ matrix.target }} 36 | 37 | - name: Package 38 | if: runner.os != 'Windows' 39 | run: | 40 | mkdir -p release 41 | cp target/${{ matrix.target }}/release/sshr sshr 42 | tar czf release/sshr-${{ matrix.target }}.${{ matrix.ext }} sshr 43 | 44 | - name: Package (Windows) 45 | if: runner.os == 'Windows' 46 | run: | 47 | mkdir -p release 48 | Copy-Item "target\${{ matrix.target }}\release\sshr.exe" "sshr.exe" 49 | 7z a -tzip "release\sshr-${{ matrix.target }}.zip" sshr.exe 50 | 51 | - name: Upload Release 52 | uses: softprops/action-gh-release@v1 53 | with: 54 | files: release/* 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.CI_TOKEN }} -------------------------------------------------------------------------------- /src/ui/status_bar.rs: -------------------------------------------------------------------------------- 1 | 2 | use ratatui::{ 3 | backend::Backend, 4 | layout::{Rect}, 5 | style::{Color, Style}, 6 | widgets::{Paragraph}, 7 | Frame, 8 | }; 9 | use crate::app::{App}; 10 | 11 | pub fn draw_status_bar(f: &mut Frame, app: &mut App, area: Rect) { 12 | if let Some((message, timestamp)) = &app.status_message { 13 | // Clear messages older than 5 seconds (except when connecting) 14 | let should_show = if app.is_connecting { 15 | true // Always show status during connection 16 | } else { 17 | timestamp.elapsed().as_secs() < 5 18 | }; 19 | 20 | if should_show { 21 | let style = if message.to_lowercase().contains("error") 22 | || message.to_lowercase().contains("failed") 23 | { 24 | Style::default().fg(Color::Red) 25 | } else if message.to_lowercase().contains("success") 26 | || message.to_lowercase().contains("successful") 27 | || message.to_lowercase().contains("ended") 28 | { 29 | Style::default().fg(Color::Green) 30 | } else if message.to_lowercase().contains("connecting") 31 | || message.to_lowercase().contains("testing") 32 | { 33 | Style::default().fg(Color::Cyan) 34 | } else { 35 | Style::default().fg(Color::Yellow) 36 | }; 37 | 38 | let paragraph = Paragraph::new(message.as_str()) 39 | .style(style) 40 | .alignment(ratatui::layout::Alignment::Center); 41 | f.render_widget(paragraph, area); 42 | } else { 43 | // Clear the status message if it's expired 44 | app.clear_status_message(); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Added 11 | 12 | 13 | ### Changed / Fixed 14 | 15 | 16 | ### Removed 17 | 18 | 19 | --- 20 | 21 | ### [0.7.0] - 2025-07-01 22 | 23 | ### Added 24 | - Update UI search mode to user friendly 25 | 26 | ### Changed / Fixed 27 | - Fix bug restore tui when has error in ssh mode 28 | 29 | --- 30 | 31 | ### [0.5.0] - 2025-06-08 32 | 33 | ### Added 34 | 35 | - Feature SFTP mode 36 | 37 | ### Changed / Fixed 38 | 39 | - Update docs keyboard shortcuts 40 | - Upgrade README.md 41 | - Fix scroll list view ssh mode and sftp mode 42 | 43 | --- 44 | 45 | ## [0.4.0] - 2025-06-06 46 | ### Features 47 | 48 | - Press s to search 49 | 50 | --- 51 | 52 | ## [0.3.0] - 2025-06-05 53 | ### Changed 54 | 55 | - Use edit action instead of add, delete action 56 | - Move logic handle pressed key to `app.rs` 57 | - Upgrade UI with loading animation 58 | 59 | ### Performance 60 | 61 | - Use main thread and run ssh thread 62 | 63 | --- 64 | 65 | ## [0.2.0] - 2025-06-03 66 | ### Added 67 | 68 | - Add formula support homebrew 69 | - Add version flag 70 | - Read my config 71 | - Support reload config 72 | - Can user custom host file with `hosts.toml` 73 | 74 | ### Changed 75 | 76 | - Upgrade README.md 77 | - Change log file name 78 | 79 | ### Fixed 80 | 81 | - Workflows release work on windows 82 | 83 | --- 84 | 85 | ## [0.1.0] - 2025-06-02 86 | 87 | ### Added 88 | 89 | - Read ssh host from ~/.ssh/config 90 | - Support connect to ssh host 91 | - Show list ssh host in TUI 92 | 93 | -------------------------------------------------------------------------------- /src/app/types.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::sftp_logic::AppSftpState; 4 | use crate::{config::ConfigManager, models::SshHost}; 5 | 6 | use crate::app_event::{SftpEvent, SshEvent, TransferEvent}; 7 | use ratatui::widgets::ListState; 8 | use std::sync::mpsc::Receiver; 9 | use tokio::sync::mpsc as tokio_mpsc; 10 | 11 | #[derive(Debug, Clone, Copy, PartialEq)] 12 | pub enum ActivePanel { 13 | Groups, 14 | Hosts, 15 | } 16 | 17 | #[derive(Debug, PartialEq)] 18 | pub enum InputMode { 19 | Normal, 20 | Search, 21 | Sftp, 22 | } 23 | 24 | #[derive(Debug, Clone)] 25 | pub struct FilteredHost { 26 | pub original_index: usize, 27 | pub score: i64, 28 | pub matched_indices: Vec, 29 | } 30 | 31 | #[derive(Debug)] 32 | pub struct App { 33 | pub should_quit: bool, 34 | pub hosts: Vec, 35 | pub selected_host: usize, 36 | pub selected_group: usize, 37 | pub active_panel: ActivePanel, 38 | pub ssh_config_path: PathBuf, 39 | pub config_manager: ConfigManager, 40 | pub input_mode: InputMode, 41 | 42 | pub status_message: Option<(String, std::time::Instant)>, 43 | 44 | // SSH Mode 45 | pub is_connecting: bool, 46 | pub connecting_host: Option, 47 | pub ssh_ready_for_terminal: bool, 48 | pub ssh_receiver: Option>, 49 | 50 | // SFTP Mode 51 | pub is_sftp_loading: bool, 52 | pub sftp_ready_for_terminal: bool, 53 | pub sftp_receiver: Option>, 54 | pub sftp_state: Option, 55 | pub transfer_receiver: Option>, 56 | 57 | // Search Mode 58 | pub search_query: String, 59 | pub filtered_hosts: Vec, // Indices of filtered hosts 60 | pub search_selected: usize, 61 | 62 | // Group State 63 | pub groups: Vec, 64 | pub hosts_in_current_group: Vec, 65 | 66 | 67 | pub host_list_state: ListState, 68 | pub group_list_state: ListState, 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 💻 SSHR 2 | 3 | SSHR is a TUI (Text User Interface) application for managing and connecting to hosts through the terminal interface. 4 | 5 | [![Release](https://github.com/hoangneeee/sshr/actions/workflows/release.yml/badge.svg)](https://github.com/hoangneeee/sshr/actions/workflows/release.yml) 6 | 7 | 🎯 Supports: macOS & Linux (x86_64) 8 | 9 | --- 10 | 11 | ## 📚 Contents 12 | 13 | - [UI Preview](#ui-preview) 14 | - [Quick Start](#quick-start) 15 | - [Installation](#installation) 16 | - [Available Flags](#available-flags) 17 | - [Keyboard Shortcuts](./docs/keyboard_shortcuts.md) 18 | - [Contribute](#contribute) 19 | - [License](#license) 20 | 21 | --- 22 | 23 | ## 🖥️ UI Preview 24 | 25 | ![image](./docs/preview_2.png) 26 | 27 | ## 🚀 Quick Start 28 | 29 | - `sshr` automatically load hosts from your ~/.ssh/config 30 | - Load customer host file with `hosts.toml` and template file [hosts.toml](./docs/hosts.toml) 31 | 32 | ## 📦 Installation 33 | 34 | ### 🍺 Install using Homebrew (recommended) 35 | 36 | ```bash 37 | brew tap hoangneeee/sshr 38 | brew install sshr 39 | ``` 40 | 41 | ### ⬇️ Install from release 42 | 43 | ```bash 44 | curl -L -O https://github.com/hoangneeee/sshr/releases/download/latest/sshr-x86_64-apple-darwin.tar.gz 45 | # or 46 | wget https://github.com/hoangneeee/sshr/releases/download/latest/sshr-x86_64-apple-darwin.tar.gz 47 | 48 | # Unzip 49 | tar -xvf sshr-x86_64-apple-darwin.tar.gz 50 | 51 | # Copy to /usr/local/bin 52 | sudo cp sshr-x86_64-apple-darwin/sshr /usr/local/bin/sshr 53 | ``` 54 | 55 | ### 🔨 For Developer 56 | 57 | ```bash 58 | git clone https://github.com/hoangneeee/sshr.git 59 | 60 | cd sshr 61 | 62 | make install 63 | ``` 64 | 65 | ## 📝 Available flags 66 | 67 | | Flag | Short flag | Description | 68 | | ----------- | ---------- | ----------------------- | 69 | | `--version` | `-V` | Current version of sshr | 70 | | `--help` | `-h` | Show help | 71 | 72 | ## 🤝 Contribute 73 | 74 | - If you want to contribute to this project, please fork this repository and create a pull request. 75 | - If you want to report an issue or suggest an improvement, please create an issue. 76 | 77 | 78 | ## 📝 License 79 | 80 | [Apache License 2.0](./LICENSE) 81 | -------------------------------------------------------------------------------- /docs/hosts.toml: -------------------------------------------------------------------------------- 1 | # SSH Hosts Configuration File 2 | # This file contains all your SSH host configurations organized in groups 3 | # Save this file as 'hosts.toml' in your config directory 4 | 5 | # The root of the config is a vector of HostGroup 6 | [[groups]] 7 | name = "Default" 8 | description = "Default group for all hosts" 9 | 10 | # Hosts in the default group 11 | [[groups.hosts]] 12 | alias = "local-server" 13 | host = "localhost" 14 | user = "your_username" 15 | port = 22 16 | description = "Local development server" 17 | 18 | [[groups.hosts]] 19 | alias = "prod-web" 20 | host = "192.168.1.100" 21 | user = "admin" 22 | port = 2222 23 | description = "Production web server" 24 | 25 | # Additional groups 26 | [[groups]] 27 | name = "Production" 28 | description = "Production servers" 29 | 30 | [[groups.hosts]] 31 | alias = "prod-db" 32 | host = "db.prod.example.com" 33 | user = "dbadmin" 34 | port = 22 35 | 36 | [[groups.hosts]] 37 | alias = "prod-app" 38 | host = "app.prod.example.com" 39 | user = "appuser" 40 | port = 22 41 | 42 | [[groups]] 43 | name = "Staging" 44 | description = "Staging environment servers" 45 | 46 | [[groups.hosts]] 47 | alias = "staging-web" 48 | host = "web.staging.example.com" 49 | user = "deploy" 50 | port = 22 51 | 52 | # Example with additional fields (if supported by your SshHost struct) 53 | [[groups]] 54 | name = "Special" 55 | description = "Servers with special configurations" 56 | 57 | [[groups.hosts]] 58 | alias = "special-server" 59 | host = "special.example.com" 60 | user = "special" 61 | port = 2222 62 | # Additional fields would go here if they exist in SshHost struct 63 | # For example: 64 | # identity_file = "~/.ssh/special_key.pem" 65 | # proxy_jump = "bastion@jump.example.com" 66 | 67 | [[groups.hosts]] 68 | alias = "behind-bastion" 69 | host = "10.0.0.10" 70 | user = "ec2-user" 71 | port = 22 72 | # Example of jump host configuration (if supported) 73 | # jump_host = "bastion.example.com" 74 | # jump_user = "bastion-user" 75 | # jump_port = 22 76 | 77 | # You can add more groups and hosts as needed 78 | # [[groups]] 79 | # name = "Group Name" 80 | # description = "Group description" 81 | # 82 | # [[groups.hosts]] 83 | # alias = "host-alias" 84 | # host = "hostname" 85 | # user = "username" 86 | # port = 22 87 | # description = "Optional description" -------------------------------------------------------------------------------- /src/sftp_logic/types.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::time::Instant; 3 | use ratatui::widgets::ListState; 4 | use tokio::sync::mpsc; 5 | use crate::app_event::TransferEvent; 6 | 7 | /// Represents a file or directory item in the file browser 8 | #[derive(Debug, Clone)] 9 | pub enum FileItem { 10 | Directory { name: String }, 11 | File { name: String, size: u64 }, 12 | } 13 | 14 | /// Represents which panel (local or remote) is currently active 15 | #[derive(Debug, Clone, PartialEq)] 16 | pub enum PanelSide { 17 | Local, 18 | Remote, 19 | } 20 | 21 | /// Represents upload progress information 22 | #[derive(Debug, Clone)] 23 | pub struct UploadProgress { 24 | pub file_name: String, 25 | pub uploaded_size: u64, 26 | pub total_size: u64, 27 | } 28 | 29 | /// Represents download progress information 30 | #[derive(Debug, Clone)] 31 | pub struct DownloadProgress { 32 | pub file_name: String, 33 | pub downloaded_size: u64, 34 | pub total_size: u64, 35 | } 36 | 37 | /// Main application state for the SFTP file browser 38 | #[derive(Debug, Clone)] 39 | pub struct AppSftpState { 40 | /// Currently active panel (local or remote) 41 | pub active_panel: PanelSide, 42 | 43 | // Local panel state 44 | pub local_current_path: PathBuf, 45 | pub local_files: Vec, 46 | pub local_selected: usize, 47 | pub local_list_state: ListState, 48 | 49 | // Remote panel state 50 | pub remote_current_path: String, 51 | pub remote_files: Vec, 52 | pub remote_selected: usize, 53 | pub remote_list_state: ListState, 54 | 55 | // SFTP connection info 56 | pub ssh_host: String, 57 | pub ssh_user: String, 58 | pub ssh_port: u16, 59 | 60 | // UI state 61 | pub status_message: Option, 62 | pub status_message_time: Option, 63 | 64 | // Upload progress 65 | pub upload_progress: Option, 66 | // Download progress 67 | pub download_progress: Option, 68 | 69 | // Transfer event sender 70 | pub transfer_tx: Option>, 71 | } 72 | 73 | impl FileItem { 74 | pub fn name(&self) -> &str { 75 | match self { 76 | FileItem::Directory { name } => name, 77 | FileItem::File { name, .. } => name, 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/sftp_logic/state.rs: -------------------------------------------------------------------------------- 1 | use super::types::{AppSftpState, PanelSide, UploadProgress, DownloadProgress}; 2 | use anyhow::{Context, Result}; 3 | use ratatui::widgets::ListState; 4 | use tokio::sync::mpsc; 5 | use crate::app_event::TransferEvent; 6 | 7 | impl AppSftpState { 8 | /// Create a new instance of AppSftpState 9 | pub fn new( 10 | ssh_user: &str, 11 | ssh_host: &str, 12 | ssh_port: u16, 13 | transfer_tx: mpsc::Sender, 14 | ) -> Result { 15 | let current_dir = std::env::current_dir().context("Failed to get current directory")?; 16 | 17 | let mut state = Self { 18 | active_panel: PanelSide::Local, 19 | // LOCAL 20 | local_current_path: current_dir, 21 | local_files: Vec::new(), 22 | local_selected: 0, 23 | local_list_state: ListState::default(), 24 | 25 | // REMOTE 26 | remote_current_path: "/".to_string(), 27 | remote_files: Vec::new(), 28 | remote_selected: 0, 29 | remote_list_state: ListState::default(), 30 | 31 | ssh_host: ssh_host.to_string(), 32 | ssh_user: ssh_user.to_string(), 33 | ssh_port, 34 | status_message: None, 35 | status_message_time: None, 36 | upload_progress: None, 37 | download_progress: None, 38 | transfer_tx: Some(transfer_tx), 39 | }; 40 | 41 | // Load initial directory contents 42 | state.refresh_local()?; 43 | state.refresh_remote()?; 44 | 45 | Ok(state) 46 | } 47 | 48 | 49 | /// Set a status message to be displayed to the user 50 | pub fn set_status_message(&mut self, message: &str) { 51 | self.status_message = Some(message.to_string()); 52 | self.status_message_time = Some(std::time::Instant::now()); 53 | } 54 | 55 | /// Clear the current status message 56 | pub fn clear_status_message(&mut self) { 57 | self.status_message = None; 58 | self.status_message_time = None; 59 | } 60 | 61 | /// Switch the active panel between local and remote 62 | pub fn switch_panel(&mut self) { 63 | self.active_panel = match self.active_panel { 64 | PanelSide::Local => PanelSide::Remote, 65 | PanelSide::Remote => PanelSide::Local, 66 | }; 67 | } 68 | 69 | pub fn navigate_up(&mut self) { 70 | match self.active_panel { 71 | PanelSide::Local => { 72 | self.navigate_local_up(); 73 | } 74 | PanelSide::Remote => { 75 | self.navigate_remote_up(); 76 | } 77 | }; 78 | } 79 | 80 | pub fn navigate_down(&mut self) { 81 | match self.active_panel { 82 | PanelSide::Local => { 83 | self.navigate_local_down(); 84 | } 85 | PanelSide::Remote => { 86 | self.navigate_remote_down(); 87 | } 88 | }; 89 | } 90 | 91 | pub fn open_selected(&mut self) -> Result<()> { 92 | match self.active_panel { 93 | PanelSide::Local => { 94 | let _ = self.open_local_selected(); 95 | } 96 | PanelSide::Remote => { 97 | let _ = self.open_remote_selected(); 98 | } 99 | }; 100 | Ok(()) 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /src/ui/footer.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{App, InputMode}; 2 | use ratatui::{ 3 | backend::Backend, 4 | layout::{Constraint, Direction, Layout, Rect}, 5 | style::{Color, Modifier, Style}, 6 | text::{Line, Span}, 7 | widgets::Paragraph, 8 | Frame, 9 | }; 10 | 11 | pub fn draw_footer(f: &mut Frame, app: &App, area: Rect) { 12 | let footer = Layout::default() 13 | .direction(Direction::Horizontal) 14 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) 15 | .split(area); 16 | 17 | let key_style = Style::default() 18 | .fg(Color::LightCyan) 19 | .add_modifier(Modifier::BOLD); 20 | let desc_style = Style::default().fg(Color::DarkGray); 21 | 22 | let (nav_spans, action_spans) = match app.input_mode { 23 | InputMode::Normal if app.is_connecting => ( 24 | Line::from(Span::styled( 25 | "Connecting to SSH host...", 26 | Style::default().fg(Color::Yellow), 27 | )), 28 | Line::from(Span::styled( 29 | "[Ctrl+C] Cancel", 30 | Style::default().fg(Color::Red), 31 | )), 32 | ), 33 | InputMode::Normal => ( 34 | Line::from(vec![ 35 | Span::styled("↑/k:", key_style), 36 | Span::styled(" Up ", desc_style), 37 | Span::styled("↓/j:", key_style), 38 | Span::styled(" Down ", desc_style), 39 | Span::styled("[Enter]", key_style), 40 | Span::styled(" Connect ", desc_style), 41 | Span::styled("[s]", key_style), 42 | Span::styled(" Search ", desc_style), 43 | Span::styled("[f]", key_style), 44 | Span::styled(" SFTP", desc_style), 45 | ]), 46 | Line::from(vec![ 47 | Span::styled("[e]", key_style), 48 | Span::styled(" Edit ", desc_style), 49 | Span::styled("[r]", key_style), 50 | Span::styled(" Reload ", desc_style), 51 | Span::styled("[q]", key_style), 52 | Span::styled(" Quit", desc_style), 53 | ]), 54 | ), 55 | InputMode::Search => ( 56 | Line::from(vec![ 57 | Span::styled("↑:", key_style), 58 | Span::styled(" Up ", desc_style), 59 | Span::styled("↓:", key_style), 60 | Span::styled(" Down ", desc_style), 61 | Span::styled("[Enter]", key_style), 62 | Span::styled(" Connect", desc_style), 63 | ]), 64 | Line::from(vec![ 65 | Span::styled("[Esc]", key_style), 66 | Span::styled(" Exit Search ", desc_style), 67 | Span::styled("Type to filter", desc_style), 68 | ]), 69 | ), 70 | InputMode::Sftp => ( 71 | Line::from(vec![ 72 | Span::styled("↑:", key_style), 73 | Span::styled(" Up ", desc_style), 74 | Span::styled("↓:", key_style), 75 | Span::styled(" Down ", desc_style), 76 | Span::styled("[Enter]", key_style), 77 | Span::styled(" Connect", desc_style), 78 | ]), 79 | Line::from(vec![ 80 | Span::styled("[Esc]", key_style), 81 | Span::styled(" Exit Search ", desc_style), 82 | Span::styled("Type to filter", desc_style), 83 | ]), 84 | ), 85 | }; 86 | 87 | let nav_help = Paragraph::new(nav_spans); 88 | 89 | let action_help = Paragraph::new(action_spans) 90 | .alignment(ratatui::layout::Alignment::Right); 91 | 92 | f.render_widget(nav_help, footer[0]); 93 | f.render_widget(action_help, footer[1]); 94 | } 95 | -------------------------------------------------------------------------------- /src/app/keymap.rs: -------------------------------------------------------------------------------- 1 | use crate::app::ActivePanel; 2 | use crate::app::{App, InputMode}; 3 | use crate::app_event::SshEvent; 4 | use anyhow::Result; 5 | use ratatui::backend::Backend; 6 | use ratatui::Terminal; 7 | use std::sync::mpsc; 8 | use std::thread; 9 | use std::time::Instant; 10 | 11 | impl App { 12 | // Group navigation and management 13 | pub fn next_group(&mut self) { 14 | if !self.groups.is_empty() { 15 | self.selected_group = (self.selected_group + 1) % self.groups.len(); 16 | self.group_list_state.select(Some(self.selected_group)); 17 | self.update_hosts_for_selected_group(); 18 | self.selected_host = 0; 19 | self.host_list_state.select(Some(self.selected_host)); 20 | } 21 | } 22 | 23 | pub fn previous_group(&mut self) { 24 | if !self.groups.is_empty() { 25 | self.selected_group = (self.selected_group + self.groups.len() - 1) % self.groups.len(); 26 | self.group_list_state.select(Some(self.selected_group)); 27 | self.update_hosts_for_selected_group(); 28 | self.selected_host = 0; 29 | self.host_list_state.select(Some(self.selected_host)); 30 | } 31 | } 32 | 33 | pub fn get_current_group(&self) -> Option<&str> { 34 | self.groups.get(self.selected_group).map(|s| s.as_str()) 35 | } 36 | 37 | // update_hosts_for_selected_group is now implemented in state.rs 38 | // Handle key 39 | pub fn handle_key_enter(&mut self, terminal: &mut Terminal) -> Result<()> { 40 | if let Some(selected_host) = self.get_current_selected_host().cloned() { 41 | tracing::info!("Enter pressed, selected host: {:?}", selected_host.alias); 42 | 43 | // Store the connecting host 44 | self.connecting_host = Some(selected_host.clone()); 45 | 46 | // Tạo channel để communication 47 | let (sender, receiver) = mpsc::channel::(); 48 | self.ssh_receiver = Some(receiver); 49 | 50 | // Set connecting state 51 | self.is_connecting = true; 52 | self.ssh_ready_for_terminal = false; 53 | self.status_message = Some(( 54 | format!("Connecting to {}...", selected_host.alias), 55 | Instant::now(), 56 | )); 57 | 58 | // Spawn SSH thread 59 | let host_clone = selected_host.clone(); 60 | thread::spawn(move || { 61 | Self::ssh_thread_worker(sender, host_clone); 62 | }); 63 | 64 | // Redraw UI to show loading 65 | terminal.draw(|f| crate::ui::hosts_list::draw::(f, self))?; 66 | } 67 | Ok(()) 68 | } 69 | 70 | pub fn handle_key_q(&mut self) -> Result<()> { 71 | self.should_quit = true; 72 | Ok(()) 73 | } 74 | 75 | pub fn handle_key_e(&mut self) -> Result<()> { 76 | // Get the path to the hosts file 77 | let hosts_path = self.config_manager.get_hosts_path(); 78 | 79 | // Create the file if it doesn't exist 80 | if !hosts_path.exists() { 81 | if let Some(parent) = hosts_path.parent() { 82 | std::fs::create_dir_all(parent)?; 83 | } 84 | std::fs::write(&hosts_path, "")?; 85 | } 86 | 87 | // TODO: Can use nvim, vim, nano if exist instead of default text editor 88 | // Open the file with the default text editor 89 | if let Err(e) = open::that(&hosts_path) { 90 | tracing::error!("Failed to open editor: {}", e); 91 | return Err(anyhow::anyhow!("Failed to open editor: {}", e)); 92 | } 93 | 94 | // Reload the config after the editor is closed 95 | self.load_all_hosts()?; 96 | 97 | Ok(()) 98 | } 99 | 100 | pub fn handle_key_esc(&mut self) -> Result<()> { 101 | self.input_mode = InputMode::Normal; 102 | Ok(()) 103 | } 104 | 105 | pub fn handle_key_tab(&mut self) -> Result<()> { 106 | self.switch_panel(); 107 | Ok(()) 108 | } 109 | 110 | pub fn handle_key_right(&mut self) -> Result<()> { 111 | match self.active_panel { 112 | ActivePanel::Groups => self.next_group(), 113 | ActivePanel::Hosts => self.select_next(), 114 | } 115 | Ok(()) 116 | } 117 | 118 | pub fn handle_key_left(&mut self) -> Result<()> { 119 | match self.active_panel { 120 | ActivePanel::Groups => self.previous_group(), 121 | ActivePanel::Hosts => self.select_previous(), 122 | } 123 | Ok(()) 124 | } 125 | 126 | pub fn handle_shift_tab(&mut self) -> Result<()> { 127 | self.previous_group(); 128 | Ok(()) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/sftp_logic/local.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use std::fs; 3 | use std::path::{Path}; 4 | use super::types::{FileItem, AppSftpState}; 5 | 6 | impl AppSftpState { 7 | /// Refresh the local file list 8 | pub fn refresh_local(&mut self) -> Result<()> { 9 | self.local_files = Self::read_local_directory(&self.local_current_path)?; 10 | if self.local_selected >= self.local_files.len() { 11 | self.local_selected = self.local_files.len().saturating_sub(1); 12 | } 13 | self.clear_status_message(); 14 | Ok(()) 15 | } 16 | 17 | /// Navigate up in the local file list 18 | pub fn navigate_local_up(&mut self) { 19 | if self.local_selected > 0 { 20 | self.local_selected -= 1; 21 | } else if !self.local_files.is_empty() { 22 | self.local_selected = self.local_files.len() - 1; 23 | } 24 | self.local_list_state.select(Some(self.local_selected)); 25 | } 26 | 27 | /// Navigate down in the local file list 28 | pub fn navigate_local_down(&mut self) { 29 | if self.local_selected < self.local_files.len().saturating_sub(1) { 30 | self.local_selected += 1; 31 | } else { 32 | self.local_selected = 0; 33 | } 34 | self.local_list_state.select(Some(self.local_selected)); 35 | } 36 | 37 | /// Open the selected item in the local file list 38 | pub fn open_local_selected(&mut self) -> Result<()> { 39 | if let Some(item) = self.local_files.get(self.local_selected) { 40 | match item { 41 | FileItem::Directory { name } => { 42 | if name == ".." { 43 | if let Some(parent) = self.local_current_path.parent() { 44 | self.local_current_path = parent.to_path_buf(); 45 | } 46 | } else { 47 | self.local_current_path = self.local_current_path.join(name); 48 | } 49 | self.local_selected = 0; 50 | self.local_list_state.select(Some(self.local_selected)); 51 | self.refresh_local()?; 52 | } 53 | FileItem::File { .. } => { 54 | // Files can't be opened in file browser context 55 | } 56 | } 57 | } 58 | Ok(()) 59 | } 60 | 61 | /// Go up one directory in the local file system 62 | pub fn go_local_back(&mut self) -> Result<()> { 63 | if let Some(parent) = self.local_current_path.parent() { 64 | self.local_current_path = parent.to_path_buf(); 65 | self.local_selected = 0; 66 | self.refresh_local()?; 67 | } 68 | Ok(()) 69 | } 70 | 71 | /// Read the contents of a local directory 72 | fn read_local_directory(path: &Path) -> Result> { 73 | let mut items = Vec::new(); 74 | 75 | // Add parent directory entry if not at root 76 | if path.parent().is_some() { 77 | items.push(FileItem::Directory { 78 | name: "..".to_string(), 79 | }); 80 | } 81 | 82 | let entries = fs::read_dir(path).context("Failed to read local directory")?; 83 | 84 | for entry in entries { 85 | let entry = entry.context("Failed to read directory entry")?; 86 | let file_name = entry.file_name().to_string_lossy().to_string(); 87 | let metadata = entry.metadata().context("Failed to read file metadata")?; 88 | 89 | if metadata.is_dir() { 90 | items.push(FileItem::Directory { name: file_name }); 91 | } else { 92 | items.push(FileItem::File { 93 | name: file_name, 94 | size: metadata.len(), 95 | }); 96 | } 97 | } 98 | 99 | // Sort: directories first, then files, both alphabetically 100 | items.sort_by(|a, b| { 101 | use std::cmp::Ordering; 102 | match (a, b) { 103 | (FileItem::Directory { name: name_a }, FileItem::Directory { name: name_b }) => { 104 | if name_a == ".." { 105 | Ordering::Less 106 | } else if name_b == ".." { 107 | Ordering::Greater 108 | } else { 109 | name_a.cmp(name_b) 110 | } 111 | } 112 | (FileItem::Directory { .. }, FileItem::File { .. }) => Ordering::Less, 113 | (FileItem::File { .. }, FileItem::Directory { .. }) => Ordering::Greater, 114 | (FileItem::File { name: name_a, .. }, FileItem::File { name: name_b, .. }) => { 115 | name_a.cmp(name_b) 116 | } 117 | } 118 | }); 119 | 120 | Ok(items) 121 | } 122 | } -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use dirs; 3 | use serde::{Deserialize, Serialize}; 4 | use std::fs; 5 | use std::path::{Path, PathBuf}; 6 | 7 | use crate::models::SshHost; 8 | 9 | #[derive(Debug, Serialize, Deserialize, Clone)] 10 | pub struct ThemeColors { 11 | pub primary: String, 12 | pub secondary: String, 13 | pub background: String, 14 | pub text: String, 15 | pub highlight: String, 16 | pub error: String, 17 | pub warning: String, 18 | pub success: String, 19 | } 20 | 21 | #[derive(Debug, Serialize, Deserialize, Clone)] 22 | pub struct Theme { 23 | pub name: String, 24 | pub colors: ThemeColors, 25 | } 26 | 27 | #[derive(Debug, Serialize, Deserialize)] 28 | pub struct AppConfig { 29 | pub default_theme: String, 30 | pub themes: Vec, 31 | pub ssh_file_config: String, 32 | } 33 | 34 | #[derive(Debug, Serialize, Deserialize, Clone)] 35 | pub struct HostGroup { 36 | pub name: String, 37 | pub description: Option, 38 | pub hosts: Vec, 39 | } 40 | 41 | #[derive(Debug, Serialize, Deserialize)] 42 | pub struct HostsConfig { 43 | pub groups: Vec, 44 | } 45 | 46 | impl Default for ThemeColors { 47 | fn default() -> Self { 48 | Self { 49 | primary: "#454545".to_string(), 50 | secondary: "#454545".to_string(), 51 | background: "#1a202c".to_string(), 52 | text: "#ffffff".to_string(), 53 | highlight: "#454545".to_string(), 54 | error: "#ff005f".to_string(), 55 | warning: "#ffb86c".to_string(), 56 | success: "#50fa7b".to_string(), 57 | } 58 | } 59 | } 60 | 61 | impl Default for Theme { 62 | fn default() -> Self { 63 | Self { 64 | name: "default".to_string(), 65 | colors: ThemeColors::default(), 66 | } 67 | } 68 | } 69 | 70 | impl Default for AppConfig { 71 | fn default() -> Self { 72 | 73 | // Set default ssh config path 74 | let ssh_config_path = dirs::home_dir().unwrap().join(".ssh").join("config"); 75 | Self { 76 | default_theme: "default".to_string(), 77 | themes: vec![Theme::default()], 78 | ssh_file_config: ssh_config_path.to_str().unwrap().to_string(), 79 | } 80 | } 81 | } 82 | 83 | impl Default for HostsConfig { 84 | fn default() -> Self { 85 | Self { groups: Vec::new() } 86 | } 87 | } 88 | 89 | #[derive(Debug)] 90 | pub struct ConfigManager { 91 | #[allow(dead_code)] 92 | config_dir: PathBuf, 93 | config_file: PathBuf, 94 | hosts_file: PathBuf, 95 | } 96 | 97 | impl ConfigManager { 98 | pub fn new() -> Result { 99 | let config_dir = dirs::config_dir() 100 | .context("Could not find config directory")? 101 | .join("sshr"); 102 | 103 | // Create config directory if it doesn't exist 104 | if !config_dir.exists() { 105 | fs::create_dir_all(&config_dir).context("Failed to create config directory")?; 106 | } 107 | 108 | let config_file = config_dir.join("sshr.toml"); 109 | let hosts_file = config_dir.join("hosts.toml"); 110 | 111 | Ok(Self { 112 | config_dir, 113 | config_file, 114 | hosts_file, 115 | }) 116 | } 117 | 118 | // pub fn get_config_dir(&self) -> &Path { 119 | // &self.config_dir 120 | // } 121 | 122 | pub fn load_config(&self) -> Result { 123 | // If config file doesn't exist, create it with default values 124 | if !self.config_file.exists() { 125 | let default_config = AppConfig::default(); 126 | self.save_config(&default_config)?; 127 | } 128 | 129 | let content: String = 130 | fs::read_to_string(&self.config_file).context("Failed to read config file")?; 131 | 132 | let mut config: AppConfig = 133 | toml::from_str(&content).context("Failed to parse config file")?; 134 | 135 | // Ensure there's always at least the default theme 136 | if config.themes.is_empty() { 137 | config.themes.push(Theme::default()); 138 | } 139 | 140 | // Ensure the default theme exists 141 | if !config.themes.iter().any(|t| t.name == config.default_theme) { 142 | config.default_theme = config.themes[0].name.clone(); 143 | } 144 | 145 | Ok(config) 146 | } 147 | 148 | pub fn save_config(&self, config: &AppConfig) -> Result<()> { 149 | let toml = toml::to_string_pretty(config).context("Failed to serialize config")?; 150 | fs::write(&self.config_file, toml).context("Failed to write config file")?; 151 | Ok(()) 152 | } 153 | 154 | // TODO: Add theme support 155 | // pub fn get_theme(&self, theme_name: Option<&str>) -> Result { 156 | // let config = self.load_config()?; 157 | // let theme_name = theme_name.unwrap_or(&config.default_theme); 158 | 159 | // config 160 | // .themes 161 | // .iter() 162 | // .find(|t| t.name == *theme_name) 163 | // .or_else(|| config.themes.first()) 164 | // .cloned() 165 | // .ok_or_else(|| anyhow::anyhow!("No themes available")) 166 | // } 167 | 168 | // pub fn get_config_path(&self) -> &Path { 169 | // &self.config_file 170 | // } 171 | 172 | pub fn load_hosts(&self) -> Result> { 173 | // If hosts file doesn't exist, return empty vector 174 | if !self.hosts_file.exists() { 175 | return Ok(Vec::new()); 176 | } 177 | 178 | let content = fs::read_to_string(&self.hosts_file) 179 | .context("Failed to read hosts file")?; 180 | 181 | let config: HostsConfig = toml::from_str(&content) 182 | .context("Failed to parse hosts file")?; 183 | 184 | // Flatten groups into a single vector of hosts 185 | let mut hosts = Vec::new(); 186 | for group in config.groups { 187 | for mut host in group.hosts { 188 | // Set group name for each host 189 | host.group = Some(group.name.clone()); 190 | hosts.push(host); 191 | } 192 | } 193 | 194 | Ok(hosts) 195 | } 196 | 197 | // pub fn save_hosts(&self, groups: &[HostGroup]) -> Result<()> { 198 | // // Create hosts file if it doesn't exist 199 | // if !self.hosts_file.exists() { 200 | // fs::write(&self.hosts_file, "").context("Failed to create hosts file")?; 201 | // } 202 | 203 | // let config = HostsConfig { 204 | // groups: groups.to_vec(), 205 | // }; 206 | 207 | // let toml = toml::to_string_pretty(&config) 208 | // .context("Failed to serialize hosts")?; 209 | 210 | // fs::write(&self.hosts_file, toml) 211 | // .context("Failed to write hosts file")?; 212 | 213 | // Ok(()) 214 | // } 215 | 216 | pub fn get_hosts_path(&self) -> &Path { 217 | &self.hosts_file 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/sftp_logic/remote.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use std::process::Command; 3 | use super::types::{FileItem, AppSftpState}; 4 | 5 | impl AppSftpState { 6 | /// Refresh the remote file list 7 | pub fn refresh_remote(&mut self) -> Result<()> { 8 | self.set_status_message("Loading remote directory..."); 9 | self.remote_files = Self::read_remote_directory( 10 | &self.ssh_user, 11 | &self.ssh_host, 12 | self.ssh_port, 13 | &self.remote_current_path 14 | )?; 15 | if self.remote_selected >= self.remote_files.len() { 16 | self.remote_selected = self.remote_files.len().saturating_sub(1); 17 | } 18 | self.clear_status_message(); 19 | Ok(()) 20 | } 21 | 22 | /// Navigate up in the remote file list 23 | pub fn navigate_remote_up(&mut self) { 24 | if self.remote_selected > 0 { 25 | self.remote_selected -= 1; 26 | } else if !self.remote_files.is_empty() { 27 | self.remote_selected = self.remote_files.len() - 1; 28 | } 29 | self.remote_list_state.select(Some(self.remote_selected)); 30 | } 31 | 32 | /// Navigate down in the remote file list 33 | pub fn navigate_remote_down(&mut self) { 34 | if self.remote_selected < self.remote_files.len().saturating_sub(1) { 35 | self.remote_selected += 1; 36 | } else { 37 | self.remote_selected = 0; 38 | } 39 | self.remote_list_state.select(Some(self.remote_selected)); 40 | } 41 | 42 | /// Open the selected item in the remote file list 43 | pub fn open_remote_selected(&mut self) -> Result<()> { 44 | if let Some(item) = self.remote_files.get(self.remote_selected) { 45 | match item { 46 | FileItem::Directory { name } => { 47 | if name == ".." { 48 | // Go to parent directory 49 | if self.remote_current_path != "/" { 50 | let mut path_parts: Vec<&str> = self.remote_current_path 51 | .trim_end_matches('/') 52 | .split('/') 53 | .collect(); 54 | if path_parts.len() > 1 { 55 | path_parts.pop(); 56 | self.remote_current_path = if path_parts.len() == 1 { 57 | "/".to_string() 58 | } else { 59 | path_parts.join("/") 60 | }; 61 | } 62 | } 63 | } else { 64 | // Enter directory 65 | self.remote_current_path = if self.remote_current_path.ends_with('/') { 66 | format!("{}{}", self.remote_current_path, name) 67 | } else { 68 | format!("{}/{}", self.remote_current_path, name) 69 | }; 70 | } 71 | self.remote_selected = 0; 72 | self.remote_list_state.select(Some(self.remote_selected)); 73 | self.refresh_remote()?; 74 | } 75 | FileItem::File { .. } => { 76 | // Files can't be opened in file browser context 77 | } 78 | } 79 | } 80 | Ok(()) 81 | } 82 | 83 | /// Go up one directory in the remote file system 84 | pub fn go_remote_back(&mut self) -> Result<()> { 85 | if self.remote_current_path != "/" { 86 | let mut path_parts: Vec<&str> = self.remote_current_path 87 | .trim_end_matches('/') 88 | .split('/') 89 | .collect(); 90 | if path_parts.len() > 1 { 91 | path_parts.pop(); 92 | self.remote_current_path = if path_parts.len() == 1 { 93 | "/".to_string() 94 | } else { 95 | path_parts.join("/") 96 | }; 97 | self.remote_selected = 0; 98 | self.refresh_remote()?; 99 | } 100 | } 101 | Ok(()) 102 | } 103 | 104 | /// Read the contents of a remote directory 105 | fn read_remote_directory( 106 | user: &str, 107 | host: &str, 108 | port: u16, 109 | remote_path: &str, 110 | ) -> Result> { 111 | let output = Command::new("ssh") 112 | .arg(format!("{}@{}", user, host)) 113 | .arg("-p") 114 | .arg(port.to_string()) 115 | .arg("-o") 116 | .arg("ConnectTimeout=10") 117 | .arg("-o") 118 | .arg("StrictHostKeyChecking=no") 119 | .arg("-o") 120 | .arg("LogLevel=ERROR") 121 | .arg(format!("ls -la '{}'", remote_path)) 122 | .output() 123 | .context("Failed to execute remote ls command")?; 124 | 125 | if !output.status.success() { 126 | let stderr = String::from_utf8_lossy(&output.stderr); 127 | return Err(anyhow::anyhow!("Remote ls failed: {}", stderr)); 128 | } 129 | 130 | let stdout = String::from_utf8_lossy(&output.stdout); 131 | let mut items = Vec::new(); 132 | 133 | // Add parent directory entry if not at root 134 | if remote_path != "/" { 135 | items.push(FileItem::Directory { 136 | name: "..".to_string(), 137 | }); 138 | } 139 | 140 | for line in stdout.lines().skip(1) { // Skip total line 141 | let parts: Vec<&str> = line.split_whitespace().collect(); 142 | if parts.len() < 9 { 143 | continue; 144 | } 145 | 146 | let permissions = parts[0]; 147 | let file_name = parts[8..].join(" "); 148 | 149 | // Skip . and .. entries (we handle .. manually) 150 | if file_name == "." || file_name == ".." { 151 | continue; 152 | } 153 | 154 | if permissions.starts_with('d') { 155 | items.push(FileItem::Directory { name: file_name }); 156 | } else { 157 | let size = parts[4].parse::().unwrap_or(0); 158 | items.push(FileItem::File { 159 | name: file_name, 160 | size, 161 | }); 162 | } 163 | } 164 | 165 | // Sort: directories first, then files, both alphabetically 166 | items.sort_by(|a, b| { 167 | use std::cmp::Ordering; 168 | match (a, b) { 169 | (FileItem::Directory { name: name_a }, FileItem::Directory { name: name_b }) => { 170 | if name_a == ".." { 171 | Ordering::Less 172 | } else if name_b == ".." { 173 | Ordering::Greater 174 | } else { 175 | name_a.cmp(name_b) 176 | } 177 | } 178 | (FileItem::Directory { .. }, FileItem::File { .. }) => Ordering::Less, 179 | (FileItem::File { .. }, FileItem::Directory { .. }) => Ordering::Greater, 180 | (FileItem::File { name: name_a, .. }, FileItem::File { name: name_b, .. }) => { 181 | name_a.cmp(name_b) 182 | } 183 | } 184 | }); 185 | 186 | Ok(items) 187 | } 188 | } -------------------------------------------------------------------------------- /src/app/host.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{App, InputMode}; 2 | use crate::models::SshHost; 3 | use anyhow::{Context, Result}; 4 | use std::collections::HashSet; 5 | use std::fs; 6 | use std::net::ToSocketAddrs; 7 | 8 | impl App { 9 | /// Update the list of groups and the hosts in the current group 10 | pub fn update_groups(&mut self) { 11 | // Extract unique group names from hosts 12 | let mut groups: Vec = self.hosts 13 | .iter() 14 | .filter_map(|host| host.group.clone()) 15 | .collect::>() 16 | .into_iter() 17 | .collect(); 18 | 19 | // Sort groups alphabetically 20 | groups.sort(); 21 | 22 | // Update groups list 23 | self.groups = groups; 24 | 25 | // If no groups, clear the current group selection 26 | if self.groups.is_empty() { 27 | self.hosts_in_current_group.clear(); 28 | return; 29 | } 30 | 31 | // Ensure selected_group is within bounds 32 | if self.selected_group >= self.groups.len() { 33 | self.selected_group = self.groups.len().saturating_sub(1); 34 | } 35 | 36 | // Update hosts for the current group 37 | self.update_hosts_for_selected_group(); 38 | } 39 | 40 | pub fn load_all_hosts(&mut self) -> Result<()> { 41 | self.load_ssh_config() 42 | .context("Failed to load SSH config")?; 43 | self.load_custom_hosts() 44 | .context("Failed to load custom hosts")?; 45 | self.handle_duplicate_hosts(); 46 | 47 | // Update groups after loading all hosts 48 | self.update_groups(); 49 | 50 | if self.hosts.is_empty() { 51 | self.selected_host = 0; 52 | } else if self.selected_host >= self.hosts.len() { 53 | self.selected_host = self.hosts.len().saturating_sub(1); 54 | } 55 | self.filter_hosts(); 56 | Ok(()) 57 | } 58 | 59 | pub fn load_ssh_config(&mut self) -> Result<()> { 60 | // Clear only system-loaded hosts to allow custom hosts to persist across reloads 61 | self.hosts.retain(|h| h.group.is_some()); // Retain only custom hosts (those with a group) 62 | 63 | if !self.ssh_config_path.exists() { 64 | tracing::warn!( 65 | "System SSH config file not found at {:?}", 66 | self.ssh_config_path 67 | ); 68 | return Ok(()); 69 | } 70 | 71 | if !self.ssh_config_path.exists() { 72 | tracing::warn!( 73 | "System SSH config file not found at {:?}", 74 | self.ssh_config_path 75 | ); 76 | return Ok(()); 77 | } 78 | 79 | let config_content = 80 | fs::read_to_string(&self.ssh_config_path).context("Failed to read SSH config file")?; 81 | 82 | let mut current_host: Option = None; 83 | 84 | for line in config_content.lines() { 85 | let line = line.trim(); 86 | if line.is_empty() { 87 | continue; 88 | } 89 | 90 | if line.to_lowercase().starts_with("host ") { 91 | // Save previous host if exists 92 | if let Some(host) = current_host.take() { 93 | // Check if a host with this alias already exists from custom config 94 | if !self.hosts.iter().any(|h| h.alias == host.alias) { 95 | self.hosts.push(host); 96 | } else { 97 | tracing::warn!( 98 | "Skipping SSH config host '{}' as it's duplicated by a custom host.", 99 | host.alias 100 | ); 101 | } 102 | } 103 | 104 | // Start new host 105 | let alias = line[5..].trim().to_string(); 106 | current_host = Some(SshHost::new(alias, String::new(), "root".to_string())); 107 | } else if let Some(host) = &mut current_host { 108 | let parts: Vec<&str> = line.split_whitespace().collect(); 109 | if parts.len() < 2 { 110 | continue; 111 | } 112 | 113 | match parts[0].to_lowercase().as_str() { 114 | "hostname" => host.host = parts[1].to_string(), 115 | "user" => host.user = parts[1].to_string(), 116 | "port" => { 117 | if let Ok(port) = parts[1].parse::() { 118 | host.port = Some(port); 119 | } 120 | } 121 | _ => {} 122 | } 123 | } 124 | } 125 | 126 | tracing::info!("Loaded {} hosts from SSH config", self.hosts.len()); 127 | 128 | // Don't forget to add the last host 129 | if let Some(host) = current_host { 130 | if !self.hosts.iter().any(|h| h.alias == host.alias) { 131 | self.hosts.push(host); 132 | } else { 133 | tracing::warn!( 134 | "Skipping SSH config host '{}' as it's duplicated by a custom host.", 135 | host.alias 136 | ); 137 | } 138 | } 139 | 140 | tracing::info!( 141 | "Loaded {} hosts from SSH config (after merging with custom hosts)", 142 | self.hosts.len() 143 | ); 144 | 145 | // Check reachability for each host 146 | for host in &mut self.hosts { 147 | if host.group.is_none() { 148 | // Only update description for system hosts if not already set by custom 149 | let socket_addr = format!("{}:{}", host.host, host.port.unwrap_or(22)) 150 | .to_socket_addrs() 151 | .ok() 152 | .and_then(|mut addrs| addrs.next()); 153 | 154 | host.description = if socket_addr.is_some() { 155 | Some("Reachable".to_string()) 156 | } else { 157 | Some("Unreachable".to_string()) 158 | }; 159 | } 160 | } 161 | 162 | Ok(()) 163 | } 164 | 165 | // Load custome hosts from hosts.toml 166 | pub fn load_custom_hosts(&mut self) -> Result<()> { 167 | match self.config_manager.load_hosts() { 168 | Ok(mut custom_hosts) => { 169 | // Prepend custom hosts to the list, as they often take precedence or are more frequently used. 170 | // Filter out any custom hosts that might have duplicate aliases with existing system hosts 171 | // (though `handle_duplicate_hosts` will catch this later, this is a good defensive step). 172 | let mut existing_aliases: HashSet = 173 | self.hosts.iter().map(|h| h.alias.clone()).collect(); 174 | custom_hosts.retain(|host| { 175 | if existing_aliases.contains(&host.alias) { 176 | tracing::warn!("Skipping custom host '{}' due to duplicate alias. System host will be used.", host.alias); 177 | false 178 | } else { 179 | existing_aliases.insert(host.alias.clone()); 180 | true 181 | } 182 | }); 183 | 184 | // Insert custom hosts at the beginning 185 | self.hosts.splice(0..0, custom_hosts); 186 | Ok(()) 187 | } 188 | Err(e) => { 189 | tracing::error!("Failed to load custom hosts: {}", e); 190 | // Don't propagate error, just log it, so app can still run. 191 | Ok(()) 192 | } 193 | } 194 | } 195 | 196 | // Remove duplicate hosts 197 | pub fn handle_duplicate_hosts(&mut self) { 198 | let mut seen_aliases = HashSet::new(); 199 | let mut unique_hosts = Vec::new(); 200 | for host in self.hosts.drain(..) { 201 | // Use drain to consume self.hosts 202 | if seen_aliases.contains(&host.alias) { 203 | tracing::warn!("Duplicate alias found: {}", host.alias); 204 | } else { 205 | seen_aliases.insert(host.alias.clone()); 206 | unique_hosts.push(host); 207 | } 208 | } 209 | self.hosts = unique_hosts; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use clap::Parser; 3 | use crossterm::{ 4 | event::{ 5 | self, DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, KeyCode, 6 | KeyModifiers, 7 | }, 8 | execute, 9 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 10 | }; 11 | use ratatui::{backend::CrosstermBackend, Terminal}; 12 | use std::path::Path; 13 | use std::{fs::File, time::Instant}; 14 | use std::{io, time::Duration}; 15 | use tracing_subscriber::{fmt, EnvFilter}; 16 | 17 | mod app_event; 18 | mod config; 19 | mod models; 20 | mod sftp_logic; 21 | mod sftp_ui; 22 | mod app; 23 | mod ui; 24 | 25 | use ui::{ 26 | hosts_list::{draw}, 27 | }; 28 | 29 | use crate::app::{App, InputMode}; 30 | 31 | /// A TUI for managing and connecting to SSH hosts 32 | /// Git: https://github.com/hoangneeee/sshr 33 | #[derive(Parser, Debug)] 34 | #[command(author, version, about, long_about = None)] 35 | struct Args { 36 | // No need for a custom version flag as clap provides it by default 37 | } 38 | 39 | fn setup_logging() -> Result<()> { 40 | let log_dir = if cfg!(debug_assertions) { 41 | // In debug mode, log to ./logs 42 | let dir = "logs"; 43 | if !Path::new(dir).exists() { 44 | std::fs::create_dir_all(dir).context("Failed to create log directory")?; 45 | } 46 | dir.to_string() 47 | } else { 48 | // In release mode, log to /tmp/sshr_logs 49 | let dir = "/tmp/sshr_logs"; 50 | if !Path::new(dir).exists() { 51 | std::fs::create_dir_all(dir).context("Failed to create /tmp/sshr_logs directory")?; 52 | } 53 | dir.to_string() 54 | }; 55 | 56 | let log_file_name = format!("{}/sshr_debug.log", log_dir); 57 | 58 | let log_file = File::create(&log_file_name).context("Failed to create log file")?; 59 | 60 | fmt() 61 | .with_env_filter( 62 | EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,sshr=debug")), 63 | ) 64 | .with_writer(log_file) 65 | .with_ansi(false) 66 | .init(); 67 | 68 | tracing::info!("SSHr started (log file: {})", log_file_name); 69 | Ok(()) 70 | } 71 | 72 | #[tokio::main] 73 | async fn main() -> Result<()> { 74 | let _args = Args::parse(); 75 | 76 | // Setup logging 77 | if let Err(e) = setup_logging() { 78 | eprintln!("Failed to setup logging: {}", e); 79 | // Continue running even if logging setup fails 80 | } 81 | 82 | // Initialize the app with configuration 83 | let app = App::new().context("Failed to initialize application")?; 84 | 85 | // Setup terminal 86 | enable_raw_mode().context("Failed to enable raw mode")?; 87 | let mut stdout = io::stdout(); 88 | execute!(stdout, EnterAlternateScreen, EnableMouseCapture) 89 | .context("Failed to enter alternate screen or enable mouse capture")?; 90 | 91 | let backend = CrosstermBackend::new(stdout); 92 | let mut terminal = Terminal::new(backend).context("Failed to create terminal")?; 93 | 94 | // Run the application 95 | tracing::info!("Running application"); 96 | let res = run_app(&mut terminal, app).await; 97 | 98 | // Restore terminal 99 | disable_raw_mode().context("Failed to disable raw mode")?; 100 | execute!( 101 | terminal.backend_mut(), 102 | LeaveAlternateScreen, 103 | DisableMouseCapture 104 | ) 105 | .context("Failed to leave alternate screen or disable mouse capture")?; 106 | terminal.show_cursor().context("Failed to show cursor")?; 107 | 108 | if let Err(err) = res { 109 | eprintln!("\nApplication error: {:?}", err); 110 | tracing::error!("Application exited with error: {:?}", err); 111 | } else { 112 | tracing::info!("sshr exited successfully"); 113 | } 114 | 115 | Ok(()) 116 | } 117 | 118 | async fn run_app( 119 | terminal: &mut Terminal, 120 | mut app: App, 121 | ) -> Result<()> { 122 | loop { 123 | // Process events 124 | let needs_redraw = app.process_ssh_events::(terminal)?; 125 | 126 | // Process SFTP events 127 | let _ = app.process_sftp_events::(terminal)?; 128 | 129 | app.process_transfer_events()?; 130 | 131 | // If we're in SSH mode, suspend the main loop until SSH ends 132 | if app.ssh_ready_for_terminal { 133 | tracing::info!("SSH mode active - suspending main loop"); 134 | 135 | // Wait for SSH session to end with longer intervals 136 | loop { 137 | // Check for SSH events with longer timeout 138 | let ssh_ended = app.process_ssh_events::(terminal)?; 139 | if ssh_ended || !app.ssh_ready_for_terminal { 140 | tracing::info!("SSH session ended or interrupted - resuming main loop"); 141 | break; 142 | } 143 | 144 | // Sleep longer to avoid interfering with SSH session 145 | tokio::time::sleep(Duration::from_millis(500)).await; 146 | } 147 | 148 | // Force redraw when returning from SSH 149 | terminal.draw(|f| draw::(f, &mut app))?; 150 | continue; 151 | } 152 | 153 | // Draw UI (only when not in SSH mode) 154 | terminal.draw(|f: &mut ratatui::Frame<'_>| match app.input_mode { 155 | InputMode::Sftp => { 156 | if let Some(sftp_state) = &mut app.sftp_state { 157 | sftp_ui::draw_sftp::(f, sftp_state); 158 | } else { 159 | draw::(f, &mut app); 160 | } 161 | } 162 | _ => draw::(f, &mut app), 163 | })?; 164 | 165 | // Handle terminal events with appropriate timeout 166 | let poll_timeout = if app.is_connecting { 167 | Duration::from_millis(50) // Faster polling when connecting 168 | } else { 169 | Duration::from_millis(100) // Normal polling 170 | }; 171 | 172 | if event::poll(poll_timeout).context("Event poll failed")? { 173 | if let CrosstermEvent::Key(key_event) = event::read().context("Event read failed")? { 174 | // Only handle keys if not connecting and not in SSH mode 175 | if key_event.kind == event::KeyEventKind::Press 176 | && !app.is_connecting 177 | && !app.ssh_ready_for_terminal 178 | { 179 | handle_key_events(&mut app, key_event, terminal).await?; 180 | } 181 | } 182 | } 183 | 184 | if app.should_quit { 185 | return Ok(()); 186 | } 187 | 188 | // Force redraw if needed 189 | if needs_redraw { 190 | terminal.draw(|f| match app.input_mode { 191 | InputMode::Sftp => { 192 | if let Some(sftp_state) = &mut app.sftp_state { 193 | sftp_ui::draw_sftp::(f, sftp_state); 194 | } else { 195 | draw::(f, &mut app); 196 | } 197 | } 198 | _ => draw::(f, &mut app), 199 | })?; 200 | } 201 | } 202 | } 203 | 204 | async fn handle_key_events( 205 | app: &mut App, 206 | key_event: crossterm::event::KeyEvent, 207 | terminal: &mut Terminal, 208 | ) -> Result<()> { 209 | match app.input_mode { 210 | InputMode::Normal => match key_event.code { 211 | KeyCode::Char('q') | KeyCode::Char('Q') => { 212 | app.handle_key_q()?; 213 | } 214 | KeyCode::Char('c') if key_event.modifiers == KeyModifiers::CONTROL => { 215 | app.should_quit = true; 216 | } 217 | KeyCode::Char('s') => { 218 | // Enter search mode 219 | app.enter_search_mode(); 220 | } 221 | KeyCode::Tab => { 222 | if key_event.modifiers.contains(KeyModifiers::SHIFT) { 223 | app.handle_shift_tab()?; 224 | } else { 225 | app.handle_key_tab()?; 226 | } 227 | } 228 | KeyCode::Right => { 229 | app.handle_key_right()?; 230 | } 231 | KeyCode::Left => { 232 | app.handle_key_left()?; 233 | } 234 | KeyCode::Char('f') => { 235 | // Enter SFTP mode 236 | app.enter_sftp_mode(terminal)?; 237 | } 238 | KeyCode::Up | KeyCode::Char('k') => { 239 | app.select_previous(); 240 | } 241 | KeyCode::Down | KeyCode::Char('j') => { 242 | app.select_next(); 243 | } 244 | KeyCode::Char('e') => { 245 | if let Err(e) = app.handle_key_e() { 246 | tracing::error!("Failed to open editor: {}", e); 247 | app.status_message = 248 | Some((format!("Failed to open editor: {}", e), Instant::now())); 249 | } 250 | } 251 | KeyCode::Esc => { 252 | app.handle_key_esc()?; 253 | } 254 | KeyCode::Enter => { 255 | app.handle_key_enter(terminal)?; 256 | } 257 | KeyCode::Char('r') => { 258 | tracing::info!("Reloading SSH config..."); 259 | if let Err(e) = app.load_all_hosts() { 260 | tracing::error!("Failed to reload SSH config: {}", e); 261 | app.status_message = Some((format!("Reload failed: {}", e), Instant::now())); 262 | } else { 263 | app.status_message = 264 | Some(("Config reloaded successfully".to_string(), Instant::now())); 265 | } 266 | } 267 | _ => {} 268 | }, 269 | InputMode::Search => { 270 | match key_event.code { 271 | KeyCode::Char(c) => { 272 | app.search_query.push(c); 273 | app.filter_hosts(); 274 | } 275 | KeyCode::Backspace | KeyCode::Delete => { 276 | app.search_query.pop(); 277 | app.filter_hosts(); 278 | } 279 | KeyCode::Enter => { 280 | // Connect to selected filtered host 281 | app.handle_key_enter(terminal)?; 282 | app.clear_search(); 283 | } 284 | KeyCode::Esc => { 285 | app.clear_search(); 286 | } 287 | KeyCode::Up => { 288 | app.search_select_previous(); 289 | } 290 | KeyCode::Down => { 291 | app.search_select_next(); 292 | } 293 | _ => {} 294 | } 295 | } 296 | 297 | // SFTP INPUT MODE 298 | InputMode::Sftp => app.handle_sftp_key(key_event).await?, 299 | } 300 | Ok(()) 301 | } 302 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [2025] [@hoangneeee](https://github.com/hoangneeee) 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /src/sftp_ui.rs: -------------------------------------------------------------------------------- 1 | use crate::sftp_logic::types::{ 2 | AppSftpState, DownloadProgress, FileItem, PanelSide, UploadProgress, 3 | }; 4 | use ratatui::{ 5 | backend::Backend, 6 | layout::{Constraint, Direction, Layout, Rect}, 7 | style::{Color, Modifier, Style}, 8 | text::{Line, Span}, 9 | widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph}, 10 | Frame, 11 | }; 12 | 13 | pub fn draw_sftp(f: &mut Frame, sftp_state: &mut AppSftpState) { 14 | let main_chunks = Layout::default() 15 | .direction(Direction::Vertical) 16 | .constraints([ 17 | Constraint::Min(3), // Main SFTP content 18 | Constraint::Length(3), // Footer with controls 19 | ]) 20 | .split(f.size()); 21 | 22 | // Split main area into two panels 23 | let panels = Layout::default() 24 | .direction(Direction::Horizontal) 25 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) 26 | .split(main_chunks[0]); 27 | 28 | // Draw local panel (left) 29 | draw_file_panel::( 30 | f, 31 | panels[0], 32 | &mut sftp_state.local_list_state, 33 | &sftp_state.local_files, 34 | sftp_state.local_selected, 35 | &format!("Local: {}", sftp_state.local_current_path.display()), 36 | sftp_state.active_panel == PanelSide::Local, 37 | ); 38 | 39 | // Draw remote panel (right) 40 | draw_file_panel::( 41 | f, 42 | panels[1], 43 | &mut sftp_state.remote_list_state, 44 | &sftp_state.remote_files, 45 | sftp_state.remote_selected, 46 | &format!("Remote: {}", sftp_state.remote_current_path), 47 | sftp_state.active_panel == PanelSide::Remote, 48 | ); 49 | 50 | // Draw footer with controls 51 | draw_sftp_footer::(f, main_chunks[1], sftp_state); 52 | 53 | // Draw status message if exists 54 | if let Some(ref message) = sftp_state.status_message { 55 | draw_status_overlay::(f, message); 56 | } 57 | 58 | // Draw upload progress if active 59 | if let Some(ref progress) = sftp_state.upload_progress { 60 | draw_upload_progress::(f, progress); 61 | } 62 | 63 | // Draw download progress if active 64 | if let Some(ref progress) = sftp_state.download_progress { 65 | draw_download_progress::(f, progress); 66 | } 67 | } 68 | 69 | fn draw_file_panel( 70 | f: &mut Frame, 71 | area: Rect, 72 | list_state: &mut ListState, 73 | files: &[FileItem], 74 | selected: usize, 75 | title: &str, 76 | is_active: bool, 77 | ) { 78 | let border_style = if is_active { 79 | Style::default().fg(Color::Green) 80 | } else { 81 | Style::default().fg(Color::Gray) 82 | }; 83 | 84 | let title_style = if is_active { 85 | Style::default() 86 | .fg(Color::Green) 87 | .add_modifier(Modifier::BOLD) 88 | } else { 89 | Style::default() 90 | .fg(Color::Gray) 91 | .add_modifier(Modifier::BOLD) 92 | }; 93 | 94 | let block = Block::default() 95 | .borders(Borders::ALL) 96 | .border_style(border_style) 97 | .title(title) 98 | .title_style(title_style); 99 | 100 | let list_items: Vec = files 101 | .iter() 102 | .enumerate() 103 | .map(|(i, file)| { 104 | let is_selected = i == selected && is_active; 105 | let mut spans = vec![]; 106 | 107 | // Selection indicator 108 | spans.push(Span::styled( 109 | if is_selected { "> " } else { " " }, 110 | Style::default().fg(Color::Yellow), 111 | )); 112 | 113 | // File type icon and name 114 | let (icon, name_color) = match file { 115 | FileItem::Directory { name } => { 116 | if name == ".." { 117 | ("↰ ", Color::Cyan) 118 | } else { 119 | ("📁 ", Color::Blue) 120 | } 121 | } 122 | FileItem::File { 123 | name: _name, 124 | size: _, 125 | } => ("📄 ", Color::White), 126 | }; 127 | 128 | spans.push(Span::styled(icon, Style::default().fg(Color::Yellow))); 129 | 130 | let name = match file { 131 | FileItem::Directory { name } => name, 132 | FileItem::File { name, size: _ } => name, 133 | }; 134 | 135 | spans.push(Span::styled( 136 | name, 137 | Style::default().fg(if is_selected { 138 | Color::Black 139 | } else { 140 | name_color 141 | }), 142 | )); 143 | 144 | // File size for files 145 | if let FileItem::File { size, .. } = file { 146 | spans.push(Span::styled( 147 | format!(" ({})", format_file_size(*size)), 148 | Style::default().fg(if is_selected { 149 | Color::Black 150 | } else { 151 | Color::Gray 152 | }), 153 | )); 154 | } 155 | 156 | let style = if is_selected { 157 | Style::default() 158 | .bg(Color::Green) 159 | .add_modifier(Modifier::BOLD) 160 | } else { 161 | Style::default() 162 | }; 163 | 164 | ListItem::new(Line::from(spans)).style(style) 165 | }) 166 | .collect(); 167 | 168 | let list = List::new(list_items).block(block); 169 | f.render_stateful_widget(list, area, list_state); 170 | } 171 | 172 | fn draw_sftp_footer(f: &mut Frame, area: Rect, sftp_state: &AppSftpState) { 173 | let footer_chunks = Layout::default() 174 | .direction(Direction::Vertical) 175 | .constraints([ 176 | Constraint::Length(1), 177 | Constraint::Length(1), 178 | Constraint::Length(1), 179 | ]) 180 | .split(area); 181 | 182 | // Navigation help 183 | let nav_text = "↑/↓: Navigate [Enter]: Open [Backspace]: Back [Tab]: Switch Panel"; 184 | let nav_help = Paragraph::new(nav_text).style(Style::default().fg(Color::Gray)); 185 | 186 | // Action help 187 | let action_text = "[u]: Upload [d]: Download [r]: Refresh [q]: Quit SFTP"; 188 | let action_help = Paragraph::new(action_text).style(Style::default().fg(Color::Yellow)); 189 | 190 | // Status/Info 191 | let active_panel_text = format!( 192 | "Active: {} Panel", 193 | match sftp_state.active_panel { 194 | PanelSide::Local => "Local", 195 | PanelSide::Remote => "Remote", 196 | } 197 | ); 198 | let status_help = Paragraph::new(active_panel_text) 199 | .style(Style::default().fg(Color::Cyan)) 200 | .alignment(ratatui::layout::Alignment::Right); 201 | 202 | f.render_widget(nav_help, footer_chunks[0]); 203 | f.render_widget(action_help, footer_chunks[1]); 204 | f.render_widget(status_help, footer_chunks[2]); 205 | } 206 | 207 | fn draw_status_overlay(f: &mut Frame, message: &str) { 208 | let area = centered_rect(60, 5, f.size()); 209 | 210 | let block = Block::default() 211 | .borders(Borders::ALL) 212 | .title(" Status ") 213 | .title_style( 214 | Style::default() 215 | .fg(Color::Yellow) 216 | .add_modifier(Modifier::BOLD), 217 | ) 218 | .border_style(Style::default().fg(Color::Yellow)); 219 | 220 | let paragraph = Paragraph::new(message) 221 | .block(block) 222 | .style(Style::default().fg(Color::White)) 223 | .alignment(ratatui::layout::Alignment::Center); 224 | 225 | f.render_widget(Clear, area); 226 | f.render_widget(paragraph, area); 227 | } 228 | 229 | fn draw_upload_progress(f: &mut Frame, progress: &UploadProgress) { 230 | // Use a wider area to accommodate the file name 231 | let area = bottom_right_rect(40, 6, f.size()); 232 | 233 | // Truncate the file name if it's too long 234 | let max_name_width = 30; 235 | let truncated_name = if progress.file_name.len() > max_name_width { 236 | format!( 237 | "..{}", 238 | &progress.file_name[progress.file_name.len() - (max_name_width - 2)..] 239 | ) 240 | } else { 241 | progress.file_name.clone() 242 | }; 243 | 244 | // Calculate percentage 245 | let percent = if progress.total_size > 0 { 246 | ((progress.uploaded_size as f64 / progress.total_size as f64) * 100.0) as u16 247 | } else { 248 | 0 249 | }; 250 | 251 | // Create a block with the file name as title 252 | let block = Block::default() 253 | .borders(Borders::ALL) 254 | .title_style( 255 | Style::default() 256 | .fg(Color::Yellow) 257 | .add_modifier(Modifier::BOLD), 258 | ) 259 | .border_style(Style::default().fg(Color::Yellow)) 260 | .title(format!(" {} ", truncated_name)); 261 | 262 | // Create the gauge with file sizes as label 263 | let gauge = Gauge::default() 264 | .block(block) 265 | .gauge_style(Style::default().fg(Color::Green).bg(Color::Black)) 266 | .label(format!( 267 | "{} / {}", 268 | format_file_size(progress.uploaded_size), 269 | format_file_size(progress.total_size) 270 | )) 271 | .percent(percent); 272 | 273 | f.render_widget(Clear, area); 274 | f.render_widget(gauge, area); 275 | } 276 | 277 | fn draw_download_progress(f: &mut Frame, progress: &DownloadProgress) { 278 | // Use a wider area to accommodate the file name 279 | let area = bottom_right_rect(40, 6, f.size()); 280 | 281 | // Truncate the file name if it's too long 282 | let max_name_width = 30; 283 | let truncated_name = if progress.file_name.len() > max_name_width { 284 | format!( 285 | "..{}", 286 | &progress.file_name[progress.file_name.len() - (max_name_width - 2)..] 287 | ) 288 | } else { 289 | progress.file_name.clone() 290 | }; 291 | 292 | // Calculate percentage 293 | let percent = if progress.total_size > 0 { 294 | ((progress.downloaded_size as f64 / progress.total_size as f64) * 100.0) as u16 295 | } else { 296 | 0 297 | }; 298 | 299 | // Create a block with the file name as title 300 | let block = Block::default() 301 | .borders(Borders::ALL) 302 | .title_style( 303 | Style::default() 304 | .fg(Color::Yellow) 305 | .add_modifier(Modifier::BOLD), 306 | ) 307 | .border_style(Style::default().fg(Color::Yellow)) 308 | .title(format!(" Download Progress {} ", truncated_name)); 309 | 310 | // Create the gauge with file sizes as label 311 | let gauge = Gauge::default() 312 | .block(block) 313 | .gauge_style(Style::default().fg(Color::Green).bg(Color::Black)) 314 | .label(format!( 315 | "{} / {}", 316 | format_file_size(progress.downloaded_size), 317 | format_file_size(progress.total_size) 318 | )) 319 | .percent(percent); 320 | 321 | f.render_widget(Clear, area); 322 | f.render_widget(gauge, area); 323 | } 324 | 325 | fn bottom_right_rect(percent_x: u16, height: u16, r: Rect) -> Rect { 326 | let popup_layout = Layout::default() 327 | .direction(Direction::Vertical) 328 | .constraints([Constraint::Min(0), Constraint::Length(height)]) 329 | .split(r); 330 | 331 | Layout::default() 332 | .direction(Direction::Horizontal) 333 | .constraints([ 334 | Constraint::Percentage(100 - percent_x), 335 | Constraint::Percentage(percent_x), 336 | ]) 337 | .split(popup_layout[1])[1] 338 | } 339 | 340 | fn format_file_size(size: u64) -> String { 341 | const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; 342 | let mut size = size as f64; 343 | let mut unit_index = 0; 344 | 345 | while size >= 1024.0 && unit_index < UNITS.len() - 1 { 346 | size /= 1024.0; 347 | unit_index += 1; 348 | } 349 | 350 | if unit_index == 0 { 351 | format!("{} {}", size as u64, UNITS[unit_index]) 352 | } else { 353 | format!("{:.1} {}", size, UNITS[unit_index]) 354 | } 355 | } 356 | 357 | fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 358 | let popup_layout = Layout::default() 359 | .direction(Direction::Vertical) 360 | .constraints([ 361 | Constraint::Percentage((100 - percent_y) / 2), 362 | Constraint::Percentage(percent_y), 363 | Constraint::Percentage((100 - percent_y) / 2), 364 | ]) 365 | .split(r); 366 | 367 | Layout::default() 368 | .direction(Direction::Horizontal) 369 | .constraints([ 370 | Constraint::Percentage((100 - percent_x) / 2), 371 | Constraint::Percentage(percent_x), 372 | Constraint::Percentage((100 - percent_x) / 2), 373 | ]) 374 | .split(popup_layout[1])[1] 375 | } 376 | -------------------------------------------------------------------------------- /src/sftp_logic/transfer.rs: -------------------------------------------------------------------------------- 1 | use super::types::AppSftpState; 2 | use crate::app_event::TransferEvent; 3 | use anyhow::{Context, Result}; 4 | use std::fs::File; 5 | use std::io::Read; 6 | use std::path::Path; 7 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; 8 | use tokio::process::Command; 9 | 10 | impl AppSftpState { 11 | /// Upload a file to the remote server 12 | pub fn upload_file(&mut self) { 13 | if let Some(super::FileItem::File { name, .. }) = 14 | self.local_files.get(self.local_selected).cloned() 15 | { 16 | let name = name.clone(); 17 | let local_path = self.local_current_path.join(&name); 18 | let remote_path = if self.remote_current_path.ends_with('/') { 19 | format!("{}{}", self.remote_current_path, name) 20 | } else { 21 | format!("{}/{}", self.remote_current_path, name) 22 | }; 23 | 24 | let ssh_user = self.ssh_user.clone(); 25 | let ssh_host = self.ssh_host.clone(); 26 | let ssh_port = self.ssh_port; 27 | let tx = self.transfer_tx.clone().unwrap(); 28 | 29 | tokio::spawn(async move { 30 | let name_clone = name.clone(); 31 | let progress_tx = tx.clone(); 32 | let result = Self::sftp_upload( 33 | &ssh_user, 34 | &ssh_host, 35 | ssh_port, 36 | &local_path, 37 | &remote_path, 38 | move |uploaded, total| { 39 | let _ = progress_tx.try_send(TransferEvent::UploadProgress( 40 | name_clone.clone(), 41 | uploaded, 42 | total, 43 | )); 44 | }, 45 | ) 46 | .await; 47 | 48 | match result { 49 | Ok(_) => { 50 | tracing::info!("Successfully uploaded {}", name); 51 | let _ = tx.send(TransferEvent::UploadComplete(name.clone())).await; 52 | } 53 | Err(e) => { 54 | tracing::error!("Failed to upload {}", name); 55 | tracing::error!("Error: {}", e.to_string()); 56 | let _ = tx 57 | .send(TransferEvent::UploadError(name.clone(), e.to_string())) 58 | .await; 59 | } 60 | } 61 | }); 62 | } else { 63 | self.set_status_message("Please select a file to upload"); 64 | } 65 | } 66 | 67 | /// Download a file from the remote server 68 | pub fn download_file(&mut self) { 69 | if let Some(super::FileItem::File { name, .. }) = 70 | self.remote_files.get(self.remote_selected).cloned() 71 | { 72 | let name = name.clone(); 73 | let remote_path = if self.remote_current_path.ends_with('/') { 74 | format!("{}{}", self.remote_current_path, name) 75 | } else { 76 | format!("{}/{}", self.remote_current_path, name) 77 | }; 78 | let local_path = self.local_current_path.join(&name); 79 | 80 | // self.set_status_message(&format!("Downloading {}...", name)); 81 | 82 | let ssh_user = self.ssh_user.clone(); 83 | let ssh_host = self.ssh_host.clone(); 84 | let ssh_port = self.ssh_port; 85 | let tx = self.transfer_tx.clone().unwrap(); 86 | 87 | tokio::spawn(async move { 88 | let name_clone = name.clone(); 89 | let progress_tx = tx.clone(); 90 | let result = Self::sftp_download( 91 | &ssh_user, 92 | &ssh_host, 93 | ssh_port, 94 | &remote_path, 95 | &local_path, 96 | move |downloaded, total| { 97 | tracing::info!("Downloading try send {}", name_clone); 98 | let _ = progress_tx.try_send(TransferEvent::DownloadProgress( 99 | name_clone.clone(), 100 | downloaded, 101 | total, 102 | )); 103 | }, 104 | ) 105 | .await; 106 | 107 | match result { 108 | Ok(_) => { 109 | tracing::info!("Successfully downloaded {}", name); 110 | let _ = tx.send(TransferEvent::DownloadComplete(name.clone())).await; 111 | } 112 | Err(e) => { 113 | tracing::error!("Failed to download {}", name); 114 | tracing::error!("Error: {}", e.to_string()); 115 | let _ = tx 116 | .send(TransferEvent::DownloadError(name.clone(), e.to_string())) 117 | .await; 118 | } 119 | } 120 | }); 121 | } else { 122 | self.set_status_message("Please select a file to download"); 123 | } 124 | } 125 | 126 | /// Upload a file using SCP with progress tracking 127 | async fn sftp_upload( 128 | user: &str, 129 | host: &str, 130 | port: u16, 131 | local_path: &Path, 132 | remote_path: &str, 133 | mut progress_callback: F, 134 | ) -> Result<()> 135 | where 136 | F: FnMut(u64, u64) + Send + 'static, 137 | { 138 | let file = File::open(local_path).context("Failed to open local file")?; 139 | let metadata = file.metadata().context("Failed to get file metadata")?; 140 | let total_size = metadata.len(); 141 | 142 | let mut command = Command::new("scp") 143 | .arg("-P") 144 | .arg(port.to_string()) 145 | .arg("-o") 146 | .arg("ConnectTimeout=30") 147 | .arg("-o") 148 | .arg("StrictHostKeyChecking=no") 149 | .arg("-o") 150 | .arg("LogLevel=ERROR") 151 | .arg(local_path) 152 | .arg(format!("{}@{}:{}", user, host, remote_path)) 153 | .stdin(std::process::Stdio::piped()) 154 | .spawn() 155 | .context("Failed to start scp upload command")?; 156 | 157 | let mut stdin = command 158 | .stdin 159 | .take() 160 | .ok_or_else(|| anyhow::anyhow!("Failed to get scp stdin"))?; 161 | 162 | let mut file = File::open(local_path).context("Failed to reopen local file")?; 163 | let mut buffer = [0u8; 8192]; 164 | let mut uploaded = 0; 165 | 166 | loop { 167 | let bytes_read = file.read(&mut buffer).context("Failed to read from file")?; 168 | if bytes_read == 0 { 169 | break; 170 | } 171 | 172 | stdin 173 | .write_all(&buffer[..bytes_read]) 174 | .await 175 | .context("Failed to write to scp")?; 176 | uploaded += bytes_read as u64; 177 | 178 | progress_callback(uploaded, total_size); 179 | 180 | // Allow other tasks to run 181 | tokio::task::yield_now().await; 182 | } 183 | 184 | drop(stdin); // Close stdin to signal end of data 185 | 186 | let output = command 187 | .wait_with_output() 188 | .await 189 | .context("Failed to complete scp upload command")?; 190 | 191 | if !output.status.success() { 192 | let stderr = String::from_utf8_lossy(&output.stderr); 193 | return Err(anyhow::anyhow!("SCP upload failed: {}", stderr)); 194 | } 195 | 196 | Ok(()) 197 | } 198 | 199 | /// Download a file using SCP with progress tracking 200 | async fn sftp_download( 201 | user: &str, 202 | host: &str, 203 | port: u16, 204 | remote_path: &str, 205 | local_path: &Path, 206 | progress_callback: F, 207 | ) -> Result<()> { 208 | // First, get the remote file size 209 | let size_output = Command::new("ssh") 210 | .arg("-p") 211 | .arg(port.to_string()) 212 | .arg("-o") 213 | .arg("ConnectTimeout=30") 214 | .arg("-o") 215 | .arg("StrictHostKeyChecking=no") 216 | .arg("-o") 217 | .arg("LogLevel=ERROR") 218 | .arg(format!("{}@{}", user, host)) 219 | .arg(format!("stat -c%s {}", remote_path)) 220 | .output() 221 | .await 222 | .context("Failed to get remote file size")?; 223 | 224 | if !size_output.status.success() { 225 | let stderr = String::from_utf8_lossy(&size_output.stderr); 226 | return Err(anyhow::anyhow!( 227 | "Failed to get remote file size: {}", 228 | stderr 229 | )); 230 | } 231 | 232 | let total_size = String::from_utf8_lossy(&size_output.stdout) 233 | .trim() 234 | .parse::() 235 | .context("Failed to parse remote file size")?; 236 | 237 | // Create parent directory if it doesn't exist 238 | if let Some(parent) = local_path.parent() { 239 | tokio::fs::create_dir_all(parent).await 240 | .context("Failed to create local directory")?; 241 | } 242 | 243 | // Start the download in a separate task 244 | let download_handle = { 245 | let local_path = local_path.to_path_buf(); 246 | let user = user.to_string(); 247 | let host = host.to_string(); 248 | let remote_path = remote_path.to_string(); 249 | 250 | tokio::spawn(async move { 251 | Command::new("scp") 252 | .arg("-P") 253 | .arg(port.to_string()) 254 | .arg("-o") 255 | .arg("ConnectTimeout=30") 256 | .arg("-o") 257 | .arg("StrictHostKeyChecking=no") 258 | .arg("-o") 259 | .arg("LogLevel=ERROR") 260 | .arg(format!("{}@{}:{}", user, host, remote_path)) 261 | .arg(&local_path) 262 | .status() 263 | .await 264 | }) 265 | }; 266 | 267 | // Monitor the download progress 268 | let start_time = std::time::Instant::now(); 269 | let mut last_size = 0u64; 270 | 271 | // Initial progress update 272 | progress_callback(0, total_size); 273 | 274 | // Create a channel for the download task to signal completion 275 | let (tx, mut rx) = tokio::sync::oneshot::channel(); 276 | 277 | // Spawn a task to wait for the download to complete 278 | let download_task = tokio::spawn({ 279 | let download_handle = download_handle; 280 | async move { 281 | match download_handle.await { 282 | Ok(status_result) => { 283 | let _ = tx.send(status_result); 284 | } 285 | Err(e) => { 286 | let _ = tx.send(Err(e.into())); 287 | } 288 | } 289 | } 290 | }); 291 | 292 | // Monitor progress until download completes 293 | let mut download_complete = false; 294 | while !download_complete { 295 | // Check if download is complete 296 | match rx.try_recv() { 297 | Ok(Ok(status)) => { 298 | if !status.success() { 299 | download_task.abort(); 300 | return Err(anyhow::anyhow!("SCP download failed with status: {}", status)); 301 | } 302 | download_complete = true; 303 | } 304 | Ok(Err(e)) => { 305 | download_task.abort(); 306 | return Err(e.into()); 307 | } 308 | Err(tokio::sync::oneshot::error::TryRecvError::Empty) => { 309 | // Download still in progress 310 | } 311 | Err(_) => { 312 | download_task.abort(); 313 | return Err(anyhow::anyhow!("Download channel error")); 314 | } 315 | } 316 | 317 | // Get current file size 318 | if let Ok(metadata) = tokio::fs::metadata(local_path).await { 319 | let current_size = metadata.len(); 320 | if current_size > last_size { 321 | progress_callback(current_size, total_size); 322 | last_size = current_size; 323 | } 324 | } 325 | 326 | // Check if download is taking too long without progress 327 | if start_time.elapsed().as_secs() > 300 && last_size == 0 { // 5 minutes without progress 328 | download_task.abort(); 329 | return Err(anyhow::anyhow!("Download timed out with no progress")); 330 | } 331 | 332 | // Don't check too frequently 333 | if !download_complete { 334 | tokio::time::sleep(std::time::Duration::from_millis(100)).await; 335 | } 336 | } 337 | 338 | // Final progress update 339 | progress_callback(total_size, total_size); 340 | 341 | Ok(()) 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /src/ui/hosts_list.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | backend::Backend, 3 | layout::{Constraint, Direction, Layout, Rect}, 4 | style::{Color, Modifier, Style, Stylize}, 5 | text::{Line, Span}, 6 | widgets::{Block, Borders, Clear, List, ListItem, Paragraph}, 7 | Frame, 8 | }; 9 | use std::time::SystemTime; 10 | 11 | use crate::app::{App, InputMode, ActivePanel}; 12 | use super::footer::draw_footer; 13 | use super::status_bar::draw_status_bar; 14 | 15 | fn _elapsed() -> u64 { 16 | SystemTime::now() 17 | .duration_since(SystemTime::UNIX_EPOCH) 18 | .unwrap_or_default() 19 | .as_secs() 20 | } 21 | 22 | pub fn draw(f: &mut Frame, app: &mut App) { 23 | let size = f.size(); 24 | 25 | // Create a layout with three sections: main content, status bar, and footer 26 | let chunks = Layout::default() 27 | .direction(Direction::Vertical) 28 | .constraints([ 29 | Constraint::Min(3), // Main content 30 | Constraint::Length(1), // Status bar 31 | Constraint::Length(1), // Footer 32 | ].as_ref()) 33 | .split(size); 34 | 35 | // Draw the main content with two-panel layout 36 | draw_hosts_list::(f, app, chunks[0]); 37 | 38 | // Draw the status bar 39 | draw_status_bar::(f, app, chunks[1]); 40 | 41 | // Draw the footer with navigation help 42 | draw_footer::(f, app, chunks[2]); 43 | 44 | // Draw loading overlay if needed 45 | if app.is_connecting { 46 | draw_enhanced_loading_overlay::(f, app); 47 | } 48 | } 49 | 50 | fn draw_hosts_list(f: &mut Frame, app: &mut App, area: Rect) { 51 | let chunks = Layout::default() 52 | .direction(Direction::Horizontal) 53 | .constraints([ 54 | Constraint::Percentage(30), // Groups panel 55 | Constraint::Percentage(70), // Hosts panel 56 | ].as_ref()) 57 | .split(area); 58 | 59 | // Draw groups panel 60 | draw_groups_panel::(f, app, chunks[0]); 61 | 62 | // Draw hosts panel 63 | draw_hosts_panel::(f, app, chunks[1]); 64 | } 65 | 66 | fn draw_groups_panel(f: &mut Frame, app: &mut App, area: Rect) { 67 | let is_active = app.active_panel == ActivePanel::Groups; 68 | let title = format!( 69 | " {} 🫂 Groups ", 70 | if is_active { ">" } else { " " } 71 | ); 72 | 73 | let items: Vec = app.groups 74 | .iter() 75 | .enumerate() 76 | .map(|(i, group)| { 77 | let is_selected = i == app.selected_group && is_active; 78 | let prefix = if is_selected { "> " } else { " " }; 79 | 80 | let (text_style, bg_style) = if is_selected { 81 | ( 82 | Style::default() 83 | .fg(Color::Black) 84 | .bg(Color::Green) 85 | .add_modifier(Modifier::BOLD), 86 | Style::default().bg(Color::Green) 87 | ) 88 | } else { 89 | (Style::default().fg(Color::White), Style::default()) 90 | }; 91 | 92 | let spans = vec![ 93 | Span::styled(prefix, text_style), 94 | Span::styled( 95 | format!("[{}] {}", i + 1, group), 96 | if is_selected { 97 | text_style 98 | } else { 99 | text_style.fg(Color::LightYellow).add_modifier(Modifier::BOLD) 100 | } 101 | ) 102 | ]; 103 | 104 | let line = Line::from(spans); 105 | ListItem::new(line).style(bg_style) 106 | }) 107 | .collect(); 108 | 109 | let border_style = if is_active { 110 | Style::default().fg(Color::Green) 111 | } else { 112 | Style::default() 113 | }; 114 | 115 | let list = List::new(items).block( 116 | Block::default() 117 | .borders(Borders::ALL) 118 | .border_style(border_style) 119 | .title(title) 120 | ); 121 | 122 | f.render_stateful_widget(list, area, &mut app.group_list_state); 123 | } 124 | 125 | fn draw_hosts_panel(f: &mut Frame, app: &mut App, area: Rect) { 126 | let is_search_mode = app.input_mode == InputMode::Search; 127 | let is_active = app.active_panel == ActivePanel::Hosts; 128 | 129 | let (list_area, list_border_style, list_title) = if is_search_mode { 130 | // --- Search Mode UI --- 131 | let search_chunks = Layout::default() 132 | .direction(Direction::Vertical) 133 | .constraints([ 134 | Constraint::Length(3), // Search input area 135 | Constraint::Min(0), // Search results area 136 | ].as_ref()) 137 | .split(area); 138 | 139 | // Draw search input box 140 | let search_title = " 🔍 Search (Press 'Esc' to exit) "; 141 | let search_block = Block::default() 142 | .borders(Borders::ALL) 143 | .title(search_title) 144 | .border_style(Style::default().fg(Color::Yellow)); 145 | 146 | let now = SystemTime::now() 147 | .duration_since(SystemTime::UNIX_EPOCH) 148 | .unwrap_or_default() 149 | .as_millis(); 150 | let cursor = if now % 1000 < 500 { "█" } else { " " }; 151 | 152 | let search_text = format!("{} {}", app.search_query, cursor); 153 | let search_paragraph = Paragraph::new(search_text) 154 | .style(Style::default().fg(Color::White)) 155 | .block(search_block); 156 | 157 | f.render_widget(search_paragraph, search_chunks[0]); 158 | 159 | let results_title = format!( 160 | " {} Results ({} matches) ", 161 | if is_active { ">" } else { " " }, 162 | app.filtered_hosts.len() 163 | ); 164 | 165 | ( 166 | search_chunks[1], 167 | Style::default().fg(Color::Yellow), 168 | results_title, 169 | ) 170 | } else { 171 | // --- Normal Mode UI --- 172 | ( 173 | area, 174 | if is_active { Style::default().fg(Color::Green) } else { Style::default() }, 175 | format!(" {} 👤 Hosts ", if is_active { ">" } else { " " }), 176 | ) 177 | }; 178 | 179 | // --- Common List Rendering --- 180 | let hosts_to_display = if is_search_mode { 181 | app.filtered_hosts 182 | .iter() 183 | .map(|fh| (fh.clone(), app.hosts.get(fh.original_index).unwrap().clone())) 184 | .collect::>() 185 | } else { 186 | app.hosts_in_current_group 187 | .iter() 188 | .map(|&idx| { 189 | let host = app.hosts.get(idx).unwrap().clone(); 190 | let filtered_host = crate::app::FilteredHost { 191 | original_index: idx, 192 | score: 0, 193 | matched_indices: vec![], 194 | }; 195 | (filtered_host, host) 196 | }) 197 | .collect::>() 198 | }; 199 | 200 | let items: Vec = hosts_to_display 201 | .iter() 202 | .enumerate() 203 | .map(|(i, (filtered_host, host))| { 204 | let is_selected = if is_search_mode { 205 | i == app.search_selected && app.active_panel == ActivePanel::Hosts 206 | } else { 207 | i == app.selected_host && app.active_panel == ActivePanel::Hosts 208 | }; 209 | 210 | let prefix = if is_selected { "> " } else { " " }; 211 | 212 | let (text_style, bg_style) = if is_selected { 213 | ( 214 | Style::default() 215 | .fg(Color::Black) 216 | .bg(if is_search_mode { Color::Yellow } else { Color::Green }) 217 | .add_modifier(Modifier::BOLD), 218 | Style::default().bg(if is_search_mode { Color::Yellow } else { Color::Green }) 219 | ) 220 | } else { 221 | (Style::default().fg(Color::White), Style::default()) 222 | }; 223 | 224 | let mut spans = vec![Span::styled(prefix, text_style)]; 225 | 226 | // Add host number 227 | spans.push(Span::styled( 228 | format!("[{}] ", i + 1), 229 | text_style.add_modifier(Modifier::BOLD).fg(if is_selected { Color::Black } else { Color::LightYellow }) 230 | )); 231 | 232 | // Add host alias with search highlighting if in search mode 233 | if is_search_mode && !app.search_query.is_empty() { 234 | let mut last_idx = 0; 235 | for (idx, char) in host.alias.chars().enumerate() { 236 | if filtered_host.matched_indices.contains(&idx) { 237 | if idx > last_idx { 238 | spans.push(Span::styled(&host.alias[last_idx..idx], text_style)); 239 | } 240 | spans.push(Span::styled( 241 | char.to_string(), 242 | Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), 243 | )); 244 | last_idx = idx + 1; 245 | } 246 | } 247 | if last_idx < host.alias.len() { 248 | spans.push(Span::styled(&host.alias[last_idx..], text_style)); 249 | } 250 | } else { 251 | // Not in search mode, just add the alias 252 | spans.push(Span::styled(host.alias.clone(), text_style)); 253 | } 254 | 255 | // Add host details 256 | let details = format!(" ({}@{}:{})", host.user, host.host, host.port.unwrap_or(22)); 257 | spans.push(Span::styled(details, text_style.fg(Color::Gray))); 258 | 259 | let item_text = Line::from(spans); 260 | ListItem::new(item_text).style(bg_style) 261 | }) 262 | .collect(); 263 | 264 | let list = if items.is_empty() { 265 | let message = if is_search_mode { 266 | format!("No results for '{}'", app.search_query) 267 | } else { 268 | "No hosts in this group".to_string() 269 | }; 270 | List::new(vec![ListItem::new(Span::styled( 271 | message, 272 | Style::default().fg(Color::Gray).not_italic() 273 | ))]) 274 | } else { 275 | List::new(items) 276 | }; 277 | 278 | let list_block = Block::default() 279 | .borders(Borders::ALL) 280 | .border_style(list_border_style) 281 | .title(list_title); 282 | 283 | let list_widget = list.block(list_block); 284 | 285 | f.render_stateful_widget(list_widget, list_area, &mut app.host_list_state); 286 | } 287 | 288 | fn draw_enhanced_loading_overlay(f: &mut Frame, app: &App) { 289 | let area = centered_rect(60, 10, f.size()); 290 | 291 | // Get current time for animation 292 | let now = SystemTime::now() 293 | .duration_since(SystemTime::UNIX_EPOCH) 294 | .unwrap() 295 | .as_millis(); 296 | 297 | // Create animated dots 298 | let dots_count = (now / 500) % 4; 299 | let dots = ".".repeat(dots_count as usize); 300 | let padding = " ".repeat(3 - dots_count as usize); 301 | 302 | 303 | // Get status message or default 304 | let status_text = if let Some((msg, _)) = &app.status_message { 305 | msg.clone() 306 | } else { 307 | "Connecting".to_string() 308 | }; 309 | 310 | // Create loading content with animation 311 | let loading_content = if app.is_sftp_loading { 312 | let status_text = if let Some((msg, _)) = &app.status_message { 313 | msg.clone() 314 | } else { 315 | "Initializing SFTP".to_string() 316 | }; 317 | vec![ 318 | Line::from(vec![ 319 | Span::styled("🔄 ", Style::default().fg(Color::Yellow)), 320 | Span::styled( 321 | "SFTP Initialization", 322 | Style::default() 323 | .fg(Color::White) 324 | .add_modifier(Modifier::BOLD), 325 | ), 326 | ]), 327 | Line::from(""), 328 | Line::from(vec![ 329 | Span::styled("📡 ", Style::default().fg(Color::Blue)), 330 | Span::styled( 331 | format!("{}{}", status_text, dots), 332 | Style::default().fg(Color::Cyan), 333 | ), 334 | Span::raw(padding), 335 | ]), 336 | Line::from(""), 337 | Line::from(vec![ 338 | Span::styled("💡 ", Style::default().fg(Color::Yellow)), 339 | Span::styled( 340 | "Please wait...", 341 | Style::default().fg(Color::Gray).add_modifier(Modifier::DIM), 342 | ), 343 | ]), 344 | ] 345 | } else if let Some(host) = &app.connecting_host { 346 | vec![ 347 | Line::from(vec![ 348 | Span::styled("🔗 ", Style::default().fg(Color::Yellow)), 349 | Span::styled( 350 | format!("SSH Connection to {}", host.alias), 351 | Style::default() 352 | .fg(Color::White) 353 | .add_modifier(Modifier::BOLD), 354 | ), 355 | ]), 356 | Line::from(""), 357 | Line::from(vec![ 358 | Span::styled("📡 ", Style::default().fg(Color::Blue)), 359 | Span::styled( 360 | format!("{}{}", status_text, dots), 361 | Style::default().fg(Color::Cyan), 362 | ), 363 | Span::raw(padding), 364 | ]), 365 | Line::from(""), 366 | Line::from(vec![ 367 | Span::styled("Host: ", Style::default().fg(Color::Gray)), 368 | Span::styled( 369 | format!("{}@{}:{}", host.user, host.host, host.port.unwrap_or(22)), 370 | Style::default().fg(Color::Green), 371 | ), 372 | ]), 373 | Line::from(""), 374 | Line::from(vec![ 375 | Span::styled("💡 ", Style::default().fg(Color::Yellow)), 376 | Span::styled( 377 | "Press Ctrl+C to cancel", 378 | Style::default().fg(Color::Gray).add_modifier(Modifier::DIM), 379 | ), 380 | ]), 381 | ] 382 | } else { 383 | vec![ 384 | Line::from(vec![ 385 | Span::styled("🔗 ", Style::default().fg(Color::Yellow)), 386 | Span::styled( 387 | "SSH Connection", 388 | Style::default() 389 | .fg(Color::White) 390 | .add_modifier(Modifier::BOLD), 391 | ), 392 | ]), 393 | Line::from(""), 394 | Line::from(vec![ 395 | Span::styled( 396 | format!("{}{}", status_text, dots), 397 | Style::default().fg(Color::Cyan), 398 | ), 399 | Span::raw(padding), 400 | ]), 401 | ] 402 | }; 403 | 404 | let block = Block::default() 405 | .borders(Borders::ALL) 406 | .title(" SSH Manager ") 407 | .title_style( 408 | Style::default() 409 | .fg(Color::Yellow) 410 | .add_modifier(Modifier::BOLD), 411 | ) 412 | .border_style(Style::default().fg(Color::Yellow)); 413 | 414 | let paragraph = Paragraph::new(loading_content) 415 | .block(block) 416 | .alignment(ratatui::layout::Alignment::Center); 417 | 418 | // Clear the area và render loading overlay 419 | f.render_widget(Clear, area); 420 | f.render_widget(paragraph, area); 421 | } 422 | 423 | fn centered_rect(percent_x: u16, height: u16, r: Rect) -> Rect { 424 | let popup_layout = Layout::default() 425 | .direction(Direction::Vertical) 426 | .constraints([ 427 | Constraint::Percentage((100 - height) / 2), 428 | Constraint::Length(height), 429 | Constraint::Min(0), 430 | ].as_ref()) 431 | .split(r); 432 | 433 | Layout::default() 434 | .direction(Direction::Horizontal) 435 | .constraints([ 436 | Constraint::Percentage((100 - percent_x) / 2), 437 | Constraint::Percentage(percent_x), 438 | Constraint::Percentage((100 - percent_x) / 2), 439 | ].as_ref()) 440 | .split(popup_layout[1])[1] 441 | } 442 | -------------------------------------------------------------------------------- /src/app/state.rs: -------------------------------------------------------------------------------- 1 | use crate::sftp_logic::types::{UploadProgress, DownloadProgress}; 2 | use crate::app::App; 3 | use crate::config::ConfigManager; 4 | use crate::models::SshHost; 5 | use crate::sftp_logic::AppSftpState; 6 | use crate::ui; 7 | use anyhow::{Context, Result}; 8 | use crossterm::event::{KeyCode, KeyEvent}; 9 | use crossterm::{ 10 | event::{DisableMouseCapture, EnableMouseCapture}, 11 | execute, 12 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 13 | }; 14 | use fuzzy_matcher::skim::SkimMatcherV2; 15 | use fuzzy_matcher::FuzzyMatcher; 16 | use std::sync::mpsc::{self, Sender}; 17 | use std::time::{Duration, Instant}; 18 | use tokio::sync::mpsc as tokio_mpsc; 19 | 20 | use crate::app_event::{SftpEvent, SshEvent, TransferEvent}; 21 | use ratatui::{backend::Backend, widgets::ListState, Terminal}; 22 | use std::path::PathBuf; 23 | use std::thread; 24 | use ui::hosts_list::draw; 25 | 26 | use crate::app::types::{ActivePanel, FilteredHost, InputMode}; 27 | 28 | impl Default for App { 29 | fn default() -> Self { 30 | let config_manager = ConfigManager::new().unwrap_or_else(|e| { 31 | eprintln!("Failed to initialize config manager: {}", e); 32 | std::process::exit(1); 33 | }); 34 | let app_config = config_manager.load_config().unwrap_or_else(|e| { 35 | eprintln!("Failed to load config: {}", e); 36 | std::process::exit(1); 37 | }); 38 | 39 | let ssh_config_path = PathBuf::from(app_config.ssh_file_config.clone()); 40 | 41 | tracing::info!("SSH config path: {:?}", ssh_config_path); 42 | Self { 43 | should_quit: false, 44 | hosts: Vec::new(), 45 | selected_host: 0, 46 | selected_group: 0, 47 | active_panel: ActivePanel::Groups, 48 | ssh_config_path, 49 | config_manager, 50 | input_mode: InputMode::Normal, 51 | is_connecting: false, 52 | connecting_host: None, 53 | status_message: None, 54 | // SSH 55 | ssh_receiver: None, 56 | ssh_ready_for_terminal: false, 57 | // SFTP 58 | sftp_receiver: None, 59 | sftp_ready_for_terminal: false, 60 | is_sftp_loading: false, 61 | sftp_state: None, 62 | transfer_receiver: None, 63 | 64 | // Search 65 | search_query: String::new(), 66 | filtered_hosts: Vec::new(), 67 | search_selected: 0, 68 | 69 | // Group State 70 | groups: Vec::new(), 71 | hosts_in_current_group: Vec::new(), 72 | 73 | host_list_state: ListState::default(), 74 | group_list_state: ListState::default(), 75 | } 76 | } 77 | } 78 | 79 | impl App { 80 | pub fn new() -> Result { 81 | let mut app = Self::default(); 82 | app.load_all_hosts().context("Failed to load hosts")?; 83 | app.host_list_state.select(Some(app.selected_host)); 84 | Ok(app) 85 | } 86 | 87 | pub fn clear_status_message(&mut self) { 88 | self.status_message = None; 89 | } 90 | 91 | pub fn switch_panel(&mut self) { 92 | self.active_panel = match self.active_panel { 93 | ActivePanel::Groups => ActivePanel::Hosts, 94 | ActivePanel::Hosts => ActivePanel::Groups, 95 | }; 96 | 97 | tracing::info!("Switched to {:?} panel", self.active_panel); 98 | 99 | // When switching to Hosts panel, ensure selected_host is within bounds 100 | if self.active_panel == ActivePanel::Hosts && !self.hosts_in_current_group.is_empty() { 101 | self.selected_host = std::cmp::min( 102 | self.selected_host, 103 | self.hosts_in_current_group.len().saturating_sub(1), 104 | ); 105 | self.host_list_state.select(Some(self.selected_host)); 106 | } 107 | } 108 | 109 | pub fn switch_to_hosts(&mut self) { 110 | self.active_panel = ActivePanel::Hosts; 111 | tracing::info!("Switched to Hosts panel"); 112 | 113 | // When switching to Hosts panel, ensure selected_host is within bounds 114 | if !self.hosts_in_current_group.is_empty() { 115 | self.selected_host = std::cmp::min( 116 | self.selected_host, 117 | self.hosts_in_current_group.len().saturating_sub(1), 118 | ); 119 | self.host_list_state.select(Some(self.selected_host)); 120 | } 121 | } 122 | 123 | pub fn update_hosts_for_selected_group(&mut self) { 124 | if self.groups.is_empty() { 125 | self.hosts_in_current_group.clear(); 126 | return; 127 | } 128 | 129 | let current_group = &self.groups[self.selected_group]; 130 | self.hosts_in_current_group = self 131 | .hosts 132 | .iter() 133 | .enumerate() 134 | .filter_map(|(i, host)| { 135 | let group_name = host.group.as_deref().unwrap_or("Ungrouped"); 136 | if group_name == current_group { 137 | Some(i) 138 | } else { 139 | None 140 | } 141 | }) 142 | .collect(); 143 | 144 | // Reset selected host when group changes 145 | if !self.hosts_in_current_group.is_empty() { 146 | self.selected_host = 0; 147 | self.host_list_state.select(Some(0)); 148 | } else { 149 | self.selected_host = 0; 150 | self.host_list_state.select(None); 151 | } 152 | } 153 | 154 | pub fn get_current_host(&self) -> Option<&SshHost> { 155 | self.hosts_in_current_group 156 | .get(self.selected_host) 157 | .and_then(|&idx| self.hosts.get(idx)) 158 | } 159 | 160 | // Improve navigation 161 | pub fn select_next(&mut self) { 162 | match self.active_panel { 163 | ActivePanel::Groups => { 164 | if self.groups.is_empty() { 165 | return; 166 | } 167 | self.selected_group = (self.selected_group + 1) % self.groups.len(); 168 | self.group_list_state.select(Some(self.selected_group)); 169 | self.update_hosts_for_selected_group(); 170 | } 171 | ActivePanel::Hosts => { 172 | if self.hosts_in_current_group.is_empty() { 173 | return; 174 | } 175 | self.selected_host = (self.selected_host + 1) % self.hosts_in_current_group.len(); 176 | tracing::info!("Selected host: {}", self.selected_host); 177 | self.host_list_state.select(Some(self.selected_host)); 178 | } 179 | } 180 | } 181 | 182 | pub fn select_previous(&mut self) { 183 | match self.active_panel { 184 | ActivePanel::Groups => { 185 | if self.groups.is_empty() { 186 | return; 187 | } 188 | let total = self.groups.len(); 189 | self.selected_group = (self.selected_group + total - 1) % total; 190 | self.group_list_state.select(Some(self.selected_group)); 191 | self.update_hosts_for_selected_group(); 192 | } 193 | ActivePanel::Hosts => { 194 | if self.hosts_in_current_group.is_empty() { 195 | return; 196 | } 197 | let total = self.hosts_in_current_group.len(); 198 | self.selected_host = (self.selected_host + total - 1) % total; 199 | tracing::info!("Selected host: {}", self.selected_host); 200 | self.host_list_state.select(Some(self.selected_host)); 201 | } 202 | } 203 | } 204 | 205 | fn transition_to_ssh_mode(&mut self, terminal: &mut Terminal) -> Result<()> { 206 | // Disable TUI mode 207 | disable_raw_mode().context("Failed to disable raw mode for SSH")?; 208 | let mut stdout = std::io::stdout(); 209 | execute!(&mut stdout, LeaveAlternateScreen, DisableMouseCapture) 210 | .context("Failed to leave alternate screen for SSH")?; 211 | terminal 212 | .show_cursor() 213 | .context("Failed to show cursor for SSH")?; 214 | 215 | tracing::info!("TUI disabled for SSH mode - main thread will suspend polling"); 216 | Ok(()) 217 | } 218 | 219 | fn restore_tui_mode(&mut self, terminal: &mut Terminal) -> Result<()> { 220 | // Re-enable TUI mode 221 | enable_raw_mode().context("Failed to re-enable raw mode post-SSH")?; 222 | let mut stdout = std::io::stdout(); 223 | execute!(&mut stdout, EnterAlternateScreen, EnableMouseCapture) 224 | .context("Failed to re-enter alternate screen post-SSH")?; 225 | 226 | terminal 227 | .clear() 228 | .context("Failed to clear terminal post-SSH")?; 229 | tracing::info!("TUI restored after SSH session - resuming main thread polling"); 230 | Ok(()) 231 | } 232 | 233 | // Worker function run in SSH thread 234 | pub fn ssh_thread_worker(sender: Sender, host: SshHost) { 235 | tracing::info!("SSH thread started for host: {}", host.alias); 236 | 237 | // Send event connecting 238 | if sender.send(SshEvent::Connecting).is_err() { 239 | tracing::error!("Failed to send Connecting event"); 240 | return; 241 | } 242 | 243 | // Perform SSH connection test first 244 | match Self::test_ssh_connection(&host) { 245 | Ok(_) => { 246 | tracing::info!("SSH connection test successful for {}", host.alias); 247 | 248 | // If connection test success, send Connected event 249 | if sender.send(SshEvent::Connected).is_ok() { 250 | // Wait a little bit for main thread to process transition 251 | thread::sleep(Duration::from_millis(200)); 252 | 253 | // Execute SSH connection (this will block until SSH session ends) 254 | tracing::info!("Starting SSH session for {}", host.alias); 255 | match Self::execute_ssh_blocking(&host) { 256 | Ok(_) => { 257 | tracing::info!("SSH session ended normally for {}", host.alias); 258 | let _ = sender.send(SshEvent::Disconnected); 259 | } 260 | Err(e) => { 261 | tracing::error!("SSH session error for {}: {}", host.alias, e); 262 | let _ = sender.send(SshEvent::Error(e.to_string())); 263 | } 264 | } 265 | } else { 266 | tracing::error!("Failed to send Connected event"); 267 | } 268 | } 269 | Err(e) => { 270 | tracing::error!("SSH connection test failed for {}: {}", host.alias, e); 271 | let _ = sender.send(SshEvent::Error(format!("Connection test failed: {}", e))); 272 | } 273 | } 274 | 275 | tracing::info!("SSH thread ending for host: {}", host.alias); 276 | } 277 | 278 | // Test SSH connection trước khi thực sự connect 279 | fn test_ssh_connection(host: &SshHost) -> Result<()> { 280 | use std::process::Command; 281 | 282 | let port_str = host.port.unwrap_or(22).to_string(); 283 | 284 | tracing::info!( 285 | "Testing SSH connection to {}@{}:{}", 286 | host.user, 287 | host.host, 288 | port_str 289 | ); 290 | 291 | // Test connection with short timeout 292 | let output = Command::new("ssh") 293 | .arg(format!("{}@{}", host.user, host.host)) 294 | .arg("-p") 295 | .arg(&port_str) 296 | .arg("-o") 297 | .arg("ConnectTimeout=5") 298 | .arg("-o") 299 | .arg("BatchMode=yes") 300 | .arg("-o") 301 | .arg("StrictHostKeyChecking=no") 302 | .arg("-o") 303 | .arg("LogLevel=ERROR") // Reduce verbose output 304 | .arg("exit") 305 | .output() 306 | .context("Failed to test SSH connection")?; 307 | 308 | if output.status.success() { 309 | Ok(()) 310 | } else { 311 | let stderr = String::from_utf8_lossy(&output.stderr); 312 | Err(anyhow::anyhow!( 313 | "SSH connection test failed: {}", 314 | stderr.trim() 315 | )) 316 | } 317 | } 318 | 319 | // Execute SSH connection (blocking) - This gives complete control to SSH 320 | fn execute_ssh_blocking(host: &SshHost) -> Result<()> { 321 | use std::process::Command; 322 | 323 | let port_str = host.port.unwrap_or(22).to_string(); 324 | let connection_str = format!("{}@{}", host.user, host.host); 325 | 326 | tracing::info!("Executing SSH: ssh {} -p {}", connection_str, port_str); 327 | 328 | // Execute SSH with full control of terminal 329 | let status = Command::new("ssh") 330 | .arg(&connection_str) 331 | .arg("-p") 332 | .arg(&port_str) 333 | .arg("-o") 334 | .arg("ConnectTimeout=30") 335 | .arg("-o") 336 | .arg("ServerAliveInterval=60") 337 | .arg("-o") 338 | .arg("ServerAliveCountMax=3") 339 | .stdin(std::process::Stdio::inherit()) 340 | .stdout(std::process::Stdio::inherit()) 341 | .stderr(std::process::Stdio::inherit()) 342 | .status() 343 | .context("Failed to execute SSH command")?; 344 | 345 | if status.success() { 346 | tracing::info!("SSH command completed successfully"); 347 | Ok(()) 348 | } else { 349 | let error_msg = format!("SSH command failed with status: {}", status); 350 | tracing::error!("{}", error_msg); 351 | Err(anyhow::anyhow!(error_msg)) 352 | } 353 | } 354 | 355 | // Process SSH events from channel 356 | pub fn process_ssh_events(&mut self, terminal: &mut Terminal) -> Result { 357 | if let Some(receiver) = &self.ssh_receiver { 358 | // Non-blocking receive 359 | if let Ok(event) = receiver.try_recv() { 360 | match event { 361 | SshEvent::Connecting => { 362 | self.status_message = 363 | Some(("Testing connection...".to_string(), Instant::now())); 364 | return Ok(false); 365 | } 366 | SshEvent::Connected => { 367 | self.status_message = Some(( 368 | "Connection successful! Launching SSH...".to_string(), 369 | Instant::now(), 370 | )); 371 | 372 | // Transition to SSH terminal mode 373 | self.transition_to_ssh_mode(terminal)?; 374 | self.ssh_ready_for_terminal = true; 375 | 376 | return Ok(false); 377 | } 378 | SshEvent::Error(err) => { 379 | tracing::error!("SSH error: {}", err); 380 | self.is_connecting = false; 381 | self.connecting_host = None; 382 | self.ssh_ready_for_terminal = false; 383 | self.ssh_receiver = None; 384 | self.status_message = Some((format!("SSH Error: {}", err), Instant::now())); 385 | 386 | // Restore TUI mode when SSH error occurs 387 | if let Err(e) = self.restore_tui_mode(terminal) { 388 | tracing::error!("Failed to restore TUI mode after SSH error: {}", e); 389 | } 390 | return Ok(false); 391 | } 392 | SshEvent::Disconnected => { 393 | tracing::info!("SSH session disconnected, restoring TUI"); 394 | 395 | // SSH session ended, restore TUI 396 | self.restore_tui_mode(terminal)?; 397 | self.is_connecting = false; 398 | self.connecting_host = None; 399 | self.ssh_ready_for_terminal = false; 400 | self.ssh_receiver = None; 401 | self.status_message = 402 | Some(("SSH session ended".to_string(), Instant::now())); 403 | return Ok(true); // Indicate we need to redraw 404 | } 405 | } 406 | } 407 | } 408 | Ok(false) 409 | } 410 | 411 | // Search logic 412 | pub fn filter_hosts(&mut self) { 413 | if self.search_query.is_empty() { 414 | self.filtered_hosts.clear(); 415 | } else { 416 | let matcher = SkimMatcherV2::default(); 417 | let query = &self.search_query; 418 | 419 | let mut results: Vec = self 420 | .hosts 421 | .iter() 422 | .enumerate() 423 | .filter_map(|(idx, host)| { 424 | let full_string = format!( 425 | "{} {} {} {}", 426 | host.alias, 427 | host.user, 428 | host.host, 429 | host.group.as_deref().unwrap_or("") 430 | ); 431 | matcher 432 | .fuzzy_indices(&full_string, query) 433 | .map(|(score, indices)| FilteredHost { 434 | original_index: idx, 435 | score, 436 | matched_indices: indices, 437 | }) 438 | }) 439 | .collect(); 440 | 441 | results.sort_by(|a, b| b.score.cmp(&a.score)); 442 | self.filtered_hosts = results; 443 | } 444 | 445 | if self.search_selected >= self.filtered_hosts.len() { 446 | self.search_selected = self.filtered_hosts.len().saturating_sub(1); 447 | } 448 | self.host_list_state.select(Some(self.search_selected)); 449 | } 450 | 451 | pub fn get_current_selected_host(&self) -> Option<&SshHost> { 452 | match self.input_mode { 453 | InputMode::Search => self 454 | .filtered_hosts 455 | .get(self.search_selected) 456 | .and_then(|filtered_host| self.hosts.get(filtered_host.original_index)), 457 | InputMode::Normal => self 458 | .hosts_in_current_group 459 | .get(self.selected_host) 460 | .and_then(|&idx| self.hosts.get(idx)), 461 | InputMode::Sftp => None, 462 | } 463 | } 464 | 465 | pub fn search_select_next(&mut self) { 466 | if self.filtered_hosts.is_empty() { 467 | self.search_selected = 0; 468 | return; 469 | } 470 | let next = self.search_selected + 1; 471 | if next < self.filtered_hosts.len() { 472 | self.search_selected = next; 473 | } else { 474 | self.search_selected = 0; 475 | } 476 | self.host_list_state.select(Some(self.search_selected)); 477 | } 478 | 479 | pub fn search_select_previous(&mut self) { 480 | if self.filtered_hosts.is_empty() { 481 | self.search_selected = 0; 482 | return; 483 | } 484 | if self.search_selected > 0 { 485 | self.search_selected -= 1; 486 | } else { 487 | self.search_selected = self.filtered_hosts.len() - 1; 488 | } 489 | self.host_list_state.select(Some(self.search_selected)); 490 | } 491 | 492 | // Clear search and return to normal mode 493 | pub fn clear_search(&mut self) { 494 | self.search_query.clear(); 495 | self.input_mode = InputMode::Normal; 496 | self.filtered_hosts.clear(); 497 | self.search_selected = 0; 498 | self.host_list_state.select(Some(self.selected_host)); 499 | } 500 | 501 | // Enter search mode 502 | pub fn enter_search_mode(&mut self) { 503 | self.input_mode = InputMode::Search; 504 | self.search_query.clear(); 505 | self.switch_to_hosts(); 506 | self.filter_hosts(); 507 | } 508 | 509 | /// Enter SFTP mode with the currently selected host 510 | pub fn enter_sftp_mode(&mut self, terminal: &mut Terminal) -> Result<()> { 511 | if let Some(selected_host) = self.get_current_selected_host().cloned() { 512 | // Create channel for SFTP connection events 513 | let (sftp_sender, sftp_receiver) = mpsc::channel::(); 514 | self.sftp_receiver = Some(sftp_receiver); 515 | 516 | // Create channel for transfer progress events 517 | let (transfer_sender, transfer_receiver) = tokio_mpsc::channel::(100); 518 | self.transfer_receiver = Some(transfer_receiver); 519 | 520 | // Turn on loading status 521 | self.is_sftp_loading = true; 522 | self.sftp_ready_for_terminal = true; 523 | self.status_message = Some(( 524 | format!("Initializing SFTP for {}...", selected_host.alias), 525 | Instant::now(), 526 | )); 527 | 528 | // Initialize AppSftpState asynchronously 529 | let host_clone = selected_host.clone(); 530 | thread::spawn(move || { 531 | Self::sftp_thread_worker(sftp_sender, host_clone, transfer_sender); 532 | }); 533 | 534 | // Redraw UI to show loading 535 | terminal.draw(|f| draw::(f, self))?; 536 | } 537 | Ok(()) 538 | } 539 | 540 | pub fn exit_sftp_mode(&mut self) { 541 | tracing::info!("Exiting SFTP mode"); 542 | self.sftp_state = None; 543 | self.input_mode = InputMode::Normal; 544 | self.is_sftp_loading = false; 545 | self.sftp_ready_for_terminal = false; 546 | self.status_message = Some(("Exited SFTP mode".to_string(), Instant::now())); 547 | } 548 | 549 | pub async fn handle_sftp_key(&mut self, key: KeyEvent) -> Result<()> { 550 | if let Some(sftp_state) = &mut self.sftp_state { 551 | match key.code { 552 | KeyCode::Char('q') => { 553 | self.exit_sftp_mode(); 554 | } 555 | KeyCode::Up => { 556 | sftp_state.navigate_up(); 557 | } 558 | KeyCode::Down => { 559 | sftp_state.navigate_down(); 560 | } 561 | KeyCode::Enter => { 562 | if let Err(e) = sftp_state.open_selected() { 563 | sftp_state.set_status_message(&format!("Error: {}", e)); 564 | } 565 | } 566 | KeyCode::Backspace => { 567 | if let Err(e) = sftp_state.open_selected() { 568 | // Assuming Backspace goes to parent 569 | sftp_state.set_status_message(&format!("Error: {}", e)); 570 | } 571 | } 572 | KeyCode::Tab => { 573 | sftp_state.switch_panel(); 574 | } 575 | KeyCode::Char('u') => { 576 | if sftp_state.upload_progress.is_none() { 577 | sftp_state.upload_file(); 578 | } else { 579 | sftp_state.set_status_message("Upload already in progress"); 580 | } 581 | } 582 | KeyCode::Char('d') => { 583 | if sftp_state.download_progress.is_none() { 584 | sftp_state.download_file(); 585 | } else { 586 | sftp_state.set_status_message("Download already in progress"); 587 | } 588 | } 589 | KeyCode::Char('r') => { 590 | if let Err(e) = sftp_state.refresh_local() { 591 | sftp_state.set_status_message(&format!("Local refresh error: {}", e)); 592 | } 593 | if let Err(e) = sftp_state.refresh_remote() { 594 | sftp_state.set_status_message(&format!("Remote refresh error: {}", e)); 595 | } 596 | } 597 | _ => {} 598 | } 599 | } 600 | Ok(()) 601 | } 602 | 603 | // Process SFTP events from channel 604 | pub fn process_sftp_events(&mut self, terminal: &mut Terminal) -> Result { 605 | if let Some(receiver) = &self.sftp_receiver { 606 | // Non-blocking receive 607 | if let Ok(event) = receiver.try_recv() { 608 | match event { 609 | SftpEvent::PreConnected(sftp_state) => { 610 | self.sftp_state = Some(sftp_state); 611 | self.input_mode = InputMode::Sftp; 612 | self.status_message = Some(( 613 | format!( 614 | "SFTP mode active for {}", 615 | self.sftp_state.as_ref().unwrap().ssh_host 616 | ), 617 | Instant::now(), 618 | )); 619 | return Ok(true); // Redraw to show SFTP UI 620 | } 621 | SftpEvent::Connecting => { 622 | self.status_message = 623 | Some(("Testing connection...".to_string(), Instant::now())); 624 | return Ok(false); 625 | } 626 | SftpEvent::Connected => { 627 | self.status_message = Some(( 628 | "Connection successful! Launching SFTP...".to_string(), 629 | Instant::now(), 630 | )); 631 | self.sftp_ready_for_terminal = true; 632 | return Ok(false); 633 | } 634 | SftpEvent::Error(err) => { 635 | tracing::error!("SFTP error: {}", err); 636 | self.is_sftp_loading = false; 637 | self.sftp_ready_for_terminal = false; 638 | self.sftp_receiver = None; 639 | self.status_message = 640 | Some((format!("SFTP Error: {}", err), Instant::now())); 641 | return Ok(true); // Redraw to show error 642 | } 643 | SftpEvent::Disconnected => { 644 | tracing::info!("SFTP session disconnected, restoring TUI"); 645 | 646 | self.is_sftp_loading = false; 647 | self.sftp_ready_for_terminal = false; 648 | self.sftp_receiver = None; 649 | self.status_message = 650 | Some(("SFTP session ended".to_string(), Instant::now())); 651 | return Ok(true); // Indicate we need to redraw 652 | } 653 | } 654 | } 655 | } 656 | // Check if we need to redraw due to upload progress 657 | if let Some(sftp_state) = &self.sftp_state { 658 | if sftp_state.upload_progress.is_some() || sftp_state.download_progress.is_some() { 659 | return Ok(true); 660 | } 661 | } 662 | Ok(false) 663 | } 664 | 665 | // Process transfer events from channel 666 | pub fn process_transfer_events(&mut self) -> Result { 667 | if let Some(receiver) = &mut self.transfer_receiver { 668 | if let Ok(event) = receiver.try_recv() { 669 | if let Some(sftp_state) = &mut self.sftp_state { 670 | match event { 671 | TransferEvent::UploadProgress(file_name, uploaded, total) => { 672 | sftp_state.upload_progress = Some(UploadProgress { 673 | file_name, 674 | uploaded_size: uploaded, 675 | total_size: total, 676 | }); 677 | } 678 | TransferEvent::UploadComplete(file_name) => { 679 | sftp_state.upload_progress = None; 680 | tracing::info!("Successfully uploaded {}", file_name); 681 | sftp_state.set_status_message(&format!("Successfully uploaded {}", file_name)); 682 | let _ = sftp_state.refresh_remote(); 683 | } 684 | TransferEvent::UploadError(file_name, error) => { 685 | sftp_state.upload_progress = None; 686 | sftp_state.set_status_message(&format!("Upload failed for {}: {}", file_name, error)); 687 | let _ = sftp_state.refresh_remote(); 688 | } 689 | TransferEvent::DownloadProgress(file_name, downloaded, total) => { 690 | tracing::info!("Downloading {}", file_name); 691 | sftp_state.download_progress = Some(DownloadProgress { 692 | file_name, 693 | downloaded_size: downloaded, 694 | total_size: total, 695 | }); 696 | } 697 | TransferEvent::DownloadComplete(file_name) => { 698 | sftp_state.download_progress = None; 699 | sftp_state.set_status_message(&format!("Successfully downloaded {}", file_name)); 700 | let _ = sftp_state.refresh_local(); 701 | } 702 | TransferEvent::DownloadError(file_name, error) => { 703 | sftp_state.download_progress = None; 704 | sftp_state.set_status_message(&format!("Download failed for {}: {}", file_name, error)); 705 | } 706 | } 707 | return Ok(true); // Redraw needed 708 | } 709 | } 710 | } 711 | Ok(false) 712 | } 713 | 714 | // Worker function run in SFTP thread 715 | fn sftp_thread_worker( 716 | sender: Sender, 717 | host: SshHost, 718 | transfer_tx: tokio_mpsc::Sender, 719 | ) { 720 | tracing::info!("SFTP thread started for host: {}", host.alias); 721 | 722 | // Send event connecting 723 | if sender.send(SftpEvent::Connecting).is_err() { 724 | tracing::error!("Failed to send Connecting event"); 725 | return; 726 | } 727 | 728 | // Perform SSH connection test first 729 | match AppSftpState::new( 730 | &host.user, 731 | &host.host, 732 | host.port.unwrap_or(22), 733 | transfer_tx, 734 | ) { 735 | Ok(sftp_state) => { 736 | tracing::info!("SFTP connection test successful for {}", host.alias); 737 | 738 | // Send PreConnected event 739 | if sender.send(SftpEvent::PreConnected(sftp_state)).is_err() { 740 | tracing::error!("Failed to send PreConnected event"); 741 | return; 742 | } 743 | // If connection test success, send Connected event 744 | if sender.send(SftpEvent::Connected).is_ok() { 745 | // Wait a little bit for main thread to process transition 746 | thread::sleep(Duration::from_millis(200)); 747 | // Execute SSH connection (this will block until SSH session ends) 748 | tracing::info!("Starting SFTP session for {}", host.alias); 749 | } else { 750 | tracing::error!("Failed to send Connected event"); 751 | } 752 | } 753 | Err(e) => { 754 | tracing::error!("SFTP connection test failed for {}: {}", host.alias, e); 755 | let _ = sender.send(SftpEvent::Error(format!("Connection test failed: {}", e))); 756 | } 757 | } 758 | } 759 | } 760 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.24.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 19 | 20 | [[package]] 21 | name = "aho-corasick" 22 | version = "1.1.3" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 25 | dependencies = [ 26 | "memchr", 27 | ] 28 | 29 | [[package]] 30 | name = "allocator-api2" 31 | version = "0.2.21" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 34 | 35 | [[package]] 36 | name = "android-tzdata" 37 | version = "0.1.1" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 40 | 41 | [[package]] 42 | name = "android_system_properties" 43 | version = "0.1.5" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 46 | dependencies = [ 47 | "libc", 48 | ] 49 | 50 | [[package]] 51 | name = "anstream" 52 | version = "0.6.18" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 55 | dependencies = [ 56 | "anstyle", 57 | "anstyle-parse", 58 | "anstyle-query", 59 | "anstyle-wincon", 60 | "colorchoice", 61 | "is_terminal_polyfill", 62 | "utf8parse", 63 | ] 64 | 65 | [[package]] 66 | name = "anstyle" 67 | version = "1.0.10" 68 | source = "registry+https://github.com/rust-lang/crates.io-index" 69 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 70 | 71 | [[package]] 72 | name = "anstyle-parse" 73 | version = "0.2.6" 74 | source = "registry+https://github.com/rust-lang/crates.io-index" 75 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 76 | dependencies = [ 77 | "utf8parse", 78 | ] 79 | 80 | [[package]] 81 | name = "anstyle-query" 82 | version = "1.1.2" 83 | source = "registry+https://github.com/rust-lang/crates.io-index" 84 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 85 | dependencies = [ 86 | "windows-sys 0.59.0", 87 | ] 88 | 89 | [[package]] 90 | name = "anstyle-wincon" 91 | version = "3.0.8" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa" 94 | dependencies = [ 95 | "anstyle", 96 | "once_cell_polyfill", 97 | "windows-sys 0.59.0", 98 | ] 99 | 100 | [[package]] 101 | name = "anyhow" 102 | version = "1.0.98" 103 | source = "registry+https://github.com/rust-lang/crates.io-index" 104 | checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" 105 | 106 | [[package]] 107 | name = "arboard" 108 | version = "3.5.0" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "c1df21f715862ede32a0c525ce2ca4d52626bb0007f8c18b87a384503ac33e70" 111 | dependencies = [ 112 | "clipboard-win", 113 | "image", 114 | "log", 115 | "objc2", 116 | "objc2-app-kit", 117 | "objc2-core-foundation", 118 | "objc2-core-graphics", 119 | "objc2-foundation", 120 | "parking_lot", 121 | "percent-encoding", 122 | "windows-sys 0.59.0", 123 | "x11rb", 124 | ] 125 | 126 | [[package]] 127 | name = "autocfg" 128 | version = "1.4.0" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 131 | 132 | [[package]] 133 | name = "backtrace" 134 | version = "0.3.75" 135 | source = "registry+https://github.com/rust-lang/crates.io-index" 136 | checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" 137 | dependencies = [ 138 | "addr2line", 139 | "cfg-if", 140 | "libc", 141 | "miniz_oxide", 142 | "object", 143 | "rustc-demangle", 144 | "windows-targets 0.52.6", 145 | ] 146 | 147 | [[package]] 148 | name = "bitflags" 149 | version = "1.3.2" 150 | source = "registry+https://github.com/rust-lang/crates.io-index" 151 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 152 | 153 | [[package]] 154 | name = "bitflags" 155 | version = "2.9.1" 156 | source = "registry+https://github.com/rust-lang/crates.io-index" 157 | checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" 158 | 159 | [[package]] 160 | name = "bumpalo" 161 | version = "3.17.0" 162 | source = "registry+https://github.com/rust-lang/crates.io-index" 163 | checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 164 | 165 | [[package]] 166 | name = "bytemuck" 167 | version = "1.23.0" 168 | source = "registry+https://github.com/rust-lang/crates.io-index" 169 | checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c" 170 | 171 | [[package]] 172 | name = "byteorder-lite" 173 | version = "0.1.0" 174 | source = "registry+https://github.com/rust-lang/crates.io-index" 175 | checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" 176 | 177 | [[package]] 178 | name = "bytes" 179 | version = "1.10.1" 180 | source = "registry+https://github.com/rust-lang/crates.io-index" 181 | checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 182 | 183 | [[package]] 184 | name = "cassowary" 185 | version = "0.3.0" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 188 | 189 | [[package]] 190 | name = "castaway" 191 | version = "0.2.3" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" 194 | dependencies = [ 195 | "rustversion", 196 | ] 197 | 198 | [[package]] 199 | name = "cc" 200 | version = "1.2.25" 201 | source = "registry+https://github.com/rust-lang/crates.io-index" 202 | checksum = "d0fc897dc1e865cc67c0e05a836d9d3f1df3cbe442aa4a9473b18e12624a4951" 203 | dependencies = [ 204 | "shlex", 205 | ] 206 | 207 | [[package]] 208 | name = "cfg-if" 209 | version = "1.0.0" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 212 | 213 | [[package]] 214 | name = "chrono" 215 | version = "0.4.41" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" 218 | dependencies = [ 219 | "android-tzdata", 220 | "iana-time-zone", 221 | "js-sys", 222 | "num-traits", 223 | "wasm-bindgen", 224 | "windows-link", 225 | ] 226 | 227 | [[package]] 228 | name = "clap" 229 | version = "4.5.39" 230 | source = "registry+https://github.com/rust-lang/crates.io-index" 231 | checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f" 232 | dependencies = [ 233 | "clap_builder", 234 | "clap_derive", 235 | ] 236 | 237 | [[package]] 238 | name = "clap_builder" 239 | version = "4.5.39" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51" 242 | dependencies = [ 243 | "anstream", 244 | "anstyle", 245 | "clap_lex", 246 | "strsim", 247 | ] 248 | 249 | [[package]] 250 | name = "clap_derive" 251 | version = "4.5.32" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" 254 | dependencies = [ 255 | "heck", 256 | "proc-macro2", 257 | "quote", 258 | "syn", 259 | ] 260 | 261 | [[package]] 262 | name = "clap_lex" 263 | version = "0.7.4" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 266 | 267 | [[package]] 268 | name = "clipboard-win" 269 | version = "5.4.0" 270 | source = "registry+https://github.com/rust-lang/crates.io-index" 271 | checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" 272 | dependencies = [ 273 | "error-code", 274 | ] 275 | 276 | [[package]] 277 | name = "colorchoice" 278 | version = "1.0.3" 279 | source = "registry+https://github.com/rust-lang/crates.io-index" 280 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 281 | 282 | [[package]] 283 | name = "compact_str" 284 | version = "0.7.1" 285 | source = "registry+https://github.com/rust-lang/crates.io-index" 286 | checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" 287 | dependencies = [ 288 | "castaway", 289 | "cfg-if", 290 | "itoa", 291 | "ryu", 292 | "static_assertions", 293 | ] 294 | 295 | [[package]] 296 | name = "core-foundation-sys" 297 | version = "0.8.7" 298 | source = "registry+https://github.com/rust-lang/crates.io-index" 299 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 300 | 301 | [[package]] 302 | name = "crc32fast" 303 | version = "1.4.2" 304 | source = "registry+https://github.com/rust-lang/crates.io-index" 305 | checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" 306 | dependencies = [ 307 | "cfg-if", 308 | ] 309 | 310 | [[package]] 311 | name = "crossterm" 312 | version = "0.27.0" 313 | source = "registry+https://github.com/rust-lang/crates.io-index" 314 | checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" 315 | dependencies = [ 316 | "bitflags 2.9.1", 317 | "crossterm_winapi", 318 | "futures-core", 319 | "libc", 320 | "mio 0.8.11", 321 | "parking_lot", 322 | "signal-hook", 323 | "signal-hook-mio", 324 | "winapi", 325 | ] 326 | 327 | [[package]] 328 | name = "crossterm_winapi" 329 | version = "0.9.1" 330 | source = "registry+https://github.com/rust-lang/crates.io-index" 331 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 332 | dependencies = [ 333 | "winapi", 334 | ] 335 | 336 | [[package]] 337 | name = "dirs" 338 | version = "6.0.0" 339 | source = "registry+https://github.com/rust-lang/crates.io-index" 340 | checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" 341 | dependencies = [ 342 | "dirs-sys", 343 | ] 344 | 345 | [[package]] 346 | name = "dirs-sys" 347 | version = "0.5.0" 348 | source = "registry+https://github.com/rust-lang/crates.io-index" 349 | checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" 350 | dependencies = [ 351 | "libc", 352 | "option-ext", 353 | "redox_users", 354 | "windows-sys 0.59.0", 355 | ] 356 | 357 | [[package]] 358 | name = "dispatch2" 359 | version = "0.3.0" 360 | source = "registry+https://github.com/rust-lang/crates.io-index" 361 | checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" 362 | dependencies = [ 363 | "bitflags 2.9.1", 364 | "objc2", 365 | ] 366 | 367 | [[package]] 368 | name = "either" 369 | version = "1.15.0" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 372 | 373 | [[package]] 374 | name = "equivalent" 375 | version = "1.0.2" 376 | source = "registry+https://github.com/rust-lang/crates.io-index" 377 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 378 | 379 | [[package]] 380 | name = "errno" 381 | version = "0.3.12" 382 | source = "registry+https://github.com/rust-lang/crates.io-index" 383 | checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" 384 | dependencies = [ 385 | "libc", 386 | "windows-sys 0.59.0", 387 | ] 388 | 389 | [[package]] 390 | name = "error-code" 391 | version = "3.3.2" 392 | source = "registry+https://github.com/rust-lang/crates.io-index" 393 | checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" 394 | 395 | [[package]] 396 | name = "fdeflate" 397 | version = "0.3.7" 398 | source = "registry+https://github.com/rust-lang/crates.io-index" 399 | checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" 400 | dependencies = [ 401 | "simd-adler32", 402 | ] 403 | 404 | [[package]] 405 | name = "flate2" 406 | version = "1.1.1" 407 | source = "registry+https://github.com/rust-lang/crates.io-index" 408 | checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" 409 | dependencies = [ 410 | "crc32fast", 411 | "miniz_oxide", 412 | ] 413 | 414 | [[package]] 415 | name = "foldhash" 416 | version = "0.1.5" 417 | source = "registry+https://github.com/rust-lang/crates.io-index" 418 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 419 | 420 | [[package]] 421 | name = "futures-core" 422 | version = "0.3.31" 423 | source = "registry+https://github.com/rust-lang/crates.io-index" 424 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 425 | 426 | [[package]] 427 | name = "fuzzy-matcher" 428 | version = "0.3.7" 429 | source = "registry+https://github.com/rust-lang/crates.io-index" 430 | checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" 431 | dependencies = [ 432 | "thread_local", 433 | ] 434 | 435 | [[package]] 436 | name = "gethostname" 437 | version = "0.4.3" 438 | source = "registry+https://github.com/rust-lang/crates.io-index" 439 | checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" 440 | dependencies = [ 441 | "libc", 442 | "windows-targets 0.48.5", 443 | ] 444 | 445 | [[package]] 446 | name = "getrandom" 447 | version = "0.2.16" 448 | source = "registry+https://github.com/rust-lang/crates.io-index" 449 | checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 450 | dependencies = [ 451 | "cfg-if", 452 | "libc", 453 | "wasi", 454 | ] 455 | 456 | [[package]] 457 | name = "gimli" 458 | version = "0.31.1" 459 | source = "registry+https://github.com/rust-lang/crates.io-index" 460 | checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 461 | 462 | [[package]] 463 | name = "hashbrown" 464 | version = "0.15.3" 465 | source = "registry+https://github.com/rust-lang/crates.io-index" 466 | checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" 467 | dependencies = [ 468 | "allocator-api2", 469 | "equivalent", 470 | "foldhash", 471 | ] 472 | 473 | [[package]] 474 | name = "heck" 475 | version = "0.5.0" 476 | source = "registry+https://github.com/rust-lang/crates.io-index" 477 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 478 | 479 | [[package]] 480 | name = "iana-time-zone" 481 | version = "0.1.63" 482 | source = "registry+https://github.com/rust-lang/crates.io-index" 483 | checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" 484 | dependencies = [ 485 | "android_system_properties", 486 | "core-foundation-sys", 487 | "iana-time-zone-haiku", 488 | "js-sys", 489 | "log", 490 | "wasm-bindgen", 491 | "windows-core", 492 | ] 493 | 494 | [[package]] 495 | name = "iana-time-zone-haiku" 496 | version = "0.1.2" 497 | source = "registry+https://github.com/rust-lang/crates.io-index" 498 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 499 | dependencies = [ 500 | "cc", 501 | ] 502 | 503 | [[package]] 504 | name = "image" 505 | version = "0.25.6" 506 | source = "registry+https://github.com/rust-lang/crates.io-index" 507 | checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" 508 | dependencies = [ 509 | "bytemuck", 510 | "byteorder-lite", 511 | "num-traits", 512 | "png", 513 | "tiff", 514 | ] 515 | 516 | [[package]] 517 | name = "indexmap" 518 | version = "2.9.0" 519 | source = "registry+https://github.com/rust-lang/crates.io-index" 520 | checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" 521 | dependencies = [ 522 | "equivalent", 523 | "hashbrown", 524 | ] 525 | 526 | [[package]] 527 | name = "is-docker" 528 | version = "0.2.0" 529 | source = "registry+https://github.com/rust-lang/crates.io-index" 530 | checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" 531 | dependencies = [ 532 | "once_cell", 533 | ] 534 | 535 | [[package]] 536 | name = "is-wsl" 537 | version = "0.4.0" 538 | source = "registry+https://github.com/rust-lang/crates.io-index" 539 | checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" 540 | dependencies = [ 541 | "is-docker", 542 | "once_cell", 543 | ] 544 | 545 | [[package]] 546 | name = "is_terminal_polyfill" 547 | version = "1.70.1" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 550 | 551 | [[package]] 552 | name = "itertools" 553 | version = "0.12.1" 554 | source = "registry+https://github.com/rust-lang/crates.io-index" 555 | checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" 556 | dependencies = [ 557 | "either", 558 | ] 559 | 560 | [[package]] 561 | name = "itertools" 562 | version = "0.13.0" 563 | source = "registry+https://github.com/rust-lang/crates.io-index" 564 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 565 | dependencies = [ 566 | "either", 567 | ] 568 | 569 | [[package]] 570 | name = "itoa" 571 | version = "1.0.15" 572 | source = "registry+https://github.com/rust-lang/crates.io-index" 573 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 574 | 575 | [[package]] 576 | name = "jpeg-decoder" 577 | version = "0.3.1" 578 | source = "registry+https://github.com/rust-lang/crates.io-index" 579 | checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" 580 | 581 | [[package]] 582 | name = "js-sys" 583 | version = "0.3.77" 584 | source = "registry+https://github.com/rust-lang/crates.io-index" 585 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 586 | dependencies = [ 587 | "once_cell", 588 | "wasm-bindgen", 589 | ] 590 | 591 | [[package]] 592 | name = "lazy_static" 593 | version = "1.5.0" 594 | source = "registry+https://github.com/rust-lang/crates.io-index" 595 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 596 | 597 | [[package]] 598 | name = "libc" 599 | version = "0.2.172" 600 | source = "registry+https://github.com/rust-lang/crates.io-index" 601 | checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" 602 | 603 | [[package]] 604 | name = "libredox" 605 | version = "0.1.3" 606 | source = "registry+https://github.com/rust-lang/crates.io-index" 607 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 608 | dependencies = [ 609 | "bitflags 2.9.1", 610 | "libc", 611 | ] 612 | 613 | [[package]] 614 | name = "linux-raw-sys" 615 | version = "0.4.15" 616 | source = "registry+https://github.com/rust-lang/crates.io-index" 617 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 618 | 619 | [[package]] 620 | name = "lock_api" 621 | version = "0.4.13" 622 | source = "registry+https://github.com/rust-lang/crates.io-index" 623 | checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" 624 | dependencies = [ 625 | "autocfg", 626 | "scopeguard", 627 | ] 628 | 629 | [[package]] 630 | name = "log" 631 | version = "0.4.27" 632 | source = "registry+https://github.com/rust-lang/crates.io-index" 633 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 634 | 635 | [[package]] 636 | name = "lru" 637 | version = "0.12.5" 638 | source = "registry+https://github.com/rust-lang/crates.io-index" 639 | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 640 | dependencies = [ 641 | "hashbrown", 642 | ] 643 | 644 | [[package]] 645 | name = "matchers" 646 | version = "0.1.0" 647 | source = "registry+https://github.com/rust-lang/crates.io-index" 648 | checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" 649 | dependencies = [ 650 | "regex-automata 0.1.10", 651 | ] 652 | 653 | [[package]] 654 | name = "memchr" 655 | version = "2.7.4" 656 | source = "registry+https://github.com/rust-lang/crates.io-index" 657 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 658 | 659 | [[package]] 660 | name = "miniz_oxide" 661 | version = "0.8.8" 662 | source = "registry+https://github.com/rust-lang/crates.io-index" 663 | checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" 664 | dependencies = [ 665 | "adler2", 666 | "simd-adler32", 667 | ] 668 | 669 | [[package]] 670 | name = "mio" 671 | version = "0.8.11" 672 | source = "registry+https://github.com/rust-lang/crates.io-index" 673 | checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" 674 | dependencies = [ 675 | "libc", 676 | "log", 677 | "wasi", 678 | "windows-sys 0.48.0", 679 | ] 680 | 681 | [[package]] 682 | name = "mio" 683 | version = "1.0.4" 684 | source = "registry+https://github.com/rust-lang/crates.io-index" 685 | checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" 686 | dependencies = [ 687 | "libc", 688 | "wasi", 689 | "windows-sys 0.59.0", 690 | ] 691 | 692 | [[package]] 693 | name = "nu-ansi-term" 694 | version = "0.46.0" 695 | source = "registry+https://github.com/rust-lang/crates.io-index" 696 | checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" 697 | dependencies = [ 698 | "overload", 699 | "winapi", 700 | ] 701 | 702 | [[package]] 703 | name = "num-traits" 704 | version = "0.2.19" 705 | source = "registry+https://github.com/rust-lang/crates.io-index" 706 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 707 | dependencies = [ 708 | "autocfg", 709 | ] 710 | 711 | [[package]] 712 | name = "objc2" 713 | version = "0.6.1" 714 | source = "registry+https://github.com/rust-lang/crates.io-index" 715 | checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" 716 | dependencies = [ 717 | "objc2-encode", 718 | ] 719 | 720 | [[package]] 721 | name = "objc2-app-kit" 722 | version = "0.3.1" 723 | source = "registry+https://github.com/rust-lang/crates.io-index" 724 | checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" 725 | dependencies = [ 726 | "bitflags 2.9.1", 727 | "objc2", 728 | "objc2-core-graphics", 729 | "objc2-foundation", 730 | ] 731 | 732 | [[package]] 733 | name = "objc2-core-foundation" 734 | version = "0.3.1" 735 | source = "registry+https://github.com/rust-lang/crates.io-index" 736 | checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" 737 | dependencies = [ 738 | "bitflags 2.9.1", 739 | "dispatch2", 740 | "objc2", 741 | ] 742 | 743 | [[package]] 744 | name = "objc2-core-graphics" 745 | version = "0.3.1" 746 | source = "registry+https://github.com/rust-lang/crates.io-index" 747 | checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" 748 | dependencies = [ 749 | "bitflags 2.9.1", 750 | "dispatch2", 751 | "objc2", 752 | "objc2-core-foundation", 753 | "objc2-io-surface", 754 | ] 755 | 756 | [[package]] 757 | name = "objc2-encode" 758 | version = "4.1.0" 759 | source = "registry+https://github.com/rust-lang/crates.io-index" 760 | checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" 761 | 762 | [[package]] 763 | name = "objc2-foundation" 764 | version = "0.3.1" 765 | source = "registry+https://github.com/rust-lang/crates.io-index" 766 | checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" 767 | dependencies = [ 768 | "bitflags 2.9.1", 769 | "objc2", 770 | "objc2-core-foundation", 771 | ] 772 | 773 | [[package]] 774 | name = "objc2-io-surface" 775 | version = "0.3.1" 776 | source = "registry+https://github.com/rust-lang/crates.io-index" 777 | checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" 778 | dependencies = [ 779 | "bitflags 2.9.1", 780 | "objc2", 781 | "objc2-core-foundation", 782 | ] 783 | 784 | [[package]] 785 | name = "object" 786 | version = "0.36.7" 787 | source = "registry+https://github.com/rust-lang/crates.io-index" 788 | checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 789 | dependencies = [ 790 | "memchr", 791 | ] 792 | 793 | [[package]] 794 | name = "once_cell" 795 | version = "1.21.3" 796 | source = "registry+https://github.com/rust-lang/crates.io-index" 797 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 798 | 799 | [[package]] 800 | name = "once_cell_polyfill" 801 | version = "1.70.1" 802 | source = "registry+https://github.com/rust-lang/crates.io-index" 803 | checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" 804 | 805 | [[package]] 806 | name = "open" 807 | version = "5.3.2" 808 | source = "registry+https://github.com/rust-lang/crates.io-index" 809 | checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" 810 | dependencies = [ 811 | "is-wsl", 812 | "libc", 813 | "pathdiff", 814 | ] 815 | 816 | [[package]] 817 | name = "option-ext" 818 | version = "0.2.0" 819 | source = "registry+https://github.com/rust-lang/crates.io-index" 820 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 821 | 822 | [[package]] 823 | name = "overload" 824 | version = "0.1.1" 825 | source = "registry+https://github.com/rust-lang/crates.io-index" 826 | checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" 827 | 828 | [[package]] 829 | name = "parking_lot" 830 | version = "0.12.4" 831 | source = "registry+https://github.com/rust-lang/crates.io-index" 832 | checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" 833 | dependencies = [ 834 | "lock_api", 835 | "parking_lot_core", 836 | ] 837 | 838 | [[package]] 839 | name = "parking_lot_core" 840 | version = "0.9.11" 841 | source = "registry+https://github.com/rust-lang/crates.io-index" 842 | checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" 843 | dependencies = [ 844 | "cfg-if", 845 | "libc", 846 | "redox_syscall", 847 | "smallvec", 848 | "windows-targets 0.52.6", 849 | ] 850 | 851 | [[package]] 852 | name = "paste" 853 | version = "1.0.15" 854 | source = "registry+https://github.com/rust-lang/crates.io-index" 855 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 856 | 857 | [[package]] 858 | name = "pathdiff" 859 | version = "0.2.3" 860 | source = "registry+https://github.com/rust-lang/crates.io-index" 861 | checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" 862 | 863 | [[package]] 864 | name = "percent-encoding" 865 | version = "2.3.1" 866 | source = "registry+https://github.com/rust-lang/crates.io-index" 867 | checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 868 | 869 | [[package]] 870 | name = "pin-project-lite" 871 | version = "0.2.16" 872 | source = "registry+https://github.com/rust-lang/crates.io-index" 873 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 874 | 875 | [[package]] 876 | name = "png" 877 | version = "0.17.16" 878 | source = "registry+https://github.com/rust-lang/crates.io-index" 879 | checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" 880 | dependencies = [ 881 | "bitflags 1.3.2", 882 | "crc32fast", 883 | "fdeflate", 884 | "flate2", 885 | "miniz_oxide", 886 | ] 887 | 888 | [[package]] 889 | name = "proc-macro2" 890 | version = "1.0.95" 891 | source = "registry+https://github.com/rust-lang/crates.io-index" 892 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 893 | dependencies = [ 894 | "unicode-ident", 895 | ] 896 | 897 | [[package]] 898 | name = "quote" 899 | version = "1.0.40" 900 | source = "registry+https://github.com/rust-lang/crates.io-index" 901 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 902 | dependencies = [ 903 | "proc-macro2", 904 | ] 905 | 906 | [[package]] 907 | name = "ratatui" 908 | version = "0.26.3" 909 | source = "registry+https://github.com/rust-lang/crates.io-index" 910 | checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" 911 | dependencies = [ 912 | "bitflags 2.9.1", 913 | "cassowary", 914 | "compact_str", 915 | "crossterm", 916 | "itertools 0.12.1", 917 | "lru", 918 | "paste", 919 | "stability", 920 | "strum", 921 | "unicode-segmentation", 922 | "unicode-truncate", 923 | "unicode-width", 924 | ] 925 | 926 | [[package]] 927 | name = "redox_syscall" 928 | version = "0.5.12" 929 | source = "registry+https://github.com/rust-lang/crates.io-index" 930 | checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" 931 | dependencies = [ 932 | "bitflags 2.9.1", 933 | ] 934 | 935 | [[package]] 936 | name = "redox_users" 937 | version = "0.5.0" 938 | source = "registry+https://github.com/rust-lang/crates.io-index" 939 | checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" 940 | dependencies = [ 941 | "getrandom", 942 | "libredox", 943 | "thiserror", 944 | ] 945 | 946 | [[package]] 947 | name = "regex" 948 | version = "1.11.1" 949 | source = "registry+https://github.com/rust-lang/crates.io-index" 950 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 951 | dependencies = [ 952 | "aho-corasick", 953 | "memchr", 954 | "regex-automata 0.4.9", 955 | "regex-syntax 0.8.5", 956 | ] 957 | 958 | [[package]] 959 | name = "regex-automata" 960 | version = "0.1.10" 961 | source = "registry+https://github.com/rust-lang/crates.io-index" 962 | checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" 963 | dependencies = [ 964 | "regex-syntax 0.6.29", 965 | ] 966 | 967 | [[package]] 968 | name = "regex-automata" 969 | version = "0.4.9" 970 | source = "registry+https://github.com/rust-lang/crates.io-index" 971 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 972 | dependencies = [ 973 | "aho-corasick", 974 | "memchr", 975 | "regex-syntax 0.8.5", 976 | ] 977 | 978 | [[package]] 979 | name = "regex-syntax" 980 | version = "0.6.29" 981 | source = "registry+https://github.com/rust-lang/crates.io-index" 982 | checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" 983 | 984 | [[package]] 985 | name = "regex-syntax" 986 | version = "0.8.5" 987 | source = "registry+https://github.com/rust-lang/crates.io-index" 988 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 989 | 990 | [[package]] 991 | name = "rustc-demangle" 992 | version = "0.1.24" 993 | source = "registry+https://github.com/rust-lang/crates.io-index" 994 | checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 995 | 996 | [[package]] 997 | name = "rustix" 998 | version = "0.38.44" 999 | source = "registry+https://github.com/rust-lang/crates.io-index" 1000 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 1001 | dependencies = [ 1002 | "bitflags 2.9.1", 1003 | "errno", 1004 | "libc", 1005 | "linux-raw-sys", 1006 | "windows-sys 0.59.0", 1007 | ] 1008 | 1009 | [[package]] 1010 | name = "rustversion" 1011 | version = "1.0.21" 1012 | source = "registry+https://github.com/rust-lang/crates.io-index" 1013 | checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" 1014 | 1015 | [[package]] 1016 | name = "ryu" 1017 | version = "1.0.20" 1018 | source = "registry+https://github.com/rust-lang/crates.io-index" 1019 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 1020 | 1021 | [[package]] 1022 | name = "scopeguard" 1023 | version = "1.2.0" 1024 | source = "registry+https://github.com/rust-lang/crates.io-index" 1025 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1026 | 1027 | [[package]] 1028 | name = "serde" 1029 | version = "1.0.219" 1030 | source = "registry+https://github.com/rust-lang/crates.io-index" 1031 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 1032 | dependencies = [ 1033 | "serde_derive", 1034 | ] 1035 | 1036 | [[package]] 1037 | name = "serde_derive" 1038 | version = "1.0.219" 1039 | source = "registry+https://github.com/rust-lang/crates.io-index" 1040 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 1041 | dependencies = [ 1042 | "proc-macro2", 1043 | "quote", 1044 | "syn", 1045 | ] 1046 | 1047 | [[package]] 1048 | name = "serde_json" 1049 | version = "1.0.140" 1050 | source = "registry+https://github.com/rust-lang/crates.io-index" 1051 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 1052 | dependencies = [ 1053 | "itoa", 1054 | "memchr", 1055 | "ryu", 1056 | "serde", 1057 | ] 1058 | 1059 | [[package]] 1060 | name = "serde_spanned" 1061 | version = "0.6.8" 1062 | source = "registry+https://github.com/rust-lang/crates.io-index" 1063 | checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" 1064 | dependencies = [ 1065 | "serde", 1066 | ] 1067 | 1068 | [[package]] 1069 | name = "sharded-slab" 1070 | version = "0.1.7" 1071 | source = "registry+https://github.com/rust-lang/crates.io-index" 1072 | checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 1073 | dependencies = [ 1074 | "lazy_static", 1075 | ] 1076 | 1077 | [[package]] 1078 | name = "shlex" 1079 | version = "1.3.0" 1080 | source = "registry+https://github.com/rust-lang/crates.io-index" 1081 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1082 | 1083 | [[package]] 1084 | name = "signal-hook" 1085 | version = "0.3.18" 1086 | source = "registry+https://github.com/rust-lang/crates.io-index" 1087 | checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" 1088 | dependencies = [ 1089 | "libc", 1090 | "signal-hook-registry", 1091 | ] 1092 | 1093 | [[package]] 1094 | name = "signal-hook-mio" 1095 | version = "0.2.4" 1096 | source = "registry+https://github.com/rust-lang/crates.io-index" 1097 | checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" 1098 | dependencies = [ 1099 | "libc", 1100 | "mio 0.8.11", 1101 | "signal-hook", 1102 | ] 1103 | 1104 | [[package]] 1105 | name = "signal-hook-registry" 1106 | version = "1.4.5" 1107 | source = "registry+https://github.com/rust-lang/crates.io-index" 1108 | checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" 1109 | dependencies = [ 1110 | "libc", 1111 | ] 1112 | 1113 | [[package]] 1114 | name = "simd-adler32" 1115 | version = "0.3.7" 1116 | source = "registry+https://github.com/rust-lang/crates.io-index" 1117 | checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" 1118 | 1119 | [[package]] 1120 | name = "smallvec" 1121 | version = "1.15.0" 1122 | source = "registry+https://github.com/rust-lang/crates.io-index" 1123 | checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" 1124 | 1125 | [[package]] 1126 | name = "socket2" 1127 | version = "0.5.10" 1128 | source = "registry+https://github.com/rust-lang/crates.io-index" 1129 | checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" 1130 | dependencies = [ 1131 | "libc", 1132 | "windows-sys 0.52.0", 1133 | ] 1134 | 1135 | [[package]] 1136 | name = "sshr" 1137 | version = "0.8.0" 1138 | dependencies = [ 1139 | "anyhow", 1140 | "arboard", 1141 | "chrono", 1142 | "clap", 1143 | "crossterm", 1144 | "dirs", 1145 | "fuzzy-matcher", 1146 | "open", 1147 | "ratatui", 1148 | "serde", 1149 | "serde_json", 1150 | "tokio", 1151 | "toml", 1152 | "tracing", 1153 | "tracing-subscriber", 1154 | ] 1155 | 1156 | [[package]] 1157 | name = "stability" 1158 | version = "0.2.1" 1159 | source = "registry+https://github.com/rust-lang/crates.io-index" 1160 | checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" 1161 | dependencies = [ 1162 | "quote", 1163 | "syn", 1164 | ] 1165 | 1166 | [[package]] 1167 | name = "static_assertions" 1168 | version = "1.1.0" 1169 | source = "registry+https://github.com/rust-lang/crates.io-index" 1170 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 1171 | 1172 | [[package]] 1173 | name = "strsim" 1174 | version = "0.11.1" 1175 | source = "registry+https://github.com/rust-lang/crates.io-index" 1176 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1177 | 1178 | [[package]] 1179 | name = "strum" 1180 | version = "0.26.3" 1181 | source = "registry+https://github.com/rust-lang/crates.io-index" 1182 | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 1183 | dependencies = [ 1184 | "strum_macros", 1185 | ] 1186 | 1187 | [[package]] 1188 | name = "strum_macros" 1189 | version = "0.26.4" 1190 | source = "registry+https://github.com/rust-lang/crates.io-index" 1191 | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 1192 | dependencies = [ 1193 | "heck", 1194 | "proc-macro2", 1195 | "quote", 1196 | "rustversion", 1197 | "syn", 1198 | ] 1199 | 1200 | [[package]] 1201 | name = "syn" 1202 | version = "2.0.101" 1203 | source = "registry+https://github.com/rust-lang/crates.io-index" 1204 | checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" 1205 | dependencies = [ 1206 | "proc-macro2", 1207 | "quote", 1208 | "unicode-ident", 1209 | ] 1210 | 1211 | [[package]] 1212 | name = "thiserror" 1213 | version = "2.0.12" 1214 | source = "registry+https://github.com/rust-lang/crates.io-index" 1215 | checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" 1216 | dependencies = [ 1217 | "thiserror-impl", 1218 | ] 1219 | 1220 | [[package]] 1221 | name = "thiserror-impl" 1222 | version = "2.0.12" 1223 | source = "registry+https://github.com/rust-lang/crates.io-index" 1224 | checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" 1225 | dependencies = [ 1226 | "proc-macro2", 1227 | "quote", 1228 | "syn", 1229 | ] 1230 | 1231 | [[package]] 1232 | name = "thread_local" 1233 | version = "1.1.8" 1234 | source = "registry+https://github.com/rust-lang/crates.io-index" 1235 | checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" 1236 | dependencies = [ 1237 | "cfg-if", 1238 | "once_cell", 1239 | ] 1240 | 1241 | [[package]] 1242 | name = "tiff" 1243 | version = "0.9.1" 1244 | source = "registry+https://github.com/rust-lang/crates.io-index" 1245 | checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" 1246 | dependencies = [ 1247 | "flate2", 1248 | "jpeg-decoder", 1249 | "weezl", 1250 | ] 1251 | 1252 | [[package]] 1253 | name = "tokio" 1254 | version = "1.45.1" 1255 | source = "registry+https://github.com/rust-lang/crates.io-index" 1256 | checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" 1257 | dependencies = [ 1258 | "backtrace", 1259 | "bytes", 1260 | "libc", 1261 | "mio 1.0.4", 1262 | "parking_lot", 1263 | "pin-project-lite", 1264 | "signal-hook-registry", 1265 | "socket2", 1266 | "tokio-macros", 1267 | "windows-sys 0.52.0", 1268 | ] 1269 | 1270 | [[package]] 1271 | name = "tokio-macros" 1272 | version = "2.5.0" 1273 | source = "registry+https://github.com/rust-lang/crates.io-index" 1274 | checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" 1275 | dependencies = [ 1276 | "proc-macro2", 1277 | "quote", 1278 | "syn", 1279 | ] 1280 | 1281 | [[package]] 1282 | name = "toml" 1283 | version = "0.8.22" 1284 | source = "registry+https://github.com/rust-lang/crates.io-index" 1285 | checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" 1286 | dependencies = [ 1287 | "serde", 1288 | "serde_spanned", 1289 | "toml_datetime", 1290 | "toml_edit", 1291 | ] 1292 | 1293 | [[package]] 1294 | name = "toml_datetime" 1295 | version = "0.6.9" 1296 | source = "registry+https://github.com/rust-lang/crates.io-index" 1297 | checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" 1298 | dependencies = [ 1299 | "serde", 1300 | ] 1301 | 1302 | [[package]] 1303 | name = "toml_edit" 1304 | version = "0.22.26" 1305 | source = "registry+https://github.com/rust-lang/crates.io-index" 1306 | checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" 1307 | dependencies = [ 1308 | "indexmap", 1309 | "serde", 1310 | "serde_spanned", 1311 | "toml_datetime", 1312 | "toml_write", 1313 | "winnow", 1314 | ] 1315 | 1316 | [[package]] 1317 | name = "toml_write" 1318 | version = "0.1.1" 1319 | source = "registry+https://github.com/rust-lang/crates.io-index" 1320 | checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" 1321 | 1322 | [[package]] 1323 | name = "tracing" 1324 | version = "0.1.41" 1325 | source = "registry+https://github.com/rust-lang/crates.io-index" 1326 | checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 1327 | dependencies = [ 1328 | "pin-project-lite", 1329 | "tracing-attributes", 1330 | "tracing-core", 1331 | ] 1332 | 1333 | [[package]] 1334 | name = "tracing-attributes" 1335 | version = "0.1.28" 1336 | source = "registry+https://github.com/rust-lang/crates.io-index" 1337 | checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" 1338 | dependencies = [ 1339 | "proc-macro2", 1340 | "quote", 1341 | "syn", 1342 | ] 1343 | 1344 | [[package]] 1345 | name = "tracing-core" 1346 | version = "0.1.33" 1347 | source = "registry+https://github.com/rust-lang/crates.io-index" 1348 | checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" 1349 | dependencies = [ 1350 | "once_cell", 1351 | "valuable", 1352 | ] 1353 | 1354 | [[package]] 1355 | name = "tracing-log" 1356 | version = "0.2.0" 1357 | source = "registry+https://github.com/rust-lang/crates.io-index" 1358 | checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 1359 | dependencies = [ 1360 | "log", 1361 | "once_cell", 1362 | "tracing-core", 1363 | ] 1364 | 1365 | [[package]] 1366 | name = "tracing-subscriber" 1367 | version = "0.3.19" 1368 | source = "registry+https://github.com/rust-lang/crates.io-index" 1369 | checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" 1370 | dependencies = [ 1371 | "matchers", 1372 | "nu-ansi-term", 1373 | "once_cell", 1374 | "regex", 1375 | "sharded-slab", 1376 | "smallvec", 1377 | "thread_local", 1378 | "tracing", 1379 | "tracing-core", 1380 | "tracing-log", 1381 | ] 1382 | 1383 | [[package]] 1384 | name = "unicode-ident" 1385 | version = "1.0.18" 1386 | source = "registry+https://github.com/rust-lang/crates.io-index" 1387 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 1388 | 1389 | [[package]] 1390 | name = "unicode-segmentation" 1391 | version = "1.12.0" 1392 | source = "registry+https://github.com/rust-lang/crates.io-index" 1393 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 1394 | 1395 | [[package]] 1396 | name = "unicode-truncate" 1397 | version = "1.1.0" 1398 | source = "registry+https://github.com/rust-lang/crates.io-index" 1399 | checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" 1400 | dependencies = [ 1401 | "itertools 0.13.0", 1402 | "unicode-segmentation", 1403 | "unicode-width", 1404 | ] 1405 | 1406 | [[package]] 1407 | name = "unicode-width" 1408 | version = "0.1.14" 1409 | source = "registry+https://github.com/rust-lang/crates.io-index" 1410 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 1411 | 1412 | [[package]] 1413 | name = "utf8parse" 1414 | version = "0.2.2" 1415 | source = "registry+https://github.com/rust-lang/crates.io-index" 1416 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1417 | 1418 | [[package]] 1419 | name = "valuable" 1420 | version = "0.1.1" 1421 | source = "registry+https://github.com/rust-lang/crates.io-index" 1422 | checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 1423 | 1424 | [[package]] 1425 | name = "wasi" 1426 | version = "0.11.0+wasi-snapshot-preview1" 1427 | source = "registry+https://github.com/rust-lang/crates.io-index" 1428 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 1429 | 1430 | [[package]] 1431 | name = "wasm-bindgen" 1432 | version = "0.2.100" 1433 | source = "registry+https://github.com/rust-lang/crates.io-index" 1434 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 1435 | dependencies = [ 1436 | "cfg-if", 1437 | "once_cell", 1438 | "rustversion", 1439 | "wasm-bindgen-macro", 1440 | ] 1441 | 1442 | [[package]] 1443 | name = "wasm-bindgen-backend" 1444 | version = "0.2.100" 1445 | source = "registry+https://github.com/rust-lang/crates.io-index" 1446 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 1447 | dependencies = [ 1448 | "bumpalo", 1449 | "log", 1450 | "proc-macro2", 1451 | "quote", 1452 | "syn", 1453 | "wasm-bindgen-shared", 1454 | ] 1455 | 1456 | [[package]] 1457 | name = "wasm-bindgen-macro" 1458 | version = "0.2.100" 1459 | source = "registry+https://github.com/rust-lang/crates.io-index" 1460 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 1461 | dependencies = [ 1462 | "quote", 1463 | "wasm-bindgen-macro-support", 1464 | ] 1465 | 1466 | [[package]] 1467 | name = "wasm-bindgen-macro-support" 1468 | version = "0.2.100" 1469 | source = "registry+https://github.com/rust-lang/crates.io-index" 1470 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 1471 | dependencies = [ 1472 | "proc-macro2", 1473 | "quote", 1474 | "syn", 1475 | "wasm-bindgen-backend", 1476 | "wasm-bindgen-shared", 1477 | ] 1478 | 1479 | [[package]] 1480 | name = "wasm-bindgen-shared" 1481 | version = "0.2.100" 1482 | source = "registry+https://github.com/rust-lang/crates.io-index" 1483 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 1484 | dependencies = [ 1485 | "unicode-ident", 1486 | ] 1487 | 1488 | [[package]] 1489 | name = "weezl" 1490 | version = "0.1.10" 1491 | source = "registry+https://github.com/rust-lang/crates.io-index" 1492 | checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" 1493 | 1494 | [[package]] 1495 | name = "winapi" 1496 | version = "0.3.9" 1497 | source = "registry+https://github.com/rust-lang/crates.io-index" 1498 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1499 | dependencies = [ 1500 | "winapi-i686-pc-windows-gnu", 1501 | "winapi-x86_64-pc-windows-gnu", 1502 | ] 1503 | 1504 | [[package]] 1505 | name = "winapi-i686-pc-windows-gnu" 1506 | version = "0.4.0" 1507 | source = "registry+https://github.com/rust-lang/crates.io-index" 1508 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1509 | 1510 | [[package]] 1511 | name = "winapi-x86_64-pc-windows-gnu" 1512 | version = "0.4.0" 1513 | source = "registry+https://github.com/rust-lang/crates.io-index" 1514 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1515 | 1516 | [[package]] 1517 | name = "windows-core" 1518 | version = "0.61.2" 1519 | source = "registry+https://github.com/rust-lang/crates.io-index" 1520 | checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" 1521 | dependencies = [ 1522 | "windows-implement", 1523 | "windows-interface", 1524 | "windows-link", 1525 | "windows-result", 1526 | "windows-strings", 1527 | ] 1528 | 1529 | [[package]] 1530 | name = "windows-implement" 1531 | version = "0.60.0" 1532 | source = "registry+https://github.com/rust-lang/crates.io-index" 1533 | checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" 1534 | dependencies = [ 1535 | "proc-macro2", 1536 | "quote", 1537 | "syn", 1538 | ] 1539 | 1540 | [[package]] 1541 | name = "windows-interface" 1542 | version = "0.59.1" 1543 | source = "registry+https://github.com/rust-lang/crates.io-index" 1544 | checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" 1545 | dependencies = [ 1546 | "proc-macro2", 1547 | "quote", 1548 | "syn", 1549 | ] 1550 | 1551 | [[package]] 1552 | name = "windows-link" 1553 | version = "0.1.1" 1554 | source = "registry+https://github.com/rust-lang/crates.io-index" 1555 | checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" 1556 | 1557 | [[package]] 1558 | name = "windows-result" 1559 | version = "0.3.4" 1560 | source = "registry+https://github.com/rust-lang/crates.io-index" 1561 | checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" 1562 | dependencies = [ 1563 | "windows-link", 1564 | ] 1565 | 1566 | [[package]] 1567 | name = "windows-strings" 1568 | version = "0.4.2" 1569 | source = "registry+https://github.com/rust-lang/crates.io-index" 1570 | checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 1571 | dependencies = [ 1572 | "windows-link", 1573 | ] 1574 | 1575 | [[package]] 1576 | name = "windows-sys" 1577 | version = "0.48.0" 1578 | source = "registry+https://github.com/rust-lang/crates.io-index" 1579 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 1580 | dependencies = [ 1581 | "windows-targets 0.48.5", 1582 | ] 1583 | 1584 | [[package]] 1585 | name = "windows-sys" 1586 | version = "0.52.0" 1587 | source = "registry+https://github.com/rust-lang/crates.io-index" 1588 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 1589 | dependencies = [ 1590 | "windows-targets 0.52.6", 1591 | ] 1592 | 1593 | [[package]] 1594 | name = "windows-sys" 1595 | version = "0.59.0" 1596 | source = "registry+https://github.com/rust-lang/crates.io-index" 1597 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1598 | dependencies = [ 1599 | "windows-targets 0.52.6", 1600 | ] 1601 | 1602 | [[package]] 1603 | name = "windows-targets" 1604 | version = "0.48.5" 1605 | source = "registry+https://github.com/rust-lang/crates.io-index" 1606 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 1607 | dependencies = [ 1608 | "windows_aarch64_gnullvm 0.48.5", 1609 | "windows_aarch64_msvc 0.48.5", 1610 | "windows_i686_gnu 0.48.5", 1611 | "windows_i686_msvc 0.48.5", 1612 | "windows_x86_64_gnu 0.48.5", 1613 | "windows_x86_64_gnullvm 0.48.5", 1614 | "windows_x86_64_msvc 0.48.5", 1615 | ] 1616 | 1617 | [[package]] 1618 | name = "windows-targets" 1619 | version = "0.52.6" 1620 | source = "registry+https://github.com/rust-lang/crates.io-index" 1621 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1622 | dependencies = [ 1623 | "windows_aarch64_gnullvm 0.52.6", 1624 | "windows_aarch64_msvc 0.52.6", 1625 | "windows_i686_gnu 0.52.6", 1626 | "windows_i686_gnullvm", 1627 | "windows_i686_msvc 0.52.6", 1628 | "windows_x86_64_gnu 0.52.6", 1629 | "windows_x86_64_gnullvm 0.52.6", 1630 | "windows_x86_64_msvc 0.52.6", 1631 | ] 1632 | 1633 | [[package]] 1634 | name = "windows_aarch64_gnullvm" 1635 | version = "0.48.5" 1636 | source = "registry+https://github.com/rust-lang/crates.io-index" 1637 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 1638 | 1639 | [[package]] 1640 | name = "windows_aarch64_gnullvm" 1641 | version = "0.52.6" 1642 | source = "registry+https://github.com/rust-lang/crates.io-index" 1643 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1644 | 1645 | [[package]] 1646 | name = "windows_aarch64_msvc" 1647 | version = "0.48.5" 1648 | source = "registry+https://github.com/rust-lang/crates.io-index" 1649 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 1650 | 1651 | [[package]] 1652 | name = "windows_aarch64_msvc" 1653 | version = "0.52.6" 1654 | source = "registry+https://github.com/rust-lang/crates.io-index" 1655 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1656 | 1657 | [[package]] 1658 | name = "windows_i686_gnu" 1659 | version = "0.48.5" 1660 | source = "registry+https://github.com/rust-lang/crates.io-index" 1661 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 1662 | 1663 | [[package]] 1664 | name = "windows_i686_gnu" 1665 | version = "0.52.6" 1666 | source = "registry+https://github.com/rust-lang/crates.io-index" 1667 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1668 | 1669 | [[package]] 1670 | name = "windows_i686_gnullvm" 1671 | version = "0.52.6" 1672 | source = "registry+https://github.com/rust-lang/crates.io-index" 1673 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1674 | 1675 | [[package]] 1676 | name = "windows_i686_msvc" 1677 | version = "0.48.5" 1678 | source = "registry+https://github.com/rust-lang/crates.io-index" 1679 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 1680 | 1681 | [[package]] 1682 | name = "windows_i686_msvc" 1683 | version = "0.52.6" 1684 | source = "registry+https://github.com/rust-lang/crates.io-index" 1685 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1686 | 1687 | [[package]] 1688 | name = "windows_x86_64_gnu" 1689 | version = "0.48.5" 1690 | source = "registry+https://github.com/rust-lang/crates.io-index" 1691 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 1692 | 1693 | [[package]] 1694 | name = "windows_x86_64_gnu" 1695 | version = "0.52.6" 1696 | source = "registry+https://github.com/rust-lang/crates.io-index" 1697 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1698 | 1699 | [[package]] 1700 | name = "windows_x86_64_gnullvm" 1701 | version = "0.48.5" 1702 | source = "registry+https://github.com/rust-lang/crates.io-index" 1703 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 1704 | 1705 | [[package]] 1706 | name = "windows_x86_64_gnullvm" 1707 | version = "0.52.6" 1708 | source = "registry+https://github.com/rust-lang/crates.io-index" 1709 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1710 | 1711 | [[package]] 1712 | name = "windows_x86_64_msvc" 1713 | version = "0.48.5" 1714 | source = "registry+https://github.com/rust-lang/crates.io-index" 1715 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 1716 | 1717 | [[package]] 1718 | name = "windows_x86_64_msvc" 1719 | version = "0.52.6" 1720 | source = "registry+https://github.com/rust-lang/crates.io-index" 1721 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1722 | 1723 | [[package]] 1724 | name = "winnow" 1725 | version = "0.7.10" 1726 | source = "registry+https://github.com/rust-lang/crates.io-index" 1727 | checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" 1728 | dependencies = [ 1729 | "memchr", 1730 | ] 1731 | 1732 | [[package]] 1733 | name = "x11rb" 1734 | version = "0.13.1" 1735 | source = "registry+https://github.com/rust-lang/crates.io-index" 1736 | checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" 1737 | dependencies = [ 1738 | "gethostname", 1739 | "rustix", 1740 | "x11rb-protocol", 1741 | ] 1742 | 1743 | [[package]] 1744 | name = "x11rb-protocol" 1745 | version = "0.13.1" 1746 | source = "registry+https://github.com/rust-lang/crates.io-index" 1747 | checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" 1748 | --------------------------------------------------------------------------------