Custom workspace dropdown + Launch-Unreal button
Replace the ugly native <select> on the connect screen with a themed custom dropdown (Select component). Add a Launch-Unreal button that appears when the chosen working folder contains a .uproject: backend find_uproject resolves the scope to disk and searches it (and one level down); the button opens the project via the opener plugin. Bundled the UE icon (currentColor, so white-on-dark / black-on-light per theme). New strings translated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
78
src/App.tsx
78
src/App.tsx
@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent, CSSProperties } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent, CSSProperties, ReactNode } from "react";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { openPath } from "@tauri-apps/plugin-opener";
|
||||
import ModelViewer from "./ModelViewer";
|
||||
import UpdateBanner from "./Updater";
|
||||
import "./App.css";
|
||||
@ -38,6 +39,7 @@ const I = {
|
||||
revert: <svg viewBox="0 0 24 24" fill="none"><path d="M4 12a8 8 0 0 1 14-5.3L21 9M21 4v5h-5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
clock: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6"/><path d="M12 8v4l2.5 2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
log: <svg viewBox="0 0 24 24" fill="none"><rect x="4" y="3" width="16" height="18" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 8h8M8 12h8M8 16h5" 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>,
|
||||
};
|
||||
|
||||
/* ---------------- theme hook ---------------- */
|
||||
@ -104,6 +106,41 @@ export default function App() {
|
||||
return <>{content}<UpdateBanner /></>;
|
||||
}
|
||||
|
||||
/* ---------------- custom styled dropdown (replaces the ugly native <select>) ---------------- */
|
||||
function Select({ value, options, onChange, icon, placeholder }:
|
||||
{ value: string; options: { value: string; label: string; sub?: string }[]; onChange: (v: string) => void; icon?: ReactNode; placeholder?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
|
||||
}, []);
|
||||
const cur = options.find((o) => o.value === value);
|
||||
return (
|
||||
<div className={"xsel" + (open ? " open" : "")} ref={ref}>
|
||||
<button type="button" className="xsel-trig" onClick={() => setOpen((o) => !o)}>
|
||||
{icon}
|
||||
<span className="xsel-val">{cur ? cur.label : <span className="xsel-ph">{placeholder}</span>}</span>
|
||||
<span className="xsel-chev">{I.chev}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="xsel-list">
|
||||
{options.length === 0 && <div className="xsel-empty">{placeholder}</div>}
|
||||
{options.map((o) => (
|
||||
<div key={o.value} className={"xsel-item" + (o.value === value ? " on" : "")} onClick={() => { onChange(o.value); setOpen(false); }}>
|
||||
<span className="xsel-ic">{o.value === value ? I.check : null}</span>
|
||||
<span className="xsel-body"><span className="xsel-name">{o.label}</span>{o.sub && <span className="xsel-sub">{o.sub}</span>}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- animated startup splash ---------------- */
|
||||
function Splash({ light, toggleTheme }: { light: boolean; toggleTheme: () => void }) {
|
||||
const steps = [t("Connecting to server…"), t("Authenticating…"), t("Loading workspace…"), t("Syncing state…")];
|
||||
@ -193,16 +230,16 @@ function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; togg
|
||||
<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6"/></svg>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && connect()} /></div></div>
|
||||
<div className="fld"><label>{t("Workspace")}</label>
|
||||
<div className="inp">
|
||||
<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>
|
||||
{clients.length ? (
|
||||
<select value={client} onChange={(e) => setClient(e.target.value)}>
|
||||
{clients.map((c) => <option key={c.client} value={c.client}>{c.client}{c.Root ? ` — ${c.Root}` : ""}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
{clients.length ? (
|
||||
<Select value={client} onChange={setClient} placeholder={t("Choose workspace")}
|
||||
icon={<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>}
|
||||
options={clients.map((c) => ({ value: c.client || "", label: c.client || "", sub: (c.Root as string) || "" }))} />
|
||||
) : (
|
||||
<div className="inp">
|
||||
<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>
|
||||
<input placeholder={t("workspace name (not a folder path!)")} value={client} onChange={(e) => setClient(e.target.value)} />
|
||||
)}
|
||||
</div></div>
|
||||
</div>
|
||||
)}</div>
|
||||
|
||||
{err && <div className="err"><span>⚠</span><span>{err}</span></div>}
|
||||
<button className="connect" disabled={busy || !password} onClick={connect}>
|
||||
@ -252,6 +289,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const [pending, setPending] = useState<Change[]>([]); // committed-but-not-pushed changelists
|
||||
const [transfer, setTransfer] = useState<Transfer | null>(null); // live sync/submit file transfer
|
||||
const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog
|
||||
const [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project
|
||||
const [ctx, setCtx] = useState<null | { x: number; y: number; items: CtxItem[] }>(null); // right-click menu
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]); // live p4 command log (P4V-style)
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
@ -344,6 +382,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const id = setTimeout(() => setShowXfer(true), 400);
|
||||
return () => clearTimeout(id);
|
||||
}, [xferActive]);
|
||||
// detect a UE project in the current working folder → show the Launch button
|
||||
useEffect(() => {
|
||||
if (!activePath) { setUproject(""); return; }
|
||||
let live = true;
|
||||
p4.findUproject(activePath).then((p) => live && setUproject(p)).catch(() => live && setUproject(""));
|
||||
return () => { live = false; };
|
||||
}, [activePath]);
|
||||
useEffect(() => {
|
||||
const close = () => setOpenMenu(null);
|
||||
document.addEventListener("click", close);
|
||||
@ -456,6 +501,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
|
||||
function openCtx(e: ReactMouseEvent, items: CtxItem[]) { e.preventDefault(); e.stopPropagation(); setCtx({ x: e.clientX, y: e.clientY, items }); }
|
||||
|
||||
// launch Unreal by opening the detected .uproject (file association starts UE)
|
||||
async function launchUE() {
|
||||
if (!uproject) return;
|
||||
try { await openPath(uproject); flash(t("Launching Unreal…")); }
|
||||
catch (e) { flash(String(e), true); }
|
||||
}
|
||||
|
||||
// reveal a depot path / workspace root in Windows Explorer
|
||||
async function reveal(target: string) {
|
||||
if (!target) { flash(t("Path not set"), true); return; }
|
||||
@ -594,6 +646,12 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
<span className="tzmeta"><span className="lbl">{t("Working folder")}</span><span className="val">{activePath || t("whole workspace (//…)")}</span></span>
|
||||
<span className="chev">{I.chev}</span>
|
||||
</div>
|
||||
{uproject && (
|
||||
<button className="ue-btn" onClick={launchUE} title={t("Open this project in Unreal Engine")}>
|
||||
<span className="ue-ic">{I.ue}</span>
|
||||
<span className="ue-lbl"><span className="ue-a">{t("Launch")}</span><span className="ue-b">Unreal</span></span>
|
||||
</button>
|
||||
)}
|
||||
{pending.length > 0 ? (
|
||||
<div className="tzone push" onClick={pushAll} title={t("Submit committed to the server (push)")}>
|
||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 20V8M6 14l6-6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
|
||||
|
||||
Reference in New Issue
Block a user