diff --git a/src/App.css b/src/App.css index 06c2971..69ccf51 100644 --- a/src/App.css +++ b/src/App.css @@ -947,6 +947,11 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .dropinner svg{width:44px;height:44px} .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{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); diff --git a/src/App.tsx b/src/App.tsx index 11774a5..f4944e5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,7 @@ import ModelViewer from "./ModelViewer"; import CodeView from "./CodeView"; import { useUpdater } from "./Updater"; 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 { 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"; @@ -408,6 +408,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan const [needResolve, setNeedResolve] = useState([]); // files awaiting conflict resolution const [resolveOpen, setResolveOpen] = useState(false); 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 [behind, setBehind] = useState<{ count: number; sample: string[] } | null>(null); // server has newer content than the workspace const lastSubmit = useRef(""); // 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 })); })().catch(() => {}); }, [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)), []); // live Perforce command log — the Rust backend emits one event per p4 call useEffect(() => { @@ -796,7 +799,10 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan 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 } }; @@ -1804,6 +1810,10 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan {showXfer && transfer && xferMin && setXferMin(false)} onCancel={cancelTransfer} />} {buildPick && setBuildPick(false)} />} {buildOpen && !buildMin && 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 && { setReauth(false); setOffline(false); onInfo(i); flash(t("Reconnected to the server.")); void refresh(); }} />} {buildOpen && buildMin && setBuildMin(false)} onClose={() => setBuildOpen(false)} />} {ctx && setCtx(null)} />} {toast &&
{toast.text}
} @@ -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(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 ( +
+
e.stopPropagation()}> +

{t("Session expired")}

+
+ {t("Perforce ended the session. Sign in again to carry on — your workspace and open changes are untouched.")} +
+
+ {session?.user} + {session?.client} + {session?.server} +
+
+
+ + setPassword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && reconnect()} /> +
+
+ {err &&
{err}
} +
+ + +
+
+
+ ); +} + /* ---------------- confirm modal ---------------- */ function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) { const btn = useRef(null); diff --git a/src/i18n.ts b/src/i18n.ts index a6adc41..617b542 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -447,6 +447,11 @@ const D: Record = { "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}" }, "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" }, "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" }, diff --git a/src/p4.ts b/src/p4.ts index 8ecbe07..e81447d 100644 --- a/src/p4.ts +++ b/src/p4.ts @@ -1,5 +1,31 @@ // 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(cmd: string, args?: Record): Promise { + return rawInvoke(cmd, args).catch((e) => { + if (!AUTH_CMDS.has(cmd) && isAuthError(e)) authLost?.(); + throw e; + }); +} export interface P4Info { serverAddress?: string;