From b7177cbe191999d72c13a775831d6c7744deb8a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Jan 2026 19:11:10 +0000 Subject: [PATCH 1/3] 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/3] 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", From f571abca8d847eecc0fd94fadb1bf352c1c61566 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Jan 2026 17:27:51 +0000 Subject: [PATCH 3/3] Fix MinIO container startup in GitHub Actions The test job was failing because the MinIO service container couldn't start properly. The issue was caused by the health check using 'curl' which is not available inside the MinIO container. Changes: - Use specific MinIO release version instead of 'latest' for stability - Remove problematic health check from service configuration - Add explicit wait step with curl from the runner to ensure MinIO is ready - Wait step runs on Ubuntu runner (has curl) instead of in container This ensures MinIO starts properly and is ready before tests run. --- .github/workflows/build.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fe6213a..f4f39c7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,17 +20,20 @@ jobs: runs-on: ubuntu-latest services: minio: - image: minio/minio:latest + image: minio/minio:RELEASE.2024-01-01T16-36-33Z 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: Wait for MinIO to be ready + run: | + timeout 60 bash -c 'until curl -sf http://localhost:9000/minio/health/live; do sleep 2; done' + - name: Setup Node.js uses: actions/setup-node@v4 with: