diff --git a/package.json b/package.json index d44dc5f..cf073b7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "exbyte-depot", "private": true, - "version": "0.2.2", + "version": "0.2.3", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1e6d48e..d1a23d2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exbyte-depot" -version = "0.2.2" +version = "0.2.3" description = "Exbyte Depot — native Perforce client by Exbyte Studios" authors = ["Exbyte Studios"] edition = "2021" diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index b768f42..9bd3d4d 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 51428da..5f23385 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 9d738d9..4e8e7aa 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index 272b6ce..4a37801 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 16abc40..27e8dc8 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index 81a280c..c2f3120 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 10cdac0..1dee881 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index ac0b4d5..578faf4 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 906c26f..3e27129 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 2909f69..7282d21 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index c4961fc..ebd0c95 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 8163b65..ab898f9 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index 13a19dc..5946bb2 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index d311bee..c73b8ed 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index 90da8e2..f4a059d 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png index 99adf19..67f37f4 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png index 318b66e..45c2cd4 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png index d9232ae..62c7f04 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png index e0bc3ff..85ffc39 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png index a4600ff..2e96ef0 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png index 6344fbe..0ab5ffa 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index 1d17804..16d8838 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png index 56a4ab9..7be82ca 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index f55be47..94955d0 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index edc9391..1d87712 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png index 80419f1..c8c61b7 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index d0b1643..11e3459 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index 0ed971d..1d6e050 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png index 320c635..9f2d629 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/app-icon-src.png b/src-tauri/icons/app-icon-src.png new file mode 100644 index 0000000..86c3bef Binary files /dev/null and b/src-tauri/icons/app-icon-src.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 4caf5e0..a19db1d 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 00c6685..294a633 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index efdda78..5cc82e8 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png index 3d5a84b..bd88566 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png index 73212ce..2d1b292 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png index 73212ce..2d1b292 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png index 47a077a..f3eba20 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png index 6f3724e..d00c9be 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png index c3ac935..d2facf1 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png index c3ac935..d2facf1 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png index d2c030e..9641383 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png index 73212ce..2d1b292 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png index 263b9e0..447677e 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png index 263b9e0..447677e 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png index 4508502..c6cd840 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png index 56ba0f4..c537d64 100644 Binary files a/src-tauri/icons/ios/AppIcon-512@2x.png and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png index 4508502..c6cd840 100644 Binary files a/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png index d2581be..3927777 100644 Binary files a/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png index 17b893a..953eb7e 100644 Binary files a/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png index 137d49b..e8df19c 100644 Binary files a/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index 83e1463..599f4cf 100644 Binary files a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4f33cf8..c5b4a1f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,7 +5,7 @@ use serde_json::Value; use std::io::{BufRead, BufReader, Write}; use std::process::{Command, Stdio}; use std::sync::{Mutex, OnceLock}; -use std::time::Instant; +use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter, Manager, State}; /// App handle stashed at startup so the low-level p4 helpers can emit a live @@ -1145,6 +1145,32 @@ async fn read_file(state: State<'_, AppState>, path: String) -> Result, depot: String, content: String) -> Result<(), String> { + let conn = current(&state)?; + let local = run_json(&conn, &["where", &depot])? + .into_iter() + .next() + .and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from)) + .ok_or_else(|| "Could not resolve the file path".to_string())?; + ensure_under_root(&conn, &local)?; + // ensure the file is open for edit (ignored for a not-yet-submitted add, or + // if it is already open); this also clears the read-only attribute + let _ = run_text(&conn, &["edit", &depot]); + // belt-and-suspenders: clear read-only in case `p4 edit` didn't apply + if let Ok(md) = std::fs::metadata(&local) { + let mut perms = md.permissions(); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + let _ = std::fs::set_permissions(&local, perms); + } + std::fs::write(&local, content.as_bytes()).map_err(|e| format!("Could not write file: {e}"))?; + Ok(()) +} + /// Read the local working-copy bytes of a depot file (resolves via `p4 where`). #[tauri::command] async fn read_depot(state: State<'_, AppState>, depot: String) -> Result { @@ -1288,6 +1314,297 @@ async fn p4_unlock(state: State<'_, AppState>, files: Vec) -> Result Option> { + const SIG: [u8; 8] = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + let mut best: Option<(usize, usize)> = None; // (start, end) + let mut i = 0usize; + while i + 8 < b.len() { + if b[i..i + 8] == SIG { + // find the IEND chunk that closes this PNG + let mut j = i + 8; + let mut end = None; + while j + 8 <= b.len() { + if &b[j..j + 4] == b"IEND" { + end = Some(j + 8); // IEND + 4-byte CRC + break; + } + j += 1; + } + let e = end.unwrap_or(b.len()); + if best.map_or(true, |(s0, e0)| (e - i) > (e0 - s0)) { + best = Some((i, e)); + } + i = e; // continue scanning after this PNG + } else { + i += 1; + } + } + best.map(|(s, e)| b[s..e].to_vec()) +} + +/// Read the bytes of a `.uasset` (working copy or an exact submitted revision) +/// and return ONLY its embedded thumbnail PNG. Reading happens server-side so a +/// huge asset never crosses the IPC bridge — just the small thumbnail does, and +/// the 64 MB preview cap does not apply here. +#[tauri::command] +async fn uasset_thumbnail(state: State<'_, AppState>, spec: String, historical: bool) -> Result { + let conn = current(&state)?; + // avoid buffering absurdly large assets just to grab a thumbnail + const MAX_UASSET: u64 = 400 * 1024 * 1024; + let bytes: Vec = if historical { + // exact revision → p4 print + if let Ok(sizes) = run_json(&conn, &["sizes", &spec]) { + if let Some(sz) = sizes.first().and_then(|v| v.get("fileSize")).and_then(|s| s.as_str()).and_then(|s| s.parse::().ok()) { + if sz > MAX_UASSET { + return Err("Asset too large for a thumbnail".into()); + } + } + } + let out = p4_cmd(&conn, false) + .args(["print", "-q", &spec]) + .output() + .map_err(|e| format!("Failed to start p4: {e}"))?; + if !out.status.success() && out.stdout.is_empty() { + return Err(decode(&out.stderr).trim().to_string()); + } + out.stdout + } else { + // working copy → resolve local path and read it + let local = run_json(&conn, &["where", &spec])? + .into_iter() + .next() + .and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from)) + .ok_or_else(|| "Could not resolve the file path".to_string())?; + if let Ok(m) = std::fs::metadata(&local) { + if m.len() > MAX_UASSET { + return Err("Asset too large for a thumbnail".into()); + } + } + std::fs::read(&local).map_err(|e| format!("Could not read file: {e}"))? + }; + match extract_png(&bytes) { + Some(png) => Ok(tauri::ipc::Response::new(png)), + None => Err("No embedded thumbnail".into()), + } +} + +/// Python run inside a headless Unreal editor to export one mesh asset to FBX. +/// Args come via env vars (EXD_ASSET = /Game path, EXD_OUT = output .fbx). +const EXPORT_PY: &str = r#" +import unreal, os +def log(m): unreal.log("EXD_EXPORT: " + str(m)) +ap = os.environ.get("EXD_ASSET", "") +op = os.environ.get("EXD_OUT", "") +if not ap or not op: + log("missing env"); raise SystemExit(1) +asset = unreal.EditorAssetLibrary.load_asset(ap) +if asset is None: + log("asset not found: " + ap); raise SystemExit(2) +task = unreal.AssetExportTask() +task.set_editor_property("object", asset) +task.set_editor_property("filename", op) +task.set_editor_property("automated", True) +task.set_editor_property("prompt", False) +task.set_editor_property("replace_identical", True) +if isinstance(asset, unreal.StaticMesh): + opt = unreal.FbxExportOption(); opt.set_editor_property("collision", False); opt.set_editor_property("level_of_detail", False) + task.set_editor_property("options", opt); task.set_editor_property("exporter", unreal.StaticMeshExporterFBX()) +elif isinstance(asset, unreal.SkeletalMesh): + opt = unreal.FbxExportOption(); opt.set_editor_property("collision", False); opt.set_editor_property("level_of_detail", False) + task.set_editor_property("options", opt); task.set_editor_property("exporter", unreal.SkeletalMeshExporterFBX()) +else: + log("unsupported type: " + asset.get_class().get_name()); raise SystemExit(3) +ok = unreal.Exporter.run_asset_export_task(task) +log("result " + str(ok)) +raise SystemExit(0 if ok else 4) +"#; + +/// Query a Windows registry value (returns the REG_SZ string, if any). +fn reg_query(key: &str, value: &str) -> Option { + let mut c = Command::new("reg"); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + c.creation_flags(0x0800_0000); + } + let out = c.stdin(Stdio::null()).args(["query", key, "/v", value]).output().ok()?; + if !out.status.success() { + return None; + } + let s = decode(&out.stdout); + for line in s.lines() { + if let Some(idx) = line.find("REG_SZ") { + let val = line[idx + 6..].trim(); + if !val.is_empty() { + return Some(val.to_string()); + } + } + } + None +} + +/// Locate UnrealEditor-Cmd.exe for a project's engine (version or source-build GUID). +fn unreal_editor_cmd(uproject: &str) -> Option { + let assoc = std::fs::read_to_string(uproject) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.get("EngineAssociation").and_then(|e| e.as_str()).map(String::from)) + .unwrap_or_default(); + let bin = |root: &str| -> Option { + for name in ["UnrealEditor-Cmd.exe", "UE4Editor-Cmd.exe"] { + let p = format!("{root}\\Engine\\Binaries\\Win64\\{name}"); + if std::path::Path::new(&p).exists() { + return Some(p); + } + } + None + }; + let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".into()); + // launcher install "UE_5.x" by version, else registry, else source-build GUID + if assoc.chars().next().map_or(false, |c| c.is_ascii_digit()) { + if let Some(b) = bin(&format!("{pf}\\Epic Games\\UE_{assoc}")) { + return Some(b); + } + if let Some(dir) = reg_query(&format!("HKLM\\SOFTWARE\\EpicGames\\Unreal Engine\\{assoc}"), "InstalledDirectory") { + if let Some(b) = bin(dir.trim()) { + return Some(b); + } + } + } else if !assoc.is_empty() { + if let Some(dir) = reg_query("HKCU\\SOFTWARE\\Epic Games\\Unreal Engine\\Builds", &assoc) { + if let Some(b) = bin(dir.trim()) { + return Some(b); + } + } + } + // fallback: any UE_* under Program Files\Epic Games + if let Ok(rd) = std::fs::read_dir(format!("{pf}\\Epic Games")) { + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() && p.file_name().and_then(|n| n.to_str()).map_or(false, |n| n.starts_with("UE_")) { + if let Some(b) = bin(&p.to_string_lossy()) { + return Some(b); + } + } + } + } + None +} + +/// Map a local `.uasset` path to its Unreal object path: +/// /Content/Foo.uasset → /Game/Foo +/// /Plugins//Content/Bar… → //Bar (plugin mount point) +fn content_to_game(uproject: &str, local: &str) -> Option { + let strip_ext = |s: &str| -> String { + for ext in [".uasset", ".umap"] { + if let Some(x) = s.strip_suffix(ext) { + return x.to_string(); + } + } + s.to_string() + }; + let proj_dir = std::path::Path::new(uproject).parent()?; + let norm = local.replace('\\', "/"); + + // project Content → /Game/ + let content = proj_dir.join("Content"); + if let Ok(rel) = std::path::Path::new(local).strip_prefix(&content) { + return Some(format!("/Game/{}", strip_ext(&rel.to_string_lossy().replace('\\', "/")))); + } + // plugin Content → // (…/Plugins/…//Content/) + if let Some(pidx) = norm.find("/Plugins/") { + let after = &norm[pidx + "/Plugins/".len()..]; + if let Some(cidx) = after.find("/Content/") { + let plugin_path = &after[..cidx]; // may be nested, e.g. "GameFeatures/GASPALS" + let mount = plugin_path.rsplit('/').next().unwrap_or(plugin_path); + let rest = &after[cidx + "/Content/".len()..]; + return Some(format!("/{mount}/{}", strip_ext(rest))); + } + } + None +} + +/// Export a `.uasset` mesh to FBX by driving a headless Unreal editor, so the +/// app's 3D viewport can show a real, rotatable model. The FBX is written to a +/// temp file that is deleted immediately after reading — nothing is left on disk; +/// the model exists only in the preview's memory while it is open. +#[tauri::command] +async fn uasset_export_3d(state: State<'_, AppState>, depot: String, uproject: String) -> Result { + let conn = current(&state)?; + if uproject.is_empty() { + return Err("Not an Unreal project — pick the project working folder first.".into()); + } + let local = run_json(&conn, &["where", &depot])? + .into_iter() + .next() + .and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from)) + .ok_or_else(|| "Could not resolve the file path".to_string())?; + if !std::path::Path::new(&local).exists() { + return Err("Asset is not synced to disk yet — Get Latest first.".into()); + } + ensure_under_root(&conn, &local)?; + let game = content_to_game(&uproject, &local) + .ok_or_else(|| "Asset is outside the project's Content folder (3D export supports /Game assets).".to_string())?; + let editor = unreal_editor_cmd(&uproject) + .ok_or_else(|| "UnrealEditor-Cmd.exe not found — is Unreal Engine installed for this project's version?".to_string())?; + + // export to a temp file that is deleted the moment we've read it — nothing is + // cached on disk; the model lives only in the preview's memory while open + let safe: String = game.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect(); + let out_fbx = std::env::temp_dir().join(format!("exd_preview_{safe}.fbx")); + let _ = std::fs::remove_file(&out_fbx); // clear any stale leftover + + let script = std::env::temp_dir().join("exd_uasset_export.py"); + std::fs::write(&script, EXPORT_PY).map_err(|e| e.to_string())?; + + let mut c = Command::new(&editor); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + c.creation_flags(0x0800_0000); + } + // commandlet mode (-run=pythonscript) + -nullrhi → no editor window appears + c.arg(&uproject) + .arg("-run=pythonscript") + .arg(format!("-script={}", script.to_string_lossy())) + .args(["-unattended", "-nosplash", "-nullrhi", "-nop4", "-NoLogTimes", "-NoLoadStartupPackages"]) + .env("EXD_ASSET", &game) + .env("EXD_OUT", out_fbx.to_string_lossy().to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) // Unreal logs a lot; ignore it to avoid a full-pipe deadlock + .stderr(Stdio::null()); + let mut child = c.spawn().map_err(|e| format!("Failed to launch Unreal: {e}"))?; + // poll with a hard timeout so a stuck editor never wedges the backend + let deadline = Instant::now() + Duration::from_secs(360); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) => { + if Instant::now() > deadline { + let _ = child.kill(); + return Err("Unreal export timed out (6 min).".into()); + } + std::thread::sleep(Duration::from_millis(400)); + } + Err(e) => return Err(e.to_string()), + } + } + if !out_fbx.exists() { + return Err("Unreal produced no file — the asset may not be a Static/Skeletal Mesh, or the editor failed to start.".into()); + } + let data = std::fs::read(&out_fbx).map_err(|e| e.to_string())?; + let _ = std::fs::remove_file(&out_fbx); // delete right away — leave nothing behind + if data.is_empty() { + return Err("Exported model is empty.".into()); + } + Ok(tauri::ipc::Response::new(data)) +} + /// Shelve a pending changelist's files on the server (share work-in-progress). #[tauri::command] async fn p4_shelve(state: State<'_, AppState>, change: String) -> Result { @@ -1599,6 +1916,7 @@ pub fn run() { open_in_editor, p4_describe, read_file, + write_depot, read_depot, print_depot, p4_console, @@ -1606,6 +1924,8 @@ pub fn run() { p4_opened_all, p4_lock, p4_unlock, + uasset_thumbnail, + uasset_export_3d, p4_shelve, p4_unshelve, p4_delete_shelf, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7de62b4..523f5ae 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "exbyte-depot", - "version": "0.2.2", + "version": "0.2.3", "identifier": "com.bonchellon.exbyte-depot", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.css b/src/App.css index 0adcb7d..344e895 100644 --- a/src/App.css +++ b/src/App.css @@ -9,6 +9,7 @@ --bar:rgba(255,255,255,.015);--shadow:0 40px 90px -30px rgba(0,0,0,.7); --sunk:rgba(0,0,0,.15);--chip:rgba(0,0,0,.4);--hover:rgba(255,255,255,.05); --diff-add-c:#a8ecca;--diff-del-c:#f3a9b8; + --logo-bg:#0c0e1a;--logo-ring:rgba(124,110,246,.28); --font:"Segoe UI Variable Text","Segoe UI",-apple-system,BlinkMacSystemFont,system-ui,"Helvetica Neue",Arial,sans-serif; --mono:"Cascadia Code","JetBrains Mono",ui-monospace,"SF Mono",Consolas,monospace; } @@ -23,6 +24,7 @@ body.light{ --bar:rgba(0,0,0,.015);--shadow:0 40px 90px -30px rgba(60,60,90,.35); --sunk:transparent;--chip:rgba(255,255,255,.72);--hover:rgba(0,0,0,.045); --diff-add-c:#137a4e;--diff-del-c:#c02b52; + --logo-bg:#e9f4ff;--logo-ring:rgba(124,110,246,.22); } *{box-sizing:border-box;margin:0;padding:0} html,body,#root{height:100%} @@ -48,9 +50,10 @@ button{font-family:var(--font)} .wbtn.close:hover{background:#e81123;color:#fff} .wbtn svg{width:11px;height:11px} .brand{display:flex;align-items:center;gap:10px;padding-right:10px;margin-right:4px;border-right:1px solid var(--border-soft)} -.logo{width:22px;height:22px;border-radius:7px;background:linear-gradient(145deg,var(--accent-2),var(--accent-deep)); - box-shadow:0 0 14px rgba(124,110,246,.55);display:grid;place-items:center;flex:0 0 auto} -.logo svg{width:13px;height:13px} +/* theme-aware brand tile: dark tile in dark theme, light tile in light theme */ +.logo{width:24px;height:24px;border-radius:8px;background:var(--logo-bg); + box-shadow:inset 0 0 0 1px var(--logo-ring),0 2px 8px rgba(0,0,0,.28);display:grid;place-items:center;flex:0 0 auto} +.logo .exlogo{width:18px;height:18px} .app-name{font-size:13px;font-weight:600;white-space:nowrap}.app-name span{color:var(--faint);font-weight:500} .conn-pill{align-self:center;margin-left:auto;display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--muted); background:rgba(62,207,142,.08);border:1px solid rgba(62,207,142,.2);padding:4px 10px;border-radius:20px;white-space:nowrap} @@ -577,6 +580,21 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .code-body code{white-space:pre;font-family:inherit;background:none;padding:0} .code.added .code-body{background:rgba(62,207,142,.04)} +/* built-in code editor */ +.cb-badge.editing{color:var(--edit);background:rgba(232,176,75,.16)} +.ce-actions{margin-left:auto;display:flex;gap:8px;align-items:center} +.ce-btn{display:flex;align-items:center;gap:6px;font-size:11.5px;font-weight:600;font-family:var(--font);cursor:pointer; + color:var(--muted);background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:5px 11px;transition:.15s} +.ce-btn:hover:not(:disabled){color:var(--txt);border-color:var(--accent)} +.ce-btn:disabled{opacity:.5;cursor:default} +.ce-btn.save{color:#fff;border:none;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep))} +.ce-btn.save:hover:not(:disabled){filter:brightness(1.1)} +.ce-btn svg{width:13px;height:13px} +.code-edit{flex:1;min-height:0;width:100%;resize:none;border:none;outline:none; + font-family:var(--mono);font-size:12.5px;line-height:1.7;tab-size:4;color:var(--txt); + background:var(--stage-bg);padding:12px 16px;white-space:pre;overflow:auto} +.code-edit:focus{box-shadow:inset 3px 0 0 var(--accent)} + /* highlight.js tokens mapped to the app palette (theme-aware) */ .hljs{color:var(--txt);background:none} .hljs-comment,.hljs-quote{color:var(--faint);font-style:italic} @@ -726,6 +744,14 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .explain-x{flex:0 0 auto;border:none;background:none;color:var(--faint);font-size:18px;line-height:1;cursor:pointer;padding:0 2px} .explain-x:hover{color:var(--txt)} +/* "Load 3D model" button in the .uasset preview */ +.load3d{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:8px; + font-size:12.5px;font-weight:600;font-family:var(--font);color:#fff;cursor:pointer;padding:9px 16px;border-radius:10px;border:none; + background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 8px 24px -8px rgba(124,110,246,.7);transition:.15s} +.load3d:hover:not(:disabled){filter:brightness(1.1)} +.load3d:disabled{opacity:.5;cursor:default;box-shadow:none} +.load3d svg{width:15px;height:15px} + /* ---------------- collaboration UI (locks / banners / search / blame / diff / resolve) ---------------- */ /* connection + resolve banners */ .netbar{display:flex;align-items:center;gap:10px;padding:8px 16px;font-size:12.5px;font-weight:600;flex:0 0 auto} diff --git a/src/App.tsx b/src/App.tsx index f6372b4..a35c35a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -157,6 +157,31 @@ function Select({ value, options, onChange, icon, placeholder }: ); } +/* ---------------- brand logo (Exbyte Depot mark; theme-aware tile) ---------------- */ +function Logo() { + return ( + + ); +} + /* ---------------- animated startup splash ---------------- */ function Splash({ light, toggleTheme }: { light: boolean; toggleTheme: () => void }) { const steps = [t("Connecting to server…"), t("Authenticating…"), t("Loading workspace…"), t("Syncing state…")]; @@ -168,7 +193,7 @@ function Splash({ light, toggleTheme }: { light: boolean; toggleTheme: () => voi return (
-
{I.hex}
Exbyte Depot — Perforce
+
Exbyte Depot — Perforce
@@ -225,7 +250,7 @@ function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; togg return (
-
{I.hex}
Exbyte Depot — Perforce
+
Exbyte Depot — Perforce
@@ -1009,7 +1034,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set return (
-
{I.hex}
Exbyte Depot — Perforce
+
Exbyte Depot — Perforce
e.stopPropagation()}> {Object.entries(menus).map(([name, items]) => (
setOpenMenu(openMenu === name ? null : name)}> @@ -1024,7 +1049,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
))}
-
{info?.serverAddress || "connected"}
+
{(session?.server || (info?.serverAddress as string) || "connected").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}
@@ -1148,7 +1173,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
)}
- {allChecked ? I.check : null} + {allChecked ? I.check : null} {t("{a} of {b} selected", { a: checked.size, b: view.length })}
{tab === "changes" ? ( - + ) : (
@@ -1210,7 +1236,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set document.addEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.addEventListener("mouseup", up); window.addEventListener("blur", up); // released outside the window → end drag }} /> - + )}
)} @@ -1247,7 +1273,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
)} - {modal && modal.kind !== "users" && setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />} {modal?.kind === "users" && setModal(null)} onFlash={flash} />} @@ -1268,8 +1294,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set } /* ---------------- app modal (workspaces / scope / settings / about) ---------------- */ -function AppModal({ modal, info, light, toggleTheme, lang, setLang, zoom, setZoom, editors, editorId, onPickEditor, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }: - { modal: ModalState; info: P4Info | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; editors: Editor[]; editorId: string; onPickEditor: (id: string) => void; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) { +function AppModal({ modal, info, session, light, toggleTheme, lang, setLang, zoom, setZoom, editors, editorId, onPickEditor, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }: + { modal: ModalState; info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; editors: Editor[]; editorId: string; onPickEditor: (id: string) => void; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) { useEffect(() => { const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", onKey); @@ -1353,7 +1379,7 @@ function AppModal({ modal, info, light, toggleTheme, lang, setLang, zoom, setZoo
-
{t("Server")}{info?.serverAddress || "—"}
+
{t("Server")}{(session?.server || (info?.serverAddress as string) || "—").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}
{t("User")}{info?.userName || "—"}
{t("Workspace")}{info?.clientName || "—"}
{t("Server version")}{(info?.serverVersion as string || "").split("/")[2] || "—"}
@@ -2061,19 +2087,16 @@ function Thumb({ file }: { file: OpenedFile }) { if (kind !== "image" && kind !== "uasset") return; if (thumbCache.has(dp)) { setUrl(thumbCache.get(dp) ?? null); return; } let live = true; - p4.readDepot(dp).then((buf) => { - if (!live) return; - let u: string | null = null; - if (kind === "image") { - const ext = (name.split(".").pop() || "png").toLowerCase(); - u = URL.createObjectURL(new Blob([buf], { type: ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}` })); - } else { - const png = extractPng(new Uint8Array(buf)); - if (png) u = URL.createObjectURL(new Blob([png], { type: "image/png" })); - } - thumbCache.set(dp, u); - if (live) setUrl(u); - }).catch(() => { thumbCache.set(dp, null); }); + // .uasset → backend pulls just the embedded thumbnail (no huge IPC transfer); + // real images → fetch the file bytes and blob them directly + const job = kind === "uasset" + ? p4.uassetThumb(dp, false).then((buf) => URL.createObjectURL(new Blob([buf], { type: "image/png" }))) + : p4.readDepot(dp).then((buf) => { + const ext = (name.split(".").pop() || "png").toLowerCase(); + return URL.createObjectURL(new Blob([buf], { type: ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}` })); + }); + job.then((u) => { thumbCache.set(dp, u); if (live) setUrl(u); }) + .catch(() => { thumbCache.set(dp, null); }); return () => { live = false; }; }, [dp]); @@ -2121,7 +2144,7 @@ function FileList({ files, sel, selRows, checked, others, onRowClick, onToggle, } /* ---------------- preview panel ---------------- */ -function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDiffPrev }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void; editorId: string; editorName: string; onBlame?: (spec: string, name: string) => void; onDiffPrev?: (f: OpenedFile) => void }) { +function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDiffPrev, uproject }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void; editorId: string; editorName: string; onBlame?: (spec: string, name: string) => void; onDiffPrev?: (f: OpenedFile) => void; uproject?: string }) { const [explain, setExplain] = useState(null); // AI code summary const [explBusy, setExplBusy] = useState(false); const explSeq = useRef(0); // drop a slow AI response if the file changed meanwhile @@ -2203,8 +2226,8 @@ function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDif {kind === "model" ? : kind === "image" ? - : kind === "uasset" ? - : } + : kind === "uasset" ? + : } ); } @@ -2244,24 +2267,44 @@ function ImageViewer({ depot, name, spec }: { depot: string; name: string; spec? ); } -/* ---------------- .uasset — extract embedded Unreal thumbnail (PNG) ---------------- */ -function UassetPreview({ file }: { file: OpenedFile }) { +/* ---------------- .uasset — embedded thumbnail + on-demand real 3D (Unreal export) ---------------- */ +function UassetPreview({ file, uproject, onFlash }: { file: OpenedFile; uproject?: string; onFlash: (text: string, err?: boolean) => void }) { const dp = file.depotFile || ""; const { name } = splitPath(dp); + const hist = !!file._spec; // historical revision (3D export only works on the working copy) const [thumb, setThumb] = useState(null); const [tried, setTried] = useState(false); + const [model, setModel] = useState(null); // exported FBX bytes + const [exporting, setExporting] = useState(false); useEffect(() => { let live = true; let url = ""; - setThumb(null); setTried(false); - fileBytes(file).then((buf) => { + setThumb(null); setTried(false); setModel(null); setExporting(false); + // backend reads the asset and returns ONLY the embedded thumbnail PNG — + // works for the working copy and for exact submitted revisions (history) + const spec = file._spec || dp; + p4.uassetThumb(spec, !!file._spec).then((buf) => { if (!live) return; - const png = extractPng(new Uint8Array(buf)); - if (png) { url = URL.createObjectURL(new Blob([png], { type: "image/png" })); setThumb(url); } + url = URL.createObjectURL(new Blob([buf], { type: "image/png" })); + setThumb(url); setTried(true); }).catch(() => live && setTried(true)); return () => { live = false; if (url) URL.revokeObjectURL(url); }; }, [dp, file._spec]); + async function load3d() { + if (!uproject) { onFlash(t("Pick the Unreal project working folder first."), true); return; } + setExporting(true); + try { + const buf = await p4.exportUasset3d(dp, uproject); + setModel(buf); + } catch (e) { onFlash(String(e), true); } + finally { setExporting(false); } + } + + // real, rotatable 3D once Unreal has exported the mesh + if (model) return ; + + const canExport = !!uproject; // exports the version currently synced on disk return (
@@ -2272,9 +2315,14 @@ function UassetPreview({ file }: { file: OpenedFile }) {
{I.cube}
{name}
-
{!tried ? t("Reading asset…") : t("This asset has no embedded thumbnail. Interactive 3D works for FBX / OBJ / GLB / GLTF / STL — export the model to one of these formats.")}
+
{!tried ? t("Reading asset…") : t("No embedded thumbnail. Load the 3D model below (needs Unreal Engine installed).")}
)} + {/* real 3D on demand — exports the mesh from Unreal to FBX, then rotates in-viewport */} +
@@ -2283,18 +2331,3 @@ function UassetPreview({ file }: { file: OpenedFile }) { ); } -// scan raw bytes for an embedded PNG and return just that PNG (sig → IEND+crc) -function extractPng(b: Uint8Array): Uint8Array | null { - const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; - for (let i = 0; i < b.length - 8; i++) { - let ok = true; - for (let k = 0; k < 8; k++) if (b[i + k] !== sig[k]) { ok = false; break; } - if (!ok) continue; - for (let j = i + 8; j < b.length - 8; j++) { - if (b[j] === 0x49 && b[j + 1] === 0x45 && b[j + 2] === 0x4e && b[j + 3] === 0x44) return b.slice(i, j + 8); - } - return b.slice(i); - } - return null; -} - diff --git a/src/CodeView.tsx b/src/CodeView.tsx index d6359c5..772a387 100644 --- a/src/CodeView.tsx +++ b/src/CodeView.tsx @@ -45,8 +45,9 @@ function esc(s: string): string { const MAX = 800_000; // don't try to render/highlight enormous files // Preview source files like GitHub: syntax-highlighted content with line numbers -// for new files, or a colored unified diff for edits. -export default function CodeView({ file }: { file: OpenedFile }) { +// for new files, or a colored unified diff for edits. Working-copy code files can +// be edited in place (built-in editor) and saved before committing. +export default function CodeView({ file, onFlash }: { file: OpenedFile; onFlash?: (text: string, err?: boolean) => void }) { const dp = file.depotFile || ""; const spec = file._spec || ""; // set → read this exact submitted revision const { name } = splitPath(dp); @@ -55,10 +56,14 @@ export default function CodeView({ file }: { file: OpenedFile }) { const [code, setCode] = useState(null); const [err, setErr] = useState(""); const [tooBig, setTooBig] = useState(false); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(""); + const [saving, setSaving] = useState(false); + const [reload, setReload] = useState(0); useEffect(() => { let live = true; - setDiff(null); setCode(null); setErr(""); setTooBig(false); + setDiff(null); setCode(null); setErr(""); setTooBig(false); setEditing(false); // working-copy edits show a diff against the depot; historical revisions just // show their full content (no meaningful working diff to fetch). if (!spec) p4.diff(dp).then((d) => live && setDiff(d)).catch(() => live && setDiff("")); @@ -72,7 +77,53 @@ export default function CodeView({ file }: { file: OpenedFile }) { setCode(new TextDecoder("utf-8", { fatal: false }).decode(bytes)); }).catch((e) => live && setErr(String(e))); return () => { live = false; }; - }, [dp, spec]); + }, [dp, spec, reload]); + + // built-in editor: editable for a code file in the working copy (not history/binary/huge) + const canEdit = !spec && !tooBig && err !== "binary" && code != null; + function startEdit() { setDraft(code || ""); setEditing(true); } + function cancelEdit() { setEditing(false); } + async function save() { + setSaving(true); + try { + await p4.writeDepot(dp, draft); + setCode(draft); + setEditing(false); + onFlash?.(t("Saved {name}", { name })); + setReload((r) => r + 1); // refresh diff/status after the on-disk change + } catch (e) { onFlash?.(String(e), true); } + finally { setSaving(false); } + } + const pencil = ; + + // editing mode — plain textarea over the working copy + if (editing) { + return ( +
+
+ {t("Editing")} + {name} + + + + +
+