diff --git a/src/renderer/routes/Login.test.tsx b/src/renderer/routes/Login.test.tsx index dfc561a96..447d3669f 100644 --- a/src/renderer/routes/Login.test.tsx +++ b/src/renderer/routes/Login.test.tsx @@ -59,6 +59,7 @@ describe('renderer/routes/Login.tsx', () => { it('should navigate to login with Gitea personal access token', async () => { renderWithProviders(, { isLoggedIn: false }); + await userEvent.click(screen.getByTestId('forge-tab-gitea')); await userEvent.click(screen.getByTestId('login-gitea-pat')); expect(navigateMock).toHaveBeenCalledTimes(1); @@ -66,4 +67,16 @@ describe('renderer/routes/Login.tsx', () => { state: { forge: 'gitea' }, }); }); + + it('should switch the visible login methods when changing forges', async () => { + renderWithProviders(, { isLoggedIn: false }); + + expect(screen.getByTestId('login-github')).toBeInTheDocument(); + expect(screen.queryByTestId('login-gitea-pat')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('forge-tab-gitea')); + + expect(screen.queryByTestId('login-github')).not.toBeInTheDocument(); + expect(screen.getByTestId('login-gitea-pat')).toBeInTheDocument(); + }); }); diff --git a/src/renderer/routes/Login.tsx b/src/renderer/routes/Login.tsx index 96b3a42eb..e8d8a55f3 100644 --- a/src/renderer/routes/Login.tsx +++ b/src/renderer/routes/Login.tsx @@ -1,4 +1,11 @@ -import { type FC, useEffect } from 'react'; +import { + type FC, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useNavigate } from 'react-router-dom'; import { Button, Heading, Stack, Text } from '@primer/react'; @@ -8,10 +15,28 @@ import { useAppContext } from '../hooks/useAppContext'; import { LogoIcon } from '../components/icons/LogoIcon'; import { Centered } from '../components/layout/Centered'; -import { Size } from '../types'; +import { type Forge, Size } from '../types'; +import type { + ForgeAdapter, + LoginMethodDescriptor, +} from '../utils/forges/types'; import { listAdapters } from '../utils/forges/registry'; import { showWindow } from '../utils/system/comms'; +import { cn } from '../utils/ui/cn'; + +/** + * Pick the method that should drive the dominant CTA for a forge. + * Falls back to the first registered method when no `primary` variant exists. + */ +function pickPrimaryMethod( + adapter: ForgeAdapter, +): LoginMethodDescriptor | undefined { + return ( + adapter.loginMethods.find((m) => m.variant === 'primary') ?? + adapter.loginMethods[0] + ); +} export const LoginRoute: FC = () => { const navigate = useNavigate(); @@ -19,6 +44,43 @@ export const LoginRoute: FC = () => { const { isLoggedIn } = useAppContext(); + const [activeForge, setActiveForge] = useState(adapters[0].id); + const activeAdapter = useMemo( + () => adapters.find((a) => a.id === activeForge) ?? adapters[0], + [activeForge, adapters], + ); + + const tablistRef = useRef(null); + const tabRefs = useRef(new Map()); + const [pillRect, setPillRect] = useState<{ + left: number; + width: number; + } | null>(null); + + // Measure the active tab so the indicator pill can slide between tabs + // instead of snapping bg colors. Runs synchronously after layout so the + // pill is positioned before paint. + useLayoutEffect(() => { + const tablist = tablistRef.current; + const active = tabRefs.current.get(activeForge); + if (!tablist || !active) { + return; + } + const parentRect = tablist.getBoundingClientRect(); + const btnRect = active.getBoundingClientRect(); + setPillRect((prev) => { + const next = { + left: btnRect.left - parentRect.left, + width: btnRect.width, + }; + // Avoid setState->layout->setState loops when nothing changed. + if (prev && prev.left === next.left && prev.width === next.width) { + return prev; + } + return next; + }); + }, [activeForge]); + useEffect(() => { if (isLoggedIn) { showWindow(); @@ -26,43 +88,144 @@ export const LoginRoute: FC = () => { } }, [isLoggedIn]); + const goTo = (method: LoginMethodDescriptor) => { + if (method.state) { + navigate(method.route, { state: method.state }); + } else { + navigate(method.route); + } + }; + + const primary = pickPrimaryMethod(activeAdapter); + const alternates = activeAdapter.loginMethods.filter((m) => m !== primary); + return ( - - + +
+ +
- + Notifications on your menu bar - - {adapters.map((adapter) => ( - - {adapter.displayName} - {adapter.loginMethods.map((method) => ( - - ))} - - ))} - + + {adapter.displayName} + + ); + })} + + )} + +
+ + {primary && ( + + )} + + {alternates.map((method) => ( + + ))} + + {activeAdapter.tagline && ( + + {activeAdapter.tagline} + + )} + +
); diff --git a/src/renderer/routes/__snapshots__/Login.test.tsx.snap b/src/renderer/routes/__snapshots__/Login.test.tsx.snap index 60f9231a0..ab77be341 100644 --- a/src/renderer/routes/__snapshots__/Login.test.tsx.snap +++ b/src/renderer/routes/__snapshots__/Login.test.tsx.snap @@ -14,62 +14,69 @@ exports[`renderer/routes/Login.tsx > should render itself & its children 1`] = ` class="prc-Stack-Stack-UQ9k6" data-align="center" data-direction="vertical" + data-gap="condensed" data-justify="start" data-padding="none" data-wrap="nowrap" > - + +

should render itself & its children 1`] = `

+
+ diff --git a/src/renderer/utils/forges/gitea/adapter.ts b/src/renderer/utils/forges/gitea/adapter.ts index 4e0537354..407e50116 100644 --- a/src/renderer/utils/forges/gitea/adapter.ts +++ b/src/renderer/utils/forges/gitea/adapter.ts @@ -74,6 +74,7 @@ function getDisplayHelpers( export const giteaAdapter: ForgeAdapter = { id: 'gitea', displayName: 'Gitea', + tagline: 'Self-hosted Git service', icon: ServerIcon, capabilities, diff --git a/src/renderer/utils/forges/github/adapter.ts b/src/renderer/utils/forges/github/adapter.ts index da8a052de..3064a64fa 100644 --- a/src/renderer/utils/forges/github/adapter.ts +++ b/src/renderer/utils/forges/github/adapter.ts @@ -88,6 +88,7 @@ function getDisplayHelpers( export const githubAdapter: ForgeAdapter = { id: 'github', displayName: 'GitHub', + tagline: 'github.com & GitHub Enterprise', icon: MarkGithubIcon, capabilities: githubCapabilities, diff --git a/src/renderer/utils/forges/types.ts b/src/renderer/utils/forges/types.ts index a372531af..f6942e95e 100644 --- a/src/renderer/utils/forges/types.ts +++ b/src/renderer/utils/forges/types.ts @@ -99,6 +99,8 @@ export interface ForgeAdapter { readonly id: Forge; /** User-facing forge name (e.g. "GitHub", "Gitea"). */ readonly displayName: string; + /** Short caption shown beside the forge name on the login screen. */ + readonly tagline?: string; /** Icon used for the platform in the UI. */ readonly icon: FC; /** Static or computed capability matrix for this forge. */ diff --git a/tailwind.config.mts b/tailwind.config.mts index 3342d9d27..0dae1b0f2 100644 --- a/tailwind.config.mts +++ b/tailwind.config.mts @@ -12,6 +12,25 @@ const config: Config = { fontSize: { xxs: '0.625rem', // 10px }, + keyframes: { + 'login-fade-up': { + '0%': { opacity: '0', transform: 'translateY(6px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + 'login-panel-fade': { + '0%': { opacity: '0', transform: 'translateY(4px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + }, + animation: { + 'login-fade-up': + 'login-fade-up 360ms cubic-bezier(0.2, 0.7, 0.2, 1) both', + 'login-panel-fade': + 'login-panel-fade 220ms cubic-bezier(0.2, 0.7, 0.2, 1) both', + }, + transitionTimingFunction: { + 'login-out': 'cubic-bezier(0.2, 0.7, 0.2, 1)', + }, spacing: { sidebar: sidebarWidth, },