Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afc4824b26 | |||
| b00b4f94b0 | |||
| cd61de548d | |||
| 670acf5242 | |||
| 72b163f592 | |||
| e9868c5b2e | |||
| 2feb7ca5e1 | |||
| d9f99bc58b | |||
| 4249d5ba66 |
14
package-lock.json
generated
14
package-lock.json
generated
@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "exbyte-depot",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "exbyte-depot",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"three": "^0.185.1"
|
||||
@ -1820,6 +1821,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "exbyte-depot",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -15,6 +15,7 @@
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"three": "^0.185.1"
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -951,7 +951,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "exbyte-depot"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "exbyte-depot"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"opener:allow-open-path",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
|
||||
@ -610,6 +610,206 @@ async fn open_in_explorer(state: State<'_, AppState>, target: String) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a depot file's local working copy in VS Code (`code <path>`).
|
||||
/// `code` is a .cmd on Windows, so it must be launched through the shell.
|
||||
#[tauri::command]
|
||||
async fn open_in_vscode(state: State<'_, AppState>, depot: 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())?;
|
||||
let mut c = Command::new("cmd");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
c.creation_flags(0x0800_0000);
|
||||
}
|
||||
c.arg("/C").arg(format!("code \"{local}\""));
|
||||
c.spawn()
|
||||
.map_err(|e| format!("VS Code not found (is `code` in PATH?): {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a local file with its associated application (e.g. launch Unreal for a
|
||||
/// `.uproject`). Uses the shell `start` so there are no path-scope restrictions.
|
||||
#[tauri::command]
|
||||
async fn launch_file(path: String) -> Result<(), String> {
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
return Err(format!("File not found: {path}"));
|
||||
}
|
||||
let mut c = Command::new("cmd");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
c.creation_flags(0x0800_0000);
|
||||
}
|
||||
c.args(["/C", "start", "", &path])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Could not launch: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a working-folder scope to a local directory via `p4 where`.
|
||||
fn scope_local_dir(conn: &Conn, scope: &str) -> Option<String> {
|
||||
if scope.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let spec = format!("{}/...", scope.trim_end_matches(['/', '.']));
|
||||
run_json(conn, &["where", &spec])
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
|
||||
.map(|p| p.trim_end_matches(['.', '/', '\\']).to_string())
|
||||
}
|
||||
|
||||
/// First file with the given extension in `dir` (checked, then one level of subdirs).
|
||||
fn find_ext(dir: &str, ext: &str) -> String {
|
||||
let matches = |p: &std::path::Path| p.extension().map_or(false, |x| x.eq_ignore_ascii_case(ext));
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
let mut subdirs = Vec::new();
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if matches(&p) {
|
||||
return p.to_string_lossy().to_string();
|
||||
}
|
||||
if p.is_dir() {
|
||||
subdirs.push(p);
|
||||
}
|
||||
}
|
||||
for sd in subdirs {
|
||||
if let Ok(inner) = std::fs::read_dir(&sd) {
|
||||
for e in inner.flatten() {
|
||||
let p = e.path();
|
||||
if matches(&p) {
|
||||
return p.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Local path of a `.uproject` in the scope (or "" if not a UE project).
|
||||
#[tauri::command]
|
||||
async fn find_uproject(state: State<'_, AppState>, scope: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
Ok(scope_local_dir(&conn, &scope).map(|d| find_ext(&d, "uproject")).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Local path of a Visual Studio `.sln` in the scope (or "" if none).
|
||||
#[tauri::command]
|
||||
async fn find_sln(state: State<'_, AppState>, scope: String) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
Ok(scope_local_dir(&conn, &scope).map(|d| find_ext(&d, "sln")).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Build a Visual Studio solution with MSBuild (located via vswhere), streaming
|
||||
/// its output line-by-line as `build-log` events. Uses the solution's default
|
||||
/// configuration — for an Unreal project that compiles the game module.
|
||||
#[tauri::command]
|
||||
async fn build_sln(path: String) -> Result<String, String> {
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
return Err(format!("Solution not found: {path}"));
|
||||
}
|
||||
let emit = |line: String, done: bool, ok: bool| {
|
||||
if let Some(app) = APP.get() {
|
||||
let _ = app.emit(
|
||||
"build-log",
|
||||
serde_json::json!({ "line": line, "done": done, "ok": ok }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// locate MSBuild.exe via vswhere (falls back to PATH)
|
||||
let pf86 = std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| "C:\\Program Files (x86)".into());
|
||||
let vswhere = format!("{pf86}\\Microsoft Visual Studio\\Installer\\vswhere.exe");
|
||||
let msbuild = {
|
||||
let mut vc = Command::new(&vswhere);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
vc.creation_flags(0x0800_0000);
|
||||
}
|
||||
vc.args(["-latest", "-requires", "Microsoft.Component.MSBuild", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"]);
|
||||
match vc.output() {
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout).lines().next().unwrap_or("").trim().to_string(),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
};
|
||||
let msbuild = if msbuild.is_empty() { "MSBuild.exe".to_string() } else { msbuild };
|
||||
emit(format!("MSBuild: {msbuild}"), false, false);
|
||||
emit(format!("Building {path} …"), false, false);
|
||||
|
||||
let mut cmd = Command::new(&msbuild);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
cmd.args([&path, "/m", "/nologo", "/v:minimal", "/clp:NoSummary"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let m = format!("Could not start MSBuild (Visual Studio C++ tools installed?): {e}");
|
||||
emit(m.clone(), true, false);
|
||||
return Err(m);
|
||||
}
|
||||
};
|
||||
|
||||
// For Unreal, the .sln drives UnrealBuildTool; MSBuild can return non-zero for a
|
||||
// side project even when UBT reports the game module built fine. So trust UBT's
|
||||
// "Result: Succeeded" (and the absence of real compile errors) over the exit code.
|
||||
let mut ubt_result = false;
|
||||
let mut ubt_success = false;
|
||||
let mut compile_error = false;
|
||||
let mut scan = |line: &str| {
|
||||
let l = line.to_ascii_lowercase();
|
||||
if l.contains("result: succeeded") { ubt_result = true; ubt_success = true; }
|
||||
if l.contains("result: failed") { ubt_result = true; }
|
||||
if l.contains(": error ") || l.contains(") error ") || l.contains("error msb") {
|
||||
compile_error = true;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(out) = child.stdout.take() {
|
||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||
if !line.trim().is_empty() {
|
||||
scan(&line);
|
||||
emit(line, false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
let status = child.wait().map_err(|e| e.to_string())?;
|
||||
if let Some(mut se) = child.stderr.take() {
|
||||
let mut buf = String::new();
|
||||
let _ = std::io::Read::read_to_string(&mut se, &mut buf);
|
||||
for line in buf.lines() {
|
||||
if !line.trim().is_empty() {
|
||||
scan(line);
|
||||
emit(line.to_string(), false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// UBT verdict wins when present; otherwise fall back to MSBuild's exit code
|
||||
let ok = if ubt_result {
|
||||
ubt_success && !compile_error
|
||||
} else {
|
||||
status.success()
|
||||
};
|
||||
emit(if ok { "✓ Build succeeded.".into() } else { "✗ Build FAILED.".into() }, true, ok);
|
||||
if ok {
|
||||
Ok("ok".into())
|
||||
} else {
|
||||
Err("Build failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Files and metadata of a submitted changelist (History detail).
|
||||
#[tauri::command]
|
||||
async fn p4_describe(state: State<'_, AppState>, change: String) -> Result<Value, String> {
|
||||
@ -702,6 +902,11 @@ pub fn run() {
|
||||
p4_scan,
|
||||
p4_undo_change,
|
||||
open_in_explorer,
|
||||
find_uproject,
|
||||
find_sln,
|
||||
build_sln,
|
||||
launch_file,
|
||||
open_in_vscode,
|
||||
p4_describe,
|
||||
read_file,
|
||||
read_depot,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "exbyte-depot",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"identifier": "com.bonchellon.exbyte-depot",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
111
src/App.css
111
src/App.css
@ -140,10 +140,10 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.files{flex:1;overflow-y:auto;min-height:0;position:relative}
|
||||
.vspace{position:relative;width:100%}
|
||||
.file{position:absolute;left:7px;right:7px;height:50px;display:flex;align-items:center;gap:11px;padding:0 12px;cursor:pointer;
|
||||
border-radius:11px;transition:background .13s}
|
||||
border-radius:11px;transition:background .13s;user-select:none}
|
||||
.file:hover{background:var(--hover)}
|
||||
.file.sel{background:rgba(124,110,246,.13);box-shadow:inset 0 0 0 1px rgba(124,110,246,.28)}
|
||||
.file.sel::before{content:"";position:absolute;left:4px;top:11px;bottom:11px;width:3px;border-radius:3px;
|
||||
.file.primary::before{content:"";position:absolute;left:4px;top:11px;bottom:11px;width:3px;border-radius:3px;
|
||||
background:linear-gradient(180deg,var(--accent-2),var(--accent-deep));box-shadow:0 0 8px rgba(124,110,246,.6)}
|
||||
.fchk{width:15px;height:15px;border-radius:6px;border:1.5px solid var(--border);display:grid;place-items:center;flex:0 0 auto;background:var(--panel)}
|
||||
.fchk.ck{background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));border-color:var(--accent)}
|
||||
@ -195,7 +195,9 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
/* right-click context menu */
|
||||
.ctxmenu{position:fixed;z-index:300;min-width:210px;padding:5px;border-radius:12px;
|
||||
background:var(--elevated);border:1px solid var(--border);box-shadow:0 18px 44px -12px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.03) inset}
|
||||
.ctxitem{padding:8px 12px;border-radius:8px;font-size:12.5px;color:var(--txt);cursor:pointer;white-space:nowrap;transition:background .1s}
|
||||
.ctxitem{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:8px;font-size:12.5px;color:var(--txt);cursor:pointer;white-space:nowrap;transition:background .1s}
|
||||
.ctx-ic{width:16px;height:16px;flex:0 0 auto;display:grid;place-items:center}
|
||||
.ctx-ic svg{width:16px;height:16px}
|
||||
.ctxitem:hover{background:var(--hover)}
|
||||
.ctxitem.danger{color:var(--del)}
|
||||
.ctxitem.danger:hover{background:rgba(242,99,126,.12)}
|
||||
@ -248,6 +250,41 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
cursor:pointer;font-size:12px;font-variant-numeric:tabular-nums}
|
||||
.zval:hover{border-color:var(--accent)}
|
||||
|
||||
/* custom dropdown (replaces native <select>) */
|
||||
.xsel{position:relative}
|
||||
.xsel-trig{display:flex;align-items:center;gap:9px;width:100%;background:var(--input-bg);border:1px solid var(--border);
|
||||
border-radius:10px;padding:10px 12px;cursor:pointer;color:var(--txt);font-family:var(--font);font-size:13px;text-align:left}
|
||||
.xsel-trig:hover,.xsel.open .xsel-trig{border-color:var(--accent)}
|
||||
.xsel.open .xsel-trig{box-shadow:0 0 0 3px rgba(124,110,246,.15)}
|
||||
.xsel-trig > svg{width:15px;height:15px;color:var(--faint);flex:0 0 auto}
|
||||
.xsel-val{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.xsel-ph{color:var(--faint)}
|
||||
.xsel-chev{flex:0 0 auto;color:var(--faint);display:grid;place-items:center;transition:transform .18s}
|
||||
.xsel-chev svg{width:15px;height:15px}
|
||||
.xsel.open .xsel-chev{transform:rotate(180deg)}
|
||||
.xsel-list{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:200;max-height:264px;overflow-y:auto;padding:5px;
|
||||
background:var(--elevated);border:1px solid var(--border);border-radius:12px;box-shadow:0 20px 48px -14px rgba(0,0,0,.5);animation:updin .16s ease}
|
||||
.xsel-empty{padding:12px;text-align:center;color:var(--faint);font-size:12.5px}
|
||||
.xsel-item{display:flex;align-items:center;gap:9px;padding:9px 10px;border-radius:9px;cursor:pointer}
|
||||
.xsel-item:hover{background:var(--hover)}
|
||||
.xsel-item.on{background:rgba(124,110,246,.12)}
|
||||
.xsel-ic{width:15px;flex:0 0 auto;display:grid;place-items:center;color:var(--accent-2)}
|
||||
.xsel-ic svg{width:13px;height:13px}
|
||||
.xsel-body{display:flex;flex-direction:column;gap:1px;min-width:0}
|
||||
.xsel-name{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.xsel-sub{font-size:11px;color:var(--faint);font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
|
||||
/* Launch-Unreal button (white icon on black / black on white per theme) */
|
||||
.ue-btn{display:flex;align-items:center;gap:9px;flex:0 0 auto;align-self:center;height:40px;margin:0 8px;padding:0 15px;
|
||||
border:none;border-radius:11px;cursor:pointer;background:var(--txt);color:var(--win-a);transition:filter .15s,transform .06s}
|
||||
.ue-btn:hover{filter:brightness(1.12)}
|
||||
.ue-btn:active{transform:scale(.97)}
|
||||
.ue-ic{width:24px;height:24px;flex:0 0 auto;display:grid;place-items:center}
|
||||
.ue-ic svg{width:24px;height:24px}
|
||||
.ue-lbl{display:flex;flex-direction:column;line-height:1.12;text-align:left}
|
||||
.ue-a{font-size:9.5px;text-transform:uppercase;letter-spacing:.6px;opacity:.72}
|
||||
.ue-b{font-size:13px;font-weight:700}
|
||||
|
||||
/* bottom status bar (always visible) + hover log peek */
|
||||
.statusbar{flex:0 0 26px;display:flex;align-items:center;gap:10px;padding:0 12px;border-top:1px solid var(--border);
|
||||
background:var(--bar);font-size:11.5px;color:var(--muted);position:relative;user-select:none;z-index:60}
|
||||
@ -325,6 +362,42 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.xfer-bar{margin-top:12px;height:6px;border-radius:4px;background:var(--panel-3);overflow:hidden;position:relative}
|
||||
.xfer-bar span{position:absolute;top:0;left:-35%;width:35%;height:100%;border-radius:4px;
|
||||
background:linear-gradient(90deg,transparent,var(--accent),transparent);animation:shim 1.1s ease-in-out infinite}
|
||||
|
||||
/* MSBuild output overlay */
|
||||
.build{width:660px;max-width:calc(100vw - 40px);height:min(70vh,560px);display:flex;flex-direction:column;
|
||||
background:var(--elevated);border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow);overflow:hidden;animation:updin .3s ease}
|
||||
.build-head{flex:0 0 auto;display:flex;align-items:center;gap:12px;padding:13px 18px;border-bottom:1px solid var(--border-soft)}
|
||||
.build-ic{width:34px;height:34px;flex:0 0 auto;border-radius:10px;display:grid;place-items:center;font-weight:700;font-size:16px;background:var(--panel-3);color:var(--muted)}
|
||||
.build-ic.run{color:var(--accent-2)}
|
||||
.build-ic.ok{background:rgba(62,207,142,.15);color:var(--add)}
|
||||
.build-ic.fail{background:rgba(242,99,126,.15);color:var(--del)}
|
||||
.build-ic svg{width:18px;height:18px}
|
||||
.build-ttl{display:flex;flex-direction:column;gap:1px;min-width:0}
|
||||
.build-ttl b{font-size:14px}
|
||||
.build-ttl span{font-size:11.5px;color:var(--faint);font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.build-x{margin-left:auto;width:28px;height:28px;flex:0 0 auto;border:1px solid var(--border);background:var(--panel);color:var(--muted);border-radius:8px;cursor:pointer;display:grid;place-items:center}
|
||||
.build-x + .build-x{margin-left:6px}
|
||||
.build-x:hover:not(:disabled){color:var(--txt);border-color:var(--accent)}
|
||||
.build-x:disabled{opacity:.4;cursor:not-allowed}
|
||||
|
||||
/* minimized build chip */
|
||||
.buildchip{position:fixed;right:18px;bottom:18px;z-index:360;display:flex;align-items:center;gap:10px;
|
||||
padding:9px 10px 9px 13px;border-radius:12px;cursor:pointer;background:var(--elevated);border:1px solid var(--border);
|
||||
box-shadow:0 16px 40px -14px rgba(0,0,0,.55);animation:updin .25s ease;max-width:300px}
|
||||
.buildchip.run{border-color:rgba(124,110,246,.45)}
|
||||
.buildchip.ok{border-color:rgba(62,207,142,.5)}
|
||||
.buildchip.fail{border-color:rgba(242,99,126,.5)}
|
||||
.buildchip:hover{filter:brightness(1.06)}
|
||||
.bc-ic{width:20px;height:20px;flex:0 0 auto;display:grid;place-items:center;font-weight:700;font-size:14px}
|
||||
.buildchip.run .bc-ic{color:var(--accent-2)}.buildchip.ok .bc-ic{color:var(--add)}.buildchip.fail .bc-ic{color:var(--del)}
|
||||
.bc-txt{font-size:12.5px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.bc-x{width:22px;height:22px;flex:0 0 auto;border:none;background:none;color:var(--faint);border-radius:6px;cursor:pointer;display:grid;place-items:center}
|
||||
.bc-x:hover{background:var(--hover);color:var(--txt)}
|
||||
.build-body{flex:1;overflow:auto;min-height:0;padding:10px 14px;font-family:var(--mono);font-size:11.5px;line-height:1.6;background:var(--stage-bg)}
|
||||
.build-ln{white-space:pre-wrap;word-break:break-word;color:var(--muted)}
|
||||
.build-ln.err{color:var(--del)}
|
||||
.build-ln.warn{color:var(--edit)}
|
||||
.build-ln.ok{color:var(--add);font-weight:600}
|
||||
.tzone.push .ic{color:var(--add)}
|
||||
.tzone.push .val{color:var(--add)}
|
||||
.commit .row{display:flex;gap:10px;align-items:center}
|
||||
@ -351,6 +424,8 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.gbtn{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);background:var(--panel);
|
||||
border:1px solid var(--border);border-radius:9px;padding:7px 12px;cursor:pointer;transition:.15s}
|
||||
.gbtn:hover{color:var(--txt);border-color:var(--accent)}.gbtn svg{width:14px;height:14px}
|
||||
.gbtn.vsc svg{color:#3aa0ff}
|
||||
.gbtn.vsc:hover{border-color:#3aa0ff;color:#3aa0ff}
|
||||
|
||||
.asset{flex:1;min-height:0;display:grid;grid-template-rows:1fr auto}
|
||||
.stage{position:relative;min-height:0;overflow:hidden;display:grid;place-items:center;
|
||||
@ -404,6 +479,36 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.dl.add{background:rgba(62,207,142,.10)}.dl.add .c{color:var(--diff-add-c)}.dl.add .g{background:var(--add)}
|
||||
.dl.del{background:rgba(242,99,126,.10)}.dl.del .c{color:var(--diff-del-c)}.dl.del .g{background:var(--del)}
|
||||
.dl.hunk{background:rgba(124,110,246,.08)}.dl.hunk .c{color:var(--accent-2)}
|
||||
|
||||
/* code viewer (GitHub-style content + line numbers) */
|
||||
.right-body{flex:1;min-height:0;display:flex;flex-direction:column}
|
||||
.codebar{flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:8px 16px;border-bottom:1px solid var(--border-soft);font-size:12px}
|
||||
.cb-badge{flex:0 0 auto;font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);
|
||||
background:var(--panel-3);border-radius:7px;padding:2px 9px}
|
||||
.cb-badge.add{color:var(--add);background:rgba(62,207,142,.14)}
|
||||
.cb-badge.diff{color:var(--accent-2);background:rgba(124,110,246,.14)}
|
||||
.cb-name{color:var(--txt);font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cb-lines{margin-left:auto;flex:0 0 auto;color:var(--faint);font-variant-numeric:tabular-nums}
|
||||
.code{flex:1;min-height:0;overflow:auto;display:flex;font-family:var(--mono);font-size:12.5px;line-height:1.7}
|
||||
.code-gutter{position:sticky;left:0;z-index:1;flex:0 0 auto;display:flex;flex-direction:column;text-align:right;
|
||||
padding:10px 12px 10px 16px;color:var(--faint);background:var(--stage-bg);border-right:1px solid var(--border-soft);
|
||||
user-select:none;font-variant-numeric:tabular-nums}
|
||||
.code.added .code-gutter{color:var(--add);background:rgba(62,207,142,.06)}
|
||||
.code-body{flex:1;margin:0;padding:10px 16px;white-space:pre;overflow:visible}
|
||||
.code-body code{white-space:pre;font-family:inherit;background:none;padding:0}
|
||||
.code.added .code-body{background:rgba(62,207,142,.04)}
|
||||
|
||||
/* 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}
|
||||
.hljs-keyword,.hljs-selector-tag,.hljs-built_in,.hljs-name,.hljs-tag{color:var(--accent-2)}
|
||||
.hljs-string,.hljs-attr,.hljs-symbol,.hljs-bullet,.hljs-addition,.hljs-meta-string{color:var(--add)}
|
||||
.hljs-number,.hljs-literal,.hljs-type,.hljs-params,.hljs-template-variable,.hljs-variable{color:var(--edit)}
|
||||
.hljs-title,.hljs-title.function_,.hljs-section,.hljs-class .hljs-title{color:var(--txt);font-weight:600}
|
||||
.hljs-attribute,.hljs-selector-id,.hljs-selector-class,.hljs-selector-attr{color:var(--accent)}
|
||||
.hljs-meta,.hljs-comment.hljs-doctag{color:var(--muted)}
|
||||
.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
|
||||
.hljs-deletion{color:var(--del)}
|
||||
.diff::-webkit-scrollbar{width:10px}.diff::-webkit-scrollbar-thumb{background:var(--panel-3);border-radius:8px}
|
||||
|
||||
/* history */
|
||||
|
||||
278
src/App.tsx
278
src/App.tsx
@ -1,9 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent, CSSProperties } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent, CSSProperties, ReactNode } from "react";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import ModelViewer from "./ModelViewer";
|
||||
import CodeView from "./CodeView";
|
||||
import UpdateBanner from "./Updater";
|
||||
import "./App.css";
|
||||
import { p4, statusOf, splitPath, kindOf, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, P4Info, OpenedFile, Client, Change, Session, Describe, Dir } from "./p4";
|
||||
@ -38,6 +39,9 @@ const I = {
|
||||
revert: <svg viewBox="0 0 24 24" fill="none"><path d="M4 12a8 8 0 0 1 14-5.3L21 9M21 4v5h-5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
clock: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6"/><path d="M12 8v4l2.5 2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
log: <svg viewBox="0 0 24 24" fill="none"><rect x="4" y="3" width="16" height="18" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 8h8M8 12h8M8 16h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
vscode: <svg viewBox="0 0 24 24" fill="none"><path d="m9 8-5 4 5 4M15 8l5 4-5 4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
hammer: <svg viewBox="0 0 24 24" fill="none"><path d="M14 6l4 4M17 3l4 4-3 3-4-4 3-3ZM13 7 4 16a2 2 0 0 0 0 3l1 1a2 2 0 0 0 3 0l9-9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
ue: <svg viewBox="0 0 1280 1280"><path fillRule="evenodd" clipRule="evenodd" fill="currentColor" d="M640,1280c353.46,0,640-286.54,640-640S993.46,0,640,0S0,286.54,0,640S286.54,1280,640,1280z M803.7,995.81c156.5-73.92,205.56-210.43,216.6-263.61c-57.22,58.6-120.53,118-163.11,76.88c0,0-2.33-219.45-2.33-309.43c0-121,114.75-211.18,114.75-211.18c-63.11,11.24-138.89,33.71-219.33,112.65c-7.26,7.2-14.14,14.76-20.62,22.67c-34.47-26.39-79.14-18.48-79.14-18.48c24.14,13.26,48.23,51.88,48.23,83.85v314.26c0,0-52.63,46.3-93.19,46.3c-9.14,0.07-18.17-2.05-26.33-6.18c-8.16-4.13-15.21-10.15-20.56-17.56c-3.21-4.19-5.87-8.78-7.91-13.65V424.07c-11.99,9.89-52.51,18.04-52.51-49.22c0-41.79,30.11-91.6,83.73-122.15c-73.63,11.23-142.59,43.04-198.92,91.76c-42.8,36.98-77.03,82.85-100.31,134.4c-23.28,51.55-35.06,107.55-34.51,164.12c0,0,39.21-122.51,88.32-133.83c7.15-1.88,14.65-2.07,21.89-0.54c7.24,1.53,14.02,4.72,19.81,9.34c5.79,4.61,10.41,10.51,13.51,17.23c3.1,6.72,4.59,14.07,4.34,21.46V844.3c0,29.16-18.8,35.53-36.17,35.22c-11.77-0.83-23.4-3.02-34.66-6.53c35.86,48.53,82.46,88.12,136.15,115.66c53.69,27.54,113.03,42.29,173.37,43.1l106.05-106.6L803.7,995.81z"/></svg>,
|
||||
};
|
||||
|
||||
/* ---------------- theme hook ---------------- */
|
||||
@ -104,6 +108,41 @@ export default function App() {
|
||||
return <>{content}<UpdateBanner /></>;
|
||||
}
|
||||
|
||||
/* ---------------- custom styled dropdown (replaces the ugly native <select>) ---------------- */
|
||||
function Select({ value, options, onChange, icon, placeholder }:
|
||||
{ value: string; options: { value: string; label: string; sub?: string }[]; onChange: (v: string) => void; icon?: ReactNode; placeholder?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
|
||||
}, []);
|
||||
const cur = options.find((o) => o.value === value);
|
||||
return (
|
||||
<div className={"xsel" + (open ? " open" : "")} ref={ref}>
|
||||
<button type="button" className="xsel-trig" onClick={() => setOpen((o) => !o)}>
|
||||
{icon}
|
||||
<span className="xsel-val">{cur ? cur.label : <span className="xsel-ph">{placeholder}</span>}</span>
|
||||
<span className="xsel-chev">{I.chev}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="xsel-list">
|
||||
{options.length === 0 && <div className="xsel-empty">{placeholder}</div>}
|
||||
{options.map((o) => (
|
||||
<div key={o.value} className={"xsel-item" + (o.value === value ? " on" : "")} onClick={() => { onChange(o.value); setOpen(false); }}>
|
||||
<span className="xsel-ic">{o.value === value ? I.check : null}</span>
|
||||
<span className="xsel-body"><span className="xsel-name">{o.label}</span>{o.sub && <span className="xsel-sub">{o.sub}</span>}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 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…")];
|
||||
@ -193,16 +232,16 @@ function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; togg
|
||||
<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6"/></svg>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && connect()} /></div></div>
|
||||
<div className="fld"><label>{t("Workspace")}</label>
|
||||
<div className="inp">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6"/></svg>
|
||||
{clients.length ? (
|
||||
<select value={client} onChange={(e) => setClient(e.target.value)}>
|
||||
{clients.map((c) => <option key={c.client} value={c.client}>{c.client}{c.Root ? ` — ${c.Root}` : ""}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
{clients.length ? (
|
||||
<Select value={client} onChange={setClient} placeholder={t("Choose workspace")}
|
||||
icon={<svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6"/></svg>}
|
||||
options={clients.map((c) => ({ value: c.client || "", label: c.client || "", sub: (c.Root as string) || "" }))} />
|
||||
) : (
|
||||
<div className="inp">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6"/></svg>
|
||||
<input placeholder={t("workspace name (not a folder path!)")} value={client} onChange={(e) => setClient(e.target.value)} />
|
||||
)}
|
||||
</div></div>
|
||||
</div>
|
||||
)}</div>
|
||||
|
||||
{err && <div className="err"><span>⚠</span><span>{err}</span></div>}
|
||||
<button className="connect" disabled={busy || !password} onClick={connect}>
|
||||
@ -217,7 +256,7 @@ function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; togg
|
||||
/* ---------------- main workbench ---------------- */
|
||||
type Tab = "changes" | "history";
|
||||
|
||||
type CtxItem = { label: string; danger?: boolean; act: () => void };
|
||||
type CtxItem = { label: string; danger?: boolean; icon?: ReactNode; act: () => void };
|
||||
|
||||
type ModalState =
|
||||
| { kind: "workspaces"; items: Client[]; current: string }
|
||||
@ -235,7 +274,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const [activePath, setActivePath] = useState<string>(() => loadScope(info?.clientName || ""));
|
||||
const [files, setFiles] = useState<OpenedFile[]>([]);
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||||
const [sel, setSel] = useState<number | null>(null);
|
||||
const [sel, setSel] = useState<number | null>(null); // primary row (preview + shift anchor)
|
||||
const [selRows, setSelRows] = useState<Set<number>>(new Set()); // multi-selection (Shift/Ctrl-click)
|
||||
const [filter, setFilter] = useState("");
|
||||
const [desc, setDesc] = useState(""); // commit summary (first line)
|
||||
const [descBody, setDescBody] = useState(""); // extended description (optional)
|
||||
@ -252,6 +292,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const [pending, setPending] = useState<Change[]>([]); // committed-but-not-pushed changelists
|
||||
const [transfer, setTransfer] = useState<Transfer | null>(null); // live sync/submit file transfer
|
||||
const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog
|
||||
const [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project
|
||||
const [slnPath, setSlnPath] = useState(""); // local .sln path in the scope (for building)
|
||||
const [buildLog, setBuildLog] = useState<string[]>([]); // live MSBuild output
|
||||
const [building, setBuilding] = useState(false);
|
||||
const [buildOk, setBuildOk] = useState<boolean | null>(null);
|
||||
const [buildOpen, setBuildOpen] = useState(false);
|
||||
const [buildMin, setBuildMin] = useState(false); // collapsed to a small floating chip
|
||||
const [ctx, setCtx] = useState<null | { x: number; y: number; items: CtxItem[] }>(null); // right-click menu
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]); // live p4 command log (P4V-style)
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
@ -292,6 +339,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const uncommitted = f.filter((x) => (x.change || "default") === "default");
|
||||
setChecked(new Set(uncommitted.map((x) => x.depotFile || "").filter(Boolean)));
|
||||
setSel((prev) => (prev != null && prev < f.length ? prev : f.length ? 0 : null));
|
||||
setSelRows(new Set());
|
||||
try { setPending(await p4.changes()); } catch { setPending([]); }
|
||||
}
|
||||
|
||||
@ -318,24 +366,24 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
useEffect(() => { refresh(); /* eslint-disable-next-line */ }, []);
|
||||
// live Perforce command log — the Rust backend emits one event per p4 call
|
||||
useEffect(() => {
|
||||
let un: (() => void) | undefined;
|
||||
let un: (() => void) | undefined; let dead = false;
|
||||
listen<{ cmd: string; count: number; ok: boolean; err?: string | null }>("p4-log", (e) => {
|
||||
setLogs((L) => {
|
||||
const next = [...L, { ...e.payload, id: ++logId.current }];
|
||||
return next.length > 600 ? next.slice(-600) : next;
|
||||
});
|
||||
}).then((u) => (un = u));
|
||||
return () => un?.();
|
||||
}).then((u) => { if (dead) u(); else un = u; });
|
||||
return () => { dead = true; un?.(); };
|
||||
}, []);
|
||||
// live file-transfer progress (Get Latest / Submit) streamed from the backend
|
||||
useEffect(() => {
|
||||
let un: (() => void) | undefined;
|
||||
let un: (() => void) | undefined; let dead = false;
|
||||
listen<{ op: "sync" | "submit"; count: number; file?: string; done: boolean }>("p4-transfer", (e) => {
|
||||
const p = e.payload;
|
||||
if (p.done) setTransfer(null);
|
||||
else setTransfer({ op: p.op, count: p.count, file: p.file });
|
||||
}).then((u) => (un = u));
|
||||
return () => un?.();
|
||||
}).then((u) => { if (dead) u(); else un = u; });
|
||||
return () => { dead = true; un?.(); };
|
||||
}, []);
|
||||
// only surface the progress dialog once a transfer has run for a moment
|
||||
const xferActive = transfer !== null;
|
||||
@ -344,6 +392,24 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const id = setTimeout(() => setShowXfer(true), 400);
|
||||
return () => clearTimeout(id);
|
||||
}, [xferActive]);
|
||||
// detect a UE project / .sln in the current working folder (Launch / Build actions)
|
||||
useEffect(() => {
|
||||
if (!activePath) { setUproject(""); setSlnPath(""); return; }
|
||||
let live = true;
|
||||
p4.findUproject(activePath).then((p) => live && setUproject(p)).catch(() => live && setUproject(""));
|
||||
p4.findSln(activePath).then((p) => live && setSlnPath(p)).catch(() => live && setSlnPath(""));
|
||||
return () => { live = false; };
|
||||
}, [activePath]);
|
||||
// live MSBuild output
|
||||
useEffect(() => {
|
||||
let un: (() => void) | undefined; let dead = false;
|
||||
listen<{ line: string; done: boolean; ok: boolean }>("build-log", (e) => {
|
||||
const p = e.payload;
|
||||
setBuildLog((L) => (L.length > 4000 ? [...L.slice(-4000), p.line] : [...L, p.line]));
|
||||
if (p.done) { setBuilding(false); setBuildOk(p.ok); }
|
||||
}).then((u) => { if (dead) u(); else un = u; });
|
||||
return () => { dead = true; un?.(); };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const close = () => setOpenMenu(null);
|
||||
document.addEventListener("click", close);
|
||||
@ -456,6 +522,21 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
|
||||
function openCtx(e: ReactMouseEvent, items: CtxItem[]) { e.preventDefault(); e.stopPropagation(); setCtx({ x: e.clientX, y: e.clientY, items }); }
|
||||
|
||||
// launch Unreal by opening the detected .uproject (file association starts UE)
|
||||
async function launchUE() {
|
||||
if (!uproject) return;
|
||||
try { await p4.launchFile(uproject); flash(t("Launching Unreal…")); }
|
||||
catch (e) { flash(String(e), true); }
|
||||
}
|
||||
|
||||
// build the solution found in the working folder (MSBuild) with live output
|
||||
async function buildSln() {
|
||||
if (!slnPath) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; }
|
||||
setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false);
|
||||
try { await p4.buildSln(slnPath); }
|
||||
catch { /* the overlay already shows the failure line */ }
|
||||
}
|
||||
|
||||
// reveal a depot path / workspace root in Windows Explorer
|
||||
async function reveal(target: string) {
|
||||
if (!target) { flash(t("Path not set"), true); return; }
|
||||
@ -512,6 +593,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
if (errors.length) flash(errors.join(" · "), true);
|
||||
else flash(t("Sent to the server: {n} changelist(s).", { n }));
|
||||
await refresh();
|
||||
await loadHistory(); // the submit created a new submitted changelist — refresh History
|
||||
}
|
||||
|
||||
async function doRevert(file: OpenedFile) {
|
||||
@ -538,13 +620,60 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
return n;
|
||||
});
|
||||
}
|
||||
// row click with Shift (range) / Ctrl (toggle) multi-selection
|
||||
function rowClick(i: number, e: ReactMouseEvent) {
|
||||
if (e.shiftKey && sel != null) {
|
||||
const a = Math.min(sel, i), b = Math.max(sel, i);
|
||||
const s = new Set<number>();
|
||||
for (let k = a; k <= b; k++) s.add(k);
|
||||
setSelRows(s);
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
setSelRows((prev) => { const n = new Set(prev); n.has(i) ? n.delete(i) : n.add(i); return n; });
|
||||
} else {
|
||||
setSelRows(new Set([i]));
|
||||
}
|
||||
setSel(i);
|
||||
}
|
||||
// checkbox: if the row is part of a multi-selection, toggle the whole selection together
|
||||
function toggleCheck(i: number, dp: string) {
|
||||
const idxs = selRows.has(i) && selRows.size > 1 ? [...selRows] : [i];
|
||||
const dps = idxs.map((k) => view[k]?.depotFile || "").filter(Boolean);
|
||||
const target = !checked.has(dp);
|
||||
setChecked((prev) => { const n = new Set(prev); dps.forEach((d) => (target ? n.add(d) : n.delete(d))); return n; });
|
||||
}
|
||||
|
||||
// Discard local changes (p4 revert) for the given files — the "undo" for opened files.
|
||||
async function discard(fs: OpenedFile[]) {
|
||||
const dps = fs.map((f) => f.depotFile || "").filter(Boolean);
|
||||
if (!dps.length) return;
|
||||
const label = dps.length === 1 ? splitPath(dps[0]).name : t("{n} files", { n: dps.length });
|
||||
if (!(await confirm({ title: t("Discard changes?"), body: t("Local changes to {name} will be lost permanently — the file returns to the server version.", { name: label }), confirm: t("Discard"), danger: true }))) return;
|
||||
setBusy(true);
|
||||
try { await p4.revert(dps); flash(t("Discarded: {name}", { name: label })); await refresh(); }
|
||||
catch (e) { flash(String(e), true); setBusy(false); }
|
||||
}
|
||||
// right-click on a Changes row → file actions (operates on the selection if the row is in it)
|
||||
function fileCtx(i: number, e: ReactMouseEvent) {
|
||||
const multi = selRows.has(i) && selRows.size > 1;
|
||||
if (!selRows.has(i)) { setSelRows(new Set([i])); setSel(i); } // right-click selects the row
|
||||
const idxs = multi ? [...selRows] : [i];
|
||||
const targets = idxs.map((k) => view[k]).filter(Boolean) as OpenedFile[];
|
||||
const dp = view[i]?.depotFile || "";
|
||||
const isCode = kindOf(splitPath(dp).name) === "code";
|
||||
openCtx(e, [
|
||||
{ label: targets.length > 1 ? t("Discard changes · {n} files", { n: targets.length }) : t("Discard changes"), danger: true, act: () => discard(targets) },
|
||||
...(isCode ? [{ label: t("Edit in VS Code"), icon: I.vscode, act: () => { p4.openInVscode(dp).then(() => flash(t("Opening in VS Code…"))).catch((err) => flash(String(err), true)); } }] : []),
|
||||
{ label: t("Open in Explorer"), act: () => reveal(dp) },
|
||||
{ label: t("Copy path"), act: () => copyText(dp, t("Path")) },
|
||||
]);
|
||||
}
|
||||
|
||||
const menus: Record<string, { label: string; kb?: string; ext?: boolean; act?: () => void }[]> = {
|
||||
File: [{ label: t("Switch workspace…"), act: openWorkspaces }, { label: t("Choose working folder…"), act: () => browseTo("") }, { label: t("Disconnect"), act: onDisconnect }],
|
||||
Connection: [{ label: t("Refresh"), kb: "F5", act: () => refresh() }, { label: t("Choose working folder…"), act: () => browseTo("") }],
|
||||
Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", act: getLatest }, { label: t("Commit (local)"), act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", act: pushAll }, { label: t("Revert All…"), act: revertAll }, { label: t("Refresh"), kb: "F5", act: () => refresh() }],
|
||||
Window: [{ label: (logOpen ? "✓ " : "") + t("Log"), kb: "Ctrl+L", act: () => setLogOpen((o) => !o) }],
|
||||
Tools: [{ label: t("Settings…"), act: () => setModal({ kind: "settings" }) }],
|
||||
Tools: [{ label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", act: buildSln }, { label: t("Settings…"), act: () => setModal({ kind: "settings" }) }],
|
||||
Help: [{ label: t("About"), act: () => setModal({ kind: "about" }) }],
|
||||
};
|
||||
|
||||
@ -586,6 +715,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
<div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title={t("Drag to resize")} />
|
||||
<div className="tzone" onClick={() => browseTo("")} title={t("Choose the project working folder · right-click for more")}
|
||||
onContextMenu={(e) => openCtx(e, [
|
||||
...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
|
||||
...(slnPath ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: buildSln }] : []),
|
||||
{ label: t("Open in Explorer"), act: () => reveal(activePath || String(info?.clientRoot || "")) },
|
||||
{ label: t("Choose another folder…"), act: () => browseTo("") },
|
||||
{ label: t("Copy path"), act: () => copyText(activePath || "//…", t("Path")) },
|
||||
@ -619,7 +750,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
{tab === "changes" ? (
|
||||
<>
|
||||
<div className="filter">
|
||||
<div className="box">{I.search}<input placeholder={t("Filter {n} files…", { n: uncommitted.length })} value={filter} onChange={(e) => setFilter(e.target.value)} /></div>
|
||||
<div className="box">{I.search}<input placeholder={t("Filter {n} files…", { n: uncommitted.length })} value={filter} onChange={(e) => { setFilter(e.target.value); setSelRows(new Set()); }} /></div>
|
||||
</div>
|
||||
{pending.length > 0 && (
|
||||
<div className="pushbar" onClick={pushAll} title={t("Submit to server")}>
|
||||
@ -649,8 +780,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
</>}
|
||||
</div>
|
||||
) : (
|
||||
<FileList files={view} sel={sel} checked={checked} onSelect={setSel}
|
||||
onToggle={(dp) => setChecked((s) => { const n = new Set(s); n.has(dp) ? n.delete(dp) : n.add(dp); return n; })} />
|
||||
<FileList files={view} sel={sel} selRows={selRows} checked={checked} onRowClick={rowClick} onToggle={toggleCheck} onContext={fileCtx} />
|
||||
)}
|
||||
<div className="commit">
|
||||
<input className="summary" placeholder={t("Summary: what changed…")} value={desc} onChange={(e) => setDesc(e.target.value)}
|
||||
@ -675,7 +805,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
</div>
|
||||
|
||||
{tab === "changes"
|
||||
? <Preview file={selFile} onRevert={doRevert} />
|
||||
? <Preview file={selFile} onRevert={doRevert} onFlash={flash} />
|
||||
: <ChangeDetail detail={detail} busy={detailBusy} />}
|
||||
</div>
|
||||
|
||||
@ -708,6 +838,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
|
||||
{ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
|
||||
{showXfer && transfer && <TransferDialog transfer={transfer} />}
|
||||
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
|
||||
{buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
|
||||
{ctx && <ContextMenu x={ctx.x} y={ctx.y} items={ctx.items} onClose={() => setCtx(null)} />}
|
||||
{toast && <div className={"toast" + (toast.err ? " err" : "")}>{toast.text}</div>}
|
||||
</div>
|
||||
@ -834,10 +966,14 @@ function ContextMenu({ x, y, items, onClose }: { x: number; y: number; items: Ct
|
||||
return () => { document.removeEventListener("click", close); document.removeEventListener("scroll", close, true); document.removeEventListener("keydown", onKey); };
|
||||
}, [onClose]);
|
||||
const style: CSSProperties = { left: Math.min(x, window.innerWidth - 240), top: Math.min(y, window.innerHeight - (items.length * 36 + 14)) };
|
||||
const hasIcon = items.some((it) => it.icon);
|
||||
return (
|
||||
<div className="ctxmenu" style={style} onClick={(e) => e.stopPropagation()} onContextMenu={(e) => e.preventDefault()}>
|
||||
{items.map((it, i) => (
|
||||
<div key={i} className={"ctxitem" + (it.danger ? " danger" : "")} onClick={() => { onClose(); it.act(); }}>{it.label}</div>
|
||||
<div key={i} className={"ctxitem" + (it.danger ? " danger" : "")} onClick={() => { onClose(); it.act(); }}>
|
||||
{hasIcon && <span className="ctx-ic">{it.icon}</span>}
|
||||
<span>{it.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@ -898,6 +1034,54 @@ function TransferDialog({ transfer }: { transfer: Transfer }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- build output (MSBuild) ---------------- */
|
||||
function BuildOverlay({ lines, building, ok, sln, onMinimize, onClose }: { lines: string[]; building: boolean; ok: boolean | null; sln: string; onMinimize: () => void; onClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]);
|
||||
const name = sln.replace(/\\/g, "/").split("/").pop() || "solution";
|
||||
return (
|
||||
<div className="modal-back">
|
||||
<div className="build">
|
||||
<div className="build-head">
|
||||
<span className={"build-ic" + (building ? " run" : ok ? " ok" : ok === false ? " fail" : "")}>
|
||||
{building ? <span className="ldr sm" /> : ok ? "✓" : ok === false ? "✗" : I.hammer}
|
||||
</span>
|
||||
<div className="build-ttl">
|
||||
<b>{building ? t("Building…") : ok ? t("Build succeeded") : ok === false ? t("Build failed") : t("Build")}</b>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
<button className="build-x" onClick={onMinimize} title={t("Minimize")}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 18h12" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" /></svg>
|
||||
</button>
|
||||
<button className="build-x" onClick={onClose} disabled={building} title={building ? t("Building…") : t("Close")}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="build-body" ref={ref}>
|
||||
{lines.map((ln, i) => {
|
||||
const low = ln.toLowerCase();
|
||||
const cls = /: error |\bfailed\b|✗/.test(low) ? " err" : /\bwarning\b/.test(low) ? " warn" : /succeeded|✓/.test(low) ? " ok" : "";
|
||||
return <div key={i} className={"build-ln" + cls}>{ln}</div>;
|
||||
})}
|
||||
{building && lines.length === 0 && <div className="build-ln">{t("Starting MSBuild…")}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/* minimized build indicator — small floating chip, click to expand */
|
||||
function BuildChip({ building, ok, onExpand, onClose }: { building: boolean; ok: boolean | null; onExpand: () => void; onClose: () => void }) {
|
||||
return (
|
||||
<div className={"buildchip" + (building ? " run" : ok ? " ok" : ok === false ? " fail" : "")} onClick={onExpand} title={t("Expand")}>
|
||||
<span className="bc-ic">{building ? <span className="ldr sm" /> : ok ? "✓" : "✗"}</span>
|
||||
<span className="bc-txt">{building ? t("Building…") : ok ? t("Build succeeded") : t("Build failed")}</span>
|
||||
<button className="bc-x" onClick={(e) => { e.stopPropagation(); onClose(); }} title={t("Close")}>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- confirm modal ---------------- */
|
||||
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
|
||||
useEffect(() => {
|
||||
@ -1026,8 +1210,8 @@ function Thumb({ file }: { file: OpenedFile }) {
|
||||
|
||||
/* ---------------- virtualized file list ---------------- */
|
||||
const ROW = 52;
|
||||
function FileList({ files, sel, checked, onSelect, onToggle }:
|
||||
{ files: OpenedFile[]; sel: number | null; checked: Set<string>; onSelect: (i: number) => void; onToggle: (dp: string) => void }) {
|
||||
function FileList({ files, sel, selRows, checked, onRowClick, onToggle, onContext }:
|
||||
{ files: OpenedFile[]; sel: number | null; selRows: Set<number>; checked: Set<string>; onRowClick: (i: number, e: ReactMouseEvent) => void; onToggle: (i: number, dp: string) => void; onContext: (i: number, e: ReactMouseEvent) => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [scroll, setScroll] = useState(0);
|
||||
const [h, setH] = useState(500);
|
||||
@ -1049,8 +1233,8 @@ function FileList({ files, sel, checked, onSelect, onToggle }:
|
||||
const st = statusOf(f.action);
|
||||
const isCk = checked.has(dp);
|
||||
rows.push(
|
||||
<div key={dp + i} className={"file" + (i === sel ? " sel" : "")} style={{ top: i * ROW }} onClick={() => onSelect(i)}>
|
||||
<span className={"fchk " + (isCk ? "ck" : "off")} onClick={(e) => { e.stopPropagation(); onToggle(dp); }}>{I.check}</span>
|
||||
<div key={dp + i} className={"file" + (selRows.has(i) ? " sel" : "") + (i === sel ? " primary" : "")} style={{ top: i * ROW }} onClick={(e) => onRowClick(i, e)} onContextMenu={(e) => onContext(i, e)}>
|
||||
<span className={"fchk " + (isCk ? "ck" : "off")} onClick={(e) => { e.stopPropagation(); onToggle(i, dp); }}>{I.check}</span>
|
||||
<Thumb file={f} />
|
||||
<span className="fname"><span className="n">{name}</span><span className="p">{dir}</span></span>
|
||||
<span className={"stat " + st.cls}>{st.ch}</span>
|
||||
@ -1061,7 +1245,7 @@ function FileList({ files, sel, checked, onSelect, onToggle }:
|
||||
}
|
||||
|
||||
/* ---------------- preview panel ---------------- */
|
||||
function Preview({ file, onRevert }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void }) {
|
||||
function Preview({ file, onRevert, onFlash }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void }) {
|
||||
if (!file) return <div className="right"><div className="nofile">{I.cube}<span>{t("Select a file on the left")}</span></div></div>;
|
||||
const dp = file.depotFile || "";
|
||||
const { name, dir } = splitPath(dp);
|
||||
@ -1078,6 +1262,11 @@ function Preview({ file, onRevert }: { file?: OpenedFile; onRevert: (f: OpenedFi
|
||||
<span className="m">{file.action} · <span className="rev">{rev}</span> · {file.type}</span>
|
||||
</span>
|
||||
<span className="tools">
|
||||
{kind === "code" && (
|
||||
<span className="gbtn vsc" onClick={async () => { try { await p4.openInVscode(dp); onFlash(t("Opening in VS Code…")); } catch (e) { onFlash(String(e), true); } }}>
|
||||
{I.vscode}{t("Edit in VS Code")}
|
||||
</span>
|
||||
)}
|
||||
<span className="gbtn" onClick={() => onRevert(file)}>{I.revert}{t("Revert")}</span>
|
||||
</span>
|
||||
</div>
|
||||
@ -1085,7 +1274,7 @@ function Preview({ file, onRevert }: { file?: OpenedFile; onRevert: (f: OpenedFi
|
||||
{kind === "model" ? <ModelViewer depot={dp} name={name} />
|
||||
: kind === "image" ? <ImageViewer depot={dp} name={name} />
|
||||
: kind === "uasset" ? <UassetPreview file={file} />
|
||||
: <DiffView file={file} />}
|
||||
: <CodeView file={file} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1093,35 +1282,6 @@ function Mi({ k, v, acc }: { k: string; v: string; acc?: boolean }) {
|
||||
return <div className="mi"><span className="k">{k}</span><span className={"v" + (acc ? " acc" : "")}>{v}</span></div>;
|
||||
}
|
||||
|
||||
/* ---------------- text diff ---------------- */
|
||||
function DiffView({ file }: { file: OpenedFile }) {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
const dp = file.depotFile || "";
|
||||
useEffect(() => {
|
||||
let live = true; setText(null); setErr("");
|
||||
p4.diff(dp).then((txt) => live && setText(txt)).catch((e) => live && setErr(String(e)));
|
||||
return () => { live = false; };
|
||||
}, [dp]);
|
||||
|
||||
if (err) return <div className="nofile" style={{ color: "var(--del)" }}>{err}</div>;
|
||||
if (text === null) return <div className="nofile">{t("Loading diff…")}</div>;
|
||||
if (!text.trim()) return <div className="nofile">{I.check}<span>{t("No text differences (or binary file).")}</span></div>;
|
||||
|
||||
const lines = text.split("\n");
|
||||
return (
|
||||
<div className="diff">
|
||||
{lines.map((ln, i) => {
|
||||
let cls = "";
|
||||
if (ln.startsWith("@@")) cls = "hunk";
|
||||
else if (ln.startsWith("+") && !ln.startsWith("+++")) cls = "add";
|
||||
else if (ln.startsWith("-") && !ln.startsWith("---")) cls = "del";
|
||||
return <div key={i} className={"dl " + cls}><span className="g" /><span className="c">{ln || " "}</span></div>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- real image viewer ---------------- */
|
||||
function ImageViewer({ depot, name }: { depot: string; name: string }) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
120
src/CodeView.tsx
Normal file
120
src/CodeView.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import cpp from "highlight.js/lib/languages/cpp";
|
||||
import csharp from "highlight.js/lib/languages/csharp";
|
||||
import python from "highlight.js/lib/languages/python";
|
||||
import javascript from "highlight.js/lib/languages/javascript";
|
||||
import typescript from "highlight.js/lib/languages/typescript";
|
||||
import json from "highlight.js/lib/languages/json";
|
||||
import xml from "highlight.js/lib/languages/xml";
|
||||
import ini from "highlight.js/lib/languages/ini";
|
||||
import glsl from "highlight.js/lib/languages/glsl";
|
||||
import { p4, OpenedFile, splitPath } from "./p4";
|
||||
import { t } from "./i18n";
|
||||
|
||||
hljs.registerLanguage("cpp", cpp);
|
||||
hljs.registerLanguage("csharp", csharp);
|
||||
hljs.registerLanguage("python", python);
|
||||
hljs.registerLanguage("javascript", javascript);
|
||||
hljs.registerLanguage("typescript", typescript);
|
||||
hljs.registerLanguage("json", json);
|
||||
hljs.registerLanguage("xml", xml);
|
||||
hljs.registerLanguage("ini", ini);
|
||||
hljs.registerLanguage("glsl", glsl);
|
||||
|
||||
// file extension -> highlight.js language (empty = no highlighting, plain text)
|
||||
function langOf(name: string): string {
|
||||
const e = (name.split(".").pop() || "").toLowerCase();
|
||||
if (/^(cpp|cc|cxx|c\+\+|hpp|hxx|h|hh|inl|c|ino)$/.test(e)) return "cpp";
|
||||
if (e === "cs") return "csharp";
|
||||
if (e === "py") return "python";
|
||||
if (/^(js|jsx|mjs|cjs)$/.test(e)) return "javascript";
|
||||
if (/^(ts|tsx)$/.test(e)) return "typescript";
|
||||
if (/^(json|uproject|uplugin)$/.test(e)) return "json";
|
||||
if (/^(xml|html|htm|svg|xaml)$/.test(e)) return "xml";
|
||||
if (/^(ini|cfg|conf|config|toml|editorconfig)$/.test(e)) return "ini";
|
||||
if (/^(usf|ush|hlsl|glsl|shader|frag|vert)$/.test(e)) return "glsl";
|
||||
return "";
|
||||
}
|
||||
const LANG_LABEL: Record<string, string> = { cpp: "C++", csharp: "C#", python: "Python", javascript: "JavaScript", typescript: "TypeScript", json: "JSON", xml: "XML", ini: "INI", glsl: "GLSL" };
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/[&<>]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : ">"));
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const dp = file.depotFile || "";
|
||||
const { name } = splitPath(dp);
|
||||
const action = (file.action || "").toLowerCase();
|
||||
const [diff, setDiff] = useState<string | null>(null);
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
const [tooBig, setTooBig] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setDiff(null); setCode(null); setErr(""); setTooBig(false);
|
||||
// edits show a diff; adds have none, so fetch it but don't depend on it
|
||||
p4.diff(dp).then((d) => live && setDiff(d)).catch(() => live && setDiff(""));
|
||||
p4.readDepot(dp).then((buf) => {
|
||||
if (!live) return;
|
||||
if (buf.byteLength > MAX) { setTooBig(true); setCode(""); return; }
|
||||
const bytes = new Uint8Array(buf);
|
||||
// crude binary sniff: a NUL in the first chunk
|
||||
if (bytes.subarray(0, 8000).includes(0)) { setCode(null); setErr("binary"); return; }
|
||||
setCode(new TextDecoder("utf-8", { fatal: false }).decode(bytes));
|
||||
}).catch((e) => live && setErr(String(e)));
|
||||
return () => { live = false; };
|
||||
}, [dp]);
|
||||
|
||||
const lang = langOf(name);
|
||||
const highlighted = useMemo(() => {
|
||||
if (code == null) return "";
|
||||
try { return lang ? hljs.highlight(code, { language: lang, ignoreIllegals: true }).value : esc(code); }
|
||||
catch { return esc(code); }
|
||||
}, [code, lang]);
|
||||
|
||||
if (err === "binary") return <div className="nofile">{t("Binary file — no code preview.")}</div>;
|
||||
if (err) return <div className="nofile" style={{ color: "var(--del)" }}>{err}</div>;
|
||||
if (tooBig) return <div className="nofile">{t("File is too large to preview.")}</div>;
|
||||
|
||||
// an edited file with a real diff → show the diff
|
||||
if (diff && diff.trim() && action.includes("edit")) {
|
||||
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="diff">
|
||||
{lines.map((ln, i) => {
|
||||
let cls = "";
|
||||
if (ln.startsWith("@@")) cls = "hunk";
|
||||
else if (ln.startsWith("+") && !ln.startsWith("+++")) cls = "add";
|
||||
else if (ln.startsWith("-") && !ln.startsWith("---")) cls = "del";
|
||||
return <div key={i} className={"dl " + cls}><span className="g" /><span className="c">{ln || " "}</span></div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// otherwise (new file, or edit with no text diff) → full content with line numbers
|
||||
if (code == null) return <div className="nofile">{t("Loading…")}</div>;
|
||||
const total = code.length ? code.replace(/\n$/, "").split("\n").length : 0;
|
||||
return (
|
||||
<div className="right-body">
|
||||
<div className="codebar">
|
||||
<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>
|
||||
</div>
|
||||
<div className={"code" + (action.includes("add") ? " added" : "")}>
|
||||
<div className="code-gutter">{Array.from({ length: total }, (_, i) => <span key={i}>{i + 1}</span>)}</div>
|
||||
<pre className="code-body"><code className="hljs" dangerouslySetInnerHTML={{ __html: highlighted }} /></pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/i18n.ts
27
src/i18n.ts
@ -25,6 +25,7 @@ const D: Record<string, Tr> = {
|
||||
"Minimize": { ru: "Свернуть", de: "Minimieren", fr: "Réduire", es: "Minimizar" },
|
||||
"Maximize": { ru: "Развернуть", de: "Maximieren", fr: "Agrandir", es: "Maximizar" },
|
||||
"Close": { ru: "Закрыть", de: "Schließen", fr: "Fermer", es: "Cerrar" },
|
||||
"Expand": { ru: "Развернуть", de: "Erweitern", fr: "Agrandir", es: "Expandir" },
|
||||
"Theme": { ru: "Тема", de: "Thema", fr: "Thème", es: "Tema" },
|
||||
"Restoring session…": { ru: "Восстановление сессии…", de: "Sitzung wird wiederhergestellt…", fr: "Restauration de la session…", es: "Restaurando sesión…" },
|
||||
// startup splash
|
||||
@ -67,6 +68,18 @@ const D: Record<string, Tr> = {
|
||||
"Get Latest": { ru: "Get Latest", de: "Get Latest", fr: "Get Latest", es: "Get Latest" },
|
||||
"Working…": { ru: "Работаю…", de: "Arbeite…", fr: "En cours…", es: "Trabajando…" },
|
||||
"pull from server": { ru: "забрать с сервера (pull)", de: "vom Server holen (pull)", fr: "récupérer du serveur (pull)", es: "traer del servidor (pull)" },
|
||||
"Launch": { ru: "Запустить", de: "Starten", fr: "Lancer", es: "Iniciar" },
|
||||
"Launch Unreal Engine": { ru: "Запустить Unreal Engine", de: "Unreal Engine starten", fr: "Lancer Unreal Engine", es: "Iniciar Unreal Engine" },
|
||||
"Build Solution (.sln)": { ru: "Собрать решение (.sln)", de: "Projektmappe bauen (.sln)", fr: "Compiler la solution (.sln)", es: "Compilar solución (.sln)" },
|
||||
"Build Solution — no .sln": { ru: "Собрать решение — нет .sln", de: "Projektmappe bauen — keine .sln", fr: "Compiler — aucune .sln", es: "Compilar — sin .sln" },
|
||||
"No .sln found in the working folder. Choose the project folder first.": { ru: "В рабочей папке нет .sln. Сначала выбери папку проекта.", de: "Keine .sln im Arbeitsordner. Wähle zuerst den Projektordner.", fr: "Aucune .sln dans le dossier de travail. Choisissez d’abord le dossier du projet.", es: "No hay .sln en la carpeta de trabajo. Elige primero la carpeta del proyecto." },
|
||||
"Build": { ru: "Сборка", de: "Build", fr: "Compilation", es: "Compilación" },
|
||||
"Building…": { ru: "Сборка…", de: "Wird gebaut…", fr: "Compilation…", es: "Compilando…" },
|
||||
"Build succeeded": { ru: "Сборка успешна", de: "Build erfolgreich", fr: "Compilation réussie", es: "Compilación correcta" },
|
||||
"Build failed": { ru: "Сборка провалена", de: "Build fehlgeschlagen", fr: "Échec de la compilation", es: "Compilación fallida" },
|
||||
"Starting MSBuild…": { ru: "Запуск MSBuild…", de: "MSBuild wird gestartet…", fr: "Démarrage de MSBuild…", es: "Iniciando MSBuild…" },
|
||||
"Open this project in Unreal Engine": { ru: "Открыть проект в Unreal Engine", de: "Projekt in Unreal Engine öffnen", fr: "Ouvrir le projet dans Unreal Engine", es: "Abrir el proyecto en Unreal Engine" },
|
||||
"Launching Unreal…": { ru: "Запуск Unreal…", de: "Unreal wird gestartet…", fr: "Lancement d’Unreal…", es: "Iniciando Unreal…" },
|
||||
|
||||
// tabs / list
|
||||
"Changes": { ru: "Changes", de: "Änderungen", fr: "Modifications", es: "Cambios" },
|
||||
@ -122,6 +135,14 @@ const D: Record<string, Tr> = {
|
||||
"Revert file?": { ru: "Откатить файл?", de: "Datei zurücksetzen?", fr: "Annuler le fichier ?", es: "¿Revertir archivo?" },
|
||||
"{name}\n\nLocal edits to this file will be lost.": { ru: "{name}\n\nЛокальные правки этого файла будут потеряны.", de: "{name}\n\nLokale Änderungen an dieser Datei gehen verloren.", fr: "{name}\n\nLes modifications locales de ce fichier seront perdues.", es: "{name}\n\nLos cambios locales de este archivo se perderán." },
|
||||
"Revert": { ru: "Откатить", de: "Zurücksetzen", fr: "Annuler", es: "Revertir" },
|
||||
"Discard changes": { ru: "Отменить изменения", de: "Änderungen verwerfen", fr: "Abandonner les modifications", es: "Descartar cambios" },
|
||||
"Discard changes · {n} files": { ru: "Отменить изменения · {n} файлов", de: "Änderungen verwerfen · {n} Dateien", fr: "Abandonner · {n} fichiers", es: "Descartar · {n} archivos" },
|
||||
"Discard changes?": { ru: "Отменить изменения?", de: "Änderungen verwerfen?", fr: "Abandonner les modifications ?", es: "¿Descartar cambios?" },
|
||||
"Discard": { ru: "Отменить", de: "Verwerfen", fr: "Abandonner", es: "Descartar" },
|
||||
"Local changes to {name} will be lost permanently — the file returns to the server version.": { ru: "Локальные изменения в {name} будут потеряны безвозвратно — файл вернётся к версии с сервера.", de: "Lokale Änderungen an {name} gehen endgültig verloren — die Datei kehrt zur Serverversion zurück.", fr: "Les modifications locales de {name} seront perdues définitivement — le fichier revient à la version du serveur.", es: "Los cambios locales en {name} se perderán para siempre — el archivo vuelve a la versión del servidor." },
|
||||
"Discarded: {name}": { ru: "Отменено: {name}", de: "Verworfen: {name}", fr: "Abandonné : {name}", es: "Descartado: {name}" },
|
||||
"Edit in VS Code": { ru: "Открыть в VS Code", de: "In VS Code öffnen", fr: "Ouvrir dans VS Code", es: "Abrir en VS Code" },
|
||||
"Opening in VS Code…": { ru: "Открываю в VS Code…", de: "Wird in VS Code geöffnet…", fr: "Ouverture dans VS Code…", es: "Abriendo en VS Code…" },
|
||||
"Revert changelist #{n}?": { ru: "Откатить changelist #{n}?", de: "Changelist #{n} zurücksetzen?", fr: "Annuler le changelist #{n} ?", es: "¿Revertir changelist #{n}?" },
|
||||
"Perforce will create a NEW changelist that undoes #{n} and submit it to the server. History is kept (like “Revert” in GitHub Desktop).\n\n“{desc}”": { ru: "Perforce создаст НОВЫЙ changelist, отменяющий изменения из #{n}, и засабмитит его на сервер. История сохранится (как «Revert» в GitHub Desktop).\n\n«{desc}»", de: "Perforce erstellt eine NEUE Changelist, die #{n} rückgängig macht, und sendet sie an den Server. Der Verlauf bleibt erhalten (wie „Revert“ in GitHub Desktop).\n\n„{desc}“", fr: "Perforce créera un NOUVEAU changelist qui annule #{n} et l’enverra au serveur. L’historique est conservé (comme « Revert » dans GitHub Desktop).\n\n« {desc} »", es: "Perforce creará un NUEVO changelist que deshace #{n} y lo enviará al servidor. El historial se conserva (como «Revert» en GitHub Desktop).\n\n«{desc}»" },
|
||||
"Revert #{n}": { ru: "Откатить #{n}", de: "#{n} zurücksetzen", fr: "Annuler #{n}", es: "Revertir #{n}" },
|
||||
@ -198,6 +219,12 @@ const D: Record<string, Tr> = {
|
||||
"Select a file on the left": { ru: "Выберите файл слева", de: "Wähle links eine Datei", fr: "Sélectionnez un fichier à gauche", es: "Selecciona un archivo a la izquierda" },
|
||||
"Loading diff…": { ru: "Загрузка diff…", de: "Diff wird geladen…", fr: "Chargement du diff…", es: "Cargando diff…" },
|
||||
"No text differences (or binary file).": { ru: "Нет текстовых отличий (или бинарный файл).", de: "Keine Textunterschiede (oder Binärdatei).", fr: "Aucune différence de texte (ou fichier binaire).", es: "Sin diferencias de texto (o archivo binario)." },
|
||||
"Binary file — no code preview.": { ru: "Бинарный файл — превью кода нет.", de: "Binärdatei — keine Code-Vorschau.", fr: "Fichier binaire — pas d’aperçu du code.", es: "Archivo binario — sin vista previa de código." },
|
||||
"File is too large to preview.": { ru: "Файл слишком большой для превью.", de: "Datei zu groß für die Vorschau.", fr: "Fichier trop volumineux pour l’aperçu.", es: "Archivo demasiado grande para previsualizar." },
|
||||
"Diff": { ru: "Diff", de: "Diff", fr: "Diff", es: "Diff" },
|
||||
"New file": { ru: "Новый файл", de: "Neue Datei", fr: "Nouveau fichier", es: "Archivo nuevo" },
|
||||
"Text": { ru: "Текст", de: "Text", fr: "Texte", es: "Texto" },
|
||||
"{n} lines": { ru: "{n} строк", de: "{n} Zeilen", fr: "{n} lignes", es: "{n} líneas" },
|
||||
"Image": { ru: "Изображение", de: "Bild", fr: "Image", es: "Imagen" },
|
||||
"Resolution": { ru: "Разрешение", de: "Auflösung", fr: "Résolution", es: "Resolución" },
|
||||
"Format": { ru: "Формат", de: "Format", fr: "Format", es: "Formato" },
|
||||
|
||||
@ -72,6 +72,11 @@ export const p4 = {
|
||||
describe: (change: string) => invoke<Describe>("p4_describe", { change }),
|
||||
undoChange: (change: string) => invoke<string>("p4_undo_change", { change }),
|
||||
openInExplorer: (target: string) => invoke<void>("open_in_explorer", { target }),
|
||||
findUproject: (scope: string) => invoke<string>("find_uproject", { scope }),
|
||||
findSln: (scope: string) => invoke<string>("find_sln", { scope }),
|
||||
buildSln: (path: string) => invoke<string>("build_sln", { path }),
|
||||
launchFile: (path: string) => invoke<void>("launch_file", { path }),
|
||||
openInVscode: (depot: string) => invoke<void>("open_in_vscode", { depot }),
|
||||
readDepot: async (depot: string): Promise<ArrayBuffer> => {
|
||||
const r: unknown = await invoke("read_depot", { depot });
|
||||
if (r instanceof ArrayBuffer) return r;
|
||||
|
||||
Reference in New Issue
Block a user