From 29303cbf433bf2605c8efd0513f7b714679344e7 Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Wed, 8 Jul 2026 09:59:42 +0300 Subject: [PATCH] Menu icons, File Locks viewer, remove dock drag highlight - Add icons to every dropdown menu item (File/Connection/Actions/Window/Tools/Help) - New File Locks viewer (Window -> File Locks): all open/locked files across the depot with who holds them (user + workspace), filter, locked-only toggle, unlock your own locks, refresh - Remove the purple highlight on the bottom-dock resize handle (cursor only) Co-Authored-By: Claude Opus 4.8 --- src-tauri/Cargo.lock | 2 +- src/App.css | 8 ++++- src/App.tsx | 75 ++++++++++++++++++++++++++++++++++++++------ src/i18n.ts | 8 +++++ 4 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f1f8f70..abe4ed1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -960,7 +960,7 @@ dependencies = [ [[package]] name = "exbyte-depot" -version = "0.2.3" +version = "0.2.4" dependencies = [ "encoding_rs", "serde", diff --git a/src/App.css b/src/App.css index 344e895..c7ba580 100644 --- a/src/App.css +++ b/src/App.css @@ -71,6 +71,9 @@ button{font-family:var(--font)} .menu.open .dropdown{display:flex} .di{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;font-size:12.5px;color:var(--txt);cursor:pointer} .di:hover{background:rgba(124,110,246,.14)} +.di-ic{flex:0 0 auto;display:grid;place-items:center;width:16px;height:16px;color:var(--muted)} +.di-ic svg{width:15px;height:15px} +.di:hover .di-ic{color:var(--accent-2)} .di .kb{margin-left:auto;font-size:10.5px;color:var(--faint);font-family:var(--mono)} .di.ext{color:var(--accent-2)} .sep{height:1px;background:var(--border-soft);margin:4px 6px} @@ -369,7 +372,6 @@ body.resizing{cursor:col-resize!important;user-select:none} /* bottom dock — tabbed Log / Terminal / Unreal */ .dockpanel{position:relative;flex:0 0 240px;display:flex;flex-direction:column;min-height:0;border-top:1px solid var(--border);background:var(--sunk)} .dockhandle{position:absolute;top:-3px;left:0;right:0;height:7px;cursor:row-resize;z-index:9} -.dockhandle:hover{background:var(--accent);opacity:.5} body.resizing-v{cursor:row-resize!important;user-select:none} .dockhead{flex:0 0 auto;display:flex;align-items:center;gap:6px;padding:4px 10px 0;border-bottom:1px solid var(--border-soft)} .docktabs{display:flex;gap:2px} @@ -781,6 +783,10 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .rslv-act{flex:0 0 auto;border:1px solid var(--border);background:var(--panel);border-radius:7px;padding:5px 11px; font-size:11.5px;font-weight:600;color:var(--muted);cursor:pointer;font-family:var(--font)} .rslv-act:hover{color:var(--txt);border-color:var(--accent)} +.rslv-act.on{color:#fff;border-color:transparent;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep))} +.rslv-act svg{width:14px;height:14px;display:block} +.lk-who{flex:0 0 auto;display:flex;flex-direction:column;align-items:flex-end;gap:1px;font-size:11.5px;color:var(--muted);max-width:170px} +.lk-client{font-size:10px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:170px} /* blame + diff panes */ .picker.blame{width:900px;height:640px} .blame-body,.diff-body{flex:1;overflow:auto;padding:8px 0;font-family:var(--mono);font-size:12px;line-height:1.5;background:var(--stage-bg)} diff --git a/src/App.tsx b/src/App.tsx index 6caf7c0..fb6dfdc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,6 +51,11 @@ const I = { unlock: , shelf: , folder: , + power: , + up: , + gear: , + info: , + scan: , ue: , }; @@ -303,6 +308,7 @@ type ModalState = | { kind: "settings" } | { kind: "users" } | { kind: "search" } + | { kind: "locks" } | { kind: "blame"; spec: string; name: string } | { kind: "diff"; title: string; text: string } | { kind: "about" }; @@ -1018,17 +1024,18 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set } catch (e) { flash(String(e), true); } } - const menus: Record void }[]> = { - File: [{ label: t("Switch workspace…"), act: openWorkspaces }, { label: t("Choose working folder…"), act: () => browseTo("") }, { label: t("Disconnect"), act: onDisconnect }], - 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 }, ...(needResolve.length ? [{ label: t("Resolve conflicts ({n})", { n: needResolve.length }), act: () => setResolveOpen(true) }] : []), { label: t("Revert All…"), act: revertAll }, { label: t("Refresh"), kb: "F5", act: () => refresh() }], + const menus: Record void }[]> = { + File: [{ label: t("Switch workspace…"), icon: I.monitor, act: openWorkspaces }, { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }, { label: t("Disconnect"), icon: I.power, act: onDisconnect }], + Connection: [{ label: t("Refresh"), kb: "F5", icon: I.sync, act: () => refresh() }, { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }], + Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", icon: I.scan, act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", icon: I.sync, act: getLatest }, { label: t("Commit (local)"), icon: I.check, act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", icon: I.up, act: pushAll }, ...(needResolve.length ? [{ label: t("Resolve conflicts ({n})", { n: needResolve.length }), icon: I.hex, act: () => setResolveOpen(true) }] : []), { label: t("Revert All…"), icon: I.revert, act: revertAll }, { label: t("Refresh"), kb: "F5", icon: I.sync, act: () => refresh() }], Window: [ - { label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", act: () => openDock("log") }, - { label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", act: () => openDock("terminal") }, - ...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), act: () => openDock("unreal") }] : []), + { label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, act: () => openDock("log") }, + { label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, act: () => openDock("terminal") }, + ...(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…"), act: () => setModal({ kind: "search" }) }, { 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" }) }], + Tools: [{ label: t("Search depot…"), icon: I.search, act: () => setModal({ kind: "search" }) }, { 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" }) }], }; return ( @@ -1042,6 +1049,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{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…" }, };