adopt the design language across gitea's lists

Gitea goes first because it is the first Officer surface anyone outside this
machine will see.

GiteaBits is now a thin adapter over components/Data rather than its own set of
styles: it translates Gitea's vocabulary into the five tones and gets out of the
way. Export names are unchanged so the ten call sites did not all have to move
at once.

The one judgement call worth flagging is CLOSED ISSUE -> neutral instead of a
colour of its own. Most issues in any list are closed, so 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 scans an
issue list for. A closed PULL request keeps danger — that one was rejected, and
rejection is a real outcome. Merged is info, because a merged PR and an open one
were previously the same green.

Repositories and issues now build on DataRow. Repo rows put the owner prefix in
muted and only the name in medium, so one weight per row survives; issue rows
truncate rather than wrap so every row keeps one height and the list can be
scanned down its left edge. Timestamps are RelativeTime, so "2 months ago" now
carries the exact date as a hover title instead of losing it.

Swept the whole app for drift: emerald/amber/sky/red palette numbers to
success/warning/info/destructive, and every text-[10px] and text-[11px] to
text-xs. ring-black/5 and text-black/50 are gone — both were invisible in dark
mode. Nothing below 12px remains anywhere in Gitea.

Empty states say why they are empty now. "No open issues" on a repo with fifty
closed ones was technically true and useless.

Typechecks clean. Still not rendered in a browser — needs pm2 restart officer
and a hard refresh. text-xs is still 65 uses against 21 text-sm; the remaining
pass is per-case judgement about which of those are meta and which are content
that should be readable, and it is not a sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 21:20:15 +00:00
co-authored by Claude Opus 5
parent 88652910d7
commit 9877a1d8e0
11 changed files with 174 additions and 170 deletions
@@ -129,7 +129,7 @@ export const NotificationsView = () => {
const to = subjectRoute(notification);
const inner = (
<>
<Bell className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
<Bell className="mt-0.5 h-4 w-4 shrink-0 text-success" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{notification.subject.title}</p>
<p className="text-xs text-muted-foreground">
@@ -236,7 +236,7 @@ export const OrganizationsView = () => {
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{org.full_name || org.username}</p>
{org.description && <p className="truncate text-xs text-muted-foreground">{org.description}</p>}
<p className="mt-0.5 text-[11px] text-muted-foreground">
<p className="mt-0.5 text-xs text-muted-foreground">
@{org.username}
{org.visibility && ` · ${org.visibility}`}
{org.location && ` · ${org.location}`}
@@ -1,28 +1,47 @@
import type { Tone } from '@/components/Data';
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 { 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: 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.
// 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-[10px] font-medium text-muted-foreground"
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>
@@ -32,6 +51,7 @@ export const Avatar = ({ user, size = 20 }: { user?: GiteaUser; size?: number })
<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"
@@ -39,9 +59,13 @@ export const Avatar = ({ user, size = 20 }: { user?: GiteaUser; size?: number })
);
};
/**
* 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-[10px] font-medium leading-none"
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}
>
@@ -49,79 +73,43 @@ export const LabelChip = ({ label }: { label: GiteaLabel }) => (
</span>
);
/**
* The open/closed/merged pill. Merged is a genuinely distinct state from closed and Gitea colours it
* differently — a merged PR is not a rejected one — so it is worth the extra branch here.
*/
export const StateBadge = ({ item }: { item: GiteaIssue | GiteaPullRequest }) => {
const isPull = 'base' in item || !!(item as GiteaIssue).pull_request;
const merged = (item as GiteaPullRequest).merged || (item as GiteaIssue).pull_request?.merged;
const open = item.state === 'open';
const [Icon, text, classes] = merged
? [GitMerge, 'Merged', 'bg-purple-500/15 text-purple-500']
: open
? isPull
? [GitPullRequest, 'Open', 'bg-emerald-500/15 text-emerald-500']
: [CircleDot, 'Open', 'bg-emerald-500/15 text-emerald-500']
: isPull
? [GitPullRequestClosed, 'Closed', 'bg-red-500/15 text-red-500']
: [CircleCheck, 'Closed', 'bg-purple-500/15 text-purple-500'];
const { tone, icon, text } = stateOf(item);
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${classes}`}>
<Icon className="h-3 w-3" />
<StatusPill tone={tone} icon={icon}>
{text}
</span>
</StatusPill>
);
};
/** The open/closed marker in a dense list, where the full pill is too much furniture. */
/** The state marker for a dense list, where the full pill is too much furniture. */
export const StateIcon = ({ item }: { item: GiteaIssue | GiteaPullRequest }) => {
const isPull = 'base' in item || !!(item as GiteaIssue).pull_request;
const merged = (item as GiteaPullRequest).merged || (item as GiteaIssue).pull_request?.merged;
if (merged) return <GitMerge className="h-4 w-4 shrink-0 text-purple-500" />;
if (item.state === 'open') {
const Icon = isPull ? GitPullRequest : CircleDot;
return <Icon className="h-4 w-4 shrink-0 text-emerald-500" />;
}
const Icon = isPull ? GitPullRequestClosed : CircleCheck;
return <Icon className={`h-4 w-4 shrink-0 ${isPull ? 'text-red-500' : 'text-purple-500'}`} />;
const { tone, icon, text } = stateOf(item);
return <StatusIcon tone={tone} icon={icon} title={text} />;
};
export const Loading = ({ label = 'Loading…' }: { label?: string }) => (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {label}
</div>
);
export const Loading = ({ label = 'Loading…' }: { label?: string }) => <LoadingBlock label={label} />;
export const ErrorState = ({ title, error }: { title: string; error: unknown }) => (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<TriangleAlert className="h-5 w-5 text-amber-500" />
<p className="text-sm font-medium">{title}</p>
<p className="max-w-sm text-xs text-muted-foreground">{serviceErrorMessage(error)}</p>
</div>
<ErrorBlock title={title} message={serviceErrorMessage(error)} />
);
export const EmptyState = ({ title, hint }: { title: string; hint?: ReactNode }) => (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<p className="text-sm font-medium">{title}</p>
{hint && <p className="max-w-sm text-xs text-muted-foreground">{hint}</p>}
</div>
<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-lg border p-0.5 text-xs">
<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 capitalize transition-colors ${
value === state ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:text-foreground'
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}
@@ -23,7 +23,7 @@ import { useGiteaConnection } from './useGiteaData';
// 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';
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';
@@ -121,7 +121,7 @@ export const GiteaConnection = () => {
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-xl flex-col gap-6 p-6">
<header className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-emerald-500/15 text-emerald-500">
<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>
@@ -141,8 +141,8 @@ export const GiteaConnection = () => {
{connection && <StatusRow connection={connection} instanceUrl={instanceUrl} health={health} />}
{connection && dialOnly && (
<div className="flex items-start gap-2 rounded-xl border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-amber-500" />
<div className="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">
@@ -249,9 +249,9 @@ type StatusRowProps = {
const StatusRow = ({ connection, instanceUrl, health }: StatusRowProps) => (
<div className="flex items-start gap-2 rounded-xl border p-3 text-xs">
{health?.ok ? (
<CheckCircle2 className="mt-px h-4 w-4 shrink-0 text-emerald-500" />
<CheckCircle2 className="mt-px h-4 w-4 shrink-0 text-success" />
) : (
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-amber-500" />
<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>
@@ -34,7 +34,7 @@ export const GiteaNav = () => {
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-500/15 text-emerald-500 ring-1 ring-black/5">
<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">
@@ -73,7 +73,7 @@ export const GiteaNav = () => {
<span className="text-xs tabular-nums text-muted-foreground">{repos.length}</span>
)}
{id === 'notifications' && !!notifications?.length && (
<span className="rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-emerald-500">
<span className="rounded-full bg-success/15 px-1.5 py-0.5 text-xs font-medium tabular-nums text-success">
{notifications.length}
</span>
)}
@@ -19,7 +19,7 @@ export const GiteaViewHeader = () => {
<GitBranch className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-xs font-medium">
{label}
{version && <span className="ml-1.5 font-normal text-black/50">· {version}</span>}
{version && <span className="ml-1.5 font-normal text-muted-foreground">· {version}</span>}
</span>
</>
);
@@ -75,9 +75,7 @@ const RefPicker = ({ owner, name, repo, path, refName }: RefPickerProps) => {
</button>
{open && (
<div className="absolute left-0 top-full z-20 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border bg-popover p-1 shadow-lg">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Branches
</div>
<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}
@@ -89,13 +87,13 @@ const RefPicker = ({ owner, name, repo, path, refName }: RefPickerProps) => {
<GitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="truncate">{branch.name}</span>
{branch.name === repo.default_branch && (
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">default</span>
<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-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
<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) => (
@@ -217,7 +215,7 @@ const EntryRow = ({ entry, owner, name, refName }: EntryRowProps) => {
to={giteaRepoPath(owner, name, { tab: 'code', path: entry.path, ref: refName })}
className="flex items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-muted/50"
>
<Icon className={`h-4 w-4 shrink-0 ${isDir ? 'text-sky-500' : 'text-muted-foreground'}`} />
<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">
@@ -44,7 +44,7 @@ export const RepoCommitsView = ({ owner, name, refName }: TabProps) => {
href={commit.html_url}
target="_blank"
rel="noreferrer"
className="shrink-0 rounded border px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
className="shrink-0 rounded 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)}
@@ -102,11 +102,11 @@ export const RepoBranchesView = ({ repo, owner, name }: TabProps) => {
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{branch.name}</span>
{branch.name === repo.default_branch && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-xs font-medium text-primary">
default
</span>
)}
{branch.protected && <Shield className="h-3 w-3 shrink-0 text-amber-500" />}
{branch.protected && <Shield className="h-3 w-3 shrink-0 text-warning" />}
</div>
{branch.commit?.message && (
<p className="truncate text-xs text-muted-foreground">{commitTitle(branch.commit.message)}</p>
@@ -129,9 +129,7 @@ export const RepoBranchesView = ({ repo, owner, name }: TabProps) => {
>
<Tag className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm">{tag.name}</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
{shortSha(tag.commit?.sha)}
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">{shortSha(tag.commit?.sha)}</span>
</Link>
))}
</div>
@@ -160,14 +158,14 @@ export const RepoReleasesView = ({ repo, owner, name }: TabProps) => {
<div className="flex flex-wrap items-center gap-2 border-b bg-muted/40 px-3 py-2">
<Package className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-sm font-medium">{release.name || release.tag_name}</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">{release.tag_name}</code>
<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-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-500">
<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-[10px] font-medium text-muted-foreground">
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
Draft
</span>
)}
@@ -2,9 +2,10 @@ 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, timeAgo } from './shared';
import { giteaRepoPath } from './shared';
import { useGiteaIssue, useGiteaIssueComments, useGiteaRepoIssues } from './useGiteaData';
// Issues for one repository — the list, and one issue with its comment timeline.
@@ -59,7 +60,14 @@ const IssueList = ({ owner, name, type }: ListProps) => {
) : error ? (
<ErrorState title={`Could not load ${noun}`} error={error} />
) : !issues?.length ? (
<EmptyState title={`No ${state === 'all' ? '' : state} ${noun}`.replace(/\s+/g, ' ').trim()} />
<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) => (
@@ -75,32 +83,33 @@ const IssueList = ({ owner, name, type }: ListProps) => {
type RowProps = { issue: GiteaIssue; owner: string; name: string; type: 'issues' | 'pulls' };
const IssueRow = ({ issue, owner, name, type }: RowProps) => (
<Link
<DataRow
to={giteaRepoPath(owner, name, { tab: type === 'pulls' ? 'pulls' : 'issues', item: issue.number })}
className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/50"
>
<span className="pt-0.5">
<StateIcon item={issue} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
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} />
))}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
#{issue.number} opened {timeAgo(issue.created_at)}
{issue.user && ` by ${issue.user.login}`}
</p>
</div>
{!!issue.comments && (
<span className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-muted-foreground">
<MessageSquare className="h-3 w-3" />
{issue.comments}
</span>
)}
</Link>
</>
}
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 ────────────────────────────────────────────────────────────────────────────────────────
@@ -169,7 +178,8 @@ const IssueHeader = ({ issue, owner, name, type }: HeaderProps) => (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<StateBadge item={issue} />
<span>
{issue.user?.login ?? 'someone'} opened this {timeAgo(issue.created_at)} · {issue.comments} comments
{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} />
@@ -202,13 +212,15 @@ export const CommentCard = ({ author, createdAt, body, repo, emptyText }: Commen
<div className="flex items-center gap-2 border-b bg-muted/40 px-3 py-2 text-xs">
<Avatar user={author} size={18} />
<span className="font-medium">{author?.login ?? 'unknown'}</span>
<span className="text-muted-foreground">commented {timeAgo(createdAt)}</span>
<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-xs italic text-muted-foreground">{emptyText ?? 'No content.'}</p>
<p className="text-sm italic text-muted-foreground">{emptyText ?? 'No content.'}</p>
)}
</div>
</div>
@@ -1,9 +1,11 @@
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, timeAgo } from './shared';
import { giteaRepoPath, shortSha } from './shared';
import { useGiteaIssueComments, useGiteaPull, useGiteaPullFiles } from './useGiteaData';
// Pull requests for one repository.
@@ -62,7 +64,7 @@ const PullDetail = ({ repo, owner, name, index }: DetailProps) => {
<StateBadge item={pull} />
{pull.draft && <span className="rounded-full bg-muted px-2 py-0.5 font-medium">Draft</span>}
<span>
{pull.user?.login ?? 'someone'} wants to merge {timeAgo(pull.created_at)}
{pull.user?.login ?? 'someone'} wants to merge <RelativeTime value={pull.created_at} />
</span>
{pull.labels?.map((label) => (
<LabelChip key={label.id} label={label} />
@@ -83,11 +85,11 @@ const PullDetail = ({ repo, owner, name, index }: DetailProps) => {
{pull.changed_files} files
</span>
)}
{pull.additions != null && <span className="text-emerald-500">+{pull.additions}</span>}
{pull.deletions != null && <span className="text-red-500">{pull.deletions}</span>}
{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-emerald-500' : 'text-amber-500'}>
<span className={pull.mergeable ? 'text-success' : 'text-warning'}>
{pull.mergeable ? 'No conflicts' : 'Conflicts'}
</span>
)}
@@ -121,11 +123,12 @@ const PullDetail = ({ repo, owner, name, index }: DetailProps) => {
// ── Changed files ─────────────────────────────────────────────────────────────────────────────────
const STATUS_COLOR: Record<string, string> = {
added: 'text-emerald-500',
modified: 'text-amber-500',
removed: 'text-red-500',
renamed: 'text-sky-500',
/** 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 };
@@ -146,14 +149,16 @@ const ChangedFiles = ({ files, owner, name, headRef }: ChangedFilesProps) => (
<Link
key={file.filename}
to={giteaRepoPath(owner, name, { tab: 'code', path: file.filename, ref: headRef })}
className="flex items-center gap-3 px-3 py-1.5 text-xs transition-colors hover:bg-muted/50"
className="flex items-center gap-3 px-3 py-2 text-sm transition-colors hover:bg-muted/50"
>
<span className={`w-16 shrink-0 capitalize ${STATUS_COLOR[file.status] ?? 'text-muted-foreground'}`}>
<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 tabular-nums text-emerald-500">+{file.additions}</span>
<span className="shrink-0 tabular-nums text-red-500">{file.deletions}</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>
@@ -43,7 +43,7 @@ export const RepoView = () => {
<span className="truncate">{repo.full_name}</span>
{repo.private && <Lock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
{repo.fork && <GitFork className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
{repo.archived && <Archive className="h-3.5 w-3.5 shrink-0 text-amber-500" />}
{repo.archived && <Archive className="h-3.5 w-3.5 shrink-0 text-warning" />}
</h2>
<a
href={repo.html_url}
@@ -58,7 +58,7 @@ export const RepoView = () => {
{repo.description && <p className="mt-0.5 text-xs text-muted-foreground">{repo.description}</p>}
<div className="mt-1 flex flex-wrap items-center gap-3 text-[11px] tabular-nums text-muted-foreground">
<div className="mt-1 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-0.5">
@@ -102,12 +102,12 @@ export const RepoView = () => {
>
{entry.label}
{entry.id === 'issues' && !!repo.open_issues_count && (
<span className="ml-1.5 rounded-full bg-muted px-1.5 py-0.5 text-[10px] tabular-nums">
<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-[10px] tabular-nums">
<span className="ml-1.5 rounded-full bg-muted px-1.5 py-0.5 text-xs tabular-nums">
{repo.open_pr_counter}
</span>
)}
@@ -1,8 +1,9 @@
import type { LucideIcon } from 'lucide-react';
import type { GiteaRepo } from './shared';
import { Link } from 'react-router';
import { Archive, GitFork, Lock, Star } from 'lucide-react';
import { DataList, DataRow, MetaItem, RelativeTime } from '@/components/Data';
import { EmptyState, ErrorState, Loading } from './GiteaBits';
import { giteaRepoPath, timeAgo } from './shared';
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,
@@ -23,16 +24,25 @@ export const RepositoriesView = () => {
}
return (
<div className="h-full overflow-y-auto">
<div className="flex flex-col divide-y">
{repos.map((repo) => (
<RepoRow key={repo.id} repo={repo} />
))}
</div>
</div>
<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.
@@ -41,35 +51,28 @@ export const RepoRow = ({ repo }: { repo: GiteaRepo }) => {
const owner = repo.owner?.login ?? repo.full_name.split('/')[0] ?? '';
return (
<Link
<DataRow
to={giteaRepoPath(owner, repo.name)}
className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/50"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{repo.full_name}</span>
{repo.private && <Lock className="h-3 w-3 shrink-0 text-muted-foreground" />}
{repo.fork && <GitFork className="h-3 w-3 shrink-0 text-muted-foreground" />}
{repo.archived && <Archive className="h-3 w-3 shrink-0 text-amber-500" />}
</div>
{repo.description && <p className="truncate text-xs text-muted-foreground">{repo.description}</p>}
<div className="mt-1 flex items-center gap-3 text-[11px] tabular-nums text-muted-foreground">
{repo.language && <span>{repo.language}</span>}
{!!repo.stars_count && (
<span className="flex items-center gap-0.5">
<Star className="h-3 w-3" />
{repo.stars_count}
</span>
)}
{!!repo.forks_count && (
<span className="flex items-center gap-0.5">
<GitFork className="h-3 w-3" />
{repo.forks_count}
</span>
)}
{repo.updated_at && <span>{timeAgo(repo.updated_at)}</span>}
</div>
</div>
</Link>
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" />,
]}
/>
);
};