carry the design language through gitea's remaining views

Commits, branches, cross-repo issues, notifications and organizations all move
onto DataRow, which is what makes them agree with each other — five lists that
were each hand-assembled from the same flex/gap/truncate parts now genuinely
share one row.

The repo header gets the same owner-muted / name-semibold split as the list row,
so the two screens agree about what a repository is called. Its description goes
from 12px to 14px, and the tab strip with it — those are read, not scanned, and
they were the smallest text on the busiest screen.

DataRow gains `href` for destinations outside the app. Notifications needed it:
Gitea's subject URL can usually be parsed back into an in-app route and
sometimes cannot, and the row should stay clickable either way rather than
becoming a dead div on the payloads that do not parse.

Empty states across the dashboard now say what the search actually covers.
"No open issues" was hiding that Gitea's cross-repo search only ever looks at
issues you created, are assigned, or are mentioned in — which is the difference
between a quiet week and a misconfigured token.

Typechecks clean and prettier is clean. Still not rendered in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 21:22:57 +00:00
co-authored by Claude Opus 5
parent 9877a1d8e0
commit 143a6453d3
4 changed files with 154 additions and 134 deletions
+22 -2
View File
@@ -27,14 +27,27 @@ type DataRowProps = {
trail?: ReactNode;
/** Makes the whole row a link. Rows are real links so cmd-click and the back button work. */
to?: string;
/** Same, for a destination outside the app. Opens in a new tab; ignored when `to` is set. */
href?: string;
onClick?: () => void;
/** Marks the row as the current selection when the list is a master beside a detail pane. */
selected?: boolean;
className?: string;
};
export const DataRow = ({ title, description, meta, lead, trail, to, onClick, selected, className }: DataRowProps) => {
const interactive = !!to || !!onClick;
export const DataRow = ({
title,
description,
meta,
lead,
trail,
to,
href,
onClick,
selected,
className,
}: DataRowProps) => {
const interactive = !!to || !!href || !!onClick;
const body = (
<>
{lead && <div className="mt-0.5 flex shrink-0 items-center">{lead}</div>}
@@ -63,6 +76,13 @@ export const DataRow = ({ title, description, meta, lead, trail, to, onClick, se
</Link>
);
}
if (href) {
return (
<a href={href} target="_blank" rel="noreferrer" className={classes}>
{body}
</a>
);
}
if (onClick) {
return (
<button type="button" onClick={onClick} className={cn(classes, 'w-full')}>
@@ -1,10 +1,10 @@
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 { DataRow, RelativeTime } from '@/components/Data';
import { EmptyState, ErrorState, LabelChip, Loading, StateFilter, StateIcon } from './GiteaBits';
import { RepoRow } from './RepositoriesView';
import { giteaRepoPath, timeAgo } from './shared';
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:
@@ -33,7 +33,10 @@ export const CrossRepoIssuesView = ({ type }: CrossRepoProps) => {
) : 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={`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) => (
@@ -56,41 +59,37 @@ const CrossRepoRow = ({ issue, type }: { issue: GiteaIssue; type: 'issues' | 'pu
const name = issue.repository?.name ?? '';
const to = owner && name ? giteaRepoPath(owner, name, { tab: type, item: issue.number }) : null;
const body = (
<>
<span className="pt-0.5">
<StateIcon item={issue} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
// 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} />
))}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{issue.repository?.full_name ?? 'unknown'} #{issue.number} · opened {timeAgo(issue.created_at)}
{issue.user && ` by ${issue.user.login}`}
</p>
</div>
{!!issue.comments && (
<span className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-muted-foreground">
<MessageSquare className="h-3 w-3" />
{issue.comments}
</span>
)}
</>
);
const className = 'flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/50';
// Without a repository on the payload there is nothing to route to, so the row stays a plain div rather
// than a link that goes somewhere wrong.
return to ? (
<Link to={to} className={className}>
{body}
</Link>
) : (
<div className={className}>{body}</div>
</>
}
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>
)
}
/>
);
};
@@ -126,36 +125,24 @@ export const NotificationsView = () => {
<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);
const inner = (
<>
<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">
{notification.repository?.full_name ?? ''} · {notification.subject.type} ·{' '}
{timeAgo(notification.updated_at)}
</p>
</div>
</>
);
const className = 'flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/50';
return to ? (
<Link key={notification.id} to={to} className={className}>
{inner}
</Link>
) : (
<a
return (
<DataRow
key={notification.id}
href={notification.subject.html_url}
target="_blank"
rel="noreferrer"
className={className}
>
{inner}
<ExternalLink className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
</a>
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>
@@ -199,7 +186,14 @@ export const ExploreView = () => {
) : error ? (
<ErrorState title="Search failed" error={error} />
) : !data?.data?.length ? (
<EmptyState title={query ? `Nothing matches “${query}` : 'No repositories'} />
<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) => (
@@ -225,24 +219,21 @@ export const OrganizationsView = () => {
<div className="h-full overflow-y-auto">
<div className="divide-y">
{orgs.map((org) => (
<div key={org.id} className="flex items-start gap-3 px-4 py-3">
{org.avatar_url ? (
<img src={org.avatar_url} alt="" className="h-8 w-8 shrink-0 rounded-lg object-cover" loading="lazy" />
) : (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted">
<Building2 className="h-4 w-4 text-muted-foreground" />
</span>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{org.full_name || org.username}</p>
{org.description && <p className="truncate text-xs text-muted-foreground">{org.description}</p>}
<p className="mt-0.5 text-xs text-muted-foreground">
@{org.username}
{org.visibility && ` · ${org.visibility}`}
{org.location && ` · ${org.location}`}
</p>
</div>
</div>
<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>
@@ -2,9 +2,10 @@ 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, timeAgo } from './shared';
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
@@ -29,27 +30,26 @@ export const RepoCommitsView = ({ owner, name, refName }: TabProps) => {
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="divide-y">
{commits.map((commit) => (
<div key={commit.sha} className="flex items-start gap-3 px-4 py-2.5">
<span className="pt-0.5">
<Avatar user={commit.author} size={24} />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{commitTitle(commit.commit.message)}</p>
<p className="text-xs text-muted-foreground">
{commit.author?.login ?? commit.commit.author?.name ?? 'unknown'} committed{' '}
{timeAgo(commit.commit.author?.date ?? commit.created)}
</p>
</div>
<a
href={commit.html_url}
target="_blank"
rel="noreferrer"
className="shrink-0 rounded border px-1.5 py-0.5 font-mono text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Open commit in Gitea"
>
{shortSha(commit.sha)}
</a>
</div>
<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>
@@ -92,28 +92,32 @@ export const RepoBranchesView = ({ repo, owner, name }: TabProps) => {
<SectionTitle icon={GitBranch} label={`Branches${branches?.length ? ` (${branches.length})` : ''}`} />
<div className="divide-y">
{(branches ?? []).map((branch) => (
<Link
<DataRow
key={branch.name}
to={giteaRepoPath(owner, name, { tab: 'code', ref: branch.name })}
className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/50"
>
<GitBranch className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
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-1.5 py-0.5 text-xs font-medium text-primary">
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
default
</span>
)}
{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>
)}
</div>
<span className="shrink-0 text-xs text-muted-foreground">{timeAgo(branch.commit?.timestamp)}</span>
</Link>
{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>
@@ -170,7 +174,7 @@ export const RepoReleasesView = ({ repo, owner, name }: TabProps) => {
</span>
)}
<span className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
{timeAgo(release.published_at ?? release.created_at)}
<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>
@@ -196,9 +200,9 @@ export const RepoReleasesView = ({ repo, owner, name }: TabProps) => {
href={asset.browser_download_url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 px-3 py-1.5 text-xs transition-colors hover:bg-muted/50"
className="flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-muted/50"
>
<Download className="h-3 w-3 shrink-0 text-muted-foreground" />
<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>
@@ -32,15 +32,20 @@ export const RepoView = () => {
<div className="border-b px-4 pt-3">
<Link
to={giteaSectionPath('repositories')}
className="mb-1.5 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
className="mb-2 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3 w-3" />
<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 font-semibold leading-snug">
<span className="truncate">{repo.full_name}</span>
<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" />}
@@ -56,19 +61,19 @@ export const RepoView = () => {
</a>
</div>
{repo.description && <p className="mt-0.5 text-xs text-muted-foreground">{repo.description}</p>}
{repo.description && <p className="mt-1 text-sm text-muted-foreground">{repo.description}</p>}
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs tabular-nums text-muted-foreground">
<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-0.5">
<Star className="h-3 w-3" />
<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-0.5">
<GitFork className="h-3 w-3" />
<span className="flex items-center gap-1.5">
<GitFork className="h-3.5 w-3.5" />
{repo.forks_count}
</span>
)}
@@ -93,7 +98,7 @@ export const RepoView = () => {
to={giteaRepoPath(owner, name, { tab: entry.id, ref: refName ?? undefined })}
end={false}
className={() =>
`whitespace-nowrap border-b-2 px-3 py-1.5 text-xs transition-colors ${
`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'