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
+3 -2
View File
@@ -27,8 +27,9 @@ Long-running and privileged work lives in **sidecars**: separate processes that
(`ecosystem.config.cjs`): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`,
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin` — nineteen as of
2026-08-04, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the source of truth.
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
source of truth.
**`officer-anthropic-proxy` and `officer-agent` are not the same thing.** The proxy holds the Anthropic
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
+8
View File
@@ -109,6 +109,14 @@ module.exports = {
args: 'run src/servers/sidecar/memos/index.ts',
watch: false,
},
// Code hosting. Wraps a self-hosted Gitea. The instance URL and its personal access token are set by
// the owner from /gitea and stored in `service_connections` — read here, never from the environment.
{
name: 'officer-gitea',
script: 'bun',
args: 'run src/servers/sidecar/gitea/index.ts',
watch: false,
},
// Calendar and contacts. Supervises Radicale (CalDAV/CardDAV) on a loopback port and owns the
// collections under DATA_PATH/dav. Two doors: /dav for phones (DAVx5, iOS, Thunderbird — HTTP Basic
// against a scoped app password) and /api/caldav for Officer's own UI. The protocol is Radicale's;
+5
View File
@@ -52,6 +52,11 @@ export function App() {
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
<Route path="/transmission/:section" element={<Dashboard.TransmissionScreen />} />
<Route path="/gitea" element={<Dashboard.GiteaScreen />} />
<Route path="/gitea/:section" element={<Dashboard.GiteaScreen />} />
<Route path="/gitea/repo/:owner/:name" element={<Dashboard.GiteaScreen />} />
<Route path="/gitea/repo/:owner/:name/:tab" element={<Dashboard.GiteaScreen />} />
<Route path="/gitea/repo/:owner/:name/:tab/:item" element={<Dashboard.GiteaScreen />} />
<Route path="/invoices" element={<Dashboard.InvoicesScreen />} />
<Route path="/invoices/:section" element={<Dashboard.InvoicesScreen />} />
<Route path="/wallet" element={<Dashboard.WalletScreen />} />
@@ -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';
@@ -23,6 +23,7 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
{ match: (p) => p.startsWith('/wallet'), title: 'Wallet' },
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
+16
View File
@@ -0,0 +1,16 @@
import { createSidecarProxy } from '../../sidecar/create-proxy';
// /api/gitea/* — auth, then forward to officer-gitea. No routes of its own and no Gitea knowledge:
// this file must never grow app logic.
//
// The sidecar owns the Gitea contract and holds the personal access token. The platform knows neither.
const proxy = createSidecarProxy({
name: 'gitea',
prefix: '/api/gitea',
});
export const giteaRouter = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getGiteaServerUrl = proxy.getHttpUrl;
+2
View File
@@ -33,6 +33,7 @@ import { vpnRouter } from './api/vpn/router';
import { terminalRouter } from './api/terminal/sidecar-server';
import { caldavRouter } from './api/dav/sidecar-server';
import { memosRouter } from './api/memos/router';
import { giteaRouter } from './api/gitea/router';
import { davSyncRouter } from './api/dav/sync-router';
import { davRouter } from './api/dav/router';
import { claimIosProfile } from './api/dav/ios-profile';
@@ -164,6 +165,7 @@ protectedRouter.route('/music', musicRouter);
protectedRouter.route('/slskd', slskdRouter);
protectedRouter.route('/terminal', terminalRouter);
protectedRouter.route('/memos', memosRouter);
protectedRouter.route('/gitea', giteaRouter);
protectedRouter.route('/caldav', caldavRouter); // the JSON door for Officer's own calendar/contacts UI
protectedRouter.route('/dav', davRouter); // app-password management (the sync door is /dav, top-level)
protectedRouter.route('/notify', notifyRouter);
+5
View File
@@ -54,6 +54,11 @@ const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '50
const ALLOWED = [
/^\/api\/v1\/version(\/|$|\?)/,
/^\/api\/v1\/user(\/|$|\?)/,
// Public profile data. Note this prefix also covers `/users/{name}/tokens`, which lists and CREATES
// access tokens — a token-minting door if it were reachable. It is not: Gitea requires basic auth there
// precisely to stop a token escalating its own scopes, and answers `401 auth required` to the
// `Authorization: token …` this sidecar sends. Verified against 1.27.0 on 2026-08-06. That protection is
// GITEA's, not ours, so if this ever needs re-checking on an upgrade, that is the endpoint to re-check.
/^\/api\/v1\/users(\/|$|\?)/,
/^\/api\/v1\/orgs(\/|$|\?)/,
/^\/api\/v1\/org(\/|$|\?)/,
+2
View File
@@ -77,6 +77,8 @@ export type SidecarEvent =
| { type: 'photos:server'; port: number }
// Memos — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'memos:server'; port: number }
// Gitea — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'gitea:server'; port: number }
// CalDAV/CardDAV — the sidecar reports where its HTTP server is listening (random port) on connect.
// One port serves both doors: /dav (forwarded to Radicale) and /_officer (JSON for Officer's UI).
| { type: 'caldav:server'; port: number }
@@ -13,6 +13,7 @@ import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
import { appRegistryMetas as photosMetas } from '../apps/Photos';
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
import { appRegistryMetas as giteaMetas } from '../apps/Gitea';
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
@@ -36,6 +37,7 @@ const apps = [
...photosMetas,
...jellyfinMetas,
...transmissionMetas,
...giteaMetas,
...invoicesMetas,
...walletMetas,
...monitorMetas,
@@ -0,0 +1,250 @@
import type { GiteaIssue, GiteaNotification } from './shared';
import { useState } from 'react';
import { Link } from 'react-router';
import { Bell, Building2, ExternalLink, MessageSquare, Search } from 'lucide-react';
import { EmptyState, ErrorState, LabelChip, Loading, StateFilter, StateIcon } from './GiteaBits';
import { RepoRow } from './RepositoriesView';
import { giteaRepoPath, timeAgo } from './shared';
import { useGiteaIssueSearch, useGiteaNotifications, useGiteaOrgs, useGiteaRepoSearch } from './useGiteaData';
// The cross-repository sections — the ones Gitea puts in its own header rather than inside a repository:
// your issues, your pull requests, your notifications, explore, and the orgs you belong to.
// ── Issues / pull requests across every repo ──────────────────────────────────────────────────────
type CrossRepoProps = { type: 'issues' | 'pulls' };
export const CrossRepoIssuesView = ({ type }: CrossRepoProps) => {
const [state, setState] = useState<'open' | 'closed' | 'all'>('open');
const { data: issues, isLoading, error } = useGiteaIssueSearch({ state, type });
const noun = type === 'pulls' ? 'pull requests' : 'issues';
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-3 border-b px-4 py-2">
<StateFilter value={state} onChange={setState} />
{!!issues?.length && (
<span className="text-xs tabular-nums text-muted-foreground">{issues.length} across all repositories</span>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{isLoading ? (
<Loading label={`Loading ${noun}`} />
) : error ? (
<ErrorState title={`Could not load ${noun}`} error={error} />
) : !issues?.length ? (
<EmptyState title={`No ${state === 'all' ? '' : state} ${noun}`.replace(/\s+/g, ' ').trim()} />
) : (
<div className="divide-y">
{issues.map((issue) => (
<CrossRepoRow key={issue.id} issue={issue} type={type} />
))}
</div>
)}
</div>
</div>
);
};
/**
* The cross-repo list is the one place an issue does not already know which repository it is in from the
* URL, so the row carries `repository` — and links through it, which is why that field is read rather than
* the issue's own html_url.
*/
const CrossRepoRow = ({ issue, type }: { issue: GiteaIssue; type: 'issues' | 'pulls' }) => {
const owner = issue.repository?.owner ?? '';
const name = issue.repository?.name ?? '';
const to = owner && name ? giteaRepoPath(owner, name, { tab: type, item: issue.number }) : null;
const body = (
<>
<span className="pt-0.5">
<StateIcon item={issue} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-medium">{issue.title}</span>
{issue.labels?.map((label) => (
<LabelChip key={label.id} label={label} />
))}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{issue.repository?.full_name ?? 'unknown'} #{issue.number} · opened {timeAgo(issue.created_at)}
{issue.user && ` by ${issue.user.login}`}
</p>
</div>
{!!issue.comments && (
<span className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-muted-foreground">
<MessageSquare className="h-3 w-3" />
{issue.comments}
</span>
)}
</>
);
const className = 'flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/50';
// Without a repository on the payload there is nothing to route to, so the row stays a plain div rather
// than a link that goes somewhere wrong.
return to ? (
<Link to={to} className={className}>
{body}
</Link>
) : (
<div className={className}>{body}</div>
);
};
// ── Notifications ─────────────────────────────────────────────────────────────────────────────────
/**
* Turn a notification's API url into an in-app route. Gitea gives the subject as an `/api/v1/...` address
* because the field is meant for a client to re-fetch, not to navigate; pulling the owner/repo/number back
* out of it is what lets a notification open in this app instead of bouncing to the instance.
*/
const API_SUBJECT = /\/api\/v1\/repos\/([^/]+)\/([^/]+)\/(issues|pulls)\/(\d+)/;
function subjectRoute(notification: GiteaNotification): string | null {
const match = notification.subject.url?.match(API_SUBJECT);
if (!match) return null;
const [, owner, repo, kind, index] = match;
if (!owner || !repo || !index) return null;
// Gitea reports a PR's subject under /issues/, and tells them apart with subject.type instead.
const tab = notification.subject.type === 'Pull' || kind === 'pulls' ? 'pulls' : 'issues';
return giteaRepoPath(decodeURIComponent(owner), decodeURIComponent(repo), { tab, item: index });
}
export const NotificationsView = () => {
const { data: notifications, isLoading, error } = useGiteaNotifications();
if (isLoading) return <Loading label="Loading notifications…" />;
if (error) return <ErrorState title="Could not load notifications" error={error} />;
if (!notifications?.length) {
return <EmptyState title="Nothing unread" hint="Notifications marked read on the instance disappear here too." />;
}
return (
<div className="h-full overflow-y-auto">
<div className="divide-y">
{notifications.map((notification) => {
const to = subjectRoute(notification);
const inner = (
<>
<Bell className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{notification.subject.title}</p>
<p className="text-xs text-muted-foreground">
{notification.repository?.full_name ?? ''} · {notification.subject.type} ·{' '}
{timeAgo(notification.updated_at)}
</p>
</div>
</>
);
const className = 'flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/50';
return to ? (
<Link key={notification.id} to={to} className={className}>
{inner}
</Link>
) : (
<a
key={notification.id}
href={notification.subject.html_url}
target="_blank"
rel="noreferrer"
className={className}
>
{inner}
<ExternalLink className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
</a>
);
})}
</div>
</div>
);
};
// ── Explore ───────────────────────────────────────────────────────────────────────────────────────
export const ExploreView = () => {
const [input, setInput] = useState('');
const [query, setQuery] = useState('');
const { data, isLoading, error } = useGiteaRepoSearch(query);
return (
<div className="flex h-full flex-col">
<form
className="flex items-center gap-2 border-b px-4 py-2"
onSubmit={(ev) => {
ev.preventDefault();
setQuery(input.trim());
}}
>
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={input}
onChange={(ev) => setInput(ev.target.value)}
placeholder="Search repositories on this instance…"
className="w-full rounded-md border bg-background py-1.5 pl-8 pr-2 text-sm outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<button type="submit" className="rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-muted">
Search
</button>
</form>
<div className="min-h-0 flex-1 overflow-y-auto">
{isLoading ? (
<Loading label="Searching…" />
) : error ? (
<ErrorState title="Search failed" error={error} />
) : !data?.data?.length ? (
<EmptyState title={query ? `Nothing matches “${query}` : 'No repositories'} />
) : (
<div className="divide-y">
{data.data.map((repo) => (
<RepoRow key={repo.id} repo={repo} />
))}
</div>
)}
</div>
</div>
);
};
// ── Organizations ─────────────────────────────────────────────────────────────────────────────────
export const OrganizationsView = () => {
const { data: orgs, isLoading, error } = useGiteaOrgs();
if (isLoading) return <Loading label="Loading organizations…" />;
if (error) return <ErrorState title="Could not load organizations" error={error} />;
if (!orgs?.length) return <EmptyState title="No organizations" hint="This account does not belong to any." />;
return (
<div className="h-full overflow-y-auto">
<div className="divide-y">
{orgs.map((org) => (
<div key={org.id} className="flex items-start gap-3 px-4 py-3">
{org.avatar_url ? (
<img src={org.avatar_url} alt="" className="h-8 w-8 shrink-0 rounded-lg object-cover" loading="lazy" />
) : (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted">
<Building2 className="h-4 w-4 text-muted-foreground" />
</span>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{org.full_name || org.username}</p>
{org.description && <p className="truncate text-xs text-muted-foreground">{org.description}</p>}
<p className="mt-0.5 text-[11px] text-muted-foreground">
@{org.username}
{org.visibility && ` · ${org.visibility}`}
{org.location && ` · ${org.location}`}
</p>
</div>
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,131 @@
import type { ReactNode } from 'react';
import type { GiteaIssue, GiteaLabel, GiteaPullRequest, GiteaUser } from './shared';
import {
CircleDot,
CircleCheck,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
Loader2,
TriangleAlert,
} from 'lucide-react';
import { serviceErrorMessage } from '../../hooks/useServiceConnection';
import { labelTextColor } from './shared';
// The small pieces every Gitea view repeats: an avatar, a label chip, a state pill, and the three
// non-content states (loading / failed / empty). Kept together so the repo, issue and PR screens look
// like one app rather than three, and so a change to "what an error looks like" is one edit.
export const Avatar = ({ user, size = 20 }: { user?: GiteaUser; size?: number }) => {
const initial = (user?.full_name || user?.login || '?').charAt(0).toUpperCase();
if (!user?.avatar_url) {
return (
<span
className="inline-flex shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground"
style={{ width: size, height: size }}
>
{initial}
</span>
);
}
return (
<img
src={user.avatar_url}
alt={user.login}
className="shrink-0 rounded-full object-cover"
style={{ width: size, height: size }}
loading="lazy"
/>
);
};
export const LabelChip = ({ label }: { label: GiteaLabel }) => (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium leading-none"
style={{ backgroundColor: `#${label.color.replace('#', '')}`, color: labelTextColor(label.color) }}
title={label.description}
>
{label.name}
</span>
);
/**
* The open/closed/merged pill. Merged is a genuinely distinct state from closed and Gitea colours it
* differently — a merged PR is not a rejected one — so it is worth the extra branch here.
*/
export const StateBadge = ({ item }: { item: GiteaIssue | GiteaPullRequest }) => {
const isPull = 'base' in item || !!(item as GiteaIssue).pull_request;
const merged = (item as GiteaPullRequest).merged || (item as GiteaIssue).pull_request?.merged;
const open = item.state === 'open';
const [Icon, text, classes] = merged
? [GitMerge, 'Merged', 'bg-purple-500/15 text-purple-500']
: open
? isPull
? [GitPullRequest, 'Open', 'bg-emerald-500/15 text-emerald-500']
: [CircleDot, 'Open', 'bg-emerald-500/15 text-emerald-500']
: isPull
? [GitPullRequestClosed, 'Closed', 'bg-red-500/15 text-red-500']
: [CircleCheck, 'Closed', 'bg-purple-500/15 text-purple-500'];
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${classes}`}>
<Icon className="h-3 w-3" />
{text}
</span>
);
};
/** The open/closed marker in a dense list, where the full pill is too much furniture. */
export const StateIcon = ({ item }: { item: GiteaIssue | GiteaPullRequest }) => {
const isPull = 'base' in item || !!(item as GiteaIssue).pull_request;
const merged = (item as GiteaPullRequest).merged || (item as GiteaIssue).pull_request?.merged;
if (merged) return <GitMerge className="h-4 w-4 shrink-0 text-purple-500" />;
if (item.state === 'open') {
const Icon = isPull ? GitPullRequest : CircleDot;
return <Icon className="h-4 w-4 shrink-0 text-emerald-500" />;
}
const Icon = isPull ? GitPullRequestClosed : CircleCheck;
return <Icon className={`h-4 w-4 shrink-0 ${isPull ? 'text-red-500' : 'text-purple-500'}`} />;
};
export const Loading = ({ label = 'Loading…' }: { label?: string }) => (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {label}
</div>
);
export const ErrorState = ({ title, error }: { title: string; error: unknown }) => (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<TriangleAlert className="h-5 w-5 text-amber-500" />
<p className="text-sm font-medium">{title}</p>
<p className="max-w-sm text-xs text-muted-foreground">{serviceErrorMessage(error)}</p>
</div>
);
export const EmptyState = ({ title, hint }: { title: string; hint?: ReactNode }) => (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<p className="text-sm font-medium">{title}</p>
{hint && <p className="max-w-sm text-xs text-muted-foreground">{hint}</p>}
</div>
);
/** A segmented open/closed filter, the control Gitea puts above every issue and PR list. */
type StateFilterProps = { value: 'open' | 'closed' | 'all'; onChange: (value: 'open' | 'closed' | 'all') => void };
export const StateFilter = ({ value, onChange }: StateFilterProps) => (
<div className="inline-flex rounded-lg border p-0.5 text-xs">
{(['open', 'closed', 'all'] as const).map((state) => (
<button
key={state}
type="button"
onClick={() => onChange(state)}
className={`rounded-md px-2.5 py-1 capitalize transition-colors ${
value === state ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:text-foreground'
}`}
>
{state}
</button>
))}
</div>
);
@@ -0,0 +1,265 @@
import type { ServiceConnection } from '../../hooks/useServiceConnection';
import { useEffect, useState } from 'react';
import { CheckCircle2, ExternalLink, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { serviceErrorMessage, useServiceConnectionActions, useServiceHealth } from '../../hooks/useServiceConnection';
import { isUnreachableFromBrowser } from './shared';
import { useGiteaConnection } from './useGiteaData';
// Connecting a Gitea instance to Officer, from the app.
//
// Both the setup wizard and the permanent settings page: GiteaView renders it in place of whatever section
// the nav asked for while nothing is connected, and the Connection section renders it for good. One
// component, so re-pointing at a different instance later goes through exactly the code path that stored
// the first one.
//
// The token is write-only — the GET it reads has no field that could carry one back, so the input is always
// blank and an empty input means "keep the stored token". That is what lets the URL be corrected without
// the token being re-pasted.
//
// TWO FORMS, not one. There is one instance and a token per person: the server owner names the instance,
// everyone else supplies only their own token against it. The sidecar enforces that — a `url` from a member
// is answered 403, not ignored — so this must not send one. The rule is upstream of the UI on purpose; what
// happens here is only that a field nobody is allowed to fill in is not drawn.
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
/** Gitea's own default HTTP port. Officer dials it from the server, not from this browser. */
const DEFAULT_URL = 'http://localhost:3000';
const URL_HINT =
"The instance's base URL, without /api. Officer reaches it from the server, not from this browser — so " +
'localhost here means the machine Officer runs on.';
const MEMBER_URL_HINT =
'The instance this server is connected to. Only the server owner can change it — your half of this is the token.';
const TOKEN_HINT =
'A personal access token from the instance: Settings → Applications → Generate Token. Officer sends it ' +
'as `Authorization: token …` on every call. Scopes read:user and read:repository are enough to browse; ' +
'grant write only if you want Officer to change things.';
type SaveInput = Record<string, unknown> & { token: string };
export const GiteaConnection = () => {
const { data, isLoading } = useGiteaConnection();
const { data: health } = useServiceHealth('gitea');
const { save, forget } = useServiceConnectionActions<SaveInput>('gitea');
const connection = data?.connection ?? null;
// Defaulted to OWNER deliberately, and it is not a loading concern — the `isLoading` guard below means
// the form never renders before `data` arrives. The only way past it without data is a FAILED query
// (`retry: false`), i.e. the sidecar is down — and that is exactly when the owner needs the URL field to
// repoint a broken instance. Defaulting to false would hide it at the one moment it is needed. A member
// seeing the field in that state costs nothing: the sidecar rejects a `url` from them regardless.
const isOwner = data?.isOwner ?? true;
const instanceUrl = data?.instanceUrl ?? null;
const [url, setUrl] = useState('');
const [token, setToken] = useState('');
const [error, setError] = useState('');
// Seed from the stored row once it arrives. Keyed on its id so a save (same id) doesn't stomp what the
// owner is still typing, while forgetting and re-adding does reset the form.
useEffect(() => {
setUrl(connection?.url ?? '');
setToken('');
}, [connection?.id]);
const submit = async () => {
setError('');
try {
// A member sends no `url` KEY at all — the sidecar rejects the field's presence, not just a value,
// so `url: undefined` would still have to survive JSON.stringify dropping it and is not worth relying on.
await save.mutateAsync(isOwner ? { url: url.trim(), token: token.trim() } : { token: token.trim() });
setToken('');
} catch (err) {
setError(serviceErrorMessage(err));
}
};
const remove = async () => {
setError('');
try {
await forget.mutateAsync();
} catch (err) {
setError(serviceErrorMessage(err));
}
};
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading
</div>
);
}
// Where to mint a token: the URL being typed while the owner is setting the instance up, the instance
// that is already there for everyone else.
const base = (isOwner ? url.trim() : (instanceUrl ?? '')).replace(/\/+$/, '');
const tokenUrl = base ? `${base}/user/settings/applications` : null;
// A member cannot connect before there is an instance to connect to, and saying so beats a 409.
const awaitingInstance = !isOwner && !data?.instanceConfigured;
// Officer dials the instance from the server, but avatars and "open in Gitea" links are fetched by the
// BROWSER, straight from whatever origin the instance mints them on. A loopback or LAN address works for
// the first and not the second, and the symptom — broken avatars, dead links — looks nothing like its
// cause, so it is worth saying out loud rather than leaving to be discovered.
const dialOnly = (() => {
const stored = connection?.url ?? instanceUrl;
if (!stored) return false;
try {
return isUnreachableFromBrowser(new URL(stored).hostname);
} catch {
return false;
}
})();
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-xl flex-col gap-6 p-6">
<header className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-emerald-500/15 text-emerald-500">
<Plug className="h-5 w-5" />
</div>
<div>
<h2 className="text-sm font-semibold">{isOwner ? 'Gitea instance' : 'Your Gitea account'}</h2>
<p className={HINT}>
{!isOwner
? awaitingInstance
? 'No Gitea instance has been connected on this server yet. Once the owner adds one, sign in to it here with your own access token.'
: 'Sign in to this servers Gitea with your own access token. What you see is your account — your repositories, your notifications.'
: connection
? 'Where Officer talks to Gitea. Saving re-checks the instance before storing anything.'
: 'Point Officer at your Gitea instance. It needs the URL and one access token.'}
</p>
</div>
</header>
{connection && <StatusRow connection={connection} instanceUrl={instanceUrl} health={health} />}
{connection && dialOnly && (
<div className="flex items-start gap-2 rounded-xl border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-amber-500" />
<div className="min-w-0">
<div className="font-medium">Avatars and links will not load</div>
<p className="mt-0.5 text-muted-foreground">
This address only works from the server. Officer can talk to Gitea, but your browser loads avatars and
open in Gitea links directly from the instance, so they need an address this browser can reach too.{' '}
{isOwner
? 'Use the instances public URL here instead.'
: 'Ask the server owner to use the instances public URL.'}
</p>
</div>
</div>
)}
<div className="flex flex-col gap-4 rounded-xl border p-4">
{isOwner ? (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">Instance URL</span>
<Input
value={url}
onChange={(ev) => setUrl(ev.target.value)}
placeholder={DEFAULT_URL}
autoFocus={!connection}
autoComplete="off"
spellCheck={false}
/>
<span className={HINT}>{URL_HINT}</span>
</label>
) : (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium">Instance</span>
<div className="truncate rounded-md border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{instanceUrl ?? 'Not connected yet'}
</div>
<span className={HINT}>{MEMBER_URL_HINT}</span>
</div>
)}
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">Access token</span>
<Input
value={token}
onChange={(ev) => setToken(ev.target.value)}
placeholder={connection?.hasSecret ? '•••••••• (unchanged)' : 'Gitea personal access token'}
type="password"
autoComplete="off"
spellCheck={false}
/>
<span className={HINT}>
{TOKEN_HINT}{' '}
{tokenUrl && (
<a
href={tokenUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-primary hover:underline"
>
Open token settings
<ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</span>
</label>
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-2.5 text-xs text-destructive">
<TriangleAlert className="mt-px h-3.5 w-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={submit}
disabled={
(isOwner ? !url.trim() : awaitingInstance) ||
(!token.trim() && !connection?.hasSecret) ||
save.isPending
}
>
{save.isPending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
{connection ? 'Save' : 'Connect'}
</Button>
{connection && (
<Button size="sm" variant="ghost" onClick={remove} disabled={forget.isPending}>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Disconnect
</Button>
)}
</div>
</div>
</div>
</div>
);
};
type StatusRowProps = {
connection: ServiceConnection;
/** The resolved instance, which is where a member's row gets its base from — theirs stores none. */
instanceUrl: string | null;
health: { ok: boolean; error?: string } | undefined;
};
const StatusRow = ({ connection, instanceUrl, health }: StatusRowProps) => (
<div className="flex items-start gap-2 rounded-xl border p-3 text-xs">
{health?.ok ? (
<CheckCircle2 className="mt-px h-4 w-4 shrink-0 text-emerald-500" />
) : (
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-amber-500" />
)}
<div className="min-w-0">
<div className="font-medium">{health?.ok ? 'Connected' : 'Not responding'}</div>
<div className="truncate text-muted-foreground">
{connection.url ?? instanceUrl ?? '—'}
{connection.version ? ` · Gitea ${connection.version}` : ''}
</div>
{!health?.ok && health?.error && <div className="text-destructive">{health.error}</div>}
</div>
</div>
);
@@ -0,0 +1,112 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeSanitize from 'rehype-sanitize';
import { CodeRenderer } from '../FileViewer/renderers';
// Markdown for anything the instance hands back — a README, an issue body, a release note.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// WHY THIS IS NOT `/api/v1/markdown`
//
// Gitea's own web UI renders markdown server-side and it does it better than this can: it resolves
// #123 issue references, @mentions and relative links against the repository, and it sanitises the
// result with bluemonday before returning it. Replicating the upstream's own rendering is the house
// rule, and the sidecar's allow-list carries `/markdown` and `/markup` so that door stays open.
//
// It is not used HERE because consuming it means putting instance-returned HTML through
// `dangerouslySetInnerHTML`, and this app has no second line of defence to put behind that: there is
// no DOMPurify, sanitize-html or xss in the tree, and installs are frozen, so adding one is a
// deliberate lockfile change and not something to slip into a feature. That would leave the owner's
// authenticated session trusting a remote server's sanitiser and nothing else — and Officer's whole
// perimeter is that one session.
//
// So: parse the markdown here instead, where `rehype-sanitize` runs on an AST that never contains raw
// HTML in the first place (note `rehype-raw` is deliberately ABSENT — with it, `<script>` would be
// parsed into the tree and we would be relying on the sanitiser to take it back out; without it, raw
// HTML is inert text and there is nothing to remove). What is lost is Gitea's cross-reference
// linking; relative links and images are resolved below instead.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
type GiteaMarkdownProps = {
content: string;
/** `html_url` of the repository the text belongs to — the base for resolving relative links/images. */
repoUrl?: string;
/** Branch, tag or sha the text was read at. Defaults to the instance's own default-branch redirect. */
refName?: string;
/**
* Directory the text was read from, repo-relative and without a trailing slash. A relative link in a
* README resolves against the folder that README is in, not the repository root — so `docs/README.md`
* linking to `install.md` means `docs/install.md`.
*/
basePath?: string;
className?: string;
};
const ABSOLUTE = /^[a-z][a-z0-9+.-]*:/i;
/**
* Resolve a relative link the way Gitea would: images come from the raw endpoint (the bytes), everything
* else from the source browser (a page). An anchor (`#heading`) and anything already absolute are left
* alone.
*/
function resolveUrl(
url: string,
repoUrl: string | undefined,
refName: string,
kind: 'raw' | 'src',
base: string,
): string {
if (!url || url.startsWith('#') || url.startsWith('//') || ABSOLUTE.test(url)) return url;
if (!repoUrl) return url;
// A leading slash means repo-root-relative, which is how Gitea reads it too — so it escapes the base.
const rooted = url.startsWith('/');
const clean = url.replace(/^\.?\//, '');
const prefix = rooted || !base ? '' : `${base.replace(/\/+$/, '')}/`;
return `${repoUrl.replace(/\/+$/, '')}/${kind}/branch/${encodeURIComponent(refName)}/${prefix}${clean}`;
}
const getNodeText = (node: unknown): string => {
const n = node as { value?: string; children?: unknown[] } | undefined;
if (!n) return '';
if (typeof n.value === 'string') return n.value;
return (n.children ?? []).map(getNodeText).join('');
};
export const GiteaMarkdown = ({ content, repoUrl, refName = 'main', basePath = '', className }: GiteaMarkdownProps) => (
<div className={`file-viewer-md ${className ?? ''}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}
// react-markdown's own transform already drops dangerous protocols; this one additionally rebases
// the repo-relative paths that make up most of a README's links.
urlTransform={(url) => resolveUrl(url, repoUrl, refName, 'src', basePath)}
components={{
// Every link in remote content leaves the app, so it opens in a new tab and drops the referrer.
a: ({ children, href }) => (
<a href={href} target="_blank" rel="noreferrer noopener">
{children}
</a>
),
img: ({ src, alt }) => (
<img
src={typeof src === 'string' ? resolveUrl(src, repoUrl, refName, 'raw', basePath) : src}
alt={alt ?? ''}
loading="lazy"
/>
),
// Fenced blocks reuse the FileViewer's shiki renderer rather than a second highlighter.
pre: ({ node, children }) => {
const codeChild = node?.children[0];
if (codeChild?.type === 'element' && codeChild.tagName === 'code') {
const classes = (codeChild.properties?.className ?? []) as string[];
const lang = classes.find((c) => c.startsWith('language-'))?.replace('language-', '') ?? '';
return <CodeRenderer content={getNodeText(codeChild).replace(/\n$/, '')} lang={lang} />;
}
return <pre>{children}</pre>;
},
}}
>
{content}
</ReactMarkdown>
</div>
);
@@ -0,0 +1,88 @@
import type { LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router';
import { Bell, Building2, CircleDot, Compass, GitBranch, GitPullRequest, Plug } from 'lucide-react';
import { useServiceConnection } from '../../hooks/useServiceConnection';
import { GITEA_SECTIONS, giteaSectionPath, type GiteaSectionId } from './shared';
import { useGiteaNotifications, useGiteaRepos, useGiteaViewer } from './useGiteaData';
// Left panel of /gitea: who the token belongs to, then the sections.
//
// Sections are real links (cmd-click, back button, reload) with active state from react-router's NavLink —
// per docs/navigation-audit.md, the URL is the selection, not a channel.
const ICONS: Record<GiteaSectionId, LucideIcon> = {
repositories: GitBranch,
issues: CircleDot,
pulls: GitPullRequest,
notifications: Bell,
explore: Compass,
organizations: Building2,
connection: Plug,
};
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
export const GiteaNav = () => {
const { data: connectionState } = useServiceConnection('gitea');
const configured = !!connectionState?.configured;
// Both are gated on there being a connection at all: unconfigured, the sidecar 503s and every row would
// be a failed request behind a form the owner has not filled in yet.
const { data: viewer } = useGiteaViewer({ enabled: configured });
const { data: repos } = useGiteaRepos({ enabled: configured });
const { data: notifications } = useGiteaNotifications({ enabled: configured });
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-500/15 text-emerald-500 ring-1 ring-black/5">
<GitBranch className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight">Gitea</div>
<div className="truncate text-xs text-muted-foreground">
{viewer ? `@${viewer.login}` : configured ? 'connecting…' : 'not connected'}
</div>
</div>
</div>
<nav className="flex flex-col gap-0.5 px-2 pb-3">
{GITEA_SECTIONS.map(({ id, label }) => {
const Icon = ICONS[id];
return (
<NavLink
key={id}
to={giteaSectionPath(id)}
className={({ isActive }) =>
`${ROW} ${
isActive
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`
}
>
{({ isActive }) => (
<>
{isActive && (
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
)}
<Icon
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
<span className="flex-1">{label}</span>
{id === 'repositories' && !!repos?.length && (
<span className="text-xs tabular-nums text-muted-foreground">{repos.length}</span>
)}
{id === 'notifications' && !!notifications?.length && (
<span className="rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-emerald-500">
{notifications.length}
</span>
)}
</>
)}
</NavLink>
);
})}
</nav>
</div>
);
};
@@ -0,0 +1,40 @@
import { useServiceConnection } from '../../hooks/useServiceConnection';
import { CrossRepoIssuesView, ExploreView, NotificationsView, OrganizationsView } from './DashboardViews';
import { GiteaConnection } from './GiteaConnection';
import { RepositoriesView } from './RepositoriesView';
import { RepoView } from './RepoView';
import { useGiteaLocation, useGiteaSection } from './useGiteaLocation';
// Right panel of the /gitea workspace — renders whatever the URL names.
//
// A repository route (/gitea/repo/:owner/:name/…) has no section segment at all, so it is checked first:
// `owner` being set is the signal that the URL is pointing inside a repository rather than at a section.
//
// With no instance configured every other view can only render an error, so the connection form takes over
// until there is one. The URL is left alone: once connected, whatever is already in it is what appears.
export const GiteaView = () => {
const section = useGiteaSection();
const { owner, repo } = useGiteaLocation();
const { data, isLoading } = useServiceConnection('gitea');
if (!isLoading && !data?.configured) return <GiteaConnection />;
if (owner && repo) return <RepoView />;
switch (section) {
case 'issues':
return <CrossRepoIssuesView type="issues" />;
case 'pulls':
return <CrossRepoIssuesView type="pulls" />;
case 'notifications':
return <NotificationsView />;
case 'explore':
return <ExploreView />;
case 'organizations':
return <OrganizationsView />;
case 'connection':
return <GiteaConnection />;
default:
return <RepositoriesView />;
}
};
@@ -0,0 +1,26 @@
import { GitBranch } from 'lucide-react';
import { useServiceConnection, useServiceHealth } from '../../hooks/useServiceConnection';
import { GITEA_SECTIONS } from './shared';
import { useGiteaLocation, useGiteaSection } from './useGiteaLocation';
// Panel header for the right (gitea-view) panel: which section, and which instance version it is talking to.
export const GiteaViewHeader = () => {
const section = useGiteaSection();
const { owner, repo } = useGiteaLocation();
const { data } = useServiceConnection('gitea');
const { data: health } = useServiceHealth('gitea');
// Inside a repository there is no section segment to name, so the repository itself is the label.
const label = owner && repo ? `${owner}/${repo}` : (GITEA_SECTIONS.find((s) => s.id === section)?.label ?? 'Gitea');
const version = health?.version ?? data?.connection?.version ?? null;
return (
<>
<GitBranch className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-xs font-medium">
{label}
{version && <span className="ml-1.5 font-normal text-black/50">· {version}</span>}
</span>
</>
);
};
@@ -0,0 +1,332 @@
import type { GiteaContentsEntry, GiteaRepo } from './shared';
import { useMemo, useState } from 'react';
import { Link } from 'react-router';
import { ChevronDown, CornerLeftUp, File, FileCode, Folder, GitBranch, Link2, Tag } from 'lucide-react';
import { CodeRenderer } from '../FileViewer/renderers';
import { getLang } from '../FileViewer/file-types';
import { EmptyState, ErrorState, Loading } from './GiteaBits';
import { GiteaMarkdown } from './GiteaMarkdown';
import { decodeBase64, formatBytes, giteaRepoPath, timeAgo } from './shared';
import { useGiteaBranches, useGiteaContents, useGiteaTags } from './useGiteaData';
// The Code tab: a directory listing or a single file, whichever the ?path= points at, plus the rendered
// README underneath a directory — which is the layout Gitea's own repository page uses.
//
// There is no recursive tree sidebar on purpose. Gitea's web UI browses one directory at a time off the
// contents API, and doing the same means one request per navigation instead of walking the whole tree of a
// repository that might have thousands of files in it.
type RepoCodeViewProps = { repo: GiteaRepo; owner: string; name: string; path: string; refName: string };
const README = /^readme(\.(md|markdown|rst|txt))?$/i;
export const RepoCodeView = ({ repo, owner, name, path, refName }: RepoCodeViewProps) => {
const { data, isLoading, error } = useGiteaContents({ owner, repo: name }, { path, ref: refName });
if (repo.empty) {
return <EmptyState title="This repository is empty" hint="Nothing has been pushed to it yet." />;
}
if (isLoading) return <Loading label="Loading files…" />;
if (error) return <ErrorState title="Could not read the repository" error={error} />;
if (!data) return <EmptyState title="Nothing here" />;
return (
<div className="flex h-full flex-col">
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-2">
<RefPicker owner={owner} name={name} repo={repo} path={path} refName={refName} />
<Breadcrumbs owner={owner} name={name} path={path} refName={refName} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{Array.isArray(data) ? (
<DirectoryView entries={data} owner={owner} name={name} path={path} refName={refName} repo={repo} />
) : (
<FileView entry={data} repo={repo} refName={refName} />
)}
</div>
</div>
);
};
// ── Ref switcher ──────────────────────────────────────────────────────────────────────────────────
type RefPickerProps = { owner: string; name: string; repo: GiteaRepo; path: string; refName: string };
/**
* Branch and tag switcher. Both lists are fetched lazily — only once the menu is opened — because most
* visits to a repository never touch it, and a repo with a few hundred tags would otherwise pay for them
* on every page load.
*/
const RefPicker = ({ owner, name, repo, path, refName }: RefPickerProps) => {
const [open, setOpen] = useState(false);
const { data: branches } = useGiteaBranches({ owner, repo: name }, { enabled: open });
const { data: tags } = useGiteaTags({ owner, repo: name }, { enabled: open });
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
className="flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
>
<GitBranch className="h-3 w-3" />
<span className="max-w-[12rem] truncate">{refName}</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
{open && (
<div className="absolute left-0 top-full z-20 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border bg-popover p-1 shadow-lg">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Branches
</div>
{(branches ?? []).map((branch) => (
<Link
key={branch.name}
to={giteaRepoPath(owner, name, { tab: 'code', path, ref: branch.name })}
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-muted ${
branch.name === refName ? 'font-medium text-primary' : ''
}`}
>
<GitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="truncate">{branch.name}</span>
{branch.name === repo.default_branch && (
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">default</span>
)}
</Link>
))}
{!!tags?.length && (
<>
<div className="mt-1 border-t px-2 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Tags
</div>
{tags.map((tag) => (
<Link
key={tag.name}
to={giteaRepoPath(owner, name, { tab: 'code', path, ref: tag.name })}
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-muted ${
tag.name === refName ? 'font-medium text-primary' : ''
}`}
>
<Tag className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="truncate">{tag.name}</span>
</Link>
))}
</>
)}
</div>
)}
</div>
);
};
// ── Breadcrumbs ───────────────────────────────────────────────────────────────────────────────────
type BreadcrumbProps = { owner: string; name: string; path: string; refName: string };
const Breadcrumbs = ({ owner, name, path, refName }: BreadcrumbProps) => {
const segments = path ? path.split('/') : [];
return (
<div className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
<Link
to={giteaRepoPath(owner, name, { tab: 'code', ref: refName })}
className="font-medium text-primary hover:underline"
>
{name}
</Link>
{segments.map((segment, i) => {
const upto = segments.slice(0, i + 1).join('/');
const last = i === segments.length - 1;
return (
<span key={upto} className="flex min-w-0 items-center gap-1">
<span className="text-muted-foreground">/</span>
{last ? (
<span className="truncate font-medium">{segment}</span>
) : (
<Link
to={giteaRepoPath(owner, name, { tab: 'code', path: upto, ref: refName })}
className="truncate text-primary hover:underline"
>
{segment}
</Link>
)}
</span>
);
})}
</div>
);
};
// ── Directory ─────────────────────────────────────────────────────────────────────────────────────
type DirectoryProps = {
entries: GiteaContentsEntry[];
owner: string;
name: string;
path: string;
refName: string;
repo: GiteaRepo;
};
const DirectoryView = ({ entries, owner, name, path, refName, repo }: DirectoryProps) => {
// Directories first, then files, each alphabetical — the ordering every file browser uses and the one
// Gitea's own listing applies. The API returns them in git's order, which is neither.
const sorted = useMemo(
() =>
[...entries].sort((a, b) => {
const aDir = a.type === 'dir';
const bDir = b.type === 'dir';
if (aDir !== bDir) return aDir ? -1 : 1;
return a.name.localeCompare(b.name);
}),
[entries],
);
const readme = sorted.find((entry) => entry.type === 'file' && README.test(entry.name));
const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
return (
<div className="flex flex-col">
<div className="divide-y">
{path && (
<Link
to={giteaRepoPath(owner, name, { tab: 'code', path: parent, ref: refName })}
className="flex items-center gap-2 px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/50"
>
<CornerLeftUp className="h-4 w-4" />
<span>..</span>
</Link>
)}
{sorted.map((entry) => (
<EntryRow key={entry.path} entry={entry} owner={owner} name={name} refName={refName} />
))}
</div>
{readme && <ReadmePanel entry={readme} repo={repo} owner={owner} name={name} refName={refName} />}
</div>
);
};
type EntryRowProps = { entry: GiteaContentsEntry; owner: string; name: string; refName: string };
const EntryRow = ({ entry, owner, name, refName }: EntryRowProps) => {
const isDir = entry.type === 'dir';
const Icon = isDir ? Folder : entry.type === 'symlink' ? Link2 : File;
return (
<Link
to={giteaRepoPath(owner, name, { tab: 'code', path: entry.path, ref: refName })}
className="flex items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-muted/50"
>
<Icon className={`h-4 w-4 shrink-0 ${isDir ? 'text-sky-500' : 'text-muted-foreground'}`} />
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
{entry.last_commit_message && (
<span className="hidden min-w-0 flex-[2] truncate text-xs text-muted-foreground md:block">
{entry.last_commit_message.split('\n')[0]}
</span>
)}
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{entry.last_committer_date ? timeAgo(entry.last_committer_date) : !isDir ? formatBytes(entry.size) : ''}
</span>
</Link>
);
};
// ── README ────────────────────────────────────────────────────────────────────────────────────────
type ReadmeProps = { entry: GiteaContentsEntry; repo: GiteaRepo; owner: string; name: string; refName: string };
/**
* The listing's own README entry carries no content — the contents API only fills `content` in when the
* path names a single file — so it is fetched again by path. That second request is what Gitea's own page
* does too.
*/
const ReadmePanel = ({ entry, repo, owner, name, refName }: ReadmeProps) => {
const { data } = useGiteaContents({ owner, repo: name }, { path: entry.path, ref: refName });
const file = Array.isArray(data) ? null : data;
if (!file?.content) return null;
const isMarkdown = /\.(md|markdown)$/i.test(entry.name);
const text = decodeBase64(file.content);
return (
<div className="m-4 overflow-hidden rounded-lg border">
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2 text-xs font-medium">
<FileCode className="h-3.5 w-3.5 text-muted-foreground" />
{entry.name}
</div>
<div className="px-4 py-3">
{isMarkdown ? (
<GiteaMarkdown
content={text}
repoUrl={repo.html_url}
refName={refName}
basePath={entry.path.includes('/') ? entry.path.slice(0, entry.path.lastIndexOf('/')) : ''}
/>
) : (
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed">{text}</pre>
)}
</div>
</div>
);
};
// ── Single file ───────────────────────────────────────────────────────────────────────────────────
type FileViewProps = { entry: GiteaContentsEntry; repo: GiteaRepo; refName: string };
const FileView = ({ entry, repo, refName }: FileViewProps) => {
if (entry.type === 'submodule') {
return <EmptyState title={`${entry.name} is a submodule`} hint={entry.submodule_git_url} />;
}
if (entry.type === 'symlink') {
return <EmptyState title={`${entry.name} is a symlink`} hint={`${entry.target ?? 'unknown target'}`} />;
}
// Gitea omits `content` above a size limit rather than streaming a megabyte of base64 into a JSON body.
if (!entry.content) {
return (
<EmptyState
title="Too large to display"
hint={
entry.download_url ? (
<a href={entry.download_url} target="_blank" rel="noreferrer" className="text-primary hover:underline">
Download {entry.name} ({formatBytes(entry.size)})
</a>
) : (
`${entry.name} is ${formatBytes(entry.size)}`
)
}
/>
);
}
const text = decodeBase64(entry.content);
const isMarkdown = /\.(md|markdown)$/i.test(entry.name);
return (
<div>
<div className="flex items-center justify-between gap-2 border-b bg-muted/40 px-4 py-2 text-xs">
<span className="truncate font-medium">{entry.name}</span>
<span className="flex shrink-0 items-center gap-3 text-muted-foreground">
<span className="tabular-nums">{formatBytes(entry.size)}</span>
{entry.download_url && (
<a href={entry.download_url} target="_blank" rel="noreferrer" className="hover:text-foreground">
Raw
</a>
)}
</span>
</div>
{isMarkdown ? (
<div className="px-4 py-3">
<GiteaMarkdown
content={text}
repoUrl={repo.html_url}
refName={refName}
basePath={entry.path.includes('/') ? entry.path.slice(0, entry.path.lastIndexOf('/')) : ''}
/>
</div>
) : (
<CodeRenderer content={text} lang={getLang(entry.name)} />
)}
</div>
);
};
@@ -0,0 +1,222 @@
import type { GiteaRepo } from './shared';
import { useState } from 'react';
import { Link } from 'react-router';
import { ChevronLeft, ChevronRight, Download, ExternalLink, GitBranch, Package, Shield, Tag } from 'lucide-react';
import { Avatar, EmptyState, ErrorState, Loading } from './GiteaBits';
import { GiteaMarkdown } from './GiteaMarkdown';
import { commitTitle, formatBytes, giteaRepoPath, shortSha, timeAgo } from './shared';
import { useGiteaBranches, useGiteaCommits, useGiteaReleases, useGiteaTags } from './useGiteaData';
// The three history tabs — commits, branches (with tags) and releases. Grouped in one file because each is
// a single list with no detail screen behind it, and splitting them would be three files of forty lines.
type TabProps = { repo: GiteaRepo; owner: string; name: string; refName: string };
// ── Commits ───────────────────────────────────────────────────────────────────────────────────────
export const RepoCommitsView = ({ owner, name, refName }: TabProps) => {
// Paging is component state, not a URL param: the ref and the repo are what make a commit list
// addressable, and "which page of the scrollback" is not something worth a history entry.
const [page, setPage] = useState(1);
const { data: commits, isLoading, error } = useGiteaCommits({ owner, repo: name }, { ref: refName, page });
if (isLoading) return <Loading label="Loading commits…" />;
if (error) return <ErrorState title="Could not load commits" error={error} />;
if (!commits?.length) return <EmptyState title="No commits" />;
return (
<div className="flex h-full flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="divide-y">
{commits.map((commit) => (
<div key={commit.sha} className="flex items-start gap-3 px-4 py-2.5">
<span className="pt-0.5">
<Avatar user={commit.author} size={24} />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{commitTitle(commit.commit.message)}</p>
<p className="text-xs text-muted-foreground">
{commit.author?.login ?? commit.commit.author?.name ?? 'unknown'} committed{' '}
{timeAgo(commit.commit.author?.date ?? commit.created)}
</p>
</div>
<a
href={commit.html_url}
target="_blank"
rel="noreferrer"
className="shrink-0 rounded border px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Open commit in Gitea"
>
{shortSha(commit.sha)}
</a>
</div>
))}
</div>
</div>
{/* Gitea's commit list gives no total, so paging is "was this page full?" rather than a page count. */}
<div className="flex items-center justify-between border-t px-4 py-2 text-xs">
<button
type="button"
disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronLeft className="h-3 w-3" /> Newer
</button>
<span className="tabular-nums text-muted-foreground">Page {page}</span>
<button
type="button"
disabled={commits.length < 30}
onClick={() => setPage((p) => p + 1)}
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40"
>
Older <ChevronRight className="h-3 w-3" />
</button>
</div>
</div>
);
};
// ── Branches and tags ─────────────────────────────────────────────────────────────────────────────
export const RepoBranchesView = ({ repo, owner, name }: TabProps) => {
const { data: branches, isLoading, error } = useGiteaBranches({ owner, repo: name });
const { data: tags } = useGiteaTags({ owner, repo: name });
if (isLoading) return <Loading label="Loading branches…" />;
if (error) return <ErrorState title="Could not load branches" error={error} />;
return (
<div className="h-full overflow-y-auto">
<SectionTitle icon={GitBranch} label={`Branches${branches?.length ? ` (${branches.length})` : ''}`} />
<div className="divide-y">
{(branches ?? []).map((branch) => (
<Link
key={branch.name}
to={giteaRepoPath(owner, name, { tab: 'code', ref: branch.name })}
className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/50"
>
<GitBranch className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{branch.name}</span>
{branch.name === repo.default_branch && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
default
</span>
)}
{branch.protected && <Shield className="h-3 w-3 shrink-0 text-amber-500" />}
</div>
{branch.commit?.message && (
<p className="truncate text-xs text-muted-foreground">{commitTitle(branch.commit.message)}</p>
)}
</div>
<span className="shrink-0 text-xs text-muted-foreground">{timeAgo(branch.commit?.timestamp)}</span>
</Link>
))}
</div>
{!!tags?.length && (
<>
<SectionTitle icon={Tag} label={`Tags (${tags.length})`} />
<div className="divide-y">
{tags.map((tag) => (
<Link
key={tag.name}
to={giteaRepoPath(owner, name, { tab: 'code', ref: tag.name })}
className="flex items-center gap-3 px-4 py-2 transition-colors hover:bg-muted/50"
>
<Tag className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm">{tag.name}</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
{shortSha(tag.commit?.sha)}
</span>
</Link>
))}
</div>
</>
)}
</div>
);
};
// ── Releases ──────────────────────────────────────────────────────────────────────────────────────
export const RepoReleasesView = ({ repo, owner, name }: TabProps) => {
const { data: releases, isLoading, error } = useGiteaReleases({ owner, repo: name });
if (isLoading) return <Loading label="Loading releases…" />;
if (error) return <ErrorState title="Could not load releases" error={error} />;
if (!releases?.length) {
return <EmptyState title="No releases" hint="Tags become releases once one is published against them." />;
}
return (
<div className="h-full overflow-y-auto">
<div className="flex flex-col gap-3 p-4">
{releases.map((release) => (
<div key={release.id} className="overflow-hidden rounded-lg border">
<div className="flex flex-wrap items-center gap-2 border-b bg-muted/40 px-3 py-2">
<Package className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-sm font-medium">{release.name || release.tag_name}</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">{release.tag_name}</code>
{release.prerelease && (
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-500">
Pre-release
</span>
)}
{release.draft && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
Draft
</span>
)}
<span className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
{timeAgo(release.published_at ?? release.created_at)}
<a href={release.html_url} target="_blank" rel="noreferrer" className="hover:text-foreground">
<ExternalLink className="h-3.5 w-3.5" />
</a>
</span>
</div>
{release.body?.trim() && (
<div className="px-3 py-2">
<GiteaMarkdown
content={release.body}
repoUrl={repo.html_url}
refName={release.tag_name}
className="text-sm"
/>
</div>
)}
{!!release.assets?.length && (
<div className="divide-y border-t">
{release.assets.map((asset) => (
<a
key={asset.id}
href={asset.browser_download_url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 px-3 py-1.5 text-xs transition-colors hover:bg-muted/50"
>
<Download className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{asset.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">{formatBytes(asset.size)}</span>
</a>
))}
</div>
)}
</div>
))}
</div>
</div>
);
};
const SectionTitle = ({ icon: Icon, label }: { icon: typeof GitBranch; label: string }) => (
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
{label}
</div>
);
@@ -0,0 +1,215 @@
import type { GiteaIssue, GiteaRepo } from './shared';
import { useState } from 'react';
import { Link } from 'react-router';
import { ArrowLeft, ExternalLink, MessageSquare } from 'lucide-react';
import { Avatar, EmptyState, ErrorState, LabelChip, Loading, StateBadge, StateFilter, StateIcon } from './GiteaBits';
import { GiteaMarkdown } from './GiteaMarkdown';
import { giteaRepoPath, timeAgo } from './shared';
import { useGiteaIssue, useGiteaIssueComments, useGiteaRepoIssues } from './useGiteaData';
// Issues for one repository — the list, and one issue with its comment timeline.
//
// `type` is a prop rather than two copies of this file because in Gitea a pull request IS an issue: same
// number space, same list endpoint, same comment timeline. Only the extra PR data (branches, changed files)
// needs its own screen, which is RepoPullsView.
type RepoIssuesViewProps = {
repo: GiteaRepo;
owner: string;
name: string;
/** Issue number from the URL, or null for the list. */
item: string | null;
type?: 'issues' | 'pulls';
};
export const RepoIssuesView = ({ repo, owner, name, item, type = 'issues' }: RepoIssuesViewProps) => {
const index = item ? Number(item) : null;
if (index != null && Number.isFinite(index)) {
return <IssueDetail repo={repo} owner={owner} name={name} index={index} type={type} />;
}
return <IssueList repo={repo} owner={owner} name={name} type={type} />;
};
// ── List ──────────────────────────────────────────────────────────────────────────────────────────
type ListProps = { repo: GiteaRepo; owner: string; name: string; type: 'issues' | 'pulls' };
const IssueList = ({ owner, name, type }: ListProps) => {
// The open/closed filter is component state rather than a URL param: it is a view preference over a list
// that is itself the addressable thing, and putting it in the URL would make every filter click a history
// entry to press back through.
const [state, setState] = useState<'open' | 'closed' | 'all'>('open');
const { data: issues, isLoading, error } = useGiteaRepoIssues({ owner, repo: name }, { state, type });
const noun = type === 'pulls' ? 'pull requests' : 'issues';
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-3 border-b px-4 py-2">
<StateFilter value={state} onChange={setState} />
{!!issues?.length && (
<span className="text-xs tabular-nums text-muted-foreground">
{issues.length} {noun}
</span>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{isLoading ? (
<Loading label={`Loading ${noun}`} />
) : error ? (
<ErrorState title={`Could not load ${noun}`} error={error} />
) : !issues?.length ? (
<EmptyState title={`No ${state === 'all' ? '' : state} ${noun}`.replace(/\s+/g, ' ').trim()} />
) : (
<div className="divide-y">
{issues.map((issue) => (
<IssueRow key={issue.id} issue={issue} owner={owner} name={name} type={type} />
))}
</div>
)}
</div>
</div>
);
};
type RowProps = { issue: GiteaIssue; owner: string; name: string; type: 'issues' | 'pulls' };
const IssueRow = ({ issue, owner, name, type }: RowProps) => (
<Link
to={giteaRepoPath(owner, name, { tab: type === 'pulls' ? 'pulls' : 'issues', item: issue.number })}
className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/50"
>
<span className="pt-0.5">
<StateIcon item={issue} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-medium">{issue.title}</span>
{issue.labels?.map((label) => (
<LabelChip key={label.id} label={label} />
))}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
#{issue.number} opened {timeAgo(issue.created_at)}
{issue.user && ` by ${issue.user.login}`}
</p>
</div>
{!!issue.comments && (
<span className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-muted-foreground">
<MessageSquare className="h-3 w-3" />
{issue.comments}
</span>
)}
</Link>
);
// ── Detail ────────────────────────────────────────────────────────────────────────────────────────
type DetailProps = { repo: GiteaRepo; owner: string; name: string; index: number; type: 'issues' | 'pulls' };
const IssueDetail = ({ repo, owner, name, index, type }: DetailProps) => {
const { data: issue, isLoading, error } = useGiteaIssue({ owner, repo: name }, index);
const { data: comments } = useGiteaIssueComments({ owner, repo: name }, index);
if (isLoading) return <Loading label={`Loading #${index}`} />;
if (error) return <ErrorState title={`Could not load #${index}`} error={error} />;
if (!issue) return <EmptyState title={`#${index} not found`} />;
return (
<div className="h-full overflow-y-auto">
<IssueHeader issue={issue} owner={owner} name={name} type={type} />
<div className="flex flex-col gap-3 px-4 pb-6">
<CommentCard
author={issue.user}
createdAt={issue.created_at}
body={issue.body}
repo={repo}
emptyText="No description provided."
/>
{comments?.map((comment) => (
<CommentCard
key={comment.id}
author={comment.user}
createdAt={comment.created_at}
body={comment.body}
repo={repo}
/>
))}
</div>
</div>
);
};
type HeaderProps = { issue: GiteaIssue; owner: string; name: string; type: 'issues' | 'pulls' };
const IssueHeader = ({ issue, owner, name, type }: HeaderProps) => (
<div className="border-b px-4 py-3">
<Link
to={giteaRepoPath(owner, name, { tab: type === 'pulls' ? 'pulls' : 'issues' })}
className="mb-2 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3 w-3" />
Back to {type === 'pulls' ? 'pull requests' : 'issues'}
</Link>
<div className="flex items-start gap-2">
<h2 className="min-w-0 flex-1 text-base font-semibold leading-snug">
{issue.title} <span className="font-normal text-muted-foreground">#{issue.number}</span>
</h2>
<a
href={issue.html_url}
target="_blank"
rel="noreferrer"
className="shrink-0 text-muted-foreground hover:text-foreground"
title="Open in Gitea"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<StateBadge item={issue} />
<span>
{issue.user?.login ?? 'someone'} opened this {timeAgo(issue.created_at)} · {issue.comments} comments
</span>
{issue.labels?.map((label) => (
<LabelChip key={label.id} label={label} />
))}
</div>
{!!issue.assignees?.length && (
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Assigned to</span>
{issue.assignees.map((user) => (
<span key={user.id} className="flex items-center gap-1">
<Avatar user={user} size={16} />
{user.login}
</span>
))}
</div>
)}
</div>
);
type CommentProps = {
author?: GiteaIssue['user'];
createdAt: string;
body?: string;
repo: GiteaRepo;
emptyText?: string;
};
export const CommentCard = ({ author, createdAt, body, repo, emptyText }: CommentProps) => (
<div className="overflow-hidden rounded-lg border">
<div className="flex items-center gap-2 border-b bg-muted/40 px-3 py-2 text-xs">
<Avatar user={author} size={18} />
<span className="font-medium">{author?.login ?? 'unknown'}</span>
<span className="text-muted-foreground">commented {timeAgo(createdAt)}</span>
</div>
<div className="px-3 py-2">
{body?.trim() ? (
<GiteaMarkdown content={body} repoUrl={repo.html_url} refName={repo.default_branch ?? 'main'} />
) : (
<p className="text-xs italic text-muted-foreground">{emptyText ?? 'No content.'}</p>
)}
</div>
</div>
);
@@ -0,0 +1,161 @@
import type { GiteaChangedFile, GiteaRepo } from './shared';
import { Link } from 'react-router';
import { ArrowLeft, ArrowRight, ExternalLink, FileDiff } from 'lucide-react';
import { EmptyState, ErrorState, LabelChip, Loading, StateBadge } from './GiteaBits';
import { CommentCard, RepoIssuesView } from './RepoIssuesView';
import { giteaRepoPath, shortSha, timeAgo } from './shared';
import { useGiteaIssueComments, useGiteaPull, useGiteaPullFiles } from './useGiteaData';
// Pull requests for one repository.
//
// The LIST is `RepoIssuesView` with type='pulls' — in Gitea a PR is an issue, so the same endpoint and the
// same row layout serve both and there is nothing to duplicate. The DETAIL is its own screen, because a PR
// carries things an issue does not: the branch pair, the merge state and the changed-file summary.
type RepoPullsViewProps = { repo: GiteaRepo; owner: string; name: string; item: string | null };
export const RepoPullsView = ({ repo, owner, name, item }: RepoPullsViewProps) => {
const index = item ? Number(item) : null;
if (index == null || !Number.isFinite(index)) {
return <RepoIssuesView repo={repo} owner={owner} name={name} item={null} type="pulls" />;
}
return <PullDetail repo={repo} owner={owner} name={name} index={index} />;
};
type DetailProps = { repo: GiteaRepo; owner: string; name: string; index: number };
const PullDetail = ({ repo, owner, name, index }: DetailProps) => {
const { data: pull, isLoading, error } = useGiteaPull({ owner, repo: name }, index);
const { data: comments } = useGiteaIssueComments({ owner, repo: name }, index);
const { data: files } = useGiteaPullFiles({ owner, repo: name }, index);
if (isLoading) return <Loading label={`Loading !${index}`} />;
if (error) return <ErrorState title={`Could not load pull request #${index}`} error={error} />;
if (!pull) return <EmptyState title={`#${index} not found`} />;
return (
<div className="h-full overflow-y-auto">
<div className="border-b px-4 py-3">
<Link
to={giteaRepoPath(owner, name, { tab: 'pulls' })}
className="mb-2 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3 w-3" />
Back to pull requests
</Link>
<div className="flex items-start gap-2">
<h2 className="min-w-0 flex-1 text-base font-semibold leading-snug">
{pull.title} <span className="font-normal text-muted-foreground">#{pull.number}</span>
</h2>
<a
href={pull.html_url}
target="_blank"
rel="noreferrer"
className="shrink-0 text-muted-foreground hover:text-foreground"
title="Open in Gitea"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<StateBadge item={pull} />
{pull.draft && <span className="rounded-full bg-muted px-2 py-0.5 font-medium">Draft</span>}
<span>
{pull.user?.login ?? 'someone'} wants to merge {timeAgo(pull.created_at)}
</span>
{pull.labels?.map((label) => (
<LabelChip key={label.id} label={label} />
))}
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">{pull.head?.label ?? '?'}</code>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">{pull.base?.label ?? '?'}</code>
{pull.merge_base && <span className="text-muted-foreground">· base {shortSha(pull.merge_base)}</span>}
</div>
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs tabular-nums text-muted-foreground">
{pull.changed_files != null && (
<span className="flex items-center gap-1">
<FileDiff className="h-3 w-3" />
{pull.changed_files} files
</span>
)}
{pull.additions != null && <span className="text-emerald-500">+{pull.additions}</span>}
{pull.deletions != null && <span className="text-red-500">{pull.deletions}</span>}
{/* `mergeable` is only meaningful while the PR is still open — Gitea leaves it false once merged. */}
{pull.state === 'open' && !pull.merged && (
<span className={pull.mergeable ? 'text-emerald-500' : 'text-amber-500'}>
{pull.mergeable ? 'No conflicts' : 'Conflicts'}
</span>
)}
</div>
</div>
<div className="flex flex-col gap-3 px-4 pb-6 pt-3">
<CommentCard
author={pull.user}
createdAt={pull.created_at}
body={pull.body}
repo={repo}
emptyText="No description provided."
/>
{!!files?.length && <ChangedFiles files={files} owner={owner} name={name} headRef={pull.head?.ref} />}
{comments?.map((comment) => (
<CommentCard
key={comment.id}
author={comment.user}
createdAt={comment.created_at}
body={comment.body}
repo={repo}
/>
))}
</div>
</div>
);
};
// ── Changed files ─────────────────────────────────────────────────────────────────────────────────
const STATUS_COLOR: Record<string, string> = {
added: 'text-emerald-500',
modified: 'text-amber-500',
removed: 'text-red-500',
renamed: 'text-sky-500',
};
type ChangedFilesProps = { files: GiteaChangedFile[]; owner: string; name: string; headRef?: string };
/**
* The file summary, not the patch. Gitea's `/pulls/{index}/files` returns per-file +/- counts and links but
* no diff text — the patch is only available as a whole-PR `.diff` from a non-API URL — so each row links
* into the code browser at the head ref instead of pretending to show a hunk.
*/
const ChangedFiles = ({ files, owner, name, headRef }: ChangedFilesProps) => (
<div className="overflow-hidden rounded-lg border">
<div className="flex items-center gap-2 border-b bg-muted/40 px-3 py-2 text-xs font-medium">
<FileDiff className="h-3.5 w-3.5 text-muted-foreground" />
{files.length} changed {files.length === 1 ? 'file' : 'files'}
</div>
<div className="divide-y">
{files.map((file) => (
<Link
key={file.filename}
to={giteaRepoPath(owner, name, { tab: 'code', path: file.filename, ref: headRef })}
className="flex items-center gap-3 px-3 py-1.5 text-xs transition-colors hover:bg-muted/50"
>
<span className={`w-16 shrink-0 capitalize ${STATUS_COLOR[file.status] ?? 'text-muted-foreground'}`}>
{file.status}
</span>
<span className="min-w-0 flex-1 truncate font-mono">{file.filename}</span>
<span className="shrink-0 tabular-nums text-emerald-500">+{file.additions}</span>
<span className="shrink-0 tabular-nums text-red-500">{file.deletions}</span>
</Link>
))}
</div>
</div>
);
@@ -0,0 +1,136 @@
import { Link, NavLink } from 'react-router';
import { Archive, ArrowLeft, ExternalLink, GitFork, Lock, Star } from 'lucide-react';
import { EmptyState, ErrorState, Loading } from './GiteaBits';
import { RepoCodeView } from './RepoCodeView';
import { RepoIssuesView } from './RepoIssuesView';
import { RepoPullsView } from './RepoPullsView';
import { RepoBranchesView, RepoCommitsView, RepoReleasesView } from './RepoHistoryViews';
import { GITEA_REPO_TABS, giteaRepoPath, giteaSectionPath } from './shared';
import { useGiteaLocation } from './useGiteaLocation';
import { useGiteaRepo } from './useGiteaData';
// One repository: the identity header, the tab strip, and whichever tab the URL names.
//
// The ref is resolved HERE rather than in each tab, because ?ref= is legitimately absent on a first visit
// and every tab would otherwise need the same "fall back to the repo's default branch" dance. Tabs receive
// a ref that is always a real branch name.
export const RepoView = () => {
const { owner, repo: name, tab, item, path, refName } = useGiteaLocation();
const { data: repo, isLoading, error } = useGiteaRepo({ owner: owner ?? '', repo: name ?? '' });
if (!owner || !name) return <EmptyState title="No repository selected" />;
if (isLoading) return <Loading label={`Loading ${owner}/${name}`} />;
if (error) return <ErrorState title={`Could not open ${owner}/${name}`} error={error} />;
if (!repo) return <EmptyState title={`${owner}/${name} not found`} />;
const effectiveRef = refName ?? repo.default_branch ?? 'main';
const tabProps = { repo, owner, name, refName: effectiveRef };
return (
<div className="flex h-full flex-col">
<div className="border-b px-4 pt-3">
<Link
to={giteaSectionPath('repositories')}
className="mb-1.5 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3 w-3" />
All repositories
</Link>
<div className="flex items-start gap-2">
<h2 className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5 text-base font-semibold leading-snug">
<span className="truncate">{repo.full_name}</span>
{repo.private && <Lock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
{repo.fork && <GitFork className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
{repo.archived && <Archive className="h-3.5 w-3.5 shrink-0 text-amber-500" />}
</h2>
<a
href={repo.html_url}
target="_blank"
rel="noreferrer"
className="shrink-0 text-muted-foreground hover:text-foreground"
title="Open in Gitea"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
{repo.description && <p className="mt-0.5 text-xs text-muted-foreground">{repo.description}</p>}
<div className="mt-1 flex flex-wrap items-center gap-3 text-[11px] tabular-nums text-muted-foreground">
{repo.language && <span>{repo.language}</span>}
{!!repo.stars_count && (
<span className="flex items-center gap-0.5">
<Star className="h-3 w-3" />
{repo.stars_count}
</span>
)}
{!!repo.forks_count && (
<span className="flex items-center gap-0.5">
<GitFork className="h-3 w-3" />
{repo.forks_count}
</span>
)}
{repo.website && (
<a href={repo.website} target="_blank" rel="noreferrer" className="text-primary hover:underline">
{repo.website.replace(/^https?:\/\//, '')}
</a>
)}
</div>
<nav className="-mb-px mt-2 flex gap-1 overflow-x-auto">
{GITEA_REPO_TABS.filter((entry) => {
// Mirror what the instance has switched on. Gitea hides the Issues tab when a repo has issues
// disabled, and a tab that always 404s is worse than no tab.
if (entry.id === 'issues') return repo.has_issues !== false;
if (entry.id === 'pulls') return repo.has_pull_requests !== false;
if (entry.id === 'releases') return repo.has_releases !== false;
return true;
}).map((entry) => (
<NavLink
key={entry.id}
to={giteaRepoPath(owner, name, { tab: entry.id, ref: refName ?? undefined })}
end={false}
className={() =>
`whitespace-nowrap border-b-2 px-3 py-1.5 text-xs transition-colors ${
entry.id === tab
? 'border-primary font-medium text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`
}
>
{entry.label}
{entry.id === 'issues' && !!repo.open_issues_count && (
<span className="ml-1.5 rounded-full bg-muted px-1.5 py-0.5 text-[10px] tabular-nums">
{repo.open_issues_count}
</span>
)}
{entry.id === 'pulls' && !!repo.open_pr_counter && (
<span className="ml-1.5 rounded-full bg-muted px-1.5 py-0.5 text-[10px] tabular-nums">
{repo.open_pr_counter}
</span>
)}
</NavLink>
))}
</nav>
</div>
<div className="min-h-0 flex-1">
{tab === 'issues' ? (
<RepoIssuesView repo={repo} owner={owner} name={name} item={item} />
) : tab === 'pulls' ? (
<RepoPullsView repo={repo} owner={owner} name={name} item={item} />
) : tab === 'commits' ? (
<RepoCommitsView {...tabProps} />
) : tab === 'branches' ? (
<RepoBranchesView {...tabProps} />
) : tab === 'releases' ? (
<RepoReleasesView {...tabProps} />
) : (
<RepoCodeView repo={repo} owner={owner} name={name} path={path} refName={effectiveRef} />
)}
</div>
</div>
);
};
@@ -0,0 +1,75 @@
import type { GiteaRepo } from './shared';
import { Link } from 'react-router';
import { Archive, GitFork, Lock, Star } from 'lucide-react';
import { EmptyState, ErrorState, Loading } from './GiteaBits';
import { giteaRepoPath, timeAgo } from './shared';
import { useGiteaRepos } from './useGiteaData';
// The repositories the stored token can see. Rows are real links into /gitea/repo/:owner/:name — cmd-click,
// middle-click and the back button all work, which is the whole point of keeping the selection in the URL.
export const RepositoriesView = () => {
const { data: repos, isLoading, error } = useGiteaRepos();
if (isLoading) return <Loading label="Loading repositories…" />;
if (error) return <ErrorState title="Could not list repositories" error={error} />;
if (!repos?.length) {
return (
<EmptyState
title="No repositories"
hint="This token cannot see any repositories. If that is a surprise, check that it was granted the read:repository scope."
/>
);
}
return (
<div className="h-full overflow-y-auto">
<div className="flex flex-col divide-y">
{repos.map((repo) => (
<RepoRow key={repo.id} repo={repo} />
))}
</div>
</div>
);
};
/**
* `owner.login` rather than splitting `full_name` on the slash: a repository name cannot contain a slash but
* relying on that to parse an identifier is the kind of assumption that breaks quietly.
*/
export const RepoRow = ({ repo }: { repo: GiteaRepo }) => {
const owner = repo.owner?.login ?? repo.full_name.split('/')[0] ?? '';
return (
<Link
to={giteaRepoPath(owner, repo.name)}
className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/50"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{repo.full_name}</span>
{repo.private && <Lock className="h-3 w-3 shrink-0 text-muted-foreground" />}
{repo.fork && <GitFork className="h-3 w-3 shrink-0 text-muted-foreground" />}
{repo.archived && <Archive className="h-3 w-3 shrink-0 text-amber-500" />}
</div>
{repo.description && <p className="truncate text-xs text-muted-foreground">{repo.description}</p>}
<div className="mt-1 flex items-center gap-3 text-[11px] tabular-nums text-muted-foreground">
{repo.language && <span>{repo.language}</span>}
{!!repo.stars_count && (
<span className="flex items-center gap-0.5">
<Star className="h-3 w-3" />
{repo.stars_count}
</span>
)}
{!!repo.forks_count && (
<span className="flex items-center gap-0.5">
<GitFork className="h-3 w-3" />
{repo.forks_count}
</span>
)}
{repo.updated_at && <span>{timeAgo(repo.updated_at)}</span>}
</div>
</div>
</Link>
);
};
@@ -0,0 +1,25 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { PanelLeft, GitBranch } from 'lucide-react';
import { GiteaNav } from './GiteaNav';
import { GiteaView } from './GiteaView';
import { GiteaViewHeader } from './GiteaViewHeader';
export { GiteaNav, GiteaView };
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'gitea-nav',
name: 'Gitea',
icon: PanelLeft,
component: GiteaNav,
availableOnPanel: false,
},
{
key: 'gitea-view',
name: 'Gitea',
icon: GitBranch,
component: GiteaView,
header: GiteaViewHeader,
availableOnPanel: false,
},
];
@@ -0,0 +1,473 @@
// Shared types/constants for the /gitea workspace panels. The wire shapes mirror what the officer-gitea
// sidecar passes through from the instance's own /api/v1 — so they keep Gitea's field names verbatim,
// snake_case and all (`full_name`, `html_url`, `updated_at`). Renaming them here would put this file and
// Gitea's swagger in disagreement about what a field is called, which is exactly the confusion you don't
// want when a minor version adds one.
//
// Only the fields actually rendered are declared. Gitea's Repository object has ~70; typing all of them
// would be a second copy of a spec that moves. Every one of these was read off the live instance's
// swagger definitions rather than from memory.
export const GITEA_SECTIONS = [
{ id: 'repositories', label: 'Repositories' },
{ id: 'issues', label: 'Issues' },
{ id: 'pulls', label: 'Pull requests' },
{ id: 'notifications', label: 'Notifications' },
{ id: 'explore', label: 'Explore' },
{ id: 'organizations', label: 'Organizations' },
{ id: 'connection', label: 'Connection' },
] as const;
export type GiteaSectionId = (typeof GITEA_SECTIONS)[number]['id'];
/** Where /gitea lands, and where an unrecognised section redirects to. */
export const DEFAULT_GITEA_SECTION: GiteaSectionId = 'repositories';
export const isGiteaSection = (value: string | undefined): value is GiteaSectionId =>
GITEA_SECTIONS.some((s) => s.id === value);
/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */
export const giteaSectionPath = (id: GiteaSectionId) => `/gitea/${id}`;
// ── Repository routes ─────────────────────────────────────────────────────────────────────────────
//
// A repository is addressed by owner + name in the path, mirroring Gitea's own URLs, with the open tab as
// a further segment: /gitea/repo/pastilhas/officer/code. Two things stay in the QUERY string rather than
// the path — the file path being browsed and the git ref — because both are free-form (a file path has
// slashes in it, a branch name can too) and neither is worth a splat route to encode.
export const GITEA_REPO_TABS = [
{ id: 'code', label: 'Code' },
{ id: 'issues', label: 'Issues' },
{ id: 'pulls', label: 'Pull requests' },
{ id: 'commits', label: 'Commits' },
{ id: 'branches', label: 'Branches' },
{ id: 'releases', label: 'Releases' },
] as const;
export type GiteaRepoTab = (typeof GITEA_REPO_TABS)[number]['id'];
export const DEFAULT_GITEA_REPO_TAB: GiteaRepoTab = 'code';
export const isGiteaRepoTab = (value: string | undefined): value is GiteaRepoTab =>
GITEA_REPO_TABS.some((t) => t.id === value);
type RepoPathOptions = { path?: string; ref?: string; tab?: GiteaRepoTab; item?: number | string };
export function giteaRepoPath(owner: string, name: string, options: RepoPathOptions = {}): string {
const { path, ref, tab = DEFAULT_GITEA_REPO_TAB, item } = options;
const base = `/gitea/repo/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${tab}`;
const withItem = item == null ? base : `${base}/${encodeURIComponent(String(item))}`;
const query = new URLSearchParams();
if (path) query.set('path', path);
if (ref) query.set('ref', ref);
const qs = query.toString();
return qs ? `${withItem}?${qs}` : withItem;
}
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
export type GiteaUser = {
id: number;
login: string;
full_name?: string;
email?: string;
avatar_url?: string;
html_url?: string;
};
export type GiteaRepo = {
id: number;
name: string;
full_name: string;
description?: string;
private: boolean;
fork: boolean;
mirror?: boolean;
template?: boolean;
archived?: boolean;
empty?: boolean;
html_url: string;
ssh_url?: string;
clone_url?: string;
website?: string;
default_branch?: string;
language?: string;
stars_count?: number;
watchers_count?: number;
forks_count?: number;
open_issues_count?: number;
open_pr_counter?: number;
release_counter?: number;
size?: number;
created_at?: string;
updated_at?: string;
owner?: GiteaUser;
parent?: GiteaRepo;
permissions?: { admin?: boolean; push?: boolean; pull?: boolean };
has_issues?: boolean;
has_pull_requests?: boolean;
has_releases?: boolean;
has_wiki?: boolean;
};
export type GiteaLabel = {
id: number;
name: string;
color: string;
description?: string;
};
export type GiteaMilestone = {
id: number;
title: string;
description?: string;
state: string;
open_issues?: number;
closed_issues?: number;
due_on?: string;
};
export type GiteaAttachment = {
id: number;
name: string;
size: number;
download_count: number;
created_at: string;
browser_download_url: string;
};
/** Present on an Issue only when that issue is really a pull request — this is how Gitea distinguishes them. */
export type GiteaPullRequestMeta = { merged: boolean; merged_at?: string; html_url?: string };
export type GiteaIssue = {
id: number;
number: number;
title: string;
body?: string;
state: string;
html_url: string;
comments: number;
created_at: string;
updated_at?: string;
closed_at?: string;
due_date?: string;
user?: GiteaUser;
assignees?: GiteaUser[];
labels?: GiteaLabel[];
milestone?: GiteaMilestone;
pull_request?: GiteaPullRequestMeta;
repository?: { id: number; name: string; owner: string; full_name: string };
is_locked?: boolean;
};
export type GiteaComment = {
id: number;
body: string;
html_url: string;
created_at: string;
updated_at?: string;
user?: GiteaUser;
};
export type GiteaBranchInfo = { label: string; ref: string; sha: string; repo_id?: number; repo?: GiteaRepo };
export type GiteaPullRequest = {
id: number;
number: number;
title: string;
body?: string;
state: string;
html_url: string;
diff_url?: string;
draft?: boolean;
merged?: boolean;
merged_at?: string;
mergeable?: boolean;
merge_base?: string;
comments?: number;
review_comments?: number;
additions?: number;
deletions?: number;
changed_files?: number;
created_at: string;
updated_at?: string;
closed_at?: string;
user?: GiteaUser;
merged_by?: GiteaUser;
assignees?: GiteaUser[];
requested_reviewers?: GiteaUser[];
labels?: GiteaLabel[];
milestone?: GiteaMilestone;
base?: GiteaBranchInfo;
head?: GiteaBranchInfo;
};
/** One entry of a pull request's changed-file list. `status` is added | modified | removed | renamed. */
export type GiteaChangedFile = {
filename: string;
previous_filename?: string;
status: string;
additions: number;
deletions: number;
changes: number;
html_url?: string;
raw_url?: string;
contents_url?: string;
};
export type GiteaCommitUser = { name?: string; email?: string; date?: string };
export type GiteaCommit = {
sha: string;
html_url: string;
created?: string;
author?: GiteaUser;
committer?: GiteaUser;
parents?: { sha: string; url?: string }[];
stats?: { additions?: number; deletions?: number; total?: number };
commit: {
message: string;
url?: string;
author?: GiteaCommitUser;
committer?: GiteaCommitUser;
};
};
export type GiteaBranch = {
name: string;
protected?: boolean;
user_can_push?: boolean;
user_can_merge?: boolean;
commit?: { id?: string; message?: string; timestamp?: string; author?: GiteaCommitUser };
};
export type GiteaTag = {
name: string;
id?: string;
message?: string;
commit?: { sha?: string; url?: string; created?: string };
tarball_url?: string;
zipball_url?: string;
};
export type GiteaRelease = {
id: number;
tag_name: string;
target_commitish?: string;
name: string;
body?: string;
url: string;
html_url: string;
tarball_url?: string;
zipball_url?: string;
draft: boolean;
prerelease: boolean;
created_at: string;
published_at?: string;
author?: GiteaUser;
assets?: GiteaAttachment[];
};
/**
* One entry from the contents API. Gitea answers an ARRAY of these for a directory and a SINGLE one for a
* file same shape either way, which is why there is one type. `content` is base64 and only present for a
* file (and omitted entirely for very large ones); `type` is file | dir | symlink | submodule.
*/
export type GiteaContentsEntry = {
name: string;
path: string;
sha: string;
type: string;
size: number;
encoding?: string;
content?: string;
target?: string;
submodule_git_url?: string;
html_url?: string;
download_url?: string;
last_commit_sha?: string;
last_commit_message?: string;
last_committer_date?: string;
};
export type GiteaOrg = {
id: number;
username: string;
full_name?: string;
description?: string;
website?: string;
location?: string;
avatar_url?: string;
visibility?: string;
};
export type GiteaNotificationSubject = {
title: string;
url?: string;
html_url?: string;
latest_comment_url?: string;
latest_comment_html_url?: string;
/** Issue | Pull | Commit | Repository */
type: string;
state?: string;
};
export type GiteaNotification = {
id: number;
unread: boolean;
pinned: boolean;
updated_at: string;
url: string;
subject: GiteaNotificationSubject;
repository?: GiteaRepo;
};
/** `/repos/search` and `/repos/issues/search` wrap their results; the plain list endpoints do not. */
export type GiteaSearchResult<T> = { ok: boolean; data: T[] };
// ── Formatting helpers ────────────────────────────────────────────────────────────────────────────
export function timeAgo(iso?: string | null): string {
if (!iso) return '';
const ms = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(ms)) return '';
if (ms < 0) return 'just now';
const mins = Math.floor(ms / 60_000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
return months < 12 ? `${months}mo ago` : `${Math.floor(months / 12)}y ago`;
}
export const fullDate = (iso?: string | null): string => (iso ? new Date(iso).toLocaleString() : '');
export function formatBytes(bytes?: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / 1024 ** i;
return `${value >= 100 || i === 0 ? Math.round(value) : value.toFixed(1)} ${units[i]}`;
}
/** Everything before the first blank line — Gitea shows this as a commit's title and the rest as its body. */
export const commitTitle = (message: string): string => message.split('\n')[0] ?? '';
export const commitBody = (message: string): string => message.split('\n').slice(1).join('\n').trim();
export const shortSha = (sha?: string): string => (sha ?? '').slice(0, 7);
/**
* A label's contrast colour. Gitea stores label colours as a bare hex string and picks black or white text
* off the perceived luminance; matching that formula keeps our chips legible on the same palette theirs is.
*/
export function labelTextColor(hex: string): string {
const clean = hex.replace('#', '');
if (clean.length !== 6) return '#fff';
const r = parseInt(clean.slice(0, 2), 16);
const g = parseInt(clean.slice(2, 4), 16);
const b = parseInt(clean.slice(4, 6), 16);
return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? '#000' : '#fff';
}
/**
* Decode the base64 the contents API returns. `atob` yields one char per BYTE, so anything non-ASCII comes
* back mojibake unless those bytes are handed to a UTF-8 decoder which is most source files with a name
* or a symbol in them.
*/
export function decodeBase64(data: string): string {
const binary = atob(data.replace(/\s/g, ''));
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
/**
* Rewrite the origin of every instance-generated URL in a response onto the URL the connection was
* configured with.
*
* This is not defensive tidying it is required. Gitea builds `html_url` and `avatar_url` from its own
* `ROOT_URL` setting, and an instance behind a reverse proxy very often has that set to whatever it is
* bound to internally. The instance this was built against answers `http://localhost:9004/officerdev/landing`
* for a repository the owner reaches at `https://gitea.pastilhas.dev/officerdev/landing`, so every avatar
* would be a broken image and every "open in Gitea" link would go nowhere. Some endpoints (contents) build
* their URLs from the request host instead and are already right rewriting those is a no-op, which is
* what makes a blanket rewrite safe.
*
* Only fields Gitea itself minted are touched. `website`, `external_tracker` and the like are genuinely
* somewhere else and are left alone which is why this is a key allow-list and not "every string that
* parses as a URL".
*/
const REBASED_KEYS = new Set(['html_url', 'avatar_url', 'download_url', 'browser_download_url', 'clone_url']);
/**
* Hosts a browser on some other machine cannot resolve. The connection URL is a SERVER-side dial address
* the form says so ("localhost here means the machine Officer runs on") so it is routinely one of these,
* while the page asking for the image is on `PUBLIC_URL`. Rebasing onto one of these would replace a URL
* the browser can fetch with one it cannot.
*/
export function isUnreachableFromBrowser(host: string): boolean {
const h = host.toLowerCase().replace(/^\[|\]$/g, '');
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
if (h === '::1' || h === '0.0.0.0' || h.startsWith('127.')) return true;
return /^10\./.test(h) || /^192\.168\./.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h);
}
/**
* Rebase instance-minted URLs onto `origin`, IN ONE DIRECTION ONLY: a URL the browser cannot reach is moved
* onto one it can, never the reverse.
*
* The direction matters because this instance answers with two different origins `/user` and `/repos`
* build from its configured ROOT_URL (`http://localhost:9004`), `/contents` from the public host
* (`https://gitea.pastilhas.dev`). An unconditional rewrite onto the connection URL therefore *broke* the
* second set to match the first, turning working https links into dead loopback ones and tripping mixed
* content on the way. Verified against the live instance on 2026-08-06.
*
* So: only a private/loopback URL is rewritten, and only when `origin` is itself publicly reachable. When
* the connection URL is a dial address there is nothing better to offer, and the correct move is to change
* nothing and let the connection screen say why.
*/
export function retargetUrls<T>(value: T, origin: string): T {
if (!origin) return value;
let base: URL;
try {
base = new URL(origin);
} catch {
return value;
}
if (isUnreachableFromBrowser(base.hostname)) return value;
return walk(value, base) as T;
}
function walk(value: unknown, base: URL): unknown {
if (Array.isArray(value)) return value.map((entry) => walk(entry, base));
if (!value || typeof value !== 'object') return value;
const out: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
out[key] = REBASED_KEYS.has(key) && typeof entry === 'string' ? rebase(entry, base) : walk(entry, base);
}
return out;
}
function rebase(url: string, base: URL): string {
try {
const parsed = new URL(url);
// ssh:// and git:// clone URLs carry a different scheme and port; rewriting them produces something
// that is not a clone URL at all.
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return url;
// Already pointing where the owner configured — this endpoint built its URL from the request host and
// is right. Leaving it untouched is also what keeps a subpath install from having its prefix doubled.
if (parsed.origin === base.origin) return url;
// Reachable already. It may be a different host than the connection URL (this instance has two), but a
// URL the browser can load is not something to improve on — see the direction note above.
if (!isUnreachableFromBrowser(parsed.hostname)) return url;
const prefix = base.pathname.replace(/\/+$/, '');
return `${base.origin}${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`;
} catch {
return url;
}
}
@@ -0,0 +1,379 @@
import type {
GiteaBranch,
GiteaChangedFile,
GiteaComment,
GiteaCommit,
GiteaContentsEntry,
GiteaIssue,
GiteaLabel,
GiteaMilestone,
GiteaNotification,
GiteaOrg,
GiteaPullRequest,
GiteaRelease,
GiteaRepo,
GiteaSearchResult,
GiteaTag,
GiteaUser,
} from './shared';
import type { ServiceConnectionState } from '../../hooks/useServiceConnection';
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useServiceConnection } from '../../hooks/useServiceConnection';
import { retargetUrls } from './shared';
// Reads against the instance, through the sidecar's `/_api/*` pass-through.
//
// The connection itself is NOT handled here — it uses the shared `useServiceConnection` /
// `useServiceHealth` / `useServiceConnectionActions` hooks, because gitea serves the same
// single-connection `_config` contract as slskd and transmission. This file is only the app data.
//
// Note the doubled prefix in the paths: `useClient` is based at `/api`, the platform mounts the proxy at
// `/api/gitea` and strips it, and the sidecar strips its own `/_api` before forwarding — so
// `/gitea/_api/api/v1/user/repos` arrives at the instance as `/api/v1/user/repos`. It reads oddly and it
// is correct; the alternative is the sidecar guessing which paths are upstream's.
const KEY = 'gitea';
/** `/api/v1` under the sidecar's pass-through, with each path segment escaped exactly once. */
const api = (path: string) => `/gitea/_api/api/v1${path}`;
/**
* A repo path segment. Owner and repo names cannot contain a slash, but they CAN contain characters that
* matter in a URL (a dot, a plus), so they go through encodeURIComponent while a *file* path must keep
* its slashes and uses `encodePath` below.
*/
const repoBase = (owner: string, repo: string) => `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
/** Escape a file path segment-wise, so `src/a b.ts` survives without its separators being eaten. */
const encodePath = (path: string) => path.split('/').map(encodeURIComponent).join('/');
/**
* `enabled` is how callers gate on there being a connection at all. Unconfigured, the sidecar answers 503
* to everything under `/_api`, so firing these anyway would put a failed request behind a form the owner
* has not filled in yet and React Query would surface it as an error state on the setup screen.
*/
type Options = { enabled?: boolean };
/**
* Gitea's `_config` answers with more than the two common fields, because it is the first sidecar where the
* connection is not one thing: the instance belongs to the server owner and the token belongs to whoever is
* asking. `connection.url` is therefore NULL for everyone but the owner their row is a bare credential
* so `instanceUrl` is the resolved base, and `isOwner` decides whether the form offers a URL field at all.
*/
export type GiteaConnectionState = ServiceConnectionState & {
instanceUrl: string | null;
instanceConfigured: boolean;
isOwner: boolean;
};
export const useGiteaConnection = () => useServiceConnection<GiteaConnectionState>('gitea');
/**
* `get`, with every instance-minted URL in the response rebased onto the connection's own URL.
*
* It lives in the data layer rather than at each `<img>` and `<a>` because it is not a rendering choice
* a response whose `avatar_url` says `http://localhost:9004` is simply wrong for this client, and fixing it
* once on arrival is what stops every view from having to remember. See `retargetUrls` for why the
* instance answers that way at all.
*/
function useGiteaGet(): <T>(path: string) => Promise<T> {
const { get } = useClient();
const { data } = useGiteaConnection();
// `instanceUrl`, not `connection.url` — the latter is null for anyone but the owner, and a member's
// avatars are just as wrong as the owner's.
const origin = data?.instanceUrl ?? data?.connection?.url ?? '';
return async <T>(path: string) => retargetUrls(await get<T>(path), origin);
}
// Two staleness tiers, and the split is deliberate. Things keyed by an immutable git object (a commit, a
// blob at a sha) never change, so re-fetching them is pure waste; things keyed by a moving target (a branch
// tip, an issue list) go stale the moment somebody pushes.
const LIVE = 60_000;
const STATIC = 300_000;
// ── Account ───────────────────────────────────────────────────────────────────────────────────────
/** The account the stored token belongs to. */
export function useGiteaViewer({ enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'viewer'],
queryFn: () => get<GiteaUser>(api('/user')),
staleTime: STATIC,
retry: false,
enabled,
});
}
/**
* Repositories the token can see, most recently touched first.
*
* `/user/repos` rather than `/repos/search`: it returns what this token actually has access to, including
* private repos and those owned by orgs the account belongs to, without a query to get wrong. Gitea pages
* at 30 by default; 50 is one screenful of scrolling and still one request.
*/
export function useGiteaRepos({ enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'repos'],
queryFn: () => get<GiteaRepo[]>(api('/user/repos?limit=50&page=1')),
staleTime: LIVE,
retry: false,
enabled,
});
}
export function useGiteaOrgs({ enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'orgs'],
queryFn: () => get<GiteaOrg[]>(api('/user/orgs?limit=50')),
staleTime: STATIC,
retry: false,
enabled,
});
}
/**
* Unread notifications. Gitea's own UI counts these in the header, and the parameter name really is
* hyphenated (`status-types`), repeated once per status wanted.
*/
export function useGiteaNotifications({ enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'notifications'],
queryFn: () => get<GiteaNotification[]>(api('/notifications?status-types=unread&limit=50')),
staleTime: 30_000,
retry: false,
enabled,
});
}
// ── Cross-repository search ───────────────────────────────────────────────────────────────────────
/** Instance-wide repository search. An empty query lists what the token can see, which is the Explore page. */
export function useGiteaRepoSearch(query: string, { enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'repo-search', query],
queryFn: () => get<GiteaSearchResult<GiteaRepo>>(api(`/repos/search?q=${encodeURIComponent(query)}&limit=50`)),
staleTime: LIVE,
retry: false,
enabled,
});
}
type IssueSearchOptions = Options & { state?: 'open' | 'closed' | 'all'; type?: 'issues' | 'pulls' };
/**
* Issues and pull requests across every repository the token can see the "Issues" / "Pull Requests"
* dashboards in Gitea's own header. `type` is what separates the two: in Gitea a pull request IS an issue,
* and the list endpoint returns both unless told otherwise.
*/
export function useGiteaIssueSearch({ state = 'open', type = 'issues', enabled = true }: IssueSearchOptions = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'issue-search', state, type],
queryFn: () => get<GiteaIssue[]>(api(`/repos/issues/search?state=${state}&type=${type}&limit=50`)),
staleTime: LIVE,
retry: false,
enabled,
});
}
// ── One repository ────────────────────────────────────────────────────────────────────────────────
type RepoRef = { owner: string; repo: string };
export function useGiteaRepo({ owner, repo }: RepoRef, { enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'repo', owner, repo],
queryFn: () => get<GiteaRepo>(api(repoBase(owner, repo))),
staleTime: LIVE,
retry: false,
enabled: enabled && !!owner && !!repo,
});
}
export function useGiteaBranches({ owner, repo }: RepoRef, { enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'branches', owner, repo],
queryFn: () => get<GiteaBranch[]>(api(`${repoBase(owner, repo)}/branches?limit=100`)),
staleTime: LIVE,
retry: false,
enabled: enabled && !!owner && !!repo,
});
}
export function useGiteaTags({ owner, repo }: RepoRef, { enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'tags', owner, repo],
queryFn: () => get<GiteaTag[]>(api(`${repoBase(owner, repo)}/tags?limit=100`)),
staleTime: LIVE,
retry: false,
enabled: enabled && !!owner && !!repo,
});
}
type ContentsOptions = Options & { path?: string; ref?: string };
/**
* A directory listing or a single file, depending on what `path` points at Gitea answers an array for the
* former and a bare object for the latter, off the same URL. Callers discriminate with `Array.isArray`.
*
* The empty path is the repository root, and it must NOT get a trailing slash: `/contents/` 404s on some
* versions where `/contents` does not.
*/
export function useGiteaContents({ owner, repo }: RepoRef, { path = '', ref, enabled = true }: ContentsOptions = {}) {
const get = useGiteaGet();
const suffix = path ? `/${encodePath(path)}` : '';
const query = ref ? `?ref=${encodeURIComponent(ref)}` : '';
return useQuery({
queryKey: [KEY, 'contents', owner, repo, path, ref ?? ''],
queryFn: () =>
get<GiteaContentsEntry | GiteaContentsEntry[]>(api(`${repoBase(owner, repo)}/contents${suffix}${query}`)),
staleTime: LIVE,
retry: false,
enabled: enabled && !!owner && !!repo,
});
}
type CommitsOptions = Options & { ref?: string; path?: string; page?: number };
export function useGiteaCommits(
{ owner, repo }: RepoRef,
{ ref, path, page = 1, enabled = true }: CommitsOptions = {},
) {
const get = useGiteaGet();
const query = new URLSearchParams({ limit: '30', page: String(page) });
if (ref) query.set('sha', ref);
if (path) query.set('path', path);
return useQuery({
queryKey: [KEY, 'commits', owner, repo, ref ?? '', path ?? '', page],
queryFn: () => get<GiteaCommit[]>(api(`${repoBase(owner, repo)}/commits?${query}`)),
staleTime: LIVE,
retry: false,
enabled: enabled && !!owner && !!repo,
});
}
/** One commit, including the per-file stats Gitea only fills in on the single-commit endpoint. */
export function useGiteaCommit({ owner, repo }: RepoRef, sha: string, { enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'commit', owner, repo, sha],
queryFn: () => get<GiteaCommit>(api(`${repoBase(owner, repo)}/git/commits/${encodeURIComponent(sha)}`)),
// A commit is immutable, so once fetched it never needs re-fetching for the life of the tab.
staleTime: Infinity,
retry: false,
enabled: enabled && !!owner && !!repo && !!sha,
});
}
export function useGiteaReleases({ owner, repo }: RepoRef, { enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'releases', owner, repo],
queryFn: () => get<GiteaRelease[]>(api(`${repoBase(owner, repo)}/releases?limit=30`)),
staleTime: LIVE,
retry: false,
enabled: enabled && !!owner && !!repo,
});
}
export function useGiteaLabels({ owner, repo }: RepoRef, { enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'labels', owner, repo],
queryFn: () => get<GiteaLabel[]>(api(`${repoBase(owner, repo)}/labels?limit=100`)),
staleTime: STATIC,
retry: false,
enabled: enabled && !!owner && !!repo,
});
}
export function useGiteaMilestones({ owner, repo }: RepoRef, { enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'milestones', owner, repo],
queryFn: () => get<GiteaMilestone[]>(api(`${repoBase(owner, repo)}/milestones?state=all&limit=50`)),
staleTime: STATIC,
retry: false,
enabled: enabled && !!owner && !!repo,
});
}
// ── Issues and pull requests ──────────────────────────────────────────────────────────────────────
type RepoIssuesOptions = Options & { state?: 'open' | 'closed' | 'all'; type?: 'issues' | 'pulls'; labels?: string };
export function useGiteaRepoIssues(
{ owner, repo }: RepoRef,
{ state = 'open', type = 'issues', labels, enabled = true }: RepoIssuesOptions = {},
) {
const get = useGiteaGet();
const query = new URLSearchParams({ state, type, limit: '50' });
if (labels) query.set('labels', labels);
return useQuery({
queryKey: [KEY, 'repo-issues', owner, repo, state, type, labels ?? ''],
queryFn: () => get<GiteaIssue[]>(api(`${repoBase(owner, repo)}/issues?${query}`)),
staleTime: LIVE,
retry: false,
enabled: enabled && !!owner && !!repo,
});
}
/**
* One issue by its per-repository number.
*
* Gitea serves a pull request from this endpoint too a PR and an issue share a number space so an issue
* detail screen works for both, and only the extra PR-specific data needs `/pulls/{index}`.
*/
export function useGiteaIssue({ owner, repo }: RepoRef, index: number, { enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'issue', owner, repo, index],
queryFn: () => get<GiteaIssue>(api(`${repoBase(owner, repo)}/issues/${index}`)),
staleTime: LIVE,
retry: false,
enabled: enabled && !!owner && !!repo && Number.isFinite(index),
});
}
export function useGiteaIssueComments({ owner, repo }: RepoRef, index: number, { enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'issue-comments', owner, repo, index],
queryFn: () => get<GiteaComment[]>(api(`${repoBase(owner, repo)}/issues/${index}/comments`)),
staleTime: LIVE,
retry: false,
enabled: enabled && !!owner && !!repo && Number.isFinite(index),
});
}
export function useGiteaPull({ owner, repo }: RepoRef, index: number, { enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'pull', owner, repo, index],
queryFn: () => get<GiteaPullRequest>(api(`${repoBase(owner, repo)}/pulls/${index}`)),
staleTime: LIVE,
retry: false,
enabled: enabled && !!owner && !!repo && Number.isFinite(index),
});
}
/** The changed-file summary for a pull request — filename plus +/- counts, not the patch text. */
export function useGiteaPullFiles({ owner, repo }: RepoRef, index: number, { enabled = true }: Options = {}) {
const get = useGiteaGet();
return useQuery({
queryKey: [KEY, 'pull-files', owner, repo, index],
queryFn: () => get<GiteaChangedFile[]>(api(`${repoBase(owner, repo)}/pulls/${index}/files?limit=100`)),
staleTime: LIVE,
retry: false,
enabled: enabled && !!owner && !!repo && Number.isFinite(index),
});
}
@@ -0,0 +1,46 @@
import { useParams, useSearchParams } from 'react-router';
import {
DEFAULT_GITEA_REPO_TAB,
DEFAULT_GITEA_SECTION,
isGiteaRepoTab,
isGiteaSection,
type GiteaRepoTab,
type GiteaSectionId,
} from './shared';
// Where the app is, read straight off the URL — per docs/navigation-audit.md, the URL is the selection.
//
// Every gitea panel calls one of these instead of being handed state by a sibling, which is what makes
// cmd-click, the back button and a pasted link all work without anything extra.
export function useGiteaSection(): GiteaSectionId {
const { section } = useParams();
return isGiteaSection(section) ? section : DEFAULT_GITEA_SECTION;
}
export type GiteaLocation = {
/** Null when the URL is not pointing at a repository at all — i.e. this is a top-level section. */
owner: string | null;
repo: string | null;
tab: GiteaRepoTab;
/** Issue / PR number or a commit sha, from the segment after the tab. */
item: string | null;
/** Path within the repository being browsed. Empty string is the root, which is a real location. */
path: string;
/** Branch, tag or sha. Null means "whatever the repo's default branch is", resolved by the caller. */
refName: string | null;
};
export function useGiteaLocation(): GiteaLocation {
const { owner, name, tab, item } = useParams();
const [params] = useSearchParams();
return {
owner: owner ?? null,
repo: name ?? null,
tab: isGiteaRepoTab(tab) ? tab : DEFAULT_GITEA_REPO_TAB,
item: item ?? null,
path: params.get('path') ?? '',
refName: params.get('ref') || null,
};
}
@@ -17,7 +17,12 @@ import { useClient } from 'hooks/useClient';
export type ServiceConnection = {
id: number;
service: string;
url: string;
/**
* Null means "inherit the instance" the row is a bare credential and the base URL comes from the
* owner's row. Only multi-user services (gitea) ever store null; a single-daemon service always sets it.
* Mirror of officerdb's `url: text('url')`, which is nullable since the one-instance-many-tokens change.
*/
url: string | null;
username: string | null;
path: string | null;
hasSecret: boolean;
+11
View File
@@ -47,6 +47,17 @@ export {
} from './apps/Transmission/shared';
export type { TransmissionSectionId } from './apps/Transmission/shared';
// Same for /gitea.
export {
DEFAULT_GITEA_REPO_TAB,
DEFAULT_GITEA_SECTION,
giteaRepoPath,
giteaSectionPath,
isGiteaRepoTab,
isGiteaSection,
} from './apps/Gitea/shared';
export type { GiteaRepoTab, GiteaSectionId } from './apps/Gitea/shared';
// /calendar and /contacts select with a query param rather than a path segment, so what the screens need
// from the app is the param name and the link builder.
export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared';