diff --git a/src-tauri/src/commands/workshop.rs b/src-tauri/src/commands/workshop.rs index fe09a34..03b2b6d 100644 --- a/src-tauri/src/commands/workshop.rs +++ b/src-tauri/src/commands/workshop.rs @@ -1,9 +1,9 @@ use crate::error::{AppResult, IpcResult, MutexResultExt}; use crate::state::SettingsState; use crate::workshop::{ - ContentTree, CreateProjectArgs, FantomePeekResult, ImportFantomeArgs, ImportGitRepoArgs, - PackProjectArgs, PackResult, SaveProjectConfigArgs, ValidationResult, WorkshopLayerInfo, - WorkshopProject, WorkshopState, + AddFilesReport, ContentTree, CreateProjectArgs, FantomePeekResult, ImportFantomeArgs, + ImportGitRepoArgs, PackProjectArgs, PackResult, SaveProjectConfigArgs, ValidationResult, + WorkshopLayerInfo, WorkshopProject, WorkshopState, }; use std::collections::HashMap; use tauri::State; @@ -253,3 +253,16 @@ pub fn reorder_project_layers( ) -> IpcResult { workshop.0.reorder_layers(&project_path, layer_names).into() } + +#[tauri::command] +pub fn add_files_to_layer( + project_path: String, + layer_name: String, + sources: Vec, + workshop: State, +) -> IpcResult { + workshop + .0 + .add_files_to_layer(&project_path, &layer_name, sources) + .into() +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 5f12796..71c02b3 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -46,6 +46,8 @@ pub enum ErrorCode { Zip, /// Library index was written by a newer app version SchemaVersionTooNew, + /// Workshop domain error. The specific variant is in `context.kind`. + Workshop, } /// Structured error response sent over IPC. @@ -212,6 +214,9 @@ pub enum AppError { file_version: u32, max_supported: u32, }, + + #[error(transparent)] + Workshop(#[from] crate::workshop::WorkshopError), } impl From for AppErrorResponse { @@ -291,6 +296,12 @@ impl From for AppErrorResponse { ), ) .with_context(serde_json::json!({ "fileVersion": file_version, "maxSupported": max_supported })), + + AppError::Workshop(workshop_err) => { + let mut response = AppErrorResponse::new(ErrorCode::Workshop, workshop_err.to_string()); + response.context = serde_json::to_value(&workshop_err).ok(); + response + } } } } @@ -380,6 +391,7 @@ mod tests { ErrorCode::PatcherRunning, ErrorCode::Zip, ErrorCode::SchemaVersionTooNew, + ErrorCode::Workshop, ] { let json = serde_json::to_string(&code).unwrap(); let deserialized: ErrorCode = serde_json::from_str(&json).unwrap(); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9c85ccf..2a0927f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -135,6 +135,7 @@ fn main() { commands::delete_project_layer, commands::reorder_project_layers, commands::update_layer_description, + commands::add_files_to_layer, // Deep Link commands::deep_link_install_mod, // for dynamic icons diff --git a/src-tauri/src/workshop/layers.rs b/src-tauri/src/workshop/layers.rs index 93a5c11..7493492 100644 --- a/src-tauri/src/workshop/layers.rs +++ b/src-tauri/src/workshop/layers.rs @@ -1,11 +1,15 @@ use super::{ - is_valid_project_name, load_workshop_project, Workshop, WorkshopLayerInfo, WorkshopProject, + is_valid_project_name, load_workshop_project, AddFilesReport, Workshop, WorkshopError, + WorkshopLayerInfo, WorkshopProject, }; use crate::error::{AppError, AppResult}; +use camino::Utf8Path; use ltk_mod_project::ModProject; use ltk_mod_project::ModProjectLayer; +use ltk_wad::{HexPathResolver, Wad, WadExtractor}; use std::collections::HashMap; use std::fs; +use std::io::BufReader; use std::path::{Path, PathBuf}; /// Create a new layer in a project at the given path. @@ -275,6 +279,150 @@ fn is_wad_entry(name: &str) -> bool { lower.ends_with(".wad.client") || lower.ends_with(".wad") || lower.ends_with(".wad.mobile") } +/// Extract a packed WAD file into `dst` using hex-named paths (no hashtable). +fn extract_wad_into_dir(src: &Path, dst: &Path) -> AppResult<()> { + fs::create_dir_all(dst)?; + + let file = fs::File::open(src)?; + let mut wad = Wad::mount(BufReader::new(file))?; + + let resolver = HexPathResolver; + let extractor = WadExtractor::new(&resolver); + let utf8_dst = Utf8Path::from_path(dst).ok_or_else(|| { + AppError::Other(format!( + "WAD output path is not valid UTF-8: {}", + dst.display() + )) + })?; + extractor.extract_all(&mut wad, utf8_dst)?; + Ok(()) +} + +/// Recursively copy `src` directory into `dst`, skipping symlinks. +fn copy_dir_recursive(src: &Path, dst: &Path) -> AppResult<()> { + fs::create_dir_all(dst)?; + for entry in walkdir::WalkDir::new(src).follow_links(false).into_iter() { + let entry = entry.map_err(|e| AppError::Io(std::io::Error::other(e.to_string())))?; + let file_type = entry.file_type(); + if file_type.is_symlink() { + continue; + } + let rel = entry + .path() + .strip_prefix(src) + .map_err(|e| AppError::Other(e.to_string()))?; + let target = dst.join(rel); + if file_type.is_dir() { + fs::create_dir_all(&target)?; + } else if file_type.is_file() { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(entry.path(), &target)?; + } + } + Ok(()) +} + +/// Add files or directories (`.wad`, `.wad.client`, `.wad.mobile`) into a layer's content +/// directory. Packed WAD files are extracted into a same-named directory using hex-named +/// paths (no hashtable); directory sources are copied as-is. If any source conflicts with +/// an existing entry, no source is imported. +pub(crate) fn add_files_to_layer_at_path( + project_path: &Path, + layer_name: &str, + sources: Vec, +) -> AppResult { + let layer_dir = get_layer_content_path(project_path, layer_name)?; + + let mut canonical_seen = std::collections::HashSet::::new(); + let mut prepared: Vec<(PathBuf, String)> = Vec::with_capacity(sources.len()); + + for src in sources { + if !src.exists() { + return Err(AppError::ValidationFailed(format!( + "Source does not exist: {}", + src.display() + ))); + } + + let canonical = fs::canonicalize(&src).unwrap_or_else(|_| src.clone()); + if !canonical_seen.insert(canonical.clone()) { + continue; + } + + let basename = canonical + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| { + AppError::ValidationFailed(format!( + "Source has no usable file name: {}", + src.display() + )) + })? + .to_string(); + + if !is_wad_entry(&basename) { + return Err(AppError::ValidationFailed(format!( + "'{}' is not a WAD file or folder (must end in .wad, .wad.client, or .wad.mobile)", + basename + ))); + } + + prepared.push((canonical, basename)); + } + + let conflicts: Vec = prepared + .iter() + .filter(|(_, name)| layer_dir.join(name).exists()) + .map(|(_, name)| name.clone()) + .collect(); + if !conflicts.is_empty() { + return Err(WorkshopError::LayerFileConflict { conflicts }.into()); + } + + let mut added: Vec = Vec::with_capacity(prepared.len()); + for (src, basename) in prepared { + let dest = layer_dir.join(&basename); + let temp = layer_dir.join(format!(".{}.tmp", basename)); + + if temp.exists() { + if temp.is_dir() { + let _ = fs::remove_dir_all(&temp); + } else { + let _ = fs::remove_file(&temp); + } + } + + let was_packed = src.is_file(); + let result = if was_packed { + extract_wad_into_dir(&src, &temp) + } else { + copy_dir_recursive(&src, &temp) + }; + + if let Err(e) = result { + let _ = fs::remove_dir_all(&temp); + return Err(e); + } + + if let Err(e) = fs::rename(&temp, &dest) { + let _ = fs::remove_dir_all(&temp); + return Err(AppError::Io(e)); + } + + tracing::info!( + layer = %layer_name, + file = %basename, + extracted = was_packed, + "Added WAD entry to layer" + ); + added.push(basename); + } + + Ok(AddFilesReport { added }) +} + /// Collect runtime info about each layer's content directory. pub(crate) fn get_layer_info_at_path( path: &Path, @@ -416,6 +564,21 @@ impl Workshop { } reorder_layers_at_path(&path, layer_names) } + + /// Add files or folders to a layer's content directory. + pub fn add_files_to_layer( + &self, + project_path: &str, + layer_name: &str, + sources: Vec, + ) -> AppResult { + let path = PathBuf::from(project_path); + if !path.exists() { + return Err(AppError::ProjectNotFound(project_path.to_string())); + } + let source_paths = sources.into_iter().map(PathBuf::from).collect(); + add_files_to_layer_at_path(&path, layer_name, source_paths) + } } #[cfg(test)] @@ -645,6 +808,127 @@ mod tests { assert_eq!(layers[1].priority, 1); } + fn build_test_wad(path: &std::path::Path, chunk_paths: &[&str]) { + use ltk_wad::{WadBuilder, WadChunkBuilder}; + use std::io::Write; + + let mut builder = WadBuilder::default(); + for chunk_path in chunk_paths { + builder = builder.with_chunk(WadChunkBuilder::default().with_path(*chunk_path)); + } + + let mut file = fs::File::create(path).unwrap(); + builder + .build_to_writer(&mut file, |_path_hash, cursor| { + cursor.write_all(&[0xAA; 64])?; + Ok(()) + }) + .unwrap(); + } + + #[test] + fn add_files_to_layer_extracts_wad_file() { + let dir = tempfile::tempdir().unwrap(); + make_project_with_layers(dir.path(), ltk_mod_project::default_layers()); + + let src_dir = tempfile::tempdir().unwrap(); + let src_file = src_dir.path().join("Aatrox.wad.client"); + build_test_wad(&src_file, &["assets/test1.bin", "assets/test2.bin"]); + + let report = add_files_to_layer_at_path(dir.path(), "base", vec![src_file]).unwrap(); + + assert_eq!(report.added, vec!["Aatrox.wad.client".to_string()]); + let dest = dir + .path() + .join("content") + .join("base") + .join("Aatrox.wad.client"); + assert!( + dest.is_dir(), + "expected extracted directory at {}", + dest.display() + ); + + let extracted: Vec<_> = fs::read_dir(&dest) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + assert!( + !extracted.is_empty(), + "expected at least one extracted entry under {}", + dest.display() + ); + } + + #[test] + fn add_files_to_layer_copies_directory() { + let dir = tempfile::tempdir().unwrap(); + make_project_with_layers(dir.path(), ltk_mod_project::default_layers()); + + let src_dir = tempfile::tempdir().unwrap(); + let wad_dir = src_dir.path().join("Champion.wad.client"); + fs::create_dir_all(wad_dir.join("nested")).unwrap(); + fs::write(wad_dir.join("meta.json"), "{}").unwrap(); + fs::write(wad_dir.join("nested").join("a.bin"), b"x").unwrap(); + + let report = add_files_to_layer_at_path(dir.path(), "base", vec![wad_dir]).unwrap(); + + assert_eq!(report.added, vec!["Champion.wad.client".to_string()]); + let dest = dir + .path() + .join("content") + .join("base") + .join("Champion.wad.client"); + assert!(dest.is_dir()); + assert!(dest.join("meta.json").is_file()); + assert!(dest.join("nested").join("a.bin").is_file()); + } + + #[test] + fn add_files_to_layer_rejects_non_wad() { + let dir = tempfile::tempdir().unwrap(); + make_project_with_layers(dir.path(), ltk_mod_project::default_layers()); + + let src_dir = tempfile::tempdir().unwrap(); + let bad = src_dir.path().join("readme.txt"); + fs::write(&bad, b"hi").unwrap(); + + let result = add_files_to_layer_at_path(dir.path(), "base", vec![bad]); + assert!(matches!(result, Err(AppError::ValidationFailed(_)))); + } + + #[test] + fn add_files_to_layer_aborts_on_conflict() { + let dir = tempfile::tempdir().unwrap(); + make_project_with_layers(dir.path(), ltk_mod_project::default_layers()); + + let layer_dir = dir.path().join("content").join("base"); + fs::create_dir_all(&layer_dir).unwrap(); + fs::write(layer_dir.join("Aatrox.wad.client"), b"existing").unwrap(); + + let src_dir = tempfile::tempdir().unwrap(); + let new_a = src_dir.path().join("Aatrox.wad.client"); + let new_b = src_dir.path().join("Sona.wad.client"); + fs::write(&new_a, b"new").unwrap(); + fs::write(&new_b, b"new").unwrap(); + + let result = add_files_to_layer_at_path(dir.path(), "base", vec![new_a, new_b]); + match result { + Err(AppError::Workshop(WorkshopError::LayerFileConflict { conflicts })) => { + assert_eq!(conflicts, vec!["Aatrox.wad.client".to_string()]); + } + other => panic!("expected LayerFileConflict, got: {:?}", other), + } + + // Sona.wad.client must not have been copied. + assert!(!layer_dir.join("Sona.wad.client").exists()); + // Existing file untouched. + assert_eq!( + fs::read(layer_dir.join("Aatrox.wad.client")).unwrap(), + b"existing" + ); + } + #[test] fn update_layer_description_persists() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/workshop/mod.rs b/src-tauri/src/workshop/mod.rs index 5aa4845..18c5438 100644 --- a/src-tauri/src/workshop/mod.rs +++ b/src-tauri/src/workshop/mod.rs @@ -14,8 +14,22 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use tauri::AppHandle; +use thiserror::Error; use ts_rs::TS; +/// Domain errors specific to workshop operations. +/// +/// Sent over IPC as the `context` payload of an `AppError` with code `WORKSHOP`. +/// Frontend code can switch on `kind` to handle each variant. +#[derive(Debug, Clone, Serialize, Deserialize, Error, TS)] +#[ts(export)] +#[serde(tag = "kind", rename_all = "SCREAMING_SNAKE_CASE")] +pub enum WorkshopError { + /// One or more files already exist in the target layer directory. + #[error("File(s) already exist in target layer: {conflicts:?}")] + LayerFileConflict { conflicts: Vec }, +} + /// Managed struct that encapsulates workshop operations. /// /// Holds the `AppHandle` for consistency with `ModLibrary`. @@ -250,6 +264,15 @@ pub struct PackResult { pub format: String, } +/// Result of adding files/folders to a layer. +#[derive(Debug, Clone, Serialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct AddFilesReport { + /// Basenames of items added to the layer directory. + pub added: Vec, +} + /// Validation result for a project. #[derive(Debug, Clone, Serialize, TS)] #[ts(export)] diff --git a/src/lib/bindings/AddFilesReport.ts b/src/lib/bindings/AddFilesReport.ts new file mode 100644 index 0000000..5176862 --- /dev/null +++ b/src/lib/bindings/AddFilesReport.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Result of adding files/folders to a layer. + */ +export type AddFilesReport = { + /** + * Basenames of items added to the layer directory. + */ + added: Array; +}; diff --git a/src/lib/bindings/EditModMetadataArgs.ts b/src/lib/bindings/EditModMetadataArgs.ts index ef5a7d6..fae8dea 100644 --- a/src/lib/bindings/EditModMetadataArgs.ts +++ b/src/lib/bindings/EditModMetadataArgs.ts @@ -5,6 +5,6 @@ export type EditModMetadataArgs = { tags: Array | null; champions: Array | null; maps: Array | null; - setThumbnailPath?: string | null; - removeThumbnail?: boolean | null; + setThumbnailPath: string | null; + removeThumbnail: boolean | null; }; diff --git a/src/lib/bindings/ErrorCode.ts b/src/lib/bindings/ErrorCode.ts index 5ebc498..bc69ce3 100644 --- a/src/lib/bindings/ErrorCode.ts +++ b/src/lib/bindings/ErrorCode.ts @@ -23,4 +23,5 @@ export type ErrorCode = | "WAD" | "PATCHER_RUNNING" | "ZIP" - | "SCHEMA_VERSION_TOO_NEW"; + | "SCHEMA_VERSION_TOO_NEW" + | "WORKSHOP"; diff --git a/src/lib/bindings/Severity.ts b/src/lib/bindings/Severity.ts index 40210f9..9145b1d 100644 --- a/src/lib/bindings/Severity.ts +++ b/src/lib/bindings/Severity.ts @@ -1,6 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Severity of a diagnostic check result. Ordered from best to worst. + * Severity of a diagnostic check result. + * + * Variants are declared best-to-worst (`Ok < Info < Warn < Bad`). The + * frontend re-sorts to display worst-first; do not derive `Ord` from this + * declaration order without revisiting the UI sort logic in + * `DiagnosticsReport.tsx`. */ export type Severity = "ok" | "info" | "warn" | "bad"; diff --git a/src/lib/bindings/WorkshopError.ts b/src/lib/bindings/WorkshopError.ts new file mode 100644 index 0000000..048b6e2 --- /dev/null +++ b/src/lib/bindings/WorkshopError.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Domain errors specific to workshop operations. + * + * Sent over IPC as the `context` payload of an `AppError` with code `WORKSHOP`. + * Frontend code can switch on `kind` to handle each variant. + */ +export type WorkshopError = { kind: "LAYER_FILE_CONFLICT"; conflicts: Array }; diff --git a/src/lib/bindings/index.ts b/src/lib/bindings/index.ts index 5108152..9563c1a 100644 --- a/src/lib/bindings/index.ts +++ b/src/lib/bindings/index.ts @@ -1,4 +1,5 @@ export type { AccentColor } from "./AccentColor"; +export type { AddFilesReport } from "./AddFilesReport"; export type { AppError } from "./AppError"; export type { AppInfo } from "./AppInfo"; export type { AuthorProfile } from "./AuthorProfile"; @@ -51,6 +52,7 @@ export type { Theme } from "./Theme"; export type { ValidationResult } from "./ValidationResult"; export type { WadBlocklistEntry } from "./WadBlocklistEntry"; export type { WorkshopAuthor } from "./WorkshopAuthor"; +export type { WorkshopError } from "./WorkshopError"; export type { WorkshopFileKind } from "./WorkshopFileKind"; export type { WorkshopLayer } from "./WorkshopLayer"; export type { WorkshopLayerInfo } from "./WorkshopLayerInfo"; diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 3d257de..d6e73c5 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { + AddFilesReport, AppError, AppInfo, BulkInstallResult, @@ -252,4 +253,6 @@ export const api = { layerName, description, }), + addFilesToLayer: (projectPath: string, layerName: string, sources: string[]) => + invokeResult("add_files_to_layer", { projectPath, layerName, sources }), }; diff --git a/src/modules/workshop/api/index.ts b/src/modules/workshop/api/index.ts index 5999ae0..b2e3942 100644 --- a/src/modules/workshop/api/index.ts +++ b/src/modules/workshop/api/index.ts @@ -1,4 +1,5 @@ export { workshopKeys } from "./keys"; +export { useAddFilesToLayer } from "./useAddFilesToLayer"; export { useCreateProject } from "./useCreateProject"; export { useDeleteProject } from "./useDeleteProject"; export { useFantomeImportProgress } from "./useFantomeImportProgress"; @@ -8,6 +9,7 @@ export { useGitImportProgress } from "./useGitImportProgress"; export { useImportFromFantome } from "./useImportFromFantome"; export { useImportFromGitRepo } from "./useImportFromGitRepo"; export { useImportFromModpkg } from "./useImportFromModpkg"; +export { useLayerFileDrop } from "./useLayerFileDrop"; export { usePackProject } from "./usePackProject"; export { usePeekFantome } from "./usePeekFantome"; export { useProjectActions } from "./useProjectActions"; diff --git a/src/modules/workshop/api/useAddFilesToLayer.ts b/src/modules/workshop/api/useAddFilesToLayer.ts new file mode 100644 index 0000000..a080fe2 --- /dev/null +++ b/src/modules/workshop/api/useAddFilesToLayer.ts @@ -0,0 +1,61 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; + +import { useToast } from "@/components"; +import { type AddFilesReport, api, type AppError } from "@/lib/tauri"; +import { unwrapForQuery } from "@/utils/query"; + +import { workshopKeys } from "./keys"; + +interface AddFilesArgs { + projectPath: string; + layerName: string; + layerDisplayName: string; + sources: string[]; +} + +const WorkshopErrorContextSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("LAYER_FILE_CONFLICT"), conflicts: z.array(z.string()) }), +]); + +function describeConflicts(error: AppError): string | undefined { + if (error.code !== "WORKSHOP" || !error.context) return undefined; + const parsed = WorkshopErrorContextSchema.safeParse(error.context); + if (!parsed.success) return undefined; + if (parsed.data.kind !== "LAYER_FILE_CONFLICT") return undefined; + const items = parsed.data.conflicts; + if (items.length === 0) return undefined; + if (items.length === 1) return `${items[0]} already exists in this layer.`; + if (items.length <= 3) return `${items.join(", ")} already exist in this layer.`; + return `${items.slice(0, 2).join(", ")} and ${items.length - 2} more already exist in this layer.`; +} + +export function useAddFilesToLayer() { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation({ + mutationFn: async ({ projectPath, layerName, sources }) => { + const result = await api.addFilesToLayer(projectPath, layerName, sources); + return unwrapForQuery(result); + }, + onSuccess: (report, { projectPath, layerDisplayName }) => { + queryClient.invalidateQueries({ queryKey: workshopKeys.contentTree(projectPath) }); + const count = report.added.length; + if (count > 0) { + toast.success( + `Added ${count} ${count === 1 ? "item" : "items"} to ${layerDisplayName}`, + report.added.join(", "), + ); + } + }, + onError: (error) => { + const conflicts = describeConflicts(error); + if (conflicts) { + toast.error("Couldn't add files", conflicts); + return; + } + toast.error("Couldn't add files", error.message); + }, + }); +} diff --git a/src/modules/workshop/api/useLayerFileDrop.ts b/src/modules/workshop/api/useLayerFileDrop.ts new file mode 100644 index 0000000..9306c00 --- /dev/null +++ b/src/modules/workshop/api/useLayerFileDrop.ts @@ -0,0 +1,49 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { useEffect, useState } from "react"; + +const WAD_SUFFIXES = [".wad.client", ".wad.mobile", ".wad"]; + +function basename(path: string): string { + const norm = path.replace(/[\\/]+$/, ""); + const slashIndex = Math.max(norm.lastIndexOf("/"), norm.lastIndexOf("\\")); + return slashIndex >= 0 ? norm.slice(slashIndex + 1) : norm; +} + +function isWadPath(path: string): boolean { + const lower = basename(path).toLowerCase(); + return WAD_SUFFIXES.some((suffix) => lower.endsWith(suffix)); +} + +/** + * Listen for OS-level drag-drop of WAD files/folders onto the window. + * Mirrors `useModFileDrop`; matches `.wad`, `.wad.client`, and `.wad.mobile` + * by basename (directory or file). + */ +export function useLayerFileDrop(onDrop: (paths: string[]) => void): boolean { + const [isDragOver, setIsDragOver] = useState(false); + + useEffect(() => { + const currentWindow = getCurrentWindow(); + const unlisten = currentWindow.onDragDropEvent((event) => { + const eventType = event.payload.type; + if (eventType === "enter" || eventType === "over") { + setIsDragOver(true); + } else if (eventType === "drop") { + setIsDragOver(false); + const paths = event.payload.paths as string[]; + const validPaths = paths.filter(isWadPath); + if (validPaths.length > 0) { + onDrop(validPaths); + } + } else if (eventType === "leave" || eventType === "cancel") { + setIsDragOver(false); + } + }); + + return () => { + unlisten.then((fn) => fn()); + }; + }, [onDrop]); + + return isDragOver; +} diff --git a/src/modules/workshop/components/ContentBrowser.tsx b/src/modules/workshop/components/ContentBrowser.tsx index 301c054..954034e 100644 --- a/src/modules/workshop/components/ContentBrowser.tsx +++ b/src/modules/workshop/components/ContentBrowser.tsx @@ -1,11 +1,12 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Spinner } from "@/components"; import type { LayerContent, WorkshopProject } from "@/lib/tauri"; -import { useProjectContentTree } from "../api"; +import { useAddFilesToLayer, useLayerFileDrop, useProjectContentTree } from "../api"; import { ContentBrowserLayerRail } from "./ContentBrowserLayerRail"; import { ContentBrowserLayerSection } from "./ContentBrowserLayerSection"; +import { LayerFileDropOverlay } from "./LayerFileDropOverlay"; interface ContentBrowserProps { project: WorkshopProject; @@ -35,8 +36,32 @@ export function ContentBrowser({ project }: ContentBrowserProps) { } }, [selectedLayer, selectedLayerName]); + const selectedLayerDisplayName = useMemo(() => { + if (!selectedLayer) return ""; + const layerMeta = project.layers.find((l) => l.name === selectedLayer.name); + return layerMeta?.displayName ?? selectedLayer.name; + }, [project.layers, selectedLayer]); + + const addFilesToLayer = useAddFilesToLayer(); + + const handleDrop = useCallback( + (paths: string[]) => { + if (!selectedLayer) return; + addFilesToLayer.mutate({ + projectPath, + layerName: selectedLayer.name, + layerDisplayName: selectedLayerDisplayName, + sources: paths, + }); + }, + [addFilesToLayer, projectPath, selectedLayer, selectedLayerDisplayName], + ); + + const isDragOver = useLayerFileDrop(handleDrop); + const showDropOverlay = isDragOver && selectedLayer !== null; + return ( -
+
refetch()} /> @@ -74,6 +100,8 @@ export function ContentBrowser({ project }: ContentBrowserProps) {
)}
+ + ); } diff --git a/src/modules/workshop/components/ContentBrowserLayerSection.tsx b/src/modules/workshop/components/ContentBrowserLayerSection.tsx index 737f422..0ea89ee 100644 --- a/src/modules/workshop/components/ContentBrowserLayerSection.tsx +++ b/src/modules/workshop/components/ContentBrowserLayerSection.tsx @@ -1,14 +1,25 @@ -import { FolderOpen, Layers, RefreshCw } from "lucide-react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { + ChevronDown, + FileArchive, + Folder, + FolderOpen, + Layers, + Plus, + RefreshCw, +} from "lucide-react"; -import { IconButton, Tooltip } from "@/components"; +import { Button, IconButton, Menu, Tooltip } from "@/components"; import { api, type LayerContent } from "@/lib/tauri"; import { formatBytes } from "@/utils"; +import { useAddFilesToLayer } from "../api"; import { ContentTree } from "./ContentTree"; interface ContentBrowserLayerSectionProps { projectPath: string; layer: LayerContent; + layerDisplayName: string; isRefreshing: boolean; onRefresh: () => void; } @@ -16,13 +27,45 @@ interface ContentBrowserLayerSectionProps { export function ContentBrowserLayerSection({ projectPath, layer, + layerDisplayName, isRefreshing, onRefresh, }: ContentBrowserLayerSectionProps) { + const addFilesToLayer = useAddFilesToLayer(); + async function handleOpenFolder() { await api.revealInExplorer(`${projectPath}/content/${layer.name}`); } + function dispatchAdd(sources: string[]) { + if (sources.length === 0) return; + addFilesToLayer.mutate({ + projectPath, + layerName: layer.name, + layerDisplayName, + sources, + }); + } + + async function handleAddFiles() { + const selection = await open({ + multiple: true, + filters: [ + { name: "WAD files", extensions: ["wad", "client", "mobile"] }, + { name: "All files", extensions: ["*"] }, + ], + }); + if (!selection) return; + const paths = Array.isArray(selection) ? selection : [selection]; + dispatchAdd(paths); + } + + async function handleAddFolder() { + const selection = await open({ directory: true, multiple: false }); + if (!selection) return; + dispatchAdd([selection as string]); + } + return (
@@ -35,6 +78,33 @@ export function ContentBrowserLayerSection({
+ + } + right={} + > + Add WAD + + } + /> + + + + } onClick={handleAddFiles}> + Add WAD file… + + } onClick={handleAddFolder}> + Add WAD folder… + + + + + + {visible && ( + + + +
+

+ Drop to add to {layerDisplayName} +

+

+ .wad, .wad.client, or .wad.mobile files and folders +

+
+
+
+ )} + + ); +} diff --git a/src/modules/workshop/components/index.ts b/src/modules/workshop/components/index.ts index cb818dd..9fee776 100644 --- a/src/modules/workshop/components/index.ts +++ b/src/modules/workshop/components/index.ts @@ -11,6 +11,7 @@ export { } from "./EmptyStates"; export { ImportFantomeDialog } from "./ImportFantomeDialog"; export { ImportGitRepoDialog } from "./ImportGitRepoDialog"; +export { LayerFileDropOverlay } from "./LayerFileDropOverlay"; export { NewProjectDialog } from "./NewProjectDialog"; export { appendAuthor,