diff --git a/desktop/src/features/projects/lib/projectLanguages.ts b/desktop/src/features/projects/lib/projectLanguages.ts new file mode 100644 index 000000000..bb93d5776 --- /dev/null +++ b/desktop/src/features/projects/lib/projectLanguages.ts @@ -0,0 +1,45 @@ +/** Language display names by file extension, used for language breakdowns. */ +export const LANGUAGE_LABELS: Record = { + 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, +): Array<[string, number]> { + return Object.entries(counts) + .sort( + (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]), + ) + .slice(0, 5); +} diff --git a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx index dc728bc78..2192a089a 100644 --- a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx @@ -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 = { - 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(); + const counts: Record = {}; 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 ( +
+ {languages.map(([language], index) => ( + + + {language} + + ))} +
+ ); +} + export function OverviewRailSection({ children, title, @@ -173,21 +166,7 @@ export function ProjectOverviewPanel({ {languages.length > 0 ? ( -
- {languages.map(([language], index) => ( - - - {language} - - ))} -
+ ) : (

No language data is available yet. diff --git a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index 3ccf4479f..1b913abf5 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -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; + snapshotsLoading?: boolean; summaries?: Record; }; @@ -79,6 +95,74 @@ function overviewStats( ); } +function overviewLanguages( + snapshots: Record | undefined, +) { + const counts: Record = {}; + 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 | undefined, +) { + const contributorEmails = new Set(); + 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 ( +

+
+ {Icon ? : null} + {label} +
+
+ {value} +
+
+ ); +} + function overviewActivityByDay( projects: Project[], summaries: Record | 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 (
@@ -202,6 +291,65 @@ export function ProjectsOverviewPanel({ className="mt-3" /> +
+
+

+ Top Languages +

+
+ {languages.length > 0 ? ( + + ) : ( +

+ {scanning + ? "Scanning repositories..." + : "No language data is available yet."} +

+ )} +
+
+
+

+ Repositories +

+
+ + + + + +
+
+