Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 597dffe92d | |||
| 30e77664d5 |
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "exbyte-depot",
|
"name": "exbyte-depot",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.1",
|
"version": "0.4.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -992,7 +992,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "exbyte-depot"
|
name = "exbyte-depot"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "exbyte-depot"
|
name = "exbyte-depot"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
|
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
|
||||||
authors = ["Exbyte Studios"]
|
authors = ["Exbyte Studios"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Exbyte Depot",
|
"productName": "Exbyte Depot",
|
||||||
"mainBinaryName": "Exbyte Depot",
|
"mainBinaryName": "Exbyte Depot",
|
||||||
"version": "0.4.1",
|
"version": "0.4.2",
|
||||||
"identifier": "com.bonchellon.exbyte-depot",
|
"identifier": "com.bonchellon.exbyte-depot",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@ -947,6 +947,11 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
|||||||
.dropinner svg{width:44px;height:44px}
|
.dropinner svg{width:44px;height:44px}
|
||||||
.dropinner span{font-size:15px;font-weight:600;color:var(--txt)}
|
.dropinner span{font-size:15px;font-weight:600;color:var(--txt)}
|
||||||
|
|
||||||
|
/* re-auth: who we're signing back in as */
|
||||||
|
.reauth-who{display:flex;flex-wrap:wrap;gap:6px;margin:2px 0 14px}
|
||||||
|
.reauth-who span{font-family:var(--mono);font-size:11px;color:var(--muted);
|
||||||
|
background:var(--panel-3);border:1px solid var(--border);border-radius:6px;padding:3px 8px}
|
||||||
|
|
||||||
/* toast */
|
/* toast */
|
||||||
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:200;max-width:72%;
|
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:200;max-width:72%;
|
||||||
background:var(--elevated);border:1px solid var(--border);color:var(--txt);
|
background:var(--elevated);border:1px solid var(--border);color:var(--txt);
|
||||||
|
|||||||
73
src/App.tsx
73
src/App.tsx
@ -9,7 +9,7 @@ import ModelViewer from "./ModelViewer";
|
|||||||
import CodeView from "./CodeView";
|
import CodeView from "./CodeView";
|
||||||
import { useUpdater } from "./Updater";
|
import { useUpdater } from "./Updater";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, syncSummary, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry, PluginInfo } from "./p4";
|
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, syncSummary, isAuthError, onAuthLost, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry, PluginInfo } from "./p4";
|
||||||
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
|
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
|
||||||
import { Theme, THEME_TOKENS, BUILTIN_THEMES, allThemes, loadCustomThemes, saveCustomThemes, loadActiveId, saveActiveId, resolveTheme, applyTheme, tokenValue, newCustomId, exportTheme, importTheme } from "./themes";
|
import { Theme, THEME_TOKENS, BUILTIN_THEMES, allThemes, loadCustomThemes, saveCustomThemes, loadActiveId, saveActiveId, resolveTheme, applyTheme, tokenValue, newCustomId, exportTheme, importTheme } from "./themes";
|
||||||
import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView, PluginDock, fetchRegistry, installFromRegistry, RegistryEntry } from "./plugins";
|
import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView, PluginDock, fetchRegistry, installFromRegistry, RegistryEntry } from "./plugins";
|
||||||
@ -408,6 +408,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
|||||||
const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // files awaiting conflict resolution
|
const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // files awaiting conflict resolution
|
||||||
const [resolveOpen, setResolveOpen] = useState(false);
|
const [resolveOpen, setResolveOpen] = useState(false);
|
||||||
const [offline, setOffline] = useState(false); // lost connection to the server
|
const [offline, setOffline] = useState(false); // lost connection to the server
|
||||||
|
const [reauth, setReauth] = useState(false); // p4 ticket expired — prompt for the password
|
||||||
const [workOffline, setWorkOffline] = useState(() => { try { return localStorage.getItem("exd-workoffline") === "1"; } catch { return false; } }); // explicit offline-work mode
|
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 [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 lastSubmit = useRef<string>(""); // newest submitted CL seen (new-submit toast)
|
||||||
@ -674,6 +675,8 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
|||||||
if (loaded) flash(t("{n} plugin(s) loaded", { n: loaded }));
|
if (loaded) flash(t("{n} plugin(s) loaded", { n: loaded }));
|
||||||
})().catch(() => {});
|
})().catch(() => {});
|
||||||
}, [info?.clientName]); // eslint-disable-line
|
}, [info?.clientName]); // eslint-disable-line
|
||||||
|
// any p4 command that comes back "not logged in" raises the re-auth prompt
|
||||||
|
useEffect(() => { onAuthLost(() => setReauth(true)); return () => onAuthLost(null); }, []);
|
||||||
useEffect(() => subscribeRegistry(() => setPluginVer((v) => v + 1)), []);
|
useEffect(() => subscribeRegistry(() => setPluginVer((v) => v + 1)), []);
|
||||||
// live Perforce command log — the Rust backend emits one event per p4 call
|
// live Perforce command log — the Rust backend emits one event per p4 call
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -796,7 +799,10 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
|||||||
checkBehind(); // a new submit landed → am I now behind?
|
checkBehind(); // a new submit landed → am I now behind?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
// an expired ticket is not an outage: the server answered, it just
|
||||||
|
// refused us. The re-auth prompt handles it — don't also claim offline.
|
||||||
|
if (isAuthError(e)) return;
|
||||||
if (alive && !offline) setOffline(true); // p4 unreachable → show offline banner
|
if (alive && !offline) setOffline(true); // p4 unreachable → show offline banner
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1804,6 +1810,10 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
|||||||
{showXfer && transfer && xferMin && <TransferChip transfer={transfer} onExpand={() => setXferMin(false)} onCancel={cancelTransfer} />}
|
{showXfer && transfer && xferMin && <TransferChip transfer={transfer} onExpand={() => setXferMin(false)} onCancel={cancelTransfer} />}
|
||||||
{buildPick && <BuildPicker onPick={buildSln} onClose={() => setBuildPick(false)} />}
|
{buildPick && <BuildPicker onPick={buildSln} onClose={() => setBuildPick(false)} />}
|
||||||
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
|
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
|
||||||
|
{/* last in the tree so it sits over every other overlay — nothing else
|
||||||
|
can succeed until the session is back */}
|
||||||
|
{reauth && <ReauthModal session={session} onSignOut={onDisconnect}
|
||||||
|
onDone={(i) => { setReauth(false); setOffline(false); onInfo(i); flash(t("Reconnected to the server.")); void refresh(); }} />}
|
||||||
{buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
|
{buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
|
||||||
{ctx && <ContextMenu x={ctx.x} y={ctx.y} items={ctx.items} onClose={() => setCtx(null)} />}
|
{ctx && <ContextMenu x={ctx.x} y={ctx.y} items={ctx.items} onClose={() => setCtx(null)} />}
|
||||||
{toast && <div className={"toast" + (toast.err ? " err" : "")}>{toast.text}</div>}
|
{toast && <div className={"toast" + (toast.err ? " err" : "")}>{toast.text}</div>}
|
||||||
@ -2406,6 +2416,65 @@ function TextPrompt({ title, label, value, onSave, onClose }: { title: string; l
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- re-authenticate (expired ticket) ----------------
|
||||||
|
Perforce expires the login ticket on its own schedule, so a long day
|
||||||
|
outlives it and every command starts failing. The server is still there and
|
||||||
|
the workspace is untouched — only the ticket died — so we ask for the
|
||||||
|
password over the running app instead of dropping back to the Connect
|
||||||
|
screen, which would throw away the scope, selection and open panels.
|
||||||
|
Deliberately not dismissable by clicking away: nothing works until it's
|
||||||
|
resolved one way or the other. */
|
||||||
|
function ReauthModal({ session, onDone, onSignOut }: {
|
||||||
|
session: Session | null; onDone: (i: P4Info) => void; onSignOut: () => void;
|
||||||
|
}) {
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => { ref.current?.focus(); }, []);
|
||||||
|
|
||||||
|
async function reconnect() {
|
||||||
|
if (!session || !password || busy) return;
|
||||||
|
setBusy(true); setErr("");
|
||||||
|
try {
|
||||||
|
const i = await p4.login(session.server, session.user, password, session.client);
|
||||||
|
setPassword("");
|
||||||
|
onDone(i);
|
||||||
|
} catch (e) { setErr(String(e)); setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-back">
|
||||||
|
<div className="modal-card prompt" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{t("Session expired")}</h3>
|
||||||
|
<div className="prompt-label">
|
||||||
|
{t("Perforce ended the session. Sign in again to carry on — your workspace and open changes are untouched.")}
|
||||||
|
</div>
|
||||||
|
<div className="reauth-who">
|
||||||
|
<span>{session?.user}</span>
|
||||||
|
<span>{session?.client}</span>
|
||||||
|
<span>{session?.server}</span>
|
||||||
|
</div>
|
||||||
|
<div className="fld"><label>{t("Password")}</label>
|
||||||
|
<div className="inp">
|
||||||
|
<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 ref={ref} type="password" value={password} disabled={busy}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && reconnect()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{err && <div className="err"><span>⚠</span><span>{err}</span></div>}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="mbtn ghost" onClick={onSignOut} disabled={busy}>{t("Sign out")}</button>
|
||||||
|
<button className="mbtn primary" onClick={reconnect} disabled={busy || !password}>
|
||||||
|
{busy ? t("Connecting…") : t("Reconnect")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- confirm modal ---------------- */
|
/* ---------------- confirm modal ---------------- */
|
||||||
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
|
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
|
||||||
const btn = useRef<HTMLButtonElement>(null);
|
const btn = useRef<HTMLButtonElement>(null);
|
||||||
|
|||||||
@ -447,6 +447,11 @@ const D: Record<string, Tr> = {
|
|||||||
"Not a text file.": { ru: "Не текстовый файл.", de: "Keine Textdatei.", fr: "Pas un fichier texte.", es: "No es un archivo de texto." },
|
"Not a text file.": { ru: "Не текстовый файл.", de: "Keine Textdatei.", fr: "Pas un fichier texte.", es: "No es un archivo de texto." },
|
||||||
"{user} submitted #{n}": { ru: "{user} отправил #{n}", de: "{user} hat #{n} übermittelt", fr: "{user} a soumis #{n}", es: "{user} envió #{n}" },
|
"{user} submitted #{n}": { ru: "{user} отправил #{n}", de: "{user} hat #{n} übermittelt", fr: "{user} a soumis #{n}", es: "{user} envió #{n}" },
|
||||||
"Reconnected to the server.": { ru: "Соединение с сервером восстановлено.", de: "Wieder mit dem Server verbunden.", fr: "Reconnecté au serveur.", es: "Reconectado al servidor." },
|
"Reconnected to the server.": { ru: "Соединение с сервером восстановлено.", de: "Wieder mit dem Server verbunden.", fr: "Reconnecté au serveur.", es: "Reconectado al servidor." },
|
||||||
|
// session expired → re-auth prompt
|
||||||
|
"Session expired": { ru: "Сессия истекла", de: "Sitzung abgelaufen", fr: "Session expirée", es: "Sesión expirada" },
|
||||||
|
"Perforce ended the session. Sign in again to carry on — your workspace and open changes are untouched.": { ru: "Perforce завершил сессию. Войдите снова, чтобы продолжить — workspace и открытые правки не тронуты.", de: "Perforce hat die Sitzung beendet. Melde dich erneut an — dein Workspace und offene Änderungen bleiben unberührt.", fr: "Perforce a mis fin à la session. Reconnectez-vous pour continuer — votre workspace et vos modifications ouvertes sont intacts.", es: "Perforce terminó la sesión. Inicia sesión de nuevo para continuar: tu workspace y los cambios abiertos están intactos." },
|
||||||
|
"Reconnect": { ru: "Переподключиться", de: "Neu verbinden", fr: "Se reconnecter", es: "Reconectar" },
|
||||||
|
"Sign out": { ru: "Выйти", de: "Abmelden", fr: "Se déconnecter", es: "Cerrar sesión" },
|
||||||
"Connected server": { ru: "Сервер подключения", de: "Verbundener Server", fr: "Serveur connecté", es: "Servidor conectado" },
|
"Connected server": { ru: "Сервер подключения", de: "Verbundener Server", fr: "Serveur connecté", es: "Servidor conectado" },
|
||||||
"No embedded thumbnail. Load the 3D model below (needs Unreal Engine installed).": { ru: "Встроенного превью нет. Загрузи 3D-модель ниже (нужен установленный Unreal Engine).", de: "Kein eingebettetes Vorschaubild. Lade unten das 3D-Modell (Unreal Engine muss installiert sein).", fr: "Pas de miniature intégrée. Charge le modèle 3D ci-dessous (Unreal Engine requis).", es: "Sin miniatura incrustada. Carga el modelo 3D abajo (requiere Unreal Engine instalado)." },
|
"No embedded thumbnail. Load the 3D model below (needs Unreal Engine installed).": { ru: "Встроенного превью нет. Загрузи 3D-модель ниже (нужен установленный Unreal Engine).", de: "Kein eingebettetes Vorschaubild. Lade unten das 3D-Modell (Unreal Engine muss installiert sein).", fr: "Pas de miniature intégrée. Charge le modèle 3D ci-dessous (Unreal Engine requis).", es: "Sin miniatura incrustada. Carga el modelo 3D abajo (requiere Unreal Engine instalado)." },
|
||||||
"Load 3D model": { ru: "Загрузить 3D-модель", de: "3D-Modell laden", fr: "Charger le modèle 3D", es: "Cargar modelo 3D" },
|
"Load 3D model": { ru: "Загрузить 3D-модель", de: "3D-Modell laden", fr: "Charger le modèle 3D", es: "Cargar modelo 3D" },
|
||||||
|
|||||||
28
src/p4.ts
28
src/p4.ts
@ -1,5 +1,31 @@
|
|||||||
// Typed wrappers around the Rust p4 commands.
|
// Typed wrappers around the Rust p4 commands.
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke as rawInvoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
/** True when p4 rejected us for auth reasons — an expired or dropped ticket —
|
||||||
|
* as opposed to the server being unreachable. Perforce expires tickets on its
|
||||||
|
* own schedule (often 12h), so a workday can outlive a login. */
|
||||||
|
export function isAuthError(e: unknown): boolean {
|
||||||
|
return /P4PASSWD|invalid or unset|session has expired|session was logged out|please login again/i
|
||||||
|
.test(String(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set by the app: called when any command comes back with an auth failure, so
|
||||||
|
// the UI can ask for the password again instead of dead-ending on every action.
|
||||||
|
let authLost: (() => void) | null = null;
|
||||||
|
export function onAuthLost(fn: (() => void) | null) { authLost = fn; }
|
||||||
|
|
||||||
|
// Commands that legitimately report bad credentials — the login screen shows
|
||||||
|
// those inline, and firing the re-auth prompt from them would loop.
|
||||||
|
const AUTH_CMDS = new Set(["p4_login", "p4_restore", "p4_clients"]);
|
||||||
|
|
||||||
|
/** Every p4 command funnels through here so a lost ticket is caught in one
|
||||||
|
* place rather than at each of the ~110 call sites. */
|
||||||
|
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
return rawInvoke<T>(cmd, args).catch((e) => {
|
||||||
|
if (!AUTH_CMDS.has(cmd) && isAuthError(e)) authLost?.();
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface P4Info {
|
export interface P4Info {
|
||||||
serverAddress?: string;
|
serverAddress?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user