From 15140918ad3e9943701186b838d4ae7f13220bc7 Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Tue, 7 Jul 2026 14:50:46 +0300 Subject: [PATCH] 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 --- src-tauri/src/lib.rs | 83 ++++++++++++++++++++++++++++-- src/App.css | 39 ++++++++++++++ src/App.tsx | 119 ++++++++++++++++++++++++++++++++----------- src/i18n.ts | 11 ++++ 4 files changed, 217 insertions(+), 35 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e0b98a1..13e07a7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { 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 { + 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 { 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 { 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. diff --git a/src/App.css b/src/App.css index 7e361fc..54d2311 100644 --- a/src/App.css +++ b/src/App.css @@ -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} diff --git a/src/App.tsx b/src/App.tsx index 5d20adb..b46aeda 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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((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 = ( -
-
-
{I.hex}
Exbyte Depot — Perforce
- - -
-
Восстановление сессии…
-
- ); + content = ; else if (phase === "connect") content = { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />; @@ -111,6 +104,42 @@ export default function App() { return <>{content}; } +/* ---------------- 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 ( +
+
+
{I.hex}
Exbyte Depot — Perforce
+ + +
+
+
+
+ +
+
+
+
+ {[0, 1, 2, 3, 4, 5].map((i) => )} +
+
+
+
Exbyte Depot
+
{steps[Math.min(step, steps.length - 1)]}
+
+
+
+
+ ); +} + /* ---------------- 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(null); const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null); const [history, setHistory] = useState([]); @@ -221,6 +250,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const [dragging, setDragging] = useState(false); 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 [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog const [ctx, setCtx] = useState(null); // right-click menu const [logs, setLogs] = useState([]); // 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
- {(busy || scanning || !!pushing) ? : I.check} + {(busy || scanning || !!transfer) ? : I.check} {logs.length ? `${logs[logs.length - 1].cmd} ${logCount(logs[logs.length - 1])}` : "p4 — ready"} @@ -662,16 +706,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set {modal && setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />} {ask && { ask.resolve(v); setAsk(null); }} />} - {pushing && ( -
-
-
{t("Submit — send to the server")}
-
{t("Changelist {a} of {b}", { a: pushing.done, b: pushing.total })}
-
-
{t("Large assets take longer to upload — don’t close the window.")}
-
-
- )} + {showXfer && transfer && } {ctx && setCtx(null)} />} {toast &&
{toast.text}
}
@@ -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 ( +
+
+
+ + {up + ? + : } + +
+ {up ? t("Submitting to server") : t("Getting latest from server")} + {t("{n} files", { n: transfer.count })} +
+
+
{transfer.file || (up ? t("Uploading…") : t("Downloading…"))}
+
+
+
+ ); +} + /* ---------------- confirm modal ---------------- */ function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) { useEffect(() => { diff --git a/src/i18n.ts b/src/i18n.ts index 87e1bd1..3dc72cf 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -27,6 +27,17 @@ const D: Record = { "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" },