feat(desktop): aggregate languages and repository totals on projects overview

Add Top Languages and Repositories summary cards to the overview's main
column, aggregated across every repository instead of a single one. A new
throttled useProjectsRepoSnapshotsQuery fans out blobless repo snapshots
(3 at a time, cached 15 minutes, only while the overview header is
visible) to feed the language tally, file/contributor totals, and latest
commit. Language helpers move to a shared lib so the single-project rail
and the workspace overview share one implementation.
This commit is contained in:
Thomas Petersen
2026-07-03 09:33:41 -04:00
parent 204a0fd2ec
commit 78744acfa7
5 changed files with 299 additions and 56 deletions
@@ -0,0 +1,45 @@
/** Language display names by file extension, used for language breakdowns. */
export const LANGUAGE_LABELS: Record<string, string> = {
css: "CSS",
dart: "Dart",
go: "Go",
html: "HTML",
js: "JavaScript",
json: "JSON",
jsx: "JavaScript",
kt: "Kotlin",
mjs: "JavaScript",
py: "Python",
rb: "Ruby",
rs: "Rust",
swift: "Swift",
ts: "TypeScript",
tsx: "TypeScript",
};
/** Dot accent colors cycled through language chips. */
export const LANGUAGE_DOT_CLASSES = [
"bg-blue-500",
"bg-violet-500",
"bg-emerald-500",
"bg-orange-500",
"bg-pink-500",
];
/** Maps a file path to its language label, or undefined when unknown. */
export function languageForPath(path: string): string | undefined {
const fileName = path.split("/").pop()?.toLowerCase() ?? "";
const extension = fileName.includes(".") ? fileName.split(".").pop() : "";
return extension ? LANGUAGE_LABELS[extension] : undefined;
}
/** Top-5 languages (label + file count) from a language tally. */
export function topLanguagesFromCounts(
counts: Record<string, number>,
): Array<[string, number]> {
return Object.entries(counts)
.sort(
(left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
)
.slice(0, 5);
}
@@ -9,6 +9,11 @@ import type {
ProjectRepoSnapshot,
} from "@/features/projects/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import {
LANGUAGE_DOT_CLASSES,
languageForPath,
topLanguagesFromCounts,
} from "@/features/projects/lib/projectLanguages";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { ReadmePanel } from "./ProjectRepositoryPanel";
@@ -24,53 +29,17 @@ type ProjectOverviewPanelProps = {
snapshot: ProjectRepoSnapshot | null | undefined;
};
const LANGUAGE_LABELS: Record<string, string> = {
css: "CSS",
dart: "Dart",
go: "Go",
html: "HTML",
js: "JavaScript",
json: "JSON",
jsx: "JavaScript",
kt: "Kotlin",
mjs: "JavaScript",
py: "Python",
rb: "Ruby",
rs: "Rust",
swift: "Swift",
ts: "TypeScript",
tsx: "TypeScript",
};
const LANGUAGE_DOT_CLASSES = [
"bg-blue-500",
"bg-violet-500",
"bg-emerald-500",
"bg-orange-500",
"bg-pink-500",
];
function shortHash(hash: string | undefined) {
return hash ? hash.slice(0, 7) : "None";
}
function languageForPath(path: string) {
const fileName = path.split("/").pop()?.toLowerCase() ?? "";
const extension = fileName.includes(".") ? fileName.split(".").pop() : "";
return extension ? LANGUAGE_LABELS[extension] : undefined;
}
function topLanguages(files: ProjectRepoFile[]) {
const counts = new Map<string, number>();
const counts: Record<string, number> = {};
for (const file of files) {
const language = languageForPath(file.path);
if (language) counts.set(language, (counts.get(language) ?? 0) + 1);
if (language) counts[language] = (counts[language] ?? 0) + 1;
}
return [...counts.entries()]
.sort(
(left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
)
.slice(0, 5);
return topLanguagesFromCounts(counts);
}
function projectPeople(project: Project) {
@@ -112,6 +81,30 @@ function PeopleAvatars({
);
}
export function LanguageChips({
languages,
}: {
languages: Array<[string, number]>;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{languages.map(([language], index) => (
<span
className="inline-flex items-center gap-1.5 rounded-full bg-muted/70 px-2 py-1 text-xs text-muted-foreground"
key={language}
>
<span
className={`h-2 w-2 rounded-full ${
LANGUAGE_DOT_CLASSES[index % LANGUAGE_DOT_CLASSES.length]
}`}
/>
{language}
</span>
))}
</div>
);
}
export function OverviewRailSection({
children,
title,
@@ -173,21 +166,7 @@ export function ProjectOverviewPanel({
</OverviewRailSection>
<OverviewRailSection title="Top Languages">
{languages.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{languages.map(([language], index) => (
<span
className="inline-flex items-center gap-1.5 rounded-full bg-muted/70 px-2 py-1 text-xs text-muted-foreground"
key={language}
>
<span
className={`h-2 w-2 rounded-full ${
LANGUAGE_DOT_CLASSES[index % LANGUAGE_DOT_CLASSES.length]
}`}
/>
{language}
</span>
))}
</div>
<LanguageChips languages={languages} />
) : (
<p className="text-sm text-muted-foreground">
No language data is available yet.
@@ -1,11 +1,24 @@
import { CircleDot, FolderGit2, GitPullRequest, Radio } from "lucide-react";
import {
CircleDot,
FileCode2,
FolderGit2,
GitCommitHorizontal,
GitPullRequest,
Radio,
Users,
} from "lucide-react";
import type * as React from "react";
import { WorkspaceEmojiIcon } from "@/features/workspaces/ui/WorkspaceSwitcher";
import type {
Project,
ProjectActivitySummary,
ProjectRepoSnapshot,
} from "@/features/projects/hooks";
import {
languageForPath,
topLanguagesFromCounts,
} from "@/features/projects/lib/projectLanguages";
import {
resolveUserLabel,
type UserProfileLookup,
@@ -13,7 +26,7 @@ import {
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { OverviewRailSection } from "./ProjectOverviewPanel";
import { LanguageChips, OverviewRailSection } from "./ProjectOverviewPanel";
import { ProjectsContributionGraph } from "./ProjectsContributionGraph";
export type ProjectsOverviewSection =
@@ -28,6 +41,9 @@ type ProjectsOverviewPanelProps = {
profiles?: UserProfileLookup;
projects: Project[];
relayName: string;
/** Repo snapshots keyed by project ID, for workspace-wide aggregates. */
snapshots?: Record<string, ProjectRepoSnapshot>;
snapshotsLoading?: boolean;
summaries?: Record<string, ProjectActivitySummary>;
};
@@ -79,6 +95,74 @@ function overviewStats(
);
}
function overviewLanguages(
snapshots: Record<string, ProjectRepoSnapshot> | undefined,
) {
const counts: Record<string, number> = {};
for (const snapshot of Object.values(snapshots ?? {})) {
for (const file of snapshot.files) {
const language = languageForPath(file.path);
if (language) counts[language] = (counts[language] ?? 0) + 1;
}
}
return topLanguagesFromCounts(counts);
}
function overviewRepoTotals(
snapshots: Record<string, ProjectRepoSnapshot> | undefined,
) {
const contributorEmails = new Set<string>();
let files = 0;
let latestCommit: ProjectRepoSnapshot["latestCommit"] = null;
for (const snapshot of Object.values(snapshots ?? {})) {
files += snapshot.files.length;
for (const contributor of snapshot.contributors) {
contributorEmails.add(
contributor.email.toLowerCase() || contributor.name.toLowerCase(),
);
}
if (
snapshot.latestCommit &&
snapshot.latestCommit.timestamp > (latestCommit?.timestamp ?? 0)
) {
latestCommit = snapshot.latestCommit;
}
}
return { contributors: contributorEmails.size, files, latestCommit };
}
function RepoTotalRow({
icon: Icon,
label,
value,
mono,
}: {
icon?: React.ComponentType<{ className?: string }>;
label: string;
value: React.ReactNode;
mono?: boolean;
}) {
return (
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
{Icon ? <Icon className="h-3.5 w-3.5" /> : null}
{label}
</dt>
<dd
className={
mono
? "font-mono text-xs text-foreground"
: "font-medium text-foreground"
}
>
{value}
</dd>
</div>
);
}
function overviewActivityByDay(
projects: Project[],
summaries: Record<string, ProjectActivitySummary> | undefined,
@@ -135,11 +219,16 @@ export function ProjectsOverviewPanel({
profiles,
projects,
relayName,
snapshots,
snapshotsLoading,
summaries,
}: ProjectsOverviewPanelProps) {
const stats = overviewStats(projects, summaries);
const people = overviewPeople(projects, summaries);
const activityByDay = overviewActivityByDay(projects, summaries);
const languages = overviewLanguages(snapshots);
const repoTotals = overviewRepoTotals(snapshots);
const scanning = Boolean(snapshotsLoading);
return (
<section className="mb-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_18rem]">
@@ -202,6 +291,65 @@ export function ProjectsOverviewPanel({
className="mt-3"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-border/50 bg-muted/20 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Top Languages
</h3>
<div className="mt-3">
{languages.length > 0 ? (
<LanguageChips languages={languages} />
) : (
<p className="text-sm text-muted-foreground">
{scanning
? "Scanning repositories..."
: "No language data is available yet."}
</p>
)}
</div>
</div>
<div className="rounded-2xl border border-border/50 bg-muted/20 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Repositories
</h3>
<dl className="mt-3 space-y-2 text-sm">
<RepoTotalRow
icon={FolderGit2}
label="Repositories"
value={projects.length}
/>
<RepoTotalRow
icon={GitCommitHorizontal}
label="Latest"
mono
value={
repoTotals.latestCommit
? repoTotals.latestCommit.shortHash
: scanning
? "..."
: "None"
}
/>
<RepoTotalRow
icon={FileCode2}
label="Files"
value={
scanning && repoTotals.files === 0 ? "..." : repoTotals.files
}
/>
<RepoTotalRow
icon={Users}
label="Contributors"
value={
scanning && repoTotals.contributors === 0
? "..."
: repoTotals.contributors
}
/>
<RepoTotalRow label="PRs" value={stats.prs} />
</dl>
</div>
</div>
</div>
<aside className="space-y-4 rounded-xl border border-border/50 bg-card/60 p-4">
<OverviewRailSection title="People">
@@ -29,6 +29,7 @@ import {
useProjectsPullRequestsQuery,
useProjectsQuery,
} from "@/features/projects/hooks";
import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots";
import { ProjectsIssuesList } from "@/features/projects/ui/ProjectsIssuesList";
import { ProjectsOverviewPanel } from "@/features/projects/ui/ProjectsOverviewPanel";
import { ProjectsPullRequestsList } from "@/features/projects/ui/ProjectsPullRequestsList";
@@ -609,6 +610,13 @@ export function ProjectsView() {
const projectIssuesQuery = useProjectsIssuesQuery(
filter === "issues" ? projects : [],
);
// One blobless clone per unique repository — only scan while the overview
// header (filter === "all") is actually visible.
const snapshotProjects = React.useMemo(
() => (filter === "all" ? uniqueRepositories(projects) : []),
[filter, projects],
);
const repoSnapshotsQuery = useProjectsRepoSnapshotsQuery(snapshotProjects);
const [storedViewMode, setStoredViewMode] =
React.useState<ProjectsViewMode | null>(() => readStoredViewMode());
const [sort, setSort] = React.useState<ProjectsSort>(() => readStoredSort());
@@ -845,6 +853,8 @@ export function ProjectsView() {
profiles={profiles}
projects={projects}
relayName={activeWorkspace?.name || "Relay"}
snapshots={repoSnapshotsQuery.data}
snapshotsLoading={repoSnapshotsQuery.isLoading}
summaries={activitySummariesQuery.data}
/>
) : null}
@@ -0,0 +1,61 @@
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import { getProjectRepoSnapshot } from "@/shared/api/projectGit";
import type { ProjectRepoSnapshot } from "@/shared/api/types";
import type { Project } from "./hooks";
// Repo snapshots are backed by a blobless `git clone` per repository, so the
// overview scan is deliberately throttled and cached for a long time.
const OVERVIEW_SNAPSHOT_CONCURRENCY = 3;
async function fetchProjectsRepoSnapshots(
projects: Project[],
): Promise<Record<string, ProjectRepoSnapshot>> {
const snapshots: Record<string, ProjectRepoSnapshot> = {};
const queue = [...projects];
const workers = Array.from(
{ length: Math.min(OVERVIEW_SNAPSHOT_CONCURRENCY, queue.length) },
async () => {
for (;;) {
const project = queue.shift();
if (!project) return;
const cloneUrl = project.cloneUrls[0];
if (!cloneUrl) continue;
try {
snapshots[project.id] = await getProjectRepoSnapshot({
cloneUrl,
defaultBranch: project.defaultBranch,
baseBranch: project.defaultBranch,
});
} catch {
// Best-effort: unreachable or empty repositories are skipped.
}
}
},
);
await Promise.all(workers);
return snapshots;
}
/**
* Fetches repo snapshots for a set of projects (throttled, failure-tolerant)
* for workspace-wide aggregates like the overview language breakdown.
* Callers should pre-filter and cap `projects` — one git clone per entry.
*/
export function useProjectsRepoSnapshotsQuery(projects: Project[]) {
const projectIds = React.useMemo(
() => projects.map((project) => project.id).sort(),
[projects],
);
return useQuery({
enabled: projects.length > 0,
queryKey: ["projects", "repo-snapshots", projectIds],
queryFn: () => fetchProjectsRepoSnapshots(projects),
staleTime: 15 * 60_000,
retry: 0,
});
}