From 10ff23c5dd25bf7baf691238f8d66fd4432a500a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 6 Aug 2026 17:10:36 +0000 Subject: [PATCH] =?UTF-8?q?gitea:=20the=20app=20=E2=80=94=20repos,=20code,?= =?UTF-8?q?=20issues,=20pulls,=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replicates what Gitea's own web UI offers, on top of the sidecar's /_api pass-through. Repository browsing (tree, file view with the FileViewer's shiki renderer, README), commits, branches, tags, releases, issues and pull requests both per-repo and cross-repo, notifications, explore/search and organizations. Routes are /gitea/:section plus /gitea/repo/:owner/:name/:tab/:item, all real Links with the URL as the source of truth — no selection channel. Markdown is rendered client-side (react-markdown + remark-gfm + rehype-sanitize, rehype-raw deliberately absent) rather than through the instance's /api/v1/markdown, because consuming that means dangerouslySetInnerHTML and there is no DOMPurify in the tree with installs frozen. The cost is Gitea's #123 and @mention cross-references; relative links and images are resolved instead. The /markdown and /markup allow-list entries stay, so that door is open when a sanitiser lands. retargetUrls rebases instance-minted URLs onto a browser-reachable origin, IN ONE DIRECTION ONLY. This instance answers with two: /user and /repos build from its configured ROOT_URL (http://localhost:9004), /contents from the public host. An unconditional rewrite onto the connection URL therefore broke the second set to match the first, turning working https links into dead loopback ones. Only a private/loopback URL is rewritten now, and only when the target is itself public; when the connection URL is a dial address nothing is touched and the connection screen says why avatars will not load. Also carries the frontend half of the one-instance-many-tokens model: the connection form draws a URL field only for the owner and sends no url key at all for anyone else, ServiceConnection.url is string | null to match officerdb, and the rebase origin comes from the resolved instanceUrl rather than connection.url, which is null for a member. Not verified: no runtime pass since the last four changes, the issues and pull views have never rendered a row (the instance has none), and the member path has never executed (one account). Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 5 +- ecosystem.config.cjs | 8 + src/apps/officer-web/App.tsx | 5 + .../Screens/Dashboard/Gitea/GiteaScreen.tsx | 61 +++ .../Screens/Dashboard/Gitea/defaultLayout.ts | 11 + .../Screens/Dashboard/Gitea/index.tsx | 1 + .../Screens/Dashboard/Layout/Dock.tsx | 2 + .../officer-web/Screens/Dashboard/index.tsx | 1 + src/apps/officer-web/state/usePageTitle.ts | 1 + src/servers/api/gitea/router.ts | 16 + src/servers/hono.ts | 2 + src/servers/sidecar/gitea/index.ts | 5 + src/servers/sidecar/protocol.ts | 2 + .../src/AppRegistry/AppRegistry.tsx | 2 + .../src/apps/Gitea/DashboardViews.tsx | 250 +++++++++ .../officerdev/src/apps/Gitea/GiteaBits.tsx | 131 +++++ .../src/apps/Gitea/GiteaConnection.tsx | 265 ++++++++++ .../src/apps/Gitea/GiteaMarkdown.tsx | 112 +++++ .../officerdev/src/apps/Gitea/GiteaNav.tsx | 88 ++++ .../officerdev/src/apps/Gitea/GiteaView.tsx | 40 ++ .../src/apps/Gitea/GiteaViewHeader.tsx | 26 + .../src/apps/Gitea/RepoCodeView.tsx | 332 ++++++++++++ .../src/apps/Gitea/RepoHistoryViews.tsx | 222 ++++++++ .../src/apps/Gitea/RepoIssuesView.tsx | 215 ++++++++ .../src/apps/Gitea/RepoPullsView.tsx | 161 ++++++ .../officerdev/src/apps/Gitea/RepoView.tsx | 136 +++++ .../src/apps/Gitea/RepositoriesView.tsx | 75 +++ .../officerdev/src/apps/Gitea/index.ts | 25 + .../officerdev/src/apps/Gitea/shared.ts | 473 ++++++++++++++++++ .../officerdev/src/apps/Gitea/useGiteaData.ts | 379 ++++++++++++++ .../src/apps/Gitea/useGiteaLocation.ts | 46 ++ .../src/hooks/useServiceConnection.ts | 7 +- src/workspaces/officerdev/src/index.ts | 11 + 33 files changed, 3113 insertions(+), 3 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Gitea/GiteaScreen.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Gitea/defaultLayout.ts create mode 100644 src/apps/officer-web/Screens/Dashboard/Gitea/index.tsx create mode 100644 src/servers/api/gitea/router.ts create mode 100644 src/workspaces/officerdev/src/apps/Gitea/DashboardViews.tsx create mode 100644 src/workspaces/officerdev/src/apps/Gitea/GiteaBits.tsx create mode 100644 src/workspaces/officerdev/src/apps/Gitea/GiteaConnection.tsx create mode 100644 src/workspaces/officerdev/src/apps/Gitea/GiteaMarkdown.tsx create mode 100644 src/workspaces/officerdev/src/apps/Gitea/GiteaNav.tsx create mode 100644 src/workspaces/officerdev/src/apps/Gitea/GiteaView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Gitea/GiteaViewHeader.tsx create mode 100644 src/workspaces/officerdev/src/apps/Gitea/RepoCodeView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Gitea/RepoHistoryViews.tsx create mode 100644 src/workspaces/officerdev/src/apps/Gitea/RepoIssuesView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Gitea/RepoPullsView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Gitea/RepoView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Gitea/RepositoriesView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Gitea/index.ts create mode 100644 src/workspaces/officerdev/src/apps/Gitea/shared.ts create mode 100644 src/workspaces/officerdev/src/apps/Gitea/useGiteaData.ts create mode 100644 src/workspaces/officerdev/src/apps/Gitea/useGiteaLocation.ts diff --git a/CLAUDE.md b/CLAUDE.md index 346a7304..7d9ae012 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,8 +27,9 @@ Long-running and privileged work lives in **sidecars**: separate processes that (`ecosystem.config.cjs`): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`, `officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`, `officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`, -`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin` — nineteen as of -2026-08-04, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the source of truth. +`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea` +— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the +source of truth. **`officer-anthropic-proxy` and `officer-agent` are not the same thing.** The proxy holds the Anthropic credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 130e7018..1475d9a2 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -109,6 +109,14 @@ module.exports = { args: 'run src/servers/sidecar/memos/index.ts', watch: false, }, + // Code hosting. Wraps a self-hosted Gitea. The instance URL and its personal access token are set by + // the owner from /gitea and stored in `service_connections` — read here, never from the environment. + { + name: 'officer-gitea', + script: 'bun', + args: 'run src/servers/sidecar/gitea/index.ts', + watch: false, + }, // Calendar and contacts. Supervises Radicale (CalDAV/CardDAV) on a loopback port and owns the // collections under DATA_PATH/dav. Two doors: /dav for phones (DAVx5, iOS, Thunderbird — HTTP Basic // against a scoped app password) and /api/caldav for Officer's own UI. The protocol is Radicale's; diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index ba6ba6fc..3e6d4a9c 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -52,6 +52,11 @@ export function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Gitea/GiteaScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Gitea/GiteaScreen.tsx new file mode 100644 index 00000000..7dc9e895 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Gitea/GiteaScreen.tsx @@ -0,0 +1,61 @@ +import { useEffect, useMemo } from 'react'; +import { Navigate, useParams } from 'react-router'; +import type { LayoutNode } from 'officerdev'; +import { WorkspaceView, DEFAULT_GITEA_SECTION, giteaSectionPath, isGiteaSection } from 'officerdev'; +import { useDashboardState } from 'state/useDashboardState'; +import { defaultLayout } from './defaultLayout'; + +// /gitea uses the Workspace/Panel system (like /transmission): a section nav on the left and the section view +// on the right. Both talk to the officer-gitea sidecar through the /api/gitea auth proxy, which holds no +// Gitea credentials of its own — the instance URL and the access token live in the sidecar. +// +// The open section is :section in the URL, so the panels read it with useParams instead of passing state +// between themselves over a channel. This screen backs both /gitea and /gitea/:section and is the single +// place that decides what an absent or bogus section means. + +const ALLOWED_APP_TYPES = new Set(['gitea-nav', 'gitea-view', null]); + +function normalizeLayout(node: LayoutNode): LayoutNode { + if (node.type === 'panel') { + return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'gitea-view' }; + } + const children = node.children.map((c) => { + const fixed = normalizeLayout(c.node); + return fixed === c.node ? c : { ...c, node: fixed }; + }); + const changed = children.some((c, i) => c !== node.children[i]); + return changed ? { ...node, children } : node; +} + +export const GiteaScreen = () => { + const { section, owner, name } = useParams(); + const rawWorkspace = useDashboardState('screens/gitea', defaultLayout); + + const workspace = useMemo(() => { + const fixed = normalizeLayout(rawWorkspace.value); + if (fixed === rawWorkspace.value) return rawWorkspace; + return { ...rawWorkspace, value: fixed }; + }, [rawWorkspace]); + + useEffect(() => { + if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { + rawWorkspace.setValue(workspace.value); + } + }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + + // Bare /gitea, or a section that doesn't exist, resolves to a canonical URL rather than rendering a default + // while the address bar says something else — the nav highlight is derived from the URL. + // + // A repository route (/gitea/repo/:owner/:name/…) has no :section at all and is exempt: it is a second, + // deeper shape on the same screen, not a malformed section. + const inRepo = !!owner && !!name; + if (!inRepo && !isGiteaSection(section)) { + return ; + } + + return ( +
+ +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Gitea/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Gitea/defaultLayout.ts new file mode 100644 index 00000000..e94dd054 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Gitea/defaultLayout.ts @@ -0,0 +1,11 @@ +import type { LayoutNode } from 'officerdev'; + +export const defaultLayout: LayoutNode = { + type: 'group', + id: 'gitea-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'gitea-nav', appType: 'gitea-nav' }, size: 22 }, + { node: { type: 'panel', id: 'gitea-view', appType: 'gitea-view' }, size: 78 }, + ], +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Gitea/index.tsx b/src/apps/officer-web/Screens/Dashboard/Gitea/index.tsx new file mode 100644 index 00000000..fe70ac93 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Gitea/index.tsx @@ -0,0 +1 @@ +export * from './GiteaScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index d9ac72e6..72ffacfb 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -142,6 +142,7 @@ import { CalendarDays, Contact, Clapperboard, + GitBranch, } from 'lucide-react'; export const ALL_DOCK_ITEMS: DockItem[] = [ @@ -159,6 +160,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [ { label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' }, { label: 'Wallet', to: '/wallet', icon: Bitcoin, color: '#f7931a' }, { label: 'Invoices', to: '/invoices', icon: Receipt, color: '#0891b2' }, + { label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' }, { label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' }, { label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' }, { label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' }, diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 3fb10608..92759093 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -18,6 +18,7 @@ export * from './Headscale'; export * from './Photos'; export * from './Jellyfin'; export * from './Transmission'; +export * from './Gitea'; export * from './Invoices'; export * from './Wallet'; export * from './SystemMonitor'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 2fd2f403..4897f78e 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -23,6 +23,7 @@ const RULES: TitleRule[] = [ { match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' }, { match: (p) => p.startsWith('/headscale'), title: 'Headscale' }, { match: (p) => p.startsWith('/transmission'), title: 'Transmission' }, + { match: (p) => p.startsWith('/gitea'), title: 'Gitea' }, { match: (p) => p.startsWith('/invoices'), title: 'Invoices' }, { match: (p) => p.startsWith('/wallet'), title: 'Wallet' }, { match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' }, diff --git a/src/servers/api/gitea/router.ts b/src/servers/api/gitea/router.ts new file mode 100644 index 00000000..f6a626d3 --- /dev/null +++ b/src/servers/api/gitea/router.ts @@ -0,0 +1,16 @@ +import { createSidecarProxy } from '../../sidecar/create-proxy'; + +// /api/gitea/* — auth, then forward to officer-gitea. No routes of its own and no Gitea knowledge: +// this file must never grow app logic. +// +// The sidecar owns the Gitea contract and holds the personal access token. The platform knows neither. + +const proxy = createSidecarProxy({ + name: 'gitea', + prefix: '/api/gitea', +}); + +export const giteaRouter = proxy.router; + +/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ +export const getGiteaServerUrl = proxy.getHttpUrl; diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 94a751c6..a2f64233 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -33,6 +33,7 @@ import { vpnRouter } from './api/vpn/router'; import { terminalRouter } from './api/terminal/sidecar-server'; import { caldavRouter } from './api/dav/sidecar-server'; import { memosRouter } from './api/memos/router'; +import { giteaRouter } from './api/gitea/router'; import { davSyncRouter } from './api/dav/sync-router'; import { davRouter } from './api/dav/router'; import { claimIosProfile } from './api/dav/ios-profile'; @@ -164,6 +165,7 @@ protectedRouter.route('/music', musicRouter); protectedRouter.route('/slskd', slskdRouter); protectedRouter.route('/terminal', terminalRouter); protectedRouter.route('/memos', memosRouter); +protectedRouter.route('/gitea', giteaRouter); protectedRouter.route('/caldav', caldavRouter); // the JSON door for Officer's own calendar/contacts UI protectedRouter.route('/dav', davRouter); // app-password management (the sync door is /dav, top-level) protectedRouter.route('/notify', notifyRouter); diff --git a/src/servers/sidecar/gitea/index.ts b/src/servers/sidecar/gitea/index.ts index 93421375..5ac8bc3e 100644 --- a/src/servers/sidecar/gitea/index.ts +++ b/src/servers/sidecar/gitea/index.ts @@ -54,6 +54,11 @@ const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '50 const ALLOWED = [ /^\/api\/v1\/version(\/|$|\?)/, /^\/api\/v1\/user(\/|$|\?)/, + // Public profile data. Note this prefix also covers `/users/{name}/tokens`, which lists and CREATES + // access tokens — a token-minting door if it were reachable. It is not: Gitea requires basic auth there + // precisely to stop a token escalating its own scopes, and answers `401 auth required` to the + // `Authorization: token …` this sidecar sends. Verified against 1.27.0 on 2026-08-06. That protection is + // GITEA's, not ours, so if this ever needs re-checking on an upgrade, that is the endpoint to re-check. /^\/api\/v1\/users(\/|$|\?)/, /^\/api\/v1\/orgs(\/|$|\?)/, /^\/api\/v1\/org(\/|$|\?)/, diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index be72092d..3974fcdf 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -77,6 +77,8 @@ export type SidecarEvent = | { type: 'photos:server'; port: number } // Memos — the sidecar reports where its HTTP server is listening (random port) on connect | { type: 'memos:server'; port: number } + // Gitea — the sidecar reports where its HTTP server is listening (random port) on connect + | { type: 'gitea:server'; port: number } // CalDAV/CardDAV — the sidecar reports where its HTTP server is listening (random port) on connect. // One port serves both doors: /dav (forwarded to Radicale) and /_officer (JSON for Officer's UI). | { type: 'caldav:server'; port: number } diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index c95f2dfe..6623059e 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -13,6 +13,7 @@ import { appRegistryMetas as headscaleMetas } from '../apps/Headscale'; import { appRegistryMetas as photosMetas } from '../apps/Photos'; import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin'; import { appRegistryMetas as transmissionMetas } from '../apps/Transmission'; +import { appRegistryMetas as giteaMetas } from '../apps/Gitea'; import { appRegistryMetas as invoicesMetas } from '../apps/Invoices'; import { appRegistryMetas as walletMetas } from '../apps/Wallet'; import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor'; @@ -36,6 +37,7 @@ const apps = [ ...photosMetas, ...jellyfinMetas, ...transmissionMetas, + ...giteaMetas, ...invoicesMetas, ...walletMetas, ...monitorMetas, diff --git a/src/workspaces/officerdev/src/apps/Gitea/DashboardViews.tsx b/src/workspaces/officerdev/src/apps/Gitea/DashboardViews.tsx new file mode 100644 index 00000000..43a73550 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Gitea/DashboardViews.tsx @@ -0,0 +1,250 @@ +import type { GiteaIssue, GiteaNotification } from './shared'; +import { useState } from 'react'; +import { Link } from 'react-router'; +import { Bell, Building2, ExternalLink, MessageSquare, Search } from 'lucide-react'; +import { EmptyState, ErrorState, LabelChip, Loading, StateFilter, StateIcon } from './GiteaBits'; +import { RepoRow } from './RepositoriesView'; +import { giteaRepoPath, timeAgo } from './shared'; +import { useGiteaIssueSearch, useGiteaNotifications, useGiteaOrgs, useGiteaRepoSearch } from './useGiteaData'; + +// The cross-repository sections — the ones Gitea puts in its own header rather than inside a repository: +// your issues, your pull requests, your notifications, explore, and the orgs you belong to. + +// ── Issues / pull requests across every repo ────────────────────────────────────────────────────── + +type CrossRepoProps = { type: 'issues' | 'pulls' }; + +export const CrossRepoIssuesView = ({ type }: CrossRepoProps) => { + const [state, setState] = useState<'open' | 'closed' | 'all'>('open'); + const { data: issues, isLoading, error } = useGiteaIssueSearch({ state, type }); + const noun = type === 'pulls' ? 'pull requests' : 'issues'; + + return ( +
+
+ + {!!issues?.length && ( + {issues.length} across all repositories + )} +
+
+ {isLoading ? ( + + ) : error ? ( + + ) : !issues?.length ? ( + + ) : ( +
+ {issues.map((issue) => ( + + ))} +
+ )} +
+
+ ); +}; + +/** + * The cross-repo list is the one place an issue does not already know which repository it is in from the + * URL, so the row carries `repository` — and links through it, which is why that field is read rather than + * the issue's own html_url. + */ +const CrossRepoRow = ({ issue, type }: { issue: GiteaIssue; type: 'issues' | 'pulls' }) => { + const owner = issue.repository?.owner ?? ''; + const name = issue.repository?.name ?? ''; + const to = owner && name ? giteaRepoPath(owner, name, { tab: type, item: issue.number }) : null; + + const body = ( + <> + + + +
+
+ {issue.title} + {issue.labels?.map((label) => ( + + ))} +
+

+ {issue.repository?.full_name ?? 'unknown'} #{issue.number} · opened {timeAgo(issue.created_at)} + {issue.user && ` by ${issue.user.login}`} +

+
+ {!!issue.comments && ( + + + {issue.comments} + + )} + + ); + + const className = 'flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/50'; + // Without a repository on the payload there is nothing to route to, so the row stays a plain div rather + // than a link that goes somewhere wrong. + return to ? ( + + {body} + + ) : ( +
{body}
+ ); +}; + +// ── 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 ; + if (error) return ; + if (!notifications?.length) { + return ; + } + + return ( +
+
+ {notifications.map((notification) => { + const to = subjectRoute(notification); + const inner = ( + <> + +
+

{notification.subject.title}

+

+ {notification.repository?.full_name ?? ''} · {notification.subject.type} ·{' '} + {timeAgo(notification.updated_at)} +

+
+ + ); + const className = 'flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/50'; + + return to ? ( + + {inner} + + ) : ( + + {inner} + + + ); + })} +
+
+ ); +}; + +// ── Explore ─────────────────────────────────────────────────────────────────────────────────────── + +export const ExploreView = () => { + const [input, setInput] = useState(''); + const [query, setQuery] = useState(''); + const { data, isLoading, error } = useGiteaRepoSearch(query); + + return ( +
+
{ + ev.preventDefault(); + setQuery(input.trim()); + }} + > +
+ + 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" + /> +
+ +
+ +
+ {isLoading ? ( + + ) : error ? ( + + ) : !data?.data?.length ? ( + + ) : ( +
+ {data.data.map((repo) => ( + + ))} +
+ )} +
+
+ ); +}; + +// ── Organizations ───────────────────────────────────────────────────────────────────────────────── + +export const OrganizationsView = () => { + const { data: orgs, isLoading, error } = useGiteaOrgs(); + + if (isLoading) return ; + if (error) return ; + if (!orgs?.length) return ; + + return ( +
+
+ {orgs.map((org) => ( +
+ {org.avatar_url ? ( + + ) : ( + + + + )} +
+

{org.full_name || org.username}

+ {org.description &&

{org.description}

} +

+ @{org.username} + {org.visibility && ` · ${org.visibility}`} + {org.location && ` · ${org.location}`} +

+
+
+ ))} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Gitea/GiteaBits.tsx b/src/workspaces/officerdev/src/apps/Gitea/GiteaBits.tsx new file mode 100644 index 00000000..1b23dcbe --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Gitea/GiteaBits.tsx @@ -0,0 +1,131 @@ +import type { ReactNode } from 'react'; +import type { GiteaIssue, GiteaLabel, GiteaPullRequest, GiteaUser } from './shared'; +import { + CircleDot, + CircleCheck, + GitMerge, + GitPullRequest, + GitPullRequestClosed, + Loader2, + TriangleAlert, +} from 'lucide-react'; +import { serviceErrorMessage } from '../../hooks/useServiceConnection'; +import { labelTextColor } from './shared'; + +// The small pieces every Gitea view repeats: an avatar, a label chip, a state pill, and the three +// non-content states (loading / failed / empty). Kept together so the repo, issue and PR screens look +// like one app rather than three, and so a change to "what an error looks like" is one edit. + +export const Avatar = ({ user, size = 20 }: { user?: GiteaUser; size?: number }) => { + const initial = (user?.full_name || user?.login || '?').charAt(0).toUpperCase(); + if (!user?.avatar_url) { + return ( + + {initial} + + ); + } + return ( + {user.login} + ); +}; + +export const LabelChip = ({ label }: { label: GiteaLabel }) => ( + + {label.name} + +); + +/** + * The open/closed/merged pill. Merged is a genuinely distinct state from closed and Gitea colours it + * differently — a merged PR is not a rejected one — so it is worth the extra branch here. + */ +export const StateBadge = ({ item }: { item: GiteaIssue | GiteaPullRequest }) => { + const isPull = 'base' in item || !!(item as GiteaIssue).pull_request; + const merged = (item as GiteaPullRequest).merged || (item as GiteaIssue).pull_request?.merged; + const open = item.state === 'open'; + + const [Icon, text, classes] = merged + ? [GitMerge, 'Merged', 'bg-purple-500/15 text-purple-500'] + : open + ? isPull + ? [GitPullRequest, 'Open', 'bg-emerald-500/15 text-emerald-500'] + : [CircleDot, 'Open', 'bg-emerald-500/15 text-emerald-500'] + : isPull + ? [GitPullRequestClosed, 'Closed', 'bg-red-500/15 text-red-500'] + : [CircleCheck, 'Closed', 'bg-purple-500/15 text-purple-500']; + + return ( + + + {text} + + ); +}; + +/** The open/closed marker in a dense list, where the full pill is too much furniture. */ +export const StateIcon = ({ item }: { item: GiteaIssue | GiteaPullRequest }) => { + const isPull = 'base' in item || !!(item as GiteaIssue).pull_request; + const merged = (item as GiteaPullRequest).merged || (item as GiteaIssue).pull_request?.merged; + if (merged) return ; + if (item.state === 'open') { + const Icon = isPull ? GitPullRequest : CircleDot; + return ; + } + const Icon = isPull ? GitPullRequestClosed : CircleCheck; + return ; +}; + +export const Loading = ({ label = 'Loading…' }: { label?: string }) => ( +
+ {label} +
+); + +export const ErrorState = ({ title, error }: { title: string; error: unknown }) => ( +
+ +

{title}

+

{serviceErrorMessage(error)}

+
+); + +export const EmptyState = ({ title, hint }: { title: string; hint?: ReactNode }) => ( +
+

{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) => ( +
+ {(['open', 'closed', 'all'] as const).map((state) => ( + + ))} +
+); diff --git a/src/workspaces/officerdev/src/apps/Gitea/GiteaConnection.tsx b/src/workspaces/officerdev/src/apps/Gitea/GiteaConnection.tsx new file mode 100644 index 00000000..1a6abd6a --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Gitea/GiteaConnection.tsx @@ -0,0 +1,265 @@ +import type { ServiceConnection } from '../../hooks/useServiceConnection'; +import { useEffect, useState } from 'react'; +import { CheckCircle2, ExternalLink, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { serviceErrorMessage, useServiceConnectionActions, useServiceHealth } from '../../hooks/useServiceConnection'; +import { isUnreachableFromBrowser } from './shared'; +import { useGiteaConnection } from './useGiteaData'; + +// Connecting a Gitea instance to Officer, from the app. +// +// Both the setup wizard and the permanent settings page: GiteaView renders it in place of whatever section +// the nav asked for while nothing is connected, and the Connection section renders it for good. One +// component, so re-pointing at a different instance later goes through exactly the code path that stored +// the first one. +// +// The token is write-only — the GET it reads has no field that could carry one back, so the input is always +// blank and an empty input means "keep the stored token". That is what lets the URL be corrected without +// the token being re-pasted. +// +// TWO FORMS, not one. There is one instance and a token per person: the server owner names the instance, +// everyone else supplies only their own token against it. The sidecar enforces that — a `url` from a member +// is answered 403, not ignored — so this must not send one. The rule is upstream of the UI on purpose; what +// happens here is only that a field nobody is allowed to fill in is not drawn. + +const HINT = 'text-[11px] leading-relaxed text-muted-foreground'; + +/** Gitea's own default HTTP port. Officer dials it from the server, not from this browser. */ +const DEFAULT_URL = 'http://localhost:3000'; + +const URL_HINT = + "The instance's base URL, without /api. Officer reaches it from the server, not from this browser — so " + + 'localhost here means the machine Officer runs on.'; + +const MEMBER_URL_HINT = + 'The instance this server is connected to. Only the server owner can change it — your half of this is the token.'; + +const TOKEN_HINT = + 'A personal access token from the instance: Settings → Applications → Generate Token. Officer sends it ' + + 'as `Authorization: token …` on every call. Scopes read:user and read:repository are enough to browse; ' + + 'grant write only if you want Officer to change things.'; + +type SaveInput = Record & { token: string }; + +export const GiteaConnection = () => { + const { data, isLoading } = useGiteaConnection(); + const { data: health } = useServiceHealth('gitea'); + const { save, forget } = useServiceConnectionActions('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 ( +
+ Loading… +
+ ); + } + + // 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 ( +
+
+
+
+ +
+
+

{isOwner ? 'Gitea instance' : 'Your Gitea account'}

+

+ {!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.'} +

+
+
+ + {connection && } + + {connection && dialOnly && ( +
+ +
+
Avatars and links will not load
+

+ 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.'} +

+
+
+ )} + +
+ {isOwner ? ( + + ) : ( +
+ Instance +
+ {instanceUrl ?? 'Not connected yet'} +
+ {MEMBER_URL_HINT} +
+ )} + + + + {error && ( +
+ + {error} +
+ )} + +
+ + {connection && ( + + )} +
+
+
+
+ ); +}; + +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) => ( +
+ {health?.ok ? ( + + ) : ( + + )} +
+
{health?.ok ? 'Connected' : 'Not responding'}
+
+ {connection.url ?? instanceUrl ?? '—'} + {connection.version ? ` · Gitea ${connection.version}` : ''} +
+ {!health?.ok && health?.error &&
{health.error}
} +
+
+); diff --git a/src/workspaces/officerdev/src/apps/Gitea/GiteaMarkdown.tsx b/src/workspaces/officerdev/src/apps/Gitea/GiteaMarkdown.tsx new file mode 100644 index 00000000..cc4d2f0b --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Gitea/GiteaMarkdown.tsx @@ -0,0 +1,112 @@ +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeSanitize from 'rehype-sanitize'; +import { CodeRenderer } from '../FileViewer/renderers'; + +// Markdown for anything the instance hands back — a README, an issue body, a release note. +// +// ───────────────────────────────────────────────────────────────────────────────────────────────── +// WHY THIS IS NOT `/api/v1/markdown` +// +// Gitea's own web UI renders markdown server-side and it does it better than this can: it resolves +// #123 issue references, @mentions and relative links against the repository, and it sanitises the +// result with bluemonday before returning it. Replicating the upstream's own rendering is the house +// rule, and the sidecar's allow-list carries `/markdown` and `/markup` so that door stays open. +// +// It is not used HERE because consuming it means putting instance-returned HTML through +// `dangerouslySetInnerHTML`, and this app has no second line of defence to put behind that: there is +// no DOMPurify, sanitize-html or xss in the tree, and installs are frozen, so adding one is a +// deliberate lockfile change and not something to slip into a feature. That would leave the owner's +// authenticated session trusting a remote server's sanitiser and nothing else — and Officer's whole +// perimeter is that one session. +// +// So: parse the markdown here instead, where `rehype-sanitize` runs on an AST that never contains raw +// HTML in the first place (note `rehype-raw` is deliberately ABSENT — with it, `