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:
@ -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.
|
||||
|
||||
39
src/App.css
39
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}
|
||||
|
||||
119
src/App.tsx
119
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<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 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 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 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 — don’t 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(() => {
|
||||
|
||||
11
src/i18n.ts
11
src/i18n.ts
@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user