Release 0.2.2 — browse submitted changelists, People & Roles, AI summaries
- History: GitHub-style split view of submitted changelists; preview file contents at their exact revision (code/image/3D/uasset) via `p4 print` - People & Roles modal: who works on the depot, activity indicators, group/role assignment (p4 users / groups / group -i) - AI commit summaries via OpenRouter (sparkle button); key/model in Settings - Commit-message draft persists until Submit (not cleared on local commit) - Resizable History file-list column (pointer-capture drag) - Fix UI-scale leaving a gap at the bottom (compensate zoom in .win height) - Author avatars on history rows; bump version to 0.2.2 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
282
src/App.tsx
282
src/App.tsx
@ -7,8 +7,9 @@ import ModelViewer from "./ModelViewer";
|
||||
import CodeView from "./CodeView";
|
||||
import UpdateBanner from "./Updater";
|
||||
import "./App.css";
|
||||
import { p4, statusOf, splitPath, kindOf, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, P4Info, OpenedFile, Client, Change, Session, Describe, Dir } from "./p4";
|
||||
import { p4, statusOf, splitPath, kindOf, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, fileBytes, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User } from "./p4";
|
||||
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
|
||||
import { aiSummary, initials, avatarColor, activity, getModel, setModel, getKeyLocal, setKeyLocal } from "./ai";
|
||||
|
||||
/* ---------------- window controls (frameless) ---------------- */
|
||||
function WinControls() {
|
||||
@ -37,6 +38,10 @@ const I = {
|
||||
spinner: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="rgba(255,255,255,.3)" strokeWidth="2"/><path d="M12 3a9 9 0 0 1 9 9" stroke="#fff" strokeWidth="2" strokeLinecap="round"/></svg>,
|
||||
cube: <svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" strokeWidth="1.5"/></svg>,
|
||||
revert: <svg viewBox="0 0 24 24" fill="none"><path d="M4 12a8 8 0 0 1 14-5.3L21 9M21 4v5h-5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
back: <svg viewBox="0 0 24 24" fill="none"><path d="M19 12H5M11 6l-6 6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
chevron: <svg viewBox="0 0 24 24" fill="none"><path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
spark: <svg viewBox="0 0 24 24" fill="none"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/><path d="M18.5 15.5l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2Z" fill="currentColor"/></svg>,
|
||||
people: <svg viewBox="0 0 24 24" fill="none"><circle cx="9" cy="8" r="3.2" stroke="currentColor" strokeWidth="1.6"/><path d="M3.5 19a5.5 5.5 0 0 1 11 0M16 5.2a3.2 3.2 0 0 1 0 6.1M17.5 14.2A5.5 5.5 0 0 1 20.5 19" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
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>,
|
||||
log: <svg viewBox="0 0 24 24" fill="none"><rect x="4" y="3" width="16" height="18" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 8h8M8 12h8M8 16h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
vscode: <svg viewBox="0 0 24 24" fill="none"><path d="m9 8-5 4 5 4M15 8l5 4-5 4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
@ -69,6 +74,10 @@ function useZoom(): [number, (z: number) => void] {
|
||||
});
|
||||
useEffect(() => {
|
||||
(document.documentElement.style as CSSStyleDeclaration & { zoom: string }).zoom = String(zoom);
|
||||
// CSS `zoom` scales layout but leaves `100vh` measuring the *unzoomed* viewport,
|
||||
// so at zoom<1 a gap grows at the bottom. Expose the factor so the root can
|
||||
// compensate its height (see `.win` in App.css).
|
||||
document.documentElement.style.setProperty("--zoom", String(zoom));
|
||||
try { localStorage.setItem("exd-zoom", String(zoom)); } catch {}
|
||||
}, [zoom]);
|
||||
return [zoom, (z) => setZoom(Math.min(2, Math.max(0.5, Math.round(z * 100) / 100)))];
|
||||
@ -262,6 +271,7 @@ type ModalState =
|
||||
| { kind: "workspaces"; items: Client[]; current: string }
|
||||
| { kind: "scope"; path: string; dirs: Dir[] }
|
||||
| { kind: "settings" }
|
||||
| { kind: "users" }
|
||||
| { kind: "about" };
|
||||
|
||||
type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null };
|
||||
@ -280,8 +290,11 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const [expandedCL, setExpandedCL] = useState<Set<string>>(new Set()); // expanded pending changelist cards
|
||||
const [prompt, setPrompt] = useState<null | { title: string; label: string; value: string; onSave: (v: string) => void }>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [desc, setDesc] = useState(""); // commit summary (first line)
|
||||
const [descBody, setDescBody] = useState(""); // extended description (optional)
|
||||
const [desc, setDesc] = useState(() => { try { return localStorage.getItem("exd-draft-summary") || ""; } catch { return ""; } }); // commit summary (first line) — persisted draft
|
||||
const [descBody, setDescBody] = useState(() => { try { return localStorage.getItem("exd-draft-body") || ""; } catch { return ""; } }); // extended description (optional) — persisted draft
|
||||
// keep the typed commit message as a draft so a refresh/restart never loses it; it is cleared only on Submit
|
||||
useEffect(() => { try { localStorage.setItem("exd-draft-summary", desc); } catch {} }, [desc]);
|
||||
useEffect(() => { try { localStorage.setItem("exd-draft-body", descBody); } catch {} }, [descBody]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [scanning, setScanning] = useState(false); // background reconcile in progress
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
@ -290,6 +303,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const [detail, setDetail] = useState<Describe | null>(null);
|
||||
const [selChange, setSelChange] = useState<string>(""); // highlighted History row (instant)
|
||||
const [detailBusy, setDetailBusy] = useState(false); // right-panel files loading
|
||||
const [histFile, setHistFile] = useState<OpenedFile | null>(null); // drilled-into submitted file (read-only preview)
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [ask, setAsk] = useState<null | { title: string; body: string; confirm: string; danger?: boolean; resolve: (v: boolean) => void }>(null);
|
||||
const [pending, setPending] = useState<Change[]>([]); // committed-but-not-pushed changelists
|
||||
@ -303,6 +317,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const [buildOpen, setBuildOpen] = useState(false);
|
||||
const [buildMin, setBuildMin] = useState(false); // collapsed to a small floating chip
|
||||
const [buildPick, setBuildPick] = useState(false); // configuration picker before building
|
||||
const [aiBusy, setAiBusy] = useState(false); // AI commit-summary generation in flight
|
||||
const [ctx, setCtx] = useState<null | { x: number; y: number; items: CtxItem[] }>(null); // right-click menu
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]); // live p4 command log (P4V-style)
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
@ -314,14 +329,17 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
// resizable panels (persisted): left panel width + Get-Latest zone width
|
||||
const [leftW, setLeftW] = useState<number>(() => { const v = Number(localStorage.getItem("exd-left-w")); return v >= 280 ? v : 380; });
|
||||
const [syncW, setSyncW] = useState<number>(() => { const v = Number(localStorage.getItem("exd-sync-w")); return v >= 150 ? v : 220; });
|
||||
const [histW, setHistW] = useState<number>(() => { const v = Number(localStorage.getItem("exd-hist-w")); return v >= 240 ? v : 340; }); // History file-list column width
|
||||
useEffect(() => { try { localStorage.setItem("exd-left-w", String(Math.round(leftW))); } catch {} }, [leftW]);
|
||||
useEffect(() => { try { localStorage.setItem("exd-sync-w", String(Math.round(syncW))); } catch {} }, [syncW]);
|
||||
useEffect(() => { try { localStorage.setItem("exd-hist-w", String(Math.round(histW))); } catch {} }, [histW]);
|
||||
|
||||
function startResize(e: ReactMouseEvent, kind: "left" | "sync") {
|
||||
function startResize(e: ReactMouseEvent, kind: "left" | "sync" | "hist") {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX, startLeft = leftW, startSync = syncW;
|
||||
const startX = e.clientX, startLeft = leftW, startSync = syncW, startHist = histW;
|
||||
const move = (ev: MouseEvent) => {
|
||||
if (kind === "left") setLeftW(Math.max(300, Math.min(startLeft + (ev.clientX - startX), window.innerWidth - 420)));
|
||||
else if (kind === "hist") setHistW(Math.max(240, Math.min(startHist + (ev.clientX - startX), 620)));
|
||||
else setSyncW(Math.max(160, Math.min(startSync - (ev.clientX - startX), 520)));
|
||||
};
|
||||
const up = () => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); document.body.classList.remove("resizing"); };
|
||||
@ -466,6 +484,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
async function openChange(c: Change) {
|
||||
const n = c.change || "";
|
||||
setSelChange(n); // highlight the row immediately — no waiting for the load
|
||||
setHistFile(null); // leave any drilled-in file view
|
||||
setDetail(null);
|
||||
setDetailBusy(true);
|
||||
try { setDetail(await p4.describe(n)); }
|
||||
@ -513,6 +532,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
}
|
||||
function switchTab(t: Tab) {
|
||||
setTab(t);
|
||||
setHistFile(null);
|
||||
if (t === "history" && history.length === 0) loadHistory();
|
||||
}
|
||||
|
||||
@ -585,11 +605,27 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
try {
|
||||
const cl = await p4.commit(message, list);
|
||||
flash(t("Committed locally · changelist {cl}. Press Submit at the top to send it to the server.", { cl }));
|
||||
setDesc(""); setDescBody("");
|
||||
// keep the draft — a local commit is not final; it clears only on Submit (see pushAll / submitOne)
|
||||
await refresh();
|
||||
} catch (e) { flash(String(e), true); setBusy(false); }
|
||||
}
|
||||
|
||||
// Ask the AI (OpenRouter) to draft a commit summary + description from the checked files.
|
||||
async function genAiSummary() {
|
||||
const picked = files.filter((f) => (f.change || "default") === "default" && checked.has(f.depotFile || ""));
|
||||
if (!picked.length) { flash(t("Select some files first."), true); return; }
|
||||
setAiBusy(true);
|
||||
try {
|
||||
const { summary, description } = await aiSummary(picked);
|
||||
if (summary) setDesc(summary);
|
||||
if (description) setDescBody(description);
|
||||
flash(t("AI drafted a commit message — review & edit before committing."));
|
||||
} catch (e) {
|
||||
const msg = String(e);
|
||||
flash(msg.includes("No OpenRouter API key") ? t("Set an OpenRouter API key in Settings → AI first.") : msg, true);
|
||||
} finally { setAiBusy(false); }
|
||||
}
|
||||
|
||||
// Submit / push: send all committed (pending) changelists to the server. (like git push)
|
||||
async function pushAll() {
|
||||
if (!pending.length) return;
|
||||
@ -602,7 +638,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
catch (e) { errors.push(`CL ${c.change}: ${String(e)}`); }
|
||||
}
|
||||
if (errors.length) flash(errors.join(" · "), true);
|
||||
else flash(t("Sent to the server: {n} changelist(s).", { n }));
|
||||
else { flash(t("Sent to the server: {n} changelist(s).", { n })); setDesc(""); setDescBody(""); } // draft done → clear on successful Submit
|
||||
await refresh();
|
||||
await loadHistory(); // the submit created a new submitted changelist — refresh History
|
||||
}
|
||||
@ -611,7 +647,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
async function submitOne(change: string) {
|
||||
if (!(await confirm({ title: t("Submit changelist #{n}?", { n: change }), body: t("This changelist goes to the Perforce server and becomes available to the team. This is a push — it cannot be undone."), confirm: t("Submit") }))) return;
|
||||
setBusy(true);
|
||||
try { await p4.submitChange(change); flash(t("Submitted changelist #{n}.", { n: change })); await refresh(); await loadHistory(); }
|
||||
try { await p4.submitChange(change); flash(t("Submitted changelist #{n}.", { n: change })); setDesc(""); setDescBody(""); await refresh(); await loadHistory(); } // draft clears on successful Submit
|
||||
catch (e) { flash(String(e), true); setBusy(false); }
|
||||
}
|
||||
// Uncommit: move a changelist's files back into the working set (default)
|
||||
@ -723,7 +759,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
Connection: [{ label: t("Refresh"), kb: "F5", act: () => refresh() }, { label: t("Choose working folder…"), act: () => browseTo("") }],
|
||||
Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", act: getLatest }, { label: t("Commit (local)"), act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", act: pushAll }, { label: t("Revert All…"), act: revertAll }, { label: t("Refresh"), kb: "F5", act: () => refresh() }],
|
||||
Window: [{ label: (logOpen ? "✓ " : "") + t("Log"), kb: "Ctrl+L", act: () => setLogOpen((o) => !o) }],
|
||||
Tools: [{ label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", act: startBuild }, { label: t("Settings…"), act: () => setModal({ kind: "settings" }) }],
|
||||
Tools: [{ label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", act: startBuild }, { label: t("People & Roles…"), act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), act: () => setModal({ kind: "settings" }) }],
|
||||
Help: [{ label: t("About"), act: () => setModal({ kind: "about" }) }],
|
||||
};
|
||||
|
||||
@ -746,6 +782,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
))}
|
||||
</div>
|
||||
<div className="conn-pill"><span className="dot" />{info?.serverAddress || "connected"}</div>
|
||||
<button className="theme-btn" onClick={() => setModal({ kind: "users" })} title={t("People & Roles — who works on this depot")}>{I.people}</button>
|
||||
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
|
||||
<WinControls />
|
||||
</div>
|
||||
@ -871,8 +908,14 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
<FileList files={view} sel={sel} selRows={selRows} checked={checked} onRowClick={rowClick} onToggle={toggleCheck} onContext={fileCtx} />
|
||||
)}
|
||||
<div className="commit">
|
||||
<input className="summary" placeholder={t("Summary: what changed…")} value={desc} onChange={(e) => setDesc(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
|
||||
<div className="summrow">
|
||||
<input className="summary" placeholder={t("Summary: what changed…")} value={desc} onChange={(e) => setDesc(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
|
||||
<button className={"aibtn icon" + (aiBusy ? " busy" : "")} disabled={aiBusy || checked.size === 0} onClick={genAiSummary}
|
||||
title={t("Draft a commit message with AI from the selected files")}>
|
||||
{aiBusy ? <span className="ldr sm" /> : I.spark}
|
||||
</button>
|
||||
</div>
|
||||
<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(); }} />
|
||||
<button className="submit" disabled={busy || !desc.trim() || checked.size === 0} onClick={doCommit}>
|
||||
@ -892,9 +935,26 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tab === "changes"
|
||||
? <Preview file={selFile} onRevert={doRevert} onFlash={flash} />
|
||||
: <ChangeDetail detail={detail} busy={detailBusy} />}
|
||||
{tab === "changes" ? (
|
||||
<Preview file={selFile} onRevert={doRevert} onFlash={flash} />
|
||||
) : (
|
||||
<div className={"histsplit" + (histFile ? "" : " solo")} style={{ "--hist-w": `${histW}px` } as CSSProperties}>
|
||||
<ChangeDetail detail={detail} busy={detailBusy} onOpen={setHistFile} selected={histFile?._spec || ""} split />
|
||||
{histFile && (<>
|
||||
<div className="vhandle histh" title={t("Drag to resize")}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget; el.setPointerCapture(e.pointerId);
|
||||
const startX = e.clientX, start = histW;
|
||||
document.body.classList.add("resizing");
|
||||
const move = (ev: PointerEvent) => setHistW(Math.max(240, Math.min(start + (ev.clientX - startX), 620)));
|
||||
const up = () => { el.releasePointerCapture(e.pointerId); el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", up); document.body.classList.remove("resizing"); };
|
||||
el.addEventListener("pointermove", move); el.addEventListener("pointerup", up);
|
||||
}} />
|
||||
<Preview file={histFile} onRevert={doRevert} onFlash={flash} />
|
||||
</>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{logOpen && <LogPanel logs={logs} onClose={() => setLogOpen(false)} onClear={() => setLogs([])} />}
|
||||
@ -922,8 +982,9 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{modal && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
|
||||
{modal && modal.kind !== "users" && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
|
||||
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
|
||||
{modal?.kind === "users" && <UsersRoles me={info?.userName || ""} onClose={() => setModal(null)} onFlash={flash} />}
|
||||
{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)} />}
|
||||
{showXfer && transfer && <TransferDialog transfer={transfer} />}
|
||||
@ -1026,6 +1087,7 @@ function AppModal({ modal, info, light, toggleTheme, lang, setLang, zoom, setZoo
|
||||
<div className="info-row"><span className="rk">{t("User")}</span><span className="rv">{info?.userName || "—"}</span></div>
|
||||
<div className="info-row"><span className="rk">{t("Workspace")}</span><span className="rv">{info?.clientName || "—"}</span></div>
|
||||
<div className="info-row"><span className="rk">{t("Server version")}</span><span className="rv">{(info?.serverVersion as string || "").split("/")[2] || "—"}</span></div>
|
||||
<AiSettings />
|
||||
<div style={{ marginTop: 18 }}><button className="mbtn danger" style={{ width: "100%" }} onClick={() => { onClose(); onDisconnect(); }}>{t("Disconnect")}</button></div>
|
||||
</div>
|
||||
</>)}
|
||||
@ -1045,6 +1107,155 @@ function AppModal({ modal, info, light, toggleTheme, lang, setLang, zoom, setZoo
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- avatar (initials, deterministic color, activity dot) ---------------- */
|
||||
function Avatar({ user, name, size = 30, act }: { user: string; name?: string; size?: number; act?: "active" | "recent" | "idle" }) {
|
||||
return (
|
||||
<span className="avatar" style={{ width: size, height: size, fontSize: Math.round(size * 0.38), background: avatarColor(user || name || "?") }} title={name ? `${name} (${user})` : user}>
|
||||
{initials(name || "", user)}
|
||||
{act && <span className={"adot " + act} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- AI settings block (inside Settings modal) — localStorage-backed ---------------- */
|
||||
function AiSettings() {
|
||||
const [key, setKey] = useState(getKeyLocal());
|
||||
const [model, setModelVal] = useState(getModel());
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<div className="ai-set">
|
||||
<div className="ai-set-h">{I.spark}<span>{t("AI commit messages")}</span></div>
|
||||
<div className="info-row"><span className="rk">{t("OpenRouter key")}</span>
|
||||
<span className="keyfield">
|
||||
<input type={show ? "text" : "password"} className="kf-in" placeholder="sk-or-…" value={key}
|
||||
onChange={(e) => { setKey(e.target.value); setKeyLocal(e.target.value); }} />
|
||||
<button className="kf-eye" onClick={() => setShow((s) => !s)} title={show ? t("Hide") : t("Show")}>{show ? "🙈" : "👁"}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="info-row"><span className="rk">{t("Model")}</span>
|
||||
<input className="kf-in mono" value={model} placeholder="google/gemini-2.0-flash-001"
|
||||
onChange={(e) => { setModelVal(e.target.value); setModel(e.target.value); }} />
|
||||
</div>
|
||||
<div className="ai-hint">{t("Stored only on this machine. Get a key at openrouter.ai — a “:free” model costs nothing.")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- People & Roles: who works on the depot + group/permission assignment ---------------- */
|
||||
function UsersRoles({ me, onClose, onFlash }: { me: string; onClose: () => void; onFlash: (text: string, err?: boolean) => void }) {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [groups, setGroups] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
const [sel, setSel] = useState(""); // selected user id
|
||||
const [myGroups, setMyGroups] = useState<string[]>([]); // groups of the selected user
|
||||
const [gBusy, setGBusy] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
(async () => {
|
||||
try {
|
||||
const [us, gs] = await Promise.all([p4.users(), p4.groups()]);
|
||||
if (!live) return;
|
||||
setUsers(us);
|
||||
setGroups(gs.map((g) => g.group || "").filter(Boolean));
|
||||
} catch (e) { if (live) setErr(String(e)); }
|
||||
finally { if (live) setBusy(false); }
|
||||
})();
|
||||
return () => { live = false; };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
async function selectUser(u: string) {
|
||||
setSel(u); setMyGroups([]); setGBusy(true);
|
||||
try { const g = await p4.groups(u); setMyGroups(g.map((x) => x.group || "").filter(Boolean)); }
|
||||
catch (e) { onFlash(String(e), true); }
|
||||
finally { setGBusy(false); }
|
||||
}
|
||||
async function toggleGroup(group: string) {
|
||||
if (!sel) return;
|
||||
const isMember = myGroups.includes(group);
|
||||
setGBusy(true);
|
||||
try {
|
||||
await p4.groupMember(group, sel, !isMember);
|
||||
setMyGroups((cur) => isMember ? cur.filter((x) => x !== group) : [...cur, group]);
|
||||
onFlash(isMember ? t("Removed {u} from “{g}”.", { u: sel, g: group }) : t("Added {u} to “{g}”.", { u: sel, g: group }));
|
||||
} catch (e) {
|
||||
const msg = String(e);
|
||||
onFlash(/permission|protect|admin|super|not allowed/i.test(msg) ? t("You need admin rights on the server to change roles.") : msg, true);
|
||||
} finally { setGBusy(false); }
|
||||
}
|
||||
|
||||
const s = q.trim().toLowerCase();
|
||||
const filtered = users.filter((u) => !s || (u.User || "").toLowerCase().includes(s) || (u.FullName || "").toLowerCase().includes(s) || (u.Email || "").toLowerCase().includes(s));
|
||||
const selUser = users.find((u) => u.User === sel);
|
||||
const X = <button className="x" onClick={onClose}><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>;
|
||||
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.people}<h3>{t("People & Roles")}</h3>
|
||||
<span className="ph-sub">{t("{n} on this depot", { n: users.length })}</span>{X}</div>
|
||||
{err ? <div className="viewer-msg err" style={{ position: "static", padding: 24 }}>{err}</div>
|
||||
: busy ? <div className="viewer-msg" style={{ position: "static", padding: 28 }}><span className="ldr" />{t("Loading users…")}</div>
|
||||
: (
|
||||
<div className="ur-body">
|
||||
<div className="ur-list">
|
||||
<div className="ur-filter">{I.search}<input placeholder={t("Filter people…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||
<div className="ur-scroll">
|
||||
{filtered.map((u) => {
|
||||
const act = activity(u.Access);
|
||||
return (
|
||||
<div key={u.User} className={"ur-row" + (sel === u.User ? " on" : "")} onClick={() => selectUser(u.User || "")}>
|
||||
<Avatar user={u.User || ""} name={u.FullName} act={act} />
|
||||
<span className="ur-meta">
|
||||
<span className="ur-name">{u.FullName || u.User}{u.User === me && <span className="ur-you">{t("you")}</span>}</span>
|
||||
<span className="ur-sub">{u.Email || u.User}</span>
|
||||
</span>
|
||||
<span className={"ur-actd " + act} title={act} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && <div className="ur-empty">{t("No matches.")}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ur-detail">
|
||||
{!selUser ? <div className="viewer-msg" style={{ position: "static", padding: 28, flexDirection: "column" }}>{I.people}<span>{t("Pick a person to see and edit their roles.")}</span></div>
|
||||
: (<>
|
||||
<div className="ur-dhead">
|
||||
<Avatar user={selUser.User || ""} name={selUser.FullName} size={46} act={activity(selUser.Access)} />
|
||||
<div className="ur-dinfo">
|
||||
<div className="ur-dname">{selUser.FullName || selUser.User}</div>
|
||||
<div className="ur-dsub">{selUser.Email || "—"}{selUser.Access ? " · " + t("active {t}", { t: fmtTime(selUser.Access) }) : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ur-grouphd"><span>{t("Roles / groups")}</span>{gBusy && <span className="ldr sm" />}</div>
|
||||
<div className="ur-groups">
|
||||
{groups.length === 0 && <div className="ur-empty">{t("No groups defined on the server.")}</div>}
|
||||
{groups.map((g) => {
|
||||
const on = myGroups.includes(g);
|
||||
return (
|
||||
<button key={g} className={"ur-chip" + (on ? " on" : "")} disabled={gBusy} onClick={() => toggleGroup(g)}>
|
||||
<span className="urc-box">{on ? I.check : null}</span>{g}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="ai-hint">{t("Toggling a group grants or revokes that role. Requires admin rights on the server.")}</div>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- right-click context menu ---------------- */
|
||||
function ContextMenu({ x, y, items, onClose }: { x: number; y: number; items: CtxItem[]; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
@ -1258,7 +1469,7 @@ function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string
|
||||
|
||||
/* ---------------- change detail (History right panel) — virtualized ---------------- */
|
||||
const CROW = 52;
|
||||
function ChangeDetail({ detail, busy }: { detail: Describe | null; busy?: boolean }) {
|
||||
function ChangeDetail({ detail, busy, onOpen, selected, split }: { detail: Describe | null; busy?: boolean; onOpen?: (f: OpenedFile) => void; selected?: string; split?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [scroll, setScroll] = useState(0);
|
||||
const [h, setH] = useState(500);
|
||||
@ -1274,8 +1485,9 @@ function ChangeDetail({ detail, busy }: { detail: Describe | null; busy?: boolea
|
||||
// jump back to top whenever a different changelist loads
|
||||
useEffect(() => { if (ref.current) ref.current.scrollTop = 0; setScroll(0); }, [detail?.change]);
|
||||
|
||||
if (busy) return <div className="right"><div className="nofile"><span className="ldr" /><span>{t("Loading file list…")}</span></div></div>;
|
||||
if (!detail) return <div className="right"><div className="nofile">{I.clock}<span>{t("Select a changelist in History")}</span></div></div>;
|
||||
const cls = "right" + (split ? " histlist" : "");
|
||||
if (busy) return <div className={cls}><div className="nofile"><span className="ldr" /><span>{t("Loading file list…")}</span></div></div>;
|
||||
if (!detail) return <div className={cls}><div className="nofile">{I.clock}<span>{t("Select a changelist in History")}</span></div></div>;
|
||||
|
||||
const first = Math.max(0, Math.floor(scroll / CROW) - 6);
|
||||
const last = Math.min(fileRows.length, Math.ceil((scroll + h) / CROW) + 6);
|
||||
@ -1284,10 +1496,13 @@ function ChangeDetail({ detail, busy }: { detail: Describe | null; busy?: boolea
|
||||
const f = fileRows[i];
|
||||
const { name, dir } = splitPath(f.depotFile);
|
||||
const st = statusOf(f.action);
|
||||
const key = `${f.depotFile}#${f.rev}`;
|
||||
rows.push(
|
||||
<div key={i} className="crow" style={{ top: i * CROW }}>
|
||||
<div key={i} className={"crow click" + (selected === key ? " sel" : "")} style={{ top: i * CROW }}
|
||||
onClick={() => onOpen?.({ depotFile: f.depotFile, action: f.action, rev: f.rev, type: "", change: detail.change, _spec: key })}>
|
||||
<span className={"stat " + st.cls}>{st.ch}</span>
|
||||
<span className="hbody"><span className="hdesc" style={{ WebkitLineClamp: 1 }}>{name}</span><span className="hmeta">{dir}#{f.rev}</span></span>
|
||||
<span className="crow-go">{I.chevron}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1313,10 +1528,10 @@ function HistoryList({ history, busy, sel, onSelect, onContext }: { history: Cha
|
||||
<div className="files" style={{ position: "relative" }}>
|
||||
{history.map((c) => (
|
||||
<div key={c.change} className={"hrow click" + (sel === c.change ? " sel" : "")} onClick={() => onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
|
||||
<span className="hnum">#{c.change}</span>
|
||||
<Avatar user={c.user || "?"} size={26} />
|
||||
<span className="hbody">
|
||||
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
|
||||
<span className="hmeta">{c.user} · {fmtTime(c.time)}</span>
|
||||
<span className="hmeta">#{c.change} · {c.user} · {fmtTime(c.time)}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@ -1396,10 +1611,13 @@ function FileList({ files, sel, selRows, checked, onRowClick, onToggle, onContex
|
||||
function Preview({ file, onRevert, onFlash }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void }) {
|
||||
if (!file) return <div className="right"><div className="nofile">{I.cube}<span>{t("Select a file on the left")}</span></div></div>;
|
||||
const dp = file.depotFile || "";
|
||||
const spec = file._spec || ""; // set → read-only view of a submitted revision
|
||||
const hist = !!spec;
|
||||
const { name, dir } = splitPath(dp);
|
||||
const st = statusOf(file.action);
|
||||
const kind = kindOf(name);
|
||||
const rev = file.rev ? `#${file.haveRev || "?"} → #${file.rev}` : "#" + (file.rev || "?");
|
||||
const rev = hist ? "#" + (file.rev || "?")
|
||||
: file.rev ? `#${file.haveRev || "?"} → #${file.rev}` : "#" + (file.rev || "?");
|
||||
|
||||
return (
|
||||
<div className="right">
|
||||
@ -1407,20 +1625,20 @@ function Preview({ file, onRevert, onFlash }: { file?: OpenedFile; onRevert: (f:
|
||||
<span className={"stat " + st.cls} style={{ width: 26, height: 26, fontSize: 13 }}>{st.ch}</span>
|
||||
<span className="ttl">
|
||||
<span className="n">{name} <span>— {dir}</span></span>
|
||||
<span className="m">{file.action} · <span className="rev">{rev}</span> · {file.type}</span>
|
||||
<span className="m">{file.action} · <span className="rev">{rev}</span>{file.type ? " · " + file.type : ""}{hist && file.change ? " · " + t("in #{n}", { n: file.change }) : ""}</span>
|
||||
</span>
|
||||
<span className="tools">
|
||||
{kind === "code" && (
|
||||
{kind === "code" && !hist && (
|
||||
<span className="gbtn vsc" onClick={async () => { try { await p4.openInVscode(dp); onFlash(t("Opening in VS Code…")); } catch (e) { onFlash(String(e), true); } }}>
|
||||
{I.vscode}{t("Edit in VS Code")}
|
||||
</span>
|
||||
)}
|
||||
<span className="gbtn" onClick={() => onRevert(file)}>{I.revert}{t("Revert")}</span>
|
||||
{!hist && <span className="gbtn" onClick={() => onRevert(file)}>{I.revert}{t("Revert")}</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{kind === "model" ? <ModelViewer depot={dp} name={name} />
|
||||
: kind === "image" ? <ImageViewer depot={dp} name={name} />
|
||||
{kind === "model" ? <ModelViewer depot={dp} name={name} spec={spec || undefined} />
|
||||
: kind === "image" ? <ImageViewer depot={dp} name={name} spec={spec || undefined} />
|
||||
: kind === "uasset" ? <UassetPreview file={file} />
|
||||
: <CodeView file={file} />}
|
||||
</div>
|
||||
@ -1431,13 +1649,13 @@ function Mi({ k, v, acc }: { k: string; v: string; acc?: boolean }) {
|
||||
}
|
||||
|
||||
/* ---------------- real image viewer ---------------- */
|
||||
function ImageViewer({ depot, name }: { depot: string; name: string }) {
|
||||
function ImageViewer({ depot, name, spec }: { depot: string; name: string; spec?: string }) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
const [dim, setDim] = useState("");
|
||||
useEffect(() => {
|
||||
let u = ""; let live = true;
|
||||
p4.readDepot(depot).then((buf) => {
|
||||
(spec ? p4.printDepot(spec) : p4.readDepot(depot)).then((buf) => {
|
||||
if (!live) return;
|
||||
const ext = (name.split(".").pop() || "png").toLowerCase();
|
||||
const mime = ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}`;
|
||||
@ -1445,7 +1663,7 @@ function ImageViewer({ depot, name }: { depot: string; name: string }) {
|
||||
setUrl(u);
|
||||
}).catch((e) => live && setErr(String(e)));
|
||||
return () => { live = false; if (u) URL.revokeObjectURL(u); };
|
||||
}, [depot, name]);
|
||||
}, [depot, name, spec]);
|
||||
return (
|
||||
<div className="asset">
|
||||
<div className="stage">
|
||||
@ -1471,14 +1689,14 @@ function UassetPreview({ file }: { file: OpenedFile }) {
|
||||
useEffect(() => {
|
||||
let live = true; let url = "";
|
||||
setThumb(null); setTried(false);
|
||||
p4.readDepot(dp).then((buf) => {
|
||||
fileBytes(file).then((buf) => {
|
||||
if (!live) return;
|
||||
const png = extractPng(new Uint8Array(buf));
|
||||
if (png) { url = URL.createObjectURL(new Blob([png], { type: "image/png" })); setThumb(url); }
|
||||
setTried(true);
|
||||
}).catch(() => live && setTried(true));
|
||||
return () => { live = false; if (url) URL.revokeObjectURL(url); };
|
||||
}, [dp]);
|
||||
}, [dp, file._spec]);
|
||||
|
||||
return (
|
||||
<div className="asset">
|
||||
|
||||
Reference in New Issue
Block a user