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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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(\/|$|\?)/,
|
||||
|
||||
@@ -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 server’s 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 instance’s public URL here instead.'
|
||||
: 'Ask the server owner to use the instance’s 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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user