From a193c918111f5ea944a855803e0b0ad068b5dc68 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 22 May 2026 23:57:50 -0700 Subject: [PATCH] render recent repos in sidebar --- .../migration.sql | 26 ++++++ packages/db/prisma/schema.prisma | 24 +++++ .../components/defaultSidebar/index.tsx | 42 +++++++++ .../defaultSidebar/repoVisitHistory.tsx | 92 +++++++++++++++++++ .../[...path]/components/trackRepoVisit.tsx | 28 ++++++ .../src/app/(app)/browse/[...path]/page.tsx | 5 +- packages/web/src/app/(app)/browse/actions.ts | 60 ++++++++++++ 7 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 packages/db/prisma/migrations/20260523064558_add_repo_visit_table/migration.sql create mode 100644 packages/web/src/app/(app)/@sidebar/components/defaultSidebar/repoVisitHistory.tsx create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/trackRepoVisit.tsx create mode 100644 packages/web/src/app/(app)/browse/actions.ts diff --git a/packages/db/prisma/migrations/20260523064558_add_repo_visit_table/migration.sql b/packages/db/prisma/migrations/20260523064558_add_repo_visit_table/migration.sql new file mode 100644 index 000000000..35cfce439 --- /dev/null +++ b/packages/db/prisma/migrations/20260523064558_add_repo_visit_table/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "RepoVisit" ( + "id" TEXT NOT NULL, + "visitedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastPromotedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "repoId" INTEGER NOT NULL, + "userId" TEXT NOT NULL, + "orgId" INTEGER NOT NULL, + + CONSTRAINT "RepoVisit_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "RepoVisit_userId_orgId_visitedAt_idx" ON "RepoVisit"("userId", "orgId", "visitedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "RepoVisit_repoId_userId_key" ON "RepoVisit"("repoId", "userId"); + +-- AddForeignKey +ALTER TABLE "RepoVisit" ADD CONSTRAINT "RepoVisit_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "Repo"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RepoVisit" ADD CONSTRAINT "RepoVisit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RepoVisit" ADD CONSTRAINT "RepoVisit_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 785f1df93..140c15b44 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -74,6 +74,7 @@ model Repo { orgId Int searchContexts SearchContext[] + visits RepoVisit[] @@unique([external_id, external_codeHostUrl, orgId]) @@index([orgId]) @@ -291,6 +292,7 @@ model Org { searchContexts SearchContext[] chats Chat[] + repoVisits RepoVisit[] license License? } @@ -395,6 +397,7 @@ model User { chats Chat[] sharedChats ChatAccess[] + repoVisits RepoVisit[] oauthTokens OAuthToken[] oauthAuthCodes OAuthAuthorizationCode[] @@ -501,6 +504,27 @@ model VerificationToken { @@unique([identifier, token]) } +model RepoVisit { + id String @id @default(cuid()) + /// visitedAt is updated everytime a repo is visited. + visitedAt DateTime @default(now()) @updatedAt + // lastPromotedAt is updated only when a repo is promoted into the top k + // most recently viewed repositories. + lastPromotedAt DateTime @default(now()) + + repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade) + repoId Int + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + @@unique([repoId, userId]) + @@index([userId, orgId, visitedAt]) +} + model Chat { id String @id @default(cuid()) diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx index c8702a4ee..f17b2d2f0 100644 --- a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx @@ -10,11 +10,13 @@ import { OrgRole } from "@prisma/client"; import { SidebarBase } from "@/app/(app)/@sidebar/components/sidebarBase"; import { Nav } from "./nav"; import { ChatHistory } from "./chatHistory"; +import { RepoVisitHistory } from "./repoVisitHistory"; import { getAuthContext, withAuth } from "@/middleware/withAuth"; import { sew } from "@/middleware/sew"; import { isValidLicenseActive } from "@/lib/entitlements"; const SIDEBAR_CHAT_LIMIT = 30; +export const SIDEBAR_REPO_VISITS_LIMIT = 10; export async function DefaultSidebar() { const session = await auth(); @@ -26,6 +28,11 @@ export async function DefaultSidebar() { throw new ServiceErrorException(chatHistory); } + const repoVisits = session ? await getRecentRepoVisits() : []; + if (isServiceError(repoVisits)) { + throw new ServiceErrorException(repoVisits); + } + const licenseActive = await isValidLicenseActive(); const authContext = await getAuthContext(); @@ -56,6 +63,7 @@ export async function DefaultSidebar() { /> } > + SIDEBAR_CHAT_LIMIT} @@ -64,6 +72,40 @@ export async function DefaultSidebar() { ); } +const getRecentRepoVisits = async () => sew(() => + withAuth(async ({ org, user, prisma }) => { + const visits = await prisma.repoVisit.findMany({ + where: { + userId: user.id, + orgId: org.id, + }, + orderBy: { + lastPromotedAt: 'desc', + }, + take: SIDEBAR_REPO_VISITS_LIMIT, + include: { + repo: { + select: { + id: true, + name: true, + displayName: true, + imageUrl: true, + external_codeHostType: true, + }, + }, + }, + }); + + return visits.map((visit) => ({ + repoId: visit.repo.id, + repoName: visit.repo.name, + displayName: visit.repo.displayName, + imageUrl: visit.repo.imageUrl, + codeHostType: visit.repo.external_codeHostType, + })); + }) +); + const getUserChatHistory = async () => sew(() => withAuth(async ({ org, user, prisma }) => { const chats = await prisma.chat.findMany({ diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/repoVisitHistory.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/repoVisitHistory.tsx new file mode 100644 index 000000000..72ee9886b --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/repoVisitHistory.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { getCodeHostIcon, getRepoImageSrc } from "@/lib/utils"; +import { CodeHostType } from "@prisma/client"; +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; + +export interface RepoVisitItem { + repoId: number; + repoName: string; + displayName: string | null; + imageUrl: string | null; + codeHostType: CodeHostType; +} + +interface RepoVisitHistoryProps { + repoVisits: RepoVisitItem[]; +} + +export function RepoVisitHistory({ repoVisits }: RepoVisitHistoryProps) { + const pathname = usePathname(); + + if (repoVisits.length === 0) { + return null; + } + + return ( + + Recent Repositories + + + {repoVisits.map((visit) => { + const href = `/browse/${visit.repoName}/-/tree/`; + const browsePrefix = `/browse/${visit.repoName}`; + const isActive = pathname === browsePrefix + || pathname.startsWith(`${browsePrefix}/-/`) + || pathname.startsWith(`${browsePrefix}@`); + const repoImageSrc = visit.imageUrl + ? getRepoImageSrc(visit.imageUrl, visit.repoId) + : undefined; + const codeHostIcon = getCodeHostIcon(visit.codeHostType); + const isInternalApiImage = repoImageSrc?.startsWith('/api/'); + + const name = visit.displayName ? + visit.displayName.split('/').pop() : + visit.repoName; + + return ( + + + + {repoImageSrc ? ( + {visit.displayName + ) : ( + {visit.displayName + )} + + {name} + + + + + ); + })} + + + + ); +} diff --git a/packages/web/src/app/(app)/browse/[...path]/components/trackRepoVisit.tsx b/packages/web/src/app/(app)/browse/[...path]/components/trackRepoVisit.tsx new file mode 100644 index 000000000..e7f40dea8 --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/trackRepoVisit.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { trackRepoVisit } from "@/app/(app)/browse/actions"; +import { isServiceError } from "@/lib/utils"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +interface TrackRepoVisitProps { + repoName: string; + isAuthenticated: boolean; +} + +export function TrackRepoVisit({ repoName, isAuthenticated }: TrackRepoVisitProps) { + const router = useRouter(); + useEffect(() => { + if (!isAuthenticated) { + return; + } + + trackRepoVisit({ repoName }).then((result) => { + if (!isServiceError(result) && result.wasPromoted) { + router.refresh(); + } + }); + }, [repoName, router, isAuthenticated]); + + return null; +} diff --git a/packages/web/src/app/(app)/browse/[...path]/page.tsx b/packages/web/src/app/(app)/browse/[...path]/page.tsx index 7680ada7f..a051e5bb2 100644 --- a/packages/web/src/app/(app)/browse/[...path]/page.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/page.tsx @@ -7,6 +7,8 @@ import { CommitsPanel } from "./components/commitHistoryPanel/commitsPanel"; import { Loader2 } from "lucide-react"; import { TreePreviewPanel } from "./components/treePreviewPanel/treePreviewPanel"; import { Metadata } from "next"; +import { TrackRepoVisit } from "./components/trackRepoVisit"; +import { auth } from "@/auth"; /** * Parses the URL path to generate a descriptive title. @@ -94,7 +96,7 @@ interface BrowsePageProps { } export default async function BrowsePage(props: BrowsePageProps) { - const [params, searchParams] = await Promise.all([props.params, props.searchParams]); + const [params, searchParams, session] = await Promise.all([props.params, props.searchParams, auth()]); const { path: _rawPath, @@ -114,6 +116,7 @@ export default async function BrowsePage(props: BrowsePageProps) { return (
+ diff --git a/packages/web/src/app/(app)/browse/actions.ts b/packages/web/src/app/(app)/browse/actions.ts new file mode 100644 index 000000000..47bd13d55 --- /dev/null +++ b/packages/web/src/app/(app)/browse/actions.ts @@ -0,0 +1,60 @@ +'use server'; + +import { sew } from "@/middleware/sew"; +import { withAuth } from "@/middleware/withAuth"; +import { SIDEBAR_REPO_VISITS_LIMIT } from "../@sidebar/components/defaultSidebar"; + +export const trackRepoVisit = async ({ repoName }: { repoName: string }) => sew(() => + withAuth(async ({ org, user, prisma }) => { + const repo = await prisma.repo.findFirst({ + where: { + name: repoName, + orgId: org.id, + }, + select: { id: true }, + }); + + if (!repo) { + return { + wasPromoted: false, + } + } + + const topKVisits = await prisma.repoVisit.findMany({ + where: { + userId: user.id, + orgId: org.id + }, + orderBy: { lastPromotedAt: 'desc'}, + take: SIDEBAR_REPO_VISITS_LIMIT, + select: { repoId: true } + }); + + const shouldPromote = !topKVisits.some((visit) => visit.repoId === repo.id); + const now = new Date(); + + await prisma.repoVisit.upsert({ + where: { + repoId_userId: { + repoId: repo.id, + userId: user.id, + }, + }, + update: { + visitedAt: now, + ...(shouldPromote ? { + lastPromotedAt: now, + } : {}) + }, + create: { + repoId: repo.id, + userId: user.id, + orgId: org.id, + }, + }); + + return { + wasPromoted: shouldPromote, + }; + }) +);