Skip to content

Commit a193c91

Browse files
render recent repos in sidebar
1 parent 1f89e7c commit a193c91

7 files changed

Lines changed: 276 additions & 1 deletion

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
-- CreateTable
2+
CREATE TABLE "RepoVisit" (
3+
"id" TEXT NOT NULL,
4+
"visitedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
5+
"lastPromotedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
6+
"repoId" INTEGER NOT NULL,
7+
"userId" TEXT NOT NULL,
8+
"orgId" INTEGER NOT NULL,
9+
10+
CONSTRAINT "RepoVisit_pkey" PRIMARY KEY ("id")
11+
);
12+
13+
-- CreateIndex
14+
CREATE INDEX "RepoVisit_userId_orgId_visitedAt_idx" ON "RepoVisit"("userId", "orgId", "visitedAt");
15+
16+
-- CreateIndex
17+
CREATE UNIQUE INDEX "RepoVisit_repoId_userId_key" ON "RepoVisit"("repoId", "userId");
18+
19+
-- AddForeignKey
20+
ALTER TABLE "RepoVisit" ADD CONSTRAINT "RepoVisit_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "Repo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
21+
22+
-- AddForeignKey
23+
ALTER TABLE "RepoVisit" ADD CONSTRAINT "RepoVisit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
24+
25+
-- AddForeignKey
26+
ALTER TABLE "RepoVisit" ADD CONSTRAINT "RepoVisit_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;

packages/db/prisma/schema.prisma

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ model Repo {
7474
orgId Int
7575
7676
searchContexts SearchContext[]
77+
visits RepoVisit[]
7778
7879
@@unique([external_id, external_codeHostUrl, orgId])
7980
@@index([orgId])
@@ -291,6 +292,7 @@ model Org {
291292
searchContexts SearchContext[]
292293
293294
chats Chat[]
295+
repoVisits RepoVisit[]
294296
295297
license License?
296298
}
@@ -395,6 +397,7 @@ model User {
395397
396398
chats Chat[]
397399
sharedChats ChatAccess[]
400+
repoVisits RepoVisit[]
398401
399402
oauthTokens OAuthToken[]
400403
oauthAuthCodes OAuthAuthorizationCode[]
@@ -501,6 +504,27 @@ model VerificationToken {
501504
@@unique([identifier, token])
502505
}
503506

507+
model RepoVisit {
508+
id String @id @default(cuid())
509+
/// visitedAt is updated everytime a repo is visited.
510+
visitedAt DateTime @default(now()) @updatedAt
511+
// lastPromotedAt is updated only when a repo is promoted into the top k
512+
// most recently viewed repositories.
513+
lastPromotedAt DateTime @default(now())
514+
515+
repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade)
516+
repoId Int
517+
518+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
519+
userId String
520+
521+
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
522+
orgId Int
523+
524+
@@unique([repoId, userId])
525+
@@index([userId, orgId, visitedAt])
526+
}
527+
504528
model Chat {
505529
id String @id @default(cuid())
506530

packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import { OrgRole } from "@prisma/client";
1010
import { SidebarBase } from "@/app/(app)/@sidebar/components/sidebarBase";
1111
import { Nav } from "./nav";
1212
import { ChatHistory } from "./chatHistory";
13+
import { RepoVisitHistory } from "./repoVisitHistory";
1314
import { getAuthContext, withAuth } from "@/middleware/withAuth";
1415
import { sew } from "@/middleware/sew";
1516
import { isValidLicenseActive } from "@/lib/entitlements";
1617

1718
const SIDEBAR_CHAT_LIMIT = 30;
19+
export const SIDEBAR_REPO_VISITS_LIMIT = 10;
1820

1921
export async function DefaultSidebar() {
2022
const session = await auth();
@@ -26,6 +28,11 @@ export async function DefaultSidebar() {
2628
throw new ServiceErrorException(chatHistory);
2729
}
2830

31+
const repoVisits = session ? await getRecentRepoVisits() : [];
32+
if (isServiceError(repoVisits)) {
33+
throw new ServiceErrorException(repoVisits);
34+
}
35+
2936
const licenseActive = await isValidLicenseActive();
3037

3138
const authContext = await getAuthContext();
@@ -56,6 +63,7 @@ export async function DefaultSidebar() {
5663
/>
5764
}
5865
>
66+
<RepoVisitHistory repoVisits={repoVisits} />
5967
<ChatHistory
6068
chatHistory={chatHistory.slice(0, SIDEBAR_CHAT_LIMIT)}
6169
hasMore={chatHistory.length > SIDEBAR_CHAT_LIMIT}
@@ -64,6 +72,40 @@ export async function DefaultSidebar() {
6472
);
6573
}
6674

75+
const getRecentRepoVisits = async () => sew(() =>
76+
withAuth(async ({ org, user, prisma }) => {
77+
const visits = await prisma.repoVisit.findMany({
78+
where: {
79+
userId: user.id,
80+
orgId: org.id,
81+
},
82+
orderBy: {
83+
lastPromotedAt: 'desc',
84+
},
85+
take: SIDEBAR_REPO_VISITS_LIMIT,
86+
include: {
87+
repo: {
88+
select: {
89+
id: true,
90+
name: true,
91+
displayName: true,
92+
imageUrl: true,
93+
external_codeHostType: true,
94+
},
95+
},
96+
},
97+
});
98+
99+
return visits.map((visit) => ({
100+
repoId: visit.repo.id,
101+
repoName: visit.repo.name,
102+
displayName: visit.repo.displayName,
103+
imageUrl: visit.repo.imageUrl,
104+
codeHostType: visit.repo.external_codeHostType,
105+
}));
106+
})
107+
);
108+
67109
const getUserChatHistory = async () => sew(() =>
68110
withAuth(async ({ org, user, prisma }) => {
69111
const chats = await prisma.chat.findMany({
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
'use client';
2+
3+
import {
4+
SidebarGroup,
5+
SidebarGroupContent,
6+
SidebarGroupLabel,
7+
SidebarMenu,
8+
SidebarMenuButton,
9+
SidebarMenuItem,
10+
} from "@/components/ui/sidebar";
11+
import { getCodeHostIcon, getRepoImageSrc } from "@/lib/utils";
12+
import { CodeHostType } from "@prisma/client";
13+
import Image from "next/image";
14+
import Link from "next/link";
15+
import { usePathname } from "next/navigation";
16+
import { cn } from "@/lib/utils";
17+
18+
export interface RepoVisitItem {
19+
repoId: number;
20+
repoName: string;
21+
displayName: string | null;
22+
imageUrl: string | null;
23+
codeHostType: CodeHostType;
24+
}
25+
26+
interface RepoVisitHistoryProps {
27+
repoVisits: RepoVisitItem[];
28+
}
29+
30+
export function RepoVisitHistory({ repoVisits }: RepoVisitHistoryProps) {
31+
const pathname = usePathname();
32+
33+
if (repoVisits.length === 0) {
34+
return null;
35+
}
36+
37+
return (
38+
<SidebarGroup className="group-data-[state=collapsed]:hidden">
39+
<SidebarGroupLabel className="text-muted-foreground whitespace-nowrap">Recent Repositories</SidebarGroupLabel>
40+
<SidebarGroupContent>
41+
<SidebarMenu>
42+
{repoVisits.map((visit) => {
43+
const href = `/browse/${visit.repoName}/-/tree/`;
44+
const browsePrefix = `/browse/${visit.repoName}`;
45+
const isActive = pathname === browsePrefix
46+
|| pathname.startsWith(`${browsePrefix}/-/`)
47+
|| pathname.startsWith(`${browsePrefix}@`);
48+
const repoImageSrc = visit.imageUrl
49+
? getRepoImageSrc(visit.imageUrl, visit.repoId)
50+
: undefined;
51+
const codeHostIcon = getCodeHostIcon(visit.codeHostType);
52+
const isInternalApiImage = repoImageSrc?.startsWith('/api/');
53+
54+
const name = visit.displayName ?
55+
visit.displayName.split('/').pop() :
56+
visit.repoName;
57+
58+
return (
59+
<SidebarMenuItem key={visit.repoId}>
60+
<SidebarMenuButton asChild isActive={isActive}>
61+
<Link href={href}>
62+
{repoImageSrc ? (
63+
<Image
64+
src={repoImageSrc}
65+
alt={visit.displayName ?? visit.repoName}
66+
width={16}
67+
height={16}
68+
className="shrink-0 rounded-sm object-cover"
69+
unoptimized={isInternalApiImage}
70+
/>
71+
) : (
72+
<Image
73+
src={codeHostIcon.src}
74+
alt={visit.displayName ?? visit.repoName}
75+
width={16}
76+
height={16}
77+
className={cn("shrink-0", codeHostIcon.className)}
78+
/>
79+
)}
80+
<span className="truncate">
81+
{name}
82+
</span>
83+
</Link>
84+
</SidebarMenuButton>
85+
</SidebarMenuItem>
86+
);
87+
})}
88+
</SidebarMenu>
89+
</SidebarGroupContent>
90+
</SidebarGroup>
91+
);
92+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
'use client';
2+
3+
import { trackRepoVisit } from "@/app/(app)/browse/actions";
4+
import { isServiceError } from "@/lib/utils";
5+
import { useRouter } from "next/navigation";
6+
import { useEffect } from "react";
7+
8+
interface TrackRepoVisitProps {
9+
repoName: string;
10+
isAuthenticated: boolean;
11+
}
12+
13+
export function TrackRepoVisit({ repoName, isAuthenticated }: TrackRepoVisitProps) {
14+
const router = useRouter();
15+
useEffect(() => {
16+
if (!isAuthenticated) {
17+
return;
18+
}
19+
20+
trackRepoVisit({ repoName }).then((result) => {
21+
if (!isServiceError(result) && result.wasPromoted) {
22+
router.refresh();
23+
}
24+
});
25+
}, [repoName, router, isAuthenticated]);
26+
27+
return null;
28+
}

packages/web/src/app/(app)/browse/[...path]/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { CommitsPanel } from "./components/commitHistoryPanel/commitsPanel";
77
import { Loader2 } from "lucide-react";
88
import { TreePreviewPanel } from "./components/treePreviewPanel/treePreviewPanel";
99
import { Metadata } from "next";
10+
import { TrackRepoVisit } from "./components/trackRepoVisit";
11+
import { auth } from "@/auth";
1012

1113
/**
1214
* Parses the URL path to generate a descriptive title.
@@ -94,7 +96,7 @@ interface BrowsePageProps {
9496
}
9597

9698
export default async function BrowsePage(props: BrowsePageProps) {
97-
const [params, searchParams] = await Promise.all([props.params, props.searchParams]);
99+
const [params, searchParams, session] = await Promise.all([props.params, props.searchParams, auth()]);
98100

99101
const {
100102
path: _rawPath,
@@ -114,6 +116,7 @@ export default async function BrowsePage(props: BrowsePageProps) {
114116

115117
return (
116118
<div className="flex flex-col h-full">
119+
<TrackRepoVisit repoName={repoName} isAuthenticated={!!session} />
117120
<Suspense fallback={
118121
<div className="flex flex-col w-full min-h-full items-center justify-center">
119122
<Loader2 className="w-4 h-4 animate-spin" />
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
'use server';
2+
3+
import { sew } from "@/middleware/sew";
4+
import { withAuth } from "@/middleware/withAuth";
5+
import { SIDEBAR_REPO_VISITS_LIMIT } from "../@sidebar/components/defaultSidebar";
6+
7+
export const trackRepoVisit = async ({ repoName }: { repoName: string }) => sew(() =>
8+
withAuth(async ({ org, user, prisma }) => {
9+
const repo = await prisma.repo.findFirst({
10+
where: {
11+
name: repoName,
12+
orgId: org.id,
13+
},
14+
select: { id: true },
15+
});
16+
17+
if (!repo) {
18+
return {
19+
wasPromoted: false,
20+
}
21+
}
22+
23+
const topKVisits = await prisma.repoVisit.findMany({
24+
where: {
25+
userId: user.id,
26+
orgId: org.id
27+
},
28+
orderBy: { lastPromotedAt: 'desc'},
29+
take: SIDEBAR_REPO_VISITS_LIMIT,
30+
select: { repoId: true }
31+
});
32+
33+
const shouldPromote = !topKVisits.some((visit) => visit.repoId === repo.id);
34+
const now = new Date();
35+
36+
await prisma.repoVisit.upsert({
37+
where: {
38+
repoId_userId: {
39+
repoId: repo.id,
40+
userId: user.id,
41+
},
42+
},
43+
update: {
44+
visitedAt: now,
45+
...(shouldPromote ? {
46+
lastPromotedAt: now,
47+
} : {})
48+
},
49+
create: {
50+
repoId: repo.id,
51+
userId: user.id,
52+
orgId: org.id,
53+
},
54+
});
55+
56+
return {
57+
wasPromoted: shouldPromote,
58+
};
59+
})
60+
);

0 commit comments

Comments
 (0)