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:
Bonchellon
2026-07-07 22:55:04 +03:00
parent 6c16d4a4f4
commit 0da836b0ac
61 changed files with 528 additions and 65 deletions

View File

@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 B

After

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -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,

View File

@ -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",