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:
403
src/App.tsx
403
src/App.tsx
@ -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 || "";
|
||||
|
||||
Reference in New Issue
Block a user