Labels: list, tag current, sync-to

- Tools -> Labels: list server labels, create/tag a label at current head,
  one-click Sync workspace to a label (via Get Revision)
- Backend p4 labels, p4 tag -l

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-08 11:49:18 +03:00
parent 6cfeb27bdd
commit 2811aec6e8
4 changed files with 86 additions and 1 deletions

View File

@ -310,6 +310,7 @@ type ModalState =
| { kind: "search" }
| { kind: "locks" }
| { kind: "typemap" }
| { kind: "labels" }
| { kind: "client"; name: string; isNew: boolean }
| { kind: "blame"; spec: string; name: string }
| { kind: "diff"; title: string; text: string }
@ -1059,7 +1060,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), icon: I.hex, act: () => openDock("unreal") }] : []),
{ label: t("File Locks…"), icon: I.lock, act: () => setModal({ kind: "locks" }) },
],
Tools: [{ label: t("Search depot…"), icon: I.search, act: () => setModal({ kind: "search" }) }, { label: t("Exclusive Locks (typemap)…"), icon: I.lock, act: () => setModal({ kind: "typemap" }) }, { label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, act: startBuild }, { label: t("People & Roles…"), icon: I.people, act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), icon: I.gear, act: () => setModal({ kind: "settings" }) }],
Tools: [{ label: t("Search depot…"), icon: I.search, act: () => setModal({ kind: "search" }) }, { label: t("Exclusive Locks (typemap)…"), icon: I.lock, act: () => setModal({ kind: "typemap" }) }, { label: t("Labels…"), icon: I.clock, act: () => setModal({ kind: "labels" }) }, { label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, act: startBuild }, { label: t("People & Roles…"), icon: I.people, act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), icon: I.gear, act: () => setModal({ kind: "settings" }) }],
Help: [{ label: t("About"), icon: I.info, act: () => setModal({ kind: "about" }) }],
};
@ -1313,6 +1314,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{modal?.kind === "search" && <SearchModal scope={activePath} onClose={() => setModal(null)} onOpenEditor={(dp) => p4.openInEditor(dp, effEditorId).catch((e) => flash(String(e), true))} onReveal={reveal} onCopy={(dp) => copyText(dp, t("Path"))} />}
{modal?.kind === "locks" && <LocksModal me={info?.userName || ""} onClose={() => setModal(null)} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />}
{modal?.kind === "typemap" && <TypemapModal onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "labels" && <LabelsModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onSyncTo={(l) => { setModal(null); syncTo(l); }} />}
{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 === "blame" && <BlameModal spec={modal.spec} name={modal.name} onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "diff" && <DiffModal title={modal.title} text={modal.text} onClose={() => setModal(null)} />}
@ -1995,6 +1997,52 @@ function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string;
);
}
/* ---------------- labels (list / create / sync-to) ---------------- */
function LabelsModal({ scope, onClose, onFlash, onSyncTo }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSyncTo: (label: string) => void }) {
const [labels, setLabels] = useState<{ label?: string; Update?: string; Owner?: string; Description?: string }[]>([]);
const [busy, setBusy] = useState(true);
const [q, setQ] = useState("");
const load = () => { setBusy(true); p4.labels().then(setLabels).catch((e) => { onFlash(String(e), true); setLabels([]); }).finally(() => setBusy(false)); };
useEffect(() => { load(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
async function create() {
const n = newName.trim();
if (!n) return;
setCreating(true);
try { await p4.labelTag(n, scope); onFlash(t("Label “{c}” tagged at current head.", { c: n })); setNewName(""); load(); }
catch (e) { onFlash(String(e), true); }
finally { setCreating(false); }
}
const s = q.trim().toLowerCase();
const rows = labels.filter((l) => !s || (l.label || "").toLowerCase().includes(s) || (l.Description || "").toLowerCase().includes(s));
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.clock}<h3>{t("Labels")}</h3><span className="ph-sub">{t("{n} labels", { n: labels.length })}</span>
<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>
</div>
<div className="rslv-bar">
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Filter labels…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
<input className="kf-in" style={{ maxWidth: 190 }} placeholder={t("New label name")} value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && create()} />
<button className="mbtn primary" disabled={creating || !newName.trim()} onClick={create}>{creating ? <span className="ldr sm" /> : null}{t("Tag current")}</button>
</div>
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
{!busy && rows.length === 0 && <div className="ur-empty">{t("No labels.")}</div>}
{rows.map((l) => (
<div key={l.label} className="srch-row">
<span className="held" style={{ marginRight: 4 }}>{I.clock}</span>
<span className="srch-body"><span className="n">{l.label}</span><span className="p">{(l.Description || "").trim() || l.Owner || ""}{l.Update ? " · " + fmtTime(l.Update) : ""}</span></span>
<button className="rslv-act" onClick={() => onSyncTo(l.label || "")}>{t("Sync to")}</button>
</div>
))}
</div>
</div>
</div>
);
}
/* ---------------- workspace (client) spec editor ---------------- */
function ClientSpecModal({ name, isNew, onClose, onFlash, onSaved }: { name: string; isNew: boolean; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSaved: (client: string) => void }) {
const [spec, setSpec] = useState("");