fix(ui): hide GitHub surfaces in non-GitHub repositories
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { GitProvider } from '@/lib/gitProvider';
|
||||||
|
|
||||||
type ComposerAttachmentControlsProps = {
|
type ComposerAttachmentControlsProps = {
|
||||||
isVSCode: boolean;
|
isVSCode: boolean;
|
||||||
@@ -26,6 +27,8 @@ type ComposerAttachmentControlsProps = {
|
|||||||
handlePickLocalFiles: () => void;
|
handlePickLocalFiles: () => void;
|
||||||
openIssuePicker: () => void;
|
openIssuePicker: () => void;
|
||||||
openPrPicker: () => void;
|
openPrPicker: () => void;
|
||||||
|
/** Only shows the GitHub issue/PR attach actions when the repo is GitHub. */
|
||||||
|
gitProvider?: GitProvider | null;
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
onMenuOpenChange?: (open: boolean) => void;
|
onMenuOpenChange?: (open: boolean) => void;
|
||||||
/** Mobile: open the attachment bottom sheet instead of the dropdown menu. */
|
/** Mobile: open the attachment bottom sheet instead of the dropdown menu. */
|
||||||
@@ -41,6 +44,7 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
|
|||||||
handlePickLocalFiles,
|
handlePickLocalFiles,
|
||||||
openIssuePicker,
|
openIssuePicker,
|
||||||
openPrPicker,
|
openPrPicker,
|
||||||
|
gitProvider,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
@@ -98,22 +102,26 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
|
|||||||
<Icon name="attachment-2"/>
|
<Icon name="attachment-2"/>
|
||||||
{t('chat.chatInput.actions.attachFiles')}
|
{t('chat.chatInput.actions.attachFiles')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
{gitProvider === 'github' ? (
|
||||||
onSelect={() => {
|
<>
|
||||||
requestAnimationFrame(openIssuePicker);
|
<DropdownMenuItem
|
||||||
}}
|
onSelect={() => {
|
||||||
>
|
requestAnimationFrame(openIssuePicker);
|
||||||
<Icon name="github"/>
|
}}
|
||||||
{t('chat.chatInput.actions.linkGithubIssue')}
|
>
|
||||||
</DropdownMenuItem>
|
<Icon name="github"/>
|
||||||
<DropdownMenuItem
|
{t('chat.chatInput.actions.linkGithubIssue')}
|
||||||
onSelect={() => {
|
</DropdownMenuItem>
|
||||||
requestAnimationFrame(openPrPicker);
|
<DropdownMenuItem
|
||||||
}}
|
onSelect={() => {
|
||||||
>
|
requestAnimationFrame(openPrPicker);
|
||||||
<Icon name="git-pull-request"/>
|
}}
|
||||||
{t('chat.chatInput.actions.linkGithubPr')}
|
>
|
||||||
</DropdownMenuItem>
|
<Icon name="git-pull-request"/>
|
||||||
|
{t('chat.chatInput.actions.linkGithubPr')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
@@ -136,6 +144,7 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
|
|||||||
prev.isVSCode === next.isVSCode
|
prev.isVSCode === next.isVSCode
|
||||||
&& prev.footerIconButtonClass === next.footerIconButtonClass
|
&& prev.footerIconButtonClass === next.footerIconButtonClass
|
||||||
&& prev.iconSizeClass === next.iconSizeClass
|
&& prev.iconSizeClass === next.iconSizeClass
|
||||||
|
&& prev.gitProvider === next.gitProvider
|
||||||
&& prev.onOpenSettings === next.onOpenSettings
|
&& prev.onOpenSettings === next.onOpenSettings
|
||||||
&& prev.onMenuOpenChange === next.onMenuOpenChange
|
&& prev.onMenuOpenChange === next.onMenuOpenChange
|
||||||
&& prev.onOpenMobileSheet === next.onOpenMobileSheet
|
&& prev.onOpenMobileSheet === next.onOpenMobileSheet
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ComposerDictation } from '@/components/dictation/ComposerDictation';
|
|||||||
import { Icon } from '@/components/icon/Icon';
|
import { Icon } from '@/components/icon/Icon';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useGitProvider } from '@/lib/gitProvider';
|
||||||
import { ModelControls } from '../../ModelControls';
|
import { ModelControls } from '../../ModelControls';
|
||||||
import { ComposerActionButtons } from './ComposerActionButtons';
|
import { ComposerActionButtons } from './ComposerActionButtons';
|
||||||
import { ComposerAttachmentControls } from './ComposerAttachmentControls';
|
import { ComposerAttachmentControls } from './ComposerAttachmentControls';
|
||||||
@@ -106,6 +107,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
|||||||
onDictationContentHeightChange,
|
onDictationContentHeightChange,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const gitProvider = useGitProvider(directory);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -130,6 +133,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
|||||||
handlePickLocalFiles={onPickLocalFiles}
|
handlePickLocalFiles={onPickLocalFiles}
|
||||||
openIssuePicker={onOpenIssuePicker}
|
openIssuePicker={onOpenIssuePicker}
|
||||||
openPrPicker={onOpenPrPicker}
|
openPrPicker={onOpenPrPicker}
|
||||||
|
gitProvider={gitProvider}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
onOpenMobileSheet={onOpenAttachSheet}
|
onOpenMobileSheet={onOpenAttachSheet}
|
||||||
/>
|
/>
|
||||||
@@ -199,6 +203,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
|||||||
handlePickLocalFiles={onPickLocalFiles}
|
handlePickLocalFiles={onPickLocalFiles}
|
||||||
openIssuePicker={onOpenIssuePicker}
|
openIssuePicker={onOpenIssuePicker}
|
||||||
openPrPicker={onOpenPrPicker}
|
openPrPicker={onOpenPrPicker}
|
||||||
|
gitProvider={gitProvider}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
/>
|
/>
|
||||||
<FocusModeButton
|
<FocusModeButton
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { SessionGoalRow } from '@/components/chat/SessionGoalRow';
|
|||||||
import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
|
import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useGitProvider } from '@/lib/gitProvider';
|
||||||
import type { Theme } from '@/types/theme';
|
import type { Theme } from '@/types/theme';
|
||||||
import { ComposerAttachmentControls } from './ComposerAttachmentControls';
|
import { ComposerAttachmentControls } from './ComposerAttachmentControls';
|
||||||
|
|
||||||
@@ -68,6 +69,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
|
|||||||
onAbort,
|
onAbort,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const gitProvider = useGitProvider(directory);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<SessionGoalRow
|
<SessionGoalRow
|
||||||
@@ -94,6 +97,7 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
|
|||||||
handlePickLocalFiles={onPickLocalFiles}
|
handlePickLocalFiles={onPickLocalFiles}
|
||||||
openIssuePicker={onOpenIssuePicker}
|
openIssuePicker={onOpenIssuePicker}
|
||||||
openPrPicker={onOpenPrPicker}
|
openPrPicker={onOpenPrPicker}
|
||||||
|
gitProvider={gitProvider}
|
||||||
onOpenMobileSheet={onOpenAttachSheet}
|
onOpenMobileSheet={onOpenAttachSheet}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { useBrowserFaviconStore } from '@/stores/useBrowserFaviconStore';
|
import { useBrowserFaviconStore } from '@/stores/useBrowserFaviconStore';
|
||||||
|
import { useGitProvider } from '@/lib/gitProvider';
|
||||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||||
import { useUIStore, type ContextPanelMode, type PendingDiffScope } from '@/stores/useUIStore';
|
import { useUIStore, type ContextPanelMode, type PendingDiffScope } from '@/stores/useUIStore';
|
||||||
import { markSessionViewed } from '@/sync/notification-store';
|
import { markSessionViewed } from '@/sync/notification-store';
|
||||||
@@ -440,6 +441,7 @@ export const ContextPanel: React.FC = () => {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
||||||
const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]);
|
const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]);
|
||||||
|
const gitProvider = useGitProvider(effectiveDirectory);
|
||||||
|
|
||||||
const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined));
|
const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined));
|
||||||
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
|
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
|
||||||
@@ -936,7 +938,7 @@ export const ContextPanel: React.FC = () => {
|
|||||||
: activeTab?.mode === 'git'
|
: activeTab?.mode === 'git'
|
||||||
? <React.Suspense fallback={null}><GitView isActive={isOpen} /></React.Suspense>
|
? <React.Suspense fallback={null}><GitView isActive={isOpen} /></React.Suspense>
|
||||||
: activeTab?.mode === 'pr'
|
: activeTab?.mode === 'pr'
|
||||||
? <PullRequestView />
|
? (gitProvider === 'github' ? <PullRequestView /> : null)
|
||||||
: activeTab?.mode === 'notes'
|
: activeTab?.mode === 'notes'
|
||||||
? <ProjectContextPanel />
|
? <ProjectContextPanel />
|
||||||
: activeTab?.mode === 'plan'
|
: activeTab?.mode === 'plan'
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
|||||||
import { useSelectionStore } from '@/sync/selection-store';
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
import * as sessionActions from '@/sync/session-actions';
|
import * as sessionActions from '@/sync/session-actions';
|
||||||
import { buildLinkedIssue } from '@/lib/linkedIssues';
|
import { buildLinkedIssue } from '@/lib/linkedIssues';
|
||||||
|
import { useGitProvider } from '@/lib/gitProvider';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
|
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
|
||||||
import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate';
|
import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate';
|
||||||
@@ -1307,6 +1308,13 @@ export function NewWorktreeDialog({
|
|||||||
// GitLab connection check
|
// GitLab connection check
|
||||||
const isGitLabConnected = gitlabAuthChecked && gitlabAuthStatus?.connected === true;
|
const isGitLabConnected = gitlabAuthChecked && gitlabAuthStatus?.connected === true;
|
||||||
|
|
||||||
|
// Only offer the provider's start-from flow when the repo actually belongs
|
||||||
|
// to that provider: a GitLab repo must not surface the GitHub picker and
|
||||||
|
// vice versa.
|
||||||
|
const gitProvider = useGitProvider(projectDirectory);
|
||||||
|
const showGitHubStartFrom = isGitHubConnected && gitProvider === 'github';
|
||||||
|
const showGitLabStartFrom = isGitLabConnected && gitProvider === 'gitlab';
|
||||||
|
|
||||||
// Check if form is valid for submission
|
// Check if form is valid for submission
|
||||||
const isFormValid = mode === 'existing-branch'
|
const isFormValid = mode === 'existing-branch'
|
||||||
? !!existingBranchState.selectedBranch && !!existingBranchState.worktreeName && !validation.branchError && !validation.worktreeError
|
? !!existingBranchState.selectedBranch && !!existingBranchState.worktreeName && !validation.branchError && !validation.worktreeError
|
||||||
@@ -1557,9 +1565,9 @@ export function NewWorktreeDialog({
|
|||||||
<label className="typography-ui-label text-foreground block font-semibold">
|
<label className="typography-ui-label text-foreground block font-semibold">
|
||||||
{t('session.newWorktree.branchName')}
|
{t('session.newWorktree.branchName')}
|
||||||
</label>
|
</label>
|
||||||
{mode === 'new-branch' && (isGitHubConnected || isGitLabConnected) && (
|
{mode === 'new-branch' && (showGitHubStartFrom || showGitLabStartFrom) && (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{isGitHubConnected && (
|
{showGitHubStartFrom && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1570,7 +1578,7 @@ export function NewWorktreeDialog({
|
|||||||
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isGitLabConnected && (
|
{showGitLabStartFrom && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -2085,9 +2093,9 @@ export function NewWorktreeDialog({
|
|||||||
<label className="typography-ui-label text-foreground block font-semibold">
|
<label className="typography-ui-label text-foreground block font-semibold">
|
||||||
{t('session.newWorktree.branchName')}
|
{t('session.newWorktree.branchName')}
|
||||||
</label>
|
</label>
|
||||||
{mode === 'new-branch' && (isGitHubConnected || isGitLabConnected) && (
|
{mode === 'new-branch' && (showGitHubStartFrom || showGitLabStartFrom) && (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{isGitHubConnected && (
|
{showGitHubStartFrom && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -2098,7 +2106,7 @@ export function NewWorktreeDialog({
|
|||||||
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isGitLabConnected && (
|
{showGitLabStartFrom && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useI18n, type Locale } from '@/lib/i18n';
|
import { useI18n, type Locale } from '@/lib/i18n';
|
||||||
import { openExternalUrl } from '@/lib/url';
|
import { openExternalUrl } from '@/lib/url';
|
||||||
|
import { useGitProvider } from '@/lib/gitProvider';
|
||||||
import { buildWalkthroughView } from '@/lib/walkthrough/model';
|
import { buildWalkthroughView } from '@/lib/walkthrough/model';
|
||||||
import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types';
|
import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types';
|
||||||
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
|
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
|
||||||
@@ -208,9 +209,10 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
|
|||||||
const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
|
const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
|
||||||
const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams);
|
const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams);
|
||||||
const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
|
const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
|
||||||
|
const gitProvider = useGitProvider(directory);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!directory || !currentBranch || !githubAuthChecked || !githubConnected) return;
|
if (!directory || !currentBranch || !githubAuthChecked || !githubConnected || gitProvider !== 'github') return;
|
||||||
const key = getGitHubPrStatusKey(directory, currentBranch);
|
const key = getGitHubPrStatusKey(directory, currentBranch);
|
||||||
ensurePrStatusEntry(key);
|
ensurePrStatusEntry(key);
|
||||||
setPrStatusParams(key, {
|
setPrStatusParams(key, {
|
||||||
@@ -230,6 +232,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
|
|||||||
github,
|
github,
|
||||||
githubAuthChecked,
|
githubAuthChecked,
|
||||||
githubConnected,
|
githubConnected,
|
||||||
|
gitProvider,
|
||||||
refreshPrStatusTargets,
|
refreshPrStatusTargets,
|
||||||
setPrStatusParams,
|
setPrStatusParams,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { detectGitProvider } from './gitProvider';
|
||||||
|
|
||||||
|
describe('detectGitProvider', () => {
|
||||||
|
test('returns null with no remotes', () => {
|
||||||
|
expect(detectGitProvider([], [])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifies github.com remotes across URL forms', () => {
|
||||||
|
expect(detectGitProvider(['git@github.com:owner/repo.git'], [])).toBe('github');
|
||||||
|
expect(detectGitProvider(['ssh://git@github.com/owner/repo.git'], [])).toBe('github');
|
||||||
|
expect(detectGitProvider(['https://github.com/owner/repo.git'], [])).toBe('github');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifies gitlab.com remotes across URL forms', () => {
|
||||||
|
expect(detectGitProvider(['git@gitlab.com:group/project.git'], [])).toBe('gitlab');
|
||||||
|
expect(detectGitProvider(['ssh://git@gitlab.com/group/sub/project.git'], [])).toBe('gitlab');
|
||||||
|
expect(detectGitProvider(['https://gitlab.com/group/project'], [])).toBe('gitlab');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifies a self-hosted GitLab remote through connected account hosts', () => {
|
||||||
|
expect(detectGitProvider(['git@git.example.com:group/project.git'], ['https://git.example.com'])).toBe('gitlab');
|
||||||
|
expect(detectGitProvider(['https://git.example.com/group/project.git'], ['https://git.example.com'])).toBe('gitlab');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not classify an unknown self-hosted host as gitlab without an account', () => {
|
||||||
|
expect(detectGitProvider(['git@git.example.com:group/project.git'], [])).toBe('other');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifies other hosts as other', () => {
|
||||||
|
expect(detectGitProvider(['git@gitea.example.com:owner/repo.git'], [])).toBe('other');
|
||||||
|
expect(detectGitProvider(['https://bitbucket.org/owner/repo.git'], [])).toBe('other');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('github wins when both github and gitlab remotes are present', () => {
|
||||||
|
expect(detectGitProvider([
|
||||||
|
'git@github.com:owner/repo.git',
|
||||||
|
'git@gitlab.com:owner/repo.git',
|
||||||
|
], [])).toBe('github');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores malformed remotes', () => {
|
||||||
|
expect(detectGitProvider(['', 'not a url', ' '], [])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { getRemotes } from '@/lib/gitApi';
|
||||||
|
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
|
||||||
|
|
||||||
|
export type GitProvider = 'github' | 'gitlab' | 'other';
|
||||||
|
|
||||||
|
const parseGitRemoteHost = (value: string): string | null => {
|
||||||
|
const url = value.trim();
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// scp-like form: git@host:owner/repo.git
|
||||||
|
const at = url.indexOf('@');
|
||||||
|
if (at >= 0) {
|
||||||
|
const rest = url.slice(at + 1);
|
||||||
|
const colon = rest.indexOf(':');
|
||||||
|
if (colon > 0 && !rest.slice(0, colon).includes('/')) {
|
||||||
|
return rest.slice(0, colon).toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url.includes('://') ? url : `ssh://${url}`);
|
||||||
|
return parsed.hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeGitLabHost = (baseUrl: string | undefined): string | null => {
|
||||||
|
if (!baseUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(baseUrl).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a repository by the hosts of its remotes. Returns null when there
|
||||||
|
* are no remotes to inspect. GitHub wins on `github.com`; a remote is treated
|
||||||
|
* as GitLab when it is `gitlab.com` or matches a connected GitLab instance
|
||||||
|
* base URL (self-hosted). Anything else resolves to 'other' so GitHub-branded
|
||||||
|
* UI is not offered for non-GitHub repositories.
|
||||||
|
*/
|
||||||
|
export const detectGitProvider = (fetchUrls: string[], gitlabHosts: string[]): GitProvider | null => {
|
||||||
|
const hosts = new Set<string>();
|
||||||
|
for (const url of fetchUrls) {
|
||||||
|
const host = parseGitRemoteHost(url);
|
||||||
|
if (host) {
|
||||||
|
hosts.add(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hosts.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (hosts.has('github.com')) {
|
||||||
|
return 'github';
|
||||||
|
}
|
||||||
|
const gitlabHostsSet = new Set([
|
||||||
|
'gitlab.com',
|
||||||
|
...gitlabHosts.map(normalizeGitLabHost).filter((host): host is string => Boolean(host)),
|
||||||
|
]);
|
||||||
|
for (const host of hosts) {
|
||||||
|
if (gitlabHostsSet.has(host)) {
|
||||||
|
return 'gitlab';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESOLVE_CACHE_TTL_MS = 60_000;
|
||||||
|
const resolveCache = new Map<string, { at: number; provider: GitProvider | null }>();
|
||||||
|
|
||||||
|
export const resolveGitProvider = async (directory: string, gitlabHosts: string[]): Promise<GitProvider | null> => {
|
||||||
|
const cached = resolveCache.get(directory);
|
||||||
|
if (cached && Date.now() - cached.at < RESOLVE_CACHE_TTL_MS) {
|
||||||
|
return cached.provider;
|
||||||
|
}
|
||||||
|
let provider: GitProvider | null = null;
|
||||||
|
try {
|
||||||
|
const remotes = await getRemotes(directory);
|
||||||
|
provider = detectGitProvider(remotes.map((remote) => remote.fetchUrl), gitlabHosts);
|
||||||
|
} catch {
|
||||||
|
provider = null;
|
||||||
|
}
|
||||||
|
resolveCache.set(directory, { at: Date.now(), provider });
|
||||||
|
return provider;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the git provider (github | gitlab | other) of a working directory.
|
||||||
|
* Self-hosted GitLab instances are recognized through the connected GitLab
|
||||||
|
* accounts' base URLs, so the classification stays null/'other' (never
|
||||||
|
* 'github') when no account is known yet — GitHub UI must not leak into a
|
||||||
|
* GitLab repo regardless of auth state.
|
||||||
|
*/
|
||||||
|
export const useGitProvider = (directory: string | null | undefined): GitProvider | null => {
|
||||||
|
const gitlabAccounts = useGitLabAuthStore((state) => state.status?.accounts);
|
||||||
|
const gitlabHosts = useMemo(
|
||||||
|
() => (gitlabAccounts ?? []).map((account) => account.baseUrl),
|
||||||
|
[gitlabAccounts],
|
||||||
|
);
|
||||||
|
const [provider, setProvider] = useState<GitProvider | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!directory) {
|
||||||
|
setProvider(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void resolveGitProvider(directory, gitlabHosts).then((resolved) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setProvider(resolved);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [directory, gitlabHosts]);
|
||||||
|
|
||||||
|
return provider;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user