mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user