diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f175215..618c6ac 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -485,6 +485,30 @@ async fn p4_sync(state: State<'_, AppState>, scope: String) -> Result) -> Result, String> { + let conn = current(&state)?; + run_json(&conn, &["labels", "-m", "300"]) +} + +/// Tag revisions with a label (auto-creates the label if new). `rev` optional: +/// a changelist, another label, or a date; empty = head. Scope confines it. +#[tauri::command] +async fn p4_label_tag(state: State<'_, AppState>, label: String, scope: String, rev: String) -> Result { + let conn = current(&state)?; + let label = label.trim(); + if label.is_empty() { + return Err("No label name".into()); + } + let mut spec = scope_spec(&scope); + let rev = rev.trim().trim_start_matches(['@', '#']); + if !rev.is_empty() { + spec = format!("{spec}@{rev}"); + } + run_text(&conn, &["tag", "-l", label, &spec]) +} + /// Sync the workspace to an exact revision: a changelist number, a label name, /// or a date (`YYYY/MM/DD`). `rev` is appended to the scope spec as `@rev`. #[tauri::command] @@ -2058,6 +2082,8 @@ pub fn run() { p4_typemap_set, p4_reopen_type, p4_sync_to, + p4_labels, + p4_label_tag, p4_client_spec, p4_client_save, uasset_thumbnail, diff --git a/src/App.tsx b/src/App.tsx index e05e945..0d75406 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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" && 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" && setModal(null)} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />} {modal?.kind === "typemap" && setModal(null)} onFlash={flash} />} + {modal?.kind === "labels" && setModal(null)} onFlash={flash} onSyncTo={(l) => { setModal(null); syncTo(l); }} />} {modal?.kind === "client" && setModal(null)} onFlash={flash} onSaved={(c) => { setModal(null); if (modal.isNew) { switchWorkspace(c); } else { refresh(); } }} />} {modal?.kind === "blame" && setModal(null)} onFlash={flash} />} {modal?.kind === "diff" && 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 ( +
+
e.stopPropagation()}> +
{I.clock}

{t("Labels")}

{t("{n} labels", { n: labels.length })} + +
+
+
{I.search} setQ(e.target.value)} />
+ setNewName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && create()} /> + +
+
+ {busy &&
{t("Loading…")}
} + {!busy && rows.length === 0 &&
{t("No labels.")}
} + {rows.map((l) => ( +
+ {I.clock} + {l.label}{(l.Description || "").trim() || l.Owner || ""}{l.Update ? " · " + fmtTime(l.Update) : ""} + +
+ ))} +
+
+
+ ); +} + /* ---------------- 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(""); diff --git a/src/i18n.ts b/src/i18n.ts index ffd5aa1..7e59083 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -421,6 +421,15 @@ const D: Record = { "Workspace “{c}” created.": { ru: "Воркспейс «{c}» создан.", de: "Arbeitsbereich „{c}“ erstellt.", fr: "Espace de travail « {c} » créé.", es: "Espacio de trabajo «{c}» creado." }, "Workspace “{c}” updated.": { ru: "Воркспейс «{c}» обновлён.", de: "Arbeitsbereich „{c}“ aktualisiert.", fr: "Espace de travail « {c} » mis à jour.", es: "Espacio de trabajo «{c}» actualizado." }, "Create & switch": { ru: "Создать и переключиться", de: "Erstellen & wechseln", fr: "Créer et basculer", es: "Crear y cambiar" }, + "Labels…": { ru: "Метки…", de: "Labels…", fr: "Labels…", es: "Etiquetas…" }, + "Labels": { ru: "Метки", de: "Labels", fr: "Labels", es: "Etiquetas" }, + "{n} labels": { ru: "меток: {n}", de: "{n} Labels", fr: "{n} labels", es: "{n} etiquetas" }, + "Filter labels…": { ru: "Фильтр меток…", de: "Labels filtern…", fr: "Filtrer les labels…", es: "Filtrar etiquetas…" }, + "New label name": { ru: "Имя новой метки", de: "Neuer Label-Name", fr: "Nom du nouveau label", es: "Nombre de nueva etiqueta" }, + "Tag current": { ru: "Отметить текущее", de: "Aktuelles taggen", fr: "Étiqueter l'actuel", es: "Etiquetar actual" }, + "Label “{c}” tagged at current head.": { ru: "Метка «{c}» проставлена на текущую голову.", de: "Label „{c}“ am aktuellen Head getaggt.", fr: "Label « {c} » posé sur la tête actuelle.", es: "Etiqueta «{c}» marcada en la cabeza actual." }, + "No labels.": { ru: "Меток нет.", de: "Keine Labels.", fr: "Aucun label.", es: "Sin etiquetas." }, + "Sync to": { ru: "Синкнуть на", de: "Sync auf", fr: "Synchroniser sur", es: "Sincronizar a" }, "Pick the Unreal project working folder first.": { ru: "Сначала выбери рабочую папку Unreal-проекта.", de: "Wähle zuerst den Arbeitsordner des Unreal-Projekts.", fr: "Choisis d'abord le dossier de travail du projet Unreal.", es: "Primero elige la carpeta de trabajo del proyecto Unreal." }, "Disconnected from the server — retrying…": { ru: "Соединение с сервером потеряно — переподключаюсь…", de: "Verbindung zum Server verloren — erneuter Versuch…", fr: "Déconnecté du serveur — nouvelle tentative…", es: "Desconectado del servidor — reintentando…" }, }; diff --git a/src/p4.ts b/src/p4.ts index 854b2f7..48e5f3c 100644 --- a/src/p4.ts +++ b/src/p4.ts @@ -65,6 +65,8 @@ export const p4 = { disconnect: () => invoke("p4_disconnect"), sync: (scope = "") => invoke("p4_sync", { scope }), syncTo: (scope: string, rev: string) => invoke("p4_sync_to", { scope, rev }), + labels: () => invoke<{ label?: string; Update?: string; Owner?: string; Description?: string; [k: string]: unknown }[]>("p4_labels"), + labelTag: (label: string, scope: string, rev = "") => invoke("p4_label_tag", { label, scope, rev }), diff: (file: string) => invoke("p4_diff", { file }), revert: (files: string[]) => invoke("p4_revert", { files }), submit: (description: string, files: string[]) =>