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.

View File

@ -285,6 +285,45 @@ body.resizing{cursor:col-resize!important;user-select:none}
.logcmd{color:var(--txt);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.logcount{margin-left:auto;flex:0 0 auto;color:var(--faint);white-space:nowrap}
.logrow.err .logcount{color:var(--del)}
/* animated startup splash */
.splash{display:flex;flex-direction:column;align-items:center;gap:16px;padding:20px;animation:updin .5s ease}
.splash-logo{width:64px;height:64px;border-radius:18px;display:grid;place-items:center;color:#fff;
background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));animation:splpulse 1.8s ease-in-out infinite}
.splash-logo svg{width:34px;height:34px}
@keyframes splpulse{0%,100%{transform:scale(1);box-shadow:0 0 40px rgba(124,110,246,.5)}50%{transform:scale(1.06);box-shadow:0 0 62px rgba(124,110,246,.85)}}
.flow{display:flex;align-items:center;margin-top:2px}
.flow .node{width:44px;height:44px;border-radius:12px;display:grid;place-items:center;color:var(--muted);background:var(--panel);border:1px solid var(--border);flex:0 0 auto}
.flow .node svg{width:22px;height:22px}
.wire{position:relative;width:150px;height:2px;background:linear-gradient(90deg,var(--border),var(--accent),var(--border))}
.packet{position:absolute;top:50%;width:7px;height:7px;border-radius:50%;background:var(--accent-2);
box-shadow:0 0 10px var(--accent);transform:translateY(-50%);animation:travel 1.7s linear infinite;opacity:0}
@keyframes travel{0%{left:0;opacity:0}12%{opacity:1}88%{opacity:1}100%{left:100%;opacity:0}}
.splash-title{font-size:20px;font-weight:700;letter-spacing:.3px;margin-top:6px}
.splash-step{font-size:12.5px;color:var(--muted);min-height:16px}
.splash-bar{width:220px;height:4px;border-radius:3px;background:var(--panel-3);overflow:hidden;position:relative}
.splash-bar span{position:absolute;top:0;left:-40%;width:40%;height:100%;border-radius:3px;
background:linear-gradient(90deg,transparent,var(--accent),transparent);animation:shim 1.3s ease-in-out infinite}
@keyframes shim{0%{left:-40%}100%{left:100%}}
/* server transfer progress dialog (P4V-style) */
.xfer-back{backdrop-filter:blur(2px)}
.xfer{width:390px;max-width:calc(100vw - 40px);background:var(--elevated);border:1px solid var(--border);border-radius:16px;padding:20px 22px;box-shadow:var(--shadow);animation:updin .3s ease}
.xfer-head{display:flex;align-items:center;gap:13px}
.xfer-ic{width:42px;height:42px;flex:0 0 auto;border-radius:12px;display:grid;place-items:center;color:#fff}
.xfer-ic.up{background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));box-shadow:0 0 22px rgba(124,110,246,.55)}
.xfer-ic.down{background:linear-gradient(145deg,var(--add),#1f9f6e);box-shadow:0 0 22px rgba(62,207,142,.45)}
.xfer-ic svg{width:22px;height:22px;animation:xbob 1.2s ease-in-out infinite}
@keyframes xbob{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
.xfer-ic.down svg{animation:xbobd 1.2s ease-in-out infinite}
@keyframes xbobd{0%,100%{transform:translateY(0)}50%{transform:translateY(3px)}}
.xfer-ttl{display:flex;flex-direction:column;gap:2px;min-width:0}
.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-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}
.tzone.push .ic{color:var(--add)}
.tzone.push .val{color:var(--add)}
.commit .row{display:flex;gap:10px;align-items:center}

View File

@ -80,26 +80,19 @@ export default function App() {
const [zoom, setZoom] = useZoom();
const saved = useMemo(() => loadSession(), []);
// try to restore the last session from the on-disk p4 ticket
// try to restore the last session from the on-disk p4 ticket.
// keep the splash on screen for at least a beat so the intro animation reads.
useEffect(() => {
if (!saved) { setPhase("connect"); return; }
if (!saved) { const id = setTimeout(() => setPhase("connect"), 900); return () => clearTimeout(id); }
const min = new Promise<void>((r) => setTimeout(r, 1000));
p4.restore(saved.server, saved.user, saved.client)
.then((i) => { setInfo(i); setSession(saved); setPhase("main"); })
.catch(() => setPhase("connect"));
.then(async (i) => { await min; setInfo(i); setSession(saved); setPhase("main"); })
.catch(async () => { await min; setPhase("connect"); });
}, []); // eslint-disable-line
let content;
if (phase === "loading")
content = (
<div className="win">
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<button className="theme-btn" onClick={toggleTheme} title="Тема">{light ? I.sun : I.moon}</button>
<WinControls />
</div>
<div className="overlay"><div className="nofile"><span className="ldr" /><span>Восстановление сессии</span></div></div>
</div>
);
content = <Splash light={light} toggleTheme={toggleTheme} />;
else if (phase === "connect")
content = <Connect light={light} toggleTheme={toggleTheme} initial={saved}
onDone={(i, s) => { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />;
@ -111,6 +104,42 @@ export default function App() {
return <>{content}<UpdateBanner /></>;
}
/* ---------------- animated startup splash ---------------- */
function Splash({ light, toggleTheme }: { light: boolean; toggleTheme: () => void }) {
const steps = [t("Connecting to server…"), t("Authenticating…"), t("Loading workspace…"), t("Syncing state…")];
const [step, setStep] = useState(0);
useEffect(() => {
const id = setInterval(() => setStep((s) => s + 1), 750);
return () => clearInterval(id);
}, []);
return (
<div className="win">
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
<div className="overlay">
<div className="splash">
<div className="splash-logo">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" strokeWidth="1.4"/></svg>
</div>
<div className="flow">
<div className="node"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 21h8M12 17v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg></div>
<div className="wire">
{[0, 1, 2, 3, 4, 5].map((i) => <span key={i} className="packet" style={{ animationDelay: `${i * 0.28}s` }} />)}
</div>
<div className="node"><svg viewBox="0 0 24 24" fill="none"><ellipse cx="12" cy="6" rx="8" ry="3" stroke="currentColor" strokeWidth="1.6"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" stroke="currentColor" strokeWidth="1.6"/></svg></div>
</div>
<div className="splash-title">Exbyte&nbsp;Depot</div>
<div className="splash-step">{steps[Math.min(step, steps.length - 1)]}</div>
<div className="splash-bar"><span /></div>
</div>
</div>
</div>
);
}
/* ---------------- connect screen ---------------- */
function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; toggleTheme: () => void; initial: Session | null; onDone: (i: P4Info, s: Session) => void }) {
const [server, setServer] = useState(initial?.server || "ssl:pf.exbytestudios.com:1666");
@ -197,6 +226,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 };
function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, setZoom, onInfo, onSession, onDisconnect }:
{ info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; onInfo: (i: P4Info) => void; onSession: (s: Session) => void; onDisconnect: () => void }) {
@ -211,7 +241,6 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const [descBody, setDescBody] = useState(""); // extended description (optional)
const [busy, setBusy] = useState(false);
const [scanning, setScanning] = useState(false); // background reconcile in progress
const [pushing, setPushing] = useState<{ done: number; total: number } | null>(null); // submit progress
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
const [history, setHistory] = useState<Change[]>([]);
@ -221,6 +250,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const [dragging, setDragging] = useState(false);
const [ask, setAsk] = useState<null | { title: string; body: string; confirm: string; danger?: boolean; resolve: (v: boolean) => void }>(null);
const [pending, setPending] = useState<Change[]>([]); // committed-but-not-pushed changelists
const [transfer, setTransfer] = useState<Transfer | null>(null); // live sync/submit file transfer
const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog
const [ctx, setCtx] = useState<null | { x: number; y: number; items: CtxItem[] }>(null); // right-click menu
const [logs, setLogs] = useState<LogEntry[]>([]); // live p4 command log (P4V-style)
const [logOpen, setLogOpen] = useState(false);
@ -296,6 +327,23 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
}).then((u) => (un = u));
return () => un?.();
}, []);
// live file-transfer progress (Get Latest / Submit) streamed from the backend
useEffect(() => {
let un: (() => void) | undefined;
listen<{ op: "sync" | "submit"; count: number; file?: string; done: boolean }>("p4-transfer", (e) => {
const p = e.payload;
if (p.done) setTransfer(null);
else setTransfer({ op: p.op, count: p.count, file: p.file });
}).then((u) => (un = u));
return () => un?.();
}, []);
// only surface the progress dialog once a transfer has run for a moment
const xferActive = transfer !== null;
useEffect(() => {
if (!xferActive) { setShowXfer(false); return; }
const id = setTimeout(() => setShowXfer(true), 400);
return () => clearTimeout(id);
}, [xferActive]);
useEffect(() => {
const close = () => setOpenMenu(null);
document.addEventListener("click", close);
@ -456,15 +504,11 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const n = pending.length;
if (!(await confirm({ title: t("Submit — send to the server"), body: t("{n} changelist(s) will go to the Perforce depot and become available to the team. This is a push — it cannot be undone.", { n }), confirm: t("Submit {n}", { n }) }))) return;
setBusy(true);
setPushing({ done: 0, total: n });
const errors: string[] = [];
let done = 0;
for (const c of pending) {
try { await p4.submitChange(c.change || ""); }
catch (e) { errors.push(`CL ${c.change}: ${String(e)}`); }
setPushing({ done: ++done, total: n });
}
setPushing(null);
if (errors.length) flash(errors.join(" · "), true);
else flash(t("Sent to the server: {n} changelist(s).", { n }));
await refresh();
@ -639,7 +683,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
<div className="statusbar">
<span className="stfront" onMouseEnter={showPeek} onMouseLeave={hidePeek}>
<span className={"stdot" + ((busy || scanning || !!pushing) ? " busy" : "")}>{(busy || scanning || !!pushing) ? <span className="ldr sm" /> : I.check}</span>
<span className={"stdot" + ((busy || scanning || !!transfer) ? " busy" : "")}>{(busy || scanning || !!transfer) ? <span className="ldr sm" /> : I.check}</span>
<span className="stlast">{logs.length ? `${logs[logs.length - 1].cmd} ${logCount(logs[logs.length - 1])}` : "p4 — ready"}</span>
</span>
<button className="stlog" onClick={() => setLogOpen((o) => !o)} title="Ctrl+L">{I.log}<span>Log</span></button>
@ -662,16 +706,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{modal && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
{ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
{pushing && (
<div className="modal-back">
<div className="push-modal">
<div className="push-title"><span className="ldr" />{t("Submit — send to the server")}</div>
<div className="push-count">{t("Changelist {a} of {b}", { a: pushing.done, b: pushing.total })}</div>
<div className="push-track"><div className="push-fill" style={{ width: `${Math.round((pushing.done / pushing.total) * 100)}%` }} /></div>
<div className="push-hint">{t("Large assets take longer to upload — dont close the window.")}</div>
</div>
</div>
)}
{showXfer && transfer && <TransferDialog transfer={transfer} />}
{ctx && <ContextMenu x={ctx.x} y={ctx.y} items={ctx.items} onClose={() => setCtx(null)} />}
{toast && <div className={"toast" + (toast.err ? " err" : "")}>{toast.text}</div>}
</div>
@ -838,6 +873,30 @@ function LogPanel({ logs, onClose, onClear }: { logs: LogEntry[]; onClose: () =>
);
}
/* ---------------- server transfer progress (P4V-style) ---------------- */
function TransferDialog({ transfer }: { transfer: Transfer }) {
const up = transfer.op === "submit";
return (
<div className="modal-back xfer-back">
<div className="xfer">
<div className="xfer-head">
<span className={"xfer-ic" + (up ? " up" : " down")}>
{up
? <svg viewBox="0 0 24 24" fill="none"><path d="M12 20V6M6 12l6-6 6 6" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>
: <svg viewBox="0 0 24 24" fill="none"><path d="M12 4v14M6 12l6 6 6-6" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</span>
<div className="xfer-ttl">
<b>{up ? t("Submitting to server") : t("Getting latest from server")}</b>
<span>{t("{n} files", { n: transfer.count })}</span>
</div>
</div>
<div className="xfer-file">{transfer.file || (up ? t("Uploading…") : t("Downloading…"))}</div>
<div className="xfer-bar"><span /></div>
</div>
</div>
);
}
/* ---------------- confirm modal ---------------- */
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
useEffect(() => {

View File

@ -27,6 +27,17 @@ const D: Record<string, Tr> = {
"Close": { ru: "Закрыть", de: "Schließen", fr: "Fermer", es: "Cerrar" },
"Theme": { ru: "Тема", de: "Thema", fr: "Thème", es: "Tema" },
"Restoring session…": { ru: "Восстановление сессии…", de: "Sitzung wird wiederhergestellt…", fr: "Restauration de la session…", es: "Restaurando sesión…" },
// startup splash
"Connecting to server…": { ru: "Подключение к серверу…", de: "Verbinde mit Server…", fr: "Connexion au serveur…", es: "Conectando al servidor…" },
"Authenticating…": { ru: "Авторизация…", de: "Authentifizierung…", fr: "Authentification…", es: "Autenticando…" },
"Loading workspace…": { ru: "Загрузка workspace…", de: "Workspace wird geladen…", fr: "Chargement du workspace…", es: "Cargando workspace…" },
"Syncing state…": { ru: "Синхронизация…", de: "Zustand wird synchronisiert…", fr: "Synchronisation…", es: "Sincronizando…" },
// server transfer dialog
"Submitting to server": { ru: "Отправка на сервер", de: "Wird an Server gesendet", fr: "Envoi au serveur", es: "Enviando al servidor" },
"Getting latest from server": { ru: "Загрузка с сервера", de: "Neueste Version wird geladen", fr: "Récupération depuis le serveur", es: "Obteniendo del servidor" },
"{n} files": { ru: "{n} файлов", de: "{n} Dateien", fr: "{n} fichiers", es: "{n} archivos" },
"Uploading…": { ru: "Отправка…", de: "Wird hochgeladen…", fr: "Envoi…", es: "Subiendo…" },
"Downloading…": { ru: "Скачивание…", de: "Wird heruntergeladen…", fr: "Téléchargement…", es: "Descargando…" },
// connect screen
"Connect to Perforce": { ru: "Подключение к Perforce", de: "Mit Perforce verbinden", fr: "Se connecter à Perforce", es: "Conectar a Perforce" },