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 (
-
-
+
+
+
+
-
+ Notificationson your menu bar
-
- {adapters.map((adapter) => (
-
- {adapter.displayName}
- {adapter.loginMethods.map((method) => (
-
);
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`] = `
+
+
+
+ GitHub
+
+
+
+ Gitea
+
+
+
-
- GitHub
- should render itself & its children 1`] = `
class="prc-Button-Label-FWkx3"
data-component="text"
>
+ Continue with
GitHub
should render itself & its children 1`] = `
should render itself & its children 1`] = `
-