From 3954082e8d5cc817c0c955c9f80a686c9cdd3c1a Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Wed, 8 Jul 2026 20:40:53 +0300 Subject: [PATCH] =?UTF-8?q?v0.3.2=20=E2=80=94=20commit/submit=20progress,?= =?UTF-8?q?=20cancel,=20thumbnails,=20waitlist=20landing+admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client: - Commit: batch `p4 reopen` so huge changelists (63k+) no longer blow the Windows command-line limit; live "N / total" progress + spinner during commit. - Submit dialog: white arrow, "N of total" + % bar, running file log, Cancel button (aborts pre-commit; changelist stays pending). - Disk scan: streaming reconcile with a live "found N files" counter + Cancel. - Thumbnails: only decode browser-renderable image formats; glyph fallback (+ onError) instead of a broken-image icon for bmp/tga/dds/etc. Landing + waitlist: - waitlist-server: Express + SQLite email collector (dedup) with a secure admin panel (scrypt auth, rate-limited login, signed cookies) and CSV export. - landing form now POSTs signups to the waitlist API. Co-Authored-By: Claude Opus 4.8 --- landing/index.html | 876 ++++++++++++++++ package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 577 ++++++++-- src-tauri/tauri.conf.json | 2 +- src/App.css | 41 +- src/App.tsx | 312 ++++-- src/i18n.ts | 34 + src/p4.ts | 25 +- waitlist-server/.env.example | 18 + waitlist-server/.gitignore | 4 + waitlist-server/README.md | 51 + waitlist-server/lib/auth.js | 129 +++ waitlist-server/lib/db.js | 56 + waitlist-server/package-lock.json | 1271 +++++++++++++++++++++++ waitlist-server/package.json | 22 + waitlist-server/scripts/set-password.js | 37 + waitlist-server/server.js | 209 ++++ waitlist-server/views/admin.html | 155 +++ waitlist-server/views/login.html | 72 ++ 21 files changed, 3753 insertions(+), 144 deletions(-) create mode 100644 landing/index.html create mode 100644 waitlist-server/.env.example create mode 100644 waitlist-server/.gitignore create mode 100644 waitlist-server/README.md create mode 100644 waitlist-server/lib/auth.js create mode 100644 waitlist-server/lib/db.js create mode 100644 waitlist-server/package-lock.json create mode 100644 waitlist-server/package.json create mode 100644 waitlist-server/scripts/set-password.js create mode 100644 waitlist-server/server.js create mode 100644 waitlist-server/views/admin.html create mode 100644 waitlist-server/views/login.html diff --git a/landing/index.html b/landing/index.html new file mode 100644 index 0000000..d0f770a --- /dev/null +++ b/landing/index.html @@ -0,0 +1,876 @@ + + + + + +Exbyte Depot — Perforce for Unreal teams, finally pleasant + + + + + + + + + + +
+
+ A desktop client for Perforce® · by Exbyte Studios +

The Perforce client your Unreal team actually wants.

+

Exbyte Depot is a fast, native desktop client for Perforce Helix Core — real 3D asset previews, one-click exclusive locks, offline work, and live teammate activity. All the power of your Perforce server, none of the P4V pain.

+ +
+ + It's a client, not a replacement. Exbyte Depot connects to your existing Perforce server through Perforce's own p4 command-line — your server, history and workflow stay exactly as they are. We don't clone Perforce or reverse-engineer anything; we just give you a nicer window into it. +
+ +
+
+ + +
+
+ + You're on the list. We'll email you the moment early access opens. +
+
Windows · early access · no spam, one launch email · on the list
+
+ + +
+
+ +
+ + Exbyte Depot — Perforce + FileConnectionActionsWindowToolsHelp + helix.auroragames.dev:1666 + + + + + +
+ + +
+
+ + Workspacekestrel_WS_02 + +
+
+ + Working folder//depot/Aurora_Road + +
+
+ + Get Latestpull from server +
+
+ + +
+
+
Changes 3History
+ + +
+
Filter 3 files…
+
+ + 3 of 3 selected + Refresh +
+
+
+ + + SM_Guardrail.uasset//depot/…/Content/Meshes/ + + +
+
+ + + T_Asphalt_D.uasset//depot/…/Content/Textures/ + + +
+
+ + + RoadSampler.cpp//depot/…/Source/Aurora/ + + +
+
+
+
+
Add guardrail mesh + asphalt texture for road kit
+
+
+
Commit changelist · 3 files
+
+
+ + + +
+ +
+
+ + + SM_Guardrail.uasset — //depot/Aurora_Road/Content/Meshes/add · #1 · binary + Revert +
+
+ 3D preview + +
+ + Load 3D model +
+ + + + +
+
+
Type
binary +l
+
Preview
3D mesh
+
Revision
#1
+
Change
default
+
+
+
+ + +
+ p4 opened -c default //depot/Aurora_Road/… {3 files} + + Unreal + Terminal + Log + +
+
+
+
+
+ + +
+

Purpose-built for game studios on Perforce

+
+ Unreal Engine 4 & 5 + Powered by Perforce Helix Core + Radmin VPN friendly + 5 languages +
+
+ + +
+
+
+ Everything, in one native app +

Built for the way Unreal teams actually work

+

Perforce is the industry standard for a reason. Exbyte Depot keeps every bit of its power and finally makes it feel good to use.

+
+
+ +
+ Signature +
+

Real 3D asset preview

+

Rotate the actual mesh inside a .uasset right in the app — plus embedded thumbnails, image, audio and video previews. See what you're committing before you commit it.

+
+ +
+
+

Exclusive locks, one click

+

Binary .uasset files can't be merged. Take an exclusive lock so only you edit a mesh or material at a time — and see everyone else's locks live. No more overwritten work.

+
+ +
+
+

Work offline, reconcile later

+

On a plane or the VPN's down? Keep editing. Exbyte Depot scans your workspace and reconciles adds, edits and deletes into a changelist when you're back — just like P4V, but nicer.

+
+ +
+
+

Live teammate activity

+

Lives in your system tray and sends a native notification the moment a teammate submits — with their message. A banner tells you when you're behind, one click to Get Latest.

+
+ +
+ AI +
+

AI commit summaries

+

Draft a clear changelist message from your selected files, and get a plain-English summary of what any source file does — in your interface language.

+
+ +
+
+

Files & Sizes explorer

+

Browse the Depot and your Workspace as a tree and see exactly how much every file and folder weighs — heaviest first. Find what's bloating your repo in seconds.

+
+ +
+
+

Built-in code editor

+

Fix a header or a config right before commit — no external IDE needed. Full syntax highlighting, line numbers, and a proper editor, not a grey textbox.

+
+ +
+
+

The full Perforce toolbox

+

Labels, streams, integrate / merge / copy, jobs, file history with blame & diff, clean, .p4ignore, get-revision, shelve/unshelve — the depth is all there when you need it.

+
+ +
+
+

Signed auto-updates

+

Ships as a signed installer with a built-in updater. New versions land in place — the whole team stays current without chasing downloads.

+
+ +
+
+
+ + +
+
+
+ Why switch +

P4V is powerful. It's also from another era.

+

We're not replacing your Perforce server — we're replacing the client you look at it through. Exbyte Depot keeps every bit of Perforce power your studio depends on and rebuilds the experience around how artists and engineers really work: visual, fast, and forgiving.

+
+
See your assets, not filenamesLive 3D meshes, thumbnails, audio & video — right where you review a changelist.
+
Commit locally, submit when readyA GitHub-Desktop-style flow on top of Perforce — revertable local commits, then push.
+
Never lose work to a binary conflictExclusive locks on .uasset are one click, and everyone's locks are visible.
+
Know what the team is doingTray notifications on every submit and a clear "you're behind" nudge.
+
+
+
+
File Locks
+
SM_Hero_Character.uasset
//depot/Char/Content/Meshes/
🔒 mira_art
+
M_Road_Master.uasset
//depot/Road/Content/Materials/
🔒 you
+
BP_GameMode.uasset
//depot/Core/Content/Blueprints/
available
+
+ + voss_dev just submitted #34 — you're 3 files behind. +
+
+
+
+ + +
+
+
+ Under the hood +

Native, fast, and quietly powerful

+

No Electron bloat. A tiny signed installer, instant startup, and a UI that stays out of your way.

+
+
+
Rust
Native Tauri backend
+
~4 MB
Installer, not 200
+
5
Languages built in
+
0
Servers to configure
+
+
+
+ + +
+
+
+

Get early access to Exbyte Depot

+

We're onboarding studios in waves. Drop your email and we'll send your download the moment your spot opens.

+
+
+ + +
+
+ + You're on the list. Talk soon. +
+
+
+
+
+ + +
+
+ + + Exbyte Depot + + + Features + Waitlist + Perforce.com + + + © Exbyte Studios. Exbyte Depot is an independent desktop client for Perforce Helix Core and requires a Perforce server. It is not affiliated with, endorsed by, or sponsored by Perforce Software, Inc. “Perforce”, “Helix Core” and “P4V” are trademarks of Perforce Software, Inc., used here only to describe compatibility. + +
+
+ + + + diff --git a/package.json b/package.json index 67d03d3..31e28bc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "exbyte-depot", "private": true, - "version": "0.3.1", + "version": "0.3.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 28bc348..e5b641b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -966,7 +966,7 @@ dependencies = [ [[package]] name = "exbyte-depot" -version = "0.3.0" +version = "0.3.2" dependencies = [ "encoding_rs", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c35e6b0..c1880e0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exbyte-depot" -version = "0.3.1" +version = "0.3.2" description = "Exbyte Depot — native Perforce client by Exbyte Studios" authors = ["Exbyte Studios"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 84515ff..e748de0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ use serde_json::Value; use std::io::{BufRead, BufReader, Write}; use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter, Manager, State}; @@ -12,6 +13,17 @@ use tauri::{AppHandle, Emitter, Manager, State}; /// command log to the frontend (a Perforce-style "Log" panel). static APP: OnceLock = OnceLock::new(); +/// Set true to ask an in-progress streaming reconcile (disk scan) to stop early. +/// The scan loop checks it per line and kills the p4 child when it flips. Reset +/// to false at the start of each scan. +static SCAN_CANCEL: AtomicBool = AtomicBool::new(false); + +/// Set true to ask an in-progress streaming transfer (submit/sync) to stop. The +/// run_stream loop checks it per line and kills the p4 child. For submit this is +/// safe only during the file-transfer phase: p4 submit is transactional, so an +/// aborted transfer leaves the changelist pending (nothing half-committed). +static TRANSFER_CANCEL: AtomicBool = AtomicBool::new(false); + /// Emit one p4-log entry to the UI: the command line, how many items/lines it /// returned, whether it succeeded, and any error text. fn log_p4(args: &[&str], count: i64, ok: bool, err: Option<&str>) { @@ -312,16 +324,44 @@ async fn p4_opened(state: State<'_, AppState>, scope: String) -> Result, path: String) -> Result, String> { let conn = current(&state)?; - let spec = if path.is_empty() { - "//*".to_string() - } else { - format!("{}/*", path.trim_end_matches(['/', '\\'])) - }; - run_json(&conn, &["dirs", &spec]) + let base = path.trim_end_matches(['/', '\\']).to_string(); + let spec = if base.is_empty() { "//*".to_string() } else { format!("{}/*", base) }; + let mut dirs = run_json(&conn, &["dirs", &spec]).unwrap_or_default(); + + // union in local-only subfolders (present on disk, not in the depot) + let local_parent = if base.is_empty() { conn.root.clone() } else { scope_local_dir(&conn, &base).unwrap_or_default() }; + if !local_parent.is_empty() { + let seen: std::collections::HashSet = dirs.iter() + .filter_map(|d| d.get("dir").and_then(|s| s.as_str()).and_then(|s| s.rsplit('/').next()).map(|s| s.to_lowercase())) + .collect(); + if let Ok(rd) = std::fs::read_dir(&local_parent) { + for e in rd.flatten() { + if !e.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; } + let name = e.file_name().to_string_lossy().to_string(); + if name.starts_with('.') || seen.contains(&name.to_lowercase()) { continue; } // skip hidden + already-in-depot + // depot path this local folder maps to + let depot = if base.is_empty() { + let probe = e.path().join("...").to_string_lossy().to_string(); + run_json(&conn, &["where", &probe]).ok() + .and_then(|v| v.into_iter().next()) + .and_then(|o| o.get("depotFile").and_then(|s| s.as_str()).map(|s| s.trim_end_matches("/...").trim_end_matches("...").trim_end_matches('/').to_string())) + .unwrap_or_default() + } else { + format!("{}/{}", base, name) + }; + if !depot.is_empty() && depot.starts_with("//") { + dirs.push(serde_json::json!({ "dir": depot, "local": true })); + } + } + } + } + Ok(dirs) } /// Browse a depot folder AND weigh it: returns the immediate children of `path` @@ -525,8 +565,11 @@ fn progress_file(line: &str) -> String { /// Run a p4 command streaming its stdout line-by-line, emitting a `p4-transfer` /// event as files move so the UI can show a live P4V-style progress dialog. -/// `op` labels the operation ("sync" | "submit"). Returns the full stdout. -fn run_stream(conn: &Conn, args: &[&str], op: &str) -> Result { +/// `op` labels the operation ("sync" | "submit"). `total` is the expected file +/// count (0 = unknown) so the UI can show "N of total" + a percentage bar. +/// Cancellable via TRANSFER_CANCEL (kills the p4 child mid-transfer). +fn run_stream(conn: &Conn, args: &[&str], op: &str, total: i64) -> Result { + TRANSFER_CANCEL.store(false, Ordering::SeqCst); let mut child = match p4_cmd(conn, false) .args(args) .stdout(Stdio::piped()) @@ -544,15 +587,21 @@ fn run_stream(conn: &Conn, args: &[&str], op: &str) -> Result { let mut collected = String::new(); let mut count: i64 = 0; let mut last = Instant::now(); + let mut cancelled = false; if let Some(out) = child.stdout.take() { for line in BufReader::new(out).lines().map_while(Result::ok) { + if TRANSFER_CANCEL.load(Ordering::SeqCst) { + cancelled = true; + let _ = child.kill(); + break; + } count += 1; // throttle to ~25 fps regardless of file count, but always send the first few if count <= 5 || last.elapsed().as_millis() >= 40 { if let Some(app) = APP.get() { let _ = app.emit( "p4-transfer", - serde_json::json!({ "op": op, "count": count, "file": progress_file(&line), "done": false }), + serde_json::json!({ "op": op, "count": count, "total": total, "file": progress_file(&line), "done": false }), ); } last = Instant::now(); @@ -576,9 +625,13 @@ fn run_stream(conn: &Conn, args: &[&str], op: &str) -> Result { if let Some(app) = APP.get() { let _ = app.emit( "p4-transfer", - serde_json::json!({ "op": op, "count": count, "done": true }), + serde_json::json!({ "op": op, "count": count, "total": total, "done": true, "cancelled": cancelled }), ); } + if cancelled { + log_p4(args, count, false, Some("cancelled by user")); + return Err("Cancelled.".into()); + } if !status.success() && !stderr.is_empty() { log_p4(args, 0, false, Some(&stderr)); return Err(stderr); @@ -587,11 +640,21 @@ fn run_stream(conn: &Conn, args: &[&str], op: &str) -> Result { Ok(collected.trim().to_string()) } +/// Count the files opened in a numbered changelist (the submit "total"). +fn count_opened_in(conn: &Conn, change: &str) -> i64 { + if change.trim().is_empty() || !change.trim().chars().all(|c| c.is_ascii_digit()) { + return 0; + } + run_json(conn, &["opened", "-c", change.trim()]) + .map(|v| v.len() as i64) + .unwrap_or(0) +} + /// Get Latest — sync to head, optionally scoped to a depot folder. #[tauri::command] async fn p4_sync(state: State<'_, AppState>, scope: String) -> Result { let conn = current(&state)?; - let msg = run_stream(&conn, &["sync", &scope_spec(&scope)], "sync")?; + let msg = run_stream(&conn, &["sync", &scope_spec(&scope)], "sync", 0)?; Ok(if msg.is_empty() { "Already up to date — nothing to sync.".into() } else { msg }) } @@ -631,7 +694,7 @@ async fn p4_sync_to(state: State<'_, AppState>, scope: String, rev: String) -> R // strip a leading @ or # if the user typed one let rev = rev.trim_start_matches(['@', '#']); let spec = format!("{}@{}", scope_spec(&scope), rev); - let msg = run_stream(&conn, &["sync", &spec], "sync")?; + let msg = run_stream(&conn, &["sync", &spec], "sync", 0)?; Ok(if msg.is_empty() { "Already at that revision — nothing to sync.".into() } else { msg }) } @@ -695,11 +758,27 @@ async fn p4_commit( return Err("No files to commit".into()); } let cl = create_changelist(&conn, &description)?; - let mut reopen: Vec<&str> = vec!["reopen", "-c", &cl]; - for f in &files { - reopen.push(f.as_str()); + // reopen the files into the new changelist in batches — 63k paths in one + // command line would exceed the Windows limit and fail to spawn. Emit a + // `p4-commit` progress event per batch so the UI can show N/total live. + let total = files.len() as i64; + let prefix_len = "reopen -c ".len() + cl.len(); + let mut done: i64 = 0; + for r in file_batches(&files, prefix_len) { + let batch_len = (r.end - r.start) as i64; + let mut reopen: Vec<&str> = vec!["reopen", "-c", &cl]; + for f in &files[r] { + reopen.push(f.as_str()); + } + run_text(&conn, &reopen)?; + done += batch_len; + if let Some(app) = APP.get() { + let _ = app.emit( + "p4-commit", + serde_json::json!({ "count": done, "total": total, "done": done >= total }), + ); + } } - run_text(&conn, &reopen)?; Ok(cl) } @@ -707,7 +786,8 @@ async fn p4_commit( #[tauri::command] async fn p4_submit_change(state: State<'_, AppState>, change: String) -> Result { let conn = current(&state)?; - run_stream(&conn, &["submit", "-c", &change], "submit") + let total = count_opened_in(&conn, &change); + run_stream(&conn, &["submit", "-c", &change], "submit", total) } /// Submit a shelved changelist directly from the server (`submit -e`), without @@ -715,7 +795,7 @@ async fn p4_submit_change(state: State<'_, AppState>, change: String) -> Result< #[tauri::command] async fn p4_submit_shelved(state: State<'_, AppState>, change: String) -> Result { let conn = current(&state)?; - run_stream(&conn, &["submit", "-e", change.trim()], "submit") + run_stream(&conn, &["submit", "-e", change.trim()], "submit", 0) } /// Submit a numbered changelist with options: `reopen` keeps the files checked @@ -725,6 +805,7 @@ async fn p4_submit_shelved(state: State<'_, AppState>, change: String) -> Result async fn p4_submit_opts(state: State<'_, AppState>, change: String, reopen: bool, revert_unchanged: bool) -> Result { let conn = current(&state)?; let ch = change.trim().to_string(); + let total = count_opened_in(&conn, &ch); let mut args: Vec = vec!["submit".into(), "-c".into(), ch]; if reopen { args.push("-r".into()); @@ -734,7 +815,7 @@ async fn p4_submit_opts(state: State<'_, AppState>, change: String, reopen: bool args.push("revertunchanged".into()); } let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); - run_stream(&conn, &refs, "submit") + run_stream(&conn, &refs, "submit", total) } /// Path of the workspace `.p4ignore` file (at the client root). @@ -835,28 +916,64 @@ async fn p4_submit( if files.is_empty() { return Err("No files selected to submit".into()); } - let total = run_json(&conn, &["opened"])?.len(); + let n = files.len() as i64; + let opened_total = run_json(&conn, &["opened"])?.len(); // all opened files selected → submit the whole default changelist in one go - if files.len() >= total { - return run_stream(&conn, &["submit", "-d", &description], "submit"); + if files.len() >= opened_total { + return run_stream(&conn, &["submit", "-d", &description], "submit", n); } - // partial submit → dedicated numbered changelist + // partial submit → dedicated numbered changelist (batched reopen for large sets) let cl = create_changelist(&conn, &description)?; - let mut reopen: Vec<&str> = vec!["reopen", "-c", &cl]; - for f in &files { - reopen.push(f.as_str()); + let prefix_len = "reopen -c ".len() + cl.len(); + for r in file_batches(&files, prefix_len) { + let mut reopen: Vec<&str> = vec!["reopen", "-c", &cl]; + for f in &files[r] { + reopen.push(f.as_str()); + } + run_text(&conn, &reopen)?; } - run_text(&conn, &reopen)?; - run_stream(&conn, &["submit", "-c", &cl], "submit") + run_stream(&conn, &["submit", "-c", &cl], "submit", n) } /// Run a p4 command over a list of file paths, returning the opened records. -fn files_cmd(conn: &Conn, cmd: &str, files: &[String]) -> Result, String> { - let mut args: Vec<&str> = vec![cmd]; - for f in files { - args.push(f.as_str()); +/// Split a file list into batches whose joined command line stays safely under +/// the Windows ~32 KiB command-line limit. Passing tens of thousands of depot +/// paths in one `p4` invocation (e.g. committing a 63k-file initial import) blows +/// past that limit and the process can't even spawn — so we chunk it. `prefix_len` +/// is the length of the fixed args before the file list (e.g. "reopen -c 42"). +fn file_batches(files: &[String], prefix_len: usize) -> Vec> { + const BUDGET: usize = 8000; // conservative; real limit is ~32767 chars + let mut ranges = Vec::new(); + let (mut start, mut i, mut len) = (0usize, 0usize, prefix_len); + while i < files.len() { + let flen = files[i].len() + 3; // path + a little slack for spacing/quoting + if i > start && len + flen > BUDGET { + ranges.push(start..i); + start = i; + len = prefix_len; + } + len += flen; + i += 1; } - run_json(conn, &args) + if start < files.len() { + ranges.push(start..files.len()); + } + ranges +} + +fn files_cmd(conn: &Conn, cmd: &str, files: &[String]) -> Result, String> { + if files.is_empty() { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + for r in file_batches(files, cmd.len() + 1) { + let mut args: Vec<&str> = vec![cmd]; + for f in &files[r] { + args.push(f.as_str()); + } + out.extend(run_json(conn, &args)?); + } + Ok(out) } /// Mark files for add. @@ -903,6 +1020,110 @@ async fn p4_scan(state: State<'_, AppState>, scope: String) -> Result run_json(&conn, &["reconcile", "-e", "-a", "-d", &scope_spec(&scope)]) } +/// Run `reconcile` streaming its plain-text output line-by-line, emitting a +/// `p4-scan` progress event per file it opens so the UI shows a live "found N +/// files" counter instead of an opaque spinner. Cancellable: the loop watches +/// SCAN_CANCEL and kills the p4 child when the user hits Cancel. Returns the +/// number of files opened (the caller re-reads `p4 opened` for the actual list). +fn run_reconcile_stream(conn: &Conn, spec: &str) -> Result { + SCAN_CANCEL.store(false, Ordering::SeqCst); + let args = ["reconcile", "-e", "-a", "-d", spec]; + let mut child = match p4_cmd(conn, false) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => { + let m = format!("Failed to start p4: {e}"); + log_p4(&args, 0, false, Some(&m)); + return Err(m); + } + }; + + let mut count: i64 = 0; + let mut last = Instant::now(); + let mut cancelled = false; + if let Some(out) = child.stdout.take() { + for line in BufReader::new(out).lines().map_while(Result::ok) { + if SCAN_CANCEL.load(Ordering::SeqCst) { + cancelled = true; + let _ = child.kill(); + break; + } + // reconcile prints one `//depot/… - opened for add|edit|delete` per file + if !line.starts_with("//") { + continue; + } + count += 1; + // throttle to ~15 fps but always send the first few so the counter appears instantly + if count <= 5 || last.elapsed().as_millis() >= 66 { + if let Some(app) = APP.get() { + let _ = app.emit( + "p4-scan", + serde_json::json!({ "count": count, "file": progress_file(&line), "done": false }), + ); + } + last = Instant::now(); + } + } + } + let _ = child.wait(); + let stderr = child + .stderr + .take() + .map(|mut s| { + let mut buf = String::new(); + let _ = std::io::Read::read_to_string(&mut s, &mut buf); + buf.trim().to_string() + }) + .unwrap_or_default(); + + // final event so the UI can clear the counter / show the total + if let Some(app) = APP.get() { + let _ = app.emit( + "p4-scan", + serde_json::json!({ "count": count, "done": true, "cancelled": cancelled }), + ); + } + // reconcile writes benign notes ("no file(s) to reconcile") to stderr — only a + // real connection/auth failure should surface as an error to the user + let hard_err = !cancelled + && count == 0 + && (stderr.contains("Connect to server failed") + || stderr.contains("Perforce password") + || stderr.contains("invalid or unset")); + if hard_err { + log_p4(&args, 0, false, Some(&stderr)); + return Err(stderr); + } + log_p4(&args, count, true, None); + Ok(count) +} + +/// Streaming disk scan (reconcile) with a live progress counter + cancel support. +/// Replaces the buffered `p4_scan` for the UI's "Rescan / Scanning…" flow. +#[tauri::command] +async fn p4_scan_stream(state: State<'_, AppState>, scope: String) -> Result { + let conn = current(&state)?; + run_reconcile_stream(&conn, &scope_spec(&scope)) +} + +/// Ask the in-progress streaming scan to stop as soon as it can. +#[tauri::command] +fn p4_scan_cancel() { + SCAN_CANCEL.store(true, Ordering::SeqCst); +} + +/// Ask the in-progress submit/sync transfer to stop. Safe for submit only during +/// the transfer phase (p4 submit is transactional — an aborted transfer leaves +/// the changelist pending, nothing half-committed). +#[tauri::command] +fn p4_transfer_cancel() { + TRANSFER_CANCEL.store(true, Ordering::SeqCst); +} + /// Undo (roll back) an already-submitted changelist. Perforce (2019.1+) does this /// with `p4 undo`: it opens the reverting revisions, which we drop into a fresh /// numbered changelist and submit — so the rollback is itself a new change, exactly @@ -1209,12 +1430,58 @@ async fn find_sln(state: State<'_, AppState>, scope: String) -> Result Option { + let mut c = Command::new("reg"); + { + use std::os::windows::process::CommandExt; + c.creation_flags(0x0800_0000); + } + let out = c.args(["query", &format!("{root}\\{key}"), "/v", name]).output().ok()?; + let s = decode(&out.stdout); + for line in s.lines() { + if let Some(i) = line.find("REG_SZ") { + let v = line[i + 6..].trim(); + if !v.is_empty() { + return Some(v.to_string()); + } + } + } + None +} + +/// Resolve the Unreal Engine install directory for a `.uproject` by reading its +/// `EngineAssociation` and looking it up in the registry — a version like "5.7" +/// (installed engine) or a `{GUID}` (source build). +#[cfg(windows)] +fn resolve_engine_dir(uproject: &str) -> Option { + let txt = std::fs::read_to_string(uproject).ok()?; + let after = txt.split("\"EngineAssociation\"").nth(1)?; + let assoc = after.split('"').nth(1)?.trim().to_string(); + if assoc.is_empty() { + return None; + } + let dir = if assoc.starts_with('{') { + // source / custom build → the Builds registry maps the GUID to a path + reg_value("HKCU", "SOFTWARE\\Epic Games\\Unreal Engine\\Builds", &assoc) + .or_else(|| reg_value("HKLM", "SOFTWARE\\Epic Games\\Unreal Engine\\Builds", &assoc))? + } else { + // launcher / installed engine → InstalledDirectory + reg_value("HKLM", &format!("SOFTWARE\\EpicGames\\Unreal Engine\\{assoc}"), "InstalledDirectory") + .or_else(|| reg_value("HKLM", &format!("SOFTWARE\\Wow6432Node\\EpicGames\\Unreal Engine\\{assoc}"), "InstalledDirectory"))? + }; + Some(dir.replace('/', "\\").trim_end_matches('\\').to_string()) +} + +/// Build a project's C++ code. For an Unreal project (a `.uproject` in the +/// scope) this drives **UnrealBuildTool** on just the game module — fast and +/// robust, and it doesn't choke on plugin build-intermediates the way a full +/// `MSBuild ` does. For a plain C++ solution it falls back to MSBuild. +/// Output streams line-by-line as `build-log` events. #[tauri::command] -async fn build_sln(path: String, config: String) -> Result { - if !std::path::Path::new(&path).exists() { +async fn build_sln(path: String, config: String, uproject: String) -> Result { + if !std::path::Path::new(&path).exists() && uproject.is_empty() { return Err(format!("Solution not found: {path}")); } let emit = |line: String, done: bool, ok: bool| { @@ -1226,53 +1493,82 @@ async fn build_sln(path: String, config: String) -> Result { } }; - // 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); + // Prefer UnrealBuildTool for a real UE project — builds only the game module. + let ue = if !uproject.is_empty() && std::path::Path::new(&uproject).exists() { + #[cfg(windows)] + { resolve_engine_dir(&uproject) } + #[cfg(not(windows))] + { None } + } else { + None + }; + + let mut cmd = if let Some(engine) = ue { + let ubt = format!("{engine}\\Engine\\Binaries\\DotNET\\UnrealBuildTool\\UnrealBuildTool.exe"); + if !std::path::Path::new(&ubt).exists() { + let m = format!("UnrealBuildTool not found: {ubt}"); + emit(m.clone(), true, false); + return Err(m); + } + let stem = std::path::Path::new(&uproject).file_stem().and_then(|s| s.to_str()).unwrap_or("Game").to_string(); + let editor = config.to_lowercase().contains("editor"); + // UBT config is the base word (Development / DebugGame / Shipping); the + // target name carries the Editor suffix. + let ubt_conf = { + let c = config.replace(" Editor", "").replace(" editor", ""); + let c = c.trim(); + if c.is_empty() { "Development".to_string() } else { c.to_string() } + }; + let target = if editor { format!("{stem}Editor") } else { stem }; + emit(format!("UnrealBuildTool → {target} · Win64 · {ubt_conf}"), false, false); + emit(format!("Engine: {engine}"), false, false); + let mut c = Command::new(&ubt); #[cfg(windows)] { use std::os::windows::process::CommandExt; - vc.creation_flags(0x0800_0000); + c.creation_flags(0x0800_0000); } - vc.args(["-latest", "-requires", "Microsoft.Component.MSBuild", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"]); - match vc.output() { - Ok(o) => decode(&o.stdout).lines().next().unwrap_or("").trim().to_string(), - Err(_) => String::new(), + c.args([target, "Win64".into(), ubt_conf, format!("-Project={uproject}"), "-WaitMutex".into(), "-NoHotReloadFromIDE".into()]); + c + } else { + // plain MSBuild solution (non-UE C++ project) + 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) => decode(&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}{} …", if config.is_empty() { String::new() } else { format!(" [{config} | Win64]") }), false, false); + let mut c = Command::new(&msbuild); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + c.creation_flags(0x0800_0000); } + let mut args: Vec = vec![path.clone(), "/m".into(), "/nologo".into(), "/v:minimal".into(), "/clp:NoSummary".into()]; + if !config.is_empty() { + args.push(format!("/p:Configuration={config}")); + args.push("/p:Platform=Win64".into()); + } + c.args(&args); + c }; - let msbuild = if msbuild.is_empty() { "MSBuild.exe".to_string() } else { msbuild }; - emit(format!("MSBuild: {msbuild}"), false, false); - emit( - format!("Building {path}{} …", if config.is_empty() { String::new() } else { format!(" [{config} | Win64]") }), - false, - false, - ); - - let mut cmd = Command::new(&msbuild); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - cmd.creation_flags(0x0800_0000); - } - // build args; a specific UE configuration ("Development Editor", "Shipping", …) → Win64 - let mut args: Vec = vec![ - path.clone(), - "/m".into(), - "/nologo".into(), - "/v:minimal".into(), - "/clp:NoSummary".into(), - ]; - if !config.is_empty() { - args.push(format!("/p:Configuration={config}")); - args.push("/p:Platform=Win64".into()); - } - cmd.args(&args).stdout(Stdio::piped()).stderr(Stdio::piped()); + cmd.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}"); + let m = format!("Could not start the build tool (Visual Studio C++ tools / Unreal installed?): {e}"); emit(m.clone(), true, false); return Err(m); } @@ -1372,6 +1668,124 @@ async fn write_depot(state: State<'_, AppState>, depot: String, content: String) Ok(()) } +/// Create a new folder in the workspace (optionally as a project) under `scope` +/// (a depot path, or "" = workspace root). Perforce has no empty folders, so a +/// `README.md` placeholder is created and opened for add — commit it to make the +/// folder real on the server. `name` may include nested segments ("Game/Source"). +/// Returns the new folder's depot path. Confined to the workspace root. +#[tauri::command] +async fn make_folder(state: State<'_, AppState>, scope: String, name: String) -> Result { + let conn = current(&state)?; + let clean = name.trim().replace('\\', "/"); + let clean = clean.trim_matches('/').to_string(); + if clean.is_empty() + || clean.contains(':') + || clean.split('/').any(|s| s.is_empty() || s == ".." || s == ".") + { + return Err("Invalid folder name".into()); + } + // resolve the local parent directory + let parent = if scope.trim().is_empty() { + if conn.root.is_empty() { + return Err("No workspace root for this connection".into()); + } + conn.root.clone() + } else { + scope_local_dir(&conn, &scope).ok_or("Could not resolve the local folder for this depot path")? + }; + ensure_under_root(&conn, &parent)?; // the parent must be inside the workspace + let new_dir = std::path::Path::new(&parent).join(&clean); + std::fs::create_dir_all(&new_dir).map_err(|e| format!("Could not create folder: {e}"))?; + + // placeholder so the folder can live in Perforce (no empty dirs on the server) + let leaf = clean.rsplit('/').next().unwrap_or(&clean); + let readme = new_dir.join("README.md"); + if !readme.exists() { + std::fs::write(&readme, format!("# {leaf}\n\nNew project folder.\n").as_bytes()) + .map_err(|e| format!("Could not create placeholder: {e}"))?; + } + // open it for add and report the depot folder from the result + let added = files_cmd(&conn, "add", &[readme.to_string_lossy().to_string()]).unwrap_or_default(); + let depot = added + .first() + .and_then(|v| v.get("depotFile").and_then(|s| s.as_str())) + .and_then(|f| f.rsplit_once('/').map(|(d, _)| d.to_string())) + .unwrap_or_default(); + Ok(depot) +} + +/// Delete a folder: mark every submitted file under it for delete (`p4 delete +/// //folder/...`, reviewable — submit removes it from the server) and un-open any +/// freshly-added files. A folder that has nothing in the depot is just removed +/// from disk. `path` is a depot path (`//…`) or a local dir. Confined to the root. +#[tauri::command] +async fn delete_folder(state: State<'_, AppState>, path: String) -> Result, String> { + let conn = current(&state)?; + if !path.starts_with("//") { + // local-only folder → remove on disk (confined) + ensure_under_root(&conn, &path)?; + std::fs::remove_dir_all(&path).map_err(|e| format!("Could not delete folder: {e}"))?; + return Ok(Vec::new()); + } + let base = path.trim_end_matches(['/', '.']); + let spec = format!("{base}/..."); + // un-open anything already opened under here (adds/edits/deletes) so delete is clean + let _ = run_json(&conn, &["revert", &spec]); + let deleted = run_json(&conn, &["delete", &spec]).or_else(|e| { + let l = e.to_lowercase(); + if l.contains("no such file") || l.contains("not on client") || l.contains("no file(s)") || l.contains("empty") { + Ok(Vec::new()) + } else { + Err(e) + } + })?; + // nothing versioned here → drop the local directory outright + if deleted.is_empty() { + if let Some(local) = scope_local_dir(&conn, base) { + if ensure_under_root(&conn, &local).is_ok() { + let _ = std::fs::remove_dir_all(&local); + } + } + } + Ok(deleted) +} + +/// Rename / move a folder: `p4 move //old/... //parent/new/...`. Source files are +/// opened for edit first so submitted content moves too. `new_name` is the new +/// leaf name (may nest with `/`). On submit the folder is renamed on the server. +#[tauri::command] +async fn rename_folder(state: State<'_, AppState>, path: String, new_name: String) -> Result, String> { + let conn = current(&state)?; + if !path.starts_with("//") { + return Err("Renaming needs a depot path".into()); + } + let base = path.trim_end_matches(['/', '.']); + let parent = base.rsplit_once('/').map(|(p, _)| p).unwrap_or(""); + let clean = new_name.trim().replace('\\', "/"); + let clean = clean.trim_matches('/').to_string(); + if clean.is_empty() || clean.contains(':') || clean.split('/').any(|s| s.is_empty() || s == ".." || s == ".") { + return Err("Invalid folder name".into()); + } + let src = format!("{base}/..."); + let dst = format!("{parent}/{clean}/..."); + // open submitted files for edit (added files move without this); ignore "nothing to edit" + let _ = run_json(&conn, &["edit", &src]); + run_json(&conn, &["move", &src, &dst]) +} + +/// Folder summary for the Properties dialog: file count and total byte size from +/// `p4 sizes -s //folder/...`. +#[tauri::command] +async fn folder_info(state: State<'_, AppState>, path: String) -> Result { + let conn = current(&state)?; + let base = path.trim_end_matches(['/', '\\', '.']); + let spec = format!("{base}/..."); + let row = run_json(&conn, &["sizes", "-s", &spec]).ok().and_then(|v| v.into_iter().next()); + let count = row.as_ref().and_then(|o| o.get("fileCount")).and_then(|s| s.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(0); + let size = row.as_ref().and_then(|o| o.get("fileSize")).and_then(|s| s.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(0); + Ok(serde_json::json!({ "path": base, "fileCount": count, "size": size })) +} + /// 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 { @@ -2076,7 +2490,7 @@ async fn p4_clean_preview(state: State<'_, AppState>, scope: String) -> Result, scope: String) -> Result { let conn = current(&state)?; - let msg = run_stream(&conn, &["clean", &scope_spec(&scope)], "sync")?; + let msg = run_stream(&conn, &["clean", &scope_spec(&scope)], "sync", 0)?; Ok(if msg.is_empty() { "Workspace already matches the depot.".into() } else { msg }) } @@ -2542,6 +2956,9 @@ pub fn run() { p4_delete, p4_reconcile, p4_scan, + p4_scan_stream, + p4_scan_cancel, + p4_transfer_cancel, p4_undo_change, open_in_explorer, find_uproject, @@ -2611,6 +3028,10 @@ pub fn run() { fs_ls, p4_change_sizes, p4_behind, + make_folder, + delete_folder, + rename_folder, + folder_info, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a0e8e33..7186459 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Exbyte Depot", "mainBinaryName": "Exbyte Depot", - "version": "0.3.1", + "version": "0.3.2", "identifier": "com.bonchellon.exbyte-depot", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.css b/src/App.css index f8fc209..954cd79 100644 --- a/src/App.css +++ b/src/App.css @@ -235,6 +235,12 @@ body.resizing{cursor:col-resize!important;user-select:none} .scanbar{display:flex;align-items:center;gap:9px;margin:8px 10px 2px;padding:9px 12px;border-radius:11px;font-size:11.5px;line-height:1.4; color:var(--accent-2);background:rgba(124,110,246,.08);border:1px solid rgba(124,110,246,.22)} .scanbar .ldr{flex:0 0 auto} +.scanbar > span{flex:1 1 auto;min-width:0} +.scancount{font-weight:600;color:var(--txt);font-variant-numeric:tabular-nums} +.scan-cancel{flex:0 0 auto;font-size:11px;font-weight:500;color:var(--muted);cursor:pointer; + background:var(--panel);border:1px solid var(--border);border-radius:7px;padding:3px 11px;transition:.15s} +.scan-cancel:hover{color:#fff;background:var(--danger,#e5484d);border-color:var(--danger,#e5484d)} +.empty .scancount{color:var(--accent-2)} /* submit progress modal */ .push-modal{width:380px;background:var(--elevated);border:1px solid var(--border);border-radius:16px;padding:24px 26px;box-shadow:var(--shadow);text-align:center} @@ -442,9 +448,20 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .xfer-ttl b{font-size:14.5px} .xfer-ttl span{font-size:12px;color:var(--faint);font-variant-numeric:tabular-nums} .xfer-file{margin-top:14px;font-family:var(--mono);font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.xfer-bar{margin-top:12px;height:6px;border-radius:4px;background:var(--panel-3);overflow:hidden;position:relative} +.xfer-cancel{margin-left:auto;flex:0 0 auto;align-self:flex-start;font-size:11.5px;font-weight:600;cursor:pointer; + background:var(--panel-2);border:1px solid var(--border);color:var(--muted);border-radius:8px;padding:5px 11px;transition:.15s} +.xfer-cancel:hover:not(:disabled){color:#fff;background:var(--danger,#e5484d);border-color:var(--danger,#e5484d)} +.xfer-cancel:disabled{opacity:.55;cursor:default} +.xfer-bar{margin-top:14px;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} +/* determinate (known total): solid fill sized by inline width, no shimmer */ +.xfer-bar.det span{position:absolute;left:0;top:0;width:0;animation:none; + background:linear-gradient(90deg,var(--accent-2),var(--accent-deep));transition:width .25s ease;box-shadow:0 0 10px rgba(124,110,246,.55)} +.xfer-log{margin-top:12px;height:132px;overflow-y:auto;overflow-x:hidden;border:1px solid var(--border);border-radius:9px; + background:var(--panel);padding:7px 10px;font-family:var(--mono);font-size:10.5px;line-height:1.55;color:var(--muted)} +.xfer-log div{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.xfer-log div:last-child{color:var(--txt)} /* MSBuild output overlay */ .build{width:660px;max-width:calc(100vw - 40px);height:min(70vh,560px);display:flex;flex-direction:column; @@ -666,7 +683,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .histsplit .histlist .crow.sel .crow-go{opacity:1;transform:translateX(0)} /* ---- right-side view panel with tabs (File Viewer / File Tree) ---- */ .viewpanel{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden} -.vtabs{display:flex;align-items:stretch;gap:2px;padding:6px 8px 0;border-bottom:1px solid var(--border);background:var(--sunk);flex:0 0 auto;overflow-x:auto} +.vtabs{display:flex;align-items:stretch;gap:2px;padding:6px 8px 0;border-bottom:1px solid var(--border);background:var(--sunk);flex:0 0 auto;overflow-x:auto;overflow-y:hidden} .vtab{display:flex;align-items:center;gap:7px;padding:7px 12px;border-radius:9px 9px 0 0;font-size:12.5px;font-weight:600;color:var(--muted);cursor:pointer;white-space:nowrap;border:1px solid transparent;border-bottom:none;position:relative;top:1px} .vtab:hover{color:var(--txt);background:var(--hover)} .vtab.on{color:var(--txt);background:var(--bg);border-color:var(--border)} @@ -681,6 +698,19 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .ft-bar{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--border-soft);flex:0 0 auto} .ft-total{margin-left:auto;font-size:12px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums} .ft-total b{color:var(--accent-2)} +.ft-new{display:flex;align-items:center;gap:5px;font-family:var(--font);font-size:11.5px;font-weight:600;color:var(--accent-2); + background:rgba(124,110,246,.1);border:1px solid rgba(124,110,246,.24);border-radius:8px;padding:4px 10px;cursor:pointer;flex:0 0 auto;transition:.14s} +.ft-new:hover{background:rgba(124,110,246,.18);border-color:rgba(124,110,246,.4)} +.ft-new svg{width:13px;height:13px} +/* folder properties dialog */ +.fp-body{padding:18px 20px} +.fp-name{display:flex;align-items:center;gap:10px;font-size:15px;margin-bottom:16px} +.fp-name svg{width:20px;height:20px;color:var(--accent-2);flex:0 0 auto} +.fp-name b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.fp-grid{display:grid;grid-template-columns:auto 1fr;gap:10px 16px;font-size:13px} +.fp-grid .k{color:var(--faint)} +.fp-grid .v{color:var(--txt);min-width:0;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums} +.fp-grid .v.mono{font-family:var(--mono);font-size:12px;word-break:break-all;white-space:normal} .vpane.tree .exp-tree{flex:1;min-height:0} .hnum{font-family:var(--mono);font-size:12px;color:var(--accent-2);font-variant-numeric:tabular-nums;flex:0 0 auto;padding-top:1px} .hbody{display:flex;flex-direction:column;gap:3px;min-width:0} @@ -722,6 +752,13 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .picker-item .pi-body{display:flex;flex-direction:column;gap:2px;min-width:0} .picker-item .pi-name{font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .picker-item .pi-sub{font-size:11.5px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +/* recently-used working folders */ +.pick-recent{border-bottom:1px solid var(--border-soft);margin-bottom:6px;padding-bottom:6px} +.pick-recent-h{font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--faint);padding:4px 12px 6px;font-weight:600} +.picker-item .pi-ic.clock,.picker-item .pi-ic.up{color:var(--muted);background:var(--panel-3)} +/* "local only, not in depot yet" tag */ +.local-tag{margin-left:8px;font-size:9.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--warn); + background:rgba(232,176,75,.14);border:1px solid rgba(232,176,75,.32);border-radius:6px;padding:1px 6px;vertical-align:middle} .picker-foot{padding:12px 20px;border-top:1px solid var(--border-soft);font-size:12px;color:var(--faint)} .info-body{padding:20px;font-size:13px;line-height:1.6;color:var(--muted)} .info-body b{color:var(--txt)} diff --git a/src/App.tsx b/src/App.tsx index b9a0929..b43a32a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,7 @@ import ModelViewer from "./ModelViewer"; import CodeView from "./CodeView"; import UpdateBanner from "./Updater"; import "./App.css"; -import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry } from "./p4"; +import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry } from "./p4"; import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n"; import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai"; @@ -70,6 +70,13 @@ const I = { info: , scan: , ue: , + trash: , + copy: , + reveal: , + move: , + pencil: , + down: , + open: , }; /* ---------------- theme hook ---------------- */ @@ -331,6 +338,7 @@ type ModalState = | { kind: "clean" } | { kind: "reconcile" } | { kind: "explorer" } + | { kind: "folderprops"; path: string; name: string } | { kind: "ignore" } | { kind: "filelog"; depot: string; name: string } | { kind: "client"; name: string; isNew: boolean } @@ -339,7 +347,7 @@ type ModalState = | { kind: "about" }; type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null }; -type Transfer = { op: "sync" | "submit"; count: number; file?: string }; +type Transfer = { op: "sync" | "submit"; count: number; total?: number; file?: string; log: string[]; cancelling?: boolean }; type TermLine = { id: number; cmd?: string; text: string; err?: boolean; running?: boolean }; function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, setZoom, onInfo, onSession, onDisconnect }: @@ -362,6 +370,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set useEffect(() => { try { localStorage.setItem("exd-draft-body", descBody); } catch {} }, [descBody]); const [busy, setBusy] = useState(false); const [scanning, setScanning] = useState(false); // background reconcile in progress + const [scanCount, setScanCount] = useState(0); // live "found N files" during a streaming scan const [openMenu, setOpenMenu] = useState(null); const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null); const [history, setHistory] = useState([]); @@ -374,6 +383,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const [ask, setAsk] = useState void }>(null); const [pending, setPending] = useState([]); // committed-but-not-pushed changelists const [transfer, setTransfer] = useState(null); // live sync/submit file transfer + const [commitProg, setCommitProg] = useState<{ count: number; total: number } | null>(null); // live commit (reopen) progress 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) @@ -587,9 +597,12 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set finally { if (seq === refreshSeq.current) setBusy(false); } if (scan) { setScanning(true); - try { await p4.scan(s); const f = await p4.opened(s); if (seq === refreshSeq.current) await applyOpened(s, f); } + setScanCount(0); + // stream the reconcile so the banner shows a live "found N files" counter + // (and can be cancelled) instead of an opaque, never-ending spinner + try { await p4.scanStream(s); const f = await p4.opened(s); if (seq === refreshSeq.current) await applyOpened(s, f); } catch (e) { if (seq === refreshSeq.current) flash(String(e), true); } - finally { if (seq === refreshSeq.current) setScanning(false); } + finally { if (seq === refreshSeq.current) { setScanning(false); setScanCount(0); } } } } // Default = Perforce-native: just read what's checked out (p4 opened). With @@ -607,13 +620,38 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set }).then((u) => { if (dead) u(); else un = u; }); return () => { dead = true; un?.(); }; }, []); - // live file-transfer progress (Get Latest / Submit) streamed from the backend + // live file-transfer progress (Get Latest / Submit) streamed from the backend. + // Accumulates a rolling file log and tracks total for an "N of total" bar. useEffect(() => { let un: (() => void) | undefined; let dead = false; - listen<{ op: "sync" | "submit"; count: number; file?: string; done: boolean }>("p4-transfer", (e) => { + listen<{ op: "sync" | "submit"; count: number; total?: number; file?: string; done: boolean; cancelled?: boolean }>("p4-transfer", (e) => { const p = e.payload; - if (p.done) setTransfer(null); - else setTransfer({ op: p.op, count: p.count, file: p.file }); + if (p.done) { setTransfer(null); return; } + setTransfer((prev) => { + const log = prev ? prev.log : []; + const next = p.file && (log.length === 0 || log[log.length - 1] !== p.file) + ? [...log, p.file].slice(-200) : log; + return { op: p.op, count: p.count, total: p.total || prev?.total, file: p.file, log: next, cancelling: prev?.cancelling }; + }); + }).then((u) => { if (dead) u(); else un = u; }); + return () => { dead = true; un?.(); }; + }, []); + // live commit progress: p4_commit reopens files in batches and emits one event + // per batch, so we can show "N / total committed" instead of a mute spinner + useEffect(() => { + let un: (() => void) | undefined; let dead = false; + listen<{ count: number; total: number; done: boolean }>("p4-commit", (e) => { + const p = e.payload; + setCommitProg(p.done ? null : { count: p.count, total: p.total }); + }).then((u) => { if (dead) u(); else un = u; }); + return () => { dead = true; un?.(); }; + }, []); + // live disk-scan progress: the streaming reconcile emits one event per file it + // opens, so we can show a rising "found N files" counter during a Rescan + useEffect(() => { + let un: (() => void) | undefined; let dead = false; + listen<{ count: number; file?: string; done: boolean }>("p4-scan", (e) => { + if (!e.payload.done) setScanCount(e.payload.count); }).then((u) => { if (dead) u(); else un = u; }); return () => { dead = true; un?.(); }; }, []); @@ -705,6 +743,23 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set // remember which view-panel tabs are open across restarts useEffect(() => { try { localStorage.setItem("exd-viewtabs", JSON.stringify(viewTabs)); } catch {} }, [viewTabs]); + // In a shipped (production) build, kill the WebView's own right-click menu + // (Back / Reload / Save as / Print / Inspect) and the devtools hotkeys — this + // is a desktop app, not a web page. In dev we keep them for debugging. + useEffect(() => { + if (import.meta.env.DEV) return; + const noCtx = (e: MouseEvent) => e.preventDefault(); // our own context menus render as React popups, unaffected + const noKeys = (e: KeyboardEvent) => { + const k = e.key.toUpperCase(); + if (k === "F12" || (e.ctrlKey && e.shiftKey && (k === "I" || k === "J" || k === "C")) || (e.ctrlKey && k === "U")) { + e.preventDefault(); + } + }; + document.addEventListener("contextmenu", noCtx); + window.addEventListener("keydown", noKeys, true); + return () => { document.removeEventListener("contextmenu", noCtx); window.removeEventListener("keydown", noKeys, true); }; + }, []); + // check "am I behind the server" when connected or the working folder changes useEffect(() => { if (info?.clientName && !workOffline) checkBehind(); /* eslint-disable-next-line */ }, [activePath, info?.clientName, workOffline]); @@ -800,6 +855,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set setModal(null); setActivePath(path); saveScope(info?.clientName || "", path); // remember the working folder per workspace + if (path) pushRecentScope(info?.clientName || "", path); // Recently list (skip whole-workspace) setHistory([]); setDetail(null); setSel(null); refresh(path); if (tab === "history") loadHistory(path); @@ -867,6 +923,50 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set function newWorkspace() { setPrompt({ title: t("New workspace"), label: t("Workspace (client) name"), value: `${session?.user || info?.userName || "user"}_new`, onSave: (v) => { setPrompt(null); const n = v.trim(); if (n) setModal({ kind: "client", name: n, isNew: true }); } }); } + // create a new folder / project in the workspace under `scope` (depot path, or "" = root) + function newFolder(scope: string) { + const where = scope || t("workspace root"); + setPrompt({ title: t("New folder in {where}", { where }), label: t("Folder name — nest with / (e.g. NewGame or NewGame/Source)"), value: "", onSave: async (v) => { + setPrompt(null); + const n = v.trim(); + if (!n) return; + try { + await p4.makeFolder(scope, n); + flash(t("Created “{name}” with a README placeholder — commit it to keep the folder on the server.", { name: n })); + await refresh(); + } catch (e) { flash(String(e), true); } + } }); + } + // rename / move a depot folder (p4 move) — review in Changes, then Submit + function renameFolder(path: string) { + const cur = splitPath(path).name || path; + setPrompt({ title: t("Rename folder"), label: t("New name for “{name}”", { name: cur }), value: cur, onSave: async (v) => { + setPrompt(null); + const n = v.trim(); + if (!n || n === cur) return; + try { const r = await p4.renameFolder(path, n); flash(t("Renamed to “{name}” — {n} file(s) moved. Commit to apply.", { name: n, n: r.length })); await refresh(); } + catch (e) { flash(String(e), true); } + } }); + } + // delete a folder — marks its files for delete (reviewable), removes fresh/local ones + async function deleteFolder(path: string) { + const name = splitPath(path).name || path; + if (!(await confirm({ title: t("Delete folder “{name}”?", { name }), body: t("Every file under this folder is marked for delete. Nothing leaves the server until you Submit; freshly-added files are removed right away."), confirm: t("Delete"), danger: true }))) return; + try { const r = await p4.deleteFolder(path); flash(r.length ? t("Marked {n} file(s) for delete — Submit to remove the folder.", { n: r.length }) : t("Folder removed.")); await refresh(); } + catch (e) { flash(String(e), true); } + } + // right-click on a folder in the File Tree → folder operations + function folderMenu(n: FsEntry, e: ReactMouseEvent) { + const isDepot = n.path.startsWith("//"); + openCtx(e, [ + { label: t("New folder / project…"), icon: I.folder, act: () => newFolder(n.path) }, + ...(isDepot ? [{ label: t("Rename…"), icon: I.pencil, act: () => renameFolder(n.path) }] : []), + { label: t("Properties"), icon: I.info, act: () => setModal({ kind: "folderprops", path: n.path, name: n.name }) }, + { label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(n.path) }, + { label: t("Copy path"), icon: I.copy, act: () => copyText(n.path, t("Path")) }, + { label: t("Delete folder…"), icon: I.trash, danger: true, act: () => deleteFolder(n.path) }, + ]); + } // Manual re-scan (same background reconcile as auto, on demand). function rescan() { refresh(activePath, true); } @@ -882,15 +982,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set // open the configuration picker (Development / Shipping / …) before building function startBuild() { - if (!slnPath) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; } + if (!slnPath && !uproject) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; } setBuildPick(true); } - // build the solution (MSBuild) with the chosen UE configuration, live output + // build with the chosen UE configuration, live output. For a real UE project we + // pass the .uproject so the backend builds the game module via UnrealBuildTool. async function buildSln(config: string) { setBuildPick(false); - if (!slnPath) return; + if (!slnPath && !uproject) return; setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false); - try { await p4.buildSln(slnPath, config); } + try { await p4.buildSln(slnPath, config, uproject); } catch { /* the overlay already shows the failure line */ } } @@ -954,6 +1055,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set } finally { setAiBusy(false); } } + // Cancel an in-progress submit/sync. Safe for submit before the server commit: + // p4 submit is transactional, so an aborted transfer leaves the CL pending. + function cancelTransfer() { + setTransfer((prev) => (prev ? { ...prev, cancelling: true } : prev)); + p4.transferCancel().catch(() => {}); + } + // Submit / push: send all committed (pending) changelists to the server. (like git push) async function pushAll() { if (!pending.length) return; @@ -1112,16 +1220,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const dps = targets.map((f) => f.depotFile || "").filter(Boolean); const isCode = isCodeFile(splitPath(dp).name); openCtx(e, [ - { label: targets.length > 1 ? t("Discard changes · {n} files", { n: targets.length }) : t("Discard changes"), danger: true, act: () => discard(targets) }, + { label: targets.length > 1 ? t("Discard changes · {n} files", { n: targets.length }) : t("Discard changes"), icon: I.trash, danger: true, act: () => discard(targets) }, { label: t("Lock (exclusive)"), icon: I.lock, act: () => lockFiles(dps, true) }, { label: t("Unlock"), icon: I.unlock, act: () => lockFiles(dps, false) }, { label: t("Set exclusive-lock type (+l)"), icon: I.lock, act: () => setExclusiveType(dps) }, ...(workOffline ? [{ label: t("Make writable (offline edit)"), icon: I.unlock, act: () => makeWritable(targets) }] : []), - ...pending.map((cl) => ({ label: t("Move to #{n}", { n: cl.change || "" }), act: () => moveToCL(cl.change || "", dps) })), + ...pending.map((cl) => ({ label: t("Move to #{n}", { n: cl.change || "" }), icon: I.move, act: () => moveToCL(cl.change || "", dps) })), ...(isCode ? [{ label: t("Open in {editor}", { editor: effEditorName }), icon: I.vscode, act: () => { p4.openInEditor(dp, effEditorId).then(() => flash(t("Opening in {editor}…", { editor: effEditorName }))).catch((err) => flash(String(err), true)); } }] : []), - ...(isCode ? [{ label: t("Blame (annotate)"), act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []), - { label: t("Open in Explorer"), act: () => reveal(dp) }, - { label: t("Copy path"), act: () => copyText(dp, t("Path")) }, + ...(isCode ? [{ label: t("Blame (annotate)"), icon: I.people, act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []), + { label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(dp) }, + { label: t("Copy path"), icon: I.copy, act: () => copyText(dp, t("Path")) }, ]); } async function lockFiles(dps: string[], lock: boolean) { @@ -1204,6 +1312,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set File: [ { label: t("Switch workspace…"), icon: I.monitor, hint: t("Change which Perforce workspace (client) you're working in."), act: openWorkspaces }, { label: t("New workspace…"), icon: I.monitor, hint: t("Create a new workspace mapping depot folders to a local folder."), act: newWorkspace }, + { label: t("New folder / project…"), icon: I.folder, hint: t("Create a new folder in the working area (with a README placeholder) — e.g. a separate project."), act: () => newFolder(activePath) }, ...(info?.clientName ? [{ label: t("Edit current workspace…"), icon: I.gear, hint: t("Edit the current workspace spec — root folder and depot view mapping."), act: () => setModal({ kind: "client", name: info.clientName as string, isNew: false }) }] : []), { label: t("Choose working folder…"), icon: I.folder, hint: t("Pick which depot folder the app shows and syncs."), act: () => browseTo("") }, { label: t("Disconnect"), icon: I.power, hint: t("Sign out and return to the connection screen."), act: onDisconnect }, @@ -1242,7 +1351,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set { label: t("Streams…"), icon: I.branch, hint: t("Switch stream, merge down from the parent, or copy up to it."), act: () => setModal({ kind: "streams" }) }, { label: t("Jobs…"), icon: I.log, hint: t("Perforce's task/bug tracker — create jobs and attach them to changelists."), act: () => setModal({ kind: "jobs" }) }, { label: t("Edit .p4ignore…"), icon: I.gear, hint: t("Edit which files reconcile / add ignore (build output, caches)."), act: () => setModal({ kind: "ignore" }) }, - { label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, hint: t("Compile the Visual Studio solution found in the working folder."), act: startBuild }, + { label: (slnPath || uproject) ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, hint: t("Compile the game C++ — Unreal projects build via UnrealBuildTool (game module only), plain C++ via MSBuild."), act: startBuild }, { label: t("People & Roles…"), icon: I.people, hint: t("See who works on this depot and their roles."), act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), icon: I.gear, hint: t("App preferences — language, theme, editor, and more."), act: () => setModal({ kind: "settings" }) }, ], @@ -1278,9 +1387,9 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
openCtx(e, [ { label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("workspace") }, - { label: t("Open in Explorer"), act: () => reveal(String(info?.clientRoot || "")) }, - { label: t("Switch workspace…"), act: openWorkspaces }, - { label: t("Copy name"), act: () => copyText(info?.clientName || "", t("Workspace name")) }, + { label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(String(info?.clientRoot || "")) }, + { label: t("Switch workspace…"), icon: I.monitor, act: openWorkspaces }, + { label: t("Copy name"), icon: I.copy, act: () => copyText(info?.clientName || "", t("Workspace name")) }, ])}> {I.monitor} {t("Workspace")}{info?.clientName || "—"} @@ -1290,12 +1399,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
startResize(e, "sync")} title={t("Drag to resize")} />
browseTo("")} title={t("Choose the project working folder · right-click for more")} onContextMenu={(e) => openCtx(e, [ + { label: t("New folder / project…"), icon: I.folder, act: () => newFolder(activePath) }, + ...(activePath ? [{ label: t("Rename…"), icon: I.pencil, act: () => renameFolder(activePath) }] : []), + ...(activePath ? [{ label: t("Properties"), icon: I.info, act: () => setModal({ kind: "folderprops", path: activePath, name: splitPath(activePath).name || activePath }) }] : []), { label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("depot") }, ...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []), - ...(slnPath ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: startBuild }] : []), - { 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")) }, + ...((slnPath || uproject) ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: startBuild }] : []), + { label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(activePath || String(info?.clientRoot || "")) }, + { label: t("Choose another folder…"), icon: I.folder, act: () => browseTo("") }, + { label: t("Copy path"), icon: I.copy, act: () => copyText(activePath || "//…", t("Path")) }, + ...(activePath ? [{ label: t("Delete folder…"), icon: I.trash, danger: true, act: () => deleteFolder(activePath) }] : []), ])}> {I.branch} {t("Working folder")}{activePath || t("whole workspace (//…)")} @@ -1372,16 +1485,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set e.stopPropagation()}>
@@ -1408,7 +1521,12 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set {scanning && (
- {t("Scanning disk for changes…")}{!activePath && t(" for the whole workspace this is slow — pick a project folder above")} + + {t("Scanning disk for changes…")} + {scanCount > 0 && {t("found {n} files", { n: scanCount.toLocaleString() })}} + {scanCount === 0 && !activePath && t(" for the whole workspace this is slow — pick a project folder above")} + +
)}
@@ -1420,7 +1538,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{view.length === 0 ? (
- {scanning ? <>{t("Scanning disk for changes…")} : <> + {scanning ? <>{t("Scanning disk for changes…")}{scanCount > 0 && {t("found {n} files", { n: scanCount.toLocaleString() })}} : <> {busy ? t("Loading…") : t("No open files yet.\nEdit assets in Unreal (Source Control → Perforce) — they show up here on their own.\n\nEdited files outside Perforce? Actions → “Rescan changes”.")} } @@ -1440,7 +1558,11 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set