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 <noreply@anthropic.com>
This commit is contained in:
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -960,7 +960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "exbyte-depot"
|
||||
version = "0.2.3"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"serde",
|
||||
|
||||
@ -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)}
|
||||
|
||||
75
src/App.tsx
75
src/App.tsx
@ -51,6 +51,11 @@ const I = {
|
||||
unlock: <svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 11V8a4 4 0 0 1 7.5-1.9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
shelf: <svg viewBox="0 0 24 24" fill="none"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
folder: <svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6"/></svg>,
|
||||
power: <svg viewBox="0 0 24 24" fill="none"><path d="M12 3v9M7.5 6.5a7 7 0 1 0 9 0" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
|
||||
up: <svg viewBox="0 0 24 24" fill="none"><path d="M12 20V6M6 12l6-6 6 6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
gear: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6"/><path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.3 1a7 7 0 0 0-1.7-1l-.4-2.6H10.5l-.4 2.6a7 7 0 0 0-1.7 1l-2.3-1-2 3.4L6 11a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 1.7 1l.4 2.6h3.5l.4-2.6a7 7 0 0 0 1.7-1l2.3 1 2-3.4-2-1.5c.06-.33.1-.66.1-1Z" stroke="currentColor" strokeWidth="1.3"/></svg>,
|
||||
info: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6"/><path d="M12 11v5M12 8h.01" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
|
||||
scan: <svg viewBox="0 0 24 24" fill="none"><path d="M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2M4 12h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
ue: <svg viewBox="0 0 1280 1280"><path fillRule="evenodd" clipRule="evenodd" fill="currentColor" d="M640,1280c353.46,0,640-286.54,640-640S993.46,0,640,0S0,286.54,0,640S286.54,1280,640,1280z M803.7,995.81c156.5-73.92,205.56-210.43,216.6-263.61c-57.22,58.6-120.53,118-163.11,76.88c0,0-2.33-219.45-2.33-309.43c0-121,114.75-211.18,114.75-211.18c-63.11,11.24-138.89,33.71-219.33,112.65c-7.26,7.2-14.14,14.76-20.62,22.67c-34.47-26.39-79.14-18.48-79.14-18.48c24.14,13.26,48.23,51.88,48.23,83.85v314.26c0,0-52.63,46.3-93.19,46.3c-9.14,0.07-18.17-2.05-26.33-6.18c-8.16-4.13-15.21-10.15-20.56-17.56c-3.21-4.19-5.87-8.78-7.91-13.65V424.07c-11.99,9.89-52.51,18.04-52.51-49.22c0-41.79,30.11-91.6,83.73-122.15c-73.63,11.23-142.59,43.04-198.92,91.76c-42.8,36.98-77.03,82.85-100.31,134.4c-23.28,51.55-35.06,107.55-34.51,164.12c0,0,39.21-122.51,88.32-133.83c7.15-1.88,14.65-2.07,21.89-0.54c7.24,1.53,14.02,4.72,19.81,9.34c5.79,4.61,10.41,10.51,13.51,17.23c3.1,6.72,4.59,14.07,4.34,21.46V844.3c0,29.16-18.8,35.53-36.17,35.22c-11.77-0.83-23.4-3.02-34.66-6.53c35.86,48.53,82.46,88.12,136.15,115.66c53.69,27.54,113.03,42.29,173.37,43.1l106.05-106.6L803.7,995.81z"/></svg>,
|
||||
};
|
||||
|
||||
@ -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<string, { label: string; kb?: string; ext?: boolean; act?: () => 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<string, { label: string; kb?: string; ext?: boolean; icon?: ReactNode; act?: () => 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
|
||||
<div className="dropdown">
|
||||
{items.map((it) => (
|
||||
<div key={it.label} className={"di" + (it.ext ? " ext" : "")} onClick={() => it.act?.()}>
|
||||
{it.icon && <span className="di-ic">{it.icon}</span>}
|
||||
{it.label}{it.kb && <span className="kb">{it.kb}</span>}
|
||||
</div>
|
||||
))}
|
||||
@ -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" && <UsersRoles me={info?.userName || ""} onClose={() => setModal(null)} onFlash={flash} />}
|
||||
{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 === "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)} />}
|
||||
{resolveOpen && <ResolveModal files={needResolve} onResolve={doResolve} onClose={() => 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<OpenedFile[]>([]);
|
||||
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<string, unknown>).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 (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.lock}<h3>{t("File Locks")}</h3>
|
||||
<span className="ph-sub">{t("{n} open · {m} locked", { n: files.length, m: lockedCount })}</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" style={{ borderBottom: "1px solid var(--border-soft)" }}>
|
||||
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Filter by file or user…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||
<button className={"rslv-act" + (lockedOnly ? " on" : "")} onClick={() => setLockedOnly((v) => !v)}>{lockedOnly ? t("Locked only") : t("All open")}</button>
|
||||
<button className="rslv-act" onClick={load} title={t("Refresh")}>{I.sync}</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">{lockedOnly ? t("No locked files.") : t("No files open by anyone.")}</div>}
|
||||
{rows.map((f) => {
|
||||
const dp = f.depotFile || "";
|
||||
const sp = splitPath(dp);
|
||||
const locked = isLocked(f);
|
||||
const mine = (f.user || "").toLowerCase() === me.toLowerCase();
|
||||
return (
|
||||
<div key={dp + (f.client || "")} className="srch-row">
|
||||
<span className={"held" + (locked ? " locked" : "")} style={{ marginRight: 4 }}>{locked ? I.lock : I.unlock}</span>
|
||||
<span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
|
||||
<span className="lk-who">{f.user}{mine ? " · " + t("you") : ""}<span className="lk-client">{f.client}</span></span>
|
||||
{locked && mine && <button className="rslv-act" title={t("Unlock")} onClick={() => { onUnlock(dp); setTimeout(load, 400); }}>{t("Unlock")}</button>}
|
||||
<button className="srch-act" title={t("Open in Explorer")} onClick={() => onReveal(dp)}>{I.folder}</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- blame / annotate ---------------- */
|
||||
function BlameModal({ spec, name, onClose, onFlash }: { spec: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
|
||||
@ -384,6 +384,14 @@ const D: Record<string, Tr> = {
|
||||
"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…" },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user