0.2.3 — built-in code editor, .uasset previews, new icon
- Built-in code editor: edit working-copy source files in place (Edit → textarea → Save) before committing, no external IDE needed. Backend write_depot opens the file for edit, clears read-only, writes UTF-8; confined to the client root. - .uasset previews: pull the embedded Unreal thumbnail (PNG) server-side for both working copy and history; on-demand real 3D via a headless Unreal FBX export (commandlet mode, no editor window), temp file deleted right after — nothing cached on disk. Plugin content paths mapped to their mount point. - New app icon (brand mark on a theme tile) + theme-aware in-app logo. - Connection pill / Settings show the actual connected server (P4PORT), not the server's internal self-reported address. - Fix: the "select all" checkbox no longer renders as a giant empty square when not everything is selected (size pinned). - IDE picker chooses which installed editor opens code; AI replies in UI language. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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<tauri::ip
|
||||
.map_err(|e| format!("Could not read file: {e}"))
|
||||
}
|
||||
|
||||
/// Write UTF-8 text back to a depot file's local working copy (built-in editor).
|
||||
/// Opens the file for edit first (so the change is tracked and the read-only bit
|
||||
/// is cleared), then writes. Confined to the workspace root.
|
||||
#[tauri::command]
|
||||
async fn write_depot(state: State<'_, AppState>, 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<tauri::ipc::Response, String> {
|
||||
@ -1288,6 +1314,297 @@ async fn p4_unlock(state: State<'_, AppState>, files: Vec<String>) -> Result<Str
|
||||
run_text(&conn, &args)
|
||||
}
|
||||
|
||||
/// Extract the largest embedded PNG from a byte blob — this is how an uncooked
|
||||
/// Unreal `.uasset` stores its Content-Browser thumbnail (PNG-compressed in the
|
||||
/// package thumbnail table). We scan for the PNG signature → IEND and keep the
|
||||
/// biggest hit (the real thumbnail, not some tiny incidental chunk). No engine,
|
||||
/// no CUE4Parse — exactly the approach Anchorpoint uses for its previews.
|
||||
fn extract_png(b: &[u8]) -> Option<Vec<u8>> {
|
||||
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<tauri::ipc::Response, String> {
|
||||
let conn = current(&state)?;
|
||||
// avoid buffering absurdly large assets just to grab a thumbnail
|
||||
const MAX_UASSET: u64 = 400 * 1024 * 1024;
|
||||
let bytes: Vec<u8> = 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::<u64>().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<String> {
|
||||
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<String> {
|
||||
let assoc = std::fs::read_to_string(uproject)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<Value>(&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<String> {
|
||||
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:
|
||||
/// <Project>/Content/Foo.uasset → /Game/Foo
|
||||
/// <Project>/Plugins/<Name>/Content/Bar… → /<Name>/Bar (plugin mount point)
|
||||
fn content_to_game(uproject: &str, local: &str) -> Option<String> {
|
||||
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 → /<PluginName>/ (…/Plugins/…/<Name>/Content/<rest>)
|
||||
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<tauri::ipc::Response, String> {
|
||||
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<String, String> {
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user