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:
Bonchellon
2026-07-07 15:28:17 +03:00
parent 4249d5ba66
commit d9f99bc58b
6 changed files with 157 additions and 10 deletions

View File

@ -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>