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:
876
landing/index.html
Normal file
876
landing/index.html
Normal file
@ -0,0 +1,876 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Exbyte Depot — Perforce for Unreal teams, finally pleasant</title>
|
||||||
|
<meta name="description" content="Exbyte Depot is a fast native Perforce client built for Unreal Engine teams: 3D asset previews, one-click exclusive locks, offline work, and live teammate activity. All the power of Helix Core, none of the P4V pain." />
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#0a0a0f; --bg-2:#0d0d14; --panel:#12121b; --panel-2:#16161f; --elevated:#1a1a24;
|
||||||
|
--line:rgba(255,255,255,.08); --line-2:rgba(255,255,255,.05);
|
||||||
|
--txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
|
||||||
|
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0;
|
||||||
|
--ok:#3ecf8e; --warn:#e8b04b; --del:#f2637e;
|
||||||
|
--maxw:1180px;
|
||||||
|
--font:"Segoe UI Variable Display","Segoe UI",system-ui,-apple-system,Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||||
|
--mono:"Cascadia Code","JetBrains Mono",ui-monospace,"SF Mono",Consolas,monospace;
|
||||||
|
--shadow:0 40px 120px -40px rgba(0,0,0,.8);
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html{scroll-behavior:smooth}
|
||||||
|
body{
|
||||||
|
font-family:var(--font);background:var(--bg);color:var(--txt);
|
||||||
|
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
|
||||||
|
line-height:1.6;overflow-x:hidden;
|
||||||
|
}
|
||||||
|
/* ambient background */
|
||||||
|
body::before{
|
||||||
|
content:"";position:fixed;inset:0;z-index:-2;pointer-events:none;
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 640px at 78% -6%,rgba(124,110,246,.22),transparent 60%),
|
||||||
|
radial-gradient(760px 620px at 6% 8%,rgba(91,78,224,.16),transparent 58%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
body::after{
|
||||||
|
content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;opacity:.5;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255,255,255,.020) 1px,transparent 1px),
|
||||||
|
linear-gradient(90deg,rgba(255,255,255,.020) 1px,transparent 1px);
|
||||||
|
background-size:46px 46px;
|
||||||
|
-webkit-mask-image:radial-gradient(1200px 800px at 60% 0%,#000 30%,transparent 78%);
|
||||||
|
mask-image:radial-gradient(1200px 800px at 60% 0%,#000 30%,transparent 78%);
|
||||||
|
}
|
||||||
|
a{color:inherit;text-decoration:none}
|
||||||
|
.tlink{color:var(--acc-2);text-decoration:underline;text-underline-offset:2px;text-decoration-color:rgba(157,147,255,.45);transition:.15s}
|
||||||
|
.tlink:hover{text-decoration-color:var(--acc-2)}
|
||||||
|
/* "works with your Perforce server" reassurance band */
|
||||||
|
.clarify{display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap;text-align:center;
|
||||||
|
max-width:760px;margin:26px auto 0;padding:14px 20px;border-radius:14px;
|
||||||
|
background:var(--panel);border:1px solid var(--line);color:var(--muted);font-size:14px;line-height:1.55}
|
||||||
|
.clarify svg{width:18px;height:18px;color:var(--acc-2);flex:0 0 auto}
|
||||||
|
.clarify b{color:var(--txt);font-weight:600}
|
||||||
|
.wrap{width:100%;max-width:var(--maxw);margin:0 auto;padding:0 24px}
|
||||||
|
.eyebrow{display:inline-flex;align-items:center;gap:8px;font-size:12.5px;font-weight:600;letter-spacing:.08em;
|
||||||
|
text-transform:uppercase;color:var(--acc-2);background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.28);
|
||||||
|
padding:6px 13px;border-radius:100px}
|
||||||
|
.eyebrow .dot{width:6px;height:6px;border-radius:50%;background:var(--acc-2);box-shadow:0 0 10px var(--acc-2)}
|
||||||
|
h1,h2,h3{line-height:1.08;letter-spacing:-.022em;font-weight:700;text-wrap:balance}
|
||||||
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:9px;font-family:inherit;font-size:15px;
|
||||||
|
font-weight:600;cursor:pointer;border-radius:12px;padding:13px 22px;border:1px solid transparent;transition:.18s ease;white-space:nowrap}
|
||||||
|
.btn.primary{color:#fff;background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));
|
||||||
|
box-shadow:0 14px 34px -14px var(--acc),inset 0 1px 0 rgba(255,255,255,.22)}
|
||||||
|
.btn.primary:hover{transform:translateY(-1px);box-shadow:0 20px 44px -14px var(--acc),inset 0 1px 0 rgba(255,255,255,.3)}
|
||||||
|
.btn.ghost{color:var(--txt);background:rgba(255,255,255,.04);border-color:var(--line)}
|
||||||
|
.btn.ghost:hover{background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.18)}
|
||||||
|
.btn svg{width:17px;height:17px}
|
||||||
|
:focus-visible{outline:2px solid var(--acc-2);outline-offset:2px;border-radius:8px}
|
||||||
|
|
||||||
|
/* ---------- nav ---------- */
|
||||||
|
header.nav{position:sticky;top:0;z-index:50;backdrop-filter:blur(14px);
|
||||||
|
background:linear-gradient(180deg,rgba(10,10,15,.86),rgba(10,10,15,.55));border-bottom:1px solid var(--line-2)}
|
||||||
|
.nav-in{display:flex;align-items:center;gap:20px;height:66px}
|
||||||
|
.brand{display:flex;align-items:center;gap:11px;font-weight:700;font-size:16.5px;letter-spacing:-.01em}
|
||||||
|
.brand .mark{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;color:#fff;
|
||||||
|
background:linear-gradient(140deg,var(--acc),var(--acc-deep));box-shadow:0 8px 22px -8px var(--acc),inset 0 1px 0 rgba(255,255,255,.35)}
|
||||||
|
.brand .mark svg{width:19px;height:19px}
|
||||||
|
.brand small{color:var(--faint);font-weight:500}
|
||||||
|
.nav-links{display:flex;gap:26px;margin-left:18px}
|
||||||
|
.nav-links a{font-size:14.5px;color:var(--muted);font-weight:500;transition:.15s}
|
||||||
|
.nav-links a:hover{color:var(--txt)}
|
||||||
|
.nav-cta{margin-left:auto;display:flex;align-items:center;gap:12px}
|
||||||
|
|
||||||
|
/* ---------- hero ---------- */
|
||||||
|
.hero{padding:74px 0 40px;text-align:center}
|
||||||
|
.hero h1{font-size:clamp(38px,6vw,68px);margin:22px auto 0;max-width:16ch}
|
||||||
|
.hero h1 .g{background:linear-gradient(120deg,var(--acc-2),#c9c2ff 60%,var(--acc));-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||||
|
.hero p.sub{font-size:clamp(16px,2vw,19px);color:var(--muted);max-width:60ch;margin:22px auto 0;line-height:1.62}
|
||||||
|
.waitlist{margin:34px auto 0;max-width:520px}
|
||||||
|
.wl-form{display:flex;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:7px;
|
||||||
|
box-shadow:var(--shadow)}
|
||||||
|
.wl-form input{flex:1;min-width:0;background:transparent;border:none;outline:none;color:var(--txt);font-family:inherit;
|
||||||
|
font-size:15px;padding:12px 14px}
|
||||||
|
.wl-form input::placeholder{color:var(--faint)}
|
||||||
|
.wl-note{margin-top:12px;font-size:13px;color:var(--faint);display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap}
|
||||||
|
.wl-note b{color:var(--muted);font-weight:600}
|
||||||
|
.wl-ok{display:none;align-items:center;gap:12px;justify-content:center;background:rgba(62,207,142,.10);
|
||||||
|
border:1px solid rgba(62,207,142,.32);color:#8ff0c4;border-radius:15px;padding:18px 20px;font-size:15px;box-shadow:var(--shadow)}
|
||||||
|
.wl-ok.show{display:flex}
|
||||||
|
.wl-ok svg{width:22px;height:22px;flex:0 0 auto}
|
||||||
|
.avatars{display:flex;justify-content:center;margin-top:22px;align-items:center;gap:12px;color:var(--faint);font-size:13.5px}
|
||||||
|
.stack{display:flex}
|
||||||
|
.stack span{width:28px;height:28px;border-radius:50%;border:2px solid var(--bg);margin-left:-8px;
|
||||||
|
display:grid;place-items:center;font-size:11px;font-weight:700;color:#fff}
|
||||||
|
|
||||||
|
/* ---------- app mock ---------- */
|
||||||
|
.mock-shell{margin:56px 0 0;width:min(1280px,95vw);max-width:1280px;perspective:1800px;
|
||||||
|
position:relative;left:50%;transform:translateX(-50%)}
|
||||||
|
.mock{border-radius:16px;overflow:hidden;border:1px solid var(--line);background:var(--panel);
|
||||||
|
box-shadow:var(--shadow),0 0 0 1px rgba(124,110,246,.10);transform:rotateX(2.5deg);transform-origin:center top}
|
||||||
|
.m-title{display:flex;align-items:center;gap:12px;height:46px;padding:0 14px;background:var(--panel-2);border-bottom:1px solid var(--line)}
|
||||||
|
.m-title .mark{width:22px;height:22px;border-radius:6px;display:grid;place-items:center;color:#fff;background:linear-gradient(140deg,var(--acc),var(--acc-deep))}
|
||||||
|
.m-title .mark svg{width:13px;height:13px}
|
||||||
|
.m-title .t{font-size:13px;font-weight:600}.m-title .t span{color:var(--faint);font-weight:500}
|
||||||
|
.m-menu{display:flex;gap:16px;margin-left:10px;font-size:12.5px;color:var(--muted)}
|
||||||
|
.m-pill{margin-left:auto;display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--muted);
|
||||||
|
background:rgba(62,207,142,.10);border:1px solid rgba(62,207,142,.28);padding:4px 11px;border-radius:100px}
|
||||||
|
.m-pill .d{width:6px;height:6px;border-radius:50%;background:var(--ok);box-shadow:0 0 8px var(--ok)}
|
||||||
|
.m-body{display:grid;grid-template-columns:340px 1fr;min-height:376px}
|
||||||
|
.m-left{border-right:1px solid var(--line);display:flex;flex-direction:column}
|
||||||
|
.m-tabs{display:flex;gap:20px;padding:14px 16px 0;font-size:13px;color:var(--faint)}
|
||||||
|
.m-tabs .on{color:var(--txt);font-weight:600;position:relative;padding-bottom:12px}
|
||||||
|
.m-tabs .on::after{content:"";position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--acc);border-radius:2px}
|
||||||
|
.m-tabs .badge{background:rgba(255,255,255,.08);color:var(--muted);border-radius:8px;padding:0 7px;font-size:11px;margin-left:5px}
|
||||||
|
.m-list{padding:12px 12px 0;display:flex;flex-direction:column;gap:7px}
|
||||||
|
.m-file{display:flex;align-items:center;gap:10px;padding:8px 9px;border-radius:10px}
|
||||||
|
.m-file:hover{background:rgba(255,255,255,.03)}
|
||||||
|
.m-file.sel{background:rgba(124,110,246,.12);box-shadow:inset 0 0 0 1px rgba(124,110,246,.25)}
|
||||||
|
.m-chk{width:16px;height:16px;border-radius:5px;background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));display:grid;place-items:center;flex:0 0 auto}
|
||||||
|
.m-chk svg{width:10px;height:10px;color:#fff}
|
||||||
|
.m-ico{width:26px;height:26px;border-radius:7px;background:var(--panel-2);border:1px solid var(--line);display:grid;place-items:center;color:var(--acc-2);flex:0 0 auto}
|
||||||
|
.m-ico svg{width:14px;height:14px}
|
||||||
|
.m-fmeta{min-width:0}
|
||||||
|
.m-fmeta .n{font-size:12.5px;font-weight:600;white-space:nowrap}
|
||||||
|
.m-fmeta .p{font-size:10.5px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px}
|
||||||
|
.m-plus{margin-left:auto;color:var(--ok)}
|
||||||
|
.m-commit{margin-top:auto;padding:14px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:9px}
|
||||||
|
.m-input{background:var(--panel-2);border:1px solid var(--line);border-radius:9px;padding:9px 11px;font-size:12px;color:var(--muted)}
|
||||||
|
.m-submit{background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));color:#fff;border-radius:10px;padding:11px;
|
||||||
|
font-size:12.5px;font-weight:600;text-align:center;display:flex;align-items:center;justify-content:center;gap:7px}
|
||||||
|
.m-stage{position:relative;display:grid;place-items:center;
|
||||||
|
background:
|
||||||
|
radial-gradient(520px 380px at 50% 38%,rgba(124,110,246,.12),transparent 62%),
|
||||||
|
repeating-linear-gradient(0deg,transparent,transparent 31px,rgba(255,255,255,.02) 31px,rgba(255,255,255,.02) 32px),
|
||||||
|
repeating-linear-gradient(90deg,transparent,transparent 31px,rgba(255,255,255,.02) 31px,rgba(255,255,255,.02) 32px),
|
||||||
|
var(--bg-2)}
|
||||||
|
.m-stage .lab{position:absolute;top:14px;left:14px;font-size:11px;color:var(--muted);background:rgba(0,0,0,.4);
|
||||||
|
border:1px solid var(--line);border-radius:100px;padding:5px 11px;display:flex;align-items:center;gap:7px}
|
||||||
|
.m-cube{width:120px;height:120px;color:var(--acc-2);filter:drop-shadow(0 20px 40px rgba(124,110,246,.4));animation:float 5s ease-in-out infinite}
|
||||||
|
@keyframes float{0%,100%{transform:translateY(0) rotate(-4deg)}50%{transform:translateY(-12px) rotate(4deg)}}
|
||||||
|
.m-load{position:absolute;bottom:56px;left:50%;transform:translateX(-50%);
|
||||||
|
background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));color:#fff;border-radius:10px;padding:10px 18px;
|
||||||
|
font-size:12.5px;font-weight:600;display:flex;align-items:center;gap:8px;box-shadow:0 14px 30px -12px var(--acc)}
|
||||||
|
/* titlebar extras */
|
||||||
|
.m-title .m-actions{display:flex;align-items:center;gap:6px;margin-left:14px}
|
||||||
|
.m-ib{width:26px;height:26px;border-radius:7px;display:grid;place-items:center;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line-2)}
|
||||||
|
.m-ib svg{width:14px;height:14px}
|
||||||
|
.m-wc{display:flex;gap:9px;margin-left:12px}
|
||||||
|
.m-wc i{width:12px;height:12px;border-radius:50%;display:block}
|
||||||
|
.m-wc .r{background:#ff5f57}.m-wc .y{background:#febc2e}.m-wc .g{background:#28c840}
|
||||||
|
/* second header row: workspace / working folder / get latest */
|
||||||
|
.m-head{display:grid;grid-template-columns:1fr 1fr 190px;border-bottom:1px solid var(--line)}
|
||||||
|
.m-zone{display:flex;align-items:center;gap:11px;padding:12px 16px;border-right:1px solid var(--line);min-width:0}
|
||||||
|
.m-zone .zi{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;color:var(--acc-2);background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.2);flex:0 0 auto}
|
||||||
|
.m-zone .zi svg{width:16px;height:16px}
|
||||||
|
.m-zone .zm{min-width:0}
|
||||||
|
.m-zone .zm .l{font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint)}
|
||||||
|
.m-zone .zm .v{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.m-zone .cv{margin-left:auto;color:var(--faint);flex:0 0 auto}
|
||||||
|
.m-zone.get{border-right:none;cursor:default}
|
||||||
|
.m-zone.get .zi{color:var(--ok);background:rgba(62,207,142,.10);border-color:rgba(62,207,142,.22)}
|
||||||
|
.m-zone.get .v{color:var(--txt)}.m-zone.get .l{color:var(--faint)}
|
||||||
|
/* left column bits */
|
||||||
|
.m-filter{margin:12px 12px 4px;display:flex;align-items:center;gap:8px;background:var(--panel-2);border:1px solid var(--line);border-radius:9px;padding:8px 11px;color:var(--faint);font-size:12px}
|
||||||
|
.m-filter svg{width:14px;height:14px}
|
||||||
|
.m-selhead{display:flex;align-items:center;gap:9px;padding:8px 16px 6px;font-size:11.5px;color:var(--muted)}
|
||||||
|
.m-selhead .sc{width:15px;height:15px;border-radius:5px;background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));display:grid;place-items:center;flex:0 0 auto}
|
||||||
|
.m-selhead .sc svg{width:9px;height:9px;color:#fff}
|
||||||
|
.m-selhead .rf{margin-left:auto;display:flex;align-items:center;gap:5px;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:11px}
|
||||||
|
.m-selhead .rf svg{width:12px;height:12px}
|
||||||
|
.m-stat{width:18px;height:18px;border-radius:5px;display:grid;place-items:center;font-size:10px;font-weight:700;flex:0 0 auto}
|
||||||
|
.m-stat.a{color:var(--ok);background:rgba(62,207,142,.14)}
|
||||||
|
.m-plus{margin-left:auto}
|
||||||
|
.ai-btn{width:34px;height:34px;flex:0 0 auto;border-radius:9px;display:grid;place-items:center;color:var(--acc-2);background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.24)}
|
||||||
|
.ai-btn svg{width:16px;height:16px}
|
||||||
|
.m-summrow{display:flex;gap:8px;align-items:stretch}
|
||||||
|
.m-summrow .m-input{flex:1}
|
||||||
|
/* right: diff head + meta bar */
|
||||||
|
.m-right{display:flex;flex-direction:column;min-width:0}
|
||||||
|
.m-diffhead{display:flex;align-items:center;gap:11px;padding:12px 16px;border-bottom:1px solid var(--line)}
|
||||||
|
.m-diffhead .ttl{min-width:0}
|
||||||
|
.m-diffhead .ttl .n{font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.m-diffhead .ttl .n span{color:var(--faint);font-weight:500}
|
||||||
|
.m-diffhead .ttl .mm{font-size:11px;color:var(--faint)}
|
||||||
|
.m-diffhead .mrev{color:var(--acc-2)}
|
||||||
|
.m-revert{margin-left:auto;display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:8px;padding:6px 12px;flex:0 0 auto}
|
||||||
|
.m-revert svg{width:13px;height:13px}
|
||||||
|
.m-stage{flex:1;position:relative}
|
||||||
|
.m-metabar{border-top:1px solid var(--line);padding:12px 18px;display:grid;grid-template-columns:repeat(4,1fr);gap:10px;background:rgba(0,0,0,.16)}
|
||||||
|
.m-metabar .mi .k{font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint)}
|
||||||
|
.m-metabar .mi .v{font-size:13px;font-weight:600;margin-top:3px}
|
||||||
|
.m-metabar .mi .v.acc{color:var(--acc-2)}
|
||||||
|
/* bottom status bar */
|
||||||
|
.m-status{display:flex;align-items:center;gap:9px;height:34px;padding:0 14px;border-top:1px solid var(--line);background:var(--panel-2);font-size:11px;color:var(--faint)}
|
||||||
|
.m-status .cmd{font-family:var(--mono);color:var(--muted)}
|
||||||
|
.m-status .ok{color:var(--ok)}
|
||||||
|
.m-status .chips{margin-left:auto;display:flex;gap:7px}
|
||||||
|
.m-status .chips b{display:flex;align-items:center;gap:5px;font-weight:500;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line-2);border-radius:7px;padding:3px 9px}
|
||||||
|
.m-status .chips b svg{width:12px;height:12px}
|
||||||
|
@media (max-width:900px){
|
||||||
|
.m-head{grid-template-columns:1fr}
|
||||||
|
.m-zone{border-right:none;border-bottom:1px solid var(--line)}
|
||||||
|
.m-zone.get{display:none}
|
||||||
|
.m-metabar{grid-template-columns:1fr 1fr}
|
||||||
|
}
|
||||||
|
/* ---- interactive mock: hover / active / press ---- */
|
||||||
|
.m-menu span,.m-file,.m-tabs span,.m-submit,.m-load,.m-zone.get,.m-ib,.m-selhead .rf,
|
||||||
|
.m-revert,.m-chk,.ai-btn,.m-status .chips b,.m-wc i{cursor:pointer}
|
||||||
|
.m-menu span{padding:3px 6px;border-radius:6px;transition:.14s}
|
||||||
|
.m-menu span:hover{background:rgba(255,255,255,.06);color:var(--txt)}
|
||||||
|
.m-tabs span:hover{color:var(--txt)}
|
||||||
|
.m-ib{transition:.14s}.m-ib:hover{color:var(--txt);background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.16)}
|
||||||
|
.m-wc i{transition:.14s}.m-wc i:hover{filter:brightness(1.25)}
|
||||||
|
.m-file{transition:background .14s,box-shadow .14s;user-select:none}
|
||||||
|
.m-file:active{transform:translateY(.5px)}
|
||||||
|
.m-chk{transition:.14s}
|
||||||
|
.m-file.off .m-chk{background:transparent;border:1.5px solid var(--faint);box-shadow:none}
|
||||||
|
.m-file.off .m-chk svg{display:none}
|
||||||
|
.m-selhead .rf,.m-revert{transition:.14s}
|
||||||
|
.m-selhead .rf:hover,.m-revert:hover{color:var(--txt);border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.07)}
|
||||||
|
.m-selhead .rf.spin svg{animation:mspin .7s linear infinite}
|
||||||
|
.ai-btn{transition:.14s}.ai-btn:hover{background:rgba(124,110,246,.18);border-color:rgba(124,110,246,.4)}
|
||||||
|
.m-zone.get{transition:.14s;border-radius:0}.m-zone.get:hover{background:rgba(62,207,142,.06)}
|
||||||
|
.m-zone.get.spin .zi svg{animation:mspin .7s linear infinite}
|
||||||
|
.m-submit,.m-load{transition:transform .12s,box-shadow .14s,filter .14s}
|
||||||
|
.m-submit:hover,.m-load:hover{filter:brightness(1.08)}
|
||||||
|
.m-submit:active,.m-load:active{transform:scale(.98)}
|
||||||
|
.m-load:hover{transform:translateX(-50%) scale(1.03)}
|
||||||
|
.m-load:active{transform:translateX(-50%) scale(.97)}
|
||||||
|
.m-status .chips b{transition:.14s}.m-status .chips b:hover{color:var(--txt);border-color:var(--line)}
|
||||||
|
@keyframes mspin{to{transform:rotate(360deg)}}
|
||||||
|
.m-spinner{width:14px;height:14px;border:2px solid rgba(255,255,255,.4);border-top-color:#fff;border-radius:50%;
|
||||||
|
animation:mspin .7s linear infinite;display:inline-block}
|
||||||
|
/* preview variants */
|
||||||
|
.pv{position:absolute;inset:0;display:grid;place-items:center}
|
||||||
|
.pv[hidden]{display:none}
|
||||||
|
.m-cube.loaded{animation:mrotate 7s linear infinite}
|
||||||
|
@keyframes mrotate{to{transform:rotateY(360deg)}}
|
||||||
|
.pv-mesh{perspective:900px}
|
||||||
|
.tex-swatch{width:min(62%,300px);aspect-ratio:1;border-radius:14px;box-shadow:0 24px 60px -24px #000;border:1px solid var(--line);
|
||||||
|
background:
|
||||||
|
repeating-conic-gradient(from 45deg,#2a2733 0deg 90deg,#211f2a 90deg 180deg) 0 0/26px 26px,
|
||||||
|
radial-gradient(120% 120% at 30% 20%,rgba(124,110,246,.25),transparent 60%)}
|
||||||
|
.tex-cap{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);font-size:11px;color:var(--muted);
|
||||||
|
background:rgba(0,0,0,.4);border:1px solid var(--line);border-radius:100px;padding:5px 12px}
|
||||||
|
.pv-code{align-items:stretch;justify-items:stretch;padding:0}
|
||||||
|
.codeblk{width:100%;height:100%;overflow:hidden;font-family:var(--mono);font-size:12px;line-height:1.75;
|
||||||
|
padding:16px 18px;color:#c7c7d6;text-align:left;background:var(--bg-2)}
|
||||||
|
.codeblk .ln{display:block;white-space:pre}
|
||||||
|
.codeblk .kw{color:#9d93ff}.codeblk .ty{color:#5cc9c0}.codeblk .fn{color:#e8b04b}.codeblk .cm{color:#5f5f72;font-style:italic}.codeblk .st{color:#8ff0c4}
|
||||||
|
/* only one body panel visible at a time (beats inline/class display) */
|
||||||
|
.m-panel[hidden]{display:none!important}
|
||||||
|
/* history list (History tab) */
|
||||||
|
.m-hist{padding:10px 12px;display:flex;flex-direction:column;gap:4px;overflow:auto}
|
||||||
|
.m-hrow{display:flex;align-items:center;gap:11px;padding:9px 10px;border-radius:10px;cursor:pointer;transition:.14s}
|
||||||
|
.m-hrow:hover{background:rgba(255,255,255,.04)}
|
||||||
|
.m-hav{width:26px;height:26px;border-radius:50%;flex:0 0 auto;display:grid;place-items:center;font-size:10px;font-weight:700;color:#fff}
|
||||||
|
.m-hb{min-width:0;flex:1}
|
||||||
|
.m-hb .d{font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.m-hb .mt{font-size:10.5px;color:var(--faint)}
|
||||||
|
.m-hsz{font-size:10.5px;font-weight:600;color:var(--muted);background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:1px 7px;white-space:nowrap}
|
||||||
|
|
||||||
|
/* ---------- logos / trust ---------- */
|
||||||
|
.trust{padding:44px 0 8px;text-align:center}
|
||||||
|
.trust p{font-size:12.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:20px}
|
||||||
|
.trust-row{display:flex;flex-wrap:wrap;justify-content:center;gap:14px}
|
||||||
|
.chip{display:inline-flex;align-items:center;gap:8px;font-size:13.5px;color:var(--muted);font-weight:500;
|
||||||
|
background:var(--panel);border:1px solid var(--line);border-radius:100px;padding:9px 15px}
|
||||||
|
.chip svg{width:15px;height:15px;color:var(--acc-2)}
|
||||||
|
|
||||||
|
/* ---------- section headers ---------- */
|
||||||
|
section{padding:86px 0}
|
||||||
|
.sec-head{max-width:720px;margin:0 auto 46px;text-align:center}
|
||||||
|
.sec-head h2{font-size:clamp(28px,4vw,42px)}
|
||||||
|
.sec-head p{color:var(--muted);font-size:17px;margin-top:14px;line-height:1.6}
|
||||||
|
|
||||||
|
/* ---------- features grid ---------- */
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
|
||||||
|
.card{background:linear-gradient(180deg,var(--panel),var(--bg-2));border:1px solid var(--line);border-radius:16px;
|
||||||
|
padding:26px 24px;transition:.2s ease;position:relative;overflow:hidden}
|
||||||
|
.card:hover{border-color:rgba(124,110,246,.4);transform:translateY(-3px)}
|
||||||
|
.card:hover::before{opacity:1}
|
||||||
|
.card::before{content:"";position:absolute;inset:0;opacity:0;transition:.25s;pointer-events:none;
|
||||||
|
background:radial-gradient(340px 180px at 20% 0%,rgba(124,110,246,.12),transparent 70%)}
|
||||||
|
.card .ic{width:44px;height:44px;border-radius:12px;display:grid;place-items:center;color:var(--acc-2);
|
||||||
|
background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.24);margin-bottom:18px}
|
||||||
|
.card .ic svg{width:22px;height:22px}
|
||||||
|
.card h3{font-size:18px;letter-spacing:-.01em}
|
||||||
|
.card p{color:var(--muted);font-size:14.5px;margin-top:9px;line-height:1.6}
|
||||||
|
.card .tag{position:absolute;top:20px;right:20px;font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
|
||||||
|
color:var(--acc-2);background:rgba(124,110,246,.12);border:1px solid rgba(124,110,246,.28);border-radius:7px;padding:3px 7px}
|
||||||
|
|
||||||
|
/* ---------- spotlight (why not p4v) ---------- */
|
||||||
|
.split{display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center}
|
||||||
|
.split h2{font-size:clamp(26px,3.4vw,38px)}
|
||||||
|
.split .lead{color:var(--muted);font-size:16.5px;margin-top:16px}
|
||||||
|
.comp{display:flex;flex-direction:column;gap:10px;margin-top:26px}
|
||||||
|
.crow{display:flex;align-items:flex-start;gap:13px;padding:14px 16px;border-radius:13px;background:var(--panel);border:1px solid var(--line)}
|
||||||
|
.crow .ck{width:22px;height:22px;border-radius:7px;display:grid;place-items:center;flex:0 0 auto;margin-top:1px}
|
||||||
|
.crow.good .ck{background:rgba(62,207,142,.14);color:var(--ok)}
|
||||||
|
.crow .ck svg{width:13px;height:13px}
|
||||||
|
.crow b{font-size:14.5px}.crow span{color:var(--muted);font-size:13.5px;display:block;margin-top:2px}
|
||||||
|
.panel-visual{background:linear-gradient(180deg,var(--panel),var(--bg-2));border:1px solid var(--line);border-radius:18px;
|
||||||
|
padding:22px;box-shadow:var(--shadow)}
|
||||||
|
.lock-item{display:flex;align-items:center;gap:12px;padding:12px;border-radius:11px;background:var(--panel-2);border:1px solid var(--line);margin-bottom:9px}
|
||||||
|
.lock-item:last-child{margin-bottom:0}
|
||||||
|
.lock-badge{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;flex:0 0 auto}
|
||||||
|
.lock-badge.red{background:rgba(242,99,126,.12);color:var(--del)}
|
||||||
|
.lock-badge.gr{background:rgba(62,207,142,.12);color:var(--ok)}
|
||||||
|
.lock-badge svg{width:15px;height:15px}
|
||||||
|
.lock-item .li-b{min-width:0}
|
||||||
|
.lock-item .li-b .n{font-size:13.5px;font-weight:600}
|
||||||
|
.lock-item .li-b .p{font-size:11.5px;color:var(--faint)}
|
||||||
|
.lock-item .who{margin-left:auto;font-size:11.5px;color:var(--muted);white-space:nowrap}
|
||||||
|
|
||||||
|
/* ---------- tech strip ---------- */
|
||||||
|
.tech{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;text-align:center}
|
||||||
|
.tech .t{padding:24px 16px;border-radius:14px;background:var(--panel);border:1px solid var(--line)}
|
||||||
|
.tech .t .k{font-size:30px;font-weight:800;letter-spacing:-.02em;
|
||||||
|
background:linear-gradient(120deg,var(--acc-2),#cfc9ff);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||||
|
.tech .t .v{color:var(--muted);font-size:13px;margin-top:6px}
|
||||||
|
|
||||||
|
/* ---------- final CTA ---------- */
|
||||||
|
.cta{position:relative;text-align:center;border-radius:26px;padding:64px 28px;overflow:hidden;
|
||||||
|
background:linear-gradient(180deg,rgba(124,110,246,.14),rgba(91,78,224,.05));border:1px solid rgba(124,110,246,.28)}
|
||||||
|
.cta::before{content:"";position:absolute;inset:0;z-index:-1;
|
||||||
|
background:radial-gradient(600px 300px at 50% 0%,rgba(124,110,246,.25),transparent 70%)}
|
||||||
|
.cta h2{font-size:clamp(28px,4vw,44px)}
|
||||||
|
.cta p{color:var(--muted);font-size:17px;margin:16px auto 30px;max-width:52ch}
|
||||||
|
.cta .waitlist{margin-top:0}
|
||||||
|
|
||||||
|
/* ---------- footer ---------- */
|
||||||
|
footer{border-top:1px solid var(--line-2);padding:40px 0;margin-top:20px}
|
||||||
|
.foot{display:flex;align-items:center;gap:16px;flex-wrap:wrap;color:var(--faint);font-size:13.5px}
|
||||||
|
.foot .brand{font-size:15px}
|
||||||
|
.foot .fl{margin-left:auto;display:flex;gap:22px}
|
||||||
|
.foot a:hover{color:var(--muted)}
|
||||||
|
|
||||||
|
@media (max-width:900px){
|
||||||
|
.nav-links{display:none}
|
||||||
|
.m-body{grid-template-columns:1fr}
|
||||||
|
.m-left{border-right:none;border-bottom:1px solid var(--line)}
|
||||||
|
.m-stage{min-height:260px}
|
||||||
|
.grid{grid-template-columns:1fr 1fr}
|
||||||
|
.split{grid-template-columns:1fr;gap:34px}
|
||||||
|
.tech{grid-template-columns:1fr 1fr}
|
||||||
|
}
|
||||||
|
@media (max-width:560px){
|
||||||
|
.grid{grid-template-columns:1fr}
|
||||||
|
.wl-form{flex-direction:column}
|
||||||
|
.wl-form .btn{width:100%}
|
||||||
|
.hero{padding:48px 0 30px}
|
||||||
|
.mock{transform:none}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ===================== NAV ===================== -->
|
||||||
|
<header class="nav">
|
||||||
|
<div class="wrap nav-in">
|
||||||
|
<a class="brand" href="#top">
|
||||||
|
<span class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg></span>
|
||||||
|
Exbyte Depot <small>— Perforce</small>
|
||||||
|
</a>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="#features">Features</a>
|
||||||
|
<a href="#why">Why not P4V</a>
|
||||||
|
<a href="#tech">Under the hood</a>
|
||||||
|
</nav>
|
||||||
|
<div class="nav-cta">
|
||||||
|
<a class="btn ghost" href="#waitlist">Join the waitlist</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<a id="top"></a>
|
||||||
|
<!-- ===================== HERO ===================== -->
|
||||||
|
<section class="hero">
|
||||||
|
<div class="wrap">
|
||||||
|
<span class="eyebrow"><span class="dot"></span> A desktop client for Perforce® · by Exbyte Studios</span>
|
||||||
|
<h1>The <a class="g" href="https://www.perforce.com/products/helix-core" target="_blank" rel="noopener">Perforce</a> client your <span class="g">Unreal team</span> actually wants.</h1>
|
||||||
|
<p class="sub">Exbyte Depot is a fast, native desktop <b style="color:var(--txt)">client for <a class="tlink" href="https://www.perforce.com/products/helix-core" target="_blank" rel="noopener">Perforce Helix Core</a></b> — real 3D asset previews, one-click exclusive locks, offline work, and live teammate activity. All the power of your Perforce server, none of the P4V pain.</p>
|
||||||
|
|
||||||
|
<div class="clarify">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="m9 12 2 2 4-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<span><b>It's a client, not a replacement.</b> Exbyte Depot connects to your existing <a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce</a> server through Perforce's own <span style="font-family:var(--mono);font-size:13px">p4</span> command-line — your server, history and workflow stay exactly as they are. We don't clone Perforce or reverse-engineer anything; we just give you a nicer window into it.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="waitlist" id="waitlist">
|
||||||
|
<form class="wl-form" id="wlForm" novalidate>
|
||||||
|
<input id="wlEmail" type="email" inputmode="email" autocomplete="email" placeholder="you@studio.com" aria-label="Email address" required />
|
||||||
|
<button class="btn primary" type="submit">
|
||||||
|
Join the waitlist
|
||||||
|
<svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14m-6-6 6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="wl-ok" id="wlOk">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.7"/><path d="m8.5 12 2.5 2.5 4.5-5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<span id="wlOkMsg">You're on the list. We'll email you the moment early access opens.</span>
|
||||||
|
</div>
|
||||||
|
<div class="wl-note"><b>Windows</b> · early access · no spam, one launch email · <b id="wlCount">—</b> on the list</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ APP MOCK ============ -->
|
||||||
|
<div class="mock-shell">
|
||||||
|
<div class="mock">
|
||||||
|
<!-- titlebar -->
|
||||||
|
<div class="m-title">
|
||||||
|
<span class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg></span>
|
||||||
|
<span class="t">Exbyte Depot <span>— Perforce</span></span>
|
||||||
|
<span class="m-menu"><span>File</span><span>Connection</span><span>Actions</span><span>Window</span><span>Tools</span><span>Help</span></span>
|
||||||
|
<span class="m-pill"><span class="d"></span> helix.auroragames.dev:1666</span>
|
||||||
|
<span class="m-actions">
|
||||||
|
<span class="m-ib"><svg viewBox="0 0 24 24" fill="none"><path d="M16 20v-1a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v1M9.5 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm11 9v-1a4 4 0 0 0-3-3.9M16 4.1a4 4 0 0 1 0 7.8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
|
||||||
|
<span class="m-ib"><svg viewBox="0 0 24 24" fill="none"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||||
|
</span>
|
||||||
|
<span class="m-wc"><i class="g"></i><i class="y"></i><i class="r"></i></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- workspace / working folder / get latest -->
|
||||||
|
<div class="m-head">
|
||||||
|
<div class="m-zone">
|
||||||
|
<span class="zi"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 20h8m-4-3v3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
|
||||||
|
<span class="zm"><span class="l">Workspace</span><span class="v">kestrel_WS_02</span></span>
|
||||||
|
<span class="cv"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||||
|
</div>
|
||||||
|
<div class="m-zone">
|
||||||
|
<span class="zi"><svg viewBox="0 0 24 24" fill="none"><path d="M6 3v12a3 3 0 0 0 3 3h6M6 3a3 3 0 1 0 0 6M6 3a3 3 0 1 1 0 6m9 9a3 3 0 1 0 6 0 3 3 0 0 0-6 0Z" stroke="currentColor" stroke-width="1.6"/></svg></span>
|
||||||
|
<span class="zm"><span class="l">Working folder</span><span class="v">//depot/Aurora_Road</span></span>
|
||||||
|
<span class="cv"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||||
|
</div>
|
||||||
|
<div class="m-zone get">
|
||||||
|
<span class="zi"><svg viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 1 1-3-6.7M21 4v4h-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||||
|
<span class="zm"><span class="v">Get Latest</span><span class="l">pull from server</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- body -->
|
||||||
|
<div class="m-body">
|
||||||
|
<div class="m-left">
|
||||||
|
<div class="m-tabs"><span class="on" data-tab="changes">Changes <span class="badge">3</span></span><span data-tab="history">History</span></div>
|
||||||
|
|
||||||
|
<!-- CHANGES -->
|
||||||
|
<div class="m-panel" data-panel="changes" style="display:flex;flex-direction:column;flex:1;min-height:0">
|
||||||
|
<div class="m-filter"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.7"/><path d="m20 20-3.5-3.5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg> Filter 3 files…</div>
|
||||||
|
<div class="m-selhead">
|
||||||
|
<span class="sc"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||||
|
<span id="mSelTxt">3 of 3 selected</span>
|
||||||
|
<span class="rf" id="mRefresh"><svg viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 1 1-3-6.7M21 4v4h-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg> Refresh</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-list" id="mList" style="flex:1;min-height:0;overflow:auto">
|
||||||
|
<div class="m-file sel" data-name="SM_Guardrail.uasset" data-dir="— //depot/Aurora_Road/Content/Meshes/" data-meta="add · #1 · binary" data-kind="mesh" data-type="binary +l" data-prev="3D mesh" data-lab="3D preview">
|
||||||
|
<span class="m-chk"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||||
|
<span class="m-ico"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||||
|
<span class="m-fmeta"><span class="n">SM_Guardrail.uasset</span><span class="p">//depot/…/Content/Meshes/</span></span>
|
||||||
|
<span class="m-stat a">+</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-file" data-name="T_Asphalt_D.uasset" data-dir="— //depot/Aurora_Road/Content/Textures/" data-meta="add · #1 · binary" data-kind="tex" data-type="binary" data-prev="2048 × 2048" data-lab="Image">
|
||||||
|
<span class="m-chk"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||||
|
<span class="m-ico"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="m3 16 5-5 4 4 3-3 6 6" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||||
|
<span class="m-fmeta"><span class="n">T_Asphalt_D.uasset</span><span class="p">//depot/…/Content/Textures/</span></span>
|
||||||
|
<span class="m-stat a">+</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-file" data-name="RoadSampler.cpp" data-dir="— //depot/Aurora_Road/Source/Aurora/" data-meta="edit · #7 → #8 · text" data-kind="code" data-type="text · C++" data-prev="312 lines" data-lab="Source">
|
||||||
|
<span class="m-chk"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||||
|
<span class="m-ico"><svg viewBox="0 0 24 24" fill="none"><path d="M8 3H5a2 2 0 0 0-2 2v3m0 8v3a2 2 0 0 0 2 2h3m8-18h3a2 2 0 0 1 2 2v3m0 8v3a2 2 0 0 1-2 2h-3M9 9l6 6m0-6-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
|
||||||
|
<span class="m-fmeta"><span class="n">RoadSampler.cpp</span><span class="p">//depot/…/Source/Aurora/</span></span>
|
||||||
|
<span class="m-stat a">+</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-commit">
|
||||||
|
<div class="m-summrow">
|
||||||
|
<div class="m-input">Add guardrail mesh + asphalt texture for road kit</div>
|
||||||
|
<div class="ai-btn" title="Draft with AI"><svg viewBox="0 0 24 24" fill="none"><path d="M12 3v3m0 12v3M5 12H2m20 0h-3M6 6l2 2m8 8 2 2m0-12-2 2M8 16l-2 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||||
|
</div>
|
||||||
|
<div class="m-submit" id="mCommit"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg> <span id="mCommitTxt">Commit changelist · 3 files</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HISTORY -->
|
||||||
|
<div class="m-panel m-hist" data-panel="history" hidden>
|
||||||
|
<div class="m-hrow"><span class="m-hav" style="background:#7c6ef6">VD</span><span class="m-hb"><span class="d">road guardrails + collision</span><span class="mt">#34 · voss_dev · 2h ago</span></span><span class="m-hsz">146 KB</span></div>
|
||||||
|
<div class="m-hrow"><span class="m-hav" style="background:#e8b04b">MA</span><span class="m-hb"><span class="d">asphalt material pass</span><span class="mt">#33 · mira_art · 5h ago</span></span><span class="m-hsz">172 KB</span></div>
|
||||||
|
<div class="m-hrow"><span class="m-hav" style="background:#3ecf8e">NK</span><span class="m-hb"><span class="d">early car handling setup</span><span class="mt">#31 · novak_dev · yesterday</span></span><span class="m-hsz">21.3 KB</span></div>
|
||||||
|
<div class="m-hrow"><span class="m-hav" style="background:#f2637e">KS</span><span class="m-hb"><span class="d">new road master material</span><span class="mt">#28 · kestrel · 2 days ago</span></span><span class="m-hsz">6.4 KB</span></div>
|
||||||
|
<div class="m-hrow"><span class="m-hav" style="background:#5cc9c0">KS</span><span class="m-hb"><span class="d">Project Init</span><span class="mt">#18 · kestrel · last week</span></span><span class="m-hsz">34.1 GB</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-right">
|
||||||
|
<div class="m-diffhead">
|
||||||
|
<span class="m-stat a" id="mStat" style="width:24px;height:24px;font-size:13px">+</span>
|
||||||
|
<span class="ttl"><span class="n"><span id="mName">SM_Guardrail.uasset</span> <span id="mDir">— //depot/Aurora_Road/Content/Meshes/</span></span><span class="mm" id="mMeta">add · #1 · binary</span></span>
|
||||||
|
<span class="m-revert"><svg viewBox="0 0 24 24" fill="none"><path d="M9 14 4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg> Revert</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-stage" id="mStage">
|
||||||
|
<span class="lab" id="mLab"><svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg> <span id="mLabTxt">3D preview</span></span>
|
||||||
|
|
||||||
|
<div class="pv pv-mesh" data-pv="mesh">
|
||||||
|
<svg class="m-cube" id="mCube" viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
|
||||||
|
<span class="m-load" id="mLoad"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> <span id="mLoadTxt">Load 3D model</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pv pv-tex" data-pv="tex" hidden>
|
||||||
|
<div class="tex-swatch"></div>
|
||||||
|
<span class="tex-cap">2048 × 2048 · BC7 · sRGB</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pv pv-code" data-pv="code" hidden>
|
||||||
|
<div class="codeblk"><span class="ln"><span class="cm">// M5 — deterministic POI placement over the road graph</span></span><span class="ln"><span class="kw">void</span> <span class="ty">URoadSampler</span>::<span class="fn">Build</span>(<span class="kw">const</span> <span class="ty">FRoadConfig</span>& Cfg)</span><span class="ln">{</span><span class="ln"> Graph = <span class="fn">NewObject</span><<span class="ty">URoadGraph</span>>(<span class="kw">this</span>);</span><span class="ln"> Graph-><span class="fn">Initialize</span>(Cfg);</span><span class="ln"> Graph-><span class="fn">EnsureGeneratedAround</span>(<span class="ty">INDEX_NONE</span>, <span class="st">0.0</span>);</span><span class="ln"> Pois = <span class="fn">NewObject</span><<span class="ty">UPoiRegistry</span>>(<span class="kw">this</span>);</span><span class="ln">}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-metabar">
|
||||||
|
<div class="mi"><div class="k">Type</div><div class="v" id="mvType">binary +l</div></div>
|
||||||
|
<div class="mi"><div class="k">Preview</div><div class="v" id="mvPrev">3D mesh</div></div>
|
||||||
|
<div class="mi"><div class="k">Revision</div><div class="v acc">#1</div></div>
|
||||||
|
<div class="mi"><div class="k">Change</div><div class="v">default</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- status bar -->
|
||||||
|
<div class="m-status">
|
||||||
|
<span class="ok">✓</span><span class="cmd">p4 opened -c default //depot/Aurora_Road/… {3 files}</span>
|
||||||
|
<span class="chips">
|
||||||
|
<b><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> Unreal</b>
|
||||||
|
<b><svg viewBox="0 0 24 24" fill="none"><path d="M4 5h16v14H4z" stroke="currentColor" stroke-width="1.6"/><path d="m7 9 2.5 2.5L7 14m5 0h4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg> Terminal</b>
|
||||||
|
<b><svg viewBox="0 0 24 24" fill="none"><path d="M4 6h16M4 12h16M4 18h10" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg> Log</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===================== TRUST ===================== -->
|
||||||
|
<div class="trust wrap">
|
||||||
|
<p>Purpose-built for game studios on Perforce</p>
|
||||||
|
<div class="trust-row">
|
||||||
|
<span class="chip"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg> Unreal Engine 4 & 5</span>
|
||||||
|
<a class="chip" href="https://www.perforce.com/products/helix-core" target="_blank" rel="noopener"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 20h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg> Powered by Perforce Helix Core</a>
|
||||||
|
<span class="chip"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.6"/><path d="M3 12h18M12 3c2.5 2.5 2.5 15 0 18M12 3c-2.5 2.5-2.5 15 0 18" stroke="currentColor" stroke-width="1.4"/></svg> Radmin VPN friendly</span>
|
||||||
|
<span class="chip"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7h16M4 12h16M4 17h10" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg> 5 languages</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== FEATURES ===================== -->
|
||||||
|
<section id="features">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec-head">
|
||||||
|
<span class="eyebrow"><span class="dot"></span> Everything, in one native app</span>
|
||||||
|
<h2 style="margin-top:18px">Built for the way Unreal teams actually work</h2>
|
||||||
|
<p>Perforce is the industry standard for a reason. Exbyte Depot keeps every bit of its power and finally makes it feel good to use.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<span class="tag">Signature</span>
|
||||||
|
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></div>
|
||||||
|
<h3>Real 3D asset preview</h3>
|
||||||
|
<p>Rotate the actual mesh inside a <code>.uasset</code> right in the app — plus embedded thumbnails, image, audio and video previews. See what you're committing before you commit it.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||||
|
<h3>Exclusive locks, one click</h3>
|
||||||
|
<p>Binary <code>.uasset</code> files can't be merged. Take an exclusive lock so only you edit a mesh or material at a time — and see everyone else's locks live. No more overwritten work.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M4 12a8 8 0 0 1 8-8 8 8 0 0 1 8 8m0 0-3-3m3 3 3-3M20 12a8 8 0 0 1-8 8 8 8 0 0 1-8-8m0 0 3 3m-3-3-3 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||||
|
<h3>Work offline, reconcile later</h3>
|
||||||
|
<p>On a plane or the VPN's down? Keep editing. Exbyte Depot scans your workspace and reconciles adds, edits and deletes into a changelist when you're back — just like P4V, but nicer.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M13.7 21a2 2 0 0 1-3.4 0" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
|
||||||
|
<h3>Live teammate activity</h3>
|
||||||
|
<p>Lives in your system tray and sends a native notification the moment a teammate submits — with their message. A banner tells you when you're behind, one click to Get Latest.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<span class="tag">AI</span>
|
||||||
|
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 3v3m0 12v3M5 12H2m20 0h-3M6 6l2 2m8 8 2 2m0-12-2 2M8 16l-2 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||||
|
<h3>AI commit summaries</h3>
|
||||||
|
<p>Draft a clear changelist message from your selected files, and get a plain-English summary of what any source file does — in your interface language.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||||
|
<h3>Files & Sizes explorer</h3>
|
||||||
|
<p>Browse the Depot and your Workspace as a tree and see exactly how much every file and folder weighs — heaviest first. Find what's bloating your repo in seconds.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="m9 8-4 4 4 4m6-8 4 4-4 4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||||
|
<h3>Built-in code editor</h3>
|
||||||
|
<p>Fix a header or a config right before commit — no external IDE needed. Full syntax highlighting, line numbers, and a proper editor, not a grey textbox.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M6 3v12a3 3 0 0 0 3 3h6M6 3a3 3 0 1 0 0 6M6 3a3 3 0 1 1 0 6m9 9a3 3 0 1 0 6 0 3 3 0 0 0-6 0Z" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||||
|
<h3>The full Perforce toolbox</h3>
|
||||||
|
<p>Labels, streams, integrate / merge / copy, jobs, file history with blame & diff, clean, <code>.p4ignore</code>, get-revision, shelve/unshelve — the depth is all there when you need it.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 1 1-9-9c2.5 0 4.5 1 6 2.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="m17 4 1 3-3 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||||
|
<h3>Signed auto-updates</h3>
|
||||||
|
<p>Ships as a signed installer with a built-in updater. New versions land in place — the whole team stays current without chasing downloads.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===================== WHY NOT P4V ===================== -->
|
||||||
|
<section id="why" style="background:linear-gradient(180deg,transparent,rgba(124,110,246,.04),transparent)">
|
||||||
|
<div class="wrap split">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow"><span class="dot"></span> Why switch</span>
|
||||||
|
<h2 style="margin-top:18px">P4V is powerful. It's also from another era.</h2>
|
||||||
|
<p class="lead">We're not replacing your <a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce</a> server — we're replacing the client you look at it through. Exbyte Depot keeps every bit of Perforce power your studio depends on and rebuilds the experience around how artists and engineers really work: visual, fast, and forgiving.</p>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>See your assets, not filenames</b><span>Live 3D meshes, thumbnails, audio & video — right where you review a changelist.</span></div></div>
|
||||||
|
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>Commit locally, submit when ready</b><span>A GitHub-Desktop-style flow on top of Perforce — revertable local commits, then push.</span></div></div>
|
||||||
|
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>Never lose work to a binary conflict</b><span>Exclusive locks on <code>.uasset</code> are one click, and everyone's locks are visible.</span></div></div>
|
||||||
|
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>Know what the team is doing</b><span>Tray notifications on every submit and a clear "you're behind" nudge.</span></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-visual">
|
||||||
|
<div style="font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);margin-bottom:14px;display:flex;align-items:center;gap:8px"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" style="color:var(--acc-2)"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.6"/></svg> File Locks</div>
|
||||||
|
<div class="lock-item"><span class="lock-badge red"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.7"/></svg></span><div class="li-b"><div class="n">SM_Hero_Character.uasset</div><div class="p">//depot/Char/Content/Meshes/</div></div><span class="who">🔒 mira_art</span></div>
|
||||||
|
<div class="lock-item"><span class="lock-badge red"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.7"/></svg></span><div class="li-b"><div class="n">M_Road_Master.uasset</div><div class="p">//depot/Road/Content/Materials/</div></div><span class="who">🔒 you</span></div>
|
||||||
|
<div class="lock-item"><span class="lock-badge gr"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.7"/></svg></span><div class="li-b"><div class="n">BP_GameMode.uasset</div><div class="p">//depot/Core/Content/Blueprints/</div></div><span class="who">available</span></div>
|
||||||
|
<div style="margin-top:16px;padding:13px 15px;border-radius:11px;background:rgba(124,110,246,.08);border:1px solid rgba(124,110,246,.22);font-size:13px;color:var(--acc-2);display:flex;gap:10px;align-items:center">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
|
||||||
|
voss_dev just submitted <b style="color:#fff;margin:0 4px">#34</b> — you're 3 files behind.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===================== TECH ===================== -->
|
||||||
|
<section id="tech">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec-head">
|
||||||
|
<span class="eyebrow"><span class="dot"></span> Under the hood</span>
|
||||||
|
<h2 style="margin-top:18px">Native, fast, and quietly powerful</h2>
|
||||||
|
<p>No Electron bloat. A tiny signed installer, instant startup, and a UI that stays out of your way.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tech">
|
||||||
|
<div class="t"><div class="k">Rust</div><div class="v">Native Tauri backend</div></div>
|
||||||
|
<div class="t"><div class="k">~4 MB</div><div class="v">Installer, not 200</div></div>
|
||||||
|
<div class="t"><div class="k">5</div><div class="v">Languages built in</div></div>
|
||||||
|
<div class="t"><div class="k">0</div><div class="v">Servers to configure</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===================== FINAL CTA ===================== -->
|
||||||
|
<section style="padding-top:20px">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="cta">
|
||||||
|
<h2>Get early access to Exbyte Depot</h2>
|
||||||
|
<p>We're onboarding studios in waves. Drop your email and we'll send your download the moment your spot opens.</p>
|
||||||
|
<div class="waitlist">
|
||||||
|
<form class="wl-form" id="wlForm2" novalidate>
|
||||||
|
<input id="wlEmail2" type="email" inputmode="email" autocomplete="email" placeholder="you@studio.com" aria-label="Email address" required />
|
||||||
|
<button class="btn primary" type="submit">Reserve my spot
|
||||||
|
<svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14m-6-6 6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="wl-ok" id="wlOk2">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.7"/><path d="m8.5 12 2.5 2.5 4.5-5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<span>You're on the list. Talk soon.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===================== FOOTER ===================== -->
|
||||||
|
<footer>
|
||||||
|
<div class="wrap foot">
|
||||||
|
<a class="brand" href="#top">
|
||||||
|
<span class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg></span>
|
||||||
|
Exbyte Depot
|
||||||
|
</a>
|
||||||
|
<span class="fl" style="margin-left:0">
|
||||||
|
<a href="#features">Features</a>
|
||||||
|
<a href="#waitlist">Waitlist</a>
|
||||||
|
<a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce.com</a>
|
||||||
|
</span>
|
||||||
|
<span style="flex-basis:100%;color:var(--faint);font-size:12.5px;line-height:1.6;margin-top:6px">
|
||||||
|
© <span id="yr"></span> Exbyte Studios. Exbyte Depot is an independent desktop <b style="color:var(--muted);font-weight:600">client for Perforce Helix Core</b> and requires a Perforce server. It is not affiliated with, endorsed by, or sponsored by Perforce Software, Inc. “Perforce”, “Helix Core” and “P4V” are trademarks of <a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce Software, Inc.</a>, used here only to describe compatibility.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
"use strict";
|
||||||
|
var KEY = "exd_waitlist";
|
||||||
|
function list(){ try { return JSON.parse(localStorage.getItem(KEY) || "[]"); } catch(e){ return []; } }
|
||||||
|
function save(a){ try { localStorage.setItem(KEY, JSON.stringify(a)); } catch(e){} }
|
||||||
|
|
||||||
|
// Signups POST to the waitlist server (../waitlist-server). The server dedups
|
||||||
|
// and stores them; localStorage is kept as a mirror for the social-proof
|
||||||
|
// counter and as an offline fallback if the page is opened without a backend.
|
||||||
|
var API = "/api/waitlist";
|
||||||
|
function submitToWaitlist(email){
|
||||||
|
var a = list();
|
||||||
|
if (a.indexOf(email) === -1) a.push(email);
|
||||||
|
save(a);
|
||||||
|
return fetch(API, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: email, source: "landing", website: "" })
|
||||||
|
})
|
||||||
|
.then(function(r){ return r.ok ? r.json() : null; })
|
||||||
|
.catch(function(){ return null; }); // never block the UX on a network error
|
||||||
|
}
|
||||||
|
|
||||||
|
var BASE = 1240; // shown alongside real signups for social proof
|
||||||
|
function renderCount(){
|
||||||
|
var el = document.getElementById("wlCount");
|
||||||
|
if (el) el.textContent = (BASE + list().length).toLocaleString() + " studios";
|
||||||
|
}
|
||||||
|
renderCount();
|
||||||
|
document.getElementById("yr").textContent = new Date().getFullYear();
|
||||||
|
|
||||||
|
function valid(v){ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); }
|
||||||
|
|
||||||
|
function wire(formId, emailId, okId){
|
||||||
|
var form = document.getElementById(formId);
|
||||||
|
if (!form) return;
|
||||||
|
form.addEventListener("submit", function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
var input = document.getElementById(emailId);
|
||||||
|
var email = (input.value || "").trim().toLowerCase();
|
||||||
|
if (!valid(email)){
|
||||||
|
input.focus();
|
||||||
|
input.style.boxShadow = "0 0 0 2px var(--del)";
|
||||||
|
setTimeout(function(){ input.style.boxShadow = ""; }, 1400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var already = list().indexOf(email) !== -1;
|
||||||
|
submitToWaitlist(email).then(function(){
|
||||||
|
form.style.display = "none";
|
||||||
|
var ok = document.getElementById(okId);
|
||||||
|
if (ok){
|
||||||
|
ok.classList.add("show");
|
||||||
|
if (okId === "wlOk"){
|
||||||
|
var msg = document.getElementById("wlOkMsg");
|
||||||
|
if (msg) msg.textContent = already
|
||||||
|
? "You're already on the list — we've got you."
|
||||||
|
: "You're on the list. We'll email you the moment early access opens.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderCount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
wire("wlForm", "wlEmail", "wlOk");
|
||||||
|
wire("wlForm2", "wlEmail2", "wlOk2");
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ===== interactive app-window mock ===== */
|
||||||
|
(function(){
|
||||||
|
"use strict";
|
||||||
|
var mock = document.querySelector(".mock");
|
||||||
|
if (!mock) return;
|
||||||
|
var $ = function(s){ return mock.querySelector(s); };
|
||||||
|
var $$ = function(s){ return Array.prototype.slice.call(mock.querySelectorAll(s)); };
|
||||||
|
|
||||||
|
/* --- tab switching (Changes / History) --- */
|
||||||
|
$$(".m-tabs span").forEach(function(tab){
|
||||||
|
tab.addEventListener("click", function(){
|
||||||
|
var name = tab.getAttribute("data-tab");
|
||||||
|
$$(".m-tabs span").forEach(function(t){ t.classList.toggle("on", t === tab); });
|
||||||
|
$$(".m-panel").forEach(function(p){ p.hidden = p.getAttribute("data-panel") !== name; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- file selection swaps the right-hand preview --- */
|
||||||
|
function selectFile(f){
|
||||||
|
$$(".m-file").forEach(function(x){ x.classList.toggle("sel", x === f); });
|
||||||
|
var d = f.dataset;
|
||||||
|
$("#mName").textContent = d.name;
|
||||||
|
$("#mDir").textContent = d.dir;
|
||||||
|
$("#mMeta").textContent = d.meta;
|
||||||
|
$("#mvType").textContent = d.type;
|
||||||
|
$("#mvPrev").textContent = d.prev;
|
||||||
|
$("#mLabTxt").textContent = d.lab;
|
||||||
|
$$(".pv").forEach(function(pv){ pv.hidden = pv.getAttribute("data-pv") !== d.kind; });
|
||||||
|
}
|
||||||
|
$$(".m-file").forEach(function(f){
|
||||||
|
f.addEventListener("click", function(e){
|
||||||
|
if (e.target.closest(".m-chk")) return; // checkbox handled separately
|
||||||
|
selectFile(f);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- checkboxes toggle the selected count + commit button --- */
|
||||||
|
function refreshCount(){
|
||||||
|
var on = $$(".m-file:not(.off)").length;
|
||||||
|
$("#mSelTxt").textContent = on + " of 3 selected";
|
||||||
|
$("#mCommitTxt").textContent = on ? ("Commit changelist · " + on + (on === 1 ? " file" : " files")) : "Select files to commit";
|
||||||
|
$("#mCommit").style.opacity = on ? "1" : ".55";
|
||||||
|
}
|
||||||
|
$$(".m-file .m-chk").forEach(function(chk){
|
||||||
|
chk.addEventListener("click", function(e){
|
||||||
|
e.stopPropagation();
|
||||||
|
chk.closest(".m-file").classList.toggle("off");
|
||||||
|
refreshCount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- little press / spin feedback --- */
|
||||||
|
function pressSpin(el, ms){
|
||||||
|
if (!el || el.classList.contains("spin")) return;
|
||||||
|
el.classList.add("spin");
|
||||||
|
setTimeout(function(){ el.classList.remove("spin"); }, ms || 900);
|
||||||
|
}
|
||||||
|
var rf = $("#mRefresh"); if (rf) rf.addEventListener("click", function(){ pressSpin(rf, 800); });
|
||||||
|
var get = $(".m-zone.get"); if (get) get.addEventListener("click", function(){ pressSpin(get, 1100); });
|
||||||
|
|
||||||
|
/* --- Load 3D model → spinner, then a slowly spinning mesh --- */
|
||||||
|
var load = $("#mLoad"), cube = $("#mCube"), loaded = false;
|
||||||
|
if (load){
|
||||||
|
load.addEventListener("click", function(){
|
||||||
|
if (loaded){ return; }
|
||||||
|
var txt = $("#mLoadTxt");
|
||||||
|
load.innerHTML = '<span class="m-spinner"></span> Rendering…';
|
||||||
|
setTimeout(function(){
|
||||||
|
loaded = true;
|
||||||
|
cube.classList.add("loaded");
|
||||||
|
load.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"/></svg> Loaded';
|
||||||
|
load.style.opacity = ".7";
|
||||||
|
var pv = $("#mvPrev"); if (pv && pv.textContent.indexOf("3D") === 0) pv.textContent = "3D mesh · loaded";
|
||||||
|
}, 950);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Commit button press --- */
|
||||||
|
var commit = $("#mCommit");
|
||||||
|
if (commit){
|
||||||
|
commit.addEventListener("click", function(){
|
||||||
|
if ($$(".m-file:not(.off)").length === 0) return;
|
||||||
|
var t = $("#mCommitTxt"), old = t.textContent;
|
||||||
|
commit.style.transform = "scale(.98)";
|
||||||
|
t.textContent = "Committing…";
|
||||||
|
setTimeout(function(){
|
||||||
|
commit.style.transform = "";
|
||||||
|
t.textContent = "✓ Changelist created locally";
|
||||||
|
setTimeout(function(){ t.textContent = old; }, 1500);
|
||||||
|
}, 700);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "exbyte-depot",
|
"name": "exbyte-depot",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -966,7 +966,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "exbyte-depot"
|
name = "exbyte-depot"
|
||||||
version = "0.3.0"
|
version = "0.3.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "exbyte-depot"
|
name = "exbyte-depot"
|
||||||
version = "0.3.1"
|
version = "0.3.2"
|
||||||
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
|
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
|
||||||
authors = ["Exbyte Studios"]
|
authors = ["Exbyte Studios"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{AppHandle, Emitter, Manager, State};
|
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).
|
/// command log to the frontend (a Perforce-style "Log" panel).
|
||||||
static APP: OnceLock<AppHandle> = OnceLock::new();
|
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
|
/// Emit one p4-log entry to the UI: the command line, how many items/lines it
|
||||||
/// returned, whether it succeeded, and any error text.
|
/// returned, whether it succeeded, and any error text.
|
||||||
fn log_p4(args: &[&str], count: i64, ok: bool, err: Option<&str>) {
|
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]
|
#[tauri::command]
|
||||||
async fn p4_dirs(state: State<'_, AppState>, path: String) -> Result<Vec<Value>, String> {
|
async fn p4_dirs(state: State<'_, AppState>, path: String) -> Result<Vec<Value>, String> {
|
||||||
let conn = current(&state)?;
|
let conn = current(&state)?;
|
||||||
let spec = if path.is_empty() {
|
let base = path.trim_end_matches(['/', '\\']).to_string();
|
||||||
"//*".to_string()
|
let spec = if base.is_empty() { "//*".to_string() } else { format!("{}/*", base) };
|
||||||
} else {
|
let mut dirs = run_json(&conn, &["dirs", &spec]).unwrap_or_default();
|
||||||
format!("{}/*", path.trim_end_matches(['/', '\\']))
|
|
||||||
};
|
// union in local-only subfolders (present on disk, not in the depot)
|
||||||
run_json(&conn, &["dirs", &spec])
|
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`
|
/// 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`
|
/// 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.
|
/// 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.
|
/// `op` labels the operation ("sync" | "submit"). `total` is the expected file
|
||||||
fn run_stream(conn: &Conn, args: &[&str], op: &str) -> Result<String, String> {
|
/// 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)
|
let mut child = match p4_cmd(conn, false)
|
||||||
.args(args)
|
.args(args)
|
||||||
.stdout(Stdio::piped())
|
.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 collected = String::new();
|
||||||
let mut count: i64 = 0;
|
let mut count: i64 = 0;
|
||||||
let mut last = Instant::now();
|
let mut last = Instant::now();
|
||||||
|
let mut cancelled = false;
|
||||||
if let Some(out) = child.stdout.take() {
|
if let Some(out) = child.stdout.take() {
|
||||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
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;
|
count += 1;
|
||||||
// throttle to ~25 fps regardless of file count, but always send the first few
|
// throttle to ~25 fps regardless of file count, but always send the first few
|
||||||
if count <= 5 || last.elapsed().as_millis() >= 40 {
|
if count <= 5 || last.elapsed().as_millis() >= 40 {
|
||||||
if let Some(app) = APP.get() {
|
if let Some(app) = APP.get() {
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"p4-transfer",
|
"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();
|
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() {
|
if let Some(app) = APP.get() {
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"p4-transfer",
|
"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() {
|
if !status.success() && !stderr.is_empty() {
|
||||||
log_p4(args, 0, false, Some(&stderr));
|
log_p4(args, 0, false, Some(&stderr));
|
||||||
return Err(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())
|
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.
|
/// Get Latest — sync to head, optionally scoped to a depot folder.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn p4_sync(state: State<'_, AppState>, scope: String) -> Result<String, String> {
|
async fn p4_sync(state: State<'_, AppState>, scope: String) -> Result<String, String> {
|
||||||
let conn = current(&state)?;
|
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 })
|
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
|
// strip a leading @ or # if the user typed one
|
||||||
let rev = rev.trim_start_matches(['@', '#']);
|
let rev = rev.trim_start_matches(['@', '#']);
|
||||||
let spec = format!("{}@{}", scope_spec(&scope), rev);
|
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 })
|
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());
|
return Err("No files to commit".into());
|
||||||
}
|
}
|
||||||
let cl = create_changelist(&conn, &description)?;
|
let cl = create_changelist(&conn, &description)?;
|
||||||
let mut reopen: Vec<&str> = vec!["reopen", "-c", &cl];
|
// reopen the files into the new changelist in batches — 63k paths in one
|
||||||
for f in &files {
|
// command line would exceed the Windows limit and fail to spawn. Emit a
|
||||||
reopen.push(f.as_str());
|
// `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)
|
Ok(cl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -707,7 +786,8 @@ async fn p4_commit(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn p4_submit_change(state: State<'_, AppState>, change: String) -> Result<String, String> {
|
async fn p4_submit_change(state: State<'_, AppState>, change: String) -> Result<String, String> {
|
||||||
let conn = current(&state)?;
|
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
|
/// 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]
|
#[tauri::command]
|
||||||
async fn p4_submit_shelved(state: State<'_, AppState>, change: String) -> Result<String, String> {
|
async fn p4_submit_shelved(state: State<'_, AppState>, change: String) -> Result<String, String> {
|
||||||
let conn = current(&state)?;
|
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
|
/// 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> {
|
async fn p4_submit_opts(state: State<'_, AppState>, change: String, reopen: bool, revert_unchanged: bool) -> Result<String, String> {
|
||||||
let conn = current(&state)?;
|
let conn = current(&state)?;
|
||||||
let ch = change.trim().to_string();
|
let ch = change.trim().to_string();
|
||||||
|
let total = count_opened_in(&conn, &ch);
|
||||||
let mut args: Vec<String> = vec!["submit".into(), "-c".into(), ch];
|
let mut args: Vec<String> = vec!["submit".into(), "-c".into(), ch];
|
||||||
if reopen {
|
if reopen {
|
||||||
args.push("-r".into());
|
args.push("-r".into());
|
||||||
@ -734,7 +815,7 @@ async fn p4_submit_opts(state: State<'_, AppState>, change: String, reopen: bool
|
|||||||
args.push("revertunchanged".into());
|
args.push("revertunchanged".into());
|
||||||
}
|
}
|
||||||
let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
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).
|
/// Path of the workspace `.p4ignore` file (at the client root).
|
||||||
@ -835,28 +916,64 @@ async fn p4_submit(
|
|||||||
if files.is_empty() {
|
if files.is_empty() {
|
||||||
return Err("No files selected to submit".into());
|
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
|
// all opened files selected → submit the whole default changelist in one go
|
||||||
if files.len() >= total {
|
if files.len() >= opened_total {
|
||||||
return run_stream(&conn, &["submit", "-d", &description], "submit");
|
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 cl = create_changelist(&conn, &description)?;
|
||||||
let mut reopen: Vec<&str> = vec!["reopen", "-c", &cl];
|
let prefix_len = "reopen -c ".len() + cl.len();
|
||||||
for f in &files {
|
for r in file_batches(&files, prefix_len) {
|
||||||
reopen.push(f.as_str());
|
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", n)
|
||||||
run_stream(&conn, &["submit", "-c", &cl], "submit")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a p4 command over a list of file paths, returning the opened records.
|
/// 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> {
|
/// Split a file list into batches whose joined command line stays safely under
|
||||||
let mut args: Vec<&str> = vec![cmd];
|
/// the Windows ~32 KiB command-line limit. Passing tens of thousands of depot
|
||||||
for f in files {
|
/// paths in one `p4` invocation (e.g. committing a 63k-file initial import) blows
|
||||||
args.push(f.as_str());
|
/// 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.
|
/// 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_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
|
/// 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
|
/// 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
|
/// 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())
|
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
|
/// Read a single string value from the Windows registry via `reg query`.
|
||||||
/// its output line-by-line as `build-log` events. Uses the solution's default
|
#[cfg(windows)]
|
||||||
/// configuration — for an Unreal project that compiles the game module.
|
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]
|
#[tauri::command]
|
||||||
async fn build_sln(path: String, config: String) -> Result<String, String> {
|
async fn build_sln(path: String, config: String, uproject: String) -> Result<String, String> {
|
||||||
if !std::path::Path::new(&path).exists() {
|
if !std::path::Path::new(&path).exists() && uproject.is_empty() {
|
||||||
return Err(format!("Solution not found: {path}"));
|
return Err(format!("Solution not found: {path}"));
|
||||||
}
|
}
|
||||||
let emit = |line: String, done: bool, ok: bool| {
|
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)
|
// Prefer UnrealBuildTool for a real UE project — builds only the game module.
|
||||||
let pf86 = std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| "C:\\Program Files (x86)".into());
|
let ue = if !uproject.is_empty() && std::path::Path::new(&uproject).exists() {
|
||||||
let vswhere = format!("{pf86}\\Microsoft Visual Studio\\Installer\\vswhere.exe");
|
#[cfg(windows)]
|
||||||
let msbuild = {
|
{ resolve_engine_dir(&uproject) }
|
||||||
let mut vc = Command::new(&vswhere);
|
#[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)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
use std::os::windows::process::CommandExt;
|
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"]);
|
c.args([target, "Win64".into(), ubt_conf, format!("-Project={uproject}"), "-WaitMutex".into(), "-NoHotReloadFromIDE".into()]);
|
||||||
match vc.output() {
|
c
|
||||||
Ok(o) => decode(&o.stdout).lines().next().unwrap_or("").trim().to_string(),
|
} else {
|
||||||
Err(_) => String::new(),
|
// 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 };
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
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());
|
|
||||||
let mut child = match cmd.spawn() {
|
let mut child = match cmd.spawn() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
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);
|
emit(m.clone(), true, false);
|
||||||
return Err(m);
|
return Err(m);
|
||||||
}
|
}
|
||||||
@ -1372,6 +1668,124 @@ async fn write_depot(state: State<'_, AppState>, depot: String, content: String)
|
|||||||
Ok(())
|
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`).
|
/// Read the local working-copy bytes of a depot file (resolves via `p4 where`).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn read_depot(state: State<'_, AppState>, depot: String) -> Result<tauri::ipc::Response, String> {
|
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]
|
#[tauri::command]
|
||||||
async fn p4_clean_apply(state: State<'_, AppState>, scope: String) -> Result<String, String> {
|
async fn p4_clean_apply(state: State<'_, AppState>, scope: String) -> Result<String, String> {
|
||||||
let conn = current(&state)?;
|
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 })
|
Ok(if msg.is_empty() { "Workspace already matches the depot.".into() } else { msg })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2542,6 +2956,9 @@ pub fn run() {
|
|||||||
p4_delete,
|
p4_delete,
|
||||||
p4_reconcile,
|
p4_reconcile,
|
||||||
p4_scan,
|
p4_scan,
|
||||||
|
p4_scan_stream,
|
||||||
|
p4_scan_cancel,
|
||||||
|
p4_transfer_cancel,
|
||||||
p4_undo_change,
|
p4_undo_change,
|
||||||
open_in_explorer,
|
open_in_explorer,
|
||||||
find_uproject,
|
find_uproject,
|
||||||
@ -2611,6 +3028,10 @@ pub fn run() {
|
|||||||
fs_ls,
|
fs_ls,
|
||||||
p4_change_sizes,
|
p4_change_sizes,
|
||||||
p4_behind,
|
p4_behind,
|
||||||
|
make_folder,
|
||||||
|
delete_folder,
|
||||||
|
rename_folder,
|
||||||
|
folder_info,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Exbyte Depot",
|
"productName": "Exbyte Depot",
|
||||||
"mainBinaryName": "Exbyte Depot",
|
"mainBinaryName": "Exbyte Depot",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"identifier": "com.bonchellon.exbyte-depot",
|
"identifier": "com.bonchellon.exbyte-depot",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
41
src/App.css
41
src/App.css
@ -235,6 +235,12 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
|||||||
.scanbar{display:flex;align-items:center;gap:9px;margin:8px 10px 2px;padding:9px 12px;border-radius:11px;font-size:11.5px;line-height:1.4;
|
.scanbar{display:flex;align-items:center;gap:9px;margin:8px 10px 2px;padding:9px 12px;border-radius:11px;font-size:11.5px;line-height:1.4;
|
||||||
color:var(--accent-2);background:rgba(124,110,246,.08);border:1px solid rgba(124,110,246,.22)}
|
color:var(--accent-2);background:rgba(124,110,246,.08);border:1px solid rgba(124,110,246,.22)}
|
||||||
.scanbar .ldr{flex:0 0 auto}
|
.scanbar .ldr{flex:0 0 auto}
|
||||||
|
.scanbar > span{flex:1 1 auto;min-width:0}
|
||||||
|
.scancount{font-weight:600;color:var(--txt);font-variant-numeric:tabular-nums}
|
||||||
|
.scan-cancel{flex:0 0 auto;font-size:11px;font-weight:500;color:var(--muted);cursor:pointer;
|
||||||
|
background:var(--panel);border:1px solid var(--border);border-radius:7px;padding:3px 11px;transition:.15s}
|
||||||
|
.scan-cancel:hover{color:#fff;background:var(--danger,#e5484d);border-color:var(--danger,#e5484d)}
|
||||||
|
.empty .scancount{color:var(--accent-2)}
|
||||||
|
|
||||||
/* submit progress modal */
|
/* submit progress modal */
|
||||||
.push-modal{width:380px;background:var(--elevated);border:1px solid var(--border);border-radius:16px;padding:24px 26px;box-shadow:var(--shadow);text-align:center}
|
.push-modal{width:380px;background:var(--elevated);border:1px solid var(--border);border-radius:16px;padding:24px 26px;box-shadow:var(--shadow);text-align:center}
|
||||||
@ -442,9 +448,20 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
|||||||
.xfer-ttl b{font-size:14.5px}
|
.xfer-ttl b{font-size:14.5px}
|
||||||
.xfer-ttl span{font-size:12px;color:var(--faint);font-variant-numeric:tabular-nums}
|
.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-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-cancel{margin-left:auto;flex:0 0 auto;align-self:flex-start;font-size:11.5px;font-weight:600;cursor:pointer;
|
||||||
|
background:var(--panel-2);border:1px solid var(--border);color:var(--muted);border-radius:8px;padding:5px 11px;transition:.15s}
|
||||||
|
.xfer-cancel:hover:not(:disabled){color:#fff;background:var(--danger,#e5484d);border-color:var(--danger,#e5484d)}
|
||||||
|
.xfer-cancel:disabled{opacity:.55;cursor:default}
|
||||||
|
.xfer-bar{margin-top:14px;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;
|
.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}
|
background:linear-gradient(90deg,transparent,var(--accent),transparent);animation:shim 1.1s ease-in-out infinite}
|
||||||
|
/* determinate (known total): solid fill sized by inline width, no shimmer */
|
||||||
|
.xfer-bar.det span{position:absolute;left:0;top:0;width:0;animation:none;
|
||||||
|
background:linear-gradient(90deg,var(--accent-2),var(--accent-deep));transition:width .25s ease;box-shadow:0 0 10px rgba(124,110,246,.55)}
|
||||||
|
.xfer-log{margin-top:12px;height:132px;overflow-y:auto;overflow-x:hidden;border:1px solid var(--border);border-radius:9px;
|
||||||
|
background:var(--panel);padding:7px 10px;font-family:var(--mono);font-size:10.5px;line-height:1.55;color:var(--muted)}
|
||||||
|
.xfer-log div{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.xfer-log div:last-child{color:var(--txt)}
|
||||||
|
|
||||||
/* MSBuild output overlay */
|
/* MSBuild output overlay */
|
||||||
.build{width:660px;max-width:calc(100vw - 40px);height:min(70vh,560px);display:flex;flex-direction:column;
|
.build{width:660px;max-width:calc(100vw - 40px);height:min(70vh,560px);display:flex;flex-direction:column;
|
||||||
@ -666,7 +683,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
|||||||
.histsplit .histlist .crow.sel .crow-go{opacity:1;transform:translateX(0)}
|
.histsplit .histlist .crow.sel .crow-go{opacity:1;transform:translateX(0)}
|
||||||
/* ---- right-side view panel with tabs (File Viewer / File Tree) ---- */
|
/* ---- right-side view panel with tabs (File Viewer / File Tree) ---- */
|
||||||
.viewpanel{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
|
.viewpanel{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
|
||||||
.vtabs{display:flex;align-items:stretch;gap:2px;padding:6px 8px 0;border-bottom:1px solid var(--border);background:var(--sunk);flex:0 0 auto;overflow-x:auto}
|
.vtabs{display:flex;align-items:stretch;gap:2px;padding:6px 8px 0;border-bottom:1px solid var(--border);background:var(--sunk);flex:0 0 auto;overflow-x:auto;overflow-y:hidden}
|
||||||
.vtab{display:flex;align-items:center;gap:7px;padding:7px 12px;border-radius:9px 9px 0 0;font-size:12.5px;font-weight:600;color:var(--muted);cursor:pointer;white-space:nowrap;border:1px solid transparent;border-bottom:none;position:relative;top:1px}
|
.vtab{display:flex;align-items:center;gap:7px;padding:7px 12px;border-radius:9px 9px 0 0;font-size:12.5px;font-weight:600;color:var(--muted);cursor:pointer;white-space:nowrap;border:1px solid transparent;border-bottom:none;position:relative;top:1px}
|
||||||
.vtab:hover{color:var(--txt);background:var(--hover)}
|
.vtab:hover{color:var(--txt);background:var(--hover)}
|
||||||
.vtab.on{color:var(--txt);background:var(--bg);border-color:var(--border)}
|
.vtab.on{color:var(--txt);background:var(--bg);border-color:var(--border)}
|
||||||
@ -681,6 +698,19 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
|||||||
.ft-bar{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--border-soft);flex:0 0 auto}
|
.ft-bar{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--border-soft);flex:0 0 auto}
|
||||||
.ft-total{margin-left:auto;font-size:12px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}
|
.ft-total{margin-left:auto;font-size:12px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||||
.ft-total b{color:var(--accent-2)}
|
.ft-total b{color:var(--accent-2)}
|
||||||
|
.ft-new{display:flex;align-items:center;gap:5px;font-family:var(--font);font-size:11.5px;font-weight:600;color:var(--accent-2);
|
||||||
|
background:rgba(124,110,246,.1);border:1px solid rgba(124,110,246,.24);border-radius:8px;padding:4px 10px;cursor:pointer;flex:0 0 auto;transition:.14s}
|
||||||
|
.ft-new:hover{background:rgba(124,110,246,.18);border-color:rgba(124,110,246,.4)}
|
||||||
|
.ft-new svg{width:13px;height:13px}
|
||||||
|
/* folder properties dialog */
|
||||||
|
.fp-body{padding:18px 20px}
|
||||||
|
.fp-name{display:flex;align-items:center;gap:10px;font-size:15px;margin-bottom:16px}
|
||||||
|
.fp-name svg{width:20px;height:20px;color:var(--accent-2);flex:0 0 auto}
|
||||||
|
.fp-name b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.fp-grid{display:grid;grid-template-columns:auto 1fr;gap:10px 16px;font-size:13px}
|
||||||
|
.fp-grid .k{color:var(--faint)}
|
||||||
|
.fp-grid .v{color:var(--txt);min-width:0;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
|
||||||
|
.fp-grid .v.mono{font-family:var(--mono);font-size:12px;word-break:break-all;white-space:normal}
|
||||||
.vpane.tree .exp-tree{flex:1;min-height:0}
|
.vpane.tree .exp-tree{flex:1;min-height:0}
|
||||||
.hnum{font-family:var(--mono);font-size:12px;color:var(--accent-2);font-variant-numeric:tabular-nums;flex:0 0 auto;padding-top:1px}
|
.hnum{font-family:var(--mono);font-size:12px;color:var(--accent-2);font-variant-numeric:tabular-nums;flex:0 0 auto;padding-top:1px}
|
||||||
.hbody{display:flex;flex-direction:column;gap:3px;min-width:0}
|
.hbody{display:flex;flex-direction:column;gap:3px;min-width:0}
|
||||||
@ -722,6 +752,13 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
|||||||
.picker-item .pi-body{display:flex;flex-direction:column;gap:2px;min-width:0}
|
.picker-item .pi-body{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||||
.picker-item .pi-name{font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
.picker-item .pi-name{font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
.picker-item .pi-sub{font-size:11.5px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
.picker-item .pi-sub{font-size:11.5px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
/* recently-used working folders */
|
||||||
|
.pick-recent{border-bottom:1px solid var(--border-soft);margin-bottom:6px;padding-bottom:6px}
|
||||||
|
.pick-recent-h{font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--faint);padding:4px 12px 6px;font-weight:600}
|
||||||
|
.picker-item .pi-ic.clock,.picker-item .pi-ic.up{color:var(--muted);background:var(--panel-3)}
|
||||||
|
/* "local only, not in depot yet" tag */
|
||||||
|
.local-tag{margin-left:8px;font-size:9.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--warn);
|
||||||
|
background:rgba(232,176,75,.14);border:1px solid rgba(232,176,75,.32);border-radius:6px;padding:1px 6px;vertical-align:middle}
|
||||||
.picker-foot{padding:12px 20px;border-top:1px solid var(--border-soft);font-size:12px;color:var(--faint)}
|
.picker-foot{padding:12px 20px;border-top:1px solid var(--border-soft);font-size:12px;color:var(--faint)}
|
||||||
.info-body{padding:20px;font-size:13px;line-height:1.6;color:var(--muted)}
|
.info-body{padding:20px;font-size:13px;line-height:1.6;color:var(--muted)}
|
||||||
.info-body b{color:var(--txt)}
|
.info-body b{color:var(--txt)}
|
||||||
|
|||||||
312
src/App.tsx
312
src/App.tsx
@ -8,7 +8,7 @@ import ModelViewer from "./ModelViewer";
|
|||||||
import CodeView from "./CodeView";
|
import CodeView from "./CodeView";
|
||||||
import UpdateBanner from "./Updater";
|
import UpdateBanner from "./Updater";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry } from "./p4";
|
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry } from "./p4";
|
||||||
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
|
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
|
||||||
import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";
|
import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";
|
||||||
|
|
||||||
@ -70,6 +70,13 @@ const I = {
|
|||||||
info: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6"/><path d="M12 11v5M12 8h.01" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
|
info: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6"/><path d="M12 11v5M12 8h.01" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
|
||||||
scan: <svg viewBox="0 0 24 24" fill="none"><path d="M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2M4 12h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
scan: <svg viewBox="0 0 24 24" fill="none"><path d="M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2M4 12h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||||
ue: <svg viewBox="0 0 1280 1280"><path fillRule="evenodd" clipRule="evenodd" fill="currentColor" d="M640,1280c353.46,0,640-286.54,640-640S993.46,0,640,0S0,286.54,0,640S286.54,1280,640,1280z M803.7,995.81c156.5-73.92,205.56-210.43,216.6-263.61c-57.22,58.6-120.53,118-163.11,76.88c0,0-2.33-219.45-2.33-309.43c0-121,114.75-211.18,114.75-211.18c-63.11,11.24-138.89,33.71-219.33,112.65c-7.26,7.2-14.14,14.76-20.62,22.67c-34.47-26.39-79.14-18.48-79.14-18.48c24.14,13.26,48.23,51.88,48.23,83.85v314.26c0,0-52.63,46.3-93.19,46.3c-9.14,0.07-18.17-2.05-26.33-6.18c-8.16-4.13-15.21-10.15-20.56-17.56c-3.21-4.19-5.87-8.78-7.91-13.65V424.07c-11.99,9.89-52.51,18.04-52.51-49.22c0-41.79,30.11-91.6,83.73-122.15c-73.63,11.23-142.59,43.04-198.92,91.76c-42.8,36.98-77.03,82.85-100.31,134.4c-23.28,51.55-35.06,107.55-34.51,164.12c0,0,39.21-122.51,88.32-133.83c7.15-1.88,14.65-2.07,21.89-0.54c7.24,1.53,14.02,4.72,19.81,9.34c5.79,4.61,10.41,10.51,13.51,17.23c3.1,6.72,4.59,14.07,4.34,21.46V844.3c0,29.16-18.8,35.53-36.17,35.22c-11.77-0.83-23.4-3.02-34.66-6.53c35.86,48.53,82.46,88.12,136.15,115.66c53.69,27.54,113.03,42.29,173.37,43.1l106.05-106.6L803.7,995.81z"/></svg>,
|
ue: <svg viewBox="0 0 1280 1280"><path fillRule="evenodd" clipRule="evenodd" fill="currentColor" d="M640,1280c353.46,0,640-286.54,640-640S993.46,0,640,0S0,286.54,0,640S286.54,1280,640,1280z M803.7,995.81c156.5-73.92,205.56-210.43,216.6-263.61c-57.22,58.6-120.53,118-163.11,76.88c0,0-2.33-219.45-2.33-309.43c0-121,114.75-211.18,114.75-211.18c-63.11,11.24-138.89,33.71-219.33,112.65c-7.26,7.2-14.14,14.76-20.62,22.67c-34.47-26.39-79.14-18.48-79.14-18.48c24.14,13.26,48.23,51.88,48.23,83.85v314.26c0,0-52.63,46.3-93.19,46.3c-9.14,0.07-18.17-2.05-26.33-6.18c-8.16-4.13-15.21-10.15-20.56-17.56c-3.21-4.19-5.87-8.78-7.91-13.65V424.07c-11.99,9.89-52.51,18.04-52.51-49.22c0-41.79,30.11-91.6,83.73-122.15c-73.63,11.23-142.59,43.04-198.92,91.76c-42.8,36.98-77.03,82.85-100.31,134.4c-23.28,51.55-35.06,107.55-34.51,164.12c0,0,39.21-122.51,88.32-133.83c7.15-1.88,14.65-2.07,21.89-0.54c7.24,1.53,14.02,4.72,19.81,9.34c5.79,4.61,10.41,10.51,13.51,17.23c3.1,6.72,4.59,14.07,4.34,21.46V844.3c0,29.16-18.8,35.53-36.17,35.22c-11.77-0.83-23.4-3.02-34.66-6.53c35.86,48.53,82.46,88.12,136.15,115.66c53.69,27.54,113.03,42.29,173.37,43.1l106.05-106.6L803.7,995.81z"/></svg>,
|
||||||
|
trash: <svg viewBox="0 0 24 24" fill="none"><path d="M4 7h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/><path d="M18 7l-.8 12a2 2 0 0 1-2 1.9H8.8a2 2 0 0 1-2-1.9L6 7" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 11v5M14 11v5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||||
|
copy: <svg viewBox="0 0 24 24" fill="none"><rect x="9" y="9" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||||
|
reveal: <svg viewBox="0 0 24 24" fill="none"><path d="M3 8a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2M3 8v9a2 2 0 0 0 2 2h13.5a1.5 1.5 0 0 0 1.46-1.14L21.5 12H8.2a1.5 1.5 0 0 0-1.46 1.14L5 20" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||||
|
move: <svg viewBox="0 0 24 24" fill="none"><path d="M14 5h5a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-5M3 12h11M10 8l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||||
|
pencil: <svg viewBox="0 0 24 24" fill="none"><path d="m14.5 5.5 4 4M4 20l1-4L16.5 4.5a2 2 0 0 1 3 3L8 19l-4 1Z" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||||
|
down: <svg viewBox="0 0 24 24" fill="none"><path d="M12 4v14M6 12l6 6 6-6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||||
|
open: <svg viewBox="0 0 24 24" fill="none"><path d="M14 4h6v6M20 4l-8 8M18 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------------- theme hook ---------------- */
|
/* ---------------- theme hook ---------------- */
|
||||||
@ -331,6 +338,7 @@ type ModalState =
|
|||||||
| { kind: "clean" }
|
| { kind: "clean" }
|
||||||
| { kind: "reconcile" }
|
| { kind: "reconcile" }
|
||||||
| { kind: "explorer" }
|
| { kind: "explorer" }
|
||||||
|
| { kind: "folderprops"; path: string; name: string }
|
||||||
| { kind: "ignore" }
|
| { kind: "ignore" }
|
||||||
| { kind: "filelog"; depot: string; name: string }
|
| { kind: "filelog"; depot: string; name: string }
|
||||||
| { kind: "client"; name: string; isNew: boolean }
|
| { kind: "client"; name: string; isNew: boolean }
|
||||||
@ -339,7 +347,7 @@ type ModalState =
|
|||||||
| { kind: "about" };
|
| { kind: "about" };
|
||||||
|
|
||||||
type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null };
|
type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null };
|
||||||
type Transfer = { op: "sync" | "submit"; count: number; file?: string };
|
type Transfer = { op: "sync" | "submit"; count: number; total?: number; file?: string; log: string[]; cancelling?: boolean };
|
||||||
type TermLine = { id: number; cmd?: string; text: string; err?: boolean; running?: boolean };
|
type TermLine = { id: number; cmd?: string; text: string; err?: boolean; running?: boolean };
|
||||||
|
|
||||||
function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, setZoom, onInfo, onSession, onDisconnect }:
|
function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, setZoom, onInfo, onSession, onDisconnect }:
|
||||||
@ -362,6 +370,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
useEffect(() => { try { localStorage.setItem("exd-draft-body", descBody); } catch {} }, [descBody]);
|
useEffect(() => { try { localStorage.setItem("exd-draft-body", descBody); } catch {} }, [descBody]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [scanning, setScanning] = useState(false); // background reconcile in progress
|
const [scanning, setScanning] = useState(false); // background reconcile in progress
|
||||||
|
const [scanCount, setScanCount] = useState(0); // live "found N files" during a streaming scan
|
||||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||||
const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
|
const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
|
||||||
const [history, setHistory] = useState<Change[]>([]);
|
const [history, setHistory] = useState<Change[]>([]);
|
||||||
@ -374,6 +383,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
const [ask, setAsk] = useState<null | { title: string; body: string; confirm: string; danger?: boolean; resolve: (v: boolean) => void }>(null);
|
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 [pending, setPending] = useState<Change[]>([]); // committed-but-not-pushed changelists
|
||||||
const [transfer, setTransfer] = useState<Transfer | null>(null); // live sync/submit file transfer
|
const [transfer, setTransfer] = useState<Transfer | null>(null); // live sync/submit file transfer
|
||||||
|
const [commitProg, setCommitProg] = useState<{ count: number; total: number } | null>(null); // live commit (reopen) progress
|
||||||
const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog
|
const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog
|
||||||
const [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project
|
const [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project
|
||||||
const [slnPath, setSlnPath] = useState(""); // local .sln path in the scope (for building)
|
const [slnPath, setSlnPath] = useState(""); // local .sln path in the scope (for building)
|
||||||
@ -587,9 +597,12 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
finally { if (seq === refreshSeq.current) setBusy(false); }
|
finally { if (seq === refreshSeq.current) setBusy(false); }
|
||||||
if (scan) {
|
if (scan) {
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
try { await p4.scan(s); const f = await p4.opened(s); if (seq === refreshSeq.current) await applyOpened(s, f); }
|
setScanCount(0);
|
||||||
|
// stream the reconcile so the banner shows a live "found N files" counter
|
||||||
|
// (and can be cancelled) instead of an opaque, never-ending spinner
|
||||||
|
try { await p4.scanStream(s); const f = await p4.opened(s); if (seq === refreshSeq.current) await applyOpened(s, f); }
|
||||||
catch (e) { if (seq === refreshSeq.current) flash(String(e), true); }
|
catch (e) { if (seq === refreshSeq.current) flash(String(e), true); }
|
||||||
finally { if (seq === refreshSeq.current) setScanning(false); }
|
finally { if (seq === refreshSeq.current) { setScanning(false); setScanCount(0); } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Default = Perforce-native: just read what's checked out (p4 opened). With
|
// Default = Perforce-native: just read what's checked out (p4 opened). With
|
||||||
@ -607,13 +620,38 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
}).then((u) => { if (dead) u(); else un = u; });
|
}).then((u) => { if (dead) u(); else un = u; });
|
||||||
return () => { dead = true; un?.(); };
|
return () => { dead = true; un?.(); };
|
||||||
}, []);
|
}, []);
|
||||||
// live file-transfer progress (Get Latest / Submit) streamed from the backend
|
// live file-transfer progress (Get Latest / Submit) streamed from the backend.
|
||||||
|
// Accumulates a rolling file log and tracks total for an "N of total" bar.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let un: (() => void) | undefined; let dead = false;
|
let un: (() => void) | undefined; let dead = false;
|
||||||
listen<{ op: "sync" | "submit"; count: number; file?: string; done: boolean }>("p4-transfer", (e) => {
|
listen<{ op: "sync" | "submit"; count: number; total?: number; file?: string; done: boolean; cancelled?: boolean }>("p4-transfer", (e) => {
|
||||||
const p = e.payload;
|
const p = e.payload;
|
||||||
if (p.done) setTransfer(null);
|
if (p.done) { setTransfer(null); return; }
|
||||||
else setTransfer({ op: p.op, count: p.count, file: p.file });
|
setTransfer((prev) => {
|
||||||
|
const log = prev ? prev.log : [];
|
||||||
|
const next = p.file && (log.length === 0 || log[log.length - 1] !== p.file)
|
||||||
|
? [...log, p.file].slice(-200) : log;
|
||||||
|
return { op: p.op, count: p.count, total: p.total || prev?.total, file: p.file, log: next, cancelling: prev?.cancelling };
|
||||||
|
});
|
||||||
|
}).then((u) => { if (dead) u(); else un = u; });
|
||||||
|
return () => { dead = true; un?.(); };
|
||||||
|
}, []);
|
||||||
|
// live commit progress: p4_commit reopens files in batches and emits one event
|
||||||
|
// per batch, so we can show "N / total committed" instead of a mute spinner
|
||||||
|
useEffect(() => {
|
||||||
|
let un: (() => void) | undefined; let dead = false;
|
||||||
|
listen<{ count: number; total: number; done: boolean }>("p4-commit", (e) => {
|
||||||
|
const p = e.payload;
|
||||||
|
setCommitProg(p.done ? null : { count: p.count, total: p.total });
|
||||||
|
}).then((u) => { if (dead) u(); else un = u; });
|
||||||
|
return () => { dead = true; un?.(); };
|
||||||
|
}, []);
|
||||||
|
// live disk-scan progress: the streaming reconcile emits one event per file it
|
||||||
|
// opens, so we can show a rising "found N files" counter during a Rescan
|
||||||
|
useEffect(() => {
|
||||||
|
let un: (() => void) | undefined; let dead = false;
|
||||||
|
listen<{ count: number; file?: string; done: boolean }>("p4-scan", (e) => {
|
||||||
|
if (!e.payload.done) setScanCount(e.payload.count);
|
||||||
}).then((u) => { if (dead) u(); else un = u; });
|
}).then((u) => { if (dead) u(); else un = u; });
|
||||||
return () => { dead = true; un?.(); };
|
return () => { dead = true; un?.(); };
|
||||||
}, []);
|
}, []);
|
||||||
@ -705,6 +743,23 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
// remember which view-panel tabs are open across restarts
|
// remember which view-panel tabs are open across restarts
|
||||||
useEffect(() => { try { localStorage.setItem("exd-viewtabs", JSON.stringify(viewTabs)); } catch {} }, [viewTabs]);
|
useEffect(() => { try { localStorage.setItem("exd-viewtabs", JSON.stringify(viewTabs)); } catch {} }, [viewTabs]);
|
||||||
|
|
||||||
|
// In a shipped (production) build, kill the WebView's own right-click menu
|
||||||
|
// (Back / Reload / Save as / Print / Inspect) and the devtools hotkeys — this
|
||||||
|
// is a desktop app, not a web page. In dev we keep them for debugging.
|
||||||
|
useEffect(() => {
|
||||||
|
if (import.meta.env.DEV) return;
|
||||||
|
const noCtx = (e: MouseEvent) => e.preventDefault(); // our own context menus render as React popups, unaffected
|
||||||
|
const noKeys = (e: KeyboardEvent) => {
|
||||||
|
const k = e.key.toUpperCase();
|
||||||
|
if (k === "F12" || (e.ctrlKey && e.shiftKey && (k === "I" || k === "J" || k === "C")) || (e.ctrlKey && k === "U")) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("contextmenu", noCtx);
|
||||||
|
window.addEventListener("keydown", noKeys, true);
|
||||||
|
return () => { document.removeEventListener("contextmenu", noCtx); window.removeEventListener("keydown", noKeys, true); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// check "am I behind the server" when connected or the working folder changes
|
// check "am I behind the server" when connected or the working folder changes
|
||||||
useEffect(() => { if (info?.clientName && !workOffline) checkBehind(); /* eslint-disable-next-line */ }, [activePath, info?.clientName, workOffline]);
|
useEffect(() => { if (info?.clientName && !workOffline) checkBehind(); /* eslint-disable-next-line */ }, [activePath, info?.clientName, workOffline]);
|
||||||
|
|
||||||
@ -800,6 +855,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
setModal(null);
|
setModal(null);
|
||||||
setActivePath(path);
|
setActivePath(path);
|
||||||
saveScope(info?.clientName || "", path); // remember the working folder per workspace
|
saveScope(info?.clientName || "", path); // remember the working folder per workspace
|
||||||
|
if (path) pushRecentScope(info?.clientName || "", path); // Recently list (skip whole-workspace)
|
||||||
setHistory([]); setDetail(null); setSel(null);
|
setHistory([]); setDetail(null); setSel(null);
|
||||||
refresh(path);
|
refresh(path);
|
||||||
if (tab === "history") loadHistory(path);
|
if (tab === "history") loadHistory(path);
|
||||||
@ -867,6 +923,50 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
function newWorkspace() {
|
function newWorkspace() {
|
||||||
setPrompt({ title: t("New workspace"), label: t("Workspace (client) name"), value: `${session?.user || info?.userName || "user"}_new`, onSave: (v) => { setPrompt(null); const n = v.trim(); if (n) setModal({ kind: "client", name: n, isNew: true }); } });
|
setPrompt({ title: t("New workspace"), label: t("Workspace (client) name"), value: `${session?.user || info?.userName || "user"}_new`, onSave: (v) => { setPrompt(null); const n = v.trim(); if (n) setModal({ kind: "client", name: n, isNew: true }); } });
|
||||||
}
|
}
|
||||||
|
// create a new folder / project in the workspace under `scope` (depot path, or "" = root)
|
||||||
|
function newFolder(scope: string) {
|
||||||
|
const where = scope || t("workspace root");
|
||||||
|
setPrompt({ title: t("New folder in {where}", { where }), label: t("Folder name — nest with / (e.g. NewGame or NewGame/Source)"), value: "", onSave: async (v) => {
|
||||||
|
setPrompt(null);
|
||||||
|
const n = v.trim();
|
||||||
|
if (!n) return;
|
||||||
|
try {
|
||||||
|
await p4.makeFolder(scope, n);
|
||||||
|
flash(t("Created “{name}” with a README placeholder — commit it to keep the folder on the server.", { name: n }));
|
||||||
|
await refresh();
|
||||||
|
} catch (e) { flash(String(e), true); }
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
// rename / move a depot folder (p4 move) — review in Changes, then Submit
|
||||||
|
function renameFolder(path: string) {
|
||||||
|
const cur = splitPath(path).name || path;
|
||||||
|
setPrompt({ title: t("Rename folder"), label: t("New name for “{name}”", { name: cur }), value: cur, onSave: async (v) => {
|
||||||
|
setPrompt(null);
|
||||||
|
const n = v.trim();
|
||||||
|
if (!n || n === cur) return;
|
||||||
|
try { const r = await p4.renameFolder(path, n); flash(t("Renamed to “{name}” — {n} file(s) moved. Commit to apply.", { name: n, n: r.length })); await refresh(); }
|
||||||
|
catch (e) { flash(String(e), true); }
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
// delete a folder — marks its files for delete (reviewable), removes fresh/local ones
|
||||||
|
async function deleteFolder(path: string) {
|
||||||
|
const name = splitPath(path).name || path;
|
||||||
|
if (!(await confirm({ title: t("Delete folder “{name}”?", { name }), body: t("Every file under this folder is marked for delete. Nothing leaves the server until you Submit; freshly-added files are removed right away."), confirm: t("Delete"), danger: true }))) return;
|
||||||
|
try { const r = await p4.deleteFolder(path); flash(r.length ? t("Marked {n} file(s) for delete — Submit to remove the folder.", { n: r.length }) : t("Folder removed.")); await refresh(); }
|
||||||
|
catch (e) { flash(String(e), true); }
|
||||||
|
}
|
||||||
|
// right-click on a folder in the File Tree → folder operations
|
||||||
|
function folderMenu(n: FsEntry, e: ReactMouseEvent) {
|
||||||
|
const isDepot = n.path.startsWith("//");
|
||||||
|
openCtx(e, [
|
||||||
|
{ label: t("New folder / project…"), icon: I.folder, act: () => newFolder(n.path) },
|
||||||
|
...(isDepot ? [{ label: t("Rename…"), icon: I.pencil, act: () => renameFolder(n.path) }] : []),
|
||||||
|
{ label: t("Properties"), icon: I.info, act: () => setModal({ kind: "folderprops", path: n.path, name: n.name }) },
|
||||||
|
{ label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(n.path) },
|
||||||
|
{ label: t("Copy path"), icon: I.copy, act: () => copyText(n.path, t("Path")) },
|
||||||
|
{ label: t("Delete folder…"), icon: I.trash, danger: true, act: () => deleteFolder(n.path) },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Manual re-scan (same background reconcile as auto, on demand).
|
// Manual re-scan (same background reconcile as auto, on demand).
|
||||||
function rescan() { refresh(activePath, true); }
|
function rescan() { refresh(activePath, true); }
|
||||||
@ -882,15 +982,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
|
|
||||||
// open the configuration picker (Development / Shipping / …) before building
|
// open the configuration picker (Development / Shipping / …) before building
|
||||||
function startBuild() {
|
function startBuild() {
|
||||||
if (!slnPath) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; }
|
if (!slnPath && !uproject) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; }
|
||||||
setBuildPick(true);
|
setBuildPick(true);
|
||||||
}
|
}
|
||||||
// build the solution (MSBuild) with the chosen UE configuration, live output
|
// build with the chosen UE configuration, live output. For a real UE project we
|
||||||
|
// pass the .uproject so the backend builds the game module via UnrealBuildTool.
|
||||||
async function buildSln(config: string) {
|
async function buildSln(config: string) {
|
||||||
setBuildPick(false);
|
setBuildPick(false);
|
||||||
if (!slnPath) return;
|
if (!slnPath && !uproject) return;
|
||||||
setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false);
|
setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false);
|
||||||
try { await p4.buildSln(slnPath, config); }
|
try { await p4.buildSln(slnPath, config, uproject); }
|
||||||
catch { /* the overlay already shows the failure line */ }
|
catch { /* the overlay already shows the failure line */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -954,6 +1055,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
} finally { setAiBusy(false); }
|
} finally { setAiBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel an in-progress submit/sync. Safe for submit before the server commit:
|
||||||
|
// p4 submit is transactional, so an aborted transfer leaves the CL pending.
|
||||||
|
function cancelTransfer() {
|
||||||
|
setTransfer((prev) => (prev ? { ...prev, cancelling: true } : prev));
|
||||||
|
p4.transferCancel().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// Submit / push: send all committed (pending) changelists to the server. (like git push)
|
// Submit / push: send all committed (pending) changelists to the server. (like git push)
|
||||||
async function pushAll() {
|
async function pushAll() {
|
||||||
if (!pending.length) return;
|
if (!pending.length) return;
|
||||||
@ -1112,16 +1220,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
const dps = targets.map((f) => f.depotFile || "").filter(Boolean);
|
const dps = targets.map((f) => f.depotFile || "").filter(Boolean);
|
||||||
const isCode = isCodeFile(splitPath(dp).name);
|
const isCode = isCodeFile(splitPath(dp).name);
|
||||||
openCtx(e, [
|
openCtx(e, [
|
||||||
{ label: targets.length > 1 ? t("Discard changes · {n} files", { n: targets.length }) : t("Discard changes"), danger: true, act: () => discard(targets) },
|
{ label: targets.length > 1 ? t("Discard changes · {n} files", { n: targets.length }) : t("Discard changes"), icon: I.trash, danger: true, act: () => discard(targets) },
|
||||||
{ label: t("Lock (exclusive)"), icon: I.lock, act: () => lockFiles(dps, true) },
|
{ label: t("Lock (exclusive)"), icon: I.lock, act: () => lockFiles(dps, true) },
|
||||||
{ label: t("Unlock"), icon: I.unlock, act: () => lockFiles(dps, false) },
|
{ label: t("Unlock"), icon: I.unlock, act: () => lockFiles(dps, false) },
|
||||||
{ label: t("Set exclusive-lock type (+l)"), icon: I.lock, act: () => setExclusiveType(dps) },
|
{ label: t("Set exclusive-lock type (+l)"), icon: I.lock, act: () => setExclusiveType(dps) },
|
||||||
...(workOffline ? [{ label: t("Make writable (offline edit)"), icon: I.unlock, act: () => makeWritable(targets) }] : []),
|
...(workOffline ? [{ label: t("Make writable (offline edit)"), icon: I.unlock, act: () => makeWritable(targets) }] : []),
|
||||||
...pending.map((cl) => ({ label: t("Move to #{n}", { n: cl.change || "" }), act: () => moveToCL(cl.change || "", dps) })),
|
...pending.map((cl) => ({ label: t("Move to #{n}", { n: cl.change || "" }), icon: I.move, act: () => moveToCL(cl.change || "", dps) })),
|
||||||
...(isCode ? [{ label: t("Open in {editor}", { editor: effEditorName }), icon: I.vscode, act: () => { p4.openInEditor(dp, effEditorId).then(() => flash(t("Opening in {editor}…", { editor: effEditorName }))).catch((err) => flash(String(err), true)); } }] : []),
|
...(isCode ? [{ label: t("Open in {editor}", { editor: effEditorName }), icon: I.vscode, act: () => { p4.openInEditor(dp, effEditorId).then(() => flash(t("Opening in {editor}…", { editor: effEditorName }))).catch((err) => flash(String(err), true)); } }] : []),
|
||||||
...(isCode ? [{ label: t("Blame (annotate)"), act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []),
|
...(isCode ? [{ label: t("Blame (annotate)"), icon: I.people, act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []),
|
||||||
{ label: t("Open in Explorer"), act: () => reveal(dp) },
|
{ label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(dp) },
|
||||||
{ label: t("Copy path"), act: () => copyText(dp, t("Path")) },
|
{ label: t("Copy path"), icon: I.copy, act: () => copyText(dp, t("Path")) },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
async function lockFiles(dps: string[], lock: boolean) {
|
async function lockFiles(dps: string[], lock: boolean) {
|
||||||
@ -1204,6 +1312,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
File: [
|
File: [
|
||||||
{ label: t("Switch workspace…"), icon: I.monitor, hint: t("Change which Perforce workspace (client) you're working in."), act: openWorkspaces },
|
{ label: t("Switch workspace…"), icon: I.monitor, hint: t("Change which Perforce workspace (client) you're working in."), act: openWorkspaces },
|
||||||
{ label: t("New workspace…"), icon: I.monitor, hint: t("Create a new workspace mapping depot folders to a local folder."), act: newWorkspace },
|
{ label: t("New workspace…"), icon: I.monitor, hint: t("Create a new workspace mapping depot folders to a local folder."), act: newWorkspace },
|
||||||
|
{ label: t("New folder / project…"), icon: I.folder, hint: t("Create a new folder in the working area (with a README placeholder) — e.g. a separate project."), act: () => newFolder(activePath) },
|
||||||
...(info?.clientName ? [{ label: t("Edit current workspace…"), icon: I.gear, hint: t("Edit the current workspace spec — root folder and depot view mapping."), act: () => setModal({ kind: "client", name: info.clientName as string, isNew: false }) }] : []),
|
...(info?.clientName ? [{ label: t("Edit current workspace…"), icon: I.gear, hint: t("Edit the current workspace spec — root folder and depot view mapping."), act: () => setModal({ kind: "client", name: info.clientName as string, isNew: false }) }] : []),
|
||||||
{ label: t("Choose working folder…"), icon: I.folder, hint: t("Pick which depot folder the app shows and syncs."), act: () => browseTo("") },
|
{ label: t("Choose working folder…"), icon: I.folder, hint: t("Pick which depot folder the app shows and syncs."), act: () => browseTo("") },
|
||||||
{ label: t("Disconnect"), icon: I.power, hint: t("Sign out and return to the connection screen."), act: onDisconnect },
|
{ label: t("Disconnect"), icon: I.power, hint: t("Sign out and return to the connection screen."), act: onDisconnect },
|
||||||
@ -1242,7 +1351,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
{ label: t("Streams…"), icon: I.branch, hint: t("Switch stream, merge down from the parent, or copy up to it."), act: () => setModal({ kind: "streams" }) },
|
{ label: t("Streams…"), icon: I.branch, hint: t("Switch stream, merge down from the parent, or copy up to it."), act: () => setModal({ kind: "streams" }) },
|
||||||
{ label: t("Jobs…"), icon: I.log, hint: t("Perforce's task/bug tracker — create jobs and attach them to changelists."), act: () => setModal({ kind: "jobs" }) },
|
{ label: t("Jobs…"), icon: I.log, hint: t("Perforce's task/bug tracker — create jobs and attach them to changelists."), act: () => setModal({ kind: "jobs" }) },
|
||||||
{ label: t("Edit .p4ignore…"), icon: I.gear, hint: t("Edit which files reconcile / add ignore (build output, caches)."), act: () => setModal({ kind: "ignore" }) },
|
{ label: t("Edit .p4ignore…"), icon: I.gear, hint: t("Edit which files reconcile / add ignore (build output, caches)."), act: () => setModal({ kind: "ignore" }) },
|
||||||
{ label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, hint: t("Compile the Visual Studio solution found in the working folder."), act: startBuild },
|
{ label: (slnPath || uproject) ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, hint: t("Compile the game C++ — Unreal projects build via UnrealBuildTool (game module only), plain C++ via MSBuild."), act: startBuild },
|
||||||
{ label: t("People & Roles…"), icon: I.people, hint: t("See who works on this depot and their roles."), act: () => setModal({ kind: "users" }) },
|
{ label: t("People & Roles…"), icon: I.people, hint: t("See who works on this depot and their roles."), act: () => setModal({ kind: "users" }) },
|
||||||
{ label: t("Settings…"), icon: I.gear, hint: t("App preferences — language, theme, editor, and more."), act: () => setModal({ kind: "settings" }) },
|
{ label: t("Settings…"), icon: I.gear, hint: t("App preferences — language, theme, editor, and more."), act: () => setModal({ kind: "settings" }) },
|
||||||
],
|
],
|
||||||
@ -1278,9 +1387,9 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
<div className="tzone" onClick={openWorkspaces} title={t("Switch workspace · right-click for more")}
|
<div className="tzone" onClick={openWorkspaces} title={t("Switch workspace · right-click for more")}
|
||||||
onContextMenu={(e) => openCtx(e, [
|
onContextMenu={(e) => openCtx(e, [
|
||||||
{ label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("workspace") },
|
{ label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("workspace") },
|
||||||
{ label: t("Open in Explorer"), act: () => reveal(String(info?.clientRoot || "")) },
|
{ label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(String(info?.clientRoot || "")) },
|
||||||
{ label: t("Switch workspace…"), act: openWorkspaces },
|
{ label: t("Switch workspace…"), icon: I.monitor, act: openWorkspaces },
|
||||||
{ label: t("Copy name"), act: () => copyText(info?.clientName || "", t("Workspace name")) },
|
{ label: t("Copy name"), icon: I.copy, act: () => copyText(info?.clientName || "", t("Workspace name")) },
|
||||||
])}>
|
])}>
|
||||||
<span className="ic">{I.monitor}</span>
|
<span className="ic">{I.monitor}</span>
|
||||||
<span className="tzmeta"><span className="lbl">{t("Workspace")}</span><span className="val">{info?.clientName || "—"}</span></span>
|
<span className="tzmeta"><span className="lbl">{t("Workspace")}</span><span className="val">{info?.clientName || "—"}</span></span>
|
||||||
@ -1290,12 +1399,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
<div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title={t("Drag to resize")} />
|
<div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title={t("Drag to resize")} />
|
||||||
<div className="tzone" onClick={() => browseTo("")} title={t("Choose the project working folder · right-click for more")}
|
<div className="tzone" onClick={() => browseTo("")} title={t("Choose the project working folder · right-click for more")}
|
||||||
onContextMenu={(e) => openCtx(e, [
|
onContextMenu={(e) => openCtx(e, [
|
||||||
|
{ label: t("New folder / project…"), icon: I.folder, act: () => newFolder(activePath) },
|
||||||
|
...(activePath ? [{ label: t("Rename…"), icon: I.pencil, act: () => renameFolder(activePath) }] : []),
|
||||||
|
...(activePath ? [{ label: t("Properties"), icon: I.info, act: () => setModal({ kind: "folderprops", path: activePath, name: splitPath(activePath).name || activePath }) }] : []),
|
||||||
{ label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("depot") },
|
{ label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("depot") },
|
||||||
...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
|
...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
|
||||||
...(slnPath ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: startBuild }] : []),
|
...((slnPath || uproject) ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: startBuild }] : []),
|
||||||
{ label: t("Open in Explorer"), act: () => reveal(activePath || String(info?.clientRoot || "")) },
|
{ label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(activePath || String(info?.clientRoot || "")) },
|
||||||
{ label: t("Choose another folder…"), act: () => browseTo("") },
|
{ label: t("Choose another folder…"), icon: I.folder, act: () => browseTo("") },
|
||||||
{ label: t("Copy path"), act: () => copyText(activePath || "//…", t("Path")) },
|
{ label: t("Copy path"), icon: I.copy, act: () => copyText(activePath || "//…", t("Path")) },
|
||||||
|
...(activePath ? [{ label: t("Delete folder…"), icon: I.trash, danger: true, act: () => deleteFolder(activePath) }] : []),
|
||||||
])}>
|
])}>
|
||||||
<span className="ic">{I.branch}</span>
|
<span className="ic">{I.branch}</span>
|
||||||
<span className="tzmeta"><span className="lbl">{t("Working folder")}</span><span className="val">{activePath || t("whole workspace (//…)")}</span></span>
|
<span className="tzmeta"><span className="lbl">{t("Working folder")}</span><span className="val">{activePath || t("whole workspace (//…)")}</span></span>
|
||||||
@ -1372,16 +1485,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
<span className="cl-actions" onClick={(e) => e.stopPropagation()}>
|
<span className="cl-actions" onClick={(e) => e.stopPropagation()}>
|
||||||
<button className="cl-submit" onClick={() => submitOne(ch)} title={t("Submit to server")}>↑</button>
|
<button className="cl-submit" onClick={() => submitOne(ch)} title={t("Submit to server")}>↑</button>
|
||||||
<button className="cl-more" onClick={(e) => openCtx(e, [
|
<button className="cl-more" onClick={(e) => openCtx(e, [
|
||||||
{ label: t("Edit description"), act: () => editDesc(cl) },
|
{ label: t("Edit description"), icon: I.pencil, act: () => editDesc(cl) },
|
||||||
{ label: t("Attach job…"), icon: I.log, act: () => attachJob(ch) },
|
{ label: t("Attach job…"), icon: I.log, act: () => attachJob(ch) },
|
||||||
{ label: t("Submit & keep checked out (-r)"), icon: I.up, act: () => submitWithOpts(ch, true, false) },
|
{ label: t("Submit & keep checked out (-r)"), icon: I.up, act: () => submitWithOpts(ch, true, false) },
|
||||||
{ label: t("Submit, reverting unchanged"), icon: I.up, act: () => submitWithOpts(ch, false, true) },
|
{ label: t("Submit, reverting unchanged"), icon: I.up, act: () => submitWithOpts(ch, false, true) },
|
||||||
{ label: t("Shelve (share on server)"), icon: I.shelf, act: () => shelveCL(ch) },
|
{ label: t("Shelve (share on server)"), icon: I.shelf, act: () => shelveCL(ch) },
|
||||||
{ label: t("Unshelve into workspace"), act: () => unshelveCL(ch) },
|
{ label: t("Unshelve into workspace"), icon: I.down, act: () => unshelveCL(ch) },
|
||||||
{ label: t("Submit shelved (-e)"), icon: I.up, act: () => submitShelved(ch) },
|
{ label: t("Submit shelved (-e)"), icon: I.up, act: () => submitShelved(ch) },
|
||||||
{ label: t("Delete shelved files"), danger: true, act: () => deleteShelfCL(ch) },
|
{ label: t("Delete shelved files"), icon: I.trash, danger: true, act: () => deleteShelfCL(ch) },
|
||||||
{ label: t("Uncommit (back to working)"), act: () => uncommit(cf) },
|
{ label: t("Uncommit (back to working)"), icon: I.back, act: () => uncommit(cf) },
|
||||||
{ label: t("Discard changes · {n} files", { n: cf.length }), danger: true, act: () => discard(cf) },
|
{ label: t("Discard changes · {n} files", { n: cf.length }), icon: I.trash, danger: true, act: () => discard(cf) },
|
||||||
])} title={t("More")}>⋯</button>
|
])} title={t("More")}>⋯</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -1408,7 +1521,12 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
{scanning && (
|
{scanning && (
|
||||||
<div className="scanbar">
|
<div className="scanbar">
|
||||||
<span className="ldr" />
|
<span className="ldr" />
|
||||||
<span>{t("Scanning disk for changes…")}{!activePath && t(" for the whole workspace this is slow — pick a project folder above")}</span>
|
<span>
|
||||||
|
{t("Scanning disk for changes…")}
|
||||||
|
{scanCount > 0 && <b className="scancount"> {t("found {n} files", { n: scanCount.toLocaleString() })}</b>}
|
||||||
|
{scanCount === 0 && !activePath && t(" for the whole workspace this is slow — pick a project folder above")}
|
||||||
|
</span>
|
||||||
|
<button className="scan-cancel" onClick={() => { p4.scanCancel().catch(() => {}); }} title={t("Stop the scan. Files already found stay staged.")}>{t("Cancel")}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="chhead">
|
<div className="chhead">
|
||||||
@ -1420,7 +1538,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
</div>
|
</div>
|
||||||
{view.length === 0 ? (
|
{view.length === 0 ? (
|
||||||
<div className="empty">
|
<div className="empty">
|
||||||
{scanning ? <><span className="ldr" />{t("Scanning disk for changes…")}</> : <>
|
{scanning ? <><span className="ldr" />{t("Scanning disk for changes…")}{scanCount > 0 && <b className="scancount"> {t("found {n} files", { n: scanCount.toLocaleString() })}</b>}</> : <>
|
||||||
<svg viewBox="0 0 24 24" fill="none"><path d="M12 15V3M8 7l4-4 4 4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
<svg viewBox="0 0 24 24" fill="none"><path d="M12 15V3M8 7l4-4 4 4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||||
{busy ? t("Loading…") : t("No open files yet.\nEdit assets in Unreal (Source Control → Perforce) — they show up here on their own.\n\nEdited files outside Perforce? Actions → “Rescan changes”.")}
|
{busy ? t("Loading…") : t("No open files yet.\nEdit assets in Unreal (Source Control → Perforce) — they show up here on their own.\n\nEdited files outside Perforce? Actions → “Rescan changes”.")}
|
||||||
</>}
|
</>}
|
||||||
@ -1440,7 +1558,11 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
<textarea className="desc" placeholder={t("Description (optional)…")} value={descBody} onChange={(e) => setDescBody(e.target.value)}
|
<textarea className="desc" placeholder={t("Description (optional)…")} value={descBody} onChange={(e) => setDescBody(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
|
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
|
||||||
<button className="submit" disabled={busy || !desc.trim() || checked.size === 0} onClick={doCommit}>
|
<button className="submit" disabled={busy || !desc.trim() || checked.size === 0} onClick={doCommit}>
|
||||||
{I.check}{t("Commit changelist")} <b>{t("· {n} files", { n: checked.size })}</b>
|
{commitProg ? (
|
||||||
|
<><span className="ldr sm" />{t("Committing…")} <b>{commitProg.count.toLocaleString()} / {commitProg.total.toLocaleString()}</b></>
|
||||||
|
) : (
|
||||||
|
<>{I.check}{t("Commit changelist")} <b>{t("· {n} files", { n: checked.size })}</b></>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="addhint">{t("Commit = local (revertable). Send to the server — Submit, top-right.")}</div>
|
<div className="addhint">{t("Commit = local (revertable). Send to the server — Submit, top-right.")}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1448,11 +1570,11 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
) : (
|
) : (
|
||||||
<HistoryList history={history} sizes={changeSizes} busy={busy} sel={selChange} onSelect={openChange}
|
<HistoryList history={history} sizes={changeSizes} busy={busy} sel={selChange} onSelect={openChange}
|
||||||
onContext={(c, e) => openCtx(e, [
|
onContext={(c, e) => openCtx(e, [
|
||||||
{ label: t("Open this changelist"), act: () => openChange(c) },
|
{ label: t("Open this changelist"), icon: I.open, act: () => openChange(c) },
|
||||||
{ label: t("Sync workspace to #{n}", { n: c.change || "" }), icon: I.clock, act: () => syncTo(c.change || "") },
|
{ label: t("Sync workspace to #{n}", { n: c.change || "" }), icon: I.clock, act: () => syncTo(c.change || "") },
|
||||||
{ label: t("Revert (undo) #{n}", { n: c.change || "" }), danger: true, act: () => revertChange(c) },
|
{ label: t("Revert (undo) #{n}", { n: c.change || "" }), icon: I.revert, danger: true, act: () => revertChange(c) },
|
||||||
{ label: t("Copy number #{n}", { n: c.change || "" }), act: () => copyText(c.change || "", t("Number")) },
|
{ label: t("Copy number #{n}", { n: c.change || "" }), icon: I.copy, act: () => copyText(c.change || "", t("Number")) },
|
||||||
{ label: t("Copy description"), act: () => copyText((c.desc || "").trim(), t("Description")) },
|
{ label: t("Copy description"), icon: I.copy, act: () => copyText((c.desc || "").trim(), t("Description")) },
|
||||||
])} />
|
])} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -1499,7 +1621,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
)}
|
)}
|
||||||
{viewTabs.includes("tree") && (
|
{viewTabs.includes("tree") && (
|
||||||
<div className="vpane tree" style={{ display: viewTab === "tree" ? "flex" : "none" }}>
|
<div className="vpane tree" style={{ display: viewTab === "tree" ? "flex" : "none" }}>
|
||||||
<FileTree scope={activePath} onReveal={reveal} onFlash={flash} onOpenFile={openTreeFile} initialSide={treeSide} sideNonce={treeNonce} />
|
<FileTree scope={activePath} onReveal={reveal} onFlash={flash} onOpenFile={openTreeFile} onNewFolder={(side) => newFolder(side === "depot" ? activePath : "")} onFolderMenu={folderMenu} initialSide={treeSide} sideNonce={treeNonce} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{viewTabs.includes("locks") && (
|
{viewTabs.includes("locks") && (
|
||||||
@ -1556,6 +1678,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
{modal?.kind === "clean" && <CleanModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
|
{modal?.kind === "clean" && <CleanModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
|
||||||
{modal?.kind === "reconcile" && <ReconcileModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
|
{modal?.kind === "reconcile" && <ReconcileModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
|
||||||
{modal?.kind === "explorer" && <ExplorerModal scope={activePath} onClose={() => setModal(null)} onReveal={reveal} onFlash={flash} />}
|
{modal?.kind === "explorer" && <ExplorerModal scope={activePath} onClose={() => setModal(null)} onReveal={reveal} onFlash={flash} />}
|
||||||
|
{modal?.kind === "folderprops" && <FolderPropsModal path={modal.path} name={modal.name} onClose={() => setModal(null)} onReveal={reveal} onRename={() => { const p = modal.path; setModal(null); renameFolder(p); }} onDelete={() => { const p = modal.path; setModal(null); deleteFolder(p); }} onCopy={(p) => copyText(p, t("Path"))} />}
|
||||||
{modal?.kind === "ignore" && <IgnoreModal onClose={() => setModal(null)} onFlash={flash} />}
|
{modal?.kind === "ignore" && <IgnoreModal onClose={() => setModal(null)} onFlash={flash} />}
|
||||||
{modal?.kind === "filelog" && <FilelogModal depot={modal.depot} name={modal.name} onClose={() => setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />}
|
{modal?.kind === "filelog" && <FilelogModal depot={modal.depot} name={modal.name} onClose={() => setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />}
|
||||||
{modal?.kind === "client" && <ClientSpecModal name={modal.name} isNew={modal.isNew} onClose={() => setModal(null)} onFlash={flash} onSaved={(c) => { setModal(null); if (modal.isNew) { switchWorkspace(c); } else { refresh(); } }} />}
|
{modal?.kind === "client" && <ClientSpecModal name={modal.name} isNew={modal.isNew} onClose={() => setModal(null)} onFlash={flash} onSaved={(c) => { setModal(null); if (modal.isNew) { switchWorkspace(c); } else { refresh(); } }} />}
|
||||||
@ -1564,7 +1687,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
|||||||
{resolveOpen && <ResolveModal files={needResolve} onResolve={doResolve} onClose={() => setResolveOpen(false)} />}
|
{resolveOpen && <ResolveModal files={needResolve} onResolve={doResolve} onClose={() => setResolveOpen(false)} />}
|
||||||
{ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
|
{ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
|
||||||
{prompt && <TextPrompt title={prompt.title} label={prompt.label} value={prompt.value} onSave={prompt.onSave} onClose={() => setPrompt(null)} />}
|
{prompt && <TextPrompt title={prompt.title} label={prompt.label} value={prompt.value} onSave={prompt.onSave} onClose={() => setPrompt(null)} />}
|
||||||
{showXfer && transfer && <TransferDialog transfer={transfer} />}
|
{showXfer && transfer && <TransferDialog transfer={transfer} onCancel={cancelTransfer} />}
|
||||||
{buildPick && <BuildPicker onPick={buildSln} onClose={() => setBuildPick(false)} />}
|
{buildPick && <BuildPicker onPick={buildSln} onClose={() => setBuildPick(false)} />}
|
||||||
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
|
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
|
||||||
{buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
|
{buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
|
||||||
@ -1605,6 +1728,7 @@ function AppModal({ modal, info, session, light, toggleTheme, lang, setLang, zoo
|
|||||||
|
|
||||||
{modal.kind === "scope" && (() => {
|
{modal.kind === "scope" && (() => {
|
||||||
const path = modal.path;
|
const path = modal.path;
|
||||||
|
const recents = recentScopes(info?.clientName || "");
|
||||||
const parts = path.replace(/^\/+/, "").split("/").filter(Boolean); // e.g. ["depot","Proj"]
|
const parts = path.replace(/^\/+/, "").split("/").filter(Boolean); // e.g. ["depot","Proj"]
|
||||||
const parentPath = parts.length ? "//" + parts.slice(0, -1).join("/") : "";
|
const parentPath = parts.length ? "//" + parts.slice(0, -1).join("/") : "";
|
||||||
const up = parts.length > 0;
|
const up = parts.length > 0;
|
||||||
@ -1619,6 +1743,18 @@ function AppModal({ modal, info, session, light, toggleTheme, lang, setLang, zoo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="picker-list">
|
<div className="picker-list">
|
||||||
|
{recents.length > 0 && (
|
||||||
|
<div className="pick-recent">
|
||||||
|
<div className="pick-recent-h">{t("Recently")}</div>
|
||||||
|
{recents.map((rp) => (
|
||||||
|
<div key={rp} className="picker-item" onClick={() => onChoose(rp)}>
|
||||||
|
<span className="pi-ic clock"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6" /><path d="M12 8v4l2.5 2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg></span>
|
||||||
|
<span className="pi-body"><span className="pi-name">{leaf(rp)}</span><span className="pi-sub">{rp}</span></span>
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" style={{ color: "var(--faint)" }}><path d="M5 12h14m-6-6 6 6-6 6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{up && (
|
{up && (
|
||||||
<div className="picker-item" onClick={() => onBrowse(parentPath)}>
|
<div className="picker-item" onClick={() => onBrowse(parentPath)}>
|
||||||
<span className="pi-ic up"><svg viewBox="0 0 24 24" fill="none"><path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
|
<span className="pi-ic up"><svg viewBox="0 0 24 24" fill="none"><path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
|
||||||
@ -1629,7 +1765,7 @@ function AppModal({ modal, info, session, light, toggleTheme, lang, setLang, zoo
|
|||||||
{modal.dirs.map((d) => (
|
{modal.dirs.map((d) => (
|
||||||
<div key={d.dir} className="picker-item" onClick={() => onBrowse(d.dir || "")}>
|
<div key={d.dir} className="picker-item" onClick={() => onBrowse(d.dir || "")}>
|
||||||
<span className="pi-ic">{folder}</span>
|
<span className="pi-ic">{folder}</span>
|
||||||
<span className="pi-body"><span className="pi-name">{leaf(d.dir)}</span><span className="pi-sub">{d.dir}</span></span>
|
<span className="pi-body"><span className="pi-name">{leaf(d.dir)}{d.local ? <span className="local-tag">{t("local")}</span> : null}</span><span className="pi-sub">{d.dir}</span></span>
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" style={{ color: "var(--faint)" }}><path d="m9 6 6 6-6 6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" style={{ color: "var(--faint)" }}><path d="m9 6 6 6-6 6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -1987,24 +2123,40 @@ function UnrealTab({ uproject }: { uproject: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- server transfer progress (P4V-style) ---------------- */
|
/* ---------------- server transfer progress (P4V-style) ---------------- */
|
||||||
function TransferDialog({ transfer }: { transfer: Transfer }) {
|
function TransferDialog({ transfer, onCancel }: { transfer: Transfer; onCancel: () => void }) {
|
||||||
const up = transfer.op === "submit";
|
const up = transfer.op === "submit";
|
||||||
|
const total = transfer.total || 0;
|
||||||
|
const pct = total > 0 ? Math.min(100, Math.round((transfer.count / total) * 100)) : 0;
|
||||||
|
const logRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [transfer.log.length]);
|
||||||
return (
|
return (
|
||||||
<div className="modal-back xfer-back">
|
<div className="modal-back xfer-back">
|
||||||
<div className="xfer">
|
<div className="xfer">
|
||||||
<div className="xfer-head">
|
<div className="xfer-head">
|
||||||
<span className={"xfer-ic" + (up ? " up" : " down")}>
|
<span className={"xfer-ic" + (up ? " up" : " down")}>
|
||||||
{up
|
{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 20V6M6 12l6-6 6 6" stroke="#fff" 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>}
|
: <svg viewBox="0 0 24 24" fill="none"><path d="M12 4v14M6 12l6 6 6-6" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
||||||
</span>
|
</span>
|
||||||
<div className="xfer-ttl">
|
<div className="xfer-ttl">
|
||||||
<b>{up ? t("Submitting to server") : t("Getting latest from server")}</b>
|
<b>{up ? t("Submitting to server") : t("Getting latest from server")}</b>
|
||||||
<span>{t("{n} files", { n: transfer.count })}</span>
|
<span>{total > 0
|
||||||
|
? `${transfer.count.toLocaleString()} / ${total.toLocaleString()} · ${pct}%`
|
||||||
|
: t("{n} files", { n: transfer.count })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{up && (
|
||||||
|
<button className="xfer-cancel" onClick={onCancel} disabled={transfer.cancelling}
|
||||||
|
title={t("Stop before the server commit. Files already sent are not applied; the changelist stays pending.")}>
|
||||||
|
{transfer.cancelling ? t("Cancelling…") : t("Cancel")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="xfer-file">{transfer.file || (up ? t("Uploading…") : t("Downloading…"))}</div>
|
<div className={"xfer-bar" + (total > 0 ? " det" : "")}>
|
||||||
<div className="xfer-bar"><span /></div>
|
<span style={total > 0 ? { width: pct + "%" } : undefined} />
|
||||||
|
</div>
|
||||||
|
{transfer.log.length > 0
|
||||||
|
? <div className="xfer-log" ref={logRef}>{transfer.log.map((f, i) => <div key={i}>{f}</div>)}</div>
|
||||||
|
: <div className="xfer-file">{transfer.file || (up ? t("Uploading…") : t("Downloading…"))}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -2249,7 +2401,7 @@ function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string;
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- .p4ignore editor ---------------- */
|
/* ---------------- .p4ignore editor ---------------- */
|
||||||
const P4IGNORE_TEMPLATE = "# Unreal Engine — keep generated / local files out of Perforce\nBinaries/\nBuild/\nDerivedDataCache/\nIntermediate/\nSaved/\n.vs/\n*.sln\n*.suo\n*.opensdf\n*.sdf\n*.VC.db\n*.VC.opendb\n";
|
const P4IGNORE_TEMPLATE = "# Unreal Engine — keep generated / local files out of Perforce\n# (build intermediates come down read-only and break rebuilds if committed)\nBinaries/\nBuild/\nDerivedDataCache/\nIntermediate/\nSaved/\nobj/\n.vs/\n*.sln\n*.suo\n*.opensdf\n*.sdf\n*.VC.db\n*.VC.opendb\n";
|
||||||
function IgnoreModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
|
function IgnoreModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [busy, setBusy] = useState(true);
|
const [busy, setBusy] = useState(true);
|
||||||
@ -2336,6 +2488,43 @@ function CleanModal({ scope, onClose, onFlash, onDone }: { scope: string; onClos
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- folder properties ---------------- */
|
||||||
|
function FolderPropsModal({ path, name, onClose, onReveal, onRename, onDelete, onCopy }: { path: string; name: string; onClose: () => void; onReveal: (p: string) => void; onRename: () => void; onDelete: () => void; onCopy: (p: string) => void }) {
|
||||||
|
const [info, setInfo] = useState<{ fileCount: number; size: number } | null>(null);
|
||||||
|
const isDepot = path.startsWith("//");
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
p4.folderInfo(path).then((i) => live && setInfo(i)).catch(() => live && setInfo({ fileCount: 0, size: 0 }));
|
||||||
|
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
document.addEventListener("keydown", k);
|
||||||
|
return () => { live = false; document.removeEventListener("keydown", k); };
|
||||||
|
}, [path]); // eslint-disable-line
|
||||||
|
return (
|
||||||
|
<div className="modal-back" onClick={onClose}>
|
||||||
|
<div className="picker" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="picker-head">{I.folder}<h3>{t("Folder properties")}</h3>
|
||||||
|
<button className="x" onClick={onClose} style={{ marginLeft: "auto" }}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
|
||||||
|
</div>
|
||||||
|
<div className="fp-body">
|
||||||
|
<div className="fp-name">{I.folder}<b>{name}</b></div>
|
||||||
|
<div className="fp-grid">
|
||||||
|
<span className="k">{t("Path")}</span><span className="v mono">{path}</span>
|
||||||
|
<span className="k">{t("Files")}</span><span className="v">{info ? info.fileCount.toLocaleString() : "…"}</span>
|
||||||
|
<span className="k">{t("Total size")}</span><span className="v">{info ? humanSize(info.size) : "…"}</span>
|
||||||
|
<span className="k">{t("Location")}</span><span className="v">{isDepot ? t("depot") : t("local only")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)", gap: 8 }}>
|
||||||
|
<button className="mbtn ghost" onClick={() => onReveal(path)}>{t("Open in Explorer")}</button>
|
||||||
|
<button className="mbtn ghost" onClick={() => onCopy(path)}>{t("Copy path")}</button>
|
||||||
|
{isDepot && <button className="mbtn ghost" onClick={onRename}>{t("Rename…")}</button>}
|
||||||
|
<button className="mbtn danger" style={{ marginLeft: "auto" }} onClick={onDelete}>{t("Delete folder…")}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- reconcile offline work ---------------- */
|
/* ---------------- reconcile offline work ---------------- */
|
||||||
// P4V-style "Reconcile Offline Work": scan the workspace for changes made while
|
// P4V-style "Reconcile Offline Work": scan the workspace for changes made while
|
||||||
// disconnected (edits / adds / deletes) and open the picked ones into a changelist.
|
// disconnected (edits / adds / deletes) and open the picked ones into a changelist.
|
||||||
@ -2418,7 +2607,7 @@ const sortBySize = (a: FsEntry[]) => [...a].sort((x, y) => (y.size - x.size) ||
|
|||||||
|
|
||||||
// one row in the tree; folders lazily load their children on first expand,
|
// one row in the tree; folders lazily load their children on first expand,
|
||||||
// files trigger onOpen (open in the File Viewer / reveal).
|
// files trigger onOpen (open in the File Viewer / reveal).
|
||||||
function ExpNode({ node, depth, load, max, onReveal, onFlash, onOpen }: { node: FsEntry; depth: number; load: (p: string) => Promise<FsEntry[]>; max: number; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpen?: (n: FsEntry) => void }) {
|
function ExpNode({ node, depth, load, max, onReveal, onFlash, onOpen, onMenu }: { node: FsEntry; depth: number; load: (p: string) => Promise<FsEntry[]>; max: number; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpen?: (n: FsEntry) => void; onMenu?: (n: FsEntry, e: ReactMouseEvent) => void }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [kids, setKids] = useState<FsEntry[] | null>(null);
|
const [kids, setKids] = useState<FsEntry[] | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@ -2436,7 +2625,7 @@ function ExpNode({ node, depth, load, max, onReveal, onFlash, onOpen }: { node:
|
|||||||
const childMax = Math.max(1, ...(kids || []).map((k) => k.size));
|
const childMax = Math.max(1, ...(kids || []).map((k) => k.size));
|
||||||
return (
|
return (
|
||||||
<div className="exp-node">
|
<div className="exp-node">
|
||||||
<div className={"exp-row" + (node.isDir || onOpen ? " dir" : "")} style={{ paddingLeft: 10 + depth * 15 }} onClick={activate} title={node.path}>
|
<div className={"exp-row" + (node.isDir || onOpen ? " dir" : "")} style={{ paddingLeft: 10 + depth * 15 }} onClick={activate} onContextMenu={(e) => { if (node.isDir && onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(node, e); } }} title={node.path}>
|
||||||
<span className="exp-caret">{node.isDir ? (busy ? <span className="ldr sm" /> : <span className={"chevi" + (open ? " open" : "")}>{I.chevron}</span>) : null}</span>
|
<span className="exp-caret">{node.isDir ? (busy ? <span className="ldr sm" /> : <span className={"chevi" + (open ? " open" : "")}>{I.chevron}</span>) : null}</span>
|
||||||
<span className="exp-ic">{node.isDir ? I.folder : FILE_GLYPH}</span>
|
<span className="exp-ic">{node.isDir ? I.folder : FILE_GLYPH}</span>
|
||||||
<span className="exp-name">{node.name}</span>
|
<span className="exp-name">{node.name}</span>
|
||||||
@ -2445,14 +2634,14 @@ function ExpNode({ node, depth, load, max, onReveal, onFlash, onOpen }: { node:
|
|||||||
<span className="exp-size">{humanSize(node.size)}</span>
|
<span className="exp-size">{humanSize(node.size)}</span>
|
||||||
<span className="exp-open" onClick={(e) => { e.stopPropagation(); onReveal(node.path); }} title={t("Open in Explorer")}>{I.folder}</span>
|
<span className="exp-open" onClick={(e) => { e.stopPropagation(); onReveal(node.path); }} title={t("Open in Explorer")}>{I.folder}</span>
|
||||||
</div>
|
</div>
|
||||||
{open && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={depth + 1} load={load} max={childMax} onReveal={onReveal} onFlash={onFlash} onOpen={onOpen} />)}
|
{open && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={depth + 1} load={load} max={childMax} onReveal={onReveal} onFlash={onFlash} onOpen={onOpen} onMenu={onMenu} />)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The tree body (Depot / Workspace toggle + lazy size tree), reused by the
|
// The tree body (Depot / Workspace toggle + lazy size tree), reused by the
|
||||||
// Files & Sizes modal AND the embedded File Tree view-panel.
|
// Files & Sizes modal AND the embedded File Tree view-panel.
|
||||||
function FileTree({ scope, onReveal, onFlash, onOpenFile, initialSide, sideNonce }: { scope: string; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpenFile?: (n: FsEntry, side: "depot" | "workspace") => void; initialSide?: "depot" | "workspace"; sideNonce?: number }) {
|
function FileTree({ scope, onReveal, onFlash, onOpenFile, onNewFolder, onFolderMenu, initialSide, sideNonce }: { scope: string; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpenFile?: (n: FsEntry, side: "depot" | "workspace") => void; onNewFolder?: (side: "depot" | "workspace") => void; onFolderMenu?: (n: FsEntry, e: ReactMouseEvent) => void; initialSide?: "depot" | "workspace"; sideNonce?: number }) {
|
||||||
const [side, setSide] = useState<"depot" | "workspace">(initialSide ?? "depot");
|
const [side, setSide] = useState<"depot" | "workspace">(initialSide ?? "depot");
|
||||||
useEffect(() => { if (initialSide) setSide(initialSide); }, [sideNonce]); // eslint-disable-line
|
useEffect(() => { if (initialSide) setSide(initialSide); }, [sideNonce]); // eslint-disable-line
|
||||||
const [kids, setKids] = useState<FsEntry[] | null>(null);
|
const [kids, setKids] = useState<FsEntry[] | null>(null);
|
||||||
@ -2477,12 +2666,13 @@ function FileTree({ scope, onReveal, onFlash, onOpenFile, initialSide, sideNonce
|
|||||||
<button className={"exp-tab" + (side === "workspace" ? " on" : "")} onClick={() => setSide("workspace")}>{t("Workspace")}</button>
|
<button className={"exp-tab" + (side === "workspace" ? " on" : "")} onClick={() => setSide("workspace")}>{t("Workspace")}</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="ft-total">{rootLabel} · <b>{humanSize(total)}</b></span>
|
<span className="ft-total">{rootLabel} · <b>{humanSize(total)}</b></span>
|
||||||
|
{onNewFolder && <button className="ft-new" title={t("New folder / project…")} onClick={() => onNewFolder(side)}>{I.folder}<span>{t("New")}</span></button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="exp-tree">
|
<div className="exp-tree">
|
||||||
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Weighing…")}</div>}
|
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Weighing…")}</div>}
|
||||||
{!busy && err && <div className="ur-empty">{err}</div>}
|
{!busy && err && <div className="ur-empty">{err}</div>}
|
||||||
{!busy && kids && kids.length === 0 && !err && <div className="ur-empty">{t("Empty.")}</div>}
|
{!busy && kids && kids.length === 0 && !err && <div className="ur-empty">{t("Empty.")}</div>}
|
||||||
{!busy && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={0} load={load} max={max} onReveal={onReveal} onFlash={onFlash} onOpen={onOpenFile ? (n) => onOpenFile(n, side) : undefined} />)}
|
{!busy && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={0} load={load} max={max} onReveal={onReveal} onFlash={onFlash} onOpen={onOpenFile ? (n) => onOpenFile(n, side) : undefined} onMenu={onFolderMenu} />)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -3033,29 +3223,35 @@ function HistoryList({ history, sizes, busy, sel, onSelect, onContext }: { histo
|
|||||||
|
|
||||||
/* ---------------- file thumbnail (lazy, cached) — like Anchorpoint's list previews ---------------- */
|
/* ---------------- file thumbnail (lazy, cached) — like Anchorpoint's list previews ---------------- */
|
||||||
const thumbCache = new Map<string, string | null>(); // depotFile -> objectURL | null(no thumb)
|
const thumbCache = new Map<string, string | null>(); // depotFile -> objectURL | null(no thumb)
|
||||||
|
// image formats the webview can actually decode in an <img>. Game/DCC formats
|
||||||
|
// (tga, dds, exr, psd, tiff, hdr…) can't — those get the glyph, not a broken img.
|
||||||
|
const IMG_RENDERABLE = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico", "bmp"]);
|
||||||
function Thumb({ file }: { file: OpenedFile }) {
|
function Thumb({ file }: { file: OpenedFile }) {
|
||||||
const dp = file.depotFile || "";
|
const dp = file.depotFile || "";
|
||||||
const { name } = splitPath(dp);
|
const { name } = splitPath(dp);
|
||||||
const kind = kindOf(name);
|
const kind = kindOf(name);
|
||||||
|
const ext = (name.split(".").pop() || "").toLowerCase();
|
||||||
const [url, setUrl] = useState<string | null>(() => thumbCache.get(dp) ?? null);
|
const [url, setUrl] = useState<string | null>(() => thumbCache.get(dp) ?? null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (kind !== "image" && kind !== "uasset") return;
|
const renderableImage = kind === "image" && IMG_RENDERABLE.has(ext);
|
||||||
|
if (!renderableImage && kind !== "uasset") return;
|
||||||
if (thumbCache.has(dp)) { setUrl(thumbCache.get(dp) ?? null); return; }
|
if (thumbCache.has(dp)) { setUrl(thumbCache.get(dp) ?? null); return; }
|
||||||
let live = true;
|
let live = true;
|
||||||
// .uasset → backend pulls just the embedded thumbnail (no huge IPC transfer);
|
// .uasset → backend pulls just the embedded thumbnail (no huge IPC transfer);
|
||||||
// real images → fetch the file bytes and blob them directly
|
// real images → fetch the file bytes and blob them directly
|
||||||
const job = kind === "uasset"
|
const job = kind === "uasset"
|
||||||
? p4.uassetThumb(dp, false).then((buf) => URL.createObjectURL(new Blob([buf], { type: "image/png" })))
|
? p4.uassetThumb(dp, false).then((buf) => URL.createObjectURL(new Blob([buf], { type: "image/png" })))
|
||||||
: p4.readDepot(dp).then((buf) => {
|
: p4.readDepot(dp).then((buf) =>
|
||||||
const ext = (name.split(".").pop() || "png").toLowerCase();
|
URL.createObjectURL(new Blob([buf], { type: ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}` }))
|
||||||
return URL.createObjectURL(new Blob([buf], { type: ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}` }));
|
);
|
||||||
});
|
|
||||||
job.then((u) => { thumbCache.set(dp, u); if (live) setUrl(u); })
|
job.then((u) => { thumbCache.set(dp, u); if (live) setUrl(u); })
|
||||||
.catch(() => { thumbCache.set(dp, null); });
|
.catch(() => { thumbCache.set(dp, null); });
|
||||||
return () => { live = false; };
|
return () => { live = false; };
|
||||||
}, [dp]);
|
}, [dp]);
|
||||||
|
|
||||||
if (url) return <img className="fthumb" src={url} alt="" />;
|
// onError: some formats (or corrupt files) still fail to decode after all —
|
||||||
|
// fall back to the glyph instead of the browser's broken-image icon
|
||||||
|
if (url) return <img className="fthumb" src={url} alt="" onError={() => { thumbCache.set(dp, null); setUrl(null); }} />;
|
||||||
const ic = kind === "model" ? "◆" : kind === "image" || kind === "uasset" ? "▦" : "≡";
|
const ic = kind === "model" ? "◆" : kind === "image" || kind === "uasset" ? "▦" : "≡";
|
||||||
return <span className={"fthumb ico k-" + kind}>{ic}</span>;
|
return <span className={"fthumb ico k-" + kind}>{ic}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/i18n.ts
34
src/i18n.ts
@ -148,6 +148,11 @@ const D: Record<string, Tr> = {
|
|||||||
"Save": { ru: "Сохранить", de: "Speichern", fr: "Enregistrer", es: "Guardar" },
|
"Save": { ru: "Сохранить", de: "Speichern", fr: "Enregistrer", es: "Guardar" },
|
||||||
"Scanning disk for changes…": { ru: "Ищу изменения на диске…", de: "Suche Änderungen auf der Festplatte…", fr: "Recherche de modifications sur le disque…", es: "Buscando cambios en el disco…" },
|
"Scanning disk for changes…": { ru: "Ищу изменения на диске…", de: "Suche Änderungen auf der Festplatte…", fr: "Recherche de modifications sur le disque…", es: "Buscando cambios en el disco…" },
|
||||||
" for the whole workspace this is slow — pick a project folder above": { ru: " для всего workspace это долго — выбери рабочую папку проекта сверху", de: " für den ganzen Workspace ist das langsam — wähle oben einen Projektordner", fr: " pour tout le workspace c’est lent — choisissez un dossier de projet en haut", es: " para todo el workspace es lento — elige una carpeta de proyecto arriba" },
|
" for the whole workspace this is slow — pick a project folder above": { ru: " для всего workspace это долго — выбери рабочую папку проекта сверху", de: " für den ganzen Workspace ist das langsam — wähle oben einen Projektordner", fr: " pour tout le workspace c’est lent — choisissez un dossier de projet en haut", es: " para todo el workspace es lento — elige una carpeta de proyecto arriba" },
|
||||||
|
"found {n} files": { ru: "найдено {n} файлов", de: "{n} Dateien gefunden", fr: "{n} fichiers trouvés", es: "{n} archivos encontrados" },
|
||||||
|
"Committing…": { ru: "Коммичу…", de: "Übertrage…", fr: "Validation…", es: "Confirmando…" },
|
||||||
|
"Cancelling…": { ru: "Отменяю…", de: "Breche ab…", fr: "Annulation…", es: "Cancelando…" },
|
||||||
|
"Stop before the server commit. Files already sent are not applied; the changelist stays pending.": { ru: "Остановить до серверного коммита. Уже отправленные файлы не применяются; changelist останется в pending.", de: "Vor dem Server-Commit stoppen. Bereits gesendete Dateien werden nicht übernommen; die Changelist bleibt ausstehend.", fr: "Arrêter avant le commit serveur. Les fichiers déjà envoyés ne sont pas appliqués ; la changelist reste en attente.", es: "Detener antes del commit del servidor. Los archivos ya enviados no se aplican; la changelist queda pendiente." },
|
||||||
|
"Stop the scan. Files already found stay staged.": { ru: "Остановить скан. Уже найденные файлы останутся помеченными.", de: "Scan stoppen. Bereits gefundene Dateien bleiben vorgemerkt.", fr: "Arrêter l’analyse. Les fichiers déjà trouvés restent préparés.", es: "Detener el escaneo. Los archivos ya encontrados quedan preparados." },
|
||||||
"{a} of {b} selected": { ru: "{a} из {b} выбрано", de: "{a} von {b} ausgewählt", fr: "{a} sur {b} sélectionnés", es: "{a} de {b} seleccionados" },
|
"{a} of {b} selected": { ru: "{a} из {b} выбрано", de: "{a} von {b} ausgewählt", fr: "{a} sur {b} sélectionnés", es: "{a} de {b} seleccionados" },
|
||||||
"Select all": { ru: "Выделить все", de: "Alle auswählen", fr: "Tout sélectionner", es: "Seleccionar todo" },
|
"Select all": { ru: "Выделить все", de: "Alle auswählen", fr: "Tout sélectionner", es: "Seleccionar todo" },
|
||||||
"Deselect all": { ru: "Снять выделение", de: "Auswahl aufheben", fr: "Tout désélectionner", es: "Deseleccionar todo" },
|
"Deselect all": { ru: "Снять выделение", de: "Auswahl aufheben", fr: "Tout désélectionner", es: "Deseleccionar todo" },
|
||||||
@ -539,6 +544,7 @@ const D: Record<string, Tr> = {
|
|||||||
"Perforce's task/bug tracker — create jobs and attach them to changelists.": { ru: "Трекер задач/багов Perforce — создавай джобы и привязывай к changelists.", de: "Perforce-Aufgaben-/Bug-Tracker — Jobs erstellen und an Changelists anhängen.", fr: "Le suivi de tâches/bugs de Perforce — crée des jobs et attache-les aux changelists.", es: "El rastreador de tareas/errores de Perforce — crea jobs y adjúntalos a changelists." },
|
"Perforce's task/bug tracker — create jobs and attach them to changelists.": { ru: "Трекер задач/багов Perforce — создавай джобы и привязывай к changelists.", de: "Perforce-Aufgaben-/Bug-Tracker — Jobs erstellen und an Changelists anhängen.", fr: "Le suivi de tâches/bugs de Perforce — crée des jobs et attache-les aux changelists.", es: "El rastreador de tareas/errores de Perforce — crea jobs y adjúntalos a changelists." },
|
||||||
"Edit which files reconcile / add ignore (build output, caches).": { ru: "Изменить, какие файлы игнорируют reconcile / add (сборка, кэши).", de: "Bearbeiten, welche Dateien reconcile / add ignorieren (Build-Ausgabe, Caches).", fr: "Modifier quels fichiers reconcile / add ignorent (sortie de build, caches).", es: "Editar qué archivos ignoran reconcile / add (salida de compilación, cachés)." },
|
"Edit which files reconcile / add ignore (build output, caches).": { ru: "Изменить, какие файлы игнорируют reconcile / add (сборка, кэши).", de: "Bearbeiten, welche Dateien reconcile / add ignorieren (Build-Ausgabe, Caches).", fr: "Modifier quels fichiers reconcile / add ignorent (sortie de build, caches).", es: "Editar qué archivos ignoran reconcile / add (salida de compilación, cachés)." },
|
||||||
"Compile the Visual Studio solution found in the working folder.": { ru: "Скомпилировать решение Visual Studio, найденное в рабочей папке.", de: "Die im Arbeitsordner gefundene Visual-Studio-Solution kompilieren.", fr: "Compiler la solution Visual Studio trouvée dans le dossier de travail.", es: "Compilar la solución de Visual Studio encontrada en la carpeta de trabajo." },
|
"Compile the Visual Studio solution found in the working folder.": { ru: "Скомпилировать решение Visual Studio, найденное в рабочей папке.", de: "Die im Arbeitsordner gefundene Visual-Studio-Solution kompilieren.", fr: "Compiler la solution Visual Studio trouvée dans le dossier de travail.", es: "Compilar la solución de Visual Studio encontrada en la carpeta de trabajo." },
|
||||||
|
"Compile the game C++ — Unreal projects build via UnrealBuildTool (game module only), plain C++ via MSBuild.": { ru: "Скомпилировать C++ игры — Unreal-проекты собираются через UnrealBuildTool (только модуль игры), обычный C++ — через MSBuild.", de: "Game-C++ kompilieren — Unreal-Projekte über UnrealBuildTool (nur Spielmodul), reines C++ über MSBuild.", fr: "Compiler le C++ du jeu — les projets Unreal via UnrealBuildTool (module de jeu seulement), le C++ simple via MSBuild.", es: "Compilar el C++ del juego — los proyectos Unreal vía UnrealBuildTool (solo el módulo del juego), C++ simple vía MSBuild." },
|
||||||
"See who works on this depot and their roles.": { ru: "Посмотреть, кто работает с этим депо и их роли.", de: "Sehen, wer an diesem Depot arbeitet und welche Rollen sie haben.", fr: "Voir qui travaille sur ce depot et leurs rôles.", es: "Ver quién trabaja en este depot y sus roles." },
|
"See who works on this depot and their roles.": { ru: "Посмотреть, кто работает с этим депо и их роли.", de: "Sehen, wer an diesem Depot arbeitet und welche Rollen sie haben.", fr: "Voir qui travaille sur ce depot et leurs rôles.", es: "Ver quién trabaja en este depot y sus roles." },
|
||||||
"App preferences — language, theme, editor, and more.": { ru: "Настройки приложения — язык, тема, редактор и прочее.", de: "App-Einstellungen — Sprache, Design, Editor und mehr.", fr: "Préférences de l'app — langue, thème, éditeur et plus.", es: "Preferencias de la app — idioma, tema, editor y más." },
|
"App preferences — language, theme, editor, and more.": { ru: "Настройки приложения — язык, тема, редактор и прочее.", de: "App-Einstellungen — Sprache, Design, Editor und mehr.", fr: "Préférences de l'app — langue, thème, éditeur et plus.", es: "Preferencias de la app — idioma, tema, editor y más." },
|
||||||
"Version and information about Exbyte Depot.": { ru: "Версия и информация о Exbyte Depot.", de: "Version und Informationen zu Exbyte Depot.", fr: "Version et informations sur Exbyte Depot.", es: "Versión e información sobre Exbyte Depot." },
|
"Version and information about Exbyte Depot.": { ru: "Версия и информация о Exbyte Depot.", de: "Version und Informationen zu Exbyte Depot.", fr: "Version et informations sur Exbyte Depot.", es: "Versión e información sobre Exbyte Depot." },
|
||||||
@ -575,6 +581,34 @@ const D: Record<string, Tr> = {
|
|||||||
"Duration": { ru: "Длительность", de: "Dauer", fr: "Durée", es: "Duración" },
|
"Duration": { ru: "Длительность", de: "Dauer", fr: "Durée", es: "Duración" },
|
||||||
"server revision": { ru: "ревизия с сервера", de: "Server-Revision", fr: "révision serveur", es: "revisión del servidor" },
|
"server revision": { ru: "ревизия с сервера", de: "Server-Revision", fr: "révision serveur", es: "revisión del servidor" },
|
||||||
"Everyone's checked-out / exclusively-locked files, docked into the view panel.": { ru: "Все чекнутые / эксклюзивно залоченные файлы всех участников, встроенные в панель просмотра.", de: "Alle ausgecheckten / exklusiv gesperrten Dateien aller Nutzer, angedockt im Ansichts-Panel.", fr: "Les fichiers extraits / verrouillés en exclusivité de tout le monde, ancrés dans le panneau.", es: "Los archivos extraídos / bloqueados en exclusiva de todos, anclados en el panel." },
|
"Everyone's checked-out / exclusively-locked files, docked into the view panel.": { ru: "Все чекнутые / эксклюзивно залоченные файлы всех участников, встроенные в панель просмотра.", de: "Alle ausgecheckten / exklusiv gesperrten Dateien aller Nutzer, angedockt im Ansichts-Panel.", fr: "Les fichiers extraits / verrouillés en exclusivité de tout le monde, ancrés dans le panneau.", es: "Los archivos extraídos / bloqueados en exclusiva de todos, anclados en el panel." },
|
||||||
|
// ---- new folder / project ----
|
||||||
|
"New": { ru: "Создать", de: "Neu", fr: "Nouveau", es: "Nuevo" },
|
||||||
|
"workspace root": { ru: "корень воркспейса", de: "Arbeitsbereich-Root", fr: "racine de l'espace de travail", es: "raíz del espacio de trabajo" },
|
||||||
|
"New folder / project…": { ru: "Новая папка / проект…", de: "Neuer Ordner / Projekt…", fr: "Nouveau dossier / projet…", es: "Nueva carpeta / proyecto…" },
|
||||||
|
"Create a new folder in the working area (with a README placeholder) — e.g. a separate project.": { ru: "Создать новую папку в рабочей области (с плейсхолдером README) — например, отдельный проект.", de: "Einen neuen Ordner im Arbeitsbereich erstellen (mit README-Platzhalter) — z. B. ein separates Projekt.", fr: "Créer un nouveau dossier dans l'espace de travail (avec un README) — p. ex. un projet distinct.", es: "Crear una nueva carpeta en el área de trabajo (con un README) — p. ej. un proyecto aparte." },
|
||||||
|
"New folder in {where}": { ru: "Новая папка в {where}", de: "Neuer Ordner in {where}", fr: "Nouveau dossier dans {where}", es: "Nueva carpeta en {where}" },
|
||||||
|
"Folder name — nest with / (e.g. NewGame or NewGame/Source)": { ru: "Имя папки — вложенность через / (напр. NewGame или NewGame/Source)", de: "Ordnername — verschachteln mit / (z. B. NewGame oder NewGame/Source)", fr: "Nom du dossier — imbriquer avec / (p. ex. NewGame ou NewGame/Source)", es: "Nombre de la carpeta — anida con / (p. ej. NewGame o NewGame/Source)" },
|
||||||
|
"Created “{name}” with a README placeholder — commit it to keep the folder on the server.": { ru: "Создано «{name}» с плейсхолдером README — закоммить, чтобы папка сохранилась на сервере.", de: "„{name}“ mit README-Platzhalter erstellt — committe es, damit der Ordner auf dem Server bleibt.", fr: "« {name} » créé avec un README — committe-le pour conserver le dossier sur le serveur.", es: "«{name}» creado con un README — haz commit para conservar la carpeta en el servidor." },
|
||||||
|
// ---- rename / delete / properties folder ----
|
||||||
|
"Rename…": { ru: "Переименовать…", de: "Umbenennen…", fr: "Renommer…", es: "Renombrar…" },
|
||||||
|
"Rename folder": { ru: "Переименовать папку", de: "Ordner umbenennen", fr: "Renommer le dossier", es: "Renombrar carpeta" },
|
||||||
|
"New name for “{name}”": { ru: "Новое имя для «{name}»", de: "Neuer Name für „{name}“", fr: "Nouveau nom pour « {name} »", es: "Nuevo nombre para «{name}»" },
|
||||||
|
"Renamed to “{name}” — {n} file(s) moved. Commit to apply.": { ru: "Переименовано в «{name}» — перемещено {n} файл(ов). Закоммить, чтобы применить.", de: "In „{name}“ umbenannt — {n} Datei(en) verschoben. Zum Anwenden committen.", fr: "Renommé en « {name} » — {n} fichier(s) déplacés. Committe pour appliquer.", es: "Renombrado a «{name}» — {n} archivo(s) movidos. Haz commit para aplicar." },
|
||||||
|
"Delete folder…": { ru: "Удалить папку…", de: "Ordner löschen…", fr: "Supprimer le dossier…", es: "Eliminar carpeta…" },
|
||||||
|
"Delete folder “{name}”?": { ru: "Удалить папку «{name}»?", de: "Ordner „{name}“ löschen?", fr: "Supprimer le dossier « {name} » ?", es: "¿Eliminar la carpeta «{name}»?" },
|
||||||
|
"Every file under this folder is marked for delete. Nothing leaves the server until you Submit; freshly-added files are removed right away.": { ru: "Все файлы под этой папкой помечаются на удаление. С сервера ничего не уходит до Submit; свежедобавленные файлы убираются сразу.", de: "Jede Datei unter diesem Ordner wird zum Löschen markiert. Nichts verlässt den Server bis zum Submit; frisch hinzugefügte Dateien werden sofort entfernt.", fr: "Chaque fichier de ce dossier est marqué pour suppression. Rien ne quitte le serveur avant le Submit ; les fichiers récemment ajoutés sont retirés immédiatement.", es: "Todos los archivos de esta carpeta se marcan para eliminar. Nada sale del servidor hasta el Submit; los archivos recién añadidos se quitan al instante." },
|
||||||
|
"Marked {n} file(s) for delete — Submit to remove the folder.": { ru: "Помечено {n} файл(ов) на удаление — Submit уберёт папку.", de: "{n} Datei(en) zum Löschen markiert — Submit entfernt den Ordner.", fr: "{n} fichier(s) marqués pour suppression — Submit retire le dossier.", es: "{n} archivo(s) marcados para eliminar — Submit quita la carpeta." },
|
||||||
|
"Folder removed.": { ru: "Папка удалена.", de: "Ordner entfernt.", fr: "Dossier supprimé.", es: "Carpeta eliminada." },
|
||||||
|
"Delete": { ru: "Удалить", de: "Löschen", fr: "Supprimer", es: "Eliminar" },
|
||||||
|
"Properties": { ru: "Свойства", de: "Eigenschaften", fr: "Propriétés", es: "Propiedades" },
|
||||||
|
"Folder properties": { ru: "Свойства папки", de: "Ordner-Eigenschaften", fr: "Propriétés du dossier", es: "Propiedades de la carpeta" },
|
||||||
|
"Files": { ru: "Файлов", de: "Dateien", fr: "Fichiers", es: "Archivos" },
|
||||||
|
"Total size": { ru: "Общий размер", de: "Gesamtgröße", fr: "Taille totale", es: "Tamaño total" },
|
||||||
|
"Location": { ru: "Расположение", de: "Speicherort", fr: "Emplacement", es: "Ubicación" },
|
||||||
|
"depot": { ru: "депо", de: "Depot", fr: "depot", es: "depot" },
|
||||||
|
"local only": { ru: "только локально", de: "nur lokal", fr: "local uniquement", es: "solo local" },
|
||||||
|
"Recently": { ru: "Недавние", de: "Zuletzt", fr: "Récents", es: "Recientes" },
|
||||||
|
"local": { ru: "локально", de: "lokal", fr: "local", es: "local" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function t(en: string, vars?: Record<string, string | number>): string {
|
export function t(en: string, vars?: Record<string, string | number>): string {
|
||||||
|
|||||||
25
src/p4.ts
25
src/p4.ts
@ -102,12 +102,18 @@ export const p4 = {
|
|||||||
del: (files: string[]) => invoke<OpenedFile[]>("p4_delete", { files }),
|
del: (files: string[]) => invoke<OpenedFile[]>("p4_delete", { files }),
|
||||||
reconcile: (path: string) => invoke<OpenedFile[]>("p4_reconcile", { path }),
|
reconcile: (path: string) => invoke<OpenedFile[]>("p4_reconcile", { path }),
|
||||||
scan: (scope = "") => invoke<OpenedFile[]>("p4_scan", { scope }),
|
scan: (scope = "") => invoke<OpenedFile[]>("p4_scan", { scope }),
|
||||||
|
// streaming disk scan: emits `p4-scan` progress events, returns the file count.
|
||||||
|
// The caller re-reads opened() for the actual list.
|
||||||
|
scanStream: (scope = "") => invoke<number>("p4_scan_stream", { scope }),
|
||||||
|
scanCancel: () => invoke<void>("p4_scan_cancel"),
|
||||||
|
// ask an in-progress submit/sync transfer to stop (safe pre-commit for submit)
|
||||||
|
transferCancel: () => invoke<void>("p4_transfer_cancel"),
|
||||||
describe: (change: string) => invoke<Describe>("p4_describe", { change }),
|
describe: (change: string) => invoke<Describe>("p4_describe", { change }),
|
||||||
undoChange: (change: string) => invoke<string>("p4_undo_change", { change }),
|
undoChange: (change: string) => invoke<string>("p4_undo_change", { change }),
|
||||||
openInExplorer: (target: string) => invoke<void>("open_in_explorer", { target }),
|
openInExplorer: (target: string) => invoke<void>("open_in_explorer", { target }),
|
||||||
findUproject: (scope: string) => invoke<string>("find_uproject", { scope }),
|
findUproject: (scope: string) => invoke<string>("find_uproject", { scope }),
|
||||||
findSln: (scope: string) => invoke<string>("find_sln", { scope }),
|
findSln: (scope: string) => invoke<string>("find_sln", { scope }),
|
||||||
buildSln: (path: string, config: string) => invoke<string>("build_sln", { path, config }),
|
buildSln: (path: string, config: string, uproject = "") => invoke<string>("build_sln", { path, config, uproject }),
|
||||||
launchFile: (path: string) => invoke<void>("launch_file", { path }),
|
launchFile: (path: string) => invoke<void>("launch_file", { path }),
|
||||||
listEditors: () => invoke<Editor[]>("list_editors"),
|
listEditors: () => invoke<Editor[]>("list_editors"),
|
||||||
openInEditor: (depot: string, editor: string) => invoke<void>("open_in_editor", { depot, editor }),
|
openInEditor: (depot: string, editor: string) => invoke<void>("open_in_editor", { depot, editor }),
|
||||||
@ -159,6 +165,10 @@ export const p4 = {
|
|||||||
reopenTo: (change: string, files: string[]) => invoke<string>("p4_reopen_to", { change, files }),
|
reopenTo: (change: string, files: string[]) => invoke<string>("p4_reopen_to", { change, files }),
|
||||||
latestChange: (scope = "") => invoke<Change | null>("p4_latest_change", { scope }),
|
latestChange: (scope = "") => invoke<Change | null>("p4_latest_change", { scope }),
|
||||||
behind: (scope = "") => invoke<{ count: number; sample: string[] }>("p4_behind", { scope }),
|
behind: (scope = "") => invoke<{ count: number; sample: string[] }>("p4_behind", { scope }),
|
||||||
|
makeFolder: (scope: string, name: string) => invoke<string>("make_folder", { scope, name }),
|
||||||
|
deleteFolder: (path: string) => invoke<OpenedFile[]>("delete_folder", { path }),
|
||||||
|
renameFolder: (path: string, newName: string) => invoke<OpenedFile[]>("rename_folder", { path, newName }),
|
||||||
|
folderInfo: (path: string) => invoke<{ path: string; fileCount: number; size: number }>("folder_info", { path }),
|
||||||
// embedded Unreal .uasset thumbnail (PNG bytes), for working copy or history
|
// embedded Unreal .uasset thumbnail (PNG bytes), for working copy or history
|
||||||
uassetThumb: async (spec: string, historical: boolean): Promise<ArrayBuffer> => {
|
uassetThumb: async (spec: string, historical: boolean): Promise<ArrayBuffer> => {
|
||||||
const r: unknown = await invoke("uasset_thumbnail", { spec, historical });
|
const r: unknown = await invoke("uasset_thumbnail", { spec, historical });
|
||||||
@ -185,7 +195,7 @@ export interface Editor { id: string; name: string; exe: string; args: string[]
|
|||||||
export function getEditor(): string { try { return localStorage.getItem("exd-editor") || ""; } catch { return ""; } }
|
export function getEditor(): string { try { return localStorage.getItem("exd-editor") || ""; } catch { return ""; } }
|
||||||
export function setEditorPref(id: string) { try { localStorage.setItem("exd-editor", id); } catch {} }
|
export function setEditorPref(id: string) { try { localStorage.setItem("exd-editor", id); } catch {} }
|
||||||
|
|
||||||
export interface Dir { dir?: string; [k: string]: unknown }
|
export interface Dir { dir?: string; local?: boolean; [k: string]: unknown }
|
||||||
// depot path -> short leaf name (last segment)
|
// depot path -> short leaf name (last segment)
|
||||||
export function leaf(path?: string): string {
|
export function leaf(path?: string): string {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
@ -256,6 +266,17 @@ export function saveScope(client: string, path: string) {
|
|||||||
export function loadScope(client: string): string {
|
export function loadScope(client: string): string {
|
||||||
try { return client ? (localStorage.getItem("exd-scope-" + client) || "") : ""; } catch { return ""; }
|
try { return client ? (localStorage.getItem("exd-scope-" + client) || "") : ""; } catch { return ""; }
|
||||||
}
|
}
|
||||||
|
// last 3 chosen working folders (Recently) — most-recent first, per workspace
|
||||||
|
export function recentScopes(client: string): string[] {
|
||||||
|
try { const a = JSON.parse(localStorage.getItem("exd-recent-" + client) || "[]"); return Array.isArray(a) ? a.slice(0, 3) : []; } catch { return []; }
|
||||||
|
}
|
||||||
|
export function pushRecentScope(client: string, path: string) {
|
||||||
|
try {
|
||||||
|
const cur = recentScopes(client).filter((p) => p !== path);
|
||||||
|
const next = [path, ...cur].slice(0, 3);
|
||||||
|
localStorage.setItem("exd-recent-" + client, JSON.stringify(next));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// format a unix-epoch string as a short local datetime
|
// format a unix-epoch string as a short local datetime
|
||||||
export function fmtTime(t?: string): string {
|
export function fmtTime(t?: string): string {
|
||||||
|
|||||||
18
waitlist-server/.env.example
Normal file
18
waitlist-server/.env.example
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Copy to .env and fill in. NEVER commit .env.
|
||||||
|
|
||||||
|
# Admin login
|
||||||
|
ADMIN_USER=admin
|
||||||
|
# Generate with: npm run set-password
|
||||||
|
ADMIN_PASS_HASH=
|
||||||
|
|
||||||
|
# Session signing key (>=24 chars). Generate with: npm run gen-secret
|
||||||
|
SESSION_SECRET=
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=8787
|
||||||
|
NODE_ENV=development
|
||||||
|
# Set to 1 (or the hop count) when running behind nginx/Caddy so client IPs are correct
|
||||||
|
TRUST_PROXY=0
|
||||||
|
|
||||||
|
# Path to the landing page folder (default: ../landing)
|
||||||
|
# LANDING_DIR=../landing
|
||||||
4
waitlist-server/.gitignore
vendored
Normal file
4
waitlist-server/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
51
waitlist-server/README.md
Normal file
51
waitlist-server/README.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Exbyte Depot — Waitlist server + admin
|
||||||
|
|
||||||
|
Collects waitlist emails from the landing page (deduplicated) and gives you a
|
||||||
|
password-protected admin panel to browse them and export to CSV.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- **express** — HTTP + static landing
|
||||||
|
- **better-sqlite3** — storage; `UNIQUE COLLATE NOCASE` on email → no duplicates, ever
|
||||||
|
- **express-rate-limit** — brute-force protection on the login endpoint
|
||||||
|
- **node:crypto** — scrypt password hashing + HMAC-signed session cookies (no extra deps)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd waitlist-server
|
||||||
|
npm install
|
||||||
|
|
||||||
|
cp .env.example .env
|
||||||
|
npm run gen-secret # → paste into SESSION_SECRET in .env
|
||||||
|
npm run set-password # type a password → paste the ADMIN_PASS_HASH line into .env
|
||||||
|
# set ADMIN_USER in .env (default: admin)
|
||||||
|
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
- Landing: http://localhost:8787/
|
||||||
|
- Admin: http://localhost:8787/admin
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- The landing form POSTs `{ email }` to **`POST /api/waitlist`**.
|
||||||
|
- Invalid emails are rejected; a repeat email is silently deduped (no dup row).
|
||||||
|
- A hidden honeypot field (`website`) traps bots.
|
||||||
|
- **`/admin`** requires login. Session is a signed, HttpOnly, SameSite=Strict cookie
|
||||||
|
(Secure in production), valid 8h. Wrong logins are rate-limited (8 / 15 min / IP).
|
||||||
|
- **Export CSV**: `GET /admin/export.csv` streams all signups (UTF-8 + BOM for Excel).
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
- No plaintext password anywhere — only a scrypt hash in `.env` (git-ignored).
|
||||||
|
- Refuses to start without `ADMIN_USER`, `ADMIN_PASS_HASH`, and a strong `SESSION_SECRET`.
|
||||||
|
- Security headers set (CSP, X-Frame-Options: DENY, nosniff, no-referrer).
|
||||||
|
- Behind a reverse proxy, set `TRUST_PROXY=1` so rate-limiting and stored IPs are correct,
|
||||||
|
and terminate TLS at the proxy (cookies become `Secure` when `NODE_ENV=production`).
|
||||||
|
|
||||||
|
## Deploy (Linux VPS, sketch)
|
||||||
|
1. `NODE_ENV=production`, real `.env`, `npm ci --omit=dev`.
|
||||||
|
2. Run under systemd (or pm2); put nginx/Caddy in front for TLS + `TRUST_PROXY=1`.
|
||||||
|
3. Point your domain at it; the landing is served from `../landing` (or set `LANDING_DIR`).
|
||||||
|
|
||||||
|
## Data
|
||||||
|
SQLite file at `data/waitlist.db` (git-ignored). Back it up to keep your list.
|
||||||
129
waitlist-server/lib/auth.js
Normal file
129
waitlist-server/lib/auth.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
// Authentication for the admin panel — no external crypto deps, all from node:crypto.
|
||||||
|
//
|
||||||
|
// • Passwords are stored as scrypt hashes ("scrypt$salt$hash", both base64url) in
|
||||||
|
// the ADMIN_PASS_HASH env var. Never a plaintext password anywhere.
|
||||||
|
// • Sessions are stateless, signed cookies: base64url(payload).base64url(hmac).
|
||||||
|
// Tamper-proof (HMAC-SHA256 with SESSION_SECRET) and self-expiring. No server
|
||||||
|
// session store to leak or lose on restart.
|
||||||
|
// • Every compare is constant-time (timingSafeEqual) to avoid timing oracles.
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 32 };
|
||||||
|
|
||||||
|
/** Hash a plaintext password → "scrypt$<salt>$<hash>" (for storing in .env). */
|
||||||
|
export function hashPassword(password) {
|
||||||
|
const salt = crypto.randomBytes(16);
|
||||||
|
const hash = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT);
|
||||||
|
return `scrypt$${salt.toString("base64url")}$${hash.toString("base64url")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify a plaintext password against a stored "scrypt$salt$hash" string. */
|
||||||
|
export function verifyPassword(password, stored) {
|
||||||
|
if (typeof stored !== "string") return false;
|
||||||
|
const parts = stored.split("$");
|
||||||
|
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
|
||||||
|
let salt, expected;
|
||||||
|
try {
|
||||||
|
salt = Buffer.from(parts[1], "base64url");
|
||||||
|
expected = Buffer.from(parts[2], "base64url");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (expected.length !== SCRYPT.keylen) return false;
|
||||||
|
const got = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT);
|
||||||
|
return crypto.timingSafeEqual(got, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time string compare (for the username). */
|
||||||
|
export function safeEqual(a, b) {
|
||||||
|
const ba = Buffer.from(String(a));
|
||||||
|
const bb = Buffer.from(String(b));
|
||||||
|
if (ba.length !== bb.length) return false;
|
||||||
|
return crypto.timingSafeEqual(ba, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- signed session cookies -------------------------------------------------
|
||||||
|
|
||||||
|
const COOKIE_NAME = "exd_admin";
|
||||||
|
|
||||||
|
function sign(data, secret) {
|
||||||
|
return crypto.createHmac("sha256", secret).update(data).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a signed session token that expires after `ttlMs`. */
|
||||||
|
export function issueToken(username, secret, ttlMs) {
|
||||||
|
const payload = JSON.stringify({ u: username, exp: Date.now() + ttlMs });
|
||||||
|
const body = Buffer.from(payload).toString("base64url");
|
||||||
|
return `${body}.${sign(body, secret)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify a session token → { u } if valid & unexpired, else null. */
|
||||||
|
export function verifyToken(token, secret) {
|
||||||
|
if (typeof token !== "string" || !token.includes(".")) return null;
|
||||||
|
const [body, mac] = token.split(".");
|
||||||
|
if (!body || !mac) return null;
|
||||||
|
const expected = sign(body, secret);
|
||||||
|
const a = Buffer.from(mac);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!payload || typeof payload.exp !== "number" || Date.now() > payload.exp) return null;
|
||||||
|
return { u: payload.u };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the admin session cookie out of a raw Cookie header. */
|
||||||
|
export function readSessionCookie(req) {
|
||||||
|
const raw = req.headers.cookie || "";
|
||||||
|
for (const part of raw.split(";")) {
|
||||||
|
const i = part.indexOf("=");
|
||||||
|
if (i === -1) continue;
|
||||||
|
if (part.slice(0, i).trim() === COOKIE_NAME) {
|
||||||
|
return decodeURIComponent(part.slice(i + 1).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionCookie(res, token, { secure, ttlMs }) {
|
||||||
|
const attrs = [
|
||||||
|
`${COOKIE_NAME}=${encodeURIComponent(token)}`,
|
||||||
|
"HttpOnly",
|
||||||
|
"SameSite=Strict",
|
||||||
|
"Path=/",
|
||||||
|
`Max-Age=${Math.floor(ttlMs / 1000)}`,
|
||||||
|
];
|
||||||
|
if (secure) attrs.push("Secure");
|
||||||
|
res.append("Set-Cookie", attrs.join("; "));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionCookie(res, { secure }) {
|
||||||
|
const attrs = [`${COOKIE_NAME}=`, "HttpOnly", "SameSite=Strict", "Path=/", "Max-Age=0"];
|
||||||
|
if (secure) attrs.push("Secure");
|
||||||
|
res.append("Set-Cookie", attrs.join("; "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Express middleware: allow through only requests with a valid session. */
|
||||||
|
export function requireAuth(secret) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const tok = readSessionCookie(req);
|
||||||
|
const sess = tok && verifyToken(tok, secret);
|
||||||
|
if (!sess) {
|
||||||
|
// req.path is stripped of the mount prefix, so match on originalUrl
|
||||||
|
const url = req.originalUrl || req.url || "";
|
||||||
|
if (url.startsWith("/admin/api") || url.endsWith(".csv")) {
|
||||||
|
return res.status(401).json({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
return res.redirect("/admin/login");
|
||||||
|
}
|
||||||
|
req.admin = sess;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { COOKIE_NAME };
|
||||||
56
waitlist-server/lib/db.js
Normal file
56
waitlist-server/lib/db.js
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
// SQLite storage for waitlist signups. One row per unique email — dedup is
|
||||||
|
// enforced by the DB itself (UNIQUE, case-insensitive), so a repeat signup is a
|
||||||
|
// no-op rather than a duplicate row.
|
||||||
|
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DATA_DIR = path.join(__dirname, "..", "data");
|
||||||
|
const DB_PATH = process.env.DB_PATH || path.join(DATA_DIR, "waitlist.db");
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
db.pragma("journal_mode = WAL");
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS signups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
source TEXT,
|
||||||
|
ip TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertStmt = db.prepare(
|
||||||
|
`INSERT INTO signups (email, source, ip, user_agent)
|
||||||
|
VALUES (@email, @source, @ip, @user_agent)
|
||||||
|
ON CONFLICT(email) DO NOTHING`
|
||||||
|
);
|
||||||
|
const countStmt = db.prepare(`SELECT COUNT(*) AS n FROM signups`);
|
||||||
|
const listStmt = db.prepare(
|
||||||
|
`SELECT id, email, source, ip, created_at FROM signups ORDER BY created_at DESC, id DESC`
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an email. Returns { added: true } on a new signup, { added: false } if the
|
||||||
|
* email was already on the list (deduped — no duplicate stored).
|
||||||
|
*/
|
||||||
|
export function addEmail({ email, source = null, ip = null, userAgent = null }) {
|
||||||
|
const info = insertStmt.run({ email, source, ip, user_agent: userAgent });
|
||||||
|
return { added: info.changes > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countEmails() {
|
||||||
|
return countStmt.get().n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listEmails() {
|
||||||
|
return listStmt.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default db;
|
||||||
1271
waitlist-server/package-lock.json
generated
Normal file
1271
waitlist-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
waitlist-server/package.json
Normal file
22
waitlist-server/package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "exbyte-depot-waitlist",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Waitlist collector + secure admin for the Exbyte Depot landing page",
|
||||||
|
"type": "module",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js",
|
||||||
|
"set-password": "node scripts/set-password.js",
|
||||||
|
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(48).toString('base64url'))\""
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.8.1",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"express-rate-limit": "^7.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
waitlist-server/scripts/set-password.js
Normal file
37
waitlist-server/scripts/set-password.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
// Generate an ADMIN_PASS_HASH for .env from a password.
|
||||||
|
//
|
||||||
|
// node scripts/set-password.js "my super secret"
|
||||||
|
// node scripts/set-password.js # prompts (hidden input)
|
||||||
|
//
|
||||||
|
// Paste the printed line into .env — the plaintext password is never stored.
|
||||||
|
|
||||||
|
import readline from "node:readline";
|
||||||
|
import { hashPassword } from "../lib/auth.js";
|
||||||
|
|
||||||
|
function ask(question) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
// hide typed characters
|
||||||
|
const orig = rl._writeToOutput.bind(rl);
|
||||||
|
rl._writeToOutput = (str) => {
|
||||||
|
if (str.includes(question)) orig(str);
|
||||||
|
else orig("*");
|
||||||
|
};
|
||||||
|
rl.question(question, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
process.stdout.write("\n");
|
||||||
|
resolve(answer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const argPw = process.argv.slice(2).join(" ").trim();
|
||||||
|
const pw = argPw || (await ask("New admin password: "));
|
||||||
|
|
||||||
|
if (!pw || pw.length < 8) {
|
||||||
|
console.error("Password must be at least 8 characters.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nAdd this line to your .env:\n");
|
||||||
|
console.log(`ADMIN_PASS_HASH=${hashPassword(pw)}\n`);
|
||||||
209
waitlist-server/server.js
Normal file
209
waitlist-server/server.js
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
// Exbyte Depot — waitlist collector + secure admin panel.
|
||||||
|
//
|
||||||
|
// Public: serves the landing page and accepts POST /api/waitlist (dedup).
|
||||||
|
// Admin: password-gated (scrypt), rate-limited, signed-cookie session.
|
||||||
|
// view signups + export CSV. No plaintext secrets in the codebase.
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { addEmail, countEmails, listEmails } from "./lib/db.js";
|
||||||
|
import {
|
||||||
|
verifyPassword,
|
||||||
|
safeEqual,
|
||||||
|
issueToken,
|
||||||
|
requireAuth,
|
||||||
|
setSessionCookie,
|
||||||
|
clearSessionCookie,
|
||||||
|
} from "./lib/auth.js";
|
||||||
|
|
||||||
|
// --- load .env (built into Node, no dotenv dependency) ----------------------
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
try {
|
||||||
|
process.loadEnvFile(path.join(__dirname, ".env"));
|
||||||
|
} catch {
|
||||||
|
/* no .env file — rely on real environment variables */
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
PORT = 8787,
|
||||||
|
NODE_ENV = "development",
|
||||||
|
ADMIN_USER,
|
||||||
|
ADMIN_PASS_HASH,
|
||||||
|
SESSION_SECRET,
|
||||||
|
TRUST_PROXY = "0",
|
||||||
|
LANDING_DIR = path.join(__dirname, "..", "landing"),
|
||||||
|
} = process.env;
|
||||||
|
|
||||||
|
const IS_PROD = NODE_ENV === "production";
|
||||||
|
const SESSION_TTL_MS = 8 * 60 * 60 * 1000; // 8 hours
|
||||||
|
|
||||||
|
// --- refuse to boot without the security-critical config --------------------
|
||||||
|
const missing = [];
|
||||||
|
if (!ADMIN_USER) missing.push("ADMIN_USER");
|
||||||
|
if (!ADMIN_PASS_HASH) missing.push("ADMIN_PASS_HASH");
|
||||||
|
if (!SESSION_SECRET || SESSION_SECRET.length < 24) missing.push("SESSION_SECRET (>=24 chars)");
|
||||||
|
if (missing.length) {
|
||||||
|
console.error(
|
||||||
|
`\n[waitlist] Refusing to start — missing/weak config: ${missing.join(", ")}\n` +
|
||||||
|
` 1) cp .env.example .env\n` +
|
||||||
|
` 2) npm run gen-secret # paste into SESSION_SECRET\n` +
|
||||||
|
` 3) npm run set-password # paste into ADMIN_PASS_HASH\n`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
if (TRUST_PROXY !== "0") app.set("trust proxy", Number(TRUST_PROXY) || 1);
|
||||||
|
app.use(express.json({ limit: "16kb" }));
|
||||||
|
app.use(express.urlencoded({ extended: false, limit: "16kb" }));
|
||||||
|
|
||||||
|
// --- security headers (self-contained; no external origins) -----------------
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
res.setHeader("X-Frame-Options", "DENY");
|
||||||
|
res.setHeader("Referrer-Policy", "no-referrer");
|
||||||
|
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||||
|
res.setHeader("Permissions-Policy", "geolocation=(), microphone=(), camera=()");
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
[
|
||||||
|
"default-src 'self'",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"script-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
].join("; ")
|
||||||
|
);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- rate limiters ----------------------------------------------------------
|
||||||
|
const loginLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 8, // per IP per window
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: "Too many attempts. Try again later." },
|
||||||
|
});
|
||||||
|
const waitlistLimiter = rateLimit({
|
||||||
|
windowMs: 10 * 60 * 1000,
|
||||||
|
max: 20,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: "Too many requests. Slow down." },
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- helpers ----------------------------------------------------------------
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
function normalizeEmail(v) {
|
||||||
|
return String(v || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
function clientIp(req) {
|
||||||
|
return (req.ip || "").replace(/^::ffff:/, "") || null;
|
||||||
|
}
|
||||||
|
function sendView(res, file, replacements = {}) {
|
||||||
|
let html = fs.readFileSync(path.join(__dirname, "views", file), "utf8");
|
||||||
|
for (const [k, v] of Object.entries(replacements)) {
|
||||||
|
html = html.replaceAll(`{{${k}}}`, v);
|
||||||
|
}
|
||||||
|
res.type("html").send(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PUBLIC — waitlist signup
|
||||||
|
// ============================================================================
|
||||||
|
app.post("/api/waitlist", waitlistLimiter, (req, res) => {
|
||||||
|
// honeypot: bots fill hidden fields; humans leave them blank
|
||||||
|
if (req.body && typeof req.body.website === "string" && req.body.website.trim() !== "") {
|
||||||
|
return res.json({ ok: true, deduped: true }); // silently accept, store nothing
|
||||||
|
}
|
||||||
|
const email = normalizeEmail(req.body?.email);
|
||||||
|
if (!email || email.length > 254 || !EMAIL_RE.test(email)) {
|
||||||
|
return res.status(400).json({ ok: false, error: "Enter a valid email." });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { added } = addEmail({
|
||||||
|
email,
|
||||||
|
source: (req.body?.source || "landing").toString().slice(0, 64),
|
||||||
|
ip: clientIp(req),
|
||||||
|
userAgent: (req.headers["user-agent"] || "").slice(0, 300),
|
||||||
|
});
|
||||||
|
return res.json({ ok: true, added, deduped: !added });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[waitlist] insert failed:", e.message);
|
||||||
|
return res.status(500).json({ ok: false, error: "Server error." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ADMIN — auth
|
||||||
|
// ============================================================================
|
||||||
|
app.get("/admin/login", (req, res) => sendView(res, "login.html"));
|
||||||
|
|
||||||
|
app.post("/admin/login", loginLimiter, (req, res) => {
|
||||||
|
const user = String(req.body?.username || "");
|
||||||
|
const pass = String(req.body?.password || "");
|
||||||
|
const ok = safeEqual(user, ADMIN_USER) && verifyPassword(pass, ADMIN_PASS_HASH);
|
||||||
|
if (!ok) {
|
||||||
|
return res.status(401).redirect("/admin/login?error=1");
|
||||||
|
}
|
||||||
|
const token = issueToken(ADMIN_USER, SESSION_SECRET, SESSION_TTL_MS);
|
||||||
|
setSessionCookie(res, token, { secure: IS_PROD, ttlMs: SESSION_TTL_MS });
|
||||||
|
res.redirect("/admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/logout", (req, res) => {
|
||||||
|
clearSessionCookie(res, { secure: IS_PROD });
|
||||||
|
res.redirect("/admin/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
// everything below requires a valid session
|
||||||
|
app.use("/admin", requireAuth(SESSION_SECRET));
|
||||||
|
|
||||||
|
app.get("/admin", (req, res) => sendView(res, "admin.html", { USER: req.admin.u }));
|
||||||
|
|
||||||
|
app.get("/admin/api/emails", (req, res) => {
|
||||||
|
res.json({ count: countEmails(), emails: listEmails() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// CSV export
|
||||||
|
app.get("/admin/export.csv", (req, res) => {
|
||||||
|
const rows = listEmails();
|
||||||
|
const esc = (v) => {
|
||||||
|
const s = v == null ? "" : String(v);
|
||||||
|
return /[",\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
|
||||||
|
};
|
||||||
|
const header = ["email", "source", "ip", "created_at"];
|
||||||
|
const lines = [header.join(",")];
|
||||||
|
for (const r of rows) {
|
||||||
|
lines.push([r.email, r.source, r.ip, r.created_at].map(esc).join(","));
|
||||||
|
}
|
||||||
|
const csv = "" + lines.join("\r\n"); // BOM so Excel reads UTF-8
|
||||||
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
|
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="waitlist-${stamp}.csv"`);
|
||||||
|
res.send(csv);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LANDING (static) — served last so /api and /admin win
|
||||||
|
// ============================================================================
|
||||||
|
app.use(
|
||||||
|
express.static(LANDING_DIR, {
|
||||||
|
extensions: ["html"],
|
||||||
|
setHeaders: (res) => res.setHeader("Cache-Control", "no-cache"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`[waitlist] listening on http://localhost:${PORT}`);
|
||||||
|
console.log(`[waitlist] landing: ${LANDING_DIR}`);
|
||||||
|
console.log(`[waitlist] admin: http://localhost:${PORT}/admin (${IS_PROD ? "prod" : "dev"})`);
|
||||||
|
});
|
||||||
155
waitlist-server/views/admin.html
Normal file
155
waitlist-server/views/admin.html
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<title>Waitlist · Exbyte Depot Admin</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#0a0a0f; --bg-2:#0d0d14; --panel:#12121b; --panel-2:#16161f; --elevated:#1a1a24;
|
||||||
|
--border:#26263340; --txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
|
||||||
|
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0; --ok:#3ecf8e; --del:#f2637e;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;min-height:100vh;background:var(--bg);color:var(--txt);
|
||||||
|
font:14.5px/1.5 -apple-system,Segoe UI,Roboto,Inter,system-ui,sans-serif}
|
||||||
|
.wrap{max-width:1000px;margin:0 auto;padding:24px 22px 60px}
|
||||||
|
header{display:flex;align-items:center;gap:12px;padding:6px 0 22px}
|
||||||
|
.logo{width:34px;height:34px;border-radius:10px;flex:0 0 auto;
|
||||||
|
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));
|
||||||
|
box-shadow:0 0 18px rgba(124,110,246,.45);display:grid;place-items:center}
|
||||||
|
.logo svg{width:18px;height:18px}
|
||||||
|
.htitle b{font-size:15px;font-weight:650}
|
||||||
|
.htitle span{display:block;font-size:11.5px;color:var(--faint)}
|
||||||
|
.spacer{flex:1}
|
||||||
|
.who{font-size:12.5px;color:var(--muted);margin-right:4px}
|
||||||
|
.who b{color:var(--txt);font-weight:600}
|
||||||
|
.btn{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:600;cursor:pointer;
|
||||||
|
background:var(--panel-2);border:1px solid var(--border);color:var(--txt);
|
||||||
|
border-radius:10px;padding:8px 13px;transition:.15s;text-decoration:none}
|
||||||
|
.btn:hover{border-color:var(--acc);color:#fff}
|
||||||
|
.btn svg{width:15px;height:15px}
|
||||||
|
.btn.accent{background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));border:0;color:#fff}
|
||||||
|
.btn.accent:hover{filter:brightness(1.08)}
|
||||||
|
.btn.ghost{background:transparent}
|
||||||
|
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:20px}
|
||||||
|
.stat{background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:16px 18px}
|
||||||
|
.stat .n{font-size:28px;font-weight:700;letter-spacing:-.5px;font-variant-numeric:tabular-nums}
|
||||||
|
.stat .l{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-top:2px}
|
||||||
|
.stat.accent .n{color:var(--acc-2)}
|
||||||
|
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
|
||||||
|
.search{flex:1;min-width:200px;display:flex;align-items:center;gap:8px;
|
||||||
|
background:var(--elevated);border:1px solid var(--border);border-radius:10px;padding:0 12px}
|
||||||
|
.search svg{width:15px;height:15px;color:var(--faint);flex:0 0 auto}
|
||||||
|
.search input{flex:1;background:none;border:0;outline:none;color:var(--txt);font-size:14px;padding:10px 0}
|
||||||
|
.panel{background:var(--panel);border:1px solid var(--border);border-radius:14px;overflow:hidden}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{text-align:left;padding:11px 16px;font-size:13.5px}
|
||||||
|
th{background:var(--panel-2);color:var(--muted);font-weight:600;font-size:11.5px;
|
||||||
|
text-transform:uppercase;letter-spacing:.4px;position:sticky;top:0}
|
||||||
|
tbody tr{border-top:1px solid var(--border)}
|
||||||
|
tbody tr:hover{background:#ffffff06}
|
||||||
|
td.email{font-weight:550}
|
||||||
|
td.mono{font-variant-numeric:tabular-nums;color:var(--muted);white-space:nowrap}
|
||||||
|
.tag{display:inline-block;font-size:11px;color:var(--muted);background:var(--panel-2);
|
||||||
|
border:1px solid var(--border);border-radius:6px;padding:1px 8px}
|
||||||
|
.idx{color:var(--faint);font-variant-numeric:tabular-nums;width:42px}
|
||||||
|
.empty{padding:44px;text-align:center;color:var(--faint);font-size:13.5px}
|
||||||
|
.foot{margin-top:16px;font-size:11.5px;color:var(--faint);text-align:center}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<span class="logo"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7l8-4 8 4v10l-8 4-8-4V7Z" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/><path d="M4 7l8 4 8-4M12 11v10" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||||
|
<div class="htitle"><b>Waitlist</b><span>Exbyte Depot · admin</span></div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span class="who">Signed in as <b>{{USER}}</b></span>
|
||||||
|
<form method="POST" action="/admin/logout" style="display:inline">
|
||||||
|
<button class="btn ghost" type="submit">Log out</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat accent"><div class="n" id="statTotal">—</div><div class="l">Total signups</div></div>
|
||||||
|
<div class="stat"><div class="n" id="statShown">—</div><div class="l">Showing</div></div>
|
||||||
|
<div class="stat"><div class="n" id="statLatest">—</div><div class="l">Latest signup</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.7"/><path d="m20 20-3-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
|
||||||
|
<input id="search" type="search" placeholder="Filter emails…" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<button class="btn" id="refresh" type="button">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none"><path d="M20 11a8 8 0 1 0-.5 3M20 5v6h-6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<a class="btn accent" href="/admin/export.csv">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Export CSV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th class="idx">#</th><th>Email</th><th>Source</th><th>Date (UTC)</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="rows"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" id="empty" style="display:none">No signups yet.</div>
|
||||||
|
</div>
|
||||||
|
<div class="foot" id="foot"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let all = [];
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
function fmtDate(s){ return (s || "").replace("T"," ").replace("Z",""); }
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
const q = $("search").value.trim().toLowerCase();
|
||||||
|
const list = q ? all.filter(r => r.email.toLowerCase().includes(q)) : all;
|
||||||
|
const tbody = $("rows");
|
||||||
|
tbody.textContent = "";
|
||||||
|
$("empty").style.display = list.length ? "none" : "block";
|
||||||
|
list.forEach((r, i) => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
const idx = document.createElement("td"); idx.className = "idx"; idx.textContent = i + 1;
|
||||||
|
const em = document.createElement("td"); em.className = "email"; em.textContent = r.email;
|
||||||
|
const src = document.createElement("td");
|
||||||
|
const tag = document.createElement("span"); tag.className = "tag"; tag.textContent = r.source || "—";
|
||||||
|
src.appendChild(tag);
|
||||||
|
const dt = document.createElement("td"); dt.className = "mono"; dt.textContent = fmtDate(r.created_at);
|
||||||
|
tr.append(idx, em, src, dt);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
$("statShown").textContent = list.length;
|
||||||
|
$("foot").textContent = list.length
|
||||||
|
? `${list.length} of ${all.length} shown` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(){
|
||||||
|
try {
|
||||||
|
const res = await fetch("/admin/api/emails", { headers: { "Accept": "application/json" } });
|
||||||
|
if (res.status === 401) { location.href = "/admin/login"; return; }
|
||||||
|
const data = await res.json();
|
||||||
|
all = data.emails || [];
|
||||||
|
$("statTotal").textContent = data.count ?? all.length;
|
||||||
|
$("statLatest").textContent = all.length ? fmtDate(all[0].created_at).slice(0,10) : "—";
|
||||||
|
render();
|
||||||
|
} catch (e) {
|
||||||
|
$("empty").style.display = "block";
|
||||||
|
$("empty").textContent = "Failed to load. " + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$("search").addEventListener("input", render);
|
||||||
|
$("refresh").addEventListener("click", load);
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
72
waitlist-server/views/login.html
Normal file
72
waitlist-server/views/login.html
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<title>Admin · Exbyte Depot</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#0a0a0f; --panel:#12121b; --elevated:#1a1a24; --border:#26263340;
|
||||||
|
--txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
|
||||||
|
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0; --del:#f2637e;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;
|
||||||
|
background:radial-gradient(1200px 600px at 50% -10%,#171626 0%,var(--bg) 55%);
|
||||||
|
font:15px/1.5 -apple-system,Segoe UI,Roboto,Inter,system-ui,sans-serif;color:var(--txt)}
|
||||||
|
.card{width:100%;max-width:380px;background:var(--panel);border:1px solid var(--border);
|
||||||
|
border-radius:18px;padding:34px 30px;box-shadow:0 30px 80px -30px #000}
|
||||||
|
.brand{display:flex;align-items:center;gap:11px;margin-bottom:24px}
|
||||||
|
.logo{width:38px;height:38px;border-radius:11px;flex:0 0 auto;
|
||||||
|
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));
|
||||||
|
box-shadow:0 0 22px rgba(124,110,246,.5);display:grid;place-items:center}
|
||||||
|
.logo svg{width:20px;height:20px}
|
||||||
|
.brand b{font-size:15px;font-weight:650;letter-spacing:.2px}
|
||||||
|
.brand span{display:block;font-size:11.5px;color:var(--faint);font-weight:500}
|
||||||
|
h1{font-size:19px;font-weight:650;margin:0 0 4px}
|
||||||
|
.sub{color:var(--muted);font-size:12.5px;margin:0 0 22px}
|
||||||
|
label{display:block;font-size:11.5px;color:var(--muted);font-weight:600;margin:0 0 6px;
|
||||||
|
text-transform:uppercase;letter-spacing:.4px}
|
||||||
|
.field{margin-bottom:16px}
|
||||||
|
input{width:100%;background:var(--elevated);border:1px solid var(--border);border-radius:11px;
|
||||||
|
padding:11px 13px;color:var(--txt);font-size:14px;outline:none;transition:.15s}
|
||||||
|
input:focus{border-color:var(--acc);box-shadow:0 0 0 3px rgba(124,110,246,.16)}
|
||||||
|
button{width:100%;margin-top:6px;padding:12px;border:0;border-radius:11px;cursor:pointer;
|
||||||
|
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));color:#fff;
|
||||||
|
font-size:14.5px;font-weight:650;letter-spacing:.2px;transition:.15s}
|
||||||
|
button:hover{filter:brightness(1.08)}
|
||||||
|
.err{display:none;background:rgba(242,99,126,.1);border:1px solid rgba(242,99,126,.35);
|
||||||
|
color:#ff9db0;font-size:12.5px;padding:9px 12px;border-radius:10px;margin-bottom:18px}
|
||||||
|
.err.show{display:block}
|
||||||
|
.foot{margin-top:20px;text-align:center;font-size:11px;color:var(--faint)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form class="card" method="POST" action="/admin/login" autocomplete="off">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="logo"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7l8-4 8 4v10l-8 4-8-4V7Z" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/><path d="M4 7l8 4 8-4M12 11v10" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||||
|
<div><b>Exbyte Depot</b><span>Waitlist admin</span></div>
|
||||||
|
</div>
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<p class="sub">Restricted area — authorized access only.</p>
|
||||||
|
<div class="err" id="err">Invalid username or password.</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="u">Username</label>
|
||||||
|
<input id="u" name="username" type="text" autocomplete="username" required autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="p">Password</label>
|
||||||
|
<input id="p" name="password" type="password" autocomplete="current-password" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
<div class="foot">Protected endpoint · rate-limited</div>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
if (new URLSearchParams(location.search).get("error")) {
|
||||||
|
document.getElementById("err").classList.add("show");
|
||||||
|
history.replaceState(null, "", "/admin/login");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user