Add animated startup splash + live server transfer dialog

Splash: pulsing logo, client->server packet-flow animation, cycling
status steps and a shimmer bar, shown for ~1s while the session restores.

Transfer progress: the backend now streams p4 sync/submit stdout line by
line (throttled ~25fps) and emits p4-transfer events; the frontend shows a
P4V-style progress dialog (op title, current file, file count, animated
bar) that only appears once an operation runs past ~400ms. Replaces the old
submit-only modal. All new strings translated (5 langs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-07 14:50:46 +03:00
parent a67c171315
commit 15140918ad
4 changed files with 217 additions and 35 deletions

View File

@ -2,9 +2,10 @@
// Thin wrappers around the `p4` CLI that return JSON (`-Mj`) to the frontend.
use serde_json::Value;
use std::io::Write;
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::Instant;
use tauri::{AppHandle, Emitter, State};
/// App handle stashed at startup so the low-level p4 helpers can emit a live
@ -324,11 +325,83 @@ fn run_text(conn: &Conn, args: &[&str]) -> Result<String, String> {
Ok(if stdout.is_empty() { stderr } else { stdout })
}
/// Pull a short file name out of a p4 sync/submit progress line, e.g.
/// "//depot/Proj/Foo.uasset#3 - added as C:\..." -> "Foo.uasset".
fn progress_file(line: &str) -> String {
let head = line.split(" - ").next().unwrap_or(line);
let head = head.split('#').next().unwrap_or(head);
head.rsplit(['/', '\\']).next().unwrap_or(head).trim().to_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> {
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 collected = String::new();
let mut count: i64 = 0;
let mut last = Instant::now();
if let Some(out) = child.stdout.take() {
for line in BufReader::new(out).lines().map_while(Result::ok) {
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 }),
);
}
last = Instant::now();
}
collected.push_str(&line);
collected.push('\n');
}
}
let status = child.wait().map_err(|e| e.to_string())?;
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();
// tell the UI the transfer is finished
if let Some(app) = APP.get() {
let _ = app.emit(
"p4-transfer",
serde_json::json!({ "op": op, "count": count, "done": true }),
);
}
if !status.success() && !stderr.is_empty() {
log_p4(args, 0, false, Some(&stderr));
return Err(stderr);
}
log_p4(args, count, true, None);
Ok(collected.trim().to_string())
}
/// 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_text(&conn, &["sync", &scope_spec(&scope)])?;
let msg = run_stream(&conn, &["sync", &scope_spec(&scope)], "sync")?;
Ok(if msg.is_empty() { "Already up to date — nothing to sync.".into() } else { msg })
}
@ -404,7 +477,7 @@ async fn p4_commit(
#[tauri::command]
async fn p4_submit_change(state: State<'_, AppState>, change: String) -> Result<String, String> {
let conn = current(&state)?;
run_text(&conn, &["submit", "-c", &change])
run_stream(&conn, &["submit", "-c", &change], "submit")
}
/// Submit the selected files with a description.
@ -421,7 +494,7 @@ async fn p4_submit(
let total = run_json(&conn, &["opened"])?.len();
// all opened files selected → submit the whole default changelist in one go
if files.len() >= total {
return run_text(&conn, &["submit", "-d", &description]);
return run_stream(&conn, &["submit", "-d", &description], "submit");
}
// partial submit → dedicated numbered changelist
let cl = create_changelist(&conn, &description)?;
@ -430,7 +503,7 @@ async fn p4_submit(
reopen.push(f.as_str());
}
run_text(&conn, &reopen)?;
run_text(&conn, &["submit", "-c", &cl])
run_stream(&conn, &["submit", "-c", &cl], "submit")
}
/// Run a p4 command over a list of file paths, returning the opened records.