From 7a9a200c8b6fca14a2f0d3b5c0fd3a351dfb0ea2 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Thu, 29 May 2025 13:40:21 -0700 Subject: [PATCH] Improve editor deep link handling (#46) --- .env | 4 - src/app/(centered)/authorized/page.tsx | 16 +- .../extension/sign-in/CursorLogo.tsx | 151 ++++++++++++++++-- .../(centered)/extension/sign-in/DeepLink.tsx | 89 ++++++++--- .../(centered)/extension/sign-in/TraeLogo.tsx | 24 +++ .../extension/sign-in/VSCodeInsidersLogo.tsx | 35 ++++ .../extension/sign-in/WindsurfLogo.tsx | 56 +++++++ src/app/(centered)/extension/sign-in/page.tsx | 34 ++-- .../sign-in/[[...sign-in]]/page.tsx | 15 +- .../sign-up/[[...sign-up]]/page.tsx | 15 +- src/hooks/useAuthState.ts | 43 +++++ src/lib/constants.ts | 9 ++ src/lib/server/env.ts | 4 - 13 files changed, 410 insertions(+), 85 deletions(-) create mode 100644 src/app/(centered)/extension/sign-in/TraeLogo.tsx create mode 100644 src/app/(centered)/extension/sign-in/VSCodeInsidersLogo.tsx create mode 100644 src/app/(centered)/extension/sign-in/WindsurfLogo.tsx create mode 100644 src/hooks/useAuthState.ts create mode 100644 src/lib/constants.ts diff --git a/.env b/.env index 63fe9cfc40..8d7d70f4b0 100644 --- a/.env +++ b/.env @@ -22,7 +22,3 @@ NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/authorized NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/authorized NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/authorized NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/authorized - -# Deep Links -VSCODE_EXTENSION_BASE_URL=vscode://RooVeterinaryInc.roo-cline -CURSOR_EXTENSION_BASE_URL=cursor://RooVeterinaryInc.roo-cline diff --git a/src/app/(centered)/authorized/page.tsx b/src/app/(centered)/authorized/page.tsx index 245e1b587c..3a438f1940 100644 --- a/src/app/(centered)/authorized/page.tsx +++ b/src/app/(centered)/authorized/page.tsx @@ -1,17 +1,19 @@ 'use client'; import { useState, useRef, useEffect } from 'react'; -import { useSessionStorage } from 'react-use'; import { useRouter } from 'next/navigation'; import { useAuth } from '@clerk/nextjs'; import { LoaderCircle, CircleCheck, CircleX } from 'lucide-react'; +import { useAuthState } from '@/hooks/useAuthState'; + export default function Page() { const { isSignedIn, orgId } = useAuth(); const [isLoading, setIsLoading] = useState(true); const isLoadingRef = useRef(true); const router = useRouter(); - const [state, setState] = useSessionStorage('state'); + + const { state, ide } = useAuthState(); useEffect(() => { if (typeof isSignedIn !== 'undefined' && isLoadingRef.current) { @@ -25,16 +27,18 @@ export default function Page() { let path; if (!isSignedIn) { - path = state ? `/sign-in?state=${state}` : '/sign-in'; + path = state ? `/sign-in?state=${state}&ide=${ide}` : '/sign-in'; } else if (!orgId) { - path = state ? `/select-org/${state}` : '/select-org'; + path = state ? `/select-org/${state}?ide=${ide}` : '/select-org'; } else { - path = state ? `/extension/sign-in?state=${state}` : '/dashboard'; + path = state + ? `/extension/sign-in?state=${state}&ide=${ide}` + : '/dashboard'; } setTimeout(() => router.push(path), 1000); } - }, [router, isLoading, isSignedIn, orgId, state, setState]); + }, [router, isLoading, isSignedIn, orgId, state, ide]); return (
diff --git a/src/app/(centered)/extension/sign-in/CursorLogo.tsx b/src/app/(centered)/extension/sign-in/CursorLogo.tsx index 5b0b61d744..e4c778b30e 100644 --- a/src/app/(centered)/extension/sign-in/CursorLogo.tsx +++ b/src/app/(centered)/extension/sign-in/CursorLogo.tsx @@ -1,8 +1,28 @@ -import type { SVGProps } from 'react'; +'use client'; + +import { type SVGProps, useEffect, useState } from 'react'; +import { useTheme } from 'next-themes'; type CursorLogoProps = SVGProps; -export const CursorLogo = ({ +export const CursorLogo = (props: CursorLogoProps) => { + const { resolvedTheme } = useTheme(); + const [variant, setVariant] = useState<'light' | 'dark'>('light'); + + // Fixes hydration error. + useEffect( + () => setVariant(resolvedTheme === 'dark' ? 'dark' : 'light'), + [resolvedTheme], + ); + + return variant === 'dark' ? ( + + ) : ( + + ); +}; + +export const CursorLogoLight = ({ width = '36', height = '36', ...props @@ -12,25 +32,124 @@ export const CursorLogo = ({ xmlnsXlink="http://www.w3.org/1999/xlink" width={width} height={height} + viewBox="0 0 24 24" fill="none" {...props} > - + + + + + - - - - + + + + + + + + + + + + + +); + +export const CursorLogoDark = ({ + width = '36', + height = '36', + ...props +}: CursorLogoProps) => ( + + + + + + + + + + + + + + + + + + + ); diff --git a/src/app/(centered)/extension/sign-in/DeepLink.tsx b/src/app/(centered)/extension/sign-in/DeepLink.tsx index a4ea28d998..e3eafb6636 100644 --- a/src/app/(centered)/extension/sign-in/DeepLink.tsx +++ b/src/app/(centered)/extension/sign-in/DeepLink.tsx @@ -1,45 +1,92 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; -import { Card, CardContent, CardFooter } from '@/components/ui'; +import { Card, CardContent } from '@/components/ui'; + import { VSCodeLogo } from './VSCodeLogo'; +import { VSCodeInsidersLogo } from './VSCodeInsidersLogo'; import { CursorLogo } from './CursorLogo'; +import { WindsurfLogo } from './WindsurfLogo'; +import { TraeLogo } from './TraeLogo'; interface DeepLinkProps { - vsCodeUrl: string; - cursorUrl: string; + editor: string; + deepLinkUrl: string; } -export const DeepLink = ({ vsCodeUrl, cursorUrl }: DeepLinkProps) => { +export const DeepLink = ({ editor, deepLinkUrl }: DeepLinkProps) => { const [redirectAttempted, setRedirectAttempted] = useState(false); useEffect(() => { - window.location.href = vsCodeUrl; + window.location.href = deepLinkUrl; const timer = setTimeout(() => setRedirectAttempted(true), 2000); return () => clearTimeout(timer); - }, [vsCodeUrl]); + }, [deepLinkUrl]); + + const ides = useMemo( + () => [ + { + editor: 'vscode', + title: 'Visual Studio Code', + logo: VSCodeLogo, + href: deepLinkUrl.replace(`${editor}://`, 'vscode://'), + }, + { + editor: 'vscode-insiders', + title: 'Visual Studio Code (Insiders)', + logo: VSCodeInsidersLogo, + href: deepLinkUrl.replace(`${editor}://`, 'vscode-insiders://'), + }, + { + editor: 'cursor', + title: 'Cursor', + logo: CursorLogo, + href: deepLinkUrl.replace(`${editor}://`, 'cursor://'), + }, + { + editor: 'windsurf', + title: 'Windsurf', + logo: WindsurfLogo, + href: deepLinkUrl.replace(`${editor}://`, 'windsurf://'), + }, + { + editor: 'trae', + title: 'Trae', + logo: TraeLogo, + href: deepLinkUrl.replace(`${editor}://`, 'trae://'), + }, + ], + [editor, deepLinkUrl], + ); + + const currentIde = ides.find((ide) => ide.editor === editor) ?? ides[0]!; return ( - + -
You've successfully authenticated.
-
-
Open in your IDE:
- - - - - +
+
+ Redirecting to {currentIde.title}... +
+ +
+ {redirectAttempted && ( +
+
+ Or, use another IDE: +
+ {ides.map((ide) => ( + + + + ))} +
+ )} - - {redirectAttempted - ? 'You can close this window after your IDE opens.' - : 'Redirecting automatically...'} - ); }; diff --git a/src/app/(centered)/extension/sign-in/TraeLogo.tsx b/src/app/(centered)/extension/sign-in/TraeLogo.tsx new file mode 100644 index 0000000000..24f48ec198 --- /dev/null +++ b/src/app/(centered)/extension/sign-in/TraeLogo.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from 'react'; + +type TraeLogoProps = SVGProps; + +export const TraeLogo = ({ + width = '36', + height = '36', + ...props +}: TraeLogoProps) => ( + + + + +); diff --git a/src/app/(centered)/extension/sign-in/VSCodeInsidersLogo.tsx b/src/app/(centered)/extension/sign-in/VSCodeInsidersLogo.tsx new file mode 100644 index 0000000000..a97b312349 --- /dev/null +++ b/src/app/(centered)/extension/sign-in/VSCodeInsidersLogo.tsx @@ -0,0 +1,35 @@ +import type { SVGProps } from 'react'; + +type VSCodeInsidersLogoProps = SVGProps; + +export const VSCodeInsidersLogo = ({ + width = '36', + height = '36', + ...props +}: VSCodeInsidersLogoProps) => ( + + + + + + +); diff --git a/src/app/(centered)/extension/sign-in/WindsurfLogo.tsx b/src/app/(centered)/extension/sign-in/WindsurfLogo.tsx new file mode 100644 index 0000000000..f18f8bd8d1 --- /dev/null +++ b/src/app/(centered)/extension/sign-in/WindsurfLogo.tsx @@ -0,0 +1,56 @@ +import type { SVGProps } from 'react'; + +type WindsurfLogoProps = SVGProps; + +export const WindsurfLogo = ({ + width = '36', + height = '36', + ...props +}: WindsurfLogoProps) => ( + + + + + + + + + + + + + + +); diff --git a/src/app/(centered)/extension/sign-in/page.tsx b/src/app/(centered)/extension/sign-in/page.tsx index 4edb6f25d4..654fc3669d 100644 --- a/src/app/(centered)/extension/sign-in/page.tsx +++ b/src/app/(centered)/extension/sign-in/page.tsx @@ -1,39 +1,53 @@ import { redirect } from 'next/navigation'; import { auth } from '@clerk/nextjs/server'; -import { Env } from '@/lib/server'; import { getSignInToken } from '@/actions/auth'; +import { EXTENSION_EDITOR, EXTENSION_URL } from '@/lib/constants'; import { DeepLink } from './DeepLink'; type Props = { - searchParams: Promise<{ state?: string }>; + searchParams: Promise<{ state?: string; ide?: string }>; }; export default async function Page(props: Props) { - const { state } = await props.searchParams; + const { state, ide } = await props.searchParams; if (!state) { redirect(`/sign-in`); } const { userId, orgId } = await auth(); + const code = userId ? await getSignInToken(userId).catch(() => undefined) : undefined; if (!code) { - redirect(`/sign-in?state=${state}`); + redirect(`/sign-in?state=${state}&ide=${ide}`); } if (!orgId) { - redirect(`/select-org/${state}`); + redirect(`/select-org/${state}?ide=${ide}`); } - const searchParams = new URLSearchParams({ state, code }); - const path = `/auth/clerk/callback?${searchParams.toString()}`; - const vsCodeUrl = new URL(path, Env.VSCODE_EXTENSION_BASE_URL); - const cursorUrl = new URL(path, Env.CURSOR_EXTENSION_BASE_URL); + let editor; + let deepLinkUrl; - return ; + try { + const params = new URLSearchParams({ state, code }); + + deepLinkUrl = new URL( + `/auth/clerk/callback?${params.toString()}`, + ide ?? EXTENSION_URL, + ).toString(); + + editor = new URL(deepLinkUrl).protocol.slice(0, -1); + } catch (_) { + // Use the defaults if we can't parse the URL. + editor = EXTENSION_EDITOR; + deepLinkUrl = EXTENSION_URL; + } + + return ; } diff --git a/src/app/(centered)/sign-in/[[...sign-in]]/page.tsx b/src/app/(centered)/sign-in/[[...sign-in]]/page.tsx index e2ee4a8fdd..d2d7d259cd 100644 --- a/src/app/(centered)/sign-in/[[...sign-in]]/page.tsx +++ b/src/app/(centered)/sign-in/[[...sign-in]]/page.tsx @@ -1,20 +1,11 @@ 'use client'; -import { useSessionStorage, useMount } from 'react-use'; -import { useSearchParams } from 'next/navigation'; import { SignIn } from '@clerk/nextjs'; +import { useSetAuthState } from '@/hooks/useAuthState'; + export default function Page() { - const searchParams = useSearchParams(); - const [, setState] = useSessionStorage('state'); - - useMount(() => { - const state = searchParams.get('state'); - - if (state) { - setState(state); - } - }); + useSetAuthState(); return ; } diff --git a/src/app/(centered)/sign-up/[[...sign-up]]/page.tsx b/src/app/(centered)/sign-up/[[...sign-up]]/page.tsx index d39ae5b7e8..b3f8325ac1 100644 --- a/src/app/(centered)/sign-up/[[...sign-up]]/page.tsx +++ b/src/app/(centered)/sign-up/[[...sign-up]]/page.tsx @@ -1,20 +1,11 @@ 'use client'; -import { useSessionStorage, useMount } from 'react-use'; -import { useSearchParams } from 'next/navigation'; import { SignUp } from '@clerk/nextjs'; +import { useSetAuthState } from '@/hooks/useAuthState'; + export default function Page() { - const searchParams = useSearchParams(); - const [, setState] = useSessionStorage('state'); - - useMount(() => { - const state = searchParams.get('state'); - - if (state) { - setState(state); - } - }); + useSetAuthState(); return ; } diff --git a/src/hooks/useAuthState.ts b/src/hooks/useAuthState.ts new file mode 100644 index 0000000000..40401cc112 --- /dev/null +++ b/src/hooks/useAuthState.ts @@ -0,0 +1,43 @@ +'use client'; + +import { useCallback } from 'react'; +import { useSessionStorage, useMount } from 'react-use'; +import { useSearchParams } from 'next/navigation'; + +import { EXTENSION_URL } from '@/lib/constants'; + +export type AuthState = { + state?: string; + ide?: string; +}; + +export type SetAuthState = (state: AuthState) => void; + +export const useAuthState = (): AuthState & { set: SetAuthState } => { + const [state, setState] = useSessionStorage('state'); + const [ide, setIde] = useSessionStorage('ide'); + + const set = useCallback( + (state: AuthState) => { + setState(state.state); + setIde(state.ide); + }, + [setState, setIde], + ); + + return { state, ide, set }; +}; + +export const useSetAuthState = () => { + const { set } = useAuthState(); + const searchParams = useSearchParams(); + + useMount(() => { + const state = searchParams.get('state'); + const ide = searchParams.get('ide'); + + if (state) { + set({ state, ide: ide ?? EXTENSION_URL }); + } + }); +}; diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000000..7d26e79308 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,9 @@ +export const EXTENSION_EDITOR = 'vscode'; + +export const EXTENSION_PUBLISHER = 'RooVeterinaryInc'; + +export const EXTENSION_NAME = 'roo-cline'; + +export const EXTENSION_IDENTIFIER = `${EXTENSION_PUBLISHER}.${EXTENSION_NAME}`; + +export const EXTENSION_URL = `vscode://${EXTENSION_IDENTIFIER}`; diff --git a/src/lib/server/env.ts b/src/lib/server/env.ts index efa5d70ab2..9c73c77b06 100644 --- a/src/lib/server/env.ts +++ b/src/lib/server/env.ts @@ -6,8 +6,6 @@ export const Env = createEnv({ CLERK_SECRET_KEY: z.string().min(1), DATABASE_URL: z.string(), LOGTAIL_SOURCE_TOKEN: z.string().optional(), - VSCODE_EXTENSION_BASE_URL: z.string().min(1), - CURSOR_EXTENSION_BASE_URL: z.string().min(1), CLICKHOUSE_URL: z.string().min(1), CLICKHOUSE_PASSWORD: z.string().min(1), }, @@ -29,8 +27,6 @@ export const Env = createEnv({ CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY, DATABASE_URL: process.env.DATABASE_URL, LOGTAIL_SOURCE_TOKEN: process.env.LOGTAIL_SOURCE_TOKEN, - VSCODE_EXTENSION_BASE_URL: process.env.VSCODE_EXTENSION_BASE_URL, - CURSOR_EXTENSION_BASE_URL: process.env.CURSOR_EXTENSION_BASE_URL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,