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; trail?: ReactNode;
/** Makes the whole row a link. Rows are real links so cmd-click and the back button work. */ /** Makes the whole row a link. Rows are real links so cmd-click and the back button work. */
to?: string; to?: string;
/** Same, for a destination outside the app. Opens in a new tab; ignored when `to` is set. */
href?: string;
onClick?: () => void; onClick?: () => void;
/** Marks the row as the current selection when the list is a master beside a detail pane. */ /** Marks the row as the current selection when the list is a master beside a detail pane. */
selected?: boolean; selected?: boolean;
className?: string; className?: string;
}; };
export const DataRow = ({ title, description, meta, lead, trail, to, onClick, selected, className }: DataRowProps) => { export const DataRow = ({
const interactive = !!to || !!onClick; title,
description,
meta,
lead,
trail,
to,
href,
onClick,
selected,
className,
}: DataRowProps) => {
const interactive = !!to || !!href || !!onClick;
const body = ( const body = (
<> <>
{lead && <div className="mt-0.5 flex shrink-0 items-center">{lead}</div>} {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> </Link>
); );
} }
if (href) {
return (
<a href={href} target="_blank" rel="noreferrer" className={classes}>
{body}
</a>
);
}
if (onClick) { if (onClick) {
return ( return (
<button type="button" onClick={onClick} className={cn(classes, 'w-full')}> <button type="button" onClick={onClick} className={cn(classes, 'w-full')}>
@@ -1,10 +1,10 @@
import type { GiteaIssue, GiteaNotification } from './shared'; import type { GiteaIssue, GiteaNotification } from './shared';
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router';
import { Bell, Building2, ExternalLink, MessageSquare, Search } from 'lucide-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 { EmptyState, ErrorState, LabelChip, Loading, StateFilter, StateIcon } from './GiteaBits';
import { RepoRow } from './RepositoriesView'; import { RepoRow } from './RepositoriesView';
import { giteaRepoPath, timeAgo } from './shared'; import { giteaRepoPath } from './shared';
import { useGiteaIssueSearch, useGiteaNotifications, useGiteaOrgs, useGiteaRepoSearch } from './useGiteaData'; import { useGiteaIssueSearch, useGiteaNotifications, useGiteaOrgs, useGiteaRepoSearch } from './useGiteaData';
// The cross-repository sections — the ones Gitea puts in its own header rather than inside a repository: // 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 ? ( ) : error ? (
<ErrorState title={`Could not load ${noun}`} error={error} /> <ErrorState title={`Could not load ${noun}`} error={error} />
) : !issues?.length ? ( ) : !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"> <div className="divide-y">
{issues.map((issue) => ( {issues.map((issue) => (
@@ -56,41 +59,37 @@ const CrossRepoRow = ({ issue, type }: { issue: GiteaIssue; type: 'issues' | 'pu
const name = issue.repository?.name ?? ''; const name = issue.repository?.name ?? '';
const to = owner && name ? giteaRepoPath(owner, name, { tab: type, item: issue.number }) : null; const to = owner && name ? giteaRepoPath(owner, name, { tab: type, item: issue.number }) : null;
const body = ( // 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="pt-0.5">
<StateIcon item={issue} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-medium">{issue.title}</span> <span className="truncate text-sm font-medium">{issue.title}</span>
{issue.labels?.map((label) => ( {issue.labels?.map((label) => (
<LabelChip key={label.id} label={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)} meta={[
{issue.user && ` by ${issue.user.login}`} // The repository leads here — it is the fact that makes a cross-repo row make sense, and it is
</p> // the first thing you need in order to know whether the row is yours to care about.
</div> issue.repository?.full_name ?? 'unknown',
{!!issue.comments && ( `#${issue.number}`,
<span className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-muted-foreground"> <RelativeTime value={issue.created_at} prefix="opened" />,
<MessageSquare className="h-3 w-3" /> 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} {issue.comments}
</span> </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>
); );
}; };
@@ -126,36 +125,24 @@ export const NotificationsView = () => {
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="divide-y"> <div className="divide-y">
{notifications.map((notification) => { {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 to = subjectRoute(notification);
const inner = ( return (
<> <DataRow
<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
key={notification.id} key={notification.id}
href={notification.subject.html_url} to={to ?? undefined}
target="_blank" href={to ? undefined : notification.subject.html_url}
rel="noreferrer" lead={<Bell className="h-4 w-4 text-success" />}
className={className} title={notification.subject.title}
> meta={[
{inner} notification.repository?.full_name,
<ExternalLink className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" /> notification.subject.type,
</a> <RelativeTime value={notification.updated_at} />,
]}
trail={!to && <ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />}
/>
); );
})} })}
</div> </div>
@@ -199,7 +186,14 @@ export const ExploreView = () => {
) : error ? ( ) : error ? (
<ErrorState title="Search failed" error={error} /> <ErrorState title="Search failed" error={error} />
) : !data?.data?.length ? ( ) : !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"> <div className="divide-y">
{data.data.map((repo) => ( {data.data.map((repo) => (
@@ -225,24 +219,21 @@ export const OrganizationsView = () => {
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="divide-y"> <div className="divide-y">
{orgs.map((org) => ( {orgs.map((org) => (
<div key={org.id} className="flex items-start gap-3 px-4 py-3"> <DataRow
{org.avatar_url ? ( key={org.id}
<img src={org.avatar_url} alt="" className="h-8 w-8 shrink-0 rounded-lg object-cover" loading="lazy" /> 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 shrink-0 items-center justify-center rounded-lg bg-muted"> <span className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
<Building2 className="h-4 w-4 text-muted-foreground" /> <Building2 className="h-4 w-4 text-muted-foreground" />
</span> </span>
)} )
<div className="min-w-0 flex-1"> }
<p className="truncate text-sm font-medium">{org.full_name || org.username}</p> title={org.full_name || org.username}
{org.description && <p className="truncate text-xs text-muted-foreground">{org.description}</p>} description={org.description}
<p className="mt-0.5 text-xs text-muted-foreground"> meta={[`@${org.username}`, org.visibility, org.location]}
@{org.username} />
{org.visibility && ` · ${org.visibility}`}
{org.location && ` · ${org.location}`}
</p>
</div>
</div>
))} ))}
</div> </div>
</div> </div>
@@ -2,9 +2,10 @@ import type { GiteaRepo } from './shared';
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { ChevronLeft, ChevronRight, Download, ExternalLink, GitBranch, Package, Shield, Tag } from 'lucide-react'; 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 { Avatar, EmptyState, ErrorState, Loading } from './GiteaBits';
import { GiteaMarkdown } from './GiteaMarkdown'; 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'; import { useGiteaBranches, useGiteaCommits, useGiteaReleases, useGiteaTags } from './useGiteaData';
// The three history tabs — commits, branches (with tags) and releases. Grouped in one file because each is // 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="min-h-0 flex-1 overflow-y-auto">
<div className="divide-y"> <div className="divide-y">
{commits.map((commit) => ( {commits.map((commit) => (
<div key={commit.sha} className="flex items-start gap-3 px-4 py-2.5"> <DataRow
<span className="pt-0.5"> key={commit.sha}
<Avatar user={commit.author} size={24} /> lead={<Avatar user={commit.author} size={24} />}
</span> title={commitTitle(commit.commit.message)}
<div className="min-w-0 flex-1"> meta={[
<p className="truncate text-sm font-medium">{commitTitle(commit.commit.message)}</p> commit.author?.login ?? commit.commit.author?.name ?? 'unknown',
<p className="text-xs text-muted-foreground"> <RelativeTime value={commit.commit.author?.date ?? commit.created} prefix="committed" />,
{commit.author?.login ?? commit.commit.author?.name ?? 'unknown'} committed{' '} ]}
{timeAgo(commit.commit.author?.date ?? commit.created)} trail={
</p>
</div>
<a <a
href={commit.html_url} href={commit.html_url}
target="_blank" target="_blank"
rel="noreferrer" 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" 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" title="Open commit in Gitea"
> >
{shortSha(commit.sha)} {shortSha(commit.sha)}
</a> </a>
</div> }
/>
))} ))}
</div> </div>
</div> </div>
@@ -92,28 +92,32 @@ export const RepoBranchesView = ({ repo, owner, name }: TabProps) => {
<SectionTitle icon={GitBranch} label={`Branches${branches?.length ? ` (${branches.length})` : ''}`} /> <SectionTitle icon={GitBranch} label={`Branches${branches?.length ? ` (${branches.length})` : ''}`} />
<div className="divide-y"> <div className="divide-y">
{(branches ?? []).map((branch) => ( {(branches ?? []).map((branch) => (
<Link <DataRow
key={branch.name} key={branch.name}
to={giteaRepoPath(owner, name, { tab: 'code', ref: 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" lead={<GitBranch className="h-4 w-4 text-muted-foreground" />}
> title={
<GitBranch className="h-4 w-4 shrink-0 text-muted-foreground" /> <>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{branch.name}</span> <span className="truncate text-sm font-medium">{branch.name}</span>
{branch.name === repo.default_branch && ( {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 default
</span> </span>
)} )}
{branch.protected && <Shield className="h-3 w-3 shrink-0 text-warning" />} {branch.protected && (
</div> <span title="Protected" className="flex shrink-0 items-center">
{branch.commit?.message && ( <Shield className="h-3.5 w-3.5 text-warning" aria-label="Protected" />
<p className="truncate text-xs text-muted-foreground">{commitTitle(branch.commit.message)}</p> </span>
)} )}
</div> </>
<span className="shrink-0 text-xs text-muted-foreground">{timeAgo(branch.commit?.timestamp)}</span> }
</Link> description={branch.commit?.message ? commitTitle(branch.commit.message) : undefined}
trail={
<span className="text-xs text-muted-foreground">
<RelativeTime value={branch.commit?.timestamp} />
</span>
}
/>
))} ))}
</div> </div>
@@ -170,7 +174,7 @@ export const RepoReleasesView = ({ repo, owner, name }: TabProps) => {
</span> </span>
)} )}
<span className="ml-auto flex items-center gap-2 text-xs text-muted-foreground"> <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"> <a href={release.html_url} target="_blank" rel="noreferrer" className="hover:text-foreground">
<ExternalLink className="h-3.5 w-3.5" /> <ExternalLink className="h-3.5 w-3.5" />
</a> </a>
@@ -196,9 +200,9 @@ export const RepoReleasesView = ({ repo, owner, name }: TabProps) => {
href={asset.browser_download_url} href={asset.browser_download_url}
target="_blank" target="_blank"
rel="noreferrer" 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="min-w-0 flex-1 truncate">{asset.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">{formatBytes(asset.size)}</span> <span className="shrink-0 tabular-nums text-muted-foreground">{formatBytes(asset.size)}</span>
</a> </a>
@@ -32,15 +32,20 @@ export const RepoView = () => {
<div className="border-b px-4 pt-3"> <div className="border-b px-4 pt-3">
<Link <Link
to={giteaSectionPath('repositories')} 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 All repositories
</Link> </Link>
<div className="flex items-start gap-2"> <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"> <h2 className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5 text-base leading-snug">
<span className="truncate">{repo.full_name}</span> {/* 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.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.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" />} {repo.archived && <Archive className="h-3.5 w-3.5 shrink-0 text-warning" />}
@@ -56,19 +61,19 @@ export const RepoView = () => {
</a> </a>
</div> </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.language && <span>{repo.language}</span>}
{!!repo.stars_count && ( {!!repo.stars_count && (
<span className="flex items-center gap-0.5"> <span className="flex items-center gap-1.5">
<Star className="h-3 w-3" /> <Star className="h-3.5 w-3.5" />
{repo.stars_count} {repo.stars_count}
</span> </span>
)} )}
{!!repo.forks_count && ( {!!repo.forks_count && (
<span className="flex items-center gap-0.5"> <span className="flex items-center gap-1.5">
<GitFork className="h-3 w-3" /> <GitFork className="h-3.5 w-3.5" />
{repo.forks_count} {repo.forks_count}
</span> </span>
)} )}
@@ -93,7 +98,7 @@ export const RepoView = () => {
to={giteaRepoPath(owner, name, { tab: entry.id, ref: refName ?? undefined })} to={giteaRepoPath(owner, name, { tab: entry.id, ref: refName ?? undefined })}
end={false} end={false}
className={() => 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 entry.id === tab
? 'border-primary font-medium text-primary' ? 'border-primary font-medium text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'