gitea: the app — repos, code, issues, pulls, notifications

Replicates what Gitea's own web UI offers, on top of the sidecar's /_api pass-through.

Repository browsing (tree, file view with the FileViewer's shiki renderer, README), commits,
branches, tags, releases, issues and pull requests both per-repo and cross-repo, notifications,
explore/search and organizations. Routes are /gitea/:section plus /gitea/repo/:owner/:name/:tab/:item,
all real Links with the URL as the source of truth — no selection channel.

Markdown is rendered client-side (react-markdown + remark-gfm + rehype-sanitize, rehype-raw
deliberately absent) rather than through the instance's /api/v1/markdown, because consuming that
means dangerouslySetInnerHTML and there is no DOMPurify in the tree with installs frozen. The cost
is Gitea's #123 and @mention cross-references; relative links and images are resolved instead.
The /markdown and /markup allow-list entries stay, so that door is open when a sanitiser lands.

retargetUrls rebases instance-minted URLs onto a browser-reachable origin, IN ONE DIRECTION ONLY.
This instance answers with two: /user and /repos build from its configured ROOT_URL
(http://localhost:9004), /contents from the public host. An unconditional rewrite onto the
connection URL therefore broke the second set to match the first, turning working https links into
dead loopback ones. Only a private/loopback URL is rewritten now, and only when the target is
itself public; when the connection URL is a dial address nothing is touched and the connection
screen says why avatars will not load.

Also carries the frontend half of the one-instance-many-tokens model: the connection form draws a
URL field only for the owner and sends no url key at all for anyone else, ServiceConnection.url is
string | null to match officerdb, and the rebase origin comes from the resolved instanceUrl rather
than connection.url, which is null for a member.

Not verified: no runtime pass since the last four changes, the issues and pull views have never
rendered a row (the instance has none), and the member path has never executed (one account).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 17:10:36 +00:00
co-authored by Claude Opus 5
parent 4892441ee2
commit 10ff23c5dd
33 changed files with 3113 additions and 3 deletions
@@ -0,0 +1,61 @@
import { useEffect, useMemo } from 'react';
import { Navigate, useParams } from 'react-router';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView, DEFAULT_GITEA_SECTION, giteaSectionPath, isGiteaSection } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /gitea uses the Workspace/Panel system (like /transmission): a section nav on the left and the section view
// on the right. Both talk to the officer-gitea sidecar through the /api/gitea auth proxy, which holds no
// Gitea credentials of its own — the instance URL and the access token live in the sidecar.
//
// The open section is :section in the URL, so the panels read it with useParams instead of passing state
// between themselves over a channel. This screen backs both /gitea and /gitea/:section and is the single
// place that decides what an absent or bogus section means.
const ALLOWED_APP_TYPES = new Set<string | null>(['gitea-nav', 'gitea-view', null]);
function normalizeLayout(node: LayoutNode): LayoutNode {
if (node.type === 'panel') {
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'gitea-view' };
}
const children = node.children.map((c) => {
const fixed = normalizeLayout(c.node);
return fixed === c.node ? c : { ...c, node: fixed };
});
const changed = children.some((c, i) => c !== node.children[i]);
return changed ? { ...node, children } : node;
}
export const GiteaScreen = () => {
const { section, owner, name } = useParams();
const rawWorkspace = useDashboardState<LayoutNode>('screens/gitea', defaultLayout);
const workspace = useMemo(() => {
const fixed = normalizeLayout(rawWorkspace.value);
if (fixed === rawWorkspace.value) return rawWorkspace;
return { ...rawWorkspace, value: fixed };
}, [rawWorkspace]);
useEffect(() => {
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
rawWorkspace.setValue(workspace.value);
}
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
// Bare /gitea, or a section that doesn't exist, resolves to a canonical URL rather than rendering a default
// while the address bar says something else — the nav highlight is derived from the URL.
//
// A repository route (/gitea/repo/:owner/:name/…) has no :section at all and is exempt: it is a second,
// deeper shape on the same screen, not a malformed section.
const inRepo = !!owner && !!name;
if (!inRepo && !isGiteaSection(section)) {
return <Navigate to={giteaSectionPath(DEFAULT_GITEA_SECTION)} replace />;
}
return (
<div className="h-full w-full pt-2">
<WorkspaceView workspace={workspace} locked />
</div>
);
};
@@ -0,0 +1,11 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'gitea-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'gitea-nav', appType: 'gitea-nav' }, size: 22 },
{ node: { type: 'panel', id: 'gitea-view', appType: 'gitea-view' }, size: 78 },
],
};
@@ -0,0 +1 @@
export * from './GiteaScreen';
@@ -142,6 +142,7 @@ import {
CalendarDays,
Contact,
Clapperboard,
GitBranch,
} from 'lucide-react';
export const ALL_DOCK_ITEMS: DockItem[] = [
@@ -159,6 +160,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
{ label: 'Wallet', to: '/wallet', icon: Bitcoin, color: '#f7931a' },
{ label: 'Invoices', to: '/invoices', icon: Receipt, color: '#0891b2' },
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
@@ -18,6 +18,7 @@ export * from './Headscale';
export * from './Photos';
export * from './Jellyfin';
export * from './Transmission';
export * from './Gitea';
export * from './Invoices';
export * from './Wallet';
export * from './SystemMonitor';