{items.map((it) => (
it.act?.()}>
+ {it.icon && {it.icon}}
{it.label}{it.kb && {it.kb}}
))}
@@ -1278,6 +1286,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
{modal?.kind === "users" &&
setModal(null)} onFlash={flash} />}
{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 === "blame" && setModal(null)} onFlash={flash} />}
{modal?.kind === "diff" && setModal(null)} />}
{resolveOpen && setResolveOpen(false)} />}
@@ -1911,6 +1920,54 @@ function SearchModal({ scope, onClose, onOpenEditor, onReveal, onCopy }: { scope
);
}
+/* ---------------- file locks viewer — who has what locked/open across the depot ---------------- */
+function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string; onClose: () => void; onReveal: (dp: string) => void; onFlash: (t: string, e?: boolean) => void; onUnlock: (dp: string) => void }) {
+ const [files, setFiles] = useState([]);
+ const [busy, setBusy] = useState(true);
+ const [q, setQ] = useState("");
+ const [lockedOnly, setLockedOnly] = useState(true);
+ const load = () => { setBusy(true); p4.openedAll("").then(setFiles).catch((e) => { onFlash(String(e), true); setFiles([]); }).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 isLocked = (f: OpenedFile) => (f as Record).ourLock !== undefined;
+ const s = q.trim().toLowerCase();
+ const rows = files.filter((f) => (!lockedOnly || isLocked(f)) && (!s || (f.depotFile || "").toLowerCase().includes(s) || (f.user || "").toLowerCase().includes(s)));
+ const lockedCount = files.filter(isLocked).length;
+ return (
+
+
e.stopPropagation()}>
+
{I.lock}
{t("File Locks")}
+
{t("{n} open · {m} locked", { n: files.length, m: lockedCount })}
+
+
+
+
{I.search} setQ(e.target.value)} />
+
+
+
+
+ {busy &&
{t("Loading…")}
}
+ {!busy && rows.length === 0 &&
{lockedOnly ? t("No locked files.") : t("No files open by anyone.")}
}
+ {rows.map((f) => {
+ const dp = f.depotFile || "";
+ const sp = splitPath(dp);
+ const locked = isLocked(f);
+ const mine = (f.user || "").toLowerCase() === me.toLowerCase();
+ return (
+
+ {locked ? I.lock : I.unlock}
+ {sp.name}{sp.dir}
+ {f.user}{mine ? " · " + t("you") : ""}{f.client}
+ {locked && mine && }
+
+
+ );
+ })}
+
+
+
+ );
+}
+
/* ---------------- blame / annotate ---------------- */
function BlameModal({ spec, name, onClose, onFlash }: { spec: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
const [text, setText] = useState(null);
diff --git a/src/i18n.ts b/src/i18n.ts
index c2863da..f901ea7 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -384,6 +384,14 @@ const D: Record = {
"Editing": { ru: "Редактирование", de: "Bearbeiten", fr: "Édition", es: "Editando" },
"Edit": { ru: "Редактировать", de: "Bearbeiten", fr: "Modifier", es: "Editar" },
"Saved {name}": { ru: "Сохранено: {name}", de: "Gespeichert: {name}", fr: "Enregistré : {name}", es: "Guardado: {name}" },
+ "File Locks…": { ru: "Блокировки файлов…", de: "Dateisperren…", fr: "Verrous de fichiers…", es: "Bloqueos de archivos…" },
+ "File Locks": { ru: "Блокировки файлов", de: "Dateisperren", fr: "Verrous de fichiers", es: "Bloqueos de archivos" },
+ "{n} open · {m} locked": { ru: "{n} открыто · {m} залочено", de: "{n} offen · {m} gesperrt", fr: "{n} ouverts · {m} verrouillés", es: "{n} abiertos · {m} bloqueados" },
+ "Filter by file or user…": { ru: "Фильтр по файлу или пользователю…", de: "Nach Datei oder Benutzer filtern…", fr: "Filtrer par fichier ou utilisateur…", es: "Filtrar por archivo o usuario…" },
+ "Locked only": { ru: "Только залоченные", de: "Nur gesperrte", fr: "Verrouillés uniquement", es: "Solo bloqueados" },
+ "All open": { ru: "Все открытые", de: "Alle offenen", fr: "Tous ouverts", es: "Todos abiertos" },
+ "No locked files.": { ru: "Залоченных файлов нет.", de: "Keine gesperrten Dateien.", fr: "Aucun fichier verrouillé.", es: "No hay archivos bloqueados." },
+ "No files open by anyone.": { ru: "Ни у кого нет открытых файлов.", de: "Niemand hat Dateien geöffnet.", fr: "Personne n'a de fichiers ouverts.", es: "Nadie tiene archivos abiertos." },
"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…" },
};