gitea, extracted into plugins/gitea — the platform half

Eleventh extraction, and the only one that deleted its UI instead of moving it.
Seventeen files under apps/Gitea/ reimplemented a fraction of an interface
Gitea already has; the plugin renders the real one in an iframe over a
same-origin proxy.

Deleting rather than moving removed a platform change nobody now has to build:
useGiteaLocation read :owner/:name/:tab/:item from five route patterns where a
plugin gets two, so an as-is extraction would have required teaching the plugin
system to declare extra route patterns. Nothing inside an iframe needs a route.

Nothing working was lost. The router had been commented out of hono.ts since
2026-08-13 with api: [], and officer-gitea was running nowhere — the screen
rendered and every request 404'd, like soulseek, jellyfin and invoices before
it.

Gitea also stops being a CORE DOCK ITEM. It was hardcoded in CORE_DOCK_ITEMS,
the only installable feature that was, on the reasoning that it fronts a remote
instance and installs nothing locally. As a plugin its tile arrives from
assets/icon.png like every other — which is also why the icon added to
public/icons/ this morning was reverted rather than kept.

'gitea' stays in the ServiceName union: the connection lives in core
service_connections, and gitea is the reason that table has its "member rows
carry only a token and inherit the owner's URL" shape at all.

registry.test.ts's grantable-permission test named gitea as "the case this was
built for"; a plugin's permissions are registered at install and are never in
that compile-time list, so it now names a core one. catalogue.test.ts
re-anchored to vnc/notify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 07:59:10 +00:00
co-authored by Claude Opus 5
parent d8cccad84c
commit d49776307d
35 changed files with 29 additions and 3527 deletions
-5
View File
@@ -102,11 +102,6 @@ export function App() {
element={<Dashboard.PluginScreen {...plugin} />}
/>,
])}
<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="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
@@ -1,38 +0,0 @@
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 /command-hq): 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.
export const GiteaScreen = () => {
const { section, owner, name } = useParams();
const workspace = useDashboardState<LayoutNode>('screens/gitea', defaultLayout);
// 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
appTypes={{ allowed: ['gitea-nav', 'gitea-view'], fallback: 'gitea-view' }}
/>
</div>
);
};
@@ -1,11 +0,0 @@
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 },
],
};
@@ -1 +0,0 @@
export * from './GiteaScreen';
@@ -173,7 +173,6 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Command HQ', to: '/command-hq', icon: Radar, color: '#f43f5e' },
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
@@ -14,7 +14,6 @@ export * from './Files';
export * from './Calendar';
export * from './Contacts';
export * from './CommandHQ';
export * from './Gitea';
export * from './SystemMonitor';
export * from './Activity';
export * from './CodeEditor';
@@ -24,7 +24,6 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/marketplace'), title: 'Marketplace' },
{ match: (p) => p.startsWith('/plugins'), title: 'Marketplace' },
{ match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' },
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
{ match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' },
{ match: (p) => p.startsWith('/activity'), title: 'Activity' },
-16
View File
@@ -1,16 +0,0 @@
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;
+1 -1
View File
@@ -111,7 +111,7 @@ describe('member provisioning is declared for every entry', () => {
// against an API that does not exist. Transmission was an example until 2026-08-18 and slskd until
// 2026-08-19, when they became `plugins/transmission/` and `plugins/soulseek/` — hence the list
// rather than a single id: it must stay non-empty, or this assertion passes by iterating nothing.
for (const id of ['vnc', 'notify', 'gitea']) expect(byId(id)!.members).toBe('none');
for (const id of ['vnc', 'notify']) expect(byId(id)!.members).toBe('none');
});
// A test asserting the vault is invite-only lived here until 2026-08-18, when it left to become
-28
View File
@@ -196,34 +196,6 @@ export const CATALOGUE: CatalogueEntry[] = [
// Deleting the entry is REQUIRED, not tidying — see the note where photos used to sit, and the trap in
// `EXTRACTING-A-PLUGIN.md`. A leftover entry makes the permission permanently `unavailable`, because
// `permissionAvailability()` derives from `sidecar_installs` and a plugin never gets a row there.
{
id: 'gitea',
ui: { name: 'Gitea', icon: 'GitBranch', color: '#34d399', rootRoute: '/gitea', routes: ['/gitea'] },
process: 'officer-gitea',
label: 'Gitea',
summary: 'Repositories, issues and pull requests from your own Gitea account',
// 'none' because there is nothing for the INSTALLER to provision, not because Gitea is single-tenant —
// it is the most per-user service here. The owner's connection carries the instance URL; every member
// adds their own access token from /gitea and acts only as themselves upstream, with Gitea's own
// permissions as the second and real gate. A provisioner could not do that part for them without
// holding an admin token and minting tokens on their behalf, which is more authority than this needs.
members: 'none',
// `existing` only, and there is no compose template. Gitea here is always something the owner already
// runs — on this machine, on another, or hosted. Provisioning one would mean owning the migration,
// backup and upgrade story for a service that is nobody's side feature.
modes: ['existing'],
permission: 'gitea',
existingFields: [
{ key: 'url', label: 'Gitea URL', type: 'url', required: true, placeholder: 'https://gitea.example.com' },
{
key: 'secret',
label: 'Your access token',
type: 'secret',
required: true,
help: 'A personal access token from your own Gitea account. Members add their own from /gitea.',
},
],
},
// Headscale was here until 2026-08-14. It became a core sidecar, and then on 2026-08-15 it left the
// repository entirely: it is the `offscale` PLUGIN now, installed from the marketplace, and neither
// this catalogue nor `CORE_PROCESSES` mentions it. The note below is kept because the FAILURE it
-2
View File
@@ -27,7 +27,6 @@ import { agentHandoffRouter } from './api/agent-handoff/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 { appStoreRouter } from './api/app-store/router';
import { davSyncRouter } from './api/dav/sync-router';
import { davRouter } from './api/dav/router';
@@ -139,7 +138,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/file-browser', fileBrowserRouter],
['/terminal', terminalRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
// ['/gitea', giteaRouter], // plugin — switched off 2026-08-13
['/app-store', appStoreRouter],
['/plugins', pluginsRouter],
['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI
+7 -4
View File
@@ -73,9 +73,9 @@ describe('totality', () => {
}
});
test('signin is exempt and gitea is not', () => {
test('signin is exempt and an ordinary route is not', () => {
expect(isExemptApiPath('/api/auth/signin')).toBe(true);
expect(isExemptApiPath('/api/gitea')).toBe(false);
expect(isExemptApiPath('/api/chat')).toBe(false);
expect(isExemptApiPath('/api/dashboards/4')).toBe(false);
});
});
@@ -254,8 +254,11 @@ describe('kinds', () => {
}
});
test('gitea is grantable — the case this was built for', () => {
expect(GRANTABLE_PERMISSIONS.some((c) => c.key === 'gitea')).toBe(true);
// `gitea` was this test's subject until 2026-08-19, when it became `plugins/gitea/`. A plugin's
// permissions are registered at INSTALL from its manifest and are always `app`, so they are never in
// this compile-time list — which is why the assertion has to name a core one to mean anything.
test('an app permission is grantable — the case this was built for', () => {
expect(GRANTABLE_PERMISSIONS.some((c) => c.key === 'chat')).toBe(true);
});
/*
-18
View File
@@ -143,24 +143,6 @@ const CORE_REGISTRY: Permission[] = [
},
// ── app: the grantable surface ──────────────────────────────────────────────────────────────────
{
key: 'gitea',
label: 'Gitea',
description: 'Repositories, issues, pull requests and notifications from your own Gitea account',
kind: 'app',
// api: ['/gitea'], // plugin — switched off 2026-08-13
api: [],
routes: ['/gitea'],
// Every Gitea call is already scoped to the caller's own personal access token — the sidecar resolves
// the token from the caller's row and the instance from the owner's, so a member holding `read` still
// acts only as themselves upstream. Gitea's own permissions are the second gate and the real one:
// a token cannot reach a repository its account cannot reach, whatever this platform thinks.
//
// Which is why the whole permission is `personal` rather than a list of sub-paths. Nothing under
// /api/gitea can affect another Officer user, so withholding write here would only stop someone
// commenting on their own issues — security theatre with a real cost and no benefit.
personal: ['/'],
},
{
key: 'memos',
label: 'Memos',
+21
View File
@@ -234,6 +234,27 @@ const BUNDLED: CatalogueEntry[] = [
// See its PLUGIN.md; this was a deliberate decision, not a side effect of extraction.
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'gitea',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Gitea',
summary: 'Repositories, issues and pull requests — your own Gitea, rendered in Officer',
color: '#609926',
icon: 'GitBranch',
source: 'https://gitea.officer.dev/plugins/gitea.git',
permissions: [
{
key: 'gitea',
label: 'Gitea',
description: 'Repositories, issues, pull requests and notifications from your own Gitea account',
},
],
// `schema: false` — the connection lives in core `service_connections`, like transmission's. The web
// half is one iframe over a proxy of Gitea's own UI rather than panels; see its PLUGIN.md.
has: { api: true, schema: false, sidecar: true, web: true },
},
{
appName: 'vault',
publisher: 'officerdev',
-290
View File
@@ -1,290 +0,0 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import {
getServiceConnection,
saveServiceConnection,
deleteServiceConnection,
recordServiceProbe,
getServiceInstanceUrl,
getOwnerUser,
} from 'officerdb';
import {
callGitea,
getGiteaConfig,
invalidateGiteaConfig,
invalidateAllGiteaConfigs,
normalizeBase,
probe,
} from './upstream';
import { API_URL } from '../../officer-url.mjs';
// The officer-gitea sidecar. Owns the whole Gitea contract: the instance URL and the personal access
// token. The platform side is a thin auth-gated forwarder holding no Gitea credentials.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/gitea mount prefix before forwarding.
//
// GET /_health is the instance up, and does the stored token work
// GET /_config the connection MINUS the token
// PUT/POST /_config { url, token } — validated live before it is stored
// DELETE /_config forget the connection
// ANY /_api/* forwarded to the instance's own /api/*, token attached
//
// That is the SINGLE-CONNECTION contract slskd and transmission already serve, matched deliberately so
// the UI can use the shared `useServiceConnection` / `useServiceConnectionActions` hooks rather than
// growing a fourth copy of them. Which is why PUT is accepted (the shared hook's `save` uses it), why a
// save answers `{ connection }`, and why DELETE answers the connection state rather than `{ ok }`.
// Gitea genuinely is one-of: nobody runs two.
//
// `/_api/*` is a deliberate pass-through rather than a hand-written wrapper per endpoint, for the same
// reason memos does it: Gitea's REST surface is enormous (its swagger is 850KB) and moves between minor
// versions, so re-describing it here would mean a second thing to keep in sync, and the UI is going to
// speak the upstream's shapes anyway. The allow-list below is the one piece of policy: it keeps the
// sidecar from being a general-purpose SSRF hop into whatever else is on that host.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// Everything under /api/v1 the UI legitimately needs.
//
// `/api/v1/admin/*` is excluded ON PURPOSE and should stay excluded. A Gitea token minted by a site
// admin carries the admin API with it — create and delete users, rewrite anyone's repositories, mint
// tokens for other accounts. None of that is a thing a notes-and-repos panel needs, and a token pasted
// into a connection form should not silently become a remote root shell for the instance. If an admin
// screen is ever genuinely wanted it gets its own reviewed entry here, not a widened prefix.
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(\/|$|\?)/,
/^\/api\/v1\/teams(\/|$|\?)/,
/^\/api\/v1\/repos(\/|$|\?)/,
/^\/api\/v1\/repositories(\/|$|\?)/,
/^\/api\/v1\/notifications(\/|$|\?)/,
/^\/api\/v1\/issues(\/|$|\?)/,
/^\/api\/v1\/packages(\/|$|\?)/,
// Instance metadata the UI reads to match the instance's own behaviour rather than guess it —
// default branch name, whether the markdown renderer allows raw HTML, which features are enabled.
/^\/api\/v1\/settings(\/|$|\?)/,
/^\/api\/v1\/topics(\/|$|\?)/,
/^\/api\/v1\/licenses(\/|$|\?)/,
/^\/api\/v1\/gitignore(\/|$|\?)/,
/^\/api\/v1\/label(\/|$|\?)/,
// Rendering. Gitea's own web UI renders a README through these, and matching it matters: the
// instance resolves relative links, #issue references and @mentions against the repo context, which a
// client-side markdown pass cannot do. Sanitisation is the instance's too — see renderMarkdown.
/^\/api\/v1\/markdown(\/|$|\?)/,
/^\/api\/v1\/markup(\/|$|\?)/,
];
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probeServer.port;
probeServer.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
const port = getFreePort();
const bad = (error: string, status = 400) => Response.json({ error }, { status });
/** Whoever owns the platform configures the instance; everyone else supplies only their own token. */
async function isOwner(userId: number): Promise<boolean> {
const owner = await getOwnerUser();
return !!owner && owner.id === userId;
}
// A member's row has no URL to return — it is genuinely not stored, not merely withheld — so this needs
// no per-caller filtering. `instanceConfigured` is what the UI needs instead: it distinguishes "you have
// not connected yet" from "there is nothing here to connect to", which are different screens.
//
// `instanceUrl` is the resolved base, returned to members as well as the owner. Not setting it is the
// invariant (a member naming the URL would be a per-user SSRF hop); not SEEING it never was, and could
// not be: every avatar_url and html_url the instance hands back is on that origin and gets rendered.
// The UI needs it as a value rather than as a thousand strings because this instance's ROOT_URL disagrees
// with its public host, so links have to be rebased client-side — see retargetUrls.
async function connectionState(userId: number): Promise<Response> {
const connection = await getServiceConnection(userId, 'gitea');
const instanceUrl = await getServiceInstanceUrl('gitea');
return Response.json({
configured: !!connection,
connection,
instanceUrl,
instanceConfigured: !!instanceUrl,
isOwner: await isOwner(userId),
});
}
async function handleConfig(req: Request, userId: number): Promise<Response> {
if (req.method === 'GET') return connectionState(userId);
if (req.method === 'DELETE') {
const owner = await isOwner(userId);
await deleteServiceConnection(userId, 'gitea');
// The owner disconnecting removes the instance every member's row inherits, so every cached config
// is now wrong — they must all resolve to null rather than keep talking to a server the platform no
// longer claims. A member disconnecting only affects themselves.
if (owner) invalidateAllGiteaConfigs();
else invalidateGiteaConfig(userId);
return connectionState(userId);
}
if (req.method === 'PUT' || req.method === 'POST') {
const body = ((await req.json().catch(() => null)) as { url?: unknown; token?: unknown } | null) ?? {};
// A blank token on a save means "keep the stored one", so the URL can be corrected without the
// token being re-pasted. There is no way to read it back out to re-send it.
const token = typeof body.token === 'string' && body.token.trim() ? body.token.trim() : null;
const owner = await isOwner(userId);
// The instance is the owner's; the token is each person's own. Which half of the form you get is
// decided here rather than in the UI, because a UI that hides a field is a suggestion and this is a
// rule: a member who could name the URL would have a per-user SSRF hop behind a settings form.
let base: string;
if (owner) {
const url = typeof body.url === 'string' ? normalizeBase(body.url) : '';
if (!url) return bad('url is required');
if (!/^https?:\/\//i.test(url)) return bad('url must start with http:// or https://');
base = url;
} else {
// Rejected, not ignored. Ignoring it would let a member believe they had pointed Gitea somewhere
// and leave them debugging a screen that is quietly talking to a different server.
if (body.url !== undefined) return bad('only the server owner can set the Gitea instance URL', 403);
const instance = await getServiceInstanceUrl('gitea');
if (!instance) return bad('the server owner has not connected a Gitea instance yet', 409);
base = instance;
}
const existing = await getServiceConnection(userId, 'gitea');
if (!token && !existing?.hasSecret) return bad('an access token is required the first time');
// Probe with the credentials as they WILL be, so a save either stores something that works or
// fails with the reason. Storing first and discovering later makes every downstream screen fail
// mysteriously instead. For a member this also proves their token is valid against the shared
// instance, which is the only thing that can be wrong on their side.
const current = token ? null : await getGiteaConfig(userId);
const result = await probe({ id: existing?.id ?? 0, base, token: token ?? current?.token ?? null });
if (!result.ok) return bad(result.error ?? 'could not reach the instance');
const connection = await saveServiceConnection({
userId,
service: 'gitea',
// Null for anyone but the owner: their row is a credential, and inherits the instance on read.
url: owner ? base : null,
secret: token ?? undefined,
version: result.version,
});
// An owner's save may have moved the instance, and every member's cached base was resolved from it.
// Clearing only the caller would leave them pointed at the previous host until this process restarts.
if (owner) invalidateAllGiteaConfigs();
else invalidateGiteaConfig(userId);
return Response.json({ connection, user: result.user, version: result.version });
}
return bad('method not allowed', 405);
}
const server = Bun.serve({
port,
hostname: '127.0.0.1',
// Release attachments and file uploads through the contents API.
maxRequestBodySize: 64 * 1024 * 1024,
async fetch(req) {
const url = new URL(req.url);
const officerUser = req.headers.get('X-Officer-User');
const userId = Number(officerUser);
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
}
try {
if (url.pathname === '/_config') return await handleConfig(req, userId);
const config = await getGiteaConfig(userId);
if (url.pathname === '/_health') {
if (!config) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
const started = Date.now();
const result = await probe(config);
const ms = Date.now() - started;
if (!result.ok) {
return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 });
}
await recordServiceProbe(userId, 'gitea', result.version);
return Response.json({ ok: true, configured: true, user: result.user, version: result.version, ms });
}
if (url.pathname.startsWith('/_api/')) {
if (!config) return Response.json({ error: 'gitea not connected', configured: false }, { status: 503 });
const subpath = url.pathname.slice('/_api'.length) + url.search;
if (!ALLOWED.some((re) => re.test(subpath))) return bad('path not allowed', 403);
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
const upstream = await callGitea(config, subpath, {
method: req.method,
headers: req.headers.get('content-type') ? { 'content-type': req.headers.get('content-type')! } : {},
body: hasBody ? await req.arrayBuffer() : undefined,
});
return new Response(upstream.body, {
status: upstream.status,
headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' },
});
}
} catch (err) {
// Path only. A repository body is the owner's private code.
console.error(`[gitea] ${req.method} ${url.pathname} failed`, String(err));
return Response.json({ error: 'internal error' }, { status: 500 });
}
return Response.json({ error: 'not found' }, { status: 404 });
},
});
console.log(`[gitea] listening on 127.0.0.1:${port} (instance configured from the UI, stored in service_connections)`);
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'gitea',
handles: ['gitea'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
connection.send({ type: 'gitea:server', port });
console.log(`[gitea] reported server port ${port} to API`);
},
});
function shutdown(signal: string) {
console.log(`[gitea] ${signal} received, shutting down...`);
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
-114
View File
@@ -1,114 +0,0 @@
import { getResolvedServiceCredentials } from 'officerdb';
// Where the Gitea instance is and what it takes to talk to it. The credential lives here and only here —
// the platform forwards to this sidecar and holds nothing.
//
// Gitea authenticates API calls with a personal access token minted from the instance's own
// Settings → Applications → Access Tokens. That is the OWNER'S to create and paste in; there is no way to
// derive one from a platform session, and no reason to want one.
//
// THE HEADER IS `token `, NOT `Bearer `. Gitea's own scheme, confirmed against a live 1.27.0 instance's
// swagger: `AuthorizationHeaderToken` is documented as "API tokens must be prepended with \"token\"
// followed by a space." Recent Gitea also tolerates `Bearer`, but `token` is the documented spelling and
// the one that works on every version we might be pointed at. The query-string forms (`?access_token=` /
// `?token=`) are deprecated for removal and must not be used — they leak the credential into access logs.
export type GiteaConfig = { id: number; base: string; token: string | null };
/** Trailing slash stripped, so `${base}/api/v1/...` never doubles the separator. */
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
const cache = new Map<number, GiteaConfig | null>();
export function invalidateGiteaConfig(userId: number): void {
cache.delete(userId);
}
/**
* Clear every user's cached config.
*
* Needed because a member's entry holds the OWNER'S base, resolved at read time. Dropping only the
* caller's entry was correct while each row was self-contained; once rows inherit, changing the instance
* leaves every member cached against the previous host until this process restarts — pointing their
* tokens at a server the owner has just moved away from. Call this whenever the owner's row changes.
*/
export function invalidateAllGiteaConfigs(): void {
cache.clear();
}
// One Gitea instance, one token per person. The base comes from whoever owns the platform; the token is
// this caller's own, so what they see is their account — their repos, their notifications, their issues.
//
// getResolvedServiceCredentials does the inheriting: the owner's row carries the URL, everyone else's
// carries only a token and resolves the base from the owner's. It returns null when the pairing is not
// usable — no row for this user, or a row waiting on an instance the owner has not connected — and both
// cases mean the same thing to every caller here: Gitea is not available to you yet.
//
// A member's URL is never read from their own row because it is never written there. That is what stops
// a settings form from becoming a per-user SSRF hop.
export async function getGiteaConfig(userId: number): Promise<GiteaConfig | null> {
if (cache.has(userId)) return cache.get(userId) ?? null;
const row = await getResolvedServiceCredentials(userId, 'gitea');
const config = row ? { id: row.id, base: normalizeBase(row.url), token: row.secret } : null;
cache.set(userId, config);
return config;
}
export const authHeaders = (config: GiteaConfig): Record<string, string> =>
config.token ? { Authorization: `token ${config.token}` } : {};
/**
* One call against the instance. Returns the raw Response so callers can stream or inspect status —
* nothing here parses a repository or an issue, because that is the owner's code and this layer has no
* business reading it.
*/
export async function callGitea(config: GiteaConfig, path: string, init?: RequestInit): Promise<Response> {
const headers = { ...authHeaders(config), ...((init?.headers as Record<string, string>) ?? {}) };
return fetch(`${config.base}${path}`, { ...init, headers, signal: init?.signal ?? AbortSignal.timeout(20_000) });
}
export type ProbeResult = { ok: boolean; version: string | null; user: string | null; error?: string };
/**
* Is the instance up, and does the stored token actually work?
*
* Both calls carry the token, which is the one place this deliberately departs from the memos sidecar's
* shape. Memos probes `/healthz` unauthenticated first so a bad URL is distinguishable from a bad token.
* Gitea has no such endpoint under a common configuration: with `REQUIRE_SIGNIN_VIEW` enabled — which the
* instance this was built against has — even `/api/v1/version` answers 403 with "Only signed in user is
* allowed to call APIs". An unauthenticated pre-flight would therefore report "unreachable" for a
* perfectly healthy server, which is the most misleading answer available.
*
* So the split is by failure mode instead, which is what the UI actually needs to tell apart:
* throw (DNS, refused, TLS, timeout) → unreachable, the URL is wrong
* 401/403 → reached it fine, the token is wrong
* 200 → both good
* Verified against the live instance: unauthenticated /version → 403, /version with a junk token → 401.
*/
export async function probe(config: GiteaConfig): Promise<ProbeResult> {
if (!config.token) return { ok: false, version: null, user: null, error: 'no access token stored' };
let version: string | null = null;
try {
const res = await callGitea(config, '/api/v1/version', { signal: AbortSignal.timeout(8000) });
if (res.status === 401 || res.status === 403) {
return { ok: false, version: null, user: null, error: `token rejected (${res.status})` };
}
if (!res.ok) return { ok: false, version: null, user: null, error: `instance returned ${res.status}` };
const body = (await res.json().catch(() => ({}))) as { version?: string };
version = body.version ?? null;
} catch (err) {
return { ok: false, version: null, user: null, error: `unreachable: ${String(err)}` };
}
// `/version` is satisfied by any valid token; `/user` is what proves the token is bound to an actual
// account and yields the identity the connection screen shows back to the owner.
try {
const me = await callGitea(config, '/api/v1/user');
if (!me.ok) return { ok: false, version, user: null, error: `token rejected (${me.status})` };
const body = (await me.json().catch(() => ({}))) as { login?: string; full_name?: string; username?: string };
return { ok: true, version, user: body.login || body.username || body.full_name || null };
} catch (err) {
return { ok: false, version, user: null, error: String(err) };
}
}
@@ -10,7 +10,6 @@ import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards';
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
import { appRegistryMetas as giteaMetas } from '../apps/Gitea';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
import { appRegistryMetas as davMetas } from '../apps/Dav';
@@ -29,7 +28,6 @@ export const apps = [
...chatHistoryMetas,
...widgetMetas,
...desktopMetas,
...giteaMetas,
...monitorMetas,
...qrTransferMetas,
...davMetas,
@@ -1,241 +0,0 @@
import type { GiteaIssue, GiteaNotification } from './shared';
import { useState } from 'react';
import { Bell, Building2, ExternalLink, MessageSquare, Search } from 'lucide-react';
import { DataRow, RelativeTime } from '@/components/Data';
import { EmptyState, ErrorState, LabelChip, Loading, StateFilter, StateIcon } from './GiteaBits';
import { RepoRow } from './RepositoriesView';
import { giteaRepoPath } 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()}
hint={`Gitea searches ${noun} you created, are assigned, or are mentioned in — across every repository this token can see.`}
/>
) : (
<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;
// Without a repository on the payload there is nothing to route to, so DataRow is left without a `to`
// and renders a plain div rather than a link that goes somewhere wrong.
return (
<DataRow
to={to ?? undefined}
lead={<StateIcon item={issue} />}
title={
<>
<span className="truncate text-sm font-medium">{issue.title}</span>
{issue.labels?.map((label) => (
<LabelChip key={label.id} label={label} />
))}
</>
}
meta={[
// The repository leads here — it is the fact that makes a cross-repo row make sense, and it is
// the first thing you need in order to know whether the row is yours to care about.
issue.repository?.full_name ?? 'unknown',
`#${issue.number}`,
<RelativeTime value={issue.created_at} prefix="opened" />,
issue.user && `by ${issue.user.login}`,
]}
trail={
!!issue.comments && (
<span className="flex items-center gap-1.5 text-xs tabular-nums text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
{issue.comments}
</span>
)
}
/>
);
};
// ── 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) => {
// An in-app route when the subject URL can be parsed back into owner/repo/number, and the
// instance itself when it cannot — the row stays clickable either way, and the external icon
// is the only thing that tells you which kind of click you are about to make.
const to = subjectRoute(notification);
return (
<DataRow
key={notification.id}
to={to ?? undefined}
href={to ? undefined : notification.subject.html_url}
lead={<Bell className="h-4 w-4 text-success" />}
title={notification.subject.title}
meta={[
notification.repository?.full_name,
notification.subject.type,
<RelativeTime value={notification.updated_at} />,
]}
trail={!to && <ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />}
/>
);
})}
</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'}
hint={
query
? 'Search covers repository names and descriptions on this instance, limited to what your token can see.'
: 'Search this instance by repository name or description.'
}
/>
) : (
<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) => (
<DataRow
key={org.id}
lead={
org.avatar_url ? (
<img src={org.avatar_url} alt="" className="h-8 w-8 rounded-full object-cover" loading="lazy" />
) : (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
<Building2 className="h-4 w-4 text-muted-foreground" />
</span>
)
}
title={org.full_name || org.username}
description={org.description}
meta={[`@${org.username}`, org.visibility, org.location]}
/>
))}
</div>
</div>
);
};
@@ -1,119 +0,0 @@
import type { Tone } from '@/components/Data';
import type { ReactNode } from 'react';
import type { GiteaIssue, GiteaLabel, GiteaPullRequest, GiteaUser } from './shared';
import { CircleCheck, CircleDot, GitMerge, GitPullRequest, GitPullRequestClosed } from 'lucide-react';
import { EmptyBlock, ErrorBlock, LoadingBlock, StatusIcon, StatusPill } from '@/components/Data';
import { serviceErrorMessage } from '../../hooks/useServiceConnection';
import { labelTextColor } from './shared';
// The small pieces every Gitea view repeats. These are now thin adapters over `components/Data` —
// they translate Gitea's vocabulary (open / closed / merged) into the design language's five tones
// and otherwise get out of the way. See docs/design-language-interface.md.
//
// The export names are unchanged so the ten call sites did not all have to move at once.
/**
* Gitea's state vocabulary, mapped once.
*
* The non-obvious choice is CLOSED ISSUE → neutral rather than a colour of its own. Most issues in
* any list are closed; giving them a tone paints most of the screen and leaves the open ones no
* quieter than the rest. Neutral is what makes "open" findable — which is the only thing anyone is
* scanning an issue list for. A closed PULL request is different: it was rejected, and that is a
* real outcome worth marking, so it keeps `danger`.
*/
function stateOf(item: GiteaIssue | GiteaPullRequest): { tone: Tone; icon: typeof CircleDot; text: string } {
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 { tone: 'info', icon: GitMerge, text: 'Merged' };
if (item.state === 'open') {
return { tone: 'success', icon: isPull ? GitPullRequest : CircleDot, text: 'Open' };
}
return isPull
? { tone: 'danger', icon: GitPullRequestClosed, text: 'Closed' }
: { tone: 'neutral', icon: CircleCheck, text: 'Closed' };
}
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-xs font-medium text-muted-foreground"
style={{ width: size, height: size }}
title={user?.login}
>
{initial}
</span>
);
}
return (
<img
src={user.avatar_url}
alt={user.login}
title={user.login}
className="shrink-0 rounded-full object-cover"
style={{ width: size, height: size }}
loading="lazy"
/>
);
};
/**
* The one place a raw colour is correct: a label's colour is data the user chose in Gitea, not a
* decision this app gets to make. Everything else on a Gitea surface goes through a tone.
*/
export const LabelChip = ({ label }: { label: GiteaLabel }) => (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium leading-none"
style={{ backgroundColor: `#${label.color.replace('#', '')}`, color: labelTextColor(label.color) }}
title={label.description}
>
{label.name}
</span>
);
export const StateBadge = ({ item }: { item: GiteaIssue | GiteaPullRequest }) => {
const { tone, icon, text } = stateOf(item);
return (
<StatusPill tone={tone} icon={icon}>
{text}
</StatusPill>
);
};
/** The state marker for a dense list, where the full pill is too much furniture. */
export const StateIcon = ({ item }: { item: GiteaIssue | GiteaPullRequest }) => {
const { tone, icon, text } = stateOf(item);
return <StatusIcon tone={tone} icon={icon} title={text} />;
};
export const Loading = ({ label = 'Loading…' }: { label?: string }) => <LoadingBlock label={label} />;
export const ErrorState = ({ title, error }: { title: string; error: unknown }) => (
<ErrorBlock title={title} message={serviceErrorMessage(error)} />
);
export const EmptyState = ({ title, hint }: { title: string; hint?: ReactNode }) => (
<EmptyBlock title={title} hint={hint} />
);
/** 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-md 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 font-medium capitalize transition-colors ${
value === state ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:text-foreground'
}`}
>
{state}
</button>
))}
</div>
);
@@ -1,265 +0,0 @@
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-xs 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-success/15 text-success">
<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-warning/40 bg-warning/10 p-3 text-xs">
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-warning" />
<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-success" />
) : (
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-warning" />
)}
<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>
);
@@ -1,112 +0,0 @@
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>
);
@@ -1,88 +0,0 @@
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-success/15 text-success ring-1 ring-border">
<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-success/15 px-1.5 py-0.5 text-xs font-medium tabular-nums text-success">
{notifications.length}
</span>
)}
</>
)}
</NavLink>
);
})}
</nav>
</div>
);
};
@@ -1,40 +0,0 @@
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 />;
}
};
@@ -1,26 +0,0 @@
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-muted-foreground">· {version}</span>}
</span>
</>
);
};
@@ -1,330 +0,0 @@
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-xs 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-xs text-muted-foreground">default</span>
)}
</Link>
))}
{!!tags?.length && (
<>
<div className="mt-1 border-t px-2 pb-1 pt-2 text-xs 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-info' : '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>
);
};
@@ -1,224 +0,0 @@
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 { DataRow, RelativeTime } from '@/components/Data';
import { Avatar, EmptyState, ErrorState, Loading } from './GiteaBits';
import { GiteaMarkdown } from './GiteaMarkdown';
import { commitTitle, formatBytes, giteaRepoPath, shortSha } 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) => (
<DataRow
key={commit.sha}
lead={<Avatar user={commit.author} size={24} />}
title={commitTitle(commit.commit.message)}
meta={[
commit.author?.login ?? commit.commit.author?.name ?? 'unknown',
<RelativeTime value={commit.commit.author?.date ?? commit.created} prefix="committed" />,
]}
trail={
<a
href={commit.html_url}
target="_blank"
rel="noreferrer"
className="rounded-md border px-1.5 py-0.5 font-mono text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Open commit in Gitea"
>
{shortSha(commit.sha)}
</a>
}
/>
))}
</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) => (
<DataRow
key={branch.name}
to={giteaRepoPath(owner, name, { tab: 'code', ref: branch.name })}
lead={<GitBranch className="h-4 w-4 text-muted-foreground" />}
title={
<>
<span className="truncate text-sm font-medium">{branch.name}</span>
{branch.name === repo.default_branch && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
default
</span>
)}
{branch.protected && (
<span title="Protected" className="flex shrink-0 items-center">
<Shield className="h-3.5 w-3.5 text-warning" aria-label="Protected" />
</span>
)}
</>
}
description={branch.commit?.message ? commitTitle(branch.commit.message) : undefined}
trail={
<span className="text-xs text-muted-foreground">
<RelativeTime value={branch.commit?.timestamp} />
</span>
}
/>
))}
</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-xs 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-xs">{release.tag_name}</code>
{release.prerelease && (
<span className="rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning">
Pre-release
</span>
)}
{release.draft && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
Draft
</span>
)}
<span className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<RelativeTime value={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-2 text-sm transition-colors hover:bg-muted/50"
>
<Download className="h-3.5 w-3.5 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>
);
@@ -1,227 +0,0 @@
import type { GiteaIssue, GiteaRepo } from './shared';
import { useState } from 'react';
import { Link } from 'react-router';
import { ArrowLeft, ExternalLink, MessageSquare } from 'lucide-react';
import { DataRow, RelativeTime } from '@/components/Data';
import { Avatar, EmptyState, ErrorState, LabelChip, Loading, StateBadge, StateFilter, StateIcon } from './GiteaBits';
import { GiteaMarkdown } from './GiteaMarkdown';
import { giteaRepoPath } 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()}
hint={
state === 'open'
? `Closed ${noun} are hidden by this filter — switch to closed or all to see them.`
: `This repository has no ${noun}.`
}
/>
) : (
<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) => (
<DataRow
to={giteaRepoPath(owner, name, { tab: type === 'pulls' ? 'pulls' : 'issues', item: issue.number })}
lead={<StateIcon item={issue} />}
title={
<>
{/* Truncates rather than wrapping, so labels stay visible and every row keeps one height —
a list of uneven rows cannot be scanned down its left edge. */}
<span className="truncate text-sm font-medium">{issue.title}</span>
{issue.labels?.map((label) => (
<LabelChip key={label.id} label={label} />
))}
</>
}
meta={[
`#${issue.number}`,
<RelativeTime value={issue.created_at} prefix="opened" />,
issue.user && `by ${issue.user.login}`,
]}
trail={
!!issue.comments && (
<span className="flex items-center gap-1.5 text-xs tabular-nums text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
{issue.comments}
</span>
)
}
/>
);
// ── 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 <RelativeTime value={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 <RelativeTime value={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-sm italic text-muted-foreground">{emptyText ?? 'No content.'}</p>
)}
</div>
</div>
);
@@ -1,166 +0,0 @@
import type { Tone } from '@/components/Data';
import type { GiteaChangedFile, GiteaRepo } from './shared';
import { Link } from 'react-router';
import { ArrowLeft, ArrowRight, ExternalLink, FileDiff } from 'lucide-react';
import { RelativeTime, toneText } from '@/components/Data';
import { EmptyState, ErrorState, LabelChip, Loading, StateBadge } from './GiteaBits';
import { CommentCard, RepoIssuesView } from './RepoIssuesView';
import { giteaRepoPath, shortSha } 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 <RelativeTime value={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-success">+{pull.additions}</span>}
{pull.deletions != null && <span className="text-destructive">−{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-success' : 'text-warning'}>
{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 ─────────────────────────────────────────────────────────────────────────────────
/** Tones, not palette numbers — added is good news, removed is destructive, renamed is neither. */
const STATUS_TONE: Record<string, Tone> = {
added: 'success',
modified: 'warning',
removed: 'danger',
renamed: 'info',
};
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-2 text-sm transition-colors hover:bg-muted/50"
>
<span
className={`w-16 shrink-0 text-xs font-medium capitalize ${toneText[STATUS_TONE[file.status] ?? 'neutral']}`}
>
{file.status}
</span>
<span className="min-w-0 flex-1 truncate font-mono">{file.filename}</span>
<span className="shrink-0 text-xs tabular-nums text-success">+{file.additions}</span>
<span className="shrink-0 text-xs tabular-nums text-destructive">−{file.deletions}</span>
</Link>
))}
</div>
</div>
);
@@ -1,141 +0,0 @@
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-2 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
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 leading-snug">
{/* Same weight split as the list row: the owner is context and the name is the subject, so
the two screens agree about what this repository is called. */}
<span className="truncate">
<span className="font-normal text-muted-foreground">{repo.owner?.login ?? ''}/</span>
<span className="font-semibold">{repo.name}</span>
</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-warning" />}
</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-1 text-sm text-muted-foreground">{repo.description}</p>}
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs tabular-nums text-muted-foreground">
{repo.language && <span>{repo.language}</span>}
{!!repo.stars_count && (
<span className="flex items-center gap-1.5">
<Star className="h-3.5 w-3.5" />
{repo.stars_count}
</span>
)}
{!!repo.forks_count && (
<span className="flex items-center gap-1.5">
<GitFork className="h-3.5 w-3.5" />
{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-2 text-sm 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-xs 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-xs 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>
);
};
@@ -1,78 +0,0 @@
import type { LucideIcon } from 'lucide-react';
import type { GiteaRepo } from './shared';
import { Archive, GitFork, Lock, Star } from 'lucide-react';
import { DataList, DataRow, MetaItem, RelativeTime } from '@/components/Data';
import { EmptyState, ErrorState, Loading } from './GiteaBits';
import { giteaRepoPath } 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 (
<DataList>
{repos.map((repo) => (
<RepoRow key={repo.id} repo={repo} />
))}
</DataList>
);
};
/**
* A bare attribute icon. The wrapping span exists for the tooltip — lucide takes no `title`, and an
* unlabelled icon is precisely what the icon language means by "if it needs explanation it is too
* complicated". A tooltip is the cheap repair when the icon has to stay for density.
*/
const Flag = ({ icon: Icon, label, tone }: { icon: LucideIcon; label: string; tone?: string }) => (
<span title={label} className="flex shrink-0 items-center">
<Icon className={`h-3.5 w-3.5 ${tone ?? 'text-muted-foreground'}`} aria-label={label} />
</span>
);
/**
* `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 (
<DataRow
to={giteaRepoPath(owner, repo.name)}
title={
<>
{/* The owner is context, the name is the focal point — one weight per row, so the prefix
recedes rather than competing with the thing being scanned for. */}
<span className="truncate text-sm">
<span className="text-muted-foreground">{owner}/</span>
<span className="font-medium">{repo.name}</span>
</span>
{repo.private && <Flag icon={Lock} label="Private" />}
{repo.fork && <Flag icon={GitFork} label="Fork" />}
{repo.archived && <Flag icon={Archive} label="Archived" tone="text-warning" />}
</>
}
description={repo.description}
meta={[
repo.language,
!!repo.stars_count && <MetaItem icon={Star}>{repo.stars_count}</MetaItem>,
!!repo.forks_count && <MetaItem icon={GitFork}>{repo.forks_count}</MetaItem>,
repo.updated_at && <RelativeTime value={repo.updated_at} prefix="updated" />,
]}
/>
);
};
@@ -1,25 +0,0 @@
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,
},
];
@@ -1,477 +0,0 @@
// 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 an instance can answer with two different origins at once, and the one
* matching the connection URL is not necessarily the good one. Gitea stamps most URLs from its own
* configured `ROOT_URL` — the public address — but some endpoints echo the REQUEST host instead, and
* Officer dials the instance directly rather than through its reverse proxy. So dialling
* `http://localhost:9004` an instance whose `ROOT_URL` is `https://gitea.pastilhas.dev` yields
* `/contents` on the public host and `/user`, `/repos` on loopback. An unconditional rewrite onto the
* connection URL therefore *broke* the good half to match the bad, turning working https links into dead
* loopback ones and tripping mixed content on the way. Verified against the live instance and its
* `app.ini` 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;
}
}
@@ -1,379 +0,0 @@
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),
});
}
@@ -1,46 +0,0 @@
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,
};
}
-10
View File
@@ -49,16 +49,6 @@ export type {
CommandHqCostSummary,
} from './apps/CommandHQ';
// 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.