v0.3.1 — view-panel tabs, tray + notifications, server-activity, media preview, dockable locks

- Tabbed view panel: File Viewer / File Tree / File Locks, header hidden at one tab, re-openable from Window, state persisted
- Right-click Workspace/Working-folder zones -> Show in File Tree (focuses the right side)
- Commit weight shown in History (p4 sizes -s //...@=CH)
- Rich code editor (highlighted underlay + gutter) and clean spec-editor surfaces (no more grey notepad)
- System tray (close-to-tray, tray menu Quit) + native OS notifications for teammate submits, even when hidden
- 'You're behind the server' banner + native notification (p4 sync -n), rechecked only when the server advances
- Audio & video preview with playback (media-src blob CSP); File Locks dockable into the view panel
- Backend: depot_ls/fs_ls, p4_change_sizes, p4_behind; tauri-plugin-notification + tray-icon

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-08 14:26:22 +03:00
parent 9c24d45bf9
commit 5652f489a2
12 changed files with 689 additions and 126 deletions

View File

@ -3,6 +3,7 @@ import type { MouseEvent as ReactMouseEvent, KeyboardEvent as ReactKeyboardEvent
import { getCurrentWindow } from "@tauri-apps/api/window";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { listen } from "@tauri-apps/api/event";
import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
import ModelViewer from "./ModelViewer";
import CodeView from "./CodeView";
import UpdateBanner from "./Updater";
@ -11,6 +12,18 @@ import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, fi
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";
// native OS notification (works even when the window is hidden in the tray)
let notifyReady: boolean | null = null;
async function notify(title: string, body: string) {
try {
if (notifyReady === null) {
notifyReady = await isPermissionGranted();
if (!notifyReady) notifyReady = (await requestPermission()) === "granted";
}
if (notifyReady) sendNotification({ title, body });
} catch { /* notifications unavailable — the in-app toast still shows */ }
}
/* ---------------- window controls (frameless) ---------------- */
function WinControls() {
const w = getCurrentWindow();
@ -302,6 +315,7 @@ type Tab = "changes" | "history";
type CtxItem = { label: string; danger?: boolean; icon?: ReactNode; act: () => void };
type ViewTab = "viewer" | "tree" | "locks"; // dockable panels in the right view area
type ModalState =
| { kind: "workspaces"; items: Client[]; current: string }
| { kind: "scope"; path: string; dirs: Dir[] }
@ -351,6 +365,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
const [history, setHistory] = useState<Change[]>([]);
const [changeSizes, setChangeSizes] = useState<Record<string, number>>({}); // per-changelist byte weight for History
const [detail, setDetail] = useState<Describe | null>(null);
const [selChange, setSelChange] = useState<string>(""); // highlighted History row (instant)
const [detailBusy, setDetailBusy] = useState(false); // right-panel files loading
@ -369,6 +384,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const [resolveOpen, setResolveOpen] = useState(false);
const [offline, setOffline] = useState(false); // lost connection to the server
const [workOffline, setWorkOffline] = useState(() => { try { return localStorage.getItem("exd-workoffline") === "1"; } catch { return false; } }); // explicit offline-work mode
const [behind, setBehind] = useState<{ count: number; sample: string[] } | null>(null); // server has newer content than the workspace
const lastSubmit = useRef<string>(""); // newest submitted CL seen (new-submit toast)
const notifPrimed = useRef(false); // don't toast on the very first poll
const [buildLog, setBuildLog] = useState<string[]>([]); // live MSBuild output
@ -383,6 +399,14 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
// bottom dock: a tabbed panel (Log / Terminal / Unreal), opened from Window menu
const [dockOpen, setDockOpen] = useState(false);
const [dockTab, setDockTab] = useState<"log" | "terminal" | "unreal">("log");
// right-side view panel: tabbed (File Viewer / File Tree). Header hides at one tab.
const [viewTabs, setViewTabs] = useState<ViewTab[]>(() => {
try { const a = JSON.parse(localStorage.getItem("exd-viewtabs") || ""); if (Array.isArray(a) && a.length && a.every((x: string) => x === "viewer" || x === "tree" || x === "locks")) return a; } catch {}
return ["viewer"];
});
const [viewTab, setViewTab] = useState<ViewTab>("viewer");
const [treeSide, setTreeSide] = useState<"depot" | "workspace">("depot"); // which side the embedded File Tree shows
const [treeNonce, setTreeNonce] = useState(0); // bump to force the tree to (re)focus a side
const [dockH, setDockH] = useState<number>(() => { const v = Number(localStorage.getItem("exd-dock-h")); return v >= 120 ? v : 240; }); // dock panel height
useEffect(() => { try { localStorage.setItem("exd-dock-h", String(Math.round(dockH))); } catch {} }, [dockH]);
const [termLines, setTermLines] = useState<TermLine[]>([]); // terminal output blocks
@ -657,11 +681,15 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
if (!notifPrimed.current) { lastSubmit.current = n; notifPrimed.current = true; }
else if (n !== lastSubmit.current && Number(n) > Number(lastSubmit.current || 0)) {
const who = latest?.user || "someone";
if ((who || "").toLowerCase() !== (info?.userName || "").toLowerCase()) {
const mine = (who || "").toLowerCase() === (info?.userName || "").toLowerCase();
if (!mine) {
const desc = (latest?.desc || "").trim().replace(/\s+/g, " ").slice(0, 160) || t("(no description)");
flash(t("{user} submitted #{n}", { user: who, n }));
notify(t("{user} submitted #{n}", { user: who, n }), desc); // native toast — arrives even from the tray
}
lastSubmit.current = n;
if (tab === "history") loadHistory();
checkBehind(); // a new submit landed → am I now behind?
}
}
} catch {
@ -674,6 +702,22 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
// eslint-disable-next-line
}, [activePath, offline, tab, workOffline]);
// remember which view-panel tabs are open across restarts
useEffect(() => { try { localStorage.setItem("exd-viewtabs", JSON.stringify(viewTabs)); } catch {} }, [viewTabs]);
// check "am I behind the server" when connected or the working folder changes
useEffect(() => { if (info?.clientName && !workOffline) checkBehind(); /* eslint-disable-next-line */ }, [activePath, info?.clientName, workOffline]);
// window was closed to the tray → tell the user once per session it's still alive
useEffect(() => {
let shown = false;
const un = listen("tray-hidden", () => {
if (shown) return; shown = true;
notify(t("Exbyte Depot is still running"), t("It's in the system tray and will notify you about teammates' submits. Right-click the tray icon to quit."));
});
return () => { un.then((f) => f()); };
}, []);
// drag & drop files/folders onto the window → reconcile them into the changelist
useEffect(() => {
let unlisten: (() => void) | undefined;
@ -766,8 +810,20 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const f = scope !== undefined ? scope : activePath;
const path = f ? f + "/..." : "";
setBusy(true);
try { setHistory(await p4.history(path)); }
catch (e) { flash(String(e), true); } finally { setBusy(false); }
try {
const h = await p4.history(path);
setHistory(h);
// commit weights load in the background so the list shows instantly
const nums = h.map((c) => c.change || "").filter(Boolean);
setChangeSizes({});
if (nums.length) {
p4.changeSizes(nums, f).then((rows) => {
const m: Record<string, number> = {};
for (const r of rows) m[r.change] = r.size;
setChangeSizes(m);
}).catch(() => {});
}
} catch (e) { flash(String(e), true); } finally { setBusy(false); }
}
function switchTab(t: Tab) {
setTab(t);
@ -777,9 +833,24 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
async function getLatest() {
setBusy(true);
try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); await loadHistory(); } // sync may pull new submitted changelists → refresh History too
try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); await loadHistory(); behindRef.current = 0; setBehind(null); } // sync may pull new submitted changelists → refresh History too
catch (e) { flash(String(e), true); setBusy(false); }
}
// check whether the server has newer content than my workspace (files behind)
const behindRef = useRef(0);
async function checkBehind() {
if (workOffline) return;
try {
const b = await p4.behind(activePath);
const was = behindRef.current;
behindRef.current = b.count;
setBehind(b.count > 0 ? b : null);
// notify only when we *newly* fall behind (not on every recheck)
if (b.count > 0 && was === 0) {
notify(t("Your workspace is behind"), t("{n} file(s) on the server are newer than your copy. Get Latest to update.", { n: b.count }));
}
} catch { /* ignore — non-critical */ }
}
// sync the workspace to an exact changelist / label / date (Get Revision)
async function syncTo(rev: string) {
const r = rev.trim();
@ -1080,6 +1151,25 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
return nv;
});
}
// right-panel view tabs: open/focus, close, and Window-menu toggle
function focusViewTab(k: ViewTab) { setViewTabs((ts) => (ts.includes(k) ? ts : [...ts, k])); setViewTab(k); }
function closeViewTab(k: ViewTab) {
setViewTabs((ts) => { const nt = ts.filter((x) => x !== k); if (viewTab === k) setViewTab((nt[nt.length - 1] as ViewTab) || "viewer"); return nt; });
}
function toggleViewTab(k: ViewTab) { if (viewTabs.includes(k)) closeViewTab(k); else focusViewTab(k); }
// open the File Tree view-tab focused on a given side (Depot or Workspace)
function showInTree(side: "depot" | "workspace") { setTreeSide(side); setTreeNonce((n) => n + 1); focusViewTab("tree"); }
// open a file picked in the File Tree: depot files preview in the File Viewer,
// local files open in the OS file browser.
function openTreeFile(n: FsEntry, side: "depot" | "workspace") {
if (side === "depot") {
if (tab !== "changes") switchTab("changes");
setPreviewCL({ depotFile: n.path, _spec: n.path, action: "browse", rev: "", type: "", change: "" } as unknown as OpenedFile);
focusViewTab("viewer");
} else {
reveal(n.path);
}
}
// clear the read-only bit on selected local files so they can be edited while
// offline (before a checkout). Reconcile picks the changes up later.
async function makeWritable(targets: OpenedFile[]) {
@ -1136,10 +1226,12 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{ label: t("Refresh"), kb: "F5", icon: I.sync, hint: t("Re-read state from the server."), act: () => refresh() },
],
Window: [
{ label: (viewTabs.includes("viewer") ? "✓ " : "") + t("File Viewer"), icon: FILE_GLYPH, hint: t("The preview panel for the selected file (image, 3D model, code, diff)."), act: () => toggleViewTab("viewer") },
{ label: (viewTabs.includes("tree") ? "✓ " : "") + t("File Tree"), icon: I.folder, hint: t("A folder hierarchy of the Depot and your Workspace, docked into the view panel."), act: () => toggleViewTab("tree") },
{ label: (viewTabs.includes("locks") ? "✓ " : "") + t("File Locks"), icon: I.lock, hint: t("Everyone's checked-out / exclusively-locked files, docked into the view panel."), act: () => toggleViewTab("locks") },
{ label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, hint: t("Show the panel of recently run Perforce commands."), act: () => openDock("log") },
{ label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, hint: t("Open an embedded terminal for raw p4 commands."), act: () => openDock("terminal") },
...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), icon: I.hex, hint: t("Show output from the headless Unreal preview process."), act: () => openDock("unreal") }] : []),
{ label: t("File Locks…"), icon: I.lock, hint: t("See every file currently exclusively locked and by whom."), act: () => setModal({ kind: "locks" }) },
],
Tools: [
{ label: t("Search depot…"), icon: I.search, hint: t("Find files anywhere in the depot by name or path."), act: () => setModal({ kind: "search" }) },
@ -1185,6 +1277,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
<div className="toolbar">
<div className="tzone" onClick={openWorkspaces} title={t("Switch workspace · right-click for more")}
onContextMenu={(e) => openCtx(e, [
{ label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("workspace") },
{ label: t("Open in Explorer"), act: () => reveal(String(info?.clientRoot || "")) },
{ label: t("Switch workspace…"), act: openWorkspaces },
{ label: t("Copy name"), act: () => copyText(info?.clientName || "", t("Workspace name")) },
@ -1197,6 +1290,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
<div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title={t("Drag to resize")} />
<div className="tzone" onClick={() => browseTo("")} title={t("Choose the project working folder · right-click for more")}
onContextMenu={(e) => openCtx(e, [
{ label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("depot") },
...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
...(slnPath ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: startBuild }] : []),
{ label: t("Open in Explorer"), act: () => reveal(activePath || String(info?.clientRoot || "")) },
@ -1239,6 +1333,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
<button className="netbar-btn">{t("Resolve…")}</button>
</div>
)}
{behind && behind.count > 0 && !workOffline && (
<div className="netbar behind" onClick={getLatest} title={behind.sample.join(", ")}>
{I.sync}{t("Server has newer content — {n} file(s) behind.", { n: behind.count })}
{behind.sample.length > 0 ? <span className="behind-names">{behind.sample.slice(0, 3).join(" · ")}{behind.count > 3 ? " …" : ""}</span> : null}
<button className="netbar-btn">{t("Get Latest")}</button>
</div>
)}
<div className="main">
<div className="vhandle mainh" onMouseDown={(e) => startResize(e, "left")} title={t("Drag to resize")} />
<div className="left">
@ -1345,7 +1446,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</div>
</>
) : (
<HistoryList history={history} busy={busy} sel={selChange} onSelect={openChange}
<HistoryList history={history} sizes={changeSizes} busy={busy} sel={selChange} onSelect={openChange}
onContext={(c, e) => openCtx(e, [
{ label: t("Open this changelist"), act: () => openChange(c) },
{ label: t("Sync workspace to #{n}", { n: c.change || "" }), icon: I.clock, act: () => syncTo(c.change || "") },
@ -1356,28 +1457,58 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
)}
</div>
{tab === "changes" ? (
<Preview file={selFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} onFilelog={(dp, nm) => setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} />
) : (
<div className={"histsplit" + (histFile ? "" : " solo")}>
<ChangeDetail detail={detail} busy={detailBusy} onOpen={setHistFile} selected={histFile?._spec || ""} split width={histFile ? histW : undefined} />
{histFile && (<>
<div className="vhandle histh" title={t("Drag to resize")}
onPointerDown={(e) => {
e.preventDefault();
const startX = e.clientX, start = histW;
document.body.classList.add("resizing");
const move = (ev: PointerEvent) => setHistW(Math.max(240, Math.min(start + (ev.clientX - startX), 620)));
const up = () => { document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up); document.removeEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.removeEventListener("mouseup", up); window.removeEventListener("blur", up); document.body.classList.remove("resizing"); };
document.addEventListener("pointermove", move); document.addEventListener("pointerup", up);
// mouse fallback in case pointer events don't fire in the webview
document.addEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.addEventListener("mouseup", up);
window.addEventListener("blur", up); // released outside the window → end drag
}} />
<Preview file={histFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} onFilelog={(dp, nm) => setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} />
</>)}
<div className="viewpanel">
{viewTabs.length > 1 && (
<div className="vtabs">
{viewTabs.map((k) => (
<div key={k} className={"vtab" + (viewTab === k ? " on" : "")} onClick={() => setViewTab(k)}>
<span className="vtab-ic">{k === "viewer" ? FILE_GLYPH : k === "tree" ? I.folder : I.lock}</span>
{k === "viewer" ? t("File Viewer") : k === "tree" ? t("File Tree") : t("File Locks")}
<span className="vtab-x" onClick={(e) => { e.stopPropagation(); closeViewTab(k); }} title={t("Close")}>×</span>
</div>
))}
</div>
)}
<div className="viewbody">
{viewTabs.length === 0 && <div className="empty">{I.folder}{t("No panel open.\nAdd File Viewer or File Tree from the Window menu.")}</div>}
{viewTabs.includes("viewer") && (
<div className="vpane" style={{ display: viewTab === "viewer" ? "flex" : "none" }}>
{tab === "changes" ? (
<Preview file={selFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} onFilelog={(dp, nm) => setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} />
) : (
<div className={"histsplit" + (histFile ? "" : " solo")}>
<ChangeDetail detail={detail} busy={detailBusy} onOpen={setHistFile} selected={histFile?._spec || ""} split width={histFile ? histW : undefined} />
{histFile && (<>
<div className="vhandle histh" title={t("Drag to resize")}
onPointerDown={(e) => {
e.preventDefault();
const startX = e.clientX, start = histW;
document.body.classList.add("resizing");
const move = (ev: PointerEvent) => setHistW(Math.max(240, Math.min(start + (ev.clientX - startX), 620)));
const up = () => { document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up); document.removeEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.removeEventListener("mouseup", up); window.removeEventListener("blur", up); document.body.classList.remove("resizing"); };
document.addEventListener("pointermove", move); document.addEventListener("pointerup", up);
// mouse fallback in case pointer events don't fire in the webview
document.addEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.addEventListener("mouseup", up);
window.addEventListener("blur", up); // released outside the window → end drag
}} />
<Preview file={histFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} onFilelog={(dp, nm) => setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} />
</>)}
</div>
)}
</div>
)}
{viewTabs.includes("tree") && (
<div className="vpane tree" style={{ display: viewTab === "tree" ? "flex" : "none" }}>
<FileTree scope={activePath} onReveal={reveal} onFlash={flash} onOpenFile={openTreeFile} initialSide={treeSide} sideNonce={treeNonce} />
</div>
)}
{viewTabs.includes("locks") && (
<div className="vpane tree" style={{ display: viewTab === "locks" ? "flex" : "none" }}>
<LocksPanel me={info?.userName || ""} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />
</div>
)}
</div>
)}
</div>
</div>
{dockOpen && <DockPanel tab={dockTab} setTab={setDockTab} onClose={() => setDockOpen(false)}
@ -2062,48 +2193,56 @@ 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 }) {
// the File Locks list (bar + rows), reused by the modal AND the dockable panel
function LocksPanel({ me, onReveal, onFlash, onUnlock }: { me: string; 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
useEffect(() => { load(); }, []); // 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="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>
</>
);
}
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 }) {
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
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>
);
})}
<button className="x" onClick={onClose} style={{ marginLeft: "auto" }}><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>
<LocksPanel me={me} onReveal={onReveal} onFlash={onFlash} onUnlock={onUnlock} />
</div>
</div>
);
@ -2277,13 +2416,14 @@ function ReconcileModal({ scope, onClose, onFlash, onDone }: { scope: string; on
const FILE_GLYPH = <svg viewBox="0 0 24 24" fill="none"><path d="M6 3h8l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" stroke="currentColor" strokeWidth="1.5" /><path d="M14 3v4h4" stroke="currentColor" strokeWidth="1.5" /></svg>;
const sortBySize = (a: FsEntry[]) => [...a].sort((x, y) => (y.size - x.size) || Number(y.isDir) - Number(x.isDir) || x.name.localeCompare(y.name));
// one row in the tree; folders lazily load their children on first expand.
function ExpNode({ node, depth, load, max, onReveal, onFlash }: { node: FsEntry; depth: number; load: (p: string) => Promise<FsEntry[]>; max: number; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void }) {
// one row in the tree; folders lazily load their children on first expand,
// files trigger onOpen (open in the File Viewer / reveal).
function ExpNode({ node, depth, load, max, onReveal, onFlash, onOpen }: { node: FsEntry; depth: number; load: (p: string) => Promise<FsEntry[]>; max: number; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpen?: (n: FsEntry) => void }) {
const [open, setOpen] = useState(false);
const [kids, setKids] = useState<FsEntry[] | null>(null);
const [busy, setBusy] = useState(false);
async function toggle() {
if (!node.isDir) return;
async function activate() {
if (!node.isDir) { onOpen?.(node); return; }
if (!open && kids === null) {
setBusy(true);
try { setKids(sortBySize(await load(node.path))); }
@ -2296,7 +2436,7 @@ function ExpNode({ node, depth, load, max, onReveal, onFlash }: { node: FsEntry;
const childMax = Math.max(1, ...(kids || []).map((k) => k.size));
return (
<div className="exp-node">
<div className={"exp-row" + (node.isDir ? " dir" : "")} style={{ paddingLeft: 10 + depth * 15 }} onClick={toggle} title={node.path}>
<div className={"exp-row" + (node.isDir || onOpen ? " dir" : "")} style={{ paddingLeft: 10 + depth * 15 }} onClick={activate} title={node.path}>
<span className="exp-caret">{node.isDir ? (busy ? <span className="ldr sm" /> : <span className={"chevi" + (open ? " open" : "")}>{I.chevron}</span>) : null}</span>
<span className="exp-ic">{node.isDir ? I.folder : FILE_GLYPH}</span>
<span className="exp-name">{node.name}</span>
@ -2305,13 +2445,16 @@ function ExpNode({ node, depth, load, max, onReveal, onFlash }: { node: FsEntry;
<span className="exp-size">{humanSize(node.size)}</span>
<span className="exp-open" onClick={(e) => { e.stopPropagation(); onReveal(node.path); }} title={t("Open in Explorer")}>{I.folder}</span>
</div>
{open && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={depth + 1} load={load} max={childMax} onReveal={onReveal} onFlash={onFlash} />)}
{open && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={depth + 1} load={load} max={childMax} onReveal={onReveal} onFlash={onFlash} onOpen={onOpen} />)}
</div>
);
}
function ExplorerModal({ scope, onClose, onReveal, onFlash }: { scope: string; onClose: () => void; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void }) {
const [side, setSide] = useState<"depot" | "workspace">("depot");
// The tree body (Depot / Workspace toggle + lazy size tree), reused by the
// Files & Sizes modal AND the embedded File Tree view-panel.
function FileTree({ scope, onReveal, onFlash, onOpenFile, initialSide, sideNonce }: { scope: string; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpenFile?: (n: FsEntry, side: "depot" | "workspace") => void; initialSide?: "depot" | "workspace"; sideNonce?: number }) {
const [side, setSide] = useState<"depot" | "workspace">(initialSide ?? "depot");
useEffect(() => { if (initialSide) setSide(initialSide); }, [sideNonce]); // eslint-disable-line
const [kids, setKids] = useState<FsEntry[] | null>(null);
const [busy, setBusy] = useState(true);
const [err, setErr] = useState("");
@ -2321,36 +2464,44 @@ function ExplorerModal({ scope, onClose, onReveal, onFlash }: { scope: string; o
useEffect(() => {
let live = true;
setBusy(true); setKids(null); setErr("");
load(rootPath)
.then((k) => { if (live) setKids(sortBySize(k)); })
.catch((e) => { if (live) { setErr(String(e)); setKids([]); } })
.finally(() => live && setBusy(false));
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { live = false; document.removeEventListener("keydown", k); };
}, [side]); // eslint-disable-line
load(rootPath).then((k) => { if (live) setKids(sortBySize(k)); }).catch((e) => { if (live) { setErr(String(e)); setKids([]); } }).finally(() => live && setBusy(false));
return () => { live = false; };
}, [side, scope]); // eslint-disable-line
const total = (kids || []).reduce((a, b) => a + b.size, 0);
const max = Math.max(1, ...(kids || []).map((k) => k.size));
return (
<>
<div className="ft-bar">
<div className="exp-tabs">
<button className={"exp-tab" + (side === "depot" ? " on" : "")} onClick={() => setSide("depot")}>{t("Depot")}</button>
<button className={"exp-tab" + (side === "workspace" ? " on" : "")} onClick={() => setSide("workspace")}>{t("Workspace")}</button>
</div>
<span className="ft-total">{rootLabel} · <b>{humanSize(total)}</b></span>
</div>
<div className="exp-tree">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Weighing…")}</div>}
{!busy && err && <div className="ur-empty">{err}</div>}
{!busy && kids && kids.length === 0 && !err && <div className="ur-empty">{t("Empty.")}</div>}
{!busy && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={0} load={load} max={max} onReveal={onReveal} onFlash={onFlash} onOpen={onOpenFile ? (n) => onOpenFile(n, side) : undefined} />)}
</div>
</>
);
}
function ExplorerModal({ scope, onClose, onReveal, onFlash }: { scope: string; onClose: () => void; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void }) {
useEffect(() => {
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => document.removeEventListener("keydown", k);
}, []); // eslint-disable-line
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide tall" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.folder}<h3>{t("Files & Sizes")}</h3>
<div className="exp-tabs">
<button className={"exp-tab" + (side === "depot" ? " on" : "")} onClick={() => setSide("depot")}>{t("Depot")}</button>
<button className={"exp-tab" + (side === "workspace" ? " on" : "")} onClick={() => setSide("workspace")}>{t("Workspace")}</button>
</div>
<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="exp-total"><span className="n">{rootLabel}</span><span className="sz">{humanSize(total)}{kids ? ` · ${t("{n} items", { n: kids.length })}` : ""}</span></div>
<div className="tm-hint">{side === "depot"
? t("Sizes come from the server (p4 sizes): the total bytes each depot folder stores across all its files. Sorted heaviest first.")
: t("Sizes are measured on your disk: how much each folder and file of your local workspace actually takes up. Sorted heaviest first.")}</div>
<div className="exp-tree">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Weighing…")}</div>}
{!busy && err && <div className="ur-empty">{err}</div>}
{!busy && kids && kids.length === 0 && !err && <div className="ur-empty">{t("Empty.")}</div>}
{!busy && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={0} load={load} max={max} onReveal={onReveal} onFlash={onFlash} />)}
<button className="x" onClick={onClose} style={{ marginLeft: "auto" }}><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="tm-hint">{t("Browse the Depot and your Workspace as a tree and see how much every file and folder weighs. Sorted heaviest first.")}</div>
<FileTree scope={scope} onReveal={onReveal} onFlash={onFlash} onOpenFile={(n) => onReveal(n.path)} />
</div>
</div>
);
@ -2859,20 +3010,23 @@ function ChangeDetail({ detail, busy, onOpen, selected, split, width }: { detail
}
/* ---------------- history list ---------------- */
function HistoryList({ history, busy, sel, onSelect, onContext }: { history: Change[]; busy: boolean; sel?: string; onSelect: (c: Change) => void; onContext?: (c: Change, e: ReactMouseEvent) => void }) {
function HistoryList({ history, sizes, busy, sel, onSelect, onContext }: { history: Change[]; sizes?: Record<string, number>; busy: boolean; sel?: string; onSelect: (c: Change) => void; onContext?: (c: Change, e: ReactMouseEvent) => void }) {
if (busy && !history.length) return <div className="empty">{t("Loading history…")}</div>;
if (!history.length) return <div className="empty">{t("No submitted changes.")}</div>;
return (
<div className="files" style={{ position: "relative" }}>
{history.map((c) => (
<div key={c.change} className={"hrow click" + (sel === c.change ? " sel" : "")} onClick={() => onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
<Avatar user={c.user || "?"} size={26} />
<span className="hbody">
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
<span className="hmeta">#{c.change} · {c.user} · {fmtTime(c.time)}</span>
</span>
</div>
))}
{history.map((c) => {
const sz = sizes?.[c.change || ""];
return (
<div key={c.change} className={"hrow click" + (sel === c.change ? " sel" : "")} onClick={() => onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
<Avatar user={c.user || "?"} size={26} />
<span className="hbody">
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
<span className="hmeta">#{c.change} · {c.user} · {fmtTime(c.time)}{sz != null && sz > 0 ? <span className="hsize" title={t("Total size of files in this changelist")}>{humanSize(sz)}</span> : null}</span>
</span>
</div>
);
})}
</div>
);
}
@ -3028,8 +3182,10 @@ function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDif
{kind === "model" ? <ModelViewer depot={dp} name={name} spec={spec || undefined} />
: kind === "image" ? <ImageViewer depot={dp} name={name} spec={spec || undefined} />
: kind === "uasset" ? <UassetPreview file={file} uproject={uproject} onFlash={onFlash} />
: <CodeView file={file} onFlash={onFlash} />}
: kind === "audio" ? <MediaViewer depot={dp} name={name} spec={spec || undefined} video={false} />
: kind === "video" ? <MediaViewer depot={dp} name={name} spec={spec || undefined} video={true} />
: kind === "uasset" ? <UassetPreview file={file} uproject={uproject} onFlash={onFlash} />
: <CodeView file={file} onFlash={onFlash} />}
</div>
);
}
@ -3069,6 +3225,55 @@ function ImageViewer({ depot, name, spec }: { depot: string; name: string; spec?
);
}
/* ---------------- audio / video preview with playback (like Anchorpoint) ---------------- */
const MEDIA_MIME: Record<string, string> = {
wav: "audio/wav", mp3: "audio/mpeg", ogg: "audio/ogg", oga: "audio/ogg", flac: "audio/flac", m4a: "audio/mp4", aac: "audio/aac", opus: "audio/opus",
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", m4v: "video/mp4", ogv: "video/ogg",
};
function MediaViewer({ depot, name, spec, video }: { depot: string; name: string; spec?: string; video: boolean }) {
const [url, setUrl] = useState<string | null>(null);
const [err, setErr] = useState("");
const [dur, setDur] = useState("");
useEffect(() => {
let u = ""; let live = true;
setUrl(null); setErr(""); setDur("");
(spec ? p4.printDepot(spec) : p4.readDepot(depot)).then((buf) => {
if (!live) return;
const ext = (name.split(".").pop() || "").toLowerCase();
u = URL.createObjectURL(new Blob([buf], { type: MEDIA_MIME[ext] || (video ? "video/mp4" : "audio/mpeg") }));
setUrl(u);
}).catch((e) => live && setErr(String(e)));
return () => { live = false; if (u) URL.revokeObjectURL(u); };
}, [depot, name, spec, video]);
const onMeta = (e: { currentTarget: HTMLMediaElement }) => {
const d = e.currentTarget.duration;
if (isFinite(d)) { const m = Math.floor(d / 60), s = Math.round(d % 60); setDur(`${m}:${s.toString().padStart(2, "0")}`); }
};
const ext = (name.split(".").pop() || "").toUpperCase();
return (
<div className="asset">
<div className="stage">
<span className="lbl2">{video ? I.hex : SOUND_GLYPH}{video ? t("Video") : t("Audio")}</span>
{err ? <div className="viewer-msg err">{err}</div>
: !url ? <div className="viewer-msg"><span className="ldr" />{t("Loading…")}</div>
: video
? <video className="mediaplayer vid" src={url} controls autoPlay={false} onLoadedMetadata={onMeta} />
: (
<div className="audiobox">
<div className="audio-art">{SOUND_GLYPH}</div>
<div className="audio-name">{name}</div>
<audio className="mediaplayer" src={url} controls autoPlay={false} onLoadedMetadata={onMeta} />
</div>
)}
</div>
<div className="assetmeta">
<Mi k={t("File")} v={name} /><Mi k={t("Duration")} v={dur || "—"} /><Mi k={t("Format")} v={ext} /><Mi k={t("Source")} v={spec ? t("server revision") : t("working copy")} acc />
</div>
</div>
);
}
const SOUND_GLYPH = <svg viewBox="0 0 24 24" fill="none"><path d="M11 5 6 9H3v6h3l5 4V5Z" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" /><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /></svg>;
/* ---------------- .uasset — embedded thumbnail + on-demand real 3D (Unreal export) ---------------- */
function UassetPreview({ file, uproject, onFlash }: { file: OpenedFile; uproject?: string; onFlash: (text: string, err?: boolean) => void }) {
const dp = file.depotFile || "";