Improve editor deep link handling (#46)

This commit is contained in:
Chris Estreich 2025-05-29 13:40:21 -07:00 committed by GitHub
parent 112c1796f6
commit 7a9a200c8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 410 additions and 85 deletions

4
.env
View file

@ -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

View file

@ -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<string | undefined>('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 (
<div className="flex flex-row items-center gap-2">

File diff suppressed because one or more lines are too long

View file

@ -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 (
<Card className="max-w-md mx-auto">
<Card className="max-w-lg mx-auto">
<CardContent className="flex flex-col gap-4">
<div>You&apos;ve successfully authenticated.</div>
<div className="flex flex-row items-center gap-4">
<div>Open in your IDE:</div>
<Link href={vsCodeUrl}>
<VSCodeLogo />
</Link>
<Link href={cursorUrl}>
<CursorLogo />
<div className="flex flex-row justify-center items-center gap-2">
<div className="text-sm text-muted-foreground">
Redirecting to {currentIde.title}...
</div>
<Link href={deepLinkUrl} title={currentIde.title}>
<currentIde.logo />
</Link>
</div>
{redirectAttempted && (
<div className="flex flex-row justify-center items-center gap-2">
<div className="text-sm text-muted-foreground">
Or, use another IDE:
</div>
{ides.map((ide) => (
<Link key={ide.editor} href={ide.href} title={ide.title}>
<ide.logo width={20} height={20} />
</Link>
))}
</div>
)}
</CardContent>
<CardFooter className="text-sm text-muted-foreground">
{redirectAttempted
? 'You can close this window after your IDE opens.'
: 'Redirecting automatically...'}
</CardFooter>
</Card>
);
};

View file

@ -0,0 +1,24 @@
import type { SVGProps } from 'react';
type TraeLogoProps = SVGProps<SVGSVGElement>;
export const TraeLogo = ({
width = '36',
height = '36',
...props
}: TraeLogoProps) => (
<svg
width={width}
height={height}
viewBox="0 0 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M27.4343 0H0.565657C0.253253 0 0 0.253253 0 0.565657V27.4343C0 27.7467 0.253253 28 0.565657 28H27.4343C27.7467 28 28 27.7467 28 27.4343V0.565657C28 0.253253 27.7467 0 27.4343 0Z"
fill="#FF4A36"
/>
<path d="M22.9121 19.8093H13.8616V22.9091H22.9121V19.8093Z" fill="white" />
</svg>
);

View file

@ -0,0 +1,35 @@
import type { SVGProps } from 'react';
type VSCodeInsidersLogoProps = SVGProps<SVGSVGElement>;
export const VSCodeInsidersLogo = ({
width = '36',
height = '36',
...props
}: VSCodeInsidersLogoProps) => (
<svg
width={width}
height={height}
viewBox="2 2 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M20.375,3.291a.874.874,0,0,1,1.463.647V10.25l-8.36,6.624L9.172,13.608Z"
fill="#009a7c"
/>
<path
d="M6.013,16.669,2.38,19.8A1.166,1.166,0,0,0,2.3,21.447c.025.027.05.053.077.077l1.541,1.4a1.166,1.166,0,0,0,1.489.066L9.6,19.935Z"
fill="#009a7c"
/>
<path
d="M21.838,21.749,5.412,9.007a1.165,1.165,0,0,0-1.489.066l-1.541,1.4a1.166,1.166,0,0,0-.077,1.647c.025.027.05.053.077.077l17.99,16.5a.875.875,0,0,0,1.466-.645Z"
fill="#00b294"
/>
<path
d="M23.244,29.747a1.745,1.745,0,0,1-1.989-.338A1.025,1.025,0,0,0,23,28.684V3.316a1.025,1.025,0,0,0-1.749-.725,1.745,1.745,0,0,1,1.989-.338l5.765,2.772A1.748,1.748,0,0,1,30,6.6V25.4a1.748,1.748,0,0,1-.991,1.576Z"
fill="#24bfa5"
/>
</svg>
);

File diff suppressed because one or more lines are too long

View file

@ -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 <DeepLink vsCodeUrl={vsCodeUrl.href} cursorUrl={cursorUrl.href} />;
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 <DeepLink editor={editor} deepLinkUrl={deepLinkUrl} />;
}

View file

@ -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<string | undefined>('state');
useMount(() => {
const state = searchParams.get('state');
if (state) {
setState(state);
}
});
useSetAuthState();
return <SignIn />;
}

View file

@ -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<string | undefined>('state');
useMount(() => {
const state = searchParams.get('state');
if (state) {
setState(state);
}
});
useSetAuthState();
return <SignUp />;
}

43
src/hooks/useAuthState.ts Normal file
View file

@ -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<string | undefined>('state');
const [ide, setIde] = useSessionStorage<string | undefined>('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 });
}
});
};

9
src/lib/constants.ts Normal file
View file

@ -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}`;

View file

@ -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,