import { useEffect, useState } from 'react'; import { useParams } from 'react-router'; import { Loader2, TriangleAlert } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { GiteaConnection, type ConnectionState } from './GiteaConnection'; // Gitea's own web interface, rendered in place. // // ── Why an iframe and not panels ── // // Gitea has a complete web interface. The version of this that shipped before was seventeen files // reimplementing a fraction of it, and every one of those files was a promise to keep up with a project // that moves faster than this plugin will. // // ── The one thing this component must do ── // // Get a cookie before the frame loads. Everything inside the iframe — stylesheets, scripts, avatars — is // fetched by the browser with no Authorization header, so the frame's URL is served above the platform's // gate and authenticates with a path-scoped cookie instead. That cookie is minted by a normal // authenticated call, which is what this does first. // // Load the iframe before that call returns and every request in it is unauthenticated, which reads as // Gitea being down. const FRAME_SRC = '/api/gitea/_web/'; export const GiteaFrame = () => { // `/gitea/connection` renders the settings instead of the frame. The plugin route pair gives this for // free — `` and `/:section` are generated for every plugin — and it is the only way to // change a token once one is set: the form used to appear ONLY when nothing was configured, so a // member who mistyped theirs, or anyone rotating one, had nowhere to go. const { section } = useParams(); const { get, post } = useClient(); const [state, setState] = useState<'opening' | 'ready' | 'failed'>('opening'); const [connection, setConnection] = useState(null); const [error, setError] = useState(null); const [attempt, setAttempt] = useState(0); useEffect(() => { let cancelled = false; // Not in a dependency array: `useClient()` returns a new object every render, so depending on `get` // or `post` would re-run this on every keystroke elsewhere in the app. void (async () => { try { // The connection FIRST. Without it the frame loads and every request inside it answers 503, // which reads as Gitea being down rather than as nobody having said where Gitea is. const conn = await get('/gitea/_config'); if (cancelled) return; setConnection(conn); if (!conn.configured) { setState('ready'); return; } await post('/gitea/session', {}); if (!cancelled) setState('ready'); } catch (err) { if (cancelled) return; setError(err instanceof Error ? err.message : 'could not open a session'); setState('failed'); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [attempt]); if (state === 'opening') { return (
Opening Gitea…
); } if (state === 'failed') { return (

Could not open Gitea

{/* The message, not a generic one: the interesting failure here is almost always "the sidecar is not running" or "no instance is configured", and those want different answers. */}

{error}

); } // Unconfigured goes straight to the form; `/gitea/connection` opens it deliberately. if (connection && (!connection.configured || section === 'connection')) { return setAttempt((n) => n + 1)} />; } return (
{/* The escape hatch, and it is not an admission of defeat: the whole point of proxying an app that already has a UI is that its own UI stays available. Anything the frame handles badly, this opens properly. */}