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

@ -6,6 +6,7 @@
"permissions": [
"core:default",
"opener:default",
"opener:allow-open-path",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",

View File

@ -610,6 +610,54 @@ async fn open_in_explorer(state: State<'_, AppState>, target: String) -> Result<
Ok(())
}
/// Look for an Unreal `.uproject` inside the working-folder scope (resolved to a
/// local dir via `p4 where`). Checks the folder itself and one level of subdirs.
/// Returns the local path of the first match, or "" if the scope isn't a UE project.
#[tauri::command]
async fn find_uproject(state: State<'_, AppState>, scope: String) -> Result<String, String> {
if scope.is_empty() {
return Ok(String::new());
}
let conn = current(&state)?;
let spec = format!("{}/...", scope.trim_end_matches(['/', '.']));
let dir = match run_json(&conn, &["where", &spec])?
.into_iter()
.next()
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
{
Some(p) => p.trim_end_matches(['.', '/', '\\']).to_string(),
None => return Ok(String::new()),
};
let is_uproject = |p: &std::path::Path| {
p.extension().map_or(false, |x| x.eq_ignore_ascii_case("uproject"))
};
// the scope folder itself
if let Ok(entries) = std::fs::read_dir(&dir) {
let mut subdirs = Vec::new();
for e in entries.flatten() {
let p = e.path();
if is_uproject(&p) {
return Ok(p.to_string_lossy().to_string());
}
if p.is_dir() {
subdirs.push(p);
}
}
// one level down
for sd in subdirs {
if let Ok(inner) = std::fs::read_dir(&sd) {
for e in inner.flatten() {
let p = e.path();
if is_uproject(&p) {
return Ok(p.to_string_lossy().to_string());
}
}
}
}
}
Ok(String::new())
}
/// Files and metadata of a submitted changelist (History detail).
#[tauri::command]
async fn p4_describe(state: State<'_, AppState>, change: String) -> Result<Value, String> {
@ -702,6 +750,7 @@ pub fn run() {
p4_scan,
p4_undo_change,
open_in_explorer,
find_uproject,
p4_describe,
read_file,
read_depot,

View File

@ -248,6 +248,41 @@ body.resizing{cursor:col-resize!important;user-select:none}
cursor:pointer;font-size:12px;font-variant-numeric:tabular-nums}
.zval:hover{border-color:var(--accent)}
/* custom dropdown (replaces native <select>) */
.xsel{position:relative}
.xsel-trig{display:flex;align-items:center;gap:9px;width:100%;background:var(--input-bg);border:1px solid var(--border);
border-radius:10px;padding:10px 12px;cursor:pointer;color:var(--txt);font-family:var(--font);font-size:13px;text-align:left}
.xsel-trig:hover,.xsel.open .xsel-trig{border-color:var(--accent)}
.xsel.open .xsel-trig{box-shadow:0 0 0 3px rgba(124,110,246,.15)}
.xsel-trig > svg{width:15px;height:15px;color:var(--faint);flex:0 0 auto}
.xsel-val{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.xsel-ph{color:var(--faint)}
.xsel-chev{flex:0 0 auto;color:var(--faint);display:grid;place-items:center;transition:transform .18s}
.xsel-chev svg{width:15px;height:15px}
.xsel.open .xsel-chev{transform:rotate(180deg)}
.xsel-list{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:200;max-height:264px;overflow-y:auto;padding:5px;
background:var(--elevated);border:1px solid var(--border);border-radius:12px;box-shadow:0 20px 48px -14px rgba(0,0,0,.5);animation:updin .16s ease}
.xsel-empty{padding:12px;text-align:center;color:var(--faint);font-size:12.5px}
.xsel-item{display:flex;align-items:center;gap:9px;padding:9px 10px;border-radius:9px;cursor:pointer}
.xsel-item:hover{background:var(--hover)}
.xsel-item.on{background:rgba(124,110,246,.12)}
.xsel-ic{width:15px;flex:0 0 auto;display:grid;place-items:center;color:var(--accent-2)}
.xsel-ic svg{width:13px;height:13px}
.xsel-body{display:flex;flex-direction:column;gap:1px;min-width:0}
.xsel-name{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.xsel-sub{font-size:11px;color:var(--faint);font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* Launch-Unreal button (white icon on black / black on white per theme) */
.ue-btn{display:flex;align-items:center;gap:9px;flex:0 0 auto;align-self:center;height:40px;margin:0 8px;padding:0 15px;
border:none;border-radius:11px;cursor:pointer;background:var(--txt);color:var(--win-a);transition:filter .15s,transform .06s}
.ue-btn:hover{filter:brightness(1.12)}
.ue-btn:active{transform:scale(.97)}
.ue-ic{width:24px;height:24px;flex:0 0 auto;display:grid;place-items:center}
.ue-ic svg{width:24px;height:24px}
.ue-lbl{display:flex;flex-direction:column;line-height:1.12;text-align:left}
.ue-a{font-size:9.5px;text-transform:uppercase;letter-spacing:.6px;opacity:.72}
.ue-b{font-size:13px;font-weight:700}
/* bottom status bar (always visible) + hover log peek */
.statusbar{flex:0 0 26px;display:flex;align-items:center;gap:10px;padding:0 12px;border-top:1px solid var(--border);
background:var(--bar);font-size:11.5px;color:var(--muted);position:relative;user-select:none;z-index:60}

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>

View File

@ -67,6 +67,9 @@ const D: Record<string, Tr> = {
"Get Latest": { ru: "Get Latest", de: "Get Latest", fr: "Get Latest", es: "Get Latest" },
"Working…": { ru: "Работаю…", de: "Arbeite…", fr: "En cours…", es: "Trabajando…" },
"pull from server": { ru: "забрать с сервера (pull)", de: "vom Server holen (pull)", fr: "récupérer du serveur (pull)", es: "traer del servidor (pull)" },
"Launch": { ru: "Запустить", de: "Starten", fr: "Lancer", es: "Iniciar" },
"Open this project in Unreal Engine": { ru: "Открыть проект в Unreal Engine", de: "Projekt in Unreal Engine öffnen", fr: "Ouvrir le projet dans Unreal Engine", es: "Abrir el proyecto en Unreal Engine" },
"Launching Unreal…": { ru: "Запуск Unreal…", de: "Unreal wird gestartet…", fr: "Lancement dUnreal…", es: "Iniciando Unreal…" },
// tabs / list
"Changes": { ru: "Changes", de: "Änderungen", fr: "Modifications", es: "Cambios" },

View File

@ -72,6 +72,7 @@ export const p4 = {
describe: (change: string) => invoke<Describe>("p4_describe", { change }),
undoChange: (change: string) => invoke<string>("p4_undo_change", { change }),
openInExplorer: (target: string) => invoke<void>("open_in_explorer", { target }),
findUproject: (scope: string) => invoke<string>("find_uproject", { scope }),
readDepot: async (depot: string): Promise<ArrayBuffer> => {
const r: unknown = await invoke("read_depot", { depot });
if (r instanceof ArrayBuffer) return r;