Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
24 changes: 24 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ model Repo {
orgId Int

searchContexts SearchContext[]
visits RepoVisit[]

@@unique([external_id, external_codeHostUrl, orgId])
@@index([orgId])
Expand Down Expand Up @@ -291,6 +292,7 @@ model Org {
searchContexts SearchContext[]

chats Chat[]
repoVisits RepoVisit[]

license License?
}
Expand Down Expand Up @@ -395,6 +397,7 @@ model User {

chats Chat[]
sharedChats ChatAccess[]
repoVisits RepoVisit[]

oauthTokens OAuthToken[]
oauthAuthCodes OAuthAuthorizationCode[]
Expand Down Expand Up @@ -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())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -56,6 +63,7 @@ export async function DefaultSidebar() {
/>
}
>
<RepoVisitHistory repoVisits={repoVisits} />
<ChatHistory
chatHistory={chatHistory.slice(0, SIDEBAR_CHAT_LIMIT)}
hasMore={chatHistory.length > SIDEBAR_CHAT_LIMIT}
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<SidebarGroup className="group-data-[state=collapsed]:hidden">
<SidebarGroupLabel className="text-muted-foreground whitespace-nowrap">Recent Repositories</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{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 (
<SidebarMenuItem key={visit.repoId}>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={href}>
{repoImageSrc ? (
<Image
src={repoImageSrc}
alt={visit.displayName ?? visit.repoName}
width={16}
height={16}
className="shrink-0 rounded-sm object-cover"
unoptimized={isInternalApiImage}
/>
) : (
<Image
src={codeHostIcon.src}
alt={visit.displayName ?? visit.repoName}
width={16}
height={16}
className={cn("shrink-0", codeHostIcon.className)}
/>
)}
<span className="truncate">
{name}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 4 additions & 1 deletion packages/web/src/app/(app)/browse/[...path]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -114,6 +116,7 @@ export default async function BrowsePage(props: BrowsePageProps) {

return (
<div className="flex flex-col h-full">
<TrackRepoVisit repoName={repoName} isAuthenticated={!!session} />
<Suspense fallback={
<div className="flex flex-col w-full min-h-full items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin" />
Expand Down
60 changes: 60 additions & 0 deletions packages/web/src/app/(app)/browse/actions.ts
Original file line number Diff line number Diff line change
@@ -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,
};
})
);
Loading