From b7177cbe191999d72c13a775831d6c7744deb8a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Jan 2026 19:11:10 +0000 Subject: [PATCH 1/2] Implement comprehensive S3 browser GUI with s5cmd integration Features: - Profile management with AWS Access Key and SSO authentication - OS keychain integration for secure credential storage - Enhanced file browser with Chonky (upgraded to v0.3.1) - File operations: copy, cut, paste, rename, delete, upload, download - Drag and drop support for uploads - Context menu with all operations - Search bar with real-time filtering and wildcard support - Column sorting with persistence - Progress dialog with cancel support - Status bar showing connection info and selection details - Settings panel (general and advanced) - Logging system with rotating files and viewer - GitHub Actions workflow for multi-platform builds - Playwright tests with Minio integration Technical changes: - Updated Tauri backend with modular Rust architecture - Added keyring crate for OS keychain integration - Added fern for logging with rotation - State management with Zustand - Toast notifications with react-hot-toast - Date formatting with date-fns --- .github/workflows/build.yml | 174 ++++++ package.json | 43 +- playwright.config.ts | 32 ++ src-tauri/Cargo.toml | 33 +- src-tauri/src/commands.rs | 411 ++++++++++++++ src-tauri/src/keychain.rs | 83 +++ src-tauri/src/logging.rs | 153 +++++ src-tauri/src/main.rs | 123 ++-- src-tauri/src/profiles.rs | 115 ++++ src-tauri/src/s5cmd.rs | 549 ++++++++++++++++++ src-tauri/src/settings.rs | 99 ++++ src-tauri/src/sso.rs | 200 +++++++ src-tauri/tauri.conf.json | 80 ++- src/api.ts | 178 ++++-- src/components/BucketConfig.tsx | 55 -- src/components/BucketSelector.tsx | 66 +++ src/components/LogsDialog.tsx | 132 +++++ src/components/ProfileSelector.tsx | 447 +++++++++++++++ src/components/ProgressDialog.tsx | 98 ++++ src/components/S3Browser.tsx | 726 ++++++++++++++++++------ src/components/SearchBar.tsx | 71 +++ src/components/SecretConfig.tsx | 146 ----- src/components/SettingsDialog.tsx | 273 +++++++++ src/components/StatusBar.tsx | 61 ++ src/main.tsx | 242 +++++++- src/stores/appStore.ts | 297 ++++++++++ src/styles.css | 880 ++++++++++++++++++++++++++--- src/types.ts | 130 ++++- src/utils.ts | 139 ++++- tests/app.spec.ts | 252 +++++++++ tests/minio.spec.ts | 196 +++++++ tsconfig.json | 14 +- 32 files changed, 5869 insertions(+), 629 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 playwright.config.ts create mode 100644 src-tauri/src/commands.rs create mode 100644 src-tauri/src/keychain.rs create mode 100644 src-tauri/src/logging.rs create mode 100644 src-tauri/src/profiles.rs create mode 100644 src-tauri/src/s5cmd.rs create mode 100644 src-tauri/src/settings.rs create mode 100644 src-tauri/src/sso.rs delete mode 100644 src/components/BucketConfig.tsx create mode 100644 src/components/BucketSelector.tsx create mode 100644 src/components/LogsDialog.tsx create mode 100644 src/components/ProfileSelector.tsx create mode 100644 src/components/ProgressDialog.tsx create mode 100644 src/components/SearchBar.tsx delete mode 100644 src/components/SecretConfig.tsx create mode 100644 src/components/SettingsDialog.tsx create mode 100644 src/components/StatusBar.tsx create mode 100644 src/stores/appStore.ts create mode 100644 tests/app.spec.ts create mode 100644 tests/minio.spec.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d841516 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,174 @@ +name: Build and Release + +on: + push: + branches: + - main + tags: + - 'v*' + pull_request: + branches: + - main + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + services: + minio: + image: minio/minio:latest + ports: + - 9000:9000 + - 9001:9001 + env: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + options: --health-cmd "curl -f http://localhost:9000/minio/health/live" --health-interval 10s --health-timeout 5s --health-retries 3 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Install dependencies + run: pnpm install + + - name: Install s5cmd + run: | + curl -L https://github.com/peak/s5cmd/releases/download/v2.2.2/s5cmd_2.2.2_Linux-64bit.tar.gz | tar xz + sudo mv s5cmd /usr/local/bin/ + + - name: Create test bucket + run: | + curl -L https://dl.min.io/client/mc/release/linux-amd64/mc -o mc + chmod +x mc + ./mc alias set myminio http://localhost:9000 minioadmin minioadmin + ./mc mb myminio/test-bucket + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + run: pnpm test + env: + MINIO_ENDPOINT: http://localhost:9000 + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin + + build: + name: Build for ${{ matrix.platform }} + needs: test + strategy: + fail-fast: false + matrix: + include: + - platform: ubuntu-22.04 + target: linux + - platform: windows-latest + target: windows + - platform: macos-latest + target: macos + + runs-on: ${{ matrix.platform }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Install Rust stable + uses: dtolnay/rust-action@stable + + - name: Install dependencies (Ubuntu) + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev + + - name: Install frontend dependencies + run: pnpm install + + - name: Build Tauri app + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tagName: v__VERSION__ + releaseName: 'S5cmd GUI v__VERSION__' + releaseBody: 'See the assets below to download and install this version.' + releaseDraft: true + prerelease: false + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: s5cmd-gui-${{ matrix.target }} + path: | + src-tauri/target/release/bundle/deb/*.deb + src-tauri/target/release/bundle/appimage/*.AppImage + src-tauri/target/release/bundle/msi/*.msi + src-tauri/target/release/bundle/nsis/*.exe + src-tauri/target/release/bundle/dmg/*.dmg + src-tauri/target/release/bundle/macos/*.app + if-no-files-found: ignore + + release: + name: Create Release + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + draft: false + prerelease: false + files: | + artifacts/**/*.deb + artifacts/**/*.AppImage + artifacts/**/*.msi + artifacts/**/*.exe + artifacts/**/*.dmg + body: | + ## Installation + + ### Linux + - **Debian/Ubuntu**: Download the `.deb` file and install with `sudo dpkg -i s5cmd-gui_*.deb` + - **AppImage**: Download the `.AppImage` file, make it executable with `chmod +x`, and run it + + ### Windows + - Download and run the `.msi` installer or the `.exe` NSIS installer + + ### macOS + - Download the `.dmg` file and drag the app to your Applications folder + - **Note**: You may need to right-click and select "Open" on first launch due to Gatekeeper + + ## Requirements + - **s5cmd** must be installed and available in your PATH + - Install from: https://github.com/peak/s5cmd diff --git a/package.json b/package.json index e96597e..46d8d03 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,44 @@ { "name": "s5cmd-gui", "private": true, - "version": "0.0.0", + "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:headed": "playwright test --headed", + "lint": "eslint src --ext .ts,.tsx", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"" }, "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", - "@tauri-apps/api": "^1.4.0", - "@aperturerobotics/chonky": "0.2.6", - "@aperturerobotics/chonky-icon-fontawesome": "0.2.2", - "path-browserify": "^1.0.1" + "@tauri-apps/api": "^1.5.0", + "@aperturerobotics/chonky": "^0.3.1", + "@aperturerobotics/chonky-icon-fontawesome": "^0.3.1", + "path-browserify": "^1.0.1", + "zustand": "^4.4.0", + "react-hot-toast": "^2.4.1", + "date-fns": "^3.0.0" }, "devDependencies": { - "@types/react": "^18.2.15", - "@types/react-dom": "^18.2.7", - "@types/path-browserify": "^1.0.0", - "@vitejs/plugin-react": "^4.0.3", - "typescript": "^5.0.2", - "vite": "^4.4.4", - - "@tauri-apps/cli": "^1.4.0", - "prettier": "3.0.0" + "@types/react": "^18.2.45", + "@types/react-dom": "^18.2.18", + "@types/path-browserify": "^1.0.2", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.0.10", + "@tauri-apps/cli": "^1.5.8", + "@playwright/test": "^1.40.1", + "prettier": "^3.1.1", + "eslint": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^6.15.0", + "@typescript-eslint/parser": "^6.15.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..cef2279 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'github' : 'html', + timeout: 60000, + + use: { + baseURL: 'http://localhost:1420', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + webServer: { + command: 'pnpm dev', + url: 'http://localhost:1420', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5231f7a..df16aca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,24 +1,39 @@ [package] name = "s5cmd-gui" -version = "0.0.0" +version = "1.0.0" description = "GUI for the s5cmd tool, which makes S3/compatible uploads/downloads much faster" authors = ["Dillon Williams"] license = "MIT" -repository = "" +repository = "https://github.com/dillonwilliams/s5cmd-gui" edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [build-dependencies] -tauri-build = { version = "1.4", features = [] } +tauri-build = { version = "1.5", features = [] } [dependencies] -tauri = { version = "1.4", features = [ "dialog-save", "dialog-open", "shell-open"] } +tauri = { version = "1.5", features = [ + "dialog-save", + "dialog-open", + "shell-open", + "shell-execute", + "process-command-api", + "fs-all", + "path-all", + "global-shortcut-all" +] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -tempfile = "3.7.0" +tempfile = "3.9" +keyring = "2.2" +tokio = { version = "1.35", features = ["full"] } +log = "0.4" +fern = { version = "0.6", features = ["colored"] } +chrono = "0.4" +dirs = "5.0" +uuid = { version = "1.6", features = ["v4"] } +regex = "1.10" +thiserror = "1.0" +lazy_static = "1.4" [features] -# this feature is used for production builds or when `devPath` points to the filesystem -# DO NOT REMOVE!! custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..97daa25 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,411 @@ +use log::{debug, error, info}; +use serde::{Deserialize, Serialize}; +use tauri::Manager; +use uuid::Uuid; + +use crate::logging; +use crate::profiles::{self, AuthType, Profile, ProfileStore}; +use crate::s5cmd::{self, OperationProgress, S3Bucket, S3Object, S5cmdRunner}; +use crate::settings::Settings; +use crate::sso::{self, SsoConfig, SsoStatus}; + +fn get_app_data_dir(app: &tauri::AppHandle) -> Result { + app.path_resolver() + .app_data_dir() + .ok_or_else(|| "Could not determine app data directory".to_string()) +} + +fn get_settings(app: &tauri::AppHandle) -> Result { + let app_data_dir = get_app_data_dir(app)?; + Settings::load(&app_data_dir) +} + +fn get_active_profile(app: &tauri::AppHandle) -> Result { + let app_data_dir = get_app_data_dir(app)?; + let store = ProfileStore::load(&app_data_dir)?; + store + .get_active() + .cloned() + .ok_or_else(|| "No active profile. Please select a profile first.".to_string()) +} + +fn create_runner(app: &tauri::AppHandle) -> Result { + let profile = get_active_profile(app)?; + let settings = get_settings(app)?; + Ok(S5cmdRunner::new(profile, settings)) +} + +// ============ Profile Management ============ + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProfileWithoutSecret { + pub id: String, + pub name: String, + pub auth_type: String, + pub endpoint_url: Option, + pub region: String, + pub is_active: bool, + // SSO fields (only present for SSO profiles) + pub sso_start_url: Option, + pub sso_region: Option, + pub sso_account_id: Option, + pub sso_role_name: Option, + // Access Key fields (only access_key_id, not secret) + pub access_key_id: Option, + pub has_session_token: bool, +} + +impl From<&Profile> for ProfileWithoutSecret { + fn from(p: &Profile) -> Self { + match &p.auth { + AuthType::AccessKey { + access_key_id, + session_token, + } => Self { + id: p.id.clone(), + name: p.name.clone(), + auth_type: "access_key".to_string(), + endpoint_url: p.endpoint_url.clone(), + region: p.region.clone(), + is_active: p.is_active, + sso_start_url: None, + sso_region: None, + sso_account_id: None, + sso_role_name: None, + access_key_id: Some(access_key_id.clone()), + has_session_token: session_token.is_some(), + }, + AuthType::Sso { + sso_start_url, + sso_region, + sso_account_id, + sso_role_name, + } => Self { + id: p.id.clone(), + name: p.name.clone(), + auth_type: "sso".to_string(), + endpoint_url: p.endpoint_url.clone(), + region: p.region.clone(), + is_active: p.is_active, + sso_start_url: Some(sso_start_url.clone()), + sso_region: Some(sso_region.clone()), + sso_account_id: Some(sso_account_id.clone()), + sso_role_name: Some(sso_role_name.clone()), + access_key_id: None, + has_session_token: false, + }, + } + } +} + +#[tauri::command] +pub fn get_profiles(app: tauri::AppHandle) -> Result, String> { + let app_data_dir = get_app_data_dir(&app)?; + let store = ProfileStore::load(&app_data_dir)?; + + Ok(store.profiles.iter().map(ProfileWithoutSecret::from).collect()) +} + +#[derive(Debug, Deserialize)] +pub struct SaveProfileRequest { + pub id: Option, + pub name: String, + pub auth_type: String, + pub region: String, + pub endpoint_url: Option, + // Access Key fields + pub access_key_id: Option, + pub secret_access_key: Option, + pub session_token: Option, + // SSO fields + pub sso_start_url: Option, + pub sso_region: Option, + pub sso_account_id: Option, + pub sso_role_name: Option, +} + +#[tauri::command] +pub fn save_profile(app: tauri::AppHandle, request: SaveProfileRequest) -> Result { + let app_data_dir = get_app_data_dir(&app)?; + let mut store = ProfileStore::load(&app_data_dir)?; + + let profile_id = request.id.unwrap_or_else(|| Uuid::new_v4().to_string()); + + let auth = if request.auth_type == "sso" { + AuthType::Sso { + sso_start_url: request + .sso_start_url + .ok_or("SSO Start URL is required")?, + sso_region: request.sso_region.ok_or("SSO Region is required")?, + sso_account_id: request.sso_account_id.ok_or("SSO Account ID is required")?, + sso_role_name: request.sso_role_name.ok_or("SSO Role Name is required")?, + } + } else { + let access_key_id = request.access_key_id.ok_or("Access Key ID is required")?; + + // Store secret in keychain + if let Some(secret) = &request.secret_access_key { + profiles::set_secret_for_profile(&profile_id, secret)?; + } + + AuthType::AccessKey { + access_key_id, + session_token: request.session_token, + } + }; + + let profile = Profile { + id: profile_id.clone(), + name: request.name, + auth, + endpoint_url: request.endpoint_url, + region: request.region, + is_active: false, + }; + + store.add_or_update(profile); + store.save(&app_data_dir)?; + + info!("Saved profile: {}", profile_id); + Ok(profile_id) +} + +#[tauri::command] +pub fn delete_profile(app: tauri::AppHandle, profile_id: String) -> Result<(), String> { + let app_data_dir = get_app_data_dir(&app)?; + let mut store = ProfileStore::load(&app_data_dir)?; + + // Delete secret from keychain + profiles::delete_secret_for_profile(&profile_id)?; + + store.delete(&profile_id); + store.save(&app_data_dir)?; + + info!("Deleted profile: {}", profile_id); + Ok(()) +} + +#[tauri::command] +pub fn set_active_profile(app: tauri::AppHandle, profile_id: String) -> Result<(), String> { + let app_data_dir = get_app_data_dir(&app)?; + let mut store = ProfileStore::load(&app_data_dir)?; + + store.set_active(&profile_id)?; + store.save(&app_data_dir)?; + + info!("Set active profile: {}", profile_id); + Ok(()) +} + +#[tauri::command] +pub fn test_profile_connection(app: tauri::AppHandle, profile_id: String) -> Result { + let app_data_dir = get_app_data_dir(&app)?; + let store = ProfileStore::load(&app_data_dir)?; + + let profile = store + .profiles + .iter() + .find(|p| p.id == profile_id) + .ok_or("Profile not found")? + .clone(); + + let settings = Settings::load(&app_data_dir)?; + let runner = S5cmdRunner::new(profile, settings); + + // Try to list buckets + let buckets = runner.list_buckets()?; + + Ok(format!( + "Connection successful! Found {} bucket(s).", + buckets.len() + )) +} + +// ============ SSO ============ + +#[tauri::command] +pub fn start_sso_login(app: tauri::AppHandle, profile_id: String) -> Result { + let app_data_dir = get_app_data_dir(&app)?; + let store = ProfileStore::load(&app_data_dir)?; + + let profile = store + .profiles + .iter() + .find(|p| p.id == profile_id) + .ok_or("Profile not found")?; + + match &profile.auth { + AuthType::Sso { + sso_start_url, + sso_region, + sso_account_id, + sso_role_name, + } => { + let config = SsoConfig { + sso_start_url: sso_start_url.clone(), + sso_region: sso_region.clone(), + sso_account_id: sso_account_id.clone(), + sso_role_name: sso_role_name.clone(), + }; + sso::start_sso_login(&profile_id, config) + } + _ => Err("Profile is not configured for SSO".to_string()), + } +} + +#[tauri::command] +pub fn check_sso_status(profile_id: String) -> Result { + let status = sso::check_sso_status(&profile_id)?; + Ok(serde_json::to_string(&status).map_err(|e| e.to_string())?) +} + +// ============ S3 Operations ============ + +#[tauri::command] +pub fn list_buckets(app: tauri::AppHandle) -> Result, String> { + let runner = create_runner(&app)?; + runner.list_buckets() +} + +#[tauri::command] +pub fn list_objects(app: tauri::AppHandle, path: String) -> Result, String> { + let runner = create_runner(&app)?; + runner.list_objects(&path) +} + +#[tauri::command] +pub fn delete_objects(app: tauri::AppHandle, paths: Vec) -> Result { + let runner = create_runner(&app)?; + runner.delete_objects(paths) +} + +#[tauri::command] +pub fn copy_objects( + app: tauri::AppHandle, + sources: Vec, + destination: String, +) -> Result { + let runner = create_runner(&app)?; + runner.copy_objects(sources, &destination) +} + +#[tauri::command] +pub fn move_objects( + app: tauri::AppHandle, + sources: Vec, + destination: String, +) -> Result { + let runner = create_runner(&app)?; + runner.move_objects(sources, &destination) +} + +#[tauri::command] +pub fn create_folder(app: tauri::AppHandle, path: String) -> Result<(), String> { + let runner = create_runner(&app)?; + runner.create_folder(&path) +} + +#[derive(Debug, Deserialize)] +pub struct DownloadSource { + pub path: String, + pub is_dir: bool, +} + +#[tauri::command] +pub fn download_objects( + app: tauri::AppHandle, + sources: Vec, + destination: String, +) -> Result { + let runner = create_runner(&app)?; + let sources: Vec<(String, bool)> = sources.into_iter().map(|s| (s.path, s.is_dir)).collect(); + runner.download_objects(sources, &destination) +} + +#[tauri::command] +pub fn upload_objects( + app: tauri::AppHandle, + sources: Vec, + destination: String, +) -> Result { + let runner = create_runner(&app)?; + runner.upload_objects(sources, &destination) +} + +#[derive(Debug, Serialize)] +pub struct ObjectMetadata { + pub key: String, + pub size: Option, + pub last_modified: Option, + pub storage_class: Option, + pub etag: Option, + pub content_type: Option, +} + +#[tauri::command] +pub fn get_object_metadata(app: tauri::AppHandle, path: String) -> Result { + // For now, return basic info from list + let runner = create_runner(&app)?; + let objects = runner.list_objects(&path)?; + + if let Some(obj) = objects.first() { + Ok(ObjectMetadata { + key: obj.key.clone(), + size: obj.size, + last_modified: obj.last_modified.clone(), + storage_class: obj.storage_class.clone(), + etag: obj.etag.clone(), + content_type: None, + }) + } else { + Err("Object not found".to_string()) + } +} + +// ============ Settings ============ + +#[tauri::command] +pub fn get_settings(app: tauri::AppHandle) -> Result { + let app_data_dir = get_app_data_dir(&app)?; + Settings::load(&app_data_dir) +} + +#[tauri::command] +pub fn save_settings(app: tauri::AppHandle, settings: Settings) -> Result<(), String> { + let app_data_dir = get_app_data_dir(&app)?; + settings.save(&app_data_dir)?; + info!("Settings saved"); + Ok(()) +} + +// ============ Logging ============ + +#[tauri::command] +pub fn get_logs(lines: Option) -> Result, String> { + logging::read_logs(lines) +} + +#[tauri::command] +pub fn clear_logs() -> Result<(), String> { + logging::clear() +} + +#[tauri::command] +pub fn get_log_path() -> Result, String> { + Ok(logging::get_log_path().map(|p| p.to_string_lossy().to_string())) +} + +// ============ Progress ============ + +#[tauri::command] +pub fn cancel_operation(operation_id: String) -> Result<(), String> { + s5cmd::cancel_operation(&operation_id) +} + +// ============ Utility ============ + +#[tauri::command] +pub fn get_s5cmd_version(app: tauri::AppHandle) -> Result { + let settings = get_settings(&app)?; + s5cmd::get_s5cmd_version(settings.s5cmd_path.as_deref()) +} diff --git a/src-tauri/src/keychain.rs b/src-tauri/src/keychain.rs new file mode 100644 index 0000000..0ac9172 --- /dev/null +++ b/src-tauri/src/keychain.rs @@ -0,0 +1,83 @@ +use keyring::Entry; +use log::{debug, error, warn}; + +const SERVICE_NAME: &str = "s5cmd-gui"; + +pub fn get_secret(profile_id: &str) -> Result, String> { + let entry = Entry::new(SERVICE_NAME, profile_id) + .map_err(|e| format!("Failed to access keychain: {}", e))?; + + match entry.get_password() { + Ok(secret) => { + debug!("Retrieved secret for profile: {}", profile_id); + Ok(Some(secret)) + } + Err(keyring::Error::NoEntry) => { + debug!("No secret found for profile: {}", profile_id); + Ok(None) + } + Err(e) => { + error!("Failed to get secret for profile {}: {}", profile_id, e); + Err(format!("Failed to get secret: {}", e)) + } + } +} + +pub fn set_secret(profile_id: &str, secret: &str) -> Result<(), String> { + let entry = Entry::new(SERVICE_NAME, profile_id) + .map_err(|e| format!("Failed to access keychain: {}", e))?; + + entry + .set_password(secret) + .map_err(|e| format!("Failed to store secret: {}", e))?; + + debug!("Stored secret for profile: {}", profile_id); + Ok(()) +} + +pub fn delete_secret(profile_id: &str) -> Result<(), String> { + let entry = Entry::new(SERVICE_NAME, profile_id) + .map_err(|e| format!("Failed to access keychain: {}", e))?; + + match entry.delete_credential() { + Ok(()) => { + debug!("Deleted secret for profile: {}", profile_id); + Ok(()) + } + Err(keyring::Error::NoEntry) => { + warn!("No secret to delete for profile: {}", profile_id); + Ok(()) + } + Err(e) => { + error!("Failed to delete secret for profile {}: {}", profile_id, e); + Err(format!("Failed to delete secret: {}", e)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_secret_operations() { + let profile_id = "test-profile-keychain"; + let secret = "test-secret-12345"; + + // Set secret + set_secret(profile_id, secret).expect("Failed to set secret"); + + // Get secret + let retrieved = get_secret(profile_id) + .expect("Failed to get secret") + .expect("Secret not found"); + assert_eq!(retrieved, secret); + + // Delete secret + delete_secret(profile_id).expect("Failed to delete secret"); + + // Verify deletion + let deleted = get_secret(profile_id).expect("Failed to check secret"); + assert!(deleted.is_none()); + } +} diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs new file mode 100644 index 0000000..7bb7093 --- /dev/null +++ b/src-tauri/src/logging.rs @@ -0,0 +1,153 @@ +use chrono::Local; +use fern::colors::{Color, ColoredLevelConfig}; +use log::LevelFilter; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; +use std::sync::Mutex; + +static LOG_PATH: Mutex> = Mutex::new(None); + +const MAX_LOG_SIZE: u64 = 10 * 1024 * 1024; // 10 MB +const MAX_LOG_FILES: usize = 5; + +pub fn get_log_dir() -> Option { + dirs::data_dir().map(|d| d.join("s5cmd-gui").join("logs")) +} + +pub fn get_log_path() -> Option { + LOG_PATH.lock().ok().and_then(|guard| guard.clone()) +} + +fn rotate_logs(log_path: &PathBuf) -> Result<(), Box> { + if !log_path.exists() { + return Ok(()); + } + + let metadata = fs::metadata(log_path)?; + if metadata.len() < MAX_LOG_SIZE { + return Ok(()); + } + + let log_dir = log_path.parent().ok_or("Invalid log path")?; + let stem = log_path.file_stem().ok_or("Invalid log file name")?; + let extension = log_path.extension().unwrap_or_default(); + + // Rotate existing logs + for i in (1..MAX_LOG_FILES).rev() { + let old_path = log_dir.join(format!( + "{}.{}.{}", + stem.to_string_lossy(), + i, + extension.to_string_lossy() + )); + let new_path = log_dir.join(format!( + "{}.{}.{}", + stem.to_string_lossy(), + i + 1, + extension.to_string_lossy() + )); + + if old_path.exists() { + if i + 1 >= MAX_LOG_FILES { + fs::remove_file(&old_path)?; + } else { + fs::rename(&old_path, &new_path)?; + } + } + } + + // Move current log to .1 + let first_backup = log_dir.join(format!( + "{}.1.{}", + stem.to_string_lossy(), + extension.to_string_lossy() + )); + fs::rename(log_path, first_backup)?; + + Ok(()) +} + +pub fn init() -> Result<(), Box> { + let log_dir = get_log_dir().ok_or("Could not determine log directory")?; + fs::create_dir_all(&log_dir)?; + + let log_path = log_dir.join("s5cmd-gui.log"); + + // Rotate logs if needed + rotate_logs(&log_path)?; + + // Store the path + if let Ok(mut guard) = LOG_PATH.lock() { + *guard = Some(log_path.clone()); + } + + let colors = ColoredLevelConfig::new() + .error(Color::Red) + .warn(Color::Yellow) + .info(Color::Green) + .debug(Color::Blue) + .trace(Color::White); + + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&log_path)?; + + fern::Dispatch::new() + .format(move |out, message, record| { + out.finish(format_args!( + "[{} {} {}] {}", + Local::now().format("%Y-%m-%d %H:%M:%S"), + colors.color(record.level()), + record.target(), + message + )) + }) + .level(LevelFilter::Info) + .level_for("s5cmd_gui", LevelFilter::Debug) + .chain(std::io::stdout()) + .chain(file) + .apply()?; + + Ok(()) +} + +pub fn read_logs(lines: Option) -> Result, String> { + let log_path = get_log_path().ok_or("Log path not initialized")?; + + if !log_path.exists() { + return Ok(Vec::new()); + } + + let file = File::open(&log_path).map_err(|e| format!("Failed to open log file: {}", e))?; + let reader = BufReader::new(file); + + let all_lines: Vec = reader + .lines() + .filter_map(Result::ok) + .collect(); + + match lines { + Some(n) => Ok(all_lines.into_iter().rev().take(n).rev().collect()), + None => Ok(all_lines), + } +} + +pub fn clear() -> Result<(), String> { + let log_path = get_log_path().ok_or("Log path not initialized")?; + + if log_path.exists() { + // Truncate the file instead of deleting + let mut file = OpenOptions::new() + .write(true) + .truncate(true) + .open(&log_path) + .map_err(|e| format!("Failed to clear log file: {}", e))?; + + writeln!(file, "[{}] Log file cleared", Local::now().format("%Y-%m-%d %H:%M:%S")) + .map_err(|e| format!("Failed to write to log file: {}", e))?; + } + + Ok(()) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ff4eb89..339ecea 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,76 +1,67 @@ -use std::env; -use std::io::Write; -use tempfile::tempdir; -use std::fs::File; - // Prevents additional console window on Windows in release, DO NOT REMOVE!! -//#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command -#[tauri::command] -fn set_s3_secret(access_key_id: String, secret_access_key: String) -> () { - env::set_var("AWS_ACCESS_KEY_ID", access_key_id); - env::set_var("AWS_SECRET_ACCESS_KEY", secret_access_key); -} - -#[tauri::command] -fn run_s5cmd_run(commands: Vec) -> +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -Result { - use std::process::Command; - let dir = tempdir().unwrap(); +mod commands; +mod keychain; +mod logging; +mod profiles; +mod s5cmd; +mod settings; +mod sso; - let file_path = dir.path().join("s5cmdrun.txt"); - let mut file = File::create(file_path.clone()).unwrap(); +use log::info; +use tauri::Manager; - for command in commands { - writeln!(file, "{}", command).unwrap(); - } - - let output = Command::new("s5cmd") - .arg("--json") - .arg("run") - .arg(file_path.clone()) - .output() - .expect("Failed to execute s5cmd"); - - println!("{}", output.status); - println!("stdout: {}", String::from_utf8_lossy(&output.stdout)); - println!("stderr: {}", String::from_utf8_lossy(&output.stderr)); - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stderr).into_owned()) - //String::from_utf8_lossy(&output.stdout).into_owned() - } else { - //String::from_utf8_lossy(&output.stderr).into_owned() - Err(String::from_utf8_lossy(&output.stderr).into_owned()) - } -} - -#[tauri::command] -fn run_s5cmd(command: String, args: Vec) -> String { - use std::process::Command; - - let output = Command::new("s5cmd") - .arg("--json") - .arg(command) - .args(args) - .output() - .expect("Failed to execute s5cmd"); - - println!("{}", output.status); - println!("stdout: {}", String::from_utf8_lossy(&output.stdout)); - println!("stderr: {}", String::from_utf8_lossy(&output.stderr)); - if output.status.success() { - String::from_utf8_lossy(&output.stdout).into_owned() - } else { - String::from_utf8_lossy(&output.stderr).into_owned() - } -} +fn main() { + // Initialize logging + if let Err(e) = logging::init() { + eprintln!("Failed to initialize logging: {}", e); + } + info!("Starting S5cmd GUI application"); -fn main() { tauri::Builder::default() - .invoke_handler(tauri::generate_handler![set_s3_secret, run_s5cmd, run_s5cmd_run]) + .setup(|app| { + // Initialize settings with app handle + let app_data_dir = app.path_resolver().app_data_dir(); + if let Some(dir) = app_data_dir { + std::fs::create_dir_all(&dir).ok(); + info!("App data directory: {:?}", dir); + } + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + // Profile management + commands::get_profiles, + commands::save_profile, + commands::delete_profile, + commands::test_profile_connection, + commands::set_active_profile, + // S3 operations + commands::list_buckets, + commands::list_objects, + commands::delete_objects, + commands::copy_objects, + commands::move_objects, + commands::create_folder, + commands::download_objects, + commands::upload_objects, + commands::get_object_metadata, + // SSO + commands::start_sso_login, + commands::check_sso_status, + // Settings + commands::get_settings, + commands::save_settings, + // Logging + commands::get_logs, + commands::clear_logs, + commands::get_log_path, + // Progress + commands::cancel_operation, + // Utility + commands::get_s5cmd_version, + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/profiles.rs b/src-tauri/src/profiles.rs new file mode 100644 index 0000000..1844767 --- /dev/null +++ b/src-tauri/src/profiles.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +use crate::keychain; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum AuthType { + #[serde(rename = "access_key")] + AccessKey { + access_key_id: String, + // Secret is stored in keychain, not here + #[serde(skip_serializing_if = "Option::is_none")] + session_token: Option, + }, + #[serde(rename = "sso")] + Sso { + sso_start_url: String, + sso_region: String, + sso_account_id: String, + sso_role_name: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Profile { + pub id: String, + pub name: String, + pub auth: AuthType, + #[serde(skip_serializing_if = "Option::is_none")] + pub endpoint_url: Option, + pub region: String, + #[serde(default)] + pub is_active: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProfileStore { + pub profiles: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_profile_id: Option, +} + +impl ProfileStore { + pub fn load(app_data_dir: &PathBuf) -> Result { + let path = app_data_dir.join("profiles.json"); + if !path.exists() { + return Ok(Self::default()); + } + + let content = fs::read_to_string(&path) + .map_err(|e| format!("Failed to read profiles: {}", e))?; + + serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse profiles: {}", e)) + } + + pub fn save(&self, app_data_dir: &PathBuf) -> Result<(), String> { + fs::create_dir_all(app_data_dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + + let path = app_data_dir.join("profiles.json"); + let content = serde_json::to_string_pretty(self) + .map_err(|e| format!("Failed to serialize profiles: {}", e))?; + + fs::write(&path, content) + .map_err(|e| format!("Failed to write profiles: {}", e)) + } + + pub fn add_or_update(&mut self, profile: Profile) { + if let Some(existing) = self.profiles.iter_mut().find(|p| p.id == profile.id) { + *existing = profile; + } else { + self.profiles.push(profile); + } + } + + pub fn delete(&mut self, profile_id: &str) { + self.profiles.retain(|p| p.id != profile_id); + if self.active_profile_id.as_deref() == Some(profile_id) { + self.active_profile_id = None; + } + } + + pub fn set_active(&mut self, profile_id: &str) -> Result<(), String> { + if !self.profiles.iter().any(|p| p.id == profile_id) { + return Err("Profile not found".to_string()); + } + + for profile in &mut self.profiles { + profile.is_active = profile.id == profile_id; + } + self.active_profile_id = Some(profile_id.to_string()); + Ok(()) + } + + pub fn get_active(&self) -> Option<&Profile> { + self.active_profile_id + .as_ref() + .and_then(|id| self.profiles.iter().find(|p| &p.id == id)) + } +} + +pub fn get_secret_for_profile(profile_id: &str) -> Result, String> { + keychain::get_secret(profile_id) +} + +pub fn set_secret_for_profile(profile_id: &str, secret: &str) -> Result<(), String> { + keychain::set_secret(profile_id, secret) +} + +pub fn delete_secret_for_profile(profile_id: &str) -> Result<(), String> { + keychain::delete_secret(profile_id) +} diff --git a/src-tauri/src/s5cmd.rs b/src-tauri/src/s5cmd.rs new file mode 100644 index 0000000..2e05602 --- /dev/null +++ b/src-tauri/src/s5cmd.rs @@ -0,0 +1,549 @@ +use log::{debug, error, info, warn}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::{BufRead, BufReader}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use tempfile::NamedTempFile; +use uuid::Uuid; + +use crate::profiles::{AuthType, Profile}; +use crate::settings::Settings; +use crate::sso; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct S3Object { + pub key: String, + #[serde(rename = "type")] + pub object_type: String, + #[serde(default)] + pub size: Option, + #[serde(default)] + pub storage_class: Option, + #[serde(default)] + pub etag: Option, + #[serde(default)] + pub last_modified: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct S3Bucket { + pub name: String, + #[serde(default)] + pub creation_date: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OperationProgress { + pub id: String, + pub operation_type: String, + pub total_files: usize, + pub completed_files: usize, + pub total_bytes: i64, + pub transferred_bytes: i64, + pub current_file: Option, + pub status: OperationStatus, + pub error: Option, + pub started_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum OperationStatus { + Running, + Paused, + Completed, + Failed, + Cancelled, +} + +// Store active operations for progress tracking and cancellation +lazy_static::lazy_static! { + static ref ACTIVE_OPERATIONS: Mutex>>> = Mutex::new(HashMap::new()); +} + +struct OperationState { + progress: OperationProgress, + child: Option, + cancelled: bool, +} + +pub struct S5cmdRunner { + profile: Profile, + settings: Settings, +} + +impl S5cmdRunner { + pub fn new(profile: Profile, settings: Settings) -> Self { + Self { profile, settings } + } + + fn build_command(&self) -> Result { + let mut cmd = Command::new(self.settings.get_s5cmd_binary()); + + // Set environment variables based on auth type + match &self.profile.auth { + AuthType::AccessKey { + access_key_id, + session_token, + } => { + cmd.env("AWS_ACCESS_KEY_ID", access_key_id); + + // Get secret from keychain + if let Some(secret) = + crate::profiles::get_secret_for_profile(&self.profile.id)? + { + cmd.env("AWS_SECRET_ACCESS_KEY", secret); + } else { + return Err("Secret access key not found in keychain".to_string()); + } + + if let Some(token) = session_token { + cmd.env("AWS_SESSION_TOKEN", token); + } + } + AuthType::Sso { .. } => { + // Get credentials from SSO session + if let Some(session) = sso::get_sso_session(&self.profile.id) { + if let (Some(access_key), Some(secret_key)) = + (&session.access_key_id, &session.secret_access_key) + { + cmd.env("AWS_ACCESS_KEY_ID", access_key); + cmd.env("AWS_SECRET_ACCESS_KEY", secret_key); + + if let Some(token) = &session.session_token { + cmd.env("AWS_SESSION_TOKEN", token); + } + } else { + return Err("SSO credentials not available".to_string()); + } + } else { + return Err("SSO session not found. Please login first.".to_string()); + } + } + } + + cmd.env("AWS_REGION", &self.profile.region); + + if let Some(endpoint) = &self.profile.endpoint_url { + cmd.arg("--endpoint-url").arg(endpoint); + } + + cmd.arg("--json"); + + Ok(cmd) + } + + pub fn list_buckets(&self) -> Result, String> { + let mut cmd = self.build_command()?; + cmd.arg("ls"); + + debug!("Running s5cmd ls for buckets"); + + let output = cmd.output().map_err(|e| format!("Failed to execute s5cmd: {}", e))?; + + if !output.status.success() { + let error = String::from_utf8_lossy(&output.stderr); + error!("s5cmd ls failed: {}", error); + return Err(parse_s5cmd_error(&error)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_json_lines(&stdout) + } + + pub fn list_objects(&self, path: &str) -> Result, String> { + let mut cmd = self.build_command()?; + cmd.arg("ls").arg(path); + + debug!("Running s5cmd ls {}", path); + + let output = cmd.output().map_err(|e| format!("Failed to execute s5cmd: {}", e))?; + + if !output.status.success() { + let error = String::from_utf8_lossy(&output.stderr); + if error.contains("no object found") { + return Ok(Vec::new()); + } + error!("s5cmd ls failed: {}", error); + return Err(parse_s5cmd_error(&error)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_json_lines(&stdout) + } + + pub fn delete_objects(&self, paths: Vec) -> Result { + let operation_id = Uuid::new_v4().to_string(); + let total_files = paths.len(); + + info!("Starting delete operation {} for {} files", operation_id, total_files); + + // For batch operations, use run command + let commands: Vec = paths.iter().map(|p| format!("rm \"{}\"", p)).collect(); + + self.run_batch_operation(&operation_id, "delete", commands, total_files) + } + + pub fn copy_objects( + &self, + sources: Vec, + destination: &str, + ) -> Result { + let operation_id = Uuid::new_v4().to_string(); + let total_files = sources.len(); + + info!( + "Starting copy operation {} for {} files to {}", + operation_id, total_files, destination + ); + + let commands: Vec = sources + .iter() + .map(|s| { + if s.ends_with('/') { + format!("cp \"{}*\" \"{}\"", s, destination) + } else { + format!("cp \"{}\" \"{}\"", s, destination) + } + }) + .collect(); + + self.run_batch_operation(&operation_id, "copy", commands, total_files) + } + + pub fn move_objects( + &self, + sources: Vec, + destination: &str, + ) -> Result { + let operation_id = Uuid::new_v4().to_string(); + let total_files = sources.len(); + + info!( + "Starting move operation {} for {} files to {}", + operation_id, total_files, destination + ); + + let commands: Vec = sources + .iter() + .map(|s| format!("mv \"{}\" \"{}\"", s, destination)) + .collect(); + + self.run_batch_operation(&operation_id, "move", commands, total_files) + } + + pub fn download_objects( + &self, + sources: Vec<(String, bool)>, // (path, is_directory) + destination: &str, + ) -> Result { + let operation_id = Uuid::new_v4().to_string(); + let total_files = sources.len(); + + info!( + "Starting download operation {} for {} items to {}", + operation_id, total_files, destination + ); + + let commands: Vec = sources + .iter() + .map(|(path, is_dir)| { + if *is_dir { + format!("cp \"{}*\" \"{}\"", path, destination) + } else { + format!("cp \"{}\" \"{}\"", path, destination) + } + }) + .collect(); + + self.run_batch_operation(&operation_id, "download", commands, total_files) + } + + pub fn upload_objects( + &self, + sources: Vec, + destination: &str, + ) -> Result { + let operation_id = Uuid::new_v4().to_string(); + let total_files = sources.len(); + + info!( + "Starting upload operation {} for {} files to {}", + operation_id, total_files, destination + ); + + let commands: Vec = sources + .iter() + .map(|s| format!("cp \"{}\" \"{}\"", s, destination)) + .collect(); + + self.run_batch_operation(&operation_id, "upload", commands, total_files) + } + + pub fn create_folder(&self, path: &str) -> Result<(), String> { + // s5cmd doesn't have a direct mkdir, but we can create an empty object with a trailing slash + // or use a placeholder file + let folder_path = if path.ends_with('/') { + path.to_string() + } else { + format!("{}/", path) + }; + + // Create a zero-byte object to represent the folder + let temp_file = NamedTempFile::new() + .map_err(|e| format!("Failed to create temp file: {}", e))?; + + let mut cmd = self.build_command()?; + cmd.arg("cp") + .arg(temp_file.path()) + .arg(&folder_path); + + let output = cmd.output().map_err(|e| format!("Failed to execute s5cmd: {}", e))?; + + if !output.status.success() { + let error = String::from_utf8_lossy(&output.stderr); + return Err(parse_s5cmd_error(&error)); + } + + Ok(()) + } + + fn run_batch_operation( + &self, + operation_id: &str, + operation_type: &str, + commands: Vec, + total_files: usize, + ) -> Result { + // Write commands to temp file + let temp_file = NamedTempFile::new() + .map_err(|e| format!("Failed to create temp file: {}", e))?; + + std::fs::write(temp_file.path(), commands.join("\n")) + .map_err(|e| format!("Failed to write commands: {}", e))?; + + let mut cmd = self.build_command()?; + cmd.arg("--numworkers") + .arg(self.settings.concurrent_operations.to_string()) + .arg("run") + .arg(temp_file.path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let child = cmd.spawn().map_err(|e| format!("Failed to spawn s5cmd: {}", e))?; + + let progress = OperationProgress { + id: operation_id.to_string(), + operation_type: operation_type.to_string(), + total_files, + completed_files: 0, + total_bytes: 0, + transferred_bytes: 0, + current_file: None, + status: OperationStatus::Running, + error: None, + started_at: chrono::Utc::now().to_rfc3339(), + }; + + let state = Arc::new(Mutex::new(OperationState { + progress: progress.clone(), + child: Some(child), + cancelled: false, + })); + + // Store the operation + if let Ok(mut ops) = ACTIVE_OPERATIONS.lock() { + ops.insert(operation_id.to_string(), state.clone()); + } + + // Monitor progress in background + let op_id = operation_id.to_string(); + std::thread::spawn(move || { + monitor_operation(op_id, state); + }); + + // Keep temp file alive for the duration of the operation + std::mem::forget(temp_file); + + Ok(progress) + } +} + +fn monitor_operation(operation_id: String, state: Arc>) { + let mut completed = 0; + let mut stderr_output = String::new(); + + loop { + let (cancelled, child_finished) = { + let mut guard = match state.lock() { + Ok(g) => g, + Err(_) => break, + }; + + if guard.cancelled { + if let Some(ref mut child) = guard.child { + let _ = child.kill(); + } + guard.progress.status = OperationStatus::Cancelled; + break; + } + + if let Some(ref mut child) = guard.child { + // Check if stderr has output + if let Some(ref mut stderr) = child.stderr { + let reader = BufReader::new(stderr); + for line in reader.lines().take(10) { + if let Ok(line) = line { + stderr_output.push_str(&line); + stderr_output.push('\n'); + + // Parse JSON output to count completed operations + if line.contains("\"operation\"") { + completed += 1; + } + } + } + } + + match child.try_wait() { + Ok(Some(status)) => { + guard.progress.completed_files = completed; + if status.success() { + guard.progress.status = OperationStatus::Completed; + } else { + guard.progress.status = OperationStatus::Failed; + guard.progress.error = Some(stderr_output.clone()); + } + (guard.cancelled, true) + } + Ok(None) => { + guard.progress.completed_files = completed; + (guard.cancelled, false) + } + Err(e) => { + guard.progress.status = OperationStatus::Failed; + guard.progress.error = Some(format!("Error checking process: {}", e)); + (guard.cancelled, true) + } + } + } else { + (guard.cancelled, true) + } + }; + + if child_finished || cancelled { + break; + } + + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + // Clean up operation from active list after a delay + std::thread::sleep(std::time::Duration::from_secs(60)); + if let Ok(mut ops) = ACTIVE_OPERATIONS.lock() { + ops.remove(&operation_id); + } +} + +pub fn cancel_operation(operation_id: &str) -> Result<(), String> { + if let Ok(ops) = ACTIVE_OPERATIONS.lock() { + if let Some(state) = ops.get(operation_id) { + if let Ok(mut guard) = state.lock() { + guard.cancelled = true; + if let Some(ref mut child) = guard.child { + let _ = child.kill(); + } + info!("Cancelled operation: {}", operation_id); + return Ok(()); + } + } + } + Err("Operation not found".to_string()) +} + +pub fn get_operation_progress(operation_id: &str) -> Option { + if let Ok(ops) = ACTIVE_OPERATIONS.lock() { + if let Some(state) = ops.get(operation_id) { + if let Ok(guard) = state.lock() { + return Some(guard.progress.clone()); + } + } + } + None +} + +fn parse_json_lines Deserialize<'de>>(output: &str) -> Result, String> { + let mut results = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + match serde_json::from_str::(line) { + Ok(item) => results.push(item), + Err(e) => { + warn!("Failed to parse JSON line: {} - {}", line, e); + } + } + } + + Ok(results) +} + +fn parse_s5cmd_error(error: &str) -> String { + // Map common s5cmd errors to user-friendly messages + if error.contains("NoCredentialProviders") || error.contains("no valid credential") { + return "AWS credentials not found or invalid. Please check your profile configuration." + .to_string(); + } + + if error.contains("AccessDenied") { + return "Access denied. You don't have permission to perform this operation.".to_string(); + } + + if error.contains("NoSuchBucket") { + return "Bucket not found. It may have been deleted or you don't have access.".to_string(); + } + + if error.contains("NoSuchKey") { + return "Object not found.".to_string(); + } + + if error.contains("InvalidAccessKeyId") { + return "Invalid AWS access key. Please check your credentials.".to_string(); + } + + if error.contains("SignatureDoesNotMatch") { + return "Invalid AWS secret key. Please check your credentials.".to_string(); + } + + if error.contains("ExpiredToken") { + return "Your session has expired. Please refresh your credentials.".to_string(); + } + + if error.contains("RequestTimeTooSkewed") { + return "Your system clock is out of sync. Please correct your system time.".to_string(); + } + + // Return the original error if no mapping found + error.to_string() +} + +pub fn get_s5cmd_version(s5cmd_path: Option<&str>) -> Result { + let binary = s5cmd_path.unwrap_or("s5cmd"); + + let output = Command::new(binary) + .arg("version") + .output() + .map_err(|e| format!("Failed to run s5cmd: {}", e))?; + + if output.status.success() { + let version = String::from_utf8_lossy(&output.stdout); + Ok(version.trim().to_string()) + } else { + Err("Failed to get s5cmd version".to_string()) + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs new file mode 100644 index 0000000..3834f98 --- /dev/null +++ b/src-tauri/src/settings.rs @@ -0,0 +1,99 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Settings { + // General + #[serde(default = "default_download_location")] + pub download_location: String, + #[serde(default = "default_concurrency")] + pub concurrent_operations: u32, + #[serde(default)] + pub show_hidden_files: bool, + #[serde(default = "default_true")] + pub confirm_before_delete: bool, + #[serde(default)] + pub single_click_to_open: bool, + + // Advanced + #[serde(default)] + pub s5cmd_path: Option, + #[serde(default = "default_retries")] + pub max_retries: u32, + #[serde(default = "default_timeout")] + pub timeout_seconds: u32, + #[serde(default = "default_log_level")] + pub log_level: String, +} + +fn default_download_location() -> String { + dirs::download_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "~/Downloads".to_string()) +} + +fn default_concurrency() -> u32 { + 10 +} + +fn default_true() -> bool { + true +} + +fn default_retries() -> u32 { + 3 +} + +fn default_timeout() -> u32 { + 300 +} + +fn default_log_level() -> String { + "Info".to_string() +} + +impl Default for Settings { + fn default() -> Self { + Self { + download_location: default_download_location(), + concurrent_operations: default_concurrency(), + show_hidden_files: false, + confirm_before_delete: true, + single_click_to_open: false, + s5cmd_path: None, + max_retries: default_retries(), + timeout_seconds: default_timeout(), + log_level: default_log_level(), + } + } +} + +impl Settings { + pub fn load(app_data_dir: &PathBuf) -> Result { + let path = app_data_dir.join("settings.json"); + if !path.exists() { + return Ok(Self::default()); + } + + let content = fs::read_to_string(&path) + .map_err(|e| format!("Failed to read settings: {}", e))?; + + serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings: {}", e)) + } + + pub fn save(&self, app_data_dir: &PathBuf) -> Result<(), String> { + fs::create_dir_all(app_data_dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + + let path = app_data_dir.join("settings.json"); + let content = serde_json::to_string_pretty(self) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + + fs::write(&path, content).map_err(|e| format!("Failed to write settings: {}", e)) + } + + pub fn get_s5cmd_binary(&self) -> String { + self.s5cmd_path.clone().unwrap_or_else(|| "s5cmd".to_string()) + } +} diff --git a/src-tauri/src/sso.rs b/src-tauri/src/sso.rs new file mode 100644 index 0000000..7a212ba --- /dev/null +++ b/src-tauri/src/sso.rs @@ -0,0 +1,200 @@ +use log::{debug, error, info}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::process::Command; +use std::sync::Mutex; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SsoConfig { + pub sso_start_url: String, + pub sso_region: String, + pub sso_account_id: String, + pub sso_role_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum SsoStatus { + NotStarted, + AwaitingBrowser, + Authenticated, + Failed(String), + Expired, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SsoSession { + pub status: SsoStatus, + pub config: SsoConfig, + #[serde(skip_serializing_if = "Option::is_none")] + pub access_key_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub secret_access_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expiration: Option, +} + +// Store active SSO sessions +lazy_static::lazy_static! { + static ref SSO_SESSIONS: Mutex> = Mutex::new(HashMap::new()); +} + +impl SsoSession { + pub fn new(config: SsoConfig) -> Self { + Self { + status: SsoStatus::NotStarted, + config, + access_key_id: None, + secret_access_key: None, + session_token: None, + expiration: None, + } + } +} + +/// Start SSO login process using AWS CLI +pub fn start_sso_login(profile_id: &str, config: SsoConfig) -> Result { + info!("Starting SSO login for profile: {}", profile_id); + + // Create a temporary AWS config for SSO + let config_content = format!( + r#"[profile s5cmd-gui-sso] +sso_start_url = {} +sso_region = {} +sso_account_id = {} +sso_role_name = {} +region = {} +"#, + config.sso_start_url, + config.sso_region, + config.sso_account_id, + config.sso_role_name, + config.sso_region + ); + + debug!("SSO config:\n{}", config_content); + + // Write temporary config + let temp_dir = std::env::temp_dir().join("s5cmd-gui-sso"); + std::fs::create_dir_all(&temp_dir) + .map_err(|e| format!("Failed to create temp directory: {}", e))?; + + let config_path = temp_dir.join("config"); + std::fs::write(&config_path, config_content) + .map_err(|e| format!("Failed to write SSO config: {}", e))?; + + // Store the session + let session = SsoSession { + status: SsoStatus::AwaitingBrowser, + config: config.clone(), + access_key_id: None, + secret_access_key: None, + session_token: None, + expiration: None, + }; + + if let Ok(mut sessions) = SSO_SESSIONS.lock() { + sessions.insert(profile_id.to_string(), session); + } + + // Run aws sso login + let output = Command::new("aws") + .env("AWS_CONFIG_FILE", config_path.to_string_lossy().to_string()) + .args(["sso", "login", "--profile", "s5cmd-gui-sso"]) + .output() + .map_err(|e| format!("Failed to run AWS CLI: {}", e))?; + + if !output.status.success() { + let error = String::from_utf8_lossy(&output.stderr); + error!("SSO login failed: {}", error); + + if let Ok(mut sessions) = SSO_SESSIONS.lock() { + if let Some(session) = sessions.get_mut(profile_id) { + session.status = SsoStatus::Failed(error.to_string()); + } + } + + return Err(format!("SSO login failed: {}", error)); + } + + info!("SSO login completed for profile: {}", profile_id); + + // Get credentials + get_sso_credentials(profile_id, &config, &config_path)?; + + Ok("SSO login completed".to_string()) +} + +fn get_sso_credentials( + profile_id: &str, + config: &SsoConfig, + config_path: &std::path::Path, +) -> Result<(), String> { + // Use aws configure export-credentials to get the credentials + let output = Command::new("aws") + .env("AWS_CONFIG_FILE", config_path.to_string_lossy().to_string()) + .args([ + "configure", + "export-credentials", + "--profile", + "s5cmd-gui-sso", + "--format", + "env", + ]) + .output() + .map_err(|e| format!("Failed to get SSO credentials: {}", e))?; + + if !output.status.success() { + let error = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to get SSO credentials: {}", error)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut access_key = None; + let mut secret_key = None; + let mut session_token = None; + + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("export AWS_ACCESS_KEY_ID=") { + access_key = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("export AWS_SECRET_ACCESS_KEY=") { + secret_key = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("export AWS_SESSION_TOKEN=") { + session_token = Some(value.trim().to_string()); + } + } + + if let Ok(mut sessions) = SSO_SESSIONS.lock() { + if let Some(session) = sessions.get_mut(profile_id) { + session.access_key_id = access_key; + session.secret_access_key = secret_key; + session.session_token = session_token; + session.status = SsoStatus::Authenticated; + } + } + + Ok(()) +} + +pub fn check_sso_status(profile_id: &str) -> Result { + if let Ok(sessions) = SSO_SESSIONS.lock() { + if let Some(session) = sessions.get(profile_id) { + return Ok(session.status.clone()); + } + } + Ok(SsoStatus::NotStarted) +} + +pub fn get_sso_session(profile_id: &str) -> Option { + SSO_SESSIONS + .lock() + .ok() + .and_then(|sessions| sessions.get(profile_id).cloned()) +} + +pub fn clear_sso_session(profile_id: &str) { + if let Ok(mut sessions) = SSO_SESSIONS.lock() { + sessions.remove(profile_id); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d124a70..69f2b59 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,4 +1,5 @@ { + "$schema": "../node_modules/@tauri-apps/cli/schema.json", "build": { "beforeDevCommand": "pnpm dev", "beforeBuildCommand": "pnpm build", @@ -7,43 +8,102 @@ "withGlobalTauri": false }, "package": { - "productName": "s5cmd-gui", - "version": "0.0.0" + "productName": "S5cmd GUI", + "version": "1.0.0" }, "tauri": { "allowlist": { "all": false, "shell": { "all": false, - "open": true + "open": true, + "execute": true, + "sidecar": true, + "scope": [ + { + "name": "s5cmd", + "cmd": "s5cmd", + "args": true + } + ] }, "dialog": { + "all": true, "open": true, - "save": true + "save": true, + "message": true, + "ask": true, + "confirm": true + }, + "fs": { + "all": true, + "readFile": true, + "writeFile": true, + "readDir": true, + "createDir": true, + "removeDir": true, + "removeFile": true, + "exists": true, + "scope": ["$HOME/**", "$APPDATA/**", "$APPLOCALDATA/**", "$DOCUMENT/**", "$DOWNLOAD/**", "$DESKTOP/**", "**"] + }, + "path": { + "all": true + }, + "globalShortcut": { + "all": true + }, + "process": { + "all": true, + "exit": true, + "relaunch": true } }, "bundle": { "active": true, "targets": "all", - "identifier": "com.tauri.dev", + "identifier": "com.s5cmdgui.app", "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "resources": [], + "externalBin": [], + "copyright": "MIT License", + "category": "DeveloperTool", + "shortDescription": "Fast S3 file browser powered by s5cmd", + "longDescription": "A graphical user interface for s5cmd, providing fast S3 file browsing, uploads, and downloads with parallel transfers.", + "deb": { + "depends": [] + }, + "macOS": { + "frameworks": [], + "minimumSystemVersion": "", + "exceptionDomain": "", + "signingIdentity": null, + "providerShortName": null, + "entitlements": null + }, + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "" + } }, "security": { - "csp": null + "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: https://asset.localhost" }, "windows": [ { "fullscreen": false, "resizable": true, - "title": "s5cmd-gui", - "width": 800, - "height": 600 + "title": "S5cmd GUI", + "width": 1200, + "height": 800, + "minWidth": 800, + "minHeight": 600 } ] } diff --git a/src/api.ts b/src/api.ts index 97395ba..e7f455a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,57 +1,165 @@ import { invoke } from '@tauri-apps/api/tauri'; -import { ListResults } from './types'; -import { FileData } from '@aperturerobotics/chonky'; +import type { + Profile, + SaveProfileRequest, + S3Object, + S3Bucket, + OperationProgress, + DownloadSource, + Settings, + ObjectMetadata, +} from './types'; +// ============ Profile Management ============ -export async function runS5cmd(command: string, args: string[]): Promise { - return invoke('run_s5cmd', {command: command, args: args}); +export async function getProfiles(): Promise { + return invoke('get_profiles'); } -export async function runS5cmdRun(commands: string[]) { - // the rust backend creates the run txt file from the args list? - return invoke('run_s5cmd_run', {commands: commands}); +export async function saveProfile(request: SaveProfileRequest): Promise { + return invoke('save_profile', { request }); } -export async function setSecret(accessKeyId: string, secretAccessKey: string) { - return invoke('set_s3_secret', {accessKeyId: accessKeyId, secretAccessKey: secretAccessKey}); +export async function deleteProfile(profileId: string): Promise { + return invoke('delete_profile', { profileId }); } -export async function removeS3Object(path: FileData) { - if (path.isDir) { - return runS5cmd('rm', [path.id + '*']); - } - return runS5cmd('rm', [path.id]); +export async function setActiveProfile(profileId: string): Promise { + return invoke('set_active_profile', { profileId }); +} + +export async function testProfileConnection(profileId: string): Promise { + return invoke('test_profile_connection', { profileId }); +} + +// ============ SSO ============ + +export async function startSsoLogin(profileId: string): Promise { + return invoke('start_sso_login', { profileId }); +} + +export async function checkSsoStatus(profileId: string): Promise { + return invoke('check_sso_status', { profileId }); +} + +// ============ S3 Operations ============ + +export async function listBuckets(): Promise { + return invoke('list_buckets'); +} + +export async function listObjects(path: string): Promise { + return invoke('list_objects', { path }); +} + +export async function deleteObjects(paths: string[]): Promise { + return invoke('delete_objects', { paths }); +} + +export async function copyObjects( + sources: string[], + destination: string +): Promise { + return invoke('copy_objects', { sources, destination }); +} + +export async function moveObjects( + sources: string[], + destination: string +): Promise { + return invoke('move_objects', { sources, destination }); +} + +export async function createFolder(path: string): Promise { + return invoke('create_folder', { path }); +} + +export async function downloadObjects( + sources: DownloadSource[], + destination: string +): Promise { + return invoke('download_objects', { sources, destination }); } -export async function moveS3Object(source: string, destination: string) { - return runS5cmd('mv', [source, destination]); +export async function uploadObjects( + sources: string[], + destination: string +): Promise { + return invoke('upload_objects', { sources, destination }); } -export async function listS3Objects(bucket: string, path: string): Promise { - return runS5cmd('ls', [bucket, path]); +export async function getObjectMetadata(path: string): Promise { + return invoke('get_object_metadata', { path }); } -export async function downloadS3Objects(sources: FileData[], destination: string) { - let commands: string[] = []; - for (let source of sources) { - if (source.isDir) { - commands.push(`cp "${source.id}*" "${destination}/${source.name}"`); - } else { - commands.push(`cp "${source.id}" "${destination}"`); - } +// ============ Settings ============ + +export async function getSettings(): Promise { + return invoke('get_settings'); +} + +export async function saveSettings(settings: Settings): Promise { + return invoke('save_settings', { settings }); +} + +// ============ Logging ============ + +export async function getLogs(lines?: number): Promise { + return invoke('get_logs', { lines }); +} + +export async function clearLogs(): Promise { + return invoke('clear_logs'); +} + +export async function getLogPath(): Promise { + return invoke('get_log_path'); +} + +// ============ Progress ============ + +export async function cancelOperation(operationId: string): Promise { + return invoke('cancel_operation', { operationId }); +} + +// ============ Utility ============ + +export async function getS5cmdVersion(): Promise { + return invoke('get_s5cmd_version'); +} + +// ============ Helpers ============ + +export function parseS3Path(path: string): { bucket: string; prefix: string } { + const match = path.match(/^s3:\/\/([^/]+)\/?(.*)$/); + if (!match) { + throw new Error(`Invalid S3 path: ${path}`); } - return runS5cmdRun(commands); + return { + bucket: match[1], + prefix: match[2] || '', + }; } -export async function uploadS3Objects(sources: string[], s3Destination: string) { - let commands: string[] = []; - for (let source of sources) { - commands.push(`cp "${source}" "${s3Destination}"`); +export function buildS3Path(bucket: string, prefix: string): string { + if (prefix) { + return `s3://${bucket}/${prefix}`; } - return runS5cmdRun(commands); + return `s3://${bucket}/`; +} + +export function getObjectName(key: string): string { + const parts = key.replace(/\/$/, '').split('/'); + return parts[parts.length - 1] || key; } -// Function to run a list of s5cmd commands -export async function runS5cmdList(commands: string[]) { - return runS5cmdRun(commands); +export function formatBytes(bytes: number | undefined): string { + if (bytes === undefined || bytes === null) return '-'; + if (bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; } diff --git a/src/components/BucketConfig.tsx b/src/components/BucketConfig.tsx deleted file mode 100644 index 9f48418..0000000 --- a/src/components/BucketConfig.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { runS5cmd, setSecret } from '../api'; -import { Bucket, Secret } from '../types'; -import { fixJsonData } from '../utils'; - -// Define the type for the BucketConfig props -interface BucketConfigProps { - updateBucket: (bucket: Bucket) => void; - selectedSecret: Secret | null; -} - -const BucketConfig: React.FC = ({ updateBucket, selectedSecret }) => { - const [buckets, setBuckets] = useState([]); - const [selectedBucket, setSelectedBucket] = useState(''); - - const fetchBuckets = async () => { - const rawBucketsData = await runS5cmd('ls', []); - // deal with s5cmd's invalid JSON here - console.log('fetched BUCKETS') - console.log(rawBucketsData); - - const bucketResults = fixJsonData(rawBucketsData) - const buckets = bucketResults.map((r: any) => ({name: r.name})); - - setBuckets(buckets); - } - - useEffect(() => { - console.log('Preparing to fetch buckets'); - if (selectedSecret) { - setSecret(selectedSecret.accessKeyId, selectedSecret.secretAccessKey); - fetchBuckets(); - } - }, [selectedSecret]); - - // Function to handle bucket selection - const handleBucketChange = (event: React.ChangeEvent) => { - const bucket = event.target.value; - setSelectedBucket(bucket); - updateBucket({name: bucket}); - } - - return ( -
- - -
- ); -} - -export default BucketConfig; diff --git a/src/components/BucketSelector.tsx b/src/components/BucketSelector.tsx new file mode 100644 index 0000000..6808e14 --- /dev/null +++ b/src/components/BucketSelector.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { useAppStore } from '../stores/appStore'; + +export const BucketSelector: React.FC = () => { + const { + buckets, + selectedBucket, + bucketsLoading, + activeProfile, + selectBucket, + loadBuckets, + } = useAppStore(); + + if (!activeProfile) { + return ( +
+ +
+ ); + } + + const handleBucketChange = (e: React.ChangeEvent) => { + const bucketName = e.target.value; + const bucket = buckets.find((b) => b.name === bucketName); + if (bucket) { + selectBucket(bucket); + } + }; + + const handleRefresh = () => { + loadBuckets(); + }; + + return ( +
+ + + +
+ ); +}; + +export default BucketSelector; diff --git a/src/components/LogsDialog.tsx b/src/components/LogsDialog.tsx new file mode 100644 index 0000000..6ddee2a --- /dev/null +++ b/src/components/LogsDialog.tsx @@ -0,0 +1,132 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useAppStore } from '../stores/appStore'; +import * as api from '../api'; +import toast from 'react-hot-toast'; + +export const LogsDialog: React.FC = () => { + const { isLogsDialogOpen, setLogsDialogOpen } = useAppStore(); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [logPath, setLogPath] = useState(null); + const logsEndRef = useRef(null); + + useEffect(() => { + if (isLogsDialogOpen) { + loadLogs(); + loadLogPath(); + } + }, [isLogsDialogOpen]); + + useEffect(() => { + // Auto-scroll to bottom when logs update + if (logsEndRef.current) { + logsEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [logs]); + + const loadLogs = async () => { + setLoading(true); + try { + const logLines = await api.getLogs(500); + setLogs(logLines); + } catch (error) { + toast.error(`Failed to load logs: ${error}`); + } finally { + setLoading(false); + } + }; + + const loadLogPath = async () => { + try { + const path = await api.getLogPath(); + setLogPath(path); + } catch (error) { + console.error('Failed to get log path:', error); + } + }; + + const handleClearLogs = async () => { + if (window.confirm('Are you sure you want to clear all logs?')) { + try { + await api.clearLogs(); + setLogs([]); + toast.success('Logs cleared'); + } catch (error) { + toast.error(`Failed to clear logs: ${error}`); + } + } + }; + + const handleExportLogs = () => { + const content = logs.join('\n'); + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `s5cmd-gui-logs-${new Date().toISOString().split('T')[0]}.log`; + a.click(); + URL.revokeObjectURL(url); + }; + + if (!isLogsDialogOpen) { + return null; + } + + return ( + +
+

Application Logs

+ +
+ + {logPath && ( +
+ Log file: {logPath} +
+ )} + +
+ {loading ? ( +
Loading logs...
+ ) : logs.length === 0 ? ( +
No logs available
+ ) : ( +
+ {logs.map((line, index) => ( +
+ {line} +
+ ))} +
+
+ )} +
+ +
+ + + + +
+
+ ); +}; + +export default LogsDialog; diff --git a/src/components/ProfileSelector.tsx b/src/components/ProfileSelector.tsx new file mode 100644 index 0000000..20f39f4 --- /dev/null +++ b/src/components/ProfileSelector.tsx @@ -0,0 +1,447 @@ +import React, { useState, useEffect } from 'react'; +import { useAppStore } from '../stores/appStore'; +import * as api from '../api'; +import type { SaveProfileRequest } from '../types'; +import toast from 'react-hot-toast'; + +const AWS_REGIONS = [ + 'us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', + 'eu-west-1', 'eu-west-2', 'eu-west-3', 'eu-central-1', 'eu-north-1', + 'ap-northeast-1', 'ap-northeast-2', 'ap-northeast-3', + 'ap-southeast-1', 'ap-southeast-2', + 'ap-south-1', 'sa-east-1', 'ca-central-1', + 'me-south-1', 'af-south-1', +]; + +interface ProfileDialogProps { + onClose: () => void; + editProfileId?: string | null; +} + +const ProfileDialog: React.FC = ({ onClose, editProfileId }) => { + const { profiles, loadProfiles } = useAppStore(); + + const [name, setName] = useState(''); + const [authType, setAuthType] = useState<'access_key' | 'sso'>('access_key'); + const [region, setRegion] = useState('us-east-1'); + const [endpointUrl, setEndpointUrl] = useState(''); + + // Access Key fields + const [accessKeyId, setAccessKeyId] = useState(''); + const [secretAccessKey, setSecretAccessKey] = useState(''); + const [sessionToken, setSessionToken] = useState(''); + + // SSO fields + const [ssoStartUrl, setSsoStartUrl] = useState(''); + const [ssoRegion, setSsoRegion] = useState('us-east-1'); + const [ssoAccountId, setSsoAccountId] = useState(''); + const [ssoRoleName, setSsoRoleName] = useState(''); + + const [testing, setTesting] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (editProfileId) { + const profile = profiles.find((p) => p.id === editProfileId); + if (profile) { + setName(profile.name); + setAuthType(profile.auth_type); + setRegion(profile.region); + setEndpointUrl(profile.endpoint_url || ''); + + if (profile.auth_type === 'access_key') { + setAccessKeyId(profile.access_key_id || ''); + } else { + setSsoStartUrl(profile.sso_start_url || ''); + setSsoRegion(profile.sso_region || 'us-east-1'); + setSsoAccountId(profile.sso_account_id || ''); + setSsoRoleName(profile.sso_role_name || ''); + } + } + } + }, [editProfileId, profiles]); + + const handleTestConnection = async () => { + if (!name || !region) { + toast.error('Please fill in required fields'); + return; + } + + setTesting(true); + try { + // First save the profile + const request: SaveProfileRequest = { + id: editProfileId || undefined, + name, + auth_type: authType, + region, + endpoint_url: endpointUrl || undefined, + }; + + if (authType === 'access_key') { + request.access_key_id = accessKeyId; + request.secret_access_key = secretAccessKey; + request.session_token = sessionToken || undefined; + } else { + request.sso_start_url = ssoStartUrl; + request.sso_region = ssoRegion; + request.sso_account_id = ssoAccountId; + request.sso_role_name = ssoRoleName; + } + + const profileId = await api.saveProfile(request); + await api.setActiveProfile(profileId); + + if (authType === 'sso') { + toast.loading('Starting SSO login...', { id: 'sso-login' }); + await api.startSsoLogin(profileId); + toast.success('SSO login successful', { id: 'sso-login' }); + } + + const result = await api.testProfileConnection(profileId); + toast.success(result); + } catch (error) { + toast.error(`Connection failed: ${error}`); + } finally { + setTesting(false); + } + }; + + const handleSave = async () => { + if (!name || !region) { + toast.error('Please fill in required fields'); + return; + } + + if (authType === 'access_key' && (!accessKeyId || !secretAccessKey)) { + toast.error('Access Key ID and Secret Access Key are required'); + return; + } + + if (authType === 'sso' && (!ssoStartUrl || !ssoAccountId || !ssoRoleName)) { + toast.error('SSO fields are required'); + return; + } + + setSaving(true); + try { + const request: SaveProfileRequest = { + id: editProfileId || undefined, + name, + auth_type: authType, + region, + endpoint_url: endpointUrl || undefined, + }; + + if (authType === 'access_key') { + request.access_key_id = accessKeyId; + request.secret_access_key = secretAccessKey; + request.session_token = sessionToken || undefined; + } else { + request.sso_start_url = ssoStartUrl; + request.sso_region = ssoRegion; + request.sso_account_id = ssoAccountId; + request.sso_role_name = ssoRoleName; + } + + await api.saveProfile(request); + await loadProfiles(); + toast.success('Profile saved'); + onClose(); + } catch (error) { + toast.error(`Failed to save profile: ${error}`); + } finally { + setSaving(false); + } + }; + + return ( + +

{editProfileId ? 'Edit Profile' : 'Add Profile'}

+ +
+ + setName(e.target.value)} + placeholder="My AWS Account" + /> +
+ +
+ + +
+ + {authType === 'access_key' ? ( + <> +
+ + setAccessKeyId(e.target.value)} + placeholder="AKIAIOSFODNN7EXAMPLE" + /> +
+ +
+ + setSecretAccessKey(e.target.value)} + placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + /> +
+ +
+ + setSessionToken(e.target.value)} + placeholder="Session token for temporary credentials" + /> +
+ + ) : ( + <> +
+ + setSsoStartUrl(e.target.value)} + placeholder="https://my-sso-portal.awsapps.com/start" + /> +
+ +
+ + +
+ +
+ + setSsoAccountId(e.target.value)} + placeholder="123456789012" + /> +
+ +
+ + setSsoRoleName(e.target.value)} + placeholder="AdministratorAccess" + /> +
+ + )} + +
+ + +
+ +
+ + setEndpointUrl(e.target.value)} + placeholder="https://s3.custom-endpoint.com" + /> + For S3-compatible services like MinIO +
+ +
+ + + +
+
+ ); +}; + +export const ProfileSelector: React.FC = () => { + const { + profiles, + activeProfile, + profilesLoading, + selectProfile, + loadProfiles, + isProfileDialogOpen, + setProfileDialogOpen, + } = useAppStore(); + + const [editProfileId, setEditProfileId] = useState(null); + const [showDropdown, setShowDropdown] = useState(false); + + useEffect(() => { + loadProfiles(); + }, [loadProfiles]); + + useEffect(() => { + // Keyboard shortcut Ctrl+P for profile switcher + const handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key === 'p') { + e.preventDefault(); + setShowDropdown((prev) => !prev); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + + const handleProfileSelect = async (profileId: string) => { + setShowDropdown(false); + await selectProfile(profileId); + }; + + const handleEditProfile = (profileId: string) => { + setEditProfileId(profileId); + setProfileDialogOpen(true); + setShowDropdown(false); + }; + + const handleDeleteProfile = async (profileId: string) => { + if (window.confirm('Are you sure you want to delete this profile?')) { + try { + await api.deleteProfile(profileId); + await loadProfiles(); + toast.success('Profile deleted'); + } catch (error) { + toast.error(`Failed to delete profile: ${error}`); + } + } + setShowDropdown(false); + }; + + const handleAddProfile = () => { + setEditProfileId(null); + setProfileDialogOpen(true); + setShowDropdown(false); + }; + + return ( +
+ + + {showDropdown && ( +
+ {profiles.map((profile) => ( +
+ handleProfileSelect(profile.id)}> + {profile.name} + {profile.region} + +
+ + +
+
+ ))} + +
+ + Add Profile +
+ +
+ Tip: Press Ctrl+P to quick switch +
+
+ )} + + {isProfileDialogOpen && ( + { + setProfileDialogOpen(false); + setEditProfileId(null); + }} + editProfileId={editProfileId} + /> + )} +
+ ); +}; + +export default ProfileSelector; diff --git a/src/components/ProgressDialog.tsx b/src/components/ProgressDialog.tsx new file mode 100644 index 0000000..e79e3f2 --- /dev/null +++ b/src/components/ProgressDialog.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { useAppStore } from '../stores/appStore'; +import { cancelOperation } from '../api'; +import { formatBytes } from '../api'; +import toast from 'react-hot-toast'; + +export const ProgressDialog: React.FC = () => { + const { activeOperations, removeOperation } = useAppStore(); + + const handleCancel = async (operationId: string) => { + try { + await cancelOperation(operationId); + toast.success('Operation cancelled'); + } catch (error) { + toast.error(`Failed to cancel: ${error}`); + } + }; + + const handleDismiss = (operationId: string) => { + removeOperation(operationId); + }; + + if (activeOperations.length === 0) { + return null; + } + + return ( +
+
+

Operations

+ {activeOperations.length} +
+ +
+ {activeOperations.map((op) => { + const progress = + op.total_files > 0 + ? Math.round((op.completed_files / op.total_files) * 100) + : 0; + + return ( +
+
+ {op.operation_type} + + {op.status} + +
+ +
+
+
+ +
+ + {op.completed_files} / {op.total_files} files ({progress}%) + + {op.current_file && ( + + {op.current_file.split('/').pop()} + + )} +
+ + {op.transferred_bytes > 0 && ( +
+ + {formatBytes(op.transferred_bytes)} / {formatBytes(op.total_bytes)} + +
+ )} + + {op.error && ( +
{op.error}
+ )} + +
+ {op.status === 'Running' && ( + + )} + {(op.status === 'Completed' || + op.status === 'Failed' || + op.status === 'Cancelled') && ( + + )} +
+
+ ); + })} +
+
+ ); +}; + +export default ProgressDialog; diff --git a/src/components/S3Browser.tsx b/src/components/S3Browser.tsx index 1f49337..94d509d 100644 --- a/src/components/S3Browser.tsx +++ b/src/components/S3Browser.tsx @@ -1,180 +1,568 @@ -import React, { useEffect, useState } from 'react'; -import { FileBrowser, FileList, FileArray, FileToolbar, FileData, ChonkyActions, ChonkyFileActionData, FileNavbar, defineFileAction, ChonkyIconName, FileAction, ChonkyActionUnion } from '@aperturerobotics/chonky'; -import { runS5cmd, removeS3Object, downloadS3Objects, uploadS3Objects, /*moveS3Object, copyS3Objects*/ } from '../api'; -import { Secret, Bucket, ListResults } from '../types'; -import { fixJsonData } from '../utils'; -import { open } from '@tauri-apps/api/dialog'; -import path from 'path-browserify'; - - -interface S3BrowserProps { - secret: Secret, - bucket: Bucket, -} - -const S3Browser: React.FC = ({ secret, bucket }) => { - const BUCKET_PREFIX = `s3://${bucket.name}`; - const uploadDirsActionName = 'upload_dirs' as ChonkyActionUnion["id"]; - - const [files, setFiles] = useState([]); - const [folderPrefix, setKeyPrefix] = useState(BUCKET_PREFIX); - const [refreshBucket, setRefreshBucket] = useState(false); - - useEffect(() => { - setKeyPrefix(BUCKET_PREFIX); - }, [bucket]); - - const folderChain = React.useMemo(() => { - // these are the "breadcrumbs" in FileNavbar - let folderChain: FileArray; - if (folderPrefix === BUCKET_PREFIX) { - folderChain = []; - } else { - let currentPrefix = ''; - const folderChainItems = folderPrefix.replace(BUCKET_PREFIX, '').split('/').filter(s => s !== ''); - - console.log(`folderChainItems: ${JSON.stringify(folderChainItems)}`); - folderChain = folderChainItems - .map( - (prefixPart): FileData => { - currentPrefix = currentPrefix - ? path.join(currentPrefix, prefixPart) - : prefixPart; - return { - id: `${BUCKET_PREFIX}/${currentPrefix}`, - name: prefixPart, - isDir: true, - }; - } - ); +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { + FileBrowser, + FileList, + FileNavbar, + FileToolbar, + FileContextMenu, + FileArray, + FileData, + ChonkyActions, + ChonkyFileActionData, + defineFileAction, + ChonkyIconName, + FileAction, +} from '@aperturerobotics/chonky'; +import { open as openDialog, confirm, message } from '@tauri-apps/api/dialog'; +import { useAppStore } from '../stores/appStore'; +import * as api from '../api'; +import { formatBytes, getObjectName, buildS3Path } from '../api'; +import { StatusBar } from './StatusBar'; +import toast from 'react-hot-toast'; +import type { S3Object, ClipboardItem } from '../types'; +import { formatDistanceToNow } from 'date-fns'; + +// Custom file actions +const RefreshAction = defineFileAction({ + id: 'refresh', + button: { + name: 'Refresh', + toolbar: true, + icon: ChonkyIconName.loading, + }, + hotkeys: ['f5'], +}); + +const UploadFilesAction = defineFileAction({ + id: 'upload_files', + button: { + name: 'Upload Files', + toolbar: true, + icon: ChonkyIconName.upload, + }, +}); + +const UploadFolderAction = defineFileAction({ + id: 'upload_folder', + button: { + name: 'Upload Folder', + toolbar: true, + icon: ChonkyIconName.folderOpen, + }, +}); + +const NewFolderAction = defineFileAction({ + id: 'new_folder', + button: { + name: 'New Folder', + toolbar: true, + icon: ChonkyIconName.folderCreate, + }, + hotkeys: ['ctrl+shift+n'], +}); + +const CopyS3UriAction = defineFileAction({ + id: 'copy_s3_uri', + requiresSelection: true, + button: { + name: 'Copy S3 URI', + contextMenu: true, + icon: ChonkyIconName.copy, + }, +}); + +const PropertiesAction = defineFileAction({ + id: 'properties', + requiresSelection: true, + button: { + name: 'Properties', + contextMenu: true, + icon: ChonkyIconName.info, + }, +}); + +export const S3Browser: React.FC = () => { + const { + selectedBucket, + currentPath, + navigate, + goUp, + clipboard, + setClipboard, + clearClipboard, + sort, + setSort, + searchQuery, + settings, + addOperation, + setError, + } = useAppStore(); + + const [objects, setObjects] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedFiles, setSelectedFiles] = useState([]); + + // Load objects when path changes + useEffect(() => { + if (currentPath) { + loadObjects(); + } + }, [currentPath]); + + const loadObjects = async () => { + if (!currentPath) return; + + setLoading(true); + try { + const result = await api.listObjects(currentPath); + setObjects(result); + } catch (error) { + toast.error(`Failed to load objects: ${error}`); + setError(`Failed to load objects: ${error}`); + } finally { + setLoading(false); + } + }; + + // Convert S3 objects to Chonky files + const files: FileArray = useMemo(() => { + if (!selectedBucket) return []; + + let filteredObjects = objects; + + // Apply search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + const wildcardRegex = new RegExp( + '^' + query.replace(/\*/g, '.*').replace(/\?/g, '.') + '$', + 'i' + ); + + filteredObjects = objects.filter((obj) => { + const name = getObjectName(obj.key).toLowerCase(); + return wildcardRegex.test(name) || name.includes(query); + }); + } + + // Apply hidden files filter + if (settings && !settings.show_hidden_files) { + filteredObjects = filteredObjects.filter((obj) => { + const name = getObjectName(obj.key); + return !name.startsWith('.'); + }); + } + + // Sort objects + const sorted = [...filteredObjects].sort((a, b) => { + const aName = getObjectName(a.key); + const bName = getObjectName(b.key); + const aIsDir = a.type === 'directory'; + const bIsDir = b.type === 'directory'; + + // Directories first + if (aIsDir && !bIsDir) return -1; + if (!aIsDir && bIsDir) return 1; + + let comparison = 0; + switch (sort.column) { + case 'name': + comparison = aName.localeCompare(bName); + break; + case 'size': + comparison = (a.size || 0) - (b.size || 0); + break; + case 'modified': + comparison = (a.last_modified || '').localeCompare(b.last_modified || ''); + break; + case 'storage_class': + comparison = (a.storage_class || '').localeCompare(b.storage_class || ''); + break; + } + + return sort.direction === 'asc' ? comparison : -comparison; + }); + + return sorted.map((obj): FileData => { + const isDir = obj.type === 'directory'; + const name = getObjectName(obj.key); + + return { + id: obj.key, + name, + isDir, + size: obj.size, + modDate: obj.last_modified ? new Date(obj.last_modified) : undefined, + // Custom properties for display + ext: isDir ? undefined : name.split('.').pop(), + thumbnailUrl: undefined, + // Extra data for context menu + color: undefined, + icon: undefined, + // Custom metadata + storageClass: obj.storage_class, + } as FileData & { storageClass?: string }; + }); + }, [objects, searchQuery, sort, settings, selectedBucket]); + + // Build folder chain for breadcrumbs + const folderChain: FileArray = useMemo(() => { + if (!selectedBucket || !currentPath) return []; + + const bucketPrefix = `s3://${selectedBucket.name}/`; + const chain: FileData[] = [ + { + id: bucketPrefix, + name: selectedBucket.name, + isDir: true, + }, + ]; + + const relativePath = currentPath.replace(bucketPrefix, ''); + const parts = relativePath.split('/').filter(Boolean); + + let accumulated = bucketPrefix; + for (const part of parts) { + accumulated += part + '/'; + chain.push({ + id: accumulated, + name: part, + isDir: true, + }); + } + + return chain; + }, [selectedBucket, currentPath]); + + // Handle file actions + const handleFileAction = useCallback( + async (data: ChonkyFileActionData) => { + const { id: actionId, payload, state } = data; + const selected = state.selectedFilesForAction || []; + + switch (actionId) { + case ChonkyActions.OpenFiles.id: { + const targetFile = payload?.targetFile; + if (targetFile?.isDir) { + navigate(targetFile.id); + } + break; } - folderChain.unshift({ - id: BUCKET_PREFIX, - name: bucket.name, - isDir: true, - }); - console.log(`folderChain: ${JSON.stringify(folderChain)}`) - return folderChain; - }, [folderPrefix, bucket]); - - - useEffect(() => { - // Fetch bucket content when secret or bucket changes - if (secret && bucket) { - runS5cmd('ls', [folderPrefix]) - .then((rawResults: string) => { - const results: ListResults = fixJsonData(rawResults); - const files = results.map(result => { - const strippedName = result.key.replace(`s3://${bucket.name}/`, ''); - let fileData: FileData = { - id: result.key, - name: strippedName, - isDir: result.type === 'directory', - modDate: result.last_modified, - size: result.size, - }; - return fileData; - }); - setFiles(files); - }); + + case ChonkyActions.DeleteFiles.id: { + if (selected.length === 0) return; + + const confirmDelete = + !settings?.confirm_before_delete || + (await confirm( + `Delete ${selected.length} item(s)? This cannot be undone.`, + { title: 'Confirm Delete', type: 'warning' } + )); + + if (confirmDelete) { + try { + const paths = selected.map((f) => f.id); + const operation = await api.deleteObjects(paths); + addOperation(operation); + toast.success(`Deleting ${selected.length} item(s)...`); + setTimeout(loadObjects, 1000); + } catch (error) { + toast.error(`Delete failed: ${error}`); + } + } + break; } - }, [secret, bucket, folderPrefix, refreshBucket]); - - const handleAction = (action: ChonkyFileActionData) => { - const selectedFiles = action.state.selectedFiles; - switch (action.id) { - case ChonkyActions.OpenFiles.id: - if (action.payload.files && action.payload.files.length !== 1) - return; - if (!action.payload.targetFile || !action.payload.targetFile.isDir) - return; - - const newPrefix = action.payload.targetFile.id; - setKeyPrefix(newPrefix); - break; - case ChonkyActions.DeleteFiles.id: - removeS3Object(selectedFiles[0]) - // TODO handle multiple files - //removeS3Object(selectedFiles) - .then(() => { - setRefreshBucket(!refreshBucket); - }); - break; - case ChonkyActions.EndDragNDrop.id: - console.log(action); - break; - case ChonkyActions.DownloadFiles.id: - const downloadFiles = async () => { - const filePath = await open({ - title: 'Choose download directory', - directory: true, - multiple: false, - }); - if (filePath && typeof filePath === 'string') { - downloadS3Objects(selectedFiles, filePath); - } - }; - downloadFiles(); - break; - case ChonkyActions.UploadFiles.id: - case uploadDirsActionName: - const uploadFiles = async () => { - const filePaths = await open({ - title: 'Upload files', - directory: action.id === uploadDirsActionName ? true : false, - multiple: true, - }); - console.log(`filePaths: ${filePaths}`); - if (filePaths && Array.isArray(filePaths)) { - uploadS3Objects(filePaths, folderPrefix); - } else if (filePaths && typeof filePaths === 'string') { - uploadS3Objects([filePaths], folderPrefix); - } - }; - uploadFiles().then(() => { - setRefreshBucket(!refreshBucket); - }); - break; + + case ChonkyActions.DownloadFiles.id: { + if (selected.length === 0) return; + + const destination = await openDialog({ + directory: true, + title: 'Select download destination', + defaultPath: settings?.download_location, + }); + + if (destination && typeof destination === 'string') { + try { + const sources = selected.map((f) => ({ + path: f.id, + is_dir: f.isDir || false, + })); + const operation = await api.downloadObjects(sources, destination); + addOperation(operation); + toast.success(`Downloading ${selected.length} item(s)...`); + } catch (error) { + toast.error(`Download failed: ${error}`); + } + } + break; } - }; + case ChonkyActions.CopyFiles.id: { + if (selected.length === 0) return; + const items: ClipboardItem[] = selected.map((f) => ({ + path: f.id, + name: f.name, + isDir: f.isDir || false, + })); - const customUploadDirs: FileAction = defineFileAction({ - id: uploadDirsActionName, - button: { - name: 'Upload folders', - toolbar: true, - icon: ChonkyIconName.upload, - }, - }); + setClipboard({ + items, + operation: 'copy', + sourceBucket: selectedBucket?.name, + }); - const myFileActions = [ - ChonkyActions.DeleteFiles, - ChonkyActions.DownloadFiles, - ChonkyActions.UploadFiles, - customUploadDirs - ]; - const actionsToDisable: string[] = [ - ChonkyActions.SelectAllFiles.id, - ChonkyActions.OpenSelection.id, - ChonkyActions.ToggleHiddenFiles.id, - ]; + toast.success(`Copied ${items.length} item(s) to clipboard`); + break; + } + + case ChonkyActions.CutFiles.id: { + if (selected.length === 0) return; + + const items: ClipboardItem[] = selected.map((f) => ({ + path: f.id, + name: f.name, + isDir: f.isDir || false, + })); + + setClipboard({ + items, + operation: 'cut', + sourceBucket: selectedBucket?.name, + }); + + toast.success(`Cut ${items.length} item(s) to clipboard`); + break; + } + + case ChonkyActions.PasteFiles.id: { + if (clipboard.items.length === 0 || !clipboard.operation) { + toast.error('Nothing to paste'); + return; + } + + try { + const sources = clipboard.items.map((item) => item.path); + + if (clipboard.operation === 'copy') { + const operation = await api.copyObjects(sources, currentPath); + addOperation(operation); + toast.success(`Copying ${sources.length} item(s)...`); + } else { + const operation = await api.moveObjects(sources, currentPath); + addOperation(operation); + toast.success(`Moving ${sources.length} item(s)...`); + clearClipboard(); + } + + setTimeout(loadObjects, 1000); + } catch (error) { + toast.error(`Paste failed: ${error}`); + } + break; + } + + case 'upload_files': { + const filePaths = await openDialog({ + multiple: true, + title: 'Select files to upload', + }); + + if (filePaths) { + const paths = Array.isArray(filePaths) ? filePaths : [filePaths]; + try { + const operation = await api.uploadObjects(paths, currentPath); + addOperation(operation); + toast.success(`Uploading ${paths.length} file(s)...`); + setTimeout(loadObjects, 1000); + } catch (error) { + toast.error(`Upload failed: ${error}`); + } + } + break; + } + + case 'upload_folder': { + const folderPath = await openDialog({ + directory: true, + title: 'Select folder to upload', + }); + + if (folderPath && typeof folderPath === 'string') { + try { + const operation = await api.uploadObjects([folderPath], currentPath); + addOperation(operation); + toast.success('Uploading folder...'); + setTimeout(loadObjects, 1000); + } catch (error) { + toast.error(`Upload failed: ${error}`); + } + } + break; + } + + case 'new_folder': { + const folderName = window.prompt('Enter folder name:'); + if (folderName) { + try { + const folderPath = currentPath + folderName + '/'; + await api.createFolder(folderPath); + toast.success('Folder created'); + loadObjects(); + } catch (error) { + toast.error(`Failed to create folder: ${error}`); + } + } + break; + } + + case 'refresh': { + loadObjects(); + break; + } + + case 'copy_s3_uri': { + if (selected.length > 0) { + const uris = selected.map((f) => f.id).join('\n'); + await navigator.clipboard.writeText(uris); + toast.success('S3 URI copied to clipboard'); + } + break; + } + + case 'properties': { + if (selected.length === 1) { + const file = selected[0]; + try { + const metadata = await api.getObjectMetadata(file.id); + await message( + `Key: ${metadata.key}\n` + + `Size: ${formatBytes(metadata.size)}\n` + + `Last Modified: ${metadata.last_modified || 'N/A'}\n` + + `Storage Class: ${metadata.storage_class || 'N/A'}\n` + + `ETag: ${metadata.etag || 'N/A'}`, + { title: 'Properties', type: 'info' } + ); + } catch (error) { + toast.error(`Failed to get properties: ${error}`); + } + } + break; + } + + case ChonkyActions.ChangeSelection.id: { + setSelectedFiles(selected); + break; + } + + case ChonkyActions.MoveFiles.id: { + // Handle drag and drop move within the browser + const destination = payload?.destination; + if (destination?.isDir && selected.length > 0) { + try { + const sources = selected.map((f) => f.id); + const operation = await api.moveObjects(sources, destination.id); + addOperation(operation); + toast.success(`Moving ${sources.length} item(s)...`); + setTimeout(loadObjects, 1000); + } catch (error) { + toast.error(`Move failed: ${error}`); + } + } + break; + } + + case ChonkyActions.SortFilesByName.id: + setSort({ column: 'name', direction: sort.column === 'name' && sort.direction === 'asc' ? 'desc' : 'asc' }); + break; + + case ChonkyActions.SortFilesBySize.id: + setSort({ column: 'size', direction: sort.column === 'size' && sort.direction === 'asc' ? 'desc' : 'asc' }); + break; + + case ChonkyActions.SortFilesByDate.id: + setSort({ column: 'modified', direction: sort.column === 'modified' && sort.direction === 'asc' ? 'desc' : 'asc' }); + break; + } + }, + [ + currentPath, + selectedBucket, + clipboard, + settings, + sort, + navigate, + setClipboard, + clearClipboard, + addOperation, + setSort, + ] + ); + + // Define available file actions + const fileActions: FileAction[] = useMemo( + () => [ + ChonkyActions.DeleteFiles, + ChonkyActions.DownloadFiles, + ChonkyActions.CopyFiles, + ChonkyActions.CutFiles, + ChonkyActions.PasteFiles, + ChonkyActions.SelectAllFiles, + ChonkyActions.ClearSelection, + ChonkyActions.SortFilesByName, + ChonkyActions.SortFilesBySize, + ChonkyActions.SortFilesByDate, + ChonkyActions.ToggleHiddenFiles, + ChonkyActions.EnableListView, + ChonkyActions.EnableGridView, + RefreshAction, + UploadFilesAction, + UploadFolderAction, + NewFolderAction, + CopyS3UriAction, + PropertiesAction, + ], + [] + ); + + if (!selectedBucket) { return ( -
- - - - - -
+
+

Select a bucket to browse

+
); + } + + const s3Objects: S3Object[] = selectedFiles.map((f) => ({ + key: f.id, + type: f.isDir ? 'directory' : 'file', + size: f.size, + storage_class: (f as FileData & { storageClass?: string }).storageClass, + last_modified: f.modDate?.toISOString(), + })); + + return ( +
+
+ + + + + + +
+ + +
+ ); }; -export default S3Browser; \ No newline at end of file +export default S3Browser; diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 0000000..b62cb79 --- /dev/null +++ b/src/components/SearchBar.tsx @@ -0,0 +1,71 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useAppStore } from '../stores/appStore'; + +export const SearchBar: React.FC = () => { + const { searchQuery, setSearchQuery } = useAppStore(); + const [localQuery, setLocalQuery] = useState(searchQuery); + const inputRef = useRef(null); + const debounceRef = useRef(); + + useEffect(() => { + // Keyboard shortcut Ctrl+F for search + const handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key === 'f') { + e.preventDefault(); + inputRef.current?.focus(); + inputRef.current?.select(); + } + // Escape to clear search + if (e.key === 'Escape' && document.activeElement === inputRef.current) { + setLocalQuery(''); + setSearchQuery(''); + inputRef.current?.blur(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [setSearchQuery]); + + useEffect(() => { + // Debounce search query updates + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + setSearchQuery(localQuery); + }, 200); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, [localQuery, setSearchQuery]); + + const handleClear = () => { + setLocalQuery(''); + setSearchQuery(''); + }; + + return ( +
+ setLocalQuery(e.target.value)} + placeholder="Search files... (Ctrl+F, supports * and ? wildcards)" + className="search-input" + /> + {localQuery && ( + + )} +
+ ); +}; + +export default SearchBar; diff --git a/src/components/SecretConfig.tsx b/src/components/SecretConfig.tsx deleted file mode 100644 index cb37eb7..0000000 --- a/src/components/SecretConfig.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Secret } from '../types'; - -type SecretModalProps = { - setVisible: (visible: boolean) => void; -}; - -const SecretModal: React.FC = ({setVisible}) => { - const [secrets, setSecrets] = useState([]); - const [newSecret, setNewSecret] = useState({ - nickname: "", - accessKeyId: "", - secretAccessKey: "", - }); - - useEffect(() => { - // Fetch secrets from localStorage when the component mounts - const storedSecrets = JSON.parse(localStorage.getItem('secrets') || '[]'); - setSecrets(storedSecrets); - }, []); - - useEffect(() => { - // add secret to localStorage when secret is added - localStorage.setItem('secrets', JSON.stringify(secrets)); - }, [secrets]); - - const handleInputChange = (e: React.ChangeEvent) => { - setNewSecret({ - ...newSecret, - [e.target.name]: e.target.value, - }); - }; - - const handleDeleteSecret = (accessKeyId: string) => { - const updatedSecrets = secrets.filter( - (secret) => secret.accessKeyId !== accessKeyId - ); - setSecrets(updatedSecrets); - }; - - const handleAddSecret = () => { - if (newSecret.nickname && newSecret.accessKeyId && newSecret.secretAccessKey) { - setSecrets([...secrets, newSecret]); - setNewSecret({ nickname: "", accessKeyId: "", secretAccessKey: "" }); - } - }; - - return ( - - - {secrets.length > 0 && ( - - - ) - } - {secrets.map((secret: Secret) => ( - - - - - - ))} -
NicknameAccess key ID -
{secret.nickname} {secret.accessKeyId} - -
- - - - - -
- ); -}; - -type SecretConfigProps = { - updateSecret: (secret: Secret) => void; -}; - -const SecretConfig: React.FC = ({ updateSecret }) => { - // State for the selected secret - const [selectedSecret, setSelectedSecret] = useState(null); - const [modalVisible, setModalVisible] = useState(false); - - // Retrieve stored secrets from localStorage - const storedSecrets = JSON.parse(localStorage.getItem('secrets') || '[]'); - - // Handler for selecting a secret - const handleSelectSecret = (event: React.ChangeEvent) => { - const selectedSecret = storedSecrets.find((secret: Secret) => secret.accessKeyId === event.target.value); - setSelectedSecret(selectedSecret); - }; - - // Update the selected secret when it changes - useEffect(() => { - if (selectedSecret) { - updateSecret(selectedSecret); - } - }, [selectedSecret, updateSecret]); - - // TODO not handling if selected secret is deleted.... - return ( -
- - - setModalVisible(true)}> ⚙ - {modalVisible && - - } -
- ); -}; - -export default SecretConfig; diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx new file mode 100644 index 0000000..76dddfb --- /dev/null +++ b/src/components/SettingsDialog.tsx @@ -0,0 +1,273 @@ +import React, { useState, useEffect } from 'react'; +import { useAppStore } from '../stores/appStore'; +import { open as openDialog } from '@tauri-apps/api/dialog'; +import toast from 'react-hot-toast'; +import type { Settings } from '../types'; + +export const SettingsDialog: React.FC = () => { + const { + settings, + loadSettings, + updateSettings, + isSettingsOpen, + setSettingsOpen, + } = useAppStore(); + + const [localSettings, setLocalSettings] = useState(null); + const [activeTab, setActiveTab] = useState<'general' | 'advanced'>('general'); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (isSettingsOpen && !settings) { + loadSettings(); + } + }, [isSettingsOpen, settings, loadSettings]); + + useEffect(() => { + if (settings) { + setLocalSettings({ ...settings }); + } + }, [settings]); + + if (!isSettingsOpen) { + return null; + } + + const handleSave = async () => { + if (!localSettings) return; + + setSaving(true); + try { + await updateSettings(localSettings); + toast.success('Settings saved'); + setSettingsOpen(false); + } catch (error) { + toast.error(`Failed to save settings: ${error}`); + } finally { + setSaving(false); + } + }; + + const handleBrowseDownloadLocation = async () => { + const path = await openDialog({ + directory: true, + title: 'Select default download location', + }); + if (path && typeof path === 'string' && localSettings) { + setLocalSettings({ ...localSettings, download_location: path }); + } + }; + + const handleBrowseS5cmdPath = async () => { + const path = await openDialog({ + title: 'Select s5cmd binary', + filters: [{ name: 'Executable', extensions: ['exe', ''] }], + }); + if (path && typeof path === 'string' && localSettings) { + setLocalSettings({ ...localSettings, s5cmd_path: path }); + } + }; + + if (!localSettings) { + return ( + +

Loading settings...

+
+ ); + } + + return ( + +
+

Settings

+ +
+ +
+ + +
+ +
+ {activeTab === 'general' && ( +
+
+ +
+ + setLocalSettings({ + ...localSettings, + download_location: e.target.value, + }) + } + /> + +
+
+ +
+ + + setLocalSettings({ + ...localSettings, + concurrent_operations: parseInt(e.target.value) || 10, + }) + } + /> +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ )} + + {activeTab === 'advanced' && ( +
+
+ +
+ + setLocalSettings({ + ...localSettings, + s5cmd_path: e.target.value || undefined, + }) + } + /> + +
+ For custom s5cmd builds or non-standard locations +
+ +
+ + + setLocalSettings({ + ...localSettings, + max_retries: parseInt(e.target.value) || 3, + }) + } + /> +
+ +
+ + + setLocalSettings({ + ...localSettings, + timeout_seconds: parseInt(e.target.value) || 300, + }) + } + /> +
+ +
+ + +
+
+ )} +
+ +
+ + +
+
+ ); +}; + +export default SettingsDialog; diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx new file mode 100644 index 0000000..6f6ed5a --- /dev/null +++ b/src/components/StatusBar.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { useAppStore } from '../stores/appStore'; +import { formatBytes } from '../api'; +import type { S3Object } from '../types'; + +interface StatusBarProps { + files: S3Object[]; + selectedFiles: S3Object[]; +} + +export const StatusBar: React.FC = ({ files, selectedFiles }) => { + const { activeProfile, selectedBucket, activeOperations } = useAppStore(); + + const selectedSize = selectedFiles.reduce((sum, f) => sum + (f.size || 0), 0); + const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0); + + const runningOperations = activeOperations.filter( + (op) => op.status === 'Running' + ); + + return ( +
+
+ {activeProfile && ( + + + {activeProfile.name} ({activeProfile.region}) + {selectedBucket && ` - ${selectedBucket.name}`} + + )} + {!activeProfile && ( + + + Not connected + + )} +
+ +
+ {runningOperations.length > 0 && ( + + {runningOperations.length} operation(s) in progress + + )} +
+ +
+ {selectedFiles.length > 0 && ( + + {selectedFiles.length} selected ({formatBytes(selectedSize)}) + + )} + + {files.length} items ({formatBytes(totalSize)}) + +
+
+ ); +}; + +export default StatusBar; diff --git a/src/main.tsx b/src/main.tsx index 8ae02a7..de9586b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,50 +1,230 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { createRoot } from 'react-dom/client'; import { setChonkyDefaults } from '@aperturerobotics/chonky'; import { ChonkyIconFA } from '@aperturerobotics/chonky-icon-fontawesome'; -import S3Browser from './components/S3Browser'; -import BucketConfig from './components/BucketConfig'; -import SecretConfig from './components/SecretConfig'; -import {Bucket, Secret} from './types'; -import "./styles.css"; +import { Toaster } from 'react-hot-toast'; +import { useAppStore } from './stores/appStore'; +import { ProfileSelector } from './components/ProfileSelector'; +import { BucketSelector } from './components/BucketSelector'; +import { SearchBar } from './components/SearchBar'; +import { S3Browser } from './components/S3Browser'; +import { ProgressDialog } from './components/ProgressDialog'; +import { SettingsDialog } from './components/SettingsDialog'; +import { LogsDialog } from './components/LogsDialog'; -// We set Chonky defaults for FontAwesome icon pack. -setChonkyDefaults({ iconComponent: ChonkyIconFA }); +import './styles.css'; +// Set Chonky defaults +setChonkyDefaults({ iconComponent: ChonkyIconFA }); const App: React.FC = () => { - const [selectedBucket, setSelectedBucket] = useState(null); - const [selectedSecret, setSelectedSecret] = useState(null); - - const updateBucket = (bucket: Bucket) => { - setSelectedBucket(bucket); - } + const { + activeProfile, + selectedBucket, + loadProfiles, + loadSettings, + goBack, + goForward, + goUp, + historyIndex, + navigationHistory, + currentPath, + setSettingsOpen, + setLogsDialogOpen, + error, + clearError, + } = useAppStore(); - const updateSecret = (secret: Secret) => { - setSelectedSecret(secret); - } + useEffect(() => { + loadProfiles(); + loadSettings(); + }, [loadProfiles, loadSettings]); + // Keyboard shortcuts useEffect(() => { - // Refresh the bucket and file browser when a new secret is selected - if(selectedSecret) { - // Code to refresh the bucket and file browser goes here - } - }, [selectedSecret]); + const handleKeyDown = (e: KeyboardEvent) => { + // Backspace to go up (when not in an input) + if ( + e.key === 'Backspace' && + !(e.target instanceof HTMLInputElement) && + !(e.target instanceof HTMLTextAreaElement) + ) { + e.preventDefault(); + goUp(); + } + + // Alt+Left for back + if (e.altKey && e.key === 'ArrowLeft') { + e.preventDefault(); + goBack(); + } + + // Alt+Right for forward + if (e.altKey && e.key === 'ArrowRight') { + e.preventDefault(); + goForward(); + } + + // Ctrl+, for settings + if (e.ctrlKey && e.key === ',') { + e.preventDefault(); + setSettingsOpen(true); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [goBack, goForward, goUp, setSettingsOpen]); + + const canGoBack = historyIndex > 0; + const canGoForward = historyIndex < navigationHistory.length - 1; + const canGoUp = + selectedBucket && currentPath !== `s3://${selectedBucket.name}/`; return ( -
-
- - -
- {selectedBucket && selectedSecret && ( - +
+ {/* Header */} +
+
+ + +
+ +
+
+ + + +
+ +
+ + {currentPath || 'No location selected'} + +
+
+ +
+ + + + + +
+
+ + {/* Error banner */} + {error && ( +
+ {error} + +
)} + + {/* Main content */} +
+ {activeProfile && selectedBucket ? ( + + ) : ( +
+

S5cmd GUI

+

Fast S3 file browser powered by s5cmd

+ + {!activeProfile ? ( +
+

Get started by selecting or creating a profile.

+

+ Click the profile selector in the top left corner to add your + AWS credentials or configure SSO. +

+
+ ) : ( +
+

Select a bucket from the dropdown to start browsing.

+
+ )} + +
+

Features

+
    +
  • Blazing fast transfers with s5cmd
  • +
  • AWS SSO support
  • +
  • Secure credential storage
  • +
  • S3-compatible endpoints (MinIO, etc.)
  • +
  • Drag and drop uploads
  • +
  • Batch operations
  • +
+
+
+ )} +
+ + {/* Dialogs */} + + + + + {/* Toast notifications */} +
); }; const container = document.getElementById('root'); -const root = createRoot(container!); -root.render(); +const root = createRoot(container!); +root.render(); diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts new file mode 100644 index 0000000..e54a854 --- /dev/null +++ b/src/stores/appStore.ts @@ -0,0 +1,297 @@ +import { create } from 'zustand'; +import type { + Profile, + S3Bucket, + Settings, + ClipboardState, + SortState, + NavigationEntry, + OperationProgress, +} from '../types'; +import * as api from '../api'; + +interface AppState { + // Profile state + profiles: Profile[]; + activeProfile: Profile | null; + profilesLoading: boolean; + + // Bucket state + buckets: S3Bucket[]; + selectedBucket: S3Bucket | null; + bucketsLoading: boolean; + + // Navigation state + currentPath: string; + navigationHistory: NavigationEntry[]; + historyIndex: number; + + // Clipboard state + clipboard: ClipboardState; + + // Sort state + sort: SortState; + + // Filter state + searchQuery: string; + + // Settings state + settings: Settings | null; + + // Operations state + activeOperations: OperationProgress[]; + + // UI state + isSettingsOpen: boolean; + isProfileDialogOpen: boolean; + isLogsDialogOpen: boolean; + + // Error state + error: string | null; + + // Actions + loadProfiles: () => Promise; + selectProfile: (profileId: string) => Promise; + loadBuckets: () => Promise; + selectBucket: (bucket: S3Bucket) => void; + navigate: (path: string) => void; + goBack: () => void; + goForward: () => void; + goUp: () => void; + setClipboard: (clipboard: ClipboardState) => void; + clearClipboard: () => void; + setSort: (sort: SortState) => void; + setSearchQuery: (query: string) => void; + loadSettings: () => Promise; + updateSettings: (settings: Settings) => Promise; + addOperation: (operation: OperationProgress) => void; + updateOperation: (operation: OperationProgress) => void; + removeOperation: (operationId: string) => void; + setSettingsOpen: (open: boolean) => void; + setProfileDialogOpen: (open: boolean) => void; + setLogsDialogOpen: (open: boolean) => void; + setError: (error: string | null) => void; + clearError: () => void; +} + +export const useAppStore = create((set, get) => ({ + // Initial state + profiles: [], + activeProfile: null, + profilesLoading: false, + buckets: [], + selectedBucket: null, + bucketsLoading: false, + currentPath: '', + navigationHistory: [], + historyIndex: -1, + clipboard: { items: [], operation: null }, + sort: { column: 'name', direction: 'asc' }, + searchQuery: '', + settings: null, + activeOperations: [], + isSettingsOpen: false, + isProfileDialogOpen: false, + isLogsDialogOpen: false, + error: null, + + // Actions + loadProfiles: async () => { + set({ profilesLoading: true }); + try { + const profiles = await api.getProfiles(); + const activeProfile = profiles.find((p) => p.is_active) || null; + set({ profiles, activeProfile, profilesLoading: false }); + + // If we have an active profile, load buckets + if (activeProfile) { + get().loadBuckets(); + } + } catch (error) { + set({ + profilesLoading: false, + error: `Failed to load profiles: ${error}`, + }); + } + }, + + selectProfile: async (profileId: string) => { + try { + await api.setActiveProfile(profileId); + const profiles = await api.getProfiles(); + const activeProfile = profiles.find((p) => p.id === profileId) || null; + set({ + profiles, + activeProfile, + selectedBucket: null, + currentPath: '', + navigationHistory: [], + historyIndex: -1, + }); + + // Load buckets for new profile + if (activeProfile) { + get().loadBuckets(); + } + } catch (error) { + set({ error: `Failed to select profile: ${error}` }); + } + }, + + loadBuckets: async () => { + set({ bucketsLoading: true }); + try { + const buckets = await api.listBuckets(); + set({ buckets, bucketsLoading: false }); + } catch (error) { + set({ + buckets: [], + bucketsLoading: false, + error: `Failed to load buckets: ${error}`, + }); + } + }, + + selectBucket: (bucket: S3Bucket) => { + const path = `s3://${bucket.name}/`; + set({ + selectedBucket: bucket, + currentPath: path, + navigationHistory: [{ bucket: bucket.name, path }], + historyIndex: 0, + }); + }, + + navigate: (path: string) => { + const { navigationHistory, historyIndex } = get(); + + // Parse the bucket from path + const match = path.match(/^s3:\/\/([^/]+)/); + const bucket = match ? match[1] : ''; + + // Trim history after current index and add new entry + const newHistory = navigationHistory.slice(0, historyIndex + 1); + newHistory.push({ bucket, path }); + + set({ + currentPath: path, + navigationHistory: newHistory, + historyIndex: newHistory.length - 1, + }); + }, + + goBack: () => { + const { navigationHistory, historyIndex } = get(); + if (historyIndex > 0) { + const newIndex = historyIndex - 1; + const entry = navigationHistory[newIndex]; + set({ + currentPath: entry.path, + historyIndex: newIndex, + }); + } + }, + + goForward: () => { + const { navigationHistory, historyIndex } = get(); + if (historyIndex < navigationHistory.length - 1) { + const newIndex = historyIndex + 1; + const entry = navigationHistory[newIndex]; + set({ + currentPath: entry.path, + historyIndex: newIndex, + }); + } + }, + + goUp: () => { + const { currentPath, selectedBucket } = get(); + if (!selectedBucket) return; + + const bucketPrefix = `s3://${selectedBucket.name}/`; + if (currentPath === bucketPrefix) return; + + // Remove last path segment + const pathWithoutTrailingSlash = currentPath.replace(/\/$/, ''); + const lastSlash = pathWithoutTrailingSlash.lastIndexOf('/'); + const parentPath = pathWithoutTrailingSlash.substring(0, lastSlash + 1); + + get().navigate(parentPath || bucketPrefix); + }, + + setClipboard: (clipboard: ClipboardState) => { + set({ clipboard }); + }, + + clearClipboard: () => { + set({ clipboard: { items: [], operation: null } }); + }, + + setSort: (sort: SortState) => { + set({ sort }); + }, + + setSearchQuery: (query: string) => { + set({ searchQuery: query }); + }, + + loadSettings: async () => { + try { + const settings = await api.getSettings(); + set({ settings }); + } catch (error) { + set({ error: `Failed to load settings: ${error}` }); + } + }, + + updateSettings: async (settings: Settings) => { + try { + await api.saveSettings(settings); + set({ settings }); + } catch (error) { + set({ error: `Failed to save settings: ${error}` }); + } + }, + + addOperation: (operation: OperationProgress) => { + set((state) => ({ + activeOperations: [...state.activeOperations, operation], + })); + }, + + updateOperation: (operation: OperationProgress) => { + set((state) => ({ + activeOperations: state.activeOperations.map((op) => + op.id === operation.id ? operation : op + ), + })); + }, + + removeOperation: (operationId: string) => { + set((state) => ({ + activeOperations: state.activeOperations.filter( + (op) => op.id !== operationId + ), + })); + }, + + setSettingsOpen: (open: boolean) => { + set({ isSettingsOpen: open }); + }, + + setProfileDialogOpen: (open: boolean) => { + set({ isProfileDialogOpen: open }); + }, + + setLogsDialogOpen: (open: boolean) => { + set({ isLogsDialogOpen: open }); + }, + + setError: (error: string | null) => { + set({ error }); + }, + + clearError: () => { + set({ error: null }); + }, +})); diff --git a/src/styles.css b/src/styles.css index b46d7a3..20ca8cc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,116 +1,852 @@ +/* Base styles */ :root { - font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + --primary-color: #2563eb; + --primary-hover: #1d4ed8; + --danger-color: #dc2626; + --success-color: #16a34a; + --warning-color: #ca8a04; + --background: #f8fafc; + --surface: #ffffff; + --border: #e2e8f0; + --text: #1e293b; + --text-muted: #64748b; + --header-height: 60px; + --status-bar-height: 28px; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--text); + background-color: var(--background); + overflow: hidden; +} + +/* App layout */ +.app { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +/* Header */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + height: var(--header-height); + padding: 0 16px; + background: var(--surface); + border-bottom: 1px solid var(--border); + gap: 16px; +} + +.header-left, +.header-right { + display: flex; + align-items: center; + gap: 12px; +} + +.header-center { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +/* Navigation buttons */ +.navigation-buttons { + display: flex; + gap: 4px; +} + +.nav-button { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + cursor: pointer; font-size: 16px; - line-height: 24px; - font-weight: 400; +} - color: #0f0f0f; - background-color: #f6f6f6; +.nav-button:hover:not(:disabled) { + background: var(--background); +} - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; +.nav-button:disabled { + opacity: 0.5; + cursor: not-allowed; } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; +/* Address bar */ +.address-bar { + flex: 1; + min-width: 0; + padding: 6px 12px; + background: var(--background); + border: 1px solid var(--border); + border-radius: 4px; + font-family: monospace; + font-size: 13px; } -a:hover { - color: #535bf2; +.address-text { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-muted); } -h1 { - text-align: center; +/* Profile selector */ +.profile-selector { + position: relative; } -input, -button { +.profile-button { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: var(--primary-color); + color: white; + border: none; border-radius: 4px; - border: 1px solid transparent; - padding: 0.2em; - font-size: 1em; - font-family: inherit; - color: #0f0f0f; - background-color: #ffffff; - transition: border-color 0.25s; - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); + cursor: pointer; + font-size: 14px; + font-weight: 500; } -button { +.profile-button:hover { + background: var(--primary-hover); +} + +.dropdown-arrow { + font-size: 10px; +} + +.profile-dropdown { + position: absolute; + top: 100%; + left: 0; + min-width: 250px; + margin-top: 4px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + overflow: hidden; +} + +.profile-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; cursor: pointer; } -button:hover { - border-color: #396cd8; +.profile-item:hover { + background: var(--background); } -button:active { - border-color: #396cd8; - background-color: #e8e8e8; + +.profile-item.active { + background: #dbeafe; } -input, -button { +.profile-item span { + display: flex; + flex-direction: column; + flex: 1; +} + +.profile-item small { + color: var(--text-muted); + font-size: 12px; +} + +.profile-actions { + display: flex; + gap: 4px; +} + +.profile-actions button { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + cursor: pointer; + border-radius: 4px; +} + +.profile-actions button:hover { + background: var(--border); +} + +.profile-item.add-profile { + color: var(--primary-color); + font-weight: 500; + border-top: 1px solid var(--border); +} + +.profile-shortcut { + padding: 8px 12px; + background: var(--background); + color: var(--text-muted); + border-top: 1px solid var(--border); +} + +/* Bucket selector */ +.bucket-selector { + display: flex; + align-items: center; + gap: 8px; +} + +.bucket-selector label { + color: var(--text-muted); + font-size: 13px; +} + +.bucket-selector select { + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + font-size: 14px; + min-width: 150px; +} + +/* Search bar */ +.search-bar { + position: relative; + display: flex; + align-items: center; +} + +.search-input { + width: 200px; + padding: 6px 28px 6px 10px; + border: 1px solid var(--border); + border-radius: 4px; + font-size: 13px; +} + +.search-input:focus { outline: none; + border-color: var(--primary-color); +} + +.search-clear { + position: absolute; + right: 6px; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + cursor: pointer; + color: var(--text-muted); + font-size: 16px; } -select { +/* Icon button */ +.icon-button { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + cursor: pointer; font-size: 14px; - border: 1px solid #007BFF; } -dialog { - font-size: 12px; - background: white; - border-radius: 8px; - max-width: 400px; - min-width: 400px; - padding: 15px; - position: fixed; - z-index: 1; +.icon-button:hover { + background: var(--background); +} + +/* Error banner */ +.error-banner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: #fef2f2; + border-bottom: 1px solid #fecaca; + color: var(--danger-color); +} + +.error-banner button { + background: transparent; + border: none; + font-size: 18px; + cursor: pointer; + color: var(--danger-color); +} + +/* Main content */ +.app-main { + flex: 1; + overflow: hidden; +} + +/* S3 Browser */ +.s3-browser { display: flex; flex-direction: column; - border: 1px solid; - left: 50%; /* center horizontally */ - top: 50%; - transform: translate(-50%, -50%); /* shift dialog back by half of its width and height */ - width: auto; /* adjust as needed */ + height: 100%; } -.secrets__table { - width: 100%; - border-collapse: separate; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); - border-radius: 8px; +.s3-browser-content { + flex: 1; overflow: hidden; - margin-bottom: 5px; } -.secrets__table th, .secrets__table td { - padding: 4x; - border-bottom: 1px solid #ddd; +.s3-browser-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); +} + +/* Welcome screen */ +.welcome-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 40px; + text-align: center; +} + +.welcome-screen h1 { + font-size: 32px; + margin-bottom: 8px; +} + +.welcome-screen > p { + color: var(--text-muted); + margin-bottom: 32px; +} + +.welcome-message { + max-width: 400px; + margin-bottom: 32px; +} + +.welcome-features { text-align: left; + background: var(--surface); + padding: 20px 30px; + border-radius: 8px; + border: 1px solid var(--border); +} + +.welcome-features h3 { + margin-bottom: 12px; +} + +.welcome-features ul { + list-style: none; +} + +.welcome-features li { + padding: 6px 0; + padding-left: 24px; + position: relative; +} + +.welcome-features li::before { + content: '✓'; + position: absolute; + left: 0; + color: var(--success-color); +} + +/* Status bar */ +.status-bar { + display: flex; + align-items: center; + justify-content: space-between; + height: var(--status-bar-height); + padding: 0 12px; + background: var(--surface); + border-top: 1px solid var(--border); + font-size: 12px; + color: var(--text-muted); +} + +.status-left, +.status-center, +.status-right { + display: flex; + align-items: center; + gap: 16px; +} + +.connection-status { + display: flex; + align-items: center; + gap: 6px; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.status-dot.connected { + background: var(--success-color); +} + +.status-dot.disconnected { + background: var(--text-muted); +} + +/* Progress dialog */ +.progress-dialog { + position: fixed; + bottom: 60px; + right: 20px; + width: 350px; + max-height: 400px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + overflow: hidden; + z-index: 100; +} + +.progress-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.progress-header h3 { + font-size: 14px; + font-weight: 600; +} + +.operation-count { + background: var(--primary-color); + color: white; + padding: 2px 8px; + border-radius: 10px; + font-size: 12px; +} + +.progress-list { + max-height: 340px; + overflow-y: auto; +} + +.progress-item { + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.progress-item:last-child { + border-bottom: none; +} + +.progress-item-header { + display: flex; + justify-content: space-between; + margin-bottom: 8px; +} + +.operation-type { + font-weight: 500; + text-transform: capitalize; +} + +.operation-status { + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; +} + +.operation-status.running { + background: #dbeafe; + color: var(--primary-color); +} + +.operation-status.completed { + background: #dcfce7; + color: var(--success-color); +} + +.operation-status.failed { + background: #fef2f2; + color: var(--danger-color); +} + +.operation-status.cancelled { + background: var(--background); + color: var(--text-muted); +} + +.progress-bar-container { + height: 4px; + background: var(--border); + border-radius: 2px; + overflow: hidden; + margin-bottom: 8px; +} + +.progress-bar { + height: 100%; + background: var(--primary-color); + transition: width 0.3s ease; +} + +.progress-info { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--text-muted); +} + +.current-file { + max-width: 150px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.progress-error { + margin-top: 8px; + padding: 8px; + background: #fef2f2; + border-radius: 4px; + font-size: 12px; + color: var(--danger-color); +} + +.progress-actions { + margin-top: 8px; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.progress-actions button { + padding: 4px 12px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + cursor: pointer; + font-size: 12px; +} + +.progress-actions button:hover { + background: var(--background); +} + +/* Dialogs */ +dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + background: var(--surface); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + max-height: 80vh; + overflow-y: auto; + z-index: 1000; +} + +dialog::backdrop { + background: rgba(0, 0, 0, 0.5); +} + +dialog h2 { + margin-bottom: 20px; +} + +/* Form groups */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 4px; + font-weight: 500; + font-size: 13px; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 4px; + font-size: 14px; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--primary-color); +} + +.form-group small { + display: block; + margin-top: 4px; + color: var(--text-muted); + font-size: 12px; +} + +.form-group.checkbox label { + display: flex; + align-items: center; + gap: 8px; + font-weight: normal; + cursor: pointer; +} + +.form-group.checkbox input { + width: auto; +} + +.input-with-button { + display: flex; + gap: 8px; +} + +.input-with-button input { + flex: 1; +} + +.input-with-button button { + padding: 8px 16px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + cursor: pointer; +} + +.input-with-button button:hover { + background: var(--background); +} + +/* Dialog actions */ +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.dialog-actions button { + padding: 8px 20px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + cursor: pointer; + font-size: 14px; +} + +.dialog-actions button:hover { + background: var(--background); } -.secrets__table th { - background-color: #f4f4f4; - border-bottom: 2px solid #ddd; +.dialog-actions button.primary { + background: var(--primary-color); + color: white; + border-color: var(--primary-color); } -.secrets__table tr:last-child td { - border-bottom: 1px solid #ddd; +.dialog-actions button.primary:hover { + background: var(--primary-hover); +} + +.dialog-actions button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Profile dialog */ +.profile-dialog { + width: 450px; +} + +/* Settings dialog */ +.settings-dialog { + width: 500px; +} + +.settings-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.settings-header h2 { + margin-bottom: 0; +} + +.close-button { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + font-size: 24px; + cursor: pointer; + color: var(--text-muted); +} + +.close-button:hover { + color: var(--text); +} + +.settings-tabs { + display: flex; + gap: 4px; + margin-bottom: 20px; } -.secrets__settings { - font-size: 22px; +.settings-tabs button { + padding: 8px 16px; + border: none; + background: transparent; cursor: pointer; - text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.324); + font-size: 14px; + border-bottom: 2px solid transparent; +} + +.settings-tabs button:hover { + background: var(--background); +} + +.settings-tabs button.active { + border-bottom-color: var(--primary-color); + color: var(--primary-color); +} + +/* Logs dialog */ +.logs-dialog { + width: 700px; + max-height: 600px; +} + +.log-path { + margin-bottom: 12px; + padding: 8px; + background: var(--background); + border-radius: 4px; + font-family: monospace; + font-size: 12px; +} + +.logs-content { + height: 350px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 4px; + background: #1e1e1e; + font-family: monospace; + font-size: 12px; +} + +.logs-loading, +.logs-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); +} + +.logs-list { + padding: 8px; +} + +.log-line { + padding: 2px 4px; + color: #d4d4d4; + white-space: pre-wrap; + word-break: break-all; } -#s3-browser { - height: 90vh; -} \ No newline at end of file +.log-line.error { + color: #f87171; +} + +.log-line.warning { + color: #fbbf24; +} + +.log-line.debug { + color: #60a5fa; +} + +/* Chonky overrides */ +.chonky-chonkyRoot { + height: calc(100vh - var(--header-height) - var(--status-bar-height)) !important; + border: none !important; +} + +/* Utilities */ +button { + font-family: inherit; +} + +/* Scrollbar styles */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--background); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} diff --git a/src/types.ts b/src/types.ts index fe1024c..e5be161 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,19 +1,121 @@ +// Profile types +export interface Profile { + id: string; + name: string; + auth_type: 'access_key' | 'sso'; + endpoint_url?: string; + region: string; + is_active: boolean; + // SSO fields + sso_start_url?: string; + sso_region?: string; + sso_account_id?: string; + sso_role_name?: string; + // Access Key fields + access_key_id?: string; + has_session_token: boolean; +} -export type Bucket = { +export interface SaveProfileRequest { + id?: string; name: string; -}; + auth_type: 'access_key' | 'sso'; + region: string; + endpoint_url?: string; + // Access Key fields + access_key_id?: string; + secret_access_key?: string; + session_token?: string; + // SSO fields + sso_start_url?: string; + sso_region?: string; + sso_account_id?: string; + sso_role_name?: string; +} + +// S3 types +export interface S3Object { + key: string; + type: string; + size?: number; + storage_class?: string; + etag?: string; + last_modified?: string; +} + +export interface S3Bucket { + name: string; + creation_date?: string; +} + +// Operation types +export interface OperationProgress { + id: string; + operation_type: string; + total_files: number; + completed_files: number; + total_bytes: number; + transferred_bytes: number; + current_file?: string; + status: 'Running' | 'Paused' | 'Completed' | 'Failed' | 'Cancelled'; + error?: string; + started_at: string; +} + +export interface DownloadSource { + path: string; + is_dir: boolean; +} -export type Secret = { - nickname: string; - accessKeyId: string; - secretAccessKey: string; -}; +// Settings types +export interface Settings { + // General + download_location: string; + concurrent_operations: number; + show_hidden_files: boolean; + confirm_before_delete: boolean; + single_click_to_open: boolean; + // Advanced + s5cmd_path?: string; + max_retries: number; + timeout_seconds: number; + log_level: 'Error' | 'Warning' | 'Info' | 'Debug'; +} -export type ListResults = { +// Object metadata +export interface ObjectMetadata { key: string; - type: string - size: number; - storage_class: string; - etag: string; - last_modified: string -}[]; \ No newline at end of file + size?: number; + last_modified?: string; + storage_class?: string; + etag?: string; + content_type?: string; +} + +// Clipboard for copy/cut operations +export interface ClipboardItem { + path: string; + name: string; + isDir: boolean; +} + +export interface ClipboardState { + items: ClipboardItem[]; + operation: 'copy' | 'cut' | null; + sourceBucket?: string; +} + +// Sort state +export type SortColumn = 'name' | 'size' | 'modified' | 'storage_class'; +export type SortDirection = 'asc' | 'desc'; + +export interface SortState { + column: SortColumn; + direction: SortDirection; +} + +// Navigation history +export interface NavigationEntry { + bucket: string; + path: string; +} diff --git a/src/utils.ts b/src/utils.ts index ff4752e..8bcdc5a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,138 @@ +// JSON parsing utilities for s5cmd output export function fixJsonData(rawJson: string): any { - const fixedJsonData = '[' + rawJson.replace(/}\s*{/g, '},{') + ']'; - return JSON.parse(fixedJsonData); -} \ No newline at end of file + if (!rawJson || rawJson.trim() === '') { + return []; + } + + // s5cmd outputs JSON lines, one per object + // We need to parse each line separately + const lines = rawJson + .split('\n') + .map((line) => line.trim()) + .filter((line) => line !== ''); + + const results: any[] = []; + + for (const line of lines) { + try { + results.push(JSON.parse(line)); + } catch (e) { + console.warn('Failed to parse JSON line:', line, e); + } + } + + return results; +} + +// Format a file size in bytes to a human-readable string +export function formatFileSize(bytes: number | undefined): string { + if (bytes === undefined || bytes === null) return '-'; + if (bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +// Format a date to a relative time string +export function formatRelativeTime(date: Date | string | undefined): string { + if (!date) return '-'; + + const d = typeof date === 'string' ? new Date(date) : date; + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSecs < 60) return 'just now'; + if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; + if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; + + return d.toLocaleDateString(); +} + +// Validate S3 path format +export function isValidS3Path(path: string): boolean { + return /^s3:\/\/[a-z0-9][a-z0-9.-]*[a-z0-9](\/.*)?$/.test(path); +} + +// Extract bucket name from S3 path +export function extractBucketFromPath(path: string): string | null { + const match = path.match(/^s3:\/\/([^/]+)/); + return match ? match[1] : null; +} + +// Extract prefix from S3 path +export function extractPrefixFromPath(path: string): string { + const match = path.match(/^s3:\/\/[^/]+\/(.*)$/); + return match ? match[1] : ''; +} + +// Join S3 path segments +export function joinS3Path(...segments: string[]): string { + return segments + .map((s) => s.replace(/^\/+|\/+$/g, '')) + .filter(Boolean) + .join('/'); +} + +// Get parent path +export function getParentPath(path: string): string { + const withoutTrailing = path.replace(/\/$/, ''); + const lastSlash = withoutTrailing.lastIndexOf('/'); + return lastSlash > 0 ? withoutTrailing.substring(0, lastSlash + 1) : path; +} + +// Check if path is a directory (ends with /) +export function isDirectory(path: string): boolean { + return path.endsWith('/'); +} + +// Get file extension +export function getFileExtension(filename: string): string { + const lastDot = filename.lastIndexOf('.'); + if (lastDot === -1 || lastDot === 0) return ''; + return filename.substring(lastDot + 1).toLowerCase(); +} + +// Debounce function +export function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeoutId: ReturnType | null = null; + + return function (this: any, ...args: Parameters) { + if (timeoutId) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + func.apply(this, args); + }, wait); + }; +} + +// Throttle function +export function throttle any>( + func: T, + limit: number +): (...args: Parameters) => void { + let inThrottle = false; + + return function (this: any, ...args: Parameters) { + if (!inThrottle) { + func.apply(this, args); + inThrottle = true; + setTimeout(() => { + inThrottle = false; + }, limit); + } + }; +} diff --git a/tests/app.spec.ts b/tests/app.spec.ts new file mode 100644 index 0000000..3ec16ef --- /dev/null +++ b/tests/app.spec.ts @@ -0,0 +1,252 @@ +import { test, expect } from '@playwright/test'; + +test.describe('S5cmd GUI Application', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('should display welcome screen on initial load', async ({ page }) => { + await expect(page.locator('h1')).toContainText('S5cmd GUI'); + await expect(page.locator('.welcome-message')).toBeVisible(); + }); + + test('should have profile selector in header', async ({ page }) => { + const profileButton = page.locator('.profile-button'); + await expect(profileButton).toBeVisible(); + await expect(profileButton).toContainText('Select Profile'); + }); + + test('should open profile dropdown when clicked', async ({ page }) => { + const profileButton = page.locator('.profile-button'); + await profileButton.click(); + + const dropdown = page.locator('.profile-dropdown'); + await expect(dropdown).toBeVisible(); + + // Should have "Add Profile" option + await expect(page.locator('.add-profile')).toContainText('Add Profile'); + }); + + test('should open profile dialog when Add Profile is clicked', async ({ page }) => { + await page.locator('.profile-button').click(); + await page.locator('.add-profile').click(); + + const dialog = page.locator('.profile-dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('h2')).toContainText('Add Profile'); + }); + + test('should show form fields for Access Key authentication', async ({ page }) => { + await page.locator('.profile-button').click(); + await page.locator('.add-profile').click(); + + // Check for Access Key fields + await expect(page.locator('#access-key-id')).toBeVisible(); + await expect(page.locator('#secret-access-key')).toBeVisible(); + await expect(page.locator('#session-token')).toBeVisible(); + }); + + test('should switch to SSO fields when SSO is selected', async ({ page }) => { + await page.locator('.profile-button').click(); + await page.locator('.add-profile').click(); + + // Select SSO auth type + await page.locator('#auth-type').selectOption('sso'); + + // Check for SSO fields + await expect(page.locator('#sso-start-url')).toBeVisible(); + await expect(page.locator('#sso-region')).toBeVisible(); + await expect(page.locator('#sso-account-id')).toBeVisible(); + await expect(page.locator('#sso-role-name')).toBeVisible(); + + // Access key fields should not be visible + await expect(page.locator('#access-key-id')).not.toBeVisible(); + }); + + test('should have settings button in header', async ({ page }) => { + const settingsButton = page.locator('.icon-button[title="Settings (Ctrl+,)"]'); + await expect(settingsButton).toBeVisible(); + }); + + test('should open settings dialog when settings button is clicked', async ({ page }) => { + await page.locator('.icon-button[title="Settings (Ctrl+,)"]').click(); + + const dialog = page.locator('.settings-dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('h2')).toContainText('Settings'); + }); + + test('should have General and Advanced tabs in settings', async ({ page }) => { + await page.locator('.icon-button[title="Settings (Ctrl+,)"]').click(); + + const generalTab = page.locator('.settings-tabs button:has-text("General")'); + const advancedTab = page.locator('.settings-tabs button:has-text("Advanced")'); + + await expect(generalTab).toBeVisible(); + await expect(advancedTab).toBeVisible(); + }); + + test('should switch between settings tabs', async ({ page }) => { + await page.locator('.icon-button[title="Settings (Ctrl+,)"]').click(); + + // Click Advanced tab + await page.locator('.settings-tabs button:has-text("Advanced")').click(); + + // Should show s5cmd path field + await expect(page.locator('label:has-text("s5cmd Binary Path")')).toBeVisible(); + }); + + test('should have logs button in header', async ({ page }) => { + const logsButton = page.locator('.icon-button[title="View Logs"]'); + await expect(logsButton).toBeVisible(); + }); + + test('should open logs dialog when logs button is clicked', async ({ page }) => { + await page.locator('.icon-button[title="View Logs"]').click(); + + const dialog = page.locator('.logs-dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('h2')).toContainText('Application Logs'); + }); + + test('should have navigation buttons', async ({ page }) => { + await expect(page.locator('.nav-button[title="Back (Alt+Left)"]')).toBeVisible(); + await expect(page.locator('.nav-button[title="Forward (Alt+Right)"]')).toBeVisible(); + await expect(page.locator('.nav-button[title="Up (Backspace)"]')).toBeVisible(); + }); + + test('navigation buttons should be disabled when no bucket selected', async ({ page }) => { + await expect(page.locator('.nav-button[title="Back (Alt+Left)"]')).toBeDisabled(); + await expect(page.locator('.nav-button[title="Forward (Alt+Right)"]')).toBeDisabled(); + await expect(page.locator('.nav-button[title="Up (Backspace)"]')).toBeDisabled(); + }); + + test('should have search input', async ({ page }) => { + const searchInput = page.locator('.search-input'); + await expect(searchInput).toBeVisible(); + await expect(searchInput).toHaveAttribute('placeholder', /Search files/); + }); + + test('should have address bar showing no location', async ({ page }) => { + const addressBar = page.locator('.address-text'); + await expect(addressBar).toContainText('No location selected'); + }); + + test('should have bucket selector disabled without profile', async ({ page }) => { + const bucketSelect = page.locator('#bucket-select'); + await expect(bucketSelect).toBeDisabled(); + }); + + test('should show welcome features list', async ({ page }) => { + const features = page.locator('.welcome-features li'); + await expect(features).toHaveCount(6); + await expect(page.locator('.welcome-features')).toContainText('Blazing fast transfers'); + await expect(page.locator('.welcome-features')).toContainText('AWS SSO support'); + }); + + test('keyboard shortcut Ctrl+P should toggle profile dropdown', async ({ page }) => { + // Profile dropdown should not be visible initially + await expect(page.locator('.profile-dropdown')).not.toBeVisible(); + + // Press Ctrl+P + await page.keyboard.press('Control+p'); + + // Dropdown should appear + await expect(page.locator('.profile-dropdown')).toBeVisible(); + + // Press Ctrl+P again + await page.keyboard.press('Control+p'); + + // Dropdown should be hidden + await expect(page.locator('.profile-dropdown')).not.toBeVisible(); + }); + + test('keyboard shortcut Ctrl+, should open settings', async ({ page }) => { + await page.keyboard.press('Control+,'); + await expect(page.locator('.settings-dialog')).toBeVisible(); + }); +}); + +test.describe('Profile Management', () => { + test('should validate required fields when saving profile', async ({ page }) => { + await page.goto('/'); + await page.locator('.profile-button').click(); + await page.locator('.add-profile').click(); + + // Try to save without filling required fields + await page.locator('button.primary:has-text("Save")').click(); + + // Should show error (via toast) + // Note: In actual implementation, you might need to check for toast notification + }); + + test('should close dialog when Cancel is clicked', async ({ page }) => { + await page.goto('/'); + await page.locator('.profile-button').click(); + await page.locator('.add-profile').click(); + + await expect(page.locator('.profile-dialog')).toBeVisible(); + + await page.locator('button:has-text("Cancel")').click(); + + await expect(page.locator('.profile-dialog')).not.toBeVisible(); + }); +}); + +test.describe('Settings Dialog', () => { + test('should have all general settings fields', async ({ page }) => { + await page.goto('/'); + await page.locator('.icon-button[title="Settings (Ctrl+,)"]').click(); + + await expect(page.locator('label:has-text("Default Download Location")')).toBeVisible(); + await expect(page.locator('label:has-text("Concurrent Operations")')).toBeVisible(); + await expect(page.locator('label:has-text("Show hidden files")')).toBeVisible(); + await expect(page.locator('label:has-text("Confirm before delete")')).toBeVisible(); + await expect(page.locator('label:has-text("Single click to open")')).toBeVisible(); + }); + + test('should have all advanced settings fields', async ({ page }) => { + await page.goto('/'); + await page.locator('.icon-button[title="Settings (Ctrl+,)"]').click(); + await page.locator('.settings-tabs button:has-text("Advanced")').click(); + + await expect(page.locator('label:has-text("s5cmd Binary Path")')).toBeVisible(); + await expect(page.locator('label:has-text("Max Retries")')).toBeVisible(); + await expect(page.locator('label:has-text("Timeout")')).toBeVisible(); + await expect(page.locator('label:has-text("Log Level")')).toBeVisible(); + }); + + test('should close settings when close button is clicked', async ({ page }) => { + await page.goto('/'); + await page.locator('.icon-button[title="Settings (Ctrl+,)"]').click(); + + await expect(page.locator('.settings-dialog')).toBeVisible(); + + await page.locator('.settings-dialog .close-button').click(); + + await expect(page.locator('.settings-dialog')).not.toBeVisible(); + }); +}); + +test.describe('Logs Dialog', () => { + test('should have action buttons', async ({ page }) => { + await page.goto('/'); + await page.locator('.icon-button[title="View Logs"]').click(); + + await expect(page.locator('button:has-text("Clear Logs")')).toBeVisible(); + await expect(page.locator('button:has-text("Export Logs")')).toBeVisible(); + await expect(page.locator('button:has-text("Refresh")')).toBeVisible(); + await expect(page.locator('button:has-text("Close")')).toBeVisible(); + }); + + test('should close logs dialog when Close is clicked', async ({ page }) => { + await page.goto('/'); + await page.locator('.icon-button[title="View Logs"]').click(); + + await expect(page.locator('.logs-dialog')).toBeVisible(); + + await page.locator('.logs-dialog button:has-text("Close")').click(); + + await expect(page.locator('.logs-dialog')).not.toBeVisible(); + }); +}); diff --git a/tests/minio.spec.ts b/tests/minio.spec.ts new file mode 100644 index 0000000..837e798 --- /dev/null +++ b/tests/minio.spec.ts @@ -0,0 +1,196 @@ +import { test, expect } from '@playwright/test'; + +/** + * These tests require Minio to be running locally. + * Start Minio with: docker run -p 9000:9000 -p 9001:9001 minio/minio server /data --console-address ":9001" + * Default credentials: minioadmin/minioadmin + * + * Or run via the GitHub Actions workflow which sets up Minio automatically. + */ + +const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'http://localhost:9000'; +const MINIO_ACCESS_KEY = process.env.AWS_ACCESS_KEY_ID || 'minioadmin'; +const MINIO_SECRET_KEY = process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin'; + +test.describe('Minio Integration Tests', () => { + test.skip(({ }, testInfo) => { + // Skip if not running in CI or MINIO_ENDPOINT is not set + return !process.env.CI && !process.env.MINIO_ENDPOINT; + }, 'Skipping Minio tests - Minio not configured'); + + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('should be able to create a profile with Minio endpoint', async ({ page }) => { + // Open profile dialog + await page.locator('.profile-button').click(); + await page.locator('.add-profile').click(); + + // Fill in profile details + await page.locator('#profile-name').fill('Minio Test'); + await page.locator('#access-key-id').fill(MINIO_ACCESS_KEY); + await page.locator('#secret-access-key').fill(MINIO_SECRET_KEY); + await page.locator('#region').selectOption('us-east-1'); + await page.locator('#endpoint-url').fill(MINIO_ENDPOINT); + + // Save profile + await page.locator('button.primary:has-text("Save")').click(); + + // Wait for dialog to close + await expect(page.locator('.profile-dialog')).not.toBeVisible({ timeout: 5000 }); + + // Profile should now be in the dropdown + await page.locator('.profile-button').click(); + await expect(page.locator('.profile-dropdown')).toContainText('Minio Test'); + }); + + test('should be able to connect and list buckets', async ({ page }) => { + // Create and select a profile + await page.locator('.profile-button').click(); + await page.locator('.add-profile').click(); + + await page.locator('#profile-name').fill('Minio List Test'); + await page.locator('#access-key-id').fill(MINIO_ACCESS_KEY); + await page.locator('#secret-access-key').fill(MINIO_SECRET_KEY); + await page.locator('#region').selectOption('us-east-1'); + await page.locator('#endpoint-url').fill(MINIO_ENDPOINT); + + await page.locator('button.primary:has-text("Save")').click(); + await expect(page.locator('.profile-dialog')).not.toBeVisible({ timeout: 5000 }); + + // Select the profile + await page.locator('.profile-button').click(); + await page.locator('.profile-item:has-text("Minio List Test")').click(); + + // Wait for buckets to load + await page.waitForTimeout(2000); + + // Bucket selector should be enabled + const bucketSelect = page.locator('#bucket-select'); + await expect(bucketSelect).not.toBeDisabled(); + + // Should have at least one option (test-bucket created in CI) + const options = await bucketSelect.locator('option').count(); + expect(options).toBeGreaterThan(1); // Including the placeholder option + }); + + test('should be able to test connection', async ({ page }) => { + await page.locator('.profile-button').click(); + await page.locator('.add-profile').click(); + + await page.locator('#profile-name').fill('Minio Connection Test'); + await page.locator('#access-key-id').fill(MINIO_ACCESS_KEY); + await page.locator('#secret-access-key').fill(MINIO_SECRET_KEY); + await page.locator('#region').selectOption('us-east-1'); + await page.locator('#endpoint-url').fill(MINIO_ENDPOINT); + + // Click Test Connection + await page.locator('button:has-text("Test Connection")').click(); + + // Wait for toast notification + await page.waitForTimeout(5000); + + // Should see success message in toast + const toast = page.locator('[role="status"]'); + await expect(toast).toContainText(/Connection successful|bucket/i, { timeout: 10000 }); + }); + + test('should be able to navigate into a bucket', async ({ page }) => { + // First, set up the profile + await page.locator('.profile-button').click(); + await page.locator('.add-profile').click(); + + await page.locator('#profile-name').fill('Minio Nav Test'); + await page.locator('#access-key-id').fill(MINIO_ACCESS_KEY); + await page.locator('#secret-access-key').fill(MINIO_SECRET_KEY); + await page.locator('#region').selectOption('us-east-1'); + await page.locator('#endpoint-url').fill(MINIO_ENDPOINT); + + await page.locator('button.primary:has-text("Save")').click(); + await expect(page.locator('.profile-dialog')).not.toBeVisible({ timeout: 5000 }); + + // Select the profile + await page.locator('.profile-button').click(); + await page.locator('.profile-item:has-text("Minio Nav Test")').click(); + + // Wait for buckets to load + await page.waitForTimeout(2000); + + // Select the test bucket + await page.locator('#bucket-select').selectOption('test-bucket'); + + // Wait for the file browser to appear + await page.waitForTimeout(1000); + + // Address bar should show the bucket path + await expect(page.locator('.address-text')).toContainText('s3://test-bucket/'); + + // Status bar should show connection info + await expect(page.locator('.connection-status')).toContainText('Minio Nav Test'); + }); +}); + +test.describe('File Operations with Minio', () => { + test.skip(({ }, testInfo) => { + return !process.env.CI && !process.env.MINIO_ENDPOINT; + }, 'Skipping Minio tests - Minio not configured'); + + test('should show empty bucket message or files', async ({ page }) => { + await page.goto('/'); + + // Set up profile and navigate to bucket + await page.locator('.profile-button').click(); + await page.locator('.add-profile').click(); + + await page.locator('#profile-name').fill('Minio Files Test'); + await page.locator('#access-key-id').fill(MINIO_ACCESS_KEY); + await page.locator('#secret-access-key').fill(MINIO_SECRET_KEY); + await page.locator('#region').selectOption('us-east-1'); + await page.locator('#endpoint-url').fill(MINIO_ENDPOINT); + + await page.locator('button.primary:has-text("Save")').click(); + await expect(page.locator('.profile-dialog')).not.toBeVisible({ timeout: 5000 }); + + await page.locator('.profile-button').click(); + await page.locator('.profile-item:has-text("Minio Files Test")').click(); + + await page.waitForTimeout(2000); + await page.locator('#bucket-select').selectOption('test-bucket'); + await page.waitForTimeout(1000); + + // The file browser should be visible + await expect(page.locator('.chonky-chonkyRoot')).toBeVisible(); + + // Status bar should show item count + await expect(page.locator('.status-bar')).toContainText('items'); + }); + + test('should be able to use search filter', async ({ page }) => { + await page.goto('/'); + + // Quick setup - we'll just test the search input works + const searchInput = page.locator('.search-input'); + await expect(searchInput).toBeVisible(); + + // Type in search + await searchInput.fill('test*'); + await expect(searchInput).toHaveValue('test*'); + + // Clear search + await page.locator('.search-clear').click(); + await expect(searchInput).toHaveValue(''); + }); + + test('keyboard shortcut Ctrl+F should focus search', async ({ page }) => { + await page.goto('/'); + + const searchInput = page.locator('.search-input'); + + // Press Ctrl+F + await page.keyboard.press('Control+f'); + + // Search input should be focused + await expect(searchInput).toBeFocused(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index a7fc6fb..a8887b5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,10 +16,16 @@ /* Linting */ "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + + /* Paths */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } }, - "include": ["src"], + "include": ["src", "tests"], "references": [{ "path": "./tsconfig.node.json" }] } From e39e492feccbf61b5e1124aa4fb0500eca3d42e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Jan 2026 06:06:50 +0000 Subject: [PATCH 2/2] Bundle s5cmd binary with application for better UX - Add externalBin configuration in tauri.conf.json to bundle s5cmd - Implement sidecar path resolution in s5cmd.rs with platform detection - Initialize sidecar path on app startup in main.rs - Update GitHub Actions workflow to download platform-specific s5cmd binaries: - Linux: s5cmd-x86_64-unknown-linux-gnu - Windows: s5cmd-x86_64-pc-windows-msvc.exe - macOS Intel: s5cmd-x86_64-apple-darwin - macOS ARM: s5cmd-aarch64-apple-darwin - Add Minio service for integration tests with Playwright --- .github/workflows/build.yml | 183 ++++++++++++++++++++++++++++-------- src-tauri/src/main.rs | 4 + src-tauri/src/s5cmd.rs | 124 ++++++++++++++++++++++-- src-tauri/tauri.conf.json | 4 +- 4 files changed, 268 insertions(+), 47 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d841516..fe6213a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,7 @@ on: env: CARGO_TERM_COLOR: always + S5CMD_VERSION: "2.2.2" jobs: test: @@ -43,10 +44,14 @@ jobs: - name: Install dependencies run: pnpm install - - name: Install s5cmd + - name: Download and setup s5cmd for testing run: | - curl -L https://github.com/peak/s5cmd/releases/download/v2.2.2/s5cmd_2.2.2_Linux-64bit.tar.gz | tar xz - sudo mv s5cmd /usr/local/bin/ + mkdir -p src-tauri/binaries + curl -L https://github.com/peak/s5cmd/releases/download/v${{ env.S5CMD_VERSION }}/s5cmd_${{ env.S5CMD_VERSION }}_Linux-64bit.tar.gz | tar xz + mv s5cmd src-tauri/binaries/s5cmd-x86_64-unknown-linux-gnu + chmod +x src-tauri/binaries/s5cmd-x86_64-unknown-linux-gnu + # Also put in PATH for test convenience + sudo cp src-tauri/binaries/s5cmd-x86_64-unknown-linux-gnu /usr/local/bin/s5cmd - name: Create test bucket run: | @@ -65,21 +70,10 @@ jobs: AWS_ACCESS_KEY_ID: minioadmin AWS_SECRET_ACCESS_KEY: minioadmin - build: - name: Build for ${{ matrix.platform }} + build-linux: + name: Build for Linux needs: test - strategy: - fail-fast: false - matrix: - include: - - platform: ubuntu-22.04 - target: linux - - platform: windows-latest - target: windows - - platform: macos-latest - target: macos - - runs-on: ${{ matrix.platform }} + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -97,12 +91,18 @@ jobs: - name: Install Rust stable uses: dtolnay/rust-action@stable - - name: Install dependencies (Ubuntu) - if: matrix.platform == 'ubuntu-22.04' + - name: Install Linux dependencies run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev + - name: Download s5cmd for Linux + run: | + mkdir -p src-tauri/binaries + curl -L https://github.com/peak/s5cmd/releases/download/v${{ env.S5CMD_VERSION }}/s5cmd_${{ env.S5CMD_VERSION }}_Linux-64bit.tar.gz | tar xz + mv s5cmd src-tauri/binaries/s5cmd-x86_64-unknown-linux-gnu + chmod +x src-tauri/binaries/s5cmd-x86_64-unknown-linux-gnu + - name: Install frontend dependencies run: pnpm install @@ -110,29 +110,127 @@ jobs: uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tagName: v__VERSION__ - releaseName: 'S5cmd GUI v__VERSION__' - releaseBody: 'See the assets below to download and install this version.' - releaseDraft: true - prerelease: false - - name: Upload artifacts + - name: Upload Linux artifacts uses: actions/upload-artifact@v4 with: - name: s5cmd-gui-${{ matrix.target }} + name: s5cmd-gui-linux path: | src-tauri/target/release/bundle/deb/*.deb src-tauri/target/release/bundle/appimage/*.AppImage + if-no-files-found: ignore + + build-windows: + name: Build for Windows + needs: test + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Install Rust stable + uses: dtolnay/rust-action@stable + + - name: Download s5cmd for Windows + shell: powershell + run: | + New-Item -ItemType Directory -Force -Path src-tauri/binaries + Invoke-WebRequest -Uri "https://github.com/peak/s5cmd/releases/download/v${{ env.S5CMD_VERSION }}/s5cmd_${{ env.S5CMD_VERSION }}_Windows-64bit.zip" -OutFile s5cmd.zip + Expand-Archive -Path s5cmd.zip -DestinationPath . + Move-Item -Path s5cmd.exe -Destination src-tauri/binaries/s5cmd-x86_64-pc-windows-msvc.exe + + - name: Install frontend dependencies + run: pnpm install + + - name: Build Tauri app + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: s5cmd-gui-windows + path: | src-tauri/target/release/bundle/msi/*.msi src-tauri/target/release/bundle/nsis/*.exe + if-no-files-found: ignore + + build-macos: + name: Build for macOS + needs: test + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Install Rust stable + uses: dtolnay/rust-action@stable + + - name: Download s5cmd for macOS + run: | + mkdir -p src-tauri/binaries + # Download both Intel and ARM versions + curl -L https://github.com/peak/s5cmd/releases/download/v${{ env.S5CMD_VERSION }}/s5cmd_${{ env.S5CMD_VERSION }}_macOS-64bit.tar.gz | tar xz + mv s5cmd src-tauri/binaries/s5cmd-x86_64-apple-darwin + chmod +x src-tauri/binaries/s5cmd-x86_64-apple-darwin + # For ARM64 (M1/M2), use the ARM binary if available, otherwise use Intel with Rosetta + curl -L https://github.com/peak/s5cmd/releases/download/v${{ env.S5CMD_VERSION }}/s5cmd_${{ env.S5CMD_VERSION }}_macOS-arm64.tar.gz | tar xz || true + if [ -f s5cmd ]; then + mv s5cmd src-tauri/binaries/s5cmd-aarch64-apple-darwin + chmod +x src-tauri/binaries/s5cmd-aarch64-apple-darwin + else + # Fall back to Intel binary for ARM (will run via Rosetta) + cp src-tauri/binaries/s5cmd-x86_64-apple-darwin src-tauri/binaries/s5cmd-aarch64-apple-darwin + fi + + - name: Install frontend dependencies + run: pnpm install + + - name: Build Tauri app + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Note: For proper code signing, set these secrets: + # APPLE_CERTIFICATE: base64 encoded .p12 certificate + # APPLE_CERTIFICATE_PASSWORD: certificate password + # APPLE_SIGNING_IDENTITY: certificate name + # APPLE_ID: Apple ID email + # APPLE_PASSWORD: App-specific password + # APPLE_TEAM_ID: Team ID + + - name: Upload macOS artifacts + uses: actions/upload-artifact@v4 + with: + name: s5cmd-gui-macos + path: | src-tauri/target/release/bundle/dmg/*.dmg src-tauri/target/release/bundle/macos/*.app if-no-files-found: ignore release: name: Create Release - needs: build + needs: [build-linux, build-windows, build-macos] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: @@ -144,6 +242,9 @@ jobs: with: path: artifacts + - name: Display structure of downloaded files + run: ls -R artifacts + - name: Create Release uses: softprops/action-gh-release@v1 with: @@ -156,19 +257,27 @@ jobs: artifacts/**/*.exe artifacts/**/*.dmg body: | - ## Installation + ## S5cmd GUI v${{ github.ref_name }} - ### Linux + A fast S3 file browser powered by s5cmd. + + ### Features + - s5cmd bundled - no additional installation required + - AWS SSO and Access Key authentication + - Secure credential storage in OS keychain + - Fast parallel transfers + - S3-compatible endpoint support (MinIO, etc.) + + ### Installation + + #### Linux - **Debian/Ubuntu**: Download the `.deb` file and install with `sudo dpkg -i s5cmd-gui_*.deb` - **AppImage**: Download the `.AppImage` file, make it executable with `chmod +x`, and run it - ### Windows + #### Windows - Download and run the `.msi` installer or the `.exe` NSIS installer - ### macOS + #### macOS - Download the `.dmg` file and drag the app to your Applications folder - - **Note**: You may need to right-click and select "Open" on first launch due to Gatekeeper - - ## Requirements - - **s5cmd** must be installed and available in your PATH - - Install from: https://github.com/peak/s5cmd + - **Note**: On first launch, right-click the app and select "Open" to bypass Gatekeeper + - The app is not signed. If you need a signed version, build from source with your own certificate. diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 339ecea..b597c22 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -28,6 +28,10 @@ fn main() { std::fs::create_dir_all(&dir).ok(); info!("App data directory: {:?}", dir); } + + // Initialize s5cmd sidecar path + s5cmd::init_sidecar_path(&app.handle()); + Ok(()) }) .invoke_handler(tauri::generate_handler![ diff --git a/src-tauri/src/s5cmd.rs b/src-tauri/src/s5cmd.rs index 2e05602..7beba40 100644 --- a/src-tauri/src/s5cmd.rs +++ b/src-tauri/src/s5cmd.rs @@ -1,10 +1,10 @@ use log::{debug, error, info, warn}; -use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::io::{BufRead, BufReader}; +use std::path::PathBuf; use std::process::{Child, Command, Stdio}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use tempfile::NamedTempFile; use uuid::Uuid; @@ -12,6 +12,9 @@ use crate::profiles::{AuthType, Profile}; use crate::settings::Settings; use crate::sso; +// Store the resolved sidecar path +static SIDECAR_PATH: OnceLock = OnceLock::new(); + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct S3Object { pub key: String, @@ -68,6 +71,94 @@ struct OperationState { cancelled: bool, } +/// Initialize the sidecar path. Call this during app setup. +pub fn init_sidecar_path(app_handle: &tauri::AppHandle) { + if SIDECAR_PATH.get().is_some() { + return; + } + + // Try to resolve the sidecar path + let sidecar_path = resolve_sidecar_path(app_handle); + + if let Some(path) = sidecar_path { + info!("s5cmd sidecar path: {:?}", path); + let _ = SIDECAR_PATH.set(path); + } else { + warn!("Could not find bundled s5cmd, will try system PATH"); + } +} + +fn resolve_sidecar_path(app_handle: &tauri::AppHandle) -> Option { + // Get the path to the current executable + let exe_path = std::env::current_exe().ok()?; + let exe_dir = exe_path.parent()?; + + // Determine the sidecar binary name based on platform + #[cfg(target_os = "windows")] + let sidecar_name = "s5cmd.exe"; + + #[cfg(not(target_os = "windows"))] + let sidecar_name = "s5cmd"; + + // In development, the sidecar might be in different locations + // Try the standard Tauri sidecar location first + let possible_paths = vec![ + // Production: next to the executable + exe_dir.join(sidecar_name), + // macOS app bundle + exe_dir.join("../Resources").join(sidecar_name), + // Development: in the target directory + exe_dir.join("binaries").join(sidecar_name), + // Tauri's sidecar location during development + { + let mut path = exe_dir.to_path_buf(); + // Go up to find src-tauri/binaries + for _ in 0..5 { + path = path.join("..").join("src-tauri").join("binaries").join(sidecar_name); + if path.exists() { + break; + } + path = path.parent().unwrap_or(&path).parent().unwrap_or(&path).to_path_buf(); + } + path + }, + ]; + + for path in possible_paths { + let canonical = path.canonicalize().ok(); + if let Some(p) = canonical { + if p.exists() && p.is_file() { + return Some(p); + } + } + if path.exists() && path.is_file() { + return Some(path); + } + } + + None +} + +/// Get the path to the s5cmd binary +pub fn get_s5cmd_path(custom_path: Option<&str>) -> String { + // First check if user specified a custom path + if let Some(path) = custom_path { + if !path.is_empty() { + return path.to_string(); + } + } + + // Then check if we have a bundled sidecar + if let Some(path) = SIDECAR_PATH.get() { + if path.exists() { + return path.to_string_lossy().to_string(); + } + } + + // Fall back to system PATH + "s5cmd".to_string() +} + pub struct S5cmdRunner { profile: Profile, settings: Settings, @@ -78,8 +169,15 @@ impl S5cmdRunner { Self { profile, settings } } + fn get_binary_path(&self) -> String { + get_s5cmd_path(self.settings.s5cmd_path.as_deref()) + } + fn build_command(&self) -> Result { - let mut cmd = Command::new(self.settings.get_s5cmd_binary()); + let binary = self.get_binary_path(); + debug!("Using s5cmd binary: {}", binary); + + let mut cmd = Command::new(&binary); // Set environment variables based on auth type match &self.profile.auth { @@ -140,7 +238,10 @@ impl S5cmdRunner { debug!("Running s5cmd ls for buckets"); - let output = cmd.output().map_err(|e| format!("Failed to execute s5cmd: {}", e))?; + let output = cmd.output().map_err(|e| { + let binary = self.get_binary_path(); + format!("Failed to execute s5cmd ({}): {}. Make sure s5cmd is installed.", binary, e) + })?; if !output.status.success() { let error = String::from_utf8_lossy(&output.stderr); @@ -283,7 +384,6 @@ impl S5cmdRunner { pub fn create_folder(&self, path: &str) -> Result<(), String> { // s5cmd doesn't have a direct mkdir, but we can create an empty object with a trailing slash - // or use a placeholder file let folder_path = if path.ends_with('/') { path.to_string() } else { @@ -532,13 +632,13 @@ fn parse_s5cmd_error(error: &str) -> String { error.to_string() } -pub fn get_s5cmd_version(s5cmd_path: Option<&str>) -> Result { - let binary = s5cmd_path.unwrap_or("s5cmd"); +pub fn get_s5cmd_version(custom_path: Option<&str>) -> Result { + let binary = get_s5cmd_path(custom_path); - let output = Command::new(binary) + let output = Command::new(&binary) .arg("version") .output() - .map_err(|e| format!("Failed to run s5cmd: {}", e))?; + .map_err(|e| format!("Failed to run s5cmd ({}): {}", binary, e))?; if output.status.success() { let version = String::from_utf8_lossy(&output.stdout); @@ -547,3 +647,9 @@ pub fn get_s5cmd_version(s5cmd_path: Option<&str>) -> Result { Err("Failed to get s5cmd version".to_string()) } } + +/// Check if s5cmd is available +pub fn check_s5cmd_available() -> Result { + let path = get_s5cmd_path(None); + get_s5cmd_version(Some(&path)) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 69f2b59..c76c008 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -70,7 +70,9 @@ "icons/icon.ico" ], "resources": [], - "externalBin": [], + "externalBin": [ + "binaries/s5cmd" + ], "copyright": "MIT License", "category": "DeveloperTool", "shortDescription": "Fast S3 file browser powered by s5cmd",