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,7 +1,7 @@
{
"name": "exbyte-depot",
"private": true,
"version": "0.2.2",
"version": "0.2.3",
"type": "module",
"scripts": {
"dev": "vite",

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

View File

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

View File

@ -157,6 +157,31 @@ function Select({ value, options, onChange, icon, placeholder }:
);
}
/* ---------------- brand logo (Exbyte Depot mark; theme-aware tile) ---------------- */
function Logo() {
return (
<svg className="exlogo" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<defs>
<linearGradient id="exG" x1="6" y1="24" x2="26" y2="8" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#8b6cf6" />
<stop offset="1" stopColor="#49a6ff" />
</linearGradient>
</defs>
<g stroke="url(#exG)" strokeWidth="2.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 16 H10 L14.5 9.5 H20" />
<path d="M10 16 L14.5 22.5 H20" />
<circle cx="22.5" cy="9.5" r="3" />
<circle cx="22.5" cy="22.5" r="3" />
</g>
<g stroke="url(#exG)" strokeWidth="1.9" strokeLinecap="round" opacity="0.5">
<path d="M15 14 H19" />
<path d="M15 16 H20" />
<path d="M15 18 H18" />
</g>
</svg>
);
}
/* ---------------- 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 (
<div className="win">
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<div className="brand" data-tauri-drag-region><div className="logo"><Logo /></div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
@ -225,7 +250,7 @@ function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; togg
return (
<div className="win">
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<div className="brand" data-tauri-drag-region><div className="logo"><Logo /></div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
@ -1009,7 +1034,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
return (
<div className="win" style={{ "--left-w": `${leftW}px`, "--right-zone-w": `${syncW}px` } as CSSProperties}>
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<div className="brand" data-tauri-drag-region><div className="logo"><Logo /></div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<div className="menubar" onClick={(e) => e.stopPropagation()}>
{Object.entries(menus).map(([name, items]) => (
<div key={name} className={"menu" + (openMenu === name ? " open" : "")} onClick={() => setOpenMenu(openMenu === name ? null : name)}>
@ -1024,7 +1049,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</div>
))}
</div>
<div className="conn-pill"><span className="dot" />{info?.serverAddress || "connected"}</div>
<div className="conn-pill" title={t("Connected server")}><span className="dot" />{(session?.server || (info?.serverAddress as string) || "connected").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}</div>
<button className="theme-btn" onClick={() => setModal({ kind: "users" })} title={t("People & Roles — who works on this depot")}>{I.people}</button>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
@ -1148,7 +1173,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</div>
)}
<div className="chhead">
<span className={"chk" + (allChecked ? "" : " empty")} onClick={toggleAll} title={allChecked ? t("Deselect all") : t("Select all")}>{allChecked ? I.check : null}</span>
<span className={"chk" + (allChecked ? "" : " empty")} onClick={toggleAll} title={allChecked ? t("Deselect all") : t("Select all")}
style={{ width: 16, height: 16, minWidth: 16, flex: "0 0 16px", boxSizing: "border-box", alignSelf: "center" }}>{allChecked ? I.check : null}</span>
<span>{t("{a} of {b} selected", { a: checked.size, b: view.length })}</span>
<button className={"scanbtn" + ((busy || scanning) ? " spinning" : "")} style={{ marginLeft: "auto" }} onClick={() => refresh()} disabled={busy || scanning} title={t("Re-read open files (p4 opened)")}>
{I.sync}<span>{scanning ? t("Scanning…") : t("Refresh")}</span>
@ -1193,7 +1219,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</div>
{tab === "changes" ? (
<Preview file={selFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} />
<Preview file={selFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} uproject={uproject} />
) : (
<div className={"histsplit" + (histFile ? "" : " solo")}>
<ChangeDetail detail={detail} busy={detailBusy} onOpen={setHistFile} selected={histFile?._spec || ""} split width={histFile ? histW : undefined} />
@ -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
}} />
<Preview file={histFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} />
<Preview file={histFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} uproject={uproject} />
</>)}
</div>
)}
@ -1247,7 +1273,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</div>
</div>
)}
{modal && modal.kind !== "users" && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
{modal && modal.kind !== "users" && <AppModal modal={modal} info={info} session={session} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
editors={editors} editorId={effEditorId} onPickEditor={chooseEditor}
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
{modal?.kind === "users" && <UsersRoles me={info?.userName || ""} onClose={() => 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
<button className="zbtn" onClick={() => setZoom(zoom + 0.1)} disabled={zoom >= 2} title="+">+</button>
</div>
</div>
<div className="info-row"><span className="rk">{t("Server")}</span><span className="rv">{info?.serverAddress || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Server")}</span><span className="rv">{(session?.server || (info?.serverAddress as string) || "—").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}</span></div>
<div className="info-row"><span className="rk">{t("User")}</span><span className="rv">{info?.userName || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Workspace")}</span><span className="rv">{info?.clientName || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Server version")}</span><span className="rv">{(info?.serverVersion as string || "").split("/")[2] || "—"}</span></div>
@ -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<string | null>(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" ? <ModelViewer depot={dp} name={name} spec={spec || undefined} />
: kind === "image" ? <ImageViewer depot={dp} name={name} spec={spec || undefined} />
: kind === "uasset" ? <UassetPreview file={file} />
: <CodeView file={file} />}
: kind === "uasset" ? <UassetPreview file={file} uproject={uproject} onFlash={onFlash} />
: <CodeView file={file} onFlash={onFlash} />}
</div>
);
}
@ -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<string | null>(null);
const [tried, setTried] = useState(false);
const [model, setModel] = useState<ArrayBuffer | null>(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 <ModelViewer depot={dp} name={name} bytes={model} forceExt="fbx" />;
const canExport = !!uproject; // exports the version currently synced on disk
return (
<div className="asset">
<div className="stage">
@ -2272,9 +2315,14 @@ function UassetPreview({ file }: { file: OpenedFile }) {
<div className="uasset-card">
<div className="uasset-ic">{I.cube}</div>
<div className="uasset-name">{name}</div>
<div className="uasset-note">{!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.")}</div>
<div className="uasset-note">{!tried ? t("Reading asset…") : t("No embedded thumbnail. Load the 3D model below (needs Unreal Engine installed).")}</div>
</div>
)}
{/* real 3D on demand — exports the mesh from Unreal to FBX, then rotates in-viewport */}
<button className="load3d" onClick={load3d} disabled={!canExport || exporting}
title={!uproject ? t("Pick the Unreal project working folder first.") : hist ? t("Exports the version currently synced on disk.") : t("Export the mesh from Unreal and view it in 3D")}>
{exporting ? <><span className="ldr sm" />{t("Exporting from Unreal… (first time is slow)")}</> : <>{I.cube}{t("Load 3D model")}</>}
</button>
</div>
<div className="assetmeta">
<Mi k={t("Type")} v={file.type || "binary"} /><Mi k={t("Preview")} v={thumb ? t("thumbnail") : (tried ? t("none") : "…")} /><Mi k={t("Revision")} v={file.rev ? "#" + file.rev : "—"} acc /><Mi k="Change" v={file.change || "default"} />
@ -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;
}

View File

@ -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<string | null>(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 = <svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="m14.5 5.5 4 4M4 20l1-4L16.5 4.5a2 2 0 0 1 3 3L8 19l-4 1Z" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>;
// editing mode — plain textarea over the working copy
if (editing) {
return (
<div className="right-body">
<div className="codebar">
<span className="cb-badge editing">{t("Editing")}</span>
<span className="cb-name">{name}</span>
<span className="ce-actions">
<button className="ce-btn" onClick={cancelEdit} disabled={saving}>{t("Cancel")}</button>
<button className="ce-btn save" onClick={save} disabled={saving || draft === code}>{saving ? <span className="ldr sm" /> : null}{t("Save")}</button>
</span>
</div>
<textarea className="code-edit" value={draft} spellCheck={false} onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") cancelEdit();
if (e.key === "s" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (draft !== code) save(); }
// Tab inserts a tab instead of leaving the field
if (e.key === "Tab") {
e.preventDefault();
const ta = e.currentTarget; const s = ta.selectionStart, en = ta.selectionEnd;
const nv = draft.slice(0, s) + "\t" + draft.slice(en);
setDraft(nv);
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 1; });
}
}} />
</div>
);
}
const lang = langOf(name);
const highlighted = useMemo(() => {
@ -90,7 +141,9 @@ export default function CodeView({ file }: { file: OpenedFile }) {
const lines = diff.split("\n");
return (
<div className="right-body">
<div className="codebar"><span className="cb-badge diff">{t("Diff")}</span><span className="cb-name">{name}</span></div>
<div className="codebar"><span className="cb-badge diff">{t("Diff")}</span><span className="cb-name">{name}</span>
{canEdit && <button className="ce-btn edit" style={{ marginLeft: "auto" }} onClick={startEdit}>{pencil}{t("Edit")}</button>}
</div>
<div className="diff">
{lines.map((ln, i) => {
let cls = "";
@ -113,6 +166,7 @@ export default function CodeView({ file }: { file: OpenedFile }) {
<span className={"cb-badge" + (action.includes("add") ? " add" : "")}>{action.includes("add") ? t("New file") : lang ? (LANG_LABEL[lang] || "") : t("Text")}</span>
<span className="cb-name">{name}</span>
<span className="cb-lines">{t("{n} lines", { n: total })}</span>
{canEdit && <button className="ce-btn edit" onClick={startEdit}>{pencil}{t("Edit")}</button>}
</div>
<div className={"code" + (action.includes("add") ? " added" : "")}>
<div className="code-gutter">{Array.from({ length: total }, (_, i) => <span key={i}>{i + 1}</span>)}</div>

View File

@ -14,7 +14,7 @@ import { t } from "./i18n";
type Api = { toggleWire: (b: boolean) => void; toggleGrid: (b: boolean) => void; reset: () => void };
export default function ModelViewer({ depot, name, spec }: { depot: string; name: string; spec?: string }) {
export default function ModelViewer({ depot, name, spec, bytes, forceExt }: { depot: string; name: string; spec?: string; bytes?: ArrayBuffer; forceExt?: string }) {
const mount = useRef<HTMLDivElement>(null);
const api = useRef<Api | null>(null);
const [status, setStatus] = useState<"loading" | "ok" | "error">("loading");
@ -69,7 +69,7 @@ export default function ModelViewer({ depot, name, spec }: { depot: string; name
let gridHelper: THREE.GridHelper | null = null;
let home = { pos: new THREE.Vector3(3, 2, 3), tgt: new THREE.Vector3() };
const ext = (name.toLowerCase().split(".").pop() || "");
const ext = (forceExt || name.toLowerCase().split(".").pop() || "");
function collectStats(obj: THREE.Object3D) {
let tris = 0, verts = 0, meshes = 0; const mats = new Set<THREE.Material>();
@ -138,7 +138,8 @@ export default function ModelViewer({ depot, name, spec }: { depot: string; name
(async () => {
try {
const buf = spec ? await p4.printDepot(spec) : await p4.readDepot(depot);
// pre-supplied bytes (e.g. an FBX exported from Unreal) skip the p4 fetch
const buf = bytes ? bytes : spec ? await p4.printDepot(spec) : await p4.readDepot(depot);
if (disposed) return;
const manager = new THREE.LoadingManager();
if (ext === "glb" || ext === "gltf") new GLTFLoader(manager).parse(buf, "", (g) => !disposed && place(g.scene), fail);
@ -173,7 +174,7 @@ export default function ModelViewer({ depot, name, spec }: { depot: string; name
api.current = null;
};
// eslint-disable-next-line
}, [depot, name, spec]);
}, [depot, name, spec, bytes, forceExt]);
const fmt = (n: number) => n.toLocaleString("ru");
return (

View File

@ -374,6 +374,17 @@ const D: Record<string, Tr> = {
"Not a text file.": { ru: "Не текстовый файл.", de: "Keine Textdatei.", fr: "Pas un fichier texte.", es: "No es un archivo de texto." },
"{user} submitted #{n}": { ru: "{user} отправил #{n}", de: "{user} hat #{n} übermittelt", fr: "{user} a soumis #{n}", es: "{user} envió #{n}" },
"Reconnected to the server.": { ru: "Соединение с сервером восстановлено.", de: "Wieder mit dem Server verbunden.", fr: "Reconnecté au serveur.", es: "Reconectado al servidor." },
"Connected server": { ru: "Сервер подключения", de: "Verbundener Server", fr: "Serveur connecté", es: "Servidor conectado" },
"No embedded thumbnail. Load the 3D model below (needs Unreal Engine installed).": { ru: "Встроенного превью нет. Загрузи 3D-модель ниже (нужен установленный Unreal Engine).", de: "Kein eingebettetes Vorschaubild. Lade unten das 3D-Modell (Unreal Engine muss installiert sein).", fr: "Pas de miniature intégrée. Charge le modèle 3D ci-dessous (Unreal Engine requis).", es: "Sin miniatura incrustada. Carga el modelo 3D abajo (requiere Unreal Engine instalado)." },
"Load 3D model": { ru: "Загрузить 3D-модель", de: "3D-Modell laden", fr: "Charger le modèle 3D", es: "Cargar modelo 3D" },
"Exporting from Unreal… (first time is slow)": { ru: "Экспорт из Unreal… (первый раз долго)", de: "Export aus Unreal… (erstes Mal langsam)", fr: "Export depuis Unreal… (lent la première fois)", es: "Exportando desde Unreal… (la primera vez es lento)" },
"Export the mesh from Unreal and view it in 3D": { ru: "Экспортировать меш из Unreal и посмотреть в 3D", de: "Mesh aus Unreal exportieren und in 3D ansehen", fr: "Exporter le mesh depuis Unreal et le voir en 3D", es: "Exportar el mesh desde Unreal y verlo en 3D" },
"3D export works on the working copy, not history": { ru: "3D-экспорт работает по рабочей копии, не по истории", de: "3D-Export funktioniert mit der Arbeitskopie, nicht mit der Historie", fr: "L'export 3D fonctionne sur la copie de travail, pas l'historique", es: "La exportación 3D funciona con la copia de trabajo, no con el historial" },
"Exports the version currently synced on disk.": { ru: "Экспортирует версию, которая сейчас синкнута на диске.", de: "Exportiert die aktuell auf der Festplatte synchronisierte Version.", fr: "Exporte la version actuellement synchronisée sur le disque.", es: "Exporta la versión actualmente sincronizada en disco." },
"Editing": { ru: "Редактирование", de: "Bearbeiten", fr: "Édition", es: "Editando" },
"Edit": { ru: "Редактировать", de: "Bearbeiten", fr: "Modifier", es: "Editar" },
"Saved {name}": { ru: "Сохранено: {name}", de: "Gespeichert: {name}", fr: "Enregistré : {name}", es: "Guardado: {name}" },
"Pick the Unreal project working folder first.": { ru: "Сначала выбери рабочую папку Unreal-проекта.", de: "Wähle zuerst den Arbeitsordner des Unreal-Projekts.", fr: "Choisis d'abord le dossier de travail du projet Unreal.", es: "Primero elige la carpeta de trabajo del proyecto Unreal." },
"Disconnected from the server — retrying…": { ru: "Соединение с сервером потеряно — переподключаюсь…", de: "Verbindung zum Server verloren — erneuter Versuch…", fr: "Déconnecté du serveur — nouvelle tentative…", es: "Desconectado del servidor — reintentando…" },
};

View File

@ -102,6 +102,8 @@ export const p4 = {
if (Array.isArray(r)) return new Uint8Array(r as number[]).buffer;
return r as ArrayBuffer;
},
// built-in editor: write text back to a depot file's working copy
writeDepot: (depot: string, content: string) => invoke<void>("write_depot", { depot, content }),
switchClient: (client: string) => invoke<P4Info>("p4_switch_client", { client }),
users: () => invoke<User[]>("p4_users"),
groups: (user = "") => invoke<{ group?: string }[]>("p4_groups", { user }),
@ -127,6 +129,22 @@ export const p4 = {
searchFiles: (query: string, scope = "") => invoke<{ depotFile?: string; rev?: string }[]>("p4_search_files", { query, scope }),
reopenTo: (change: string, files: string[]) => invoke<string>("p4_reopen_to", { change, files }),
latestChange: (scope = "") => invoke<Change | null>("p4_latest_change", { scope }),
// embedded Unreal .uasset thumbnail (PNG bytes), for working copy or history
uassetThumb: async (spec: string, historical: boolean): Promise<ArrayBuffer> => {
const r: unknown = await invoke("uasset_thumbnail", { spec, historical });
if (r instanceof ArrayBuffer) return r;
if (ArrayBuffer.isView(r)) return (r as ArrayBufferView).buffer as ArrayBuffer;
if (Array.isArray(r)) return new Uint8Array(r as number[]).buffer;
return r as ArrayBuffer;
},
// export a working-copy .uasset mesh to FBX via headless Unreal → real 3D
exportUasset3d: async (depot: string, uproject: string): Promise<ArrayBuffer> => {
const r: unknown = await invoke("uasset_export_3d", { depot, uproject });
if (r instanceof ArrayBuffer) return r;
if (ArrayBuffer.isView(r)) return (r as ArrayBufferView).buffer as ArrayBuffer;
if (Array.isArray(r)) return new Uint8Array(r as number[]).buffer;
return r as ArrayBuffer;
},
};
export interface User { User?: string; Email?: string; FullName?: string; Access?: string; Update?: string; [k: string]: unknown }