v0.3.2 — commit/submit progress, cancel, thumbnails, waitlist landing+admin
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 <noreply@anthropic.com>
This commit is contained in:
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -966,7 +966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "exbyte-depot"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"serde",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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<AppHandle> = 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<Vec<Valu
|
||||
}
|
||||
}
|
||||
|
||||
/// List sub-directories of a depot path (for the working-folder picker).
|
||||
/// List sub-directories of a depot path for the working-folder picker. Includes
|
||||
/// LOCAL-only folders that exist on disk but aren't in the depot yet (flagged
|
||||
/// `local: true`) so a new local project can be navigated to and pushed.
|
||||
#[tauri::command]
|
||||
async fn p4_dirs(state: State<'_, AppState>, path: String) -> Result<Vec<Value>, 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<String> = 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<String, String> {
|
||||
/// `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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
let conn = current(&state)?;
|
||||
let ch = change.trim().to_string();
|
||||
let total = count_opened_in(&conn, &ch);
|
||||
let mut args: Vec<String> = 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<Vec<Value>, 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<std::ops::Range<usize>> {
|
||||
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<Vec<Value>, 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<Vec<Value>
|
||||
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<i64, String> {
|
||||
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<i64, String> {
|
||||
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<String, S
|
||||
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.
|
||||
/// Read a single string value from the Windows registry via `reg query`.
|
||||
#[cfg(windows)]
|
||||
fn reg_value(root: &str, key: &str, name: &str) -> Option<String> {
|
||||
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<String> {
|
||||
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 <solution>` 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<String, String> {
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
async fn build_sln(path: String, config: String, uproject: String) -> Result<String, String> {
|
||||
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<String, String> {
|
||||
}
|
||||
};
|
||||
|
||||
// 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<String> = 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<String> = 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<String, String> {
|
||||
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<Vec<Value>, 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<Vec<Value>, 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<Value, String> {
|
||||
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::<u64>().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::<u64>().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<tauri::ipc::Response, String> {
|
||||
@ -2076,7 +2490,7 @@ async fn p4_clean_preview(state: State<'_, AppState>, scope: String) -> Result<V
|
||||
#[tauri::command]
|
||||
async fn p4_clean_apply(state: State<'_, AppState>, scope: String) -> Result<String, String> {
|
||||
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");
|
||||
|
||||
@ -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",
|
||||
|
||||
Reference in New Issue
Block a user