diff --git a/Cargo.lock b/Cargo.lock index 0b582bb..c99134d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -547,6 +547,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -570,9 +580,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -583,7 +593,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -826,7 +836,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -996,7 +1006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1108,6 +1118,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1115,7 +1134,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1129,6 +1148,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1161,6 +1186,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1556,6 +1582,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1682,6 +1727,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1709,6 +1755,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1727,9 +1789,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2294,6 +2358,7 @@ dependencies = [ "byteorder", "camino", "chrono", + "flate2", "image", "libloading 0.9.0", "ltk_fantome", @@ -2303,10 +2368,12 @@ dependencies = [ "ltk_modpkg", "ltk_overlay", "ltk_wad", + "reqwest 0.12.28", "semver", "serde", "serde_json", "slug", + "tar", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -2645,6 +2712,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2717,7 +2801,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3040,12 +3124,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3059,7 +3181,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -3818,6 +3940,48 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -3939,7 +4103,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3983,7 +4147,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -3995,7 +4159,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4033,6 +4197,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -4115,7 +4285,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4254,6 +4424,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.16.1" @@ -4634,6 +4816,27 @@ dependencies = [ "windows 0.57.0", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4655,7 +4858,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.11.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -4746,7 +4949,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -4934,7 +5137,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.13.2", "rustls", "semver", "serde", @@ -5062,7 +5265,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5208,6 +5411,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5977,7 +6190,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6148,6 +6361,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.1.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1fdafa5..1658e7f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -49,6 +49,10 @@ image = "0.25.2" webp = "0.3" slug = "0.1" +reqwest = { version = "0.12", features = ["blocking"] } +flate2 = "1" +tar = "0.4" + tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" diff --git a/src-tauri/src/commands/workshop.rs b/src-tauri/src/commands/workshop.rs index d6344ad..a5d4f40 100644 --- a/src-tauri/src/commands/workshop.rs +++ b/src-tauri/src/commands/workshop.rs @@ -1,8 +1,8 @@ use crate::error::{AppResult, IpcResult, MutexResultExt}; use crate::state::SettingsState; use crate::workshop::{ - CreateProjectArgs, PackProjectArgs, PackResult, SaveProjectConfigArgs, ValidationResult, - WorkshopProject, WorkshopState, + CreateProjectArgs, FantomePeekResult, ImportFantomeArgs, ImportGitRepoArgs, PackProjectArgs, + PackResult, SaveProjectConfigArgs, ValidationResult, WorkshopProject, WorkshopState, }; use std::collections::HashMap; use tauri::State; @@ -86,6 +86,40 @@ pub fn import_from_modpkg( result.into() } +#[tauri::command] +pub fn peek_fantome( + file_path: String, + workshop: State, +) -> IpcResult { + workshop.0.peek_fantome(&file_path).into() +} + +#[tauri::command] +pub fn import_from_fantome( + args: ImportFantomeArgs, + workshop: State, + settings: State, +) -> IpcResult { + let result: AppResult = (|| { + let settings = settings.0.lock().mutex_err()?.clone(); + workshop.0.import_from_fantome(&settings, args) + })(); + result.into() +} + +#[tauri::command] +pub fn import_from_git_repo( + args: ImportGitRepoArgs, + workshop: State, + settings: State, +) -> IpcResult { + let result: AppResult = (|| { + let settings = settings.0.lock().mutex_err()?.clone(); + workshop.0.import_from_git_repo(&settings, args) + })(); + result.into() +} + #[tauri::command] pub fn validate_project( project_path: String, diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index abf46c4..acb7c30 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -34,6 +34,8 @@ pub enum ErrorCode { ProjectAlreadyExists, /// Failed to pack workshop project PackFailed, + /// Error processing a .fantome file + Fantome, /// WAD file error Wad, /// Operation blocked because the patcher is running @@ -184,6 +186,9 @@ pub enum AppError { #[error("Failed to pack project: {0}")] PackFailed(String), + #[error("Fantome error: {0}")] + Fantome(String), + #[error("WAD error: {0}")] WadError(#[from] ltk_wad::WadError), @@ -253,6 +258,8 @@ impl From for AppErrorResponse { AppError::PackFailed(msg) => AppErrorResponse::new(ErrorCode::PackFailed, msg), + AppError::Fantome(msg) => AppErrorResponse::new(ErrorCode::Fantome, msg), + AppError::WadError(e) => AppErrorResponse::new(ErrorCode::Wad, e.to_string()), AppError::WadBuilderError(e) => AppErrorResponse::new(ErrorCode::Wad, e.to_string()), diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index eb8258d..9ed5dd6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -155,6 +155,9 @@ fn main() { commands::delete_workshop_project, commands::pack_workshop_project, commands::import_from_modpkg, + commands::peek_fantome, + commands::import_from_fantome, + commands::import_from_git_repo, commands::validate_project, commands::set_project_thumbnail, commands::get_project_thumbnail, diff --git a/src-tauri/src/workshop/mod.rs b/src-tauri/src/workshop/mod.rs index bc9b44c..3038224 100644 --- a/src-tauri/src/workshop/mod.rs +++ b/src-tauri/src/workshop/mod.rs @@ -17,7 +17,6 @@ use tauri::AppHandle; /// Holds the `AppHandle` for consistency with `ModLibrary`. /// Settings are passed per-call since they can change at runtime. pub struct Workshop { - #[allow(dead_code)] app_handle: AppHandle, } @@ -87,6 +86,53 @@ pub struct WorkshopLayer { pub string_overrides: HashMap>, } +/// Metadata peeked from a .fantome archive without extracting content. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FantomePeekResult { + pub name: String, + pub author: String, + pub version: String, + pub description: String, + pub wad_files: Vec, + pub suggested_name: String, +} + +/// Arguments for importing a .fantome archive. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportFantomeArgs { + pub file_path: String, + pub name: String, + pub display_name: String, +} + +/// Progress event emitted during fantome import. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FantomeImportProgress { + pub stage: String, + pub current_wad: Option, + pub current: u32, + pub total: u32, +} + +/// Arguments for importing a project from a GitHub repository. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportGitRepoArgs { + pub url: String, + pub branch: Option, +} + +/// Progress event emitted during git repo import. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitImportProgress { + pub stage: String, + pub message: Option, +} + /// Arguments for creating a new project. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/src/workshop/projects.rs b/src-tauri/src/workshop/projects.rs index 530346e..f014300 100644 --- a/src-tauri/src/workshop/projects.rs +++ b/src-tauri/src/workshop/projects.rs @@ -1,14 +1,22 @@ use super::{ find_config_file, is_valid_project_name, load_mod_project, load_workshop_project, - CreateProjectArgs, SaveProjectConfigArgs, Workshop, WorkshopProject, + CreateProjectArgs, FantomeImportProgress, FantomePeekResult, GitImportProgress, + ImportFantomeArgs, ImportGitRepoArgs, SaveProjectConfigArgs, Workshop, WorkshopProject, }; use crate::error::{AppError, AppResult}; use crate::state::Settings; use ltk_mod_project::{ default_layers, ModMap, ModProject, ModProjectAuthor, ModProjectLayer, ModTag, }; +use std::collections::HashSet; use std::fs; +use std::io::{Cursor, Read, Seek}; use std::path::PathBuf; +use tauri::Emitter; +use zip::ZipArchive; + +use camino::Utf8Path; +use ltk_wad::{HexPathResolver, WadExtractor}; impl Workshop { /// Get all workshop projects from the configured workshop directory. @@ -215,6 +223,150 @@ impl Workshop { Ok(()) } + /// Peek into a .fantome archive and return metadata without extracting content. + pub fn peek_fantome(&self, file_path: &str) -> AppResult { + let file = fs::File::open(file_path)?; + let mut archive = ZipArchive::new(file)?; + + let info = read_fantome_info(&mut archive)?; + let wad_files = scan_fantome_wad_names(&mut archive)?; + + Ok(FantomePeekResult { + suggested_name: slug::slugify(&info.name), + name: info.name, + author: info.author, + version: info.version, + description: info.description, + wad_files, + }) + } + + /// Import a .fantome archive as a new workshop project. + pub fn import_from_fantome( + &self, + settings: &Settings, + args: ImportFantomeArgs, + ) -> AppResult { + let workshop_path = self.workshop_dir(settings)?; + + if !is_valid_project_name(&args.name) { + return Err(AppError::ValidationFailed( + "Project name must be lowercase alphanumeric with hyphens only".to_string(), + )); + } + + let project_dir = workshop_path.join(&args.name); + if project_dir.exists() { + return Err(AppError::ProjectAlreadyExists(args.name)); + } + + let file = fs::File::open(&args.file_path)?; + let mut archive = ZipArchive::new(file)?; + + let info = read_fantome_info(&mut archive)?; + let wad_names = scan_fantome_wad_names(&mut archive)?; + let total_wads = wad_names.len() as u32; + + fs::create_dir_all(&project_dir)?; + + let result = (|| -> AppResult { + let base_dir = project_dir.join("content").join("base"); + fs::create_dir_all(&base_dir)?; + + for (idx, wad_name) in wad_names.iter().enumerate() { + self.emit_fantome_progress("extracting", Some(wad_name), idx as u32, total_wads); + extract_fantome_wad(&mut archive, wad_name, &base_dir)?; + } + + self.emit_fantome_progress("finalizing", None, total_wads, total_wads); + + let readme_path = project_dir.join("README.md"); + extract_fantome_file(&mut archive, "meta/readme.md", &readme_path); + if !readme_path.exists() { + extract_fantome_file(&mut archive, "readme.md", &readme_path); + } + + // Extract thumbnail + extract_fantome_file( + &mut archive, + "meta/image.png", + &project_dir.join("thumbnail.png"), + ); + + // Build layers from fantome info + let layers = if info.layers.is_empty() { + default_layers() + } else { + let mut layers: Vec = info + .layers + .into_values() + .map(|layer_info| ModProjectLayer { + name: layer_info.name, + priority: layer_info.priority, + description: None, + string_overrides: layer_info.string_overrides, + }) + .collect(); + if !layers.iter().any(|l| l.name == "base") { + layers.insert(0, ModProjectLayer::base()); + } + layers.sort_by(|a, b| { + a.priority + .cmp(&b.priority) + .then_with(|| a.name.cmp(&b.name)) + }); + layers + }; + + let mod_project = ModProject { + name: args.name.clone(), + display_name: args.display_name, + version: info.version, + description: info.description, + authors: vec![ModProjectAuthor::Name(info.author)], + license: None, + tags: info.tags.into_iter().map(ModTag::from).collect(), + champions: info.champions, + maps: info.maps.into_iter().map(ModMap::from).collect(), + transformers: Vec::new(), + layers, + thumbnail: None, + }; + + let config_path = project_dir.join("mod.config.json"); + fs::write(&config_path, serde_json::to_string_pretty(&mod_project)?)?; + + self.emit_fantome_progress("complete", None, total_wads, total_wads); + + load_workshop_project(&project_dir) + })(); + + if result.is_err() { + self.emit_fantome_progress("error", None, 0, total_wads); + let _ = fs::remove_dir_all(&project_dir); + } + + result + } + + fn emit_fantome_progress( + &self, + stage: &str, + current_wad: Option<&str>, + current: u32, + total: u32, + ) { + let _ = self.app_handle.emit( + "fantome-import-progress", + FantomeImportProgress { + stage: stage.to_string(), + current_wad: current_wad.map(String::from), + current, + total, + }, + ); + } + /// Import a .modpkg file as a new workshop project. pub fn import_from_modpkg( &self, @@ -312,4 +464,319 @@ impl Workshop { load_workshop_project(&project_dir) } + + /// Import a project from a GitHub repository by downloading and extracting its tarball. + pub fn import_from_git_repo( + &self, + settings: &Settings, + args: ImportGitRepoArgs, + ) -> AppResult { + let workshop_path = self.workshop_dir(settings)?; + let (owner, repo) = parse_github_url(&args.url)?; + let branch = args.branch.unwrap_or_else(|| "main".to_string()); + + let tarball_url = format!( + "https://github.com/{}/{}/archive/refs/heads/{}.tar.gz", + owner, repo, branch + ); + + self.emit_git_progress("downloading", None); + + let response = reqwest::blocking::get(&tarball_url) + .map_err(|e| AppError::Other(format!("Failed to download repository: {}", e)))?; + + if !response.status().is_success() { + return Err(AppError::Other(format!( + "Failed to download repository (HTTP {}). Check the URL and branch name.", + response.status() + ))); + } + + let bytes = response + .bytes() + .map_err(|e| AppError::Other(format!("Failed to read response: {}", e)))?; + + self.emit_git_progress("extracting", None); + + let temp_dir = workshop_path.join(format!(".git-import-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir)?; + + let result = (|| -> AppResult { + let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(&bytes)); + let mut archive = tar::Archive::new(decoder); + archive.unpack(&temp_dir)?; + + // GitHub tarballs extract to "{repo}-{branch}/" — find the single top-level directory + let mut entries = fs::read_dir(&temp_dir)?; + let extracted_dir = entries + .next() + .ok_or_else(|| AppError::Other("Archive is empty".to_string()))?? + .path(); + + if !extracted_dir.is_dir() { + return Err(AppError::Other( + "Archive does not contain a directory".to_string(), + )); + } + + if find_config_file(&extracted_dir).is_none() { + return Err(AppError::ValidationFailed( + "Repository does not contain a mod.config.json or mod.config.toml".to_string(), + )); + } + + let config_path = find_config_file(&extracted_dir).unwrap(); + let mod_project = load_mod_project(&config_path)?; + + let project_name = &mod_project.name; + if !is_valid_project_name(project_name) { + return Err(AppError::ValidationFailed(format!( + "Project name '{}' in config is invalid. Must be lowercase alphanumeric with hyphens only.", + project_name + ))); + } + + let project_dir = workshop_path.join(project_name); + if project_dir.exists() { + return Err(AppError::ProjectAlreadyExists(project_name.clone())); + } + + fs::rename(&extracted_dir, &project_dir)?; + + self.emit_git_progress("complete", None); + load_workshop_project(&project_dir) + })(); + + if result.is_err() { + self.emit_git_progress("error", None); + } + + if temp_dir.exists() { + let _ = fs::remove_dir_all(&temp_dir); + } + result + } + + fn emit_git_progress(&self, stage: &str, message: Option<&str>) { + let _ = self.app_handle.emit( + "git-import-progress", + GitImportProgress { + stage: stage.to_string(), + message: message.map(String::from), + }, + ); + } +} + +/// Parse a GitHub URL and extract the owner and repo name. +fn parse_github_url(url: &str) -> AppResult<(String, String)> { + let url = url.trim().trim_end_matches('/'); + let url = url.strip_suffix(".git").unwrap_or(url); + + let path = url + .strip_prefix("https://github.com/") + .or_else(|| url.strip_prefix("http://github.com/")) + .ok_or_else(|| { + AppError::ValidationFailed( + "URL must be a GitHub repository (https://github.com/owner/repo)".to_string(), + ) + })?; + + let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + if parts.len() < 2 { + return Err(AppError::ValidationFailed( + "URL must include owner and repository name (https://github.com/owner/repo)" + .to_string(), + )); + } + if parts.len() > 2 { + return Err(AppError::ValidationFailed( + "URL must not contain extra path segments beyond owner and repository name (https://github.com/owner/repo)" + .to_string(), + )); + } + + Ok((parts[0].to_string(), parts[1].to_string())) +} + +// ============================================================================ +// Fantome helpers +// ============================================================================ + +fn is_wad_file_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.ends_with(".wad.client") || lower.ends_with(".wad") || lower.ends_with(".wad.mobile") +} + +/// Read and parse META/info.json from a fantome ZIP archive. +fn read_fantome_info( + archive: &mut ZipArchive, +) -> AppResult { + let mut info_content = String::new(); + let mut found = false; + + for i in 0..archive.len() { + let file = archive.by_index(i)?; + let name = file.name().to_lowercase(); + if name == "meta/info.json" { + drop(file); + let mut info_file = archive.by_index(i)?; + info_file.read_to_string(&mut info_content)?; + found = true; + break; + } + } + + if !found { + return Err(AppError::Fantome( + "Missing META/info.json in fantome archive".to_string(), + )); + } + + let info_content = info_content.trim_start_matches('\u{feff}').trim(); + serde_json::from_str(info_content) + .map_err(|e| AppError::Fantome(format!("Failed to parse info.json: {}", e))) +} + +/// Scan a fantome archive for WAD file names under WAD/. +fn scan_fantome_wad_names(archive: &mut ZipArchive) -> AppResult> { + let mut dir_wads: HashSet = HashSet::new(); + let mut file_wads: HashSet = HashSet::new(); + + for i in 0..archive.len() { + let file = archive.by_index(i)?; + let name = file.name().to_string(); + + let Some(relative) = name.strip_prefix("WAD/") else { + continue; + }; + if relative.is_empty() { + continue; + } + + if !relative.contains('/') && !file.is_dir() && is_wad_file_name(relative) { + file_wads.insert(relative.to_string()); + } else if let Some(wad_name) = relative.split('/').next() { + if is_wad_file_name(wad_name) { + dir_wads.insert(wad_name.to_string()); + } + } + } + + let mut wads: Vec = dir_wads.into_iter().collect(); + for wad_name in file_wads { + if !wads.contains(&wad_name) { + wads.push(wad_name); + } + } + wads.sort(); + + Ok(wads) +} + +/// Extract a single WAD (directory-style or packed) from a fantome archive into the target dir. +fn extract_fantome_wad( + archive: &mut ZipArchive, + wad_name: &str, + base_dir: &std::path::Path, +) -> AppResult<()> { + let dir_prefix = format!("WAD/{}/", wad_name); + let packed_path = format!("WAD/{}", wad_name); + let mut has_dir_entries = false; + + let wad_output_dir = base_dir.join(wad_name); + + // Try directory-style WAD first + for i in 0..archive.len() { + let file = archive.by_index(i)?; + let name = file.name().to_string(); + + if name.starts_with(&dir_prefix) && !file.is_dir() { + let rel = name.strip_prefix(&dir_prefix).unwrap_or(&name); + if rel.is_empty() { + continue; + } + + if rel.contains("..") || std::path::Path::new(rel).is_absolute() { + return Err(AppError::Fantome(format!( + "Zip entry contains unsafe path: {}", + name + ))); + } + + has_dir_entries = true; + drop(file); + + let mut entry = archive.by_index(i)?; + let target_path = wad_output_dir.join(rel); + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent)?; + } + let mut out = fs::File::create(&target_path)?; + std::io::copy(&mut entry, &mut out)?; + } + } + + if has_dir_entries { + return Ok(()); + } + + // Try packed WAD — use WadExtractor for proper extraction + for i in 0..archive.len() { + let file = archive.by_index(i)?; + let name = file.name().to_string(); + + if name == packed_path && !file.is_dir() { + drop(file); + + let mut entry = archive.by_index(i)?; + let mut wad_data = Vec::new(); + entry.read_to_end(&mut wad_data)?; + + let cursor = Cursor::new(wad_data); + let mut wad = ltk_wad::Wad::mount(cursor)?; + + let wad_dir = base_dir.join(wad_name); + fs::create_dir_all(&wad_dir)?; + + let resolver = HexPathResolver; + let extractor = WadExtractor::new(&resolver); + extractor.extract_all( + &mut wad, + Utf8Path::from_path(&wad_dir).ok_or_else(|| { + AppError::Fantome("WAD output path is not valid UTF-8".to_string()) + })?, + )?; + + return Ok(()); + } + } + + Err(AppError::Fantome(format!( + "Failed to locate or extract WAD '{}' from Fantome archive", + wad_name + ))) +} + +/// Try to extract a file from the archive by case-insensitive name match. +fn extract_fantome_file( + archive: &mut ZipArchive, + name_lower: &str, + target: &std::path::Path, +) { + for i in 0..archive.len() { + let Ok(file) = archive.by_index(i) else { + continue; + }; + if file.name().to_lowercase() == name_lower && !file.is_dir() { + drop(file); + if let Ok(mut entry) = archive.by_index(i) { + let mut bytes = Vec::new(); + if entry.read_to_end(&mut bytes).is_ok() { + let _ = fs::write(target, bytes); + return; + } + } + } + } } diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index c5dab46..cc33345 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -197,6 +197,38 @@ export interface ValidationResult { warnings: string[]; } +export interface FantomePeekResult { + name: string; + author: string; + version: string; + description: string; + wadFiles: string[]; + suggestedName: string; +} + +export interface ImportFantomeArgs { + filePath: string; + name: string; + displayName: string; +} + +export interface FantomeImportProgress { + stage: "extracting" | "finalizing" | "complete" | "error"; + currentWad: string | null; + current: number; + total: number; +} + +export interface ImportGitRepoArgs { + url: string; + branch?: string; +} + +export interface GitImportProgress { + stage: "downloading" | "extracting" | "complete" | "error"; + message: string | null; +} + /** * Raw IPC result from Tauri commands. * This matches the Rust IpcResult serialization format. @@ -287,6 +319,11 @@ export const api = { invokeResult("pack_workshop_project", { args }), importFromModpkg: (filePath: string) => invokeResult("import_from_modpkg", { filePath }), + peekFantome: (filePath: string) => invokeResult("peek_fantome", { filePath }), + importFromFantome: (args: ImportFantomeArgs) => + invokeResult("import_from_fantome", { args }), + importFromGitRepo: (args: ImportGitRepoArgs) => + invokeResult("import_from_git_repo", { args }), validateProject: (projectPath: string) => invokeResult("validate_project", { projectPath }), setProjectThumbnail: (projectPath: string, imagePath: string) => diff --git a/src/lib/useTauriProgress.ts b/src/lib/useTauriProgress.ts new file mode 100644 index 0000000..62f01a0 --- /dev/null +++ b/src/lib/useTauriProgress.ts @@ -0,0 +1,67 @@ +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +interface TauriProgressOptions { + terminalStages?: string[]; + clearDelay?: number; +} + +interface TauriProgressResult { + progress: T | null; + clear: () => void; +} + +const DEFAULT_TERMINAL_STAGES = ["complete", "error"]; + +/** + * Subscribe to a Tauri backend progress event and auto-clear after a terminal stage. + * + * The payload type `T` must have a `stage` string field. When `stage` matches one + * of the `terminalStages` (default: `["complete", "error"]`), progress is cleared + * after `clearDelay` ms (default: 1000). + */ +export function useTauriProgress( + eventName: string, + options?: TauriProgressOptions, +): TauriProgressResult { + const [progress, setProgress] = useState(null); + + const terminalStages = useMemo( + () => options?.terminalStages ?? DEFAULT_TERMINAL_STAGES, + [options?.terminalStages], + ); + const clearDelay = options?.clearDelay ?? 1000; + + useEffect(() => { + let unlisten: UnlistenFn | null = null; + let timeoutId: ReturnType | null = null; + let mounted = true; + + listen(eventName, (event) => { + setProgress(event.payload); + + if (terminalStages.includes(event.payload.stage)) { + if (timeoutId !== null) clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + if (mounted) setProgress(null); + }, clearDelay); + } + }).then((fn) => { + if (!mounted) { + fn(); + return; + } + unlisten = fn; + }); + + return () => { + mounted = false; + if (unlisten) unlisten(); + if (timeoutId !== null) clearTimeout(timeoutId); + }; + }, [eventName, terminalStages, clearDelay]); + + const clear = useCallback(() => setProgress(null), []); + + return { progress, clear }; +} diff --git a/src/modules/patcher/api/useOverlayProgress.ts b/src/modules/patcher/api/useOverlayProgress.ts index 61e0581..6f65dee 100644 --- a/src/modules/patcher/api/useOverlayProgress.ts +++ b/src/modules/patcher/api/useOverlayProgress.ts @@ -1,47 +1,28 @@ -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import type { OverlayProgress } from "@/lib/tauri"; +import { useTauriProgress } from "@/lib/useTauriProgress"; import { usePatcherStatus } from "./usePatcherStatus"; +const TERMINAL_STAGES = ["complete"]; + export function useOverlayProgress() { - const [progress, setProgress] = useState(null); + const { progress, clear } = useTauriProgress("overlay-progress", { + terminalStages: TERMINAL_STAGES, + }); const { data: patcherStatus } = usePatcherStatus(); const wasPatcherRunning = useRef(false); - useEffect(() => { - let unlisten: UnlistenFn | null = null; - - listen("overlay-progress", (event) => { - setProgress(event.payload); - - // Clear progress after completion - if (event.payload.stage === "complete") { - setTimeout(() => setProgress(null), 1000); - } - }).then((fn) => { - unlisten = fn; - }); - - return () => { - if (unlisten) { - unlisten(); - } - }; - }, []); - - // Reset progress when patcher stops useEffect(() => { const isPatcherRunning = patcherStatus?.running ?? false; - // If patcher was running and now stopped, reset progress if (wasPatcherRunning.current && !isPatcherRunning) { - setProgress(null); + clear(); } wasPatcherRunning.current = isPatcherRunning; - }, [patcherStatus?.running]); + }, [patcherStatus?.running, clear]); return progress; } diff --git a/src/modules/workshop/api/index.ts b/src/modules/workshop/api/index.ts index 0f8da55..97f4488 100644 --- a/src/modules/workshop/api/index.ts +++ b/src/modules/workshop/api/index.ts @@ -1,8 +1,13 @@ export { workshopKeys } from "./keys"; export { useCreateProject } from "./useCreateProject"; export { useDeleteProject } from "./useDeleteProject"; +export { useFantomeImportProgress } from "./useFantomeImportProgress"; +export { useGitImportProgress } from "./useGitImportProgress"; +export { useImportFromFantome } from "./useImportFromFantome"; +export { useImportFromGitRepo } from "./useImportFromGitRepo"; export { useImportFromModpkg } from "./useImportFromModpkg"; export { usePackProject } from "./usePackProject"; +export { usePeekFantome } from "./usePeekFantome"; export { useProjectActions } from "./useProjectActions"; export { projectThumbnailOptions, useProjectThumbnail } from "./useProjectThumbnail"; export { useRenameProject } from "./useRenameProject"; diff --git a/src/modules/workshop/api/useFantomeImportProgress.ts b/src/modules/workshop/api/useFantomeImportProgress.ts new file mode 100644 index 0000000..0fa4693 --- /dev/null +++ b/src/modules/workshop/api/useFantomeImportProgress.ts @@ -0,0 +1,6 @@ +import type { FantomeImportProgress } from "@/lib/tauri"; +import { useTauriProgress } from "@/lib/useTauriProgress"; + +export function useFantomeImportProgress() { + return useTauriProgress("fantome-import-progress").progress; +} diff --git a/src/modules/workshop/api/useGitImportProgress.ts b/src/modules/workshop/api/useGitImportProgress.ts new file mode 100644 index 0000000..7e57cc5 --- /dev/null +++ b/src/modules/workshop/api/useGitImportProgress.ts @@ -0,0 +1,6 @@ +import type { GitImportProgress } from "@/lib/tauri"; +import { useTauriProgress } from "@/lib/useTauriProgress"; + +export function useGitImportProgress() { + return useTauriProgress("git-import-progress").progress; +} diff --git a/src/modules/workshop/api/useImportFromFantome.ts b/src/modules/workshop/api/useImportFromFantome.ts new file mode 100644 index 0000000..6b13eb2 --- /dev/null +++ b/src/modules/workshop/api/useImportFromFantome.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { api, type AppError, type ImportFantomeArgs, type WorkshopProject } from "@/lib/tauri"; +import { unwrapForQuery } from "@/utils/query"; + +import { workshopKeys } from "./keys"; + +export function useImportFromFantome() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (args) => { + const result = await api.importFromFantome(args); + return unwrapForQuery(result); + }, + onSuccess: (newProject) => { + queryClient.setQueryData(workshopKeys.projects(), (old) => + old ? [newProject, ...old] : [newProject], + ); + }, + }); +} diff --git a/src/modules/workshop/api/useImportFromGitRepo.ts b/src/modules/workshop/api/useImportFromGitRepo.ts new file mode 100644 index 0000000..009a5eb --- /dev/null +++ b/src/modules/workshop/api/useImportFromGitRepo.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { api, type AppError, type ImportGitRepoArgs, type WorkshopProject } from "@/lib/tauri"; +import { unwrapForQuery } from "@/utils/query"; + +import { workshopKeys } from "./keys"; + +export function useImportFromGitRepo() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (args) => { + const result = await api.importFromGitRepo(args); + return unwrapForQuery(result); + }, + onSuccess: (newProject) => { + queryClient.setQueryData(workshopKeys.projects(), (old) => + old ? [newProject, ...old] : [newProject], + ); + }, + }); +} diff --git a/src/modules/workshop/api/usePeekFantome.ts b/src/modules/workshop/api/usePeekFantome.ts new file mode 100644 index 0000000..b9f59b7 --- /dev/null +++ b/src/modules/workshop/api/usePeekFantome.ts @@ -0,0 +1,13 @@ +import { useMutation } from "@tanstack/react-query"; + +import { api, type AppError, type FantomePeekResult } from "@/lib/tauri"; +import { unwrapForQuery } from "@/utils/query"; + +export function usePeekFantome() { + return useMutation({ + mutationFn: async (filePath) => { + const result = await api.peekFantome(filePath); + return unwrapForQuery(result); + }, + }); +} diff --git a/src/modules/workshop/components/ImportFantomeDialog.tsx b/src/modules/workshop/components/ImportFantomeDialog.tsx new file mode 100644 index 0000000..ee63840 --- /dev/null +++ b/src/modules/workshop/components/ImportFantomeDialog.tsx @@ -0,0 +1,209 @@ +import { useEffect } from "react"; +import { z } from "zod"; + +import { Button, Dialog } from "@/components"; +import { useAppForm } from "@/lib/form"; +import type { FantomeImportProgress, FantomePeekResult, ImportFantomeArgs } from "@/lib/tauri"; + +const importSchema = z.object({ + name: z + .string() + .min(1, "Project name is required") + .regex(/^[a-z0-9-]+$/, "Name must be lowercase letters, numbers, and hyphens only") + .refine( + (val) => !val.startsWith("-") && !val.endsWith("-"), + "Name cannot start or end with a hyphen", + ), + displayName: z.string().min(1, "Display name is required"), +}); + +interface ImportFantomeDialogProps { + open: boolean; + filePath: string | null; + peekResult: FantomePeekResult | null; + progress: FantomeImportProgress | null; + onClose: () => void; + onSubmit: (args: ImportFantomeArgs) => void; + isPending: boolean; +} + +export function ImportFantomeDialog({ + open, + filePath, + peekResult, + progress, + onClose, + onSubmit, + isPending, +}: ImportFantomeDialogProps) { + const isImporting = isPending || (progress !== null && progress.stage !== "complete"); + + const form = useAppForm({ + defaultValues: { + name: peekResult?.suggestedName ?? "", + displayName: peekResult?.name ?? "", + }, + validators: { + onChange: importSchema, + }, + onSubmit: ({ value }) => { + if (!filePath) return; + onSubmit({ + filePath, + name: value.name, + displayName: value.displayName, + }); + }, + }); + + useEffect(() => { + if (peekResult) { + form.reset({ + name: peekResult.suggestedName ?? "", + displayName: peekResult.name ?? "", + }); + } + }, [form, filePath, peekResult]); + + function handleClose() { + if (isImporting) return; + form.reset(); + onClose(); + } + + return ( + !open && handleClose()}> + + + + + Import from Fantome + {!isImporting && } + + +
{ + e.preventDefault(); + form.handleSubmit(); + }} + > + + {peekResult && ( +
+
+
+ Author +

{peekResult.author || "Unknown"}

+
+
+ Version +

{peekResult.version || "Unknown"}

+
+
+ {peekResult.description && ( +
+ Description +

{peekResult.description}

+
+ )} +
+ + WAD Files ({peekResult.wadFiles.length}) + +
+ {peekResult.wadFiles.map((wad) => ( +
+ {wad} +
+ ))} + {peekResult.wadFiles.length === 0 && ( +

No WAD files found

+ )} +
+
+
+ )} + + + {(field) => ( + value.toLowerCase()} + /> + )} + + + + {(field) => ( + + )} + + + {progress && progress.stage !== "complete" && ( +
+
+ + {progress.stage === "extracting" && "Extracting WADs..."} + {progress.stage === "finalizing" && "Finalizing..."} + {progress.stage === "error" && "Error occurred"} + + {progress.total > 0 && ( + + {progress.current}/{progress.total} + + )} +
+ {progress.currentWad && ( +

+ {progress.currentWad} +

+ )} +
+
0 + ? `${(progress.current / progress.total) * 100}%` + : "0%", + }} + /> +
+
+ )} + + + + + ({ canSubmit: state.canSubmit, isValid: state.isValid })} + > + {({ canSubmit, isValid }) => ( + + )} + + + + + + + ); +} diff --git a/src/modules/workshop/components/ImportGitRepoDialog.tsx b/src/modules/workshop/components/ImportGitRepoDialog.tsx new file mode 100644 index 0000000..6a2c91e --- /dev/null +++ b/src/modules/workshop/components/ImportGitRepoDialog.tsx @@ -0,0 +1,143 @@ +import { z } from "zod"; + +import { Button, Dialog } from "@/components"; +import { useAppForm } from "@/lib/form"; +import type { GitImportProgress, ImportGitRepoArgs } from "@/lib/tauri"; + +const importSchema = z.object({ + url: z + .string() + .min(1, "Repository URL is required") + .regex( + /^https?:\/\/github\.com\/[^/]+\/[^/]+\/?$/, + "Must be a GitHub repository URL (https://github.com/owner/repo)", + ), + branch: z.string(), +}); + +interface ImportGitRepoDialogProps { + open: boolean; + progress: GitImportProgress | null; + onClose: () => void; + onSubmit: (args: ImportGitRepoArgs) => void; + isPending: boolean; +} + +export function ImportGitRepoDialog({ + open, + progress, + onClose, + onSubmit, + isPending, +}: ImportGitRepoDialogProps) { + const isImporting = isPending || (progress !== null && progress.stage !== "complete"); + + const form = useAppForm({ + defaultValues: { + url: "", + branch: "", + }, + validators: { + onChange: importSchema, + }, + onSubmit: ({ value }) => { + onSubmit({ + url: value.url, + branch: value.branch || undefined, + }); + }, + }); + + function handleClose() { + if (isImporting) return; + form.reset(); + onClose(); + } + + return ( + !open && handleClose()}> + + + + + Import from Git Repository + {!isImporting && } + + +
{ + e.preventDefault(); + form.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + {progress && progress.stage !== "complete" && ( +
+
+ {progress.stage === "error" && ( + Error occurred + )} + {progress.stage === "downloading" && ( + Downloading repository... + )} + {progress.stage === "extracting" && ( + Extracting files... + )} +
+ {progress.stage !== "error" && ( +
+
+
+ )} +
+ )} + + + + + ({ canSubmit: state.canSubmit, isValid: state.isValid })} + > + {({ canSubmit, isValid }) => ( + + )} + + + + + + + ); +} diff --git a/src/modules/workshop/components/WorkshopToolbar.tsx b/src/modules/workshop/components/WorkshopToolbar.tsx index b2dc8f8..7aaaf82 100644 --- a/src/modules/workshop/components/WorkshopToolbar.tsx +++ b/src/modules/workshop/components/WorkshopToolbar.tsx @@ -1,6 +1,16 @@ -import { LuDownload, LuGrid3X3, LuList, LuPlus, LuSearch } from "react-icons/lu"; +import { + LuChevronDown, + LuDownload, + LuFileArchive, + LuGitBranch, + LuGrid3X3, + LuList, + LuPackage, + LuPlus, + LuSearch, +} from "react-icons/lu"; -import { Button, IconButton } from "@/components"; +import { Button, IconButton, Menu } from "@/components"; export type ViewMode = "grid" | "list"; @@ -9,7 +19,9 @@ interface WorkshopToolbarProps { onSearchChange: (query: string) => void; viewMode: ViewMode; onViewModeChange: (mode: ViewMode) => void; - onImport: () => void; + onImportModpkg: () => void; + onImportFantome: () => void; + onImportGitRepo: () => void; onNewProject: () => void; isImporting?: boolean; } @@ -19,7 +31,9 @@ export function WorkshopToolbar({ onSearchChange, viewMode, onViewModeChange, - onImport, + onImportModpkg, + onImportFantome, + onImportGitRepo, onNewProject, isImporting, }: WorkshopToolbarProps) { @@ -57,15 +71,36 @@ export function WorkshopToolbar({
{/* Actions */} - + + } + right={} + > + Import + + } + /> + + + + } onClick={onImportFantome}> + From Fantome + + } onClick={onImportModpkg}> + From Modpkg + + } onClick={onImportGitRepo}> + From Git Repository + + + + +
); } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index c43f04c..2a436f2 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -13,6 +13,7 @@ export type ErrorCode = | "MOD_NOT_FOUND" | "VALIDATION_FAILED" | "INTERNAL_STATE" + | "FANTOME" | "PATCHER_RUNNING" | "UNKNOWN";