import React from 'react'; import { useShallow } from 'zustand/react/shallow'; import { Icon } from '@/components/icon/Icon'; import { Button } from '@/components/ui/button'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { GitLabIssuesSection } from '@/components/views/git/GitLabIssuesSection'; import { ForgeEntityDetailView } from '@/components/views/forge'; import { buildForgeProvider } from '@/lib/forge'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useGitStatus, useGitStore } from '@/stores/useGitStore'; import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; import { useUIStore } from '@/stores/useUIStore'; import { openExternalUrl } from '@/lib/url'; import type { GitLabMergeRequestContextResult, GitLabMergeRequestSummary, GitLabRepoRef } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; import { toast } from '@/components/ui'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; const mrStateColor = (state: string): string => { switch (state) { case 'merged': return 'var(--pr-merged)'; case 'closed': return 'var(--pr-closed)'; default: return 'var(--pr-open)'; } }; const mrAuthorLabel = (mr: GitLabMergeRequestSummary): string => mr.author?.name?.trim() || mr.author?.username || ''; const draftBadgeClass = 'inline-flex items-center rounded border border-border/60 bg-surface-elevated px-1.5 py-px typography-micro text-foreground'; /** * Read-only GitLab merge request surface for the context panel. Resolves the * same repository context GitView uses (effective directory + current branch * from the shared git stores) and renders the branch's merge request plus the * repository's open merge requests. v1 is intentionally read-only: no create, * update, or merge actions. */ export const GitLabMrView: React.FC = () => { const { t } = useI18n(); const { git, gitlab } = useRuntimeAPIs(); const currentDirectory = useEffectiveDirectory(); const status = useGitStatus(currentDirectory ?? null); const { ensureAll } = useGitStore(useShallow((state) => ({ ensureAll: state.ensureAll }))); const gitlabAuthStatus = useGitLabAuthStore((state) => state.status); const gitlabAuthChecked = useGitLabAuthStore((state) => state.hasChecked); const refreshGitLabStatus = useGitLabAuthStore((state) => state.refreshStatus); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); React.useEffect(() => { if (!currentDirectory || !git) { return; } void ensureAll(currentDirectory, git); }, [currentDirectory, ensureAll, git]); // Settle the connection state exactly once; the store dedupes in-flight // refreshes so remounts never pile up status requests. React.useEffect(() => { if (gitlabAuthChecked) { return; } void refreshGitLabStatus(gitlab); }, [gitlab, gitlabAuthChecked, refreshGitLabStatus]); const currentBranch = status?.current ?? null; const connected = gitlabAuthChecked ? gitlabAuthStatus?.connected === true : null; const openGitLabSettings = React.useCallback(() => { setSettingsPage('git'); setSettingsDialogOpen(true); }, [setSettingsDialogOpen, setSettingsPage]); // Local tab selection between the merge-request and issues surfaces. Not // persisted: reopening the panel always lands on merge requests. const [activeTab, setActiveTab] = React.useState<'mr' | 'issues'>('mr'); // ---- Current-branch merge request -------------------------------------- const [branchMr, setBranchMr] = React.useState(null); const [branchMrLoading, setBranchMrLoading] = React.useState(false); const [branchMrError, setBranchMrError] = React.useState(null); const [retryToken, setRetryToken] = React.useState(0); const [repoRef, setRepoRef] = React.useState(null); const retry = React.useCallback(() => setRetryToken((value) => value + 1), []); React.useEffect(() => { if (!currentDirectory || !currentBranch || !connected || !gitlab?.mrsList) { return; } let cancelled = false; setBranchMrLoading(true); setBranchMrError(null); // Re-resolving the repo context invalidates the previously fetched branch // list so a stale repo's branches never leak into the create form. setRepoRef(null); setBranches([]); setDefaultBranch(null); void gitlab .mrsList(currentDirectory, { sourceBranch: currentBranch }) .then((result) => { if (cancelled) { return; } const candidates = result.mrs ?? []; // Prefer the open MR for the branch; fall back to a merged one so a // just-merged branch still shows its request instead of nothing. const matching = candidates.find((mr) => mr.state === 'opened') ?? candidates.find((mr) => mr.state === 'merged') ?? null; setBranchMr(matching); setRepoRef(result.repo ?? null); }) .catch((error) => { if (!cancelled) { setBranchMrError(error instanceof Error ? error.message : String(error)); } }) .finally(() => { if (!cancelled) { setBranchMrLoading(false); } }); return () => { cancelled = true; }; }, [connected, currentBranch, currentDirectory, gitlab, retryToken]); // ---- Open merge requests in this repository ---------------------------- const [openMrs, setOpenMrs] = React.useState([]); const [listPage, setListPage] = React.useState(1); const [listHasMore, setListHasMore] = React.useState(false); const [listLoading, setListLoading] = React.useState(false); const [listLoadingMore, setListLoadingMore] = React.useState(false); const [listError, setListError] = React.useState(null); React.useEffect(() => { if (!currentDirectory || !connected || !gitlab?.mrsList) { return; } let cancelled = false; setListLoading(true); setListError(null); void gitlab .mrsList(currentDirectory, { page: 1 }) .then((result) => { if (cancelled) { return; } setOpenMrs(result.mrs ?? []); setListPage(result.page ?? 1); setListHasMore(Boolean(result.hasMore)); }) .catch((error) => { if (!cancelled) { setListError(error instanceof Error ? error.message : String(error)); } }) .finally(() => { if (!cancelled) { setListLoading(false); } }); return () => { cancelled = true; }; }, [connected, currentDirectory, gitlab, retryToken]); const loadMore = React.useCallback(async () => { if (!currentDirectory || !connected || !gitlab?.mrsList) { return; } if (listLoadingMore || listLoading || !listHasMore) { return; } setListLoadingMore(true); try { const next = await gitlab.mrsList(currentDirectory, { page: listPage + 1 }); setOpenMrs((previous) => [...previous, ...(next.mrs ?? [])]); setListPage(next.page ?? listPage + 1); setListHasMore(Boolean(next.hasMore)); } catch (error) { setListError(error instanceof Error ? error.message : String(error)); } finally { setListLoadingMore(false); } }, [connected, currentDirectory, gitlab, listHasMore, listLoading, listLoadingMore, listPage]); // ---- Inline MR context (current-branch MR only) ------------------------ const [contextOpen, setContextOpen] = React.useState(false); const [contextResult, setContextResult] = React.useState(null); const [contextLoading, setContextLoading] = React.useState(false); // A different branch MR invalidates any previously loaded context. React.useEffect(() => { setContextOpen(false); setContextResult(null); }, [branchMr?.number]); // A different branch MR invalidates the update/merge transient state so the // previous MR's edit form, squash flag, and in-flight requests don't leak. React.useEffect(() => { setUpdateOpen(false); setEditTitle(''); setEditDescription(''); setEditDescriptionKnown(false); setEditDescriptionLoading(false); setUpdating(false); setMergeSquash(false); setMerging(false); }, [branchMr?.number]); const toggleContext = React.useCallback(async (mr: GitLabMergeRequestSummary) => { if (!currentDirectory || !gitlab?.mrContext) { return; } if (contextOpen) { setContextOpen(false); setContextResult(null); return; } setContextOpen(true); setContextLoading(true); try { const result = await gitlab.mrContext(currentDirectory, mr.number, { includeDiff: false }); setContextResult(result.connected === false ? null : result); } catch { setContextResult(null); } finally { setContextLoading(false); } }, [contextOpen, currentDirectory, gitlab]); // Shared rich view for the branch MR's detail (title/body/chips/commits/ // files/timeline). Owns its own fetching through the forge facade. const mrProvider = React.useMemo(() => (gitlab ? buildForgeProvider('gitlab', { gitlab }) : null), [gitlab]); // ---- Create / update / merge actions ----------------------------------- const [createTitle, setCreateTitle] = React.useState(''); const [createDescription, setCreateDescription] = React.useState(''); const [createSourceBranch, setCreateSourceBranch] = React.useState(currentBranch ?? ''); const [createTargetBranch, setCreateTargetBranch] = React.useState('main'); const [createRemoveSourceBranch, setCreateRemoveSourceBranch] = React.useState(false); const [creating, setCreating] = React.useState(false); const createTargetTouchedRef = React.useRef(false); // Repository branches for the source/target dropdowns, fetched lazily once // the create form is visible. const [branches, setBranches] = React.useState([]); const [defaultBranch, setDefaultBranch] = React.useState(null); const [branchesLoading, setBranchesLoading] = React.useState(false); // The current branch is only known after git status resolves, so adopt it as // the default source branch when it arrives without clobbering a pick. React.useEffect(() => { if (currentBranch) { setCreateSourceBranch((previous) => previous || currentBranch); } }, [currentBranch]); // The default target branch is the target of the repository's previously // listed open MRs when available; otherwise fall back to main. const defaultTargetBranch = React.useMemo( () => openMrs.find((mr) => mr.targetBranch)?.targetBranch ?? 'main', [openMrs], ); // Adopt the repository's target branch default once the open-MR list // resolves, unless the user has already typed into the field. React.useEffect(() => { if (branchMrLoading || branchMr || createTargetTouchedRef.current) { return; } setCreateTargetBranch(defaultBranch ?? defaultTargetBranch); }, [branchMr, branchMrLoading, defaultBranch, defaultTargetBranch]); // The source dropdown must always offer the picked/current branch, even // before the branch list resolves. const sourceBranchOptions = React.useMemo(() => { if (!createSourceBranch) { return branches; } return branches.includes(createSourceBranch) ? branches : [createSourceBranch, ...branches]; }, [branches, createSourceBranch]); // A merge request cannot target its own source branch once there is more // than one branch to choose from. const targetBranchOptions = React.useMemo( () => (branches.length >= 2 ? branches.filter((branch) => branch !== createSourceBranch) : branches), [branches, createSourceBranch], ); // Fetch the repository's branches lazily once the create form is visible so // the source/target dropdowns can offer real values. Failure surfaces as a // toast and leaves the dropdowns on the current-branch fallback. React.useEffect(() => { if (!repoRef || branchMr || !connected || !gitlab?.repoBranches) { return; } let cancelled = false; setBranchesLoading(true); void gitlab .repoBranches(repoRef.namespace, repoRef.project) .then((result) => { if (cancelled) { return; } setBranches(result.branches ?? []); setDefaultBranch(result.defaultBranch ?? null); }) .catch((error) => { if (cancelled) { return; } setBranches([]); setDefaultBranch(null); toast.error(t('contextPanel.gitlabMr.error.loadFailed'), { description: error instanceof Error ? error.message : String(error), }); }) .finally(() => { if (!cancelled) { setBranchesLoading(false); } }); return () => { cancelled = true; }; }, [branchMr, connected, gitlab, repoRef, t]); const [updateOpen, setUpdateOpen] = React.useState(false); const [editTitle, setEditTitle] = React.useState(''); const [editDescription, setEditDescription] = React.useState(''); const [editDescriptionKnown, setEditDescriptionKnown] = React.useState(false); const [editDescriptionLoading, setEditDescriptionLoading] = React.useState(false); const [updating, setUpdating] = React.useState(false); const [mergeSquash, setMergeSquash] = React.useState(false); const [merging, setMerging] = React.useState(false); const createMr = React.useCallback(async () => { if (!currentDirectory || !currentBranch || !gitlab?.mrCreate) { return; } const targetBranch = createTargetBranch.trim(); if (!targetBranch) { return; } setCreating(true); try { const created = await gitlab.mrCreate({ directory: currentDirectory, title: createTitle.trim() || currentBranch, sourceBranch: createSourceBranch, targetBranch, ...(createDescription.trim() ? { description: createDescription } : {}), ...(createRemoveSourceBranch ? { removeSourceBranch: true } : {}), }); toast.success(t('contextPanel.gitlabMr.createMr.toast.created')); // Show the created MR immediately and refresh both the branch MR and // the open list so the card flips to the opened state. setBranchMr(created); setRetryToken((value) => value + 1); // Clear the form. setCreateTitle(''); setCreateDescription(''); setCreateRemoveSourceBranch(false); createTargetTouchedRef.current = false; setCreateTargetBranch(defaultBranch ?? defaultTargetBranch); } catch (error) { toast.error(t('contextPanel.gitlabMr.createMr.toast.createFailed'), { description: error instanceof Error ? error.message : String(error), }); } finally { setCreating(false); } }, [createDescription, createRemoveSourceBranch, createSourceBranch, createTargetBranch, createTitle, currentBranch, currentDirectory, defaultBranch, defaultTargetBranch, gitlab, t]); const toggleUpdate = React.useCallback(async () => { if (!branchMr) { return; } if (updateOpen) { setUpdateOpen(false); return; } setUpdateOpen(true); setEditTitle(branchMr.title); const knownBody = contextResult?.mr?.body; if (typeof knownBody === 'string') { setEditDescription(knownBody); setEditDescriptionKnown(true); return; } setEditDescription(''); setEditDescriptionKnown(false); if (!currentDirectory || !gitlab?.mrContext) { return; } setEditDescriptionLoading(true); try { const result = await gitlab.mrContext(currentDirectory, branchMr.number, { includeDiff: false }); if (result.connected === false) { setEditDescription(''); return; } setEditDescription(result.mr?.body ?? ''); setEditDescriptionKnown(true); } catch { // Leave the description empty; the title can still be edited. } finally { setEditDescriptionLoading(false); } }, [branchMr, contextResult?.mr?.body, currentDirectory, gitlab, updateOpen]); const saveMr = React.useCallback(async () => { if (!currentDirectory || !branchMr || !gitlab?.mrUpdate) { return; } const trimmedTitle = editTitle.trim(); if (!trimmedTitle) { return; } setUpdating(true); try { await gitlab.mrUpdate({ directory: currentDirectory, number: branchMr.number, title: trimmedTitle, // Only send the description when it was actually loaded so an // unresolved description can never be wiped out by a title-only save. ...(editDescriptionKnown ? { description: editDescription } : {}), }); toast.success(t('contextPanel.gitlabMr.updateMr.toast.updated')); setUpdateOpen(false); setRetryToken((value) => value + 1); } catch (error) { toast.error(t('contextPanel.gitlabMr.updateMr.toast.updateFailed'), { description: error instanceof Error ? error.message : String(error), }); } finally { setUpdating(false); } }, [branchMr, currentDirectory, editDescription, editDescriptionKnown, editTitle, gitlab, t]); const mergeMr = React.useCallback(async () => { if (!currentDirectory || !branchMr || !gitlab?.mrMerge) { return; } setMerging(true); try { const result = await gitlab.mrMerge({ directory: currentDirectory, number: branchMr.number, ...(mergeSquash ? { squash: true } : {}), }); if (result.merged) { toast.success(t('contextPanel.gitlabMr.mergeMr.toast.merged')); } else { toast.error(t('contextPanel.gitlabMr.mergeMr.toast.mergeFailed'), { ...(result.message ? { description: result.message } : {}), }); } // Refresh the branch MR (flips to the merged state) and the open list. setRetryToken((value) => value + 1); } catch (error) { toast.error(t('contextPanel.gitlabMr.mergeMr.toast.mergeFailed'), { description: error instanceof Error ? error.message : String(error), }); } finally { setMerging(false); } }, [branchMr, currentDirectory, gitlab, mergeSquash, t]); // ---- Render ------------------------------------------------------------ if (!currentDirectory) { return (
{t('contextPanel.gitlabMr.title')}
{t('contextPanel.gitlabMr.empty.noActiveProject')}
); } if (connected === null) { return (
{t('contextPanel.gitlabMr.loading')}
); } if (connected === false) { return (
{t('contextPanel.gitlabMr.error.notConnected')}
); } const branchMrStateLabel = branchMr ? branchMr.state === 'merged' ? t('contextPanel.gitlabMr.state.merged') : branchMr.state === 'closed' ? t('contextPanel.gitlabMr.state.closed') : t('contextPanel.gitlabMr.state.opened') : ''; const branchMrAuthor = branchMr ? mrAuthorLabel(branchMr) : ''; return (
setActiveTab(tabId as 'mr' | 'issues')} layoutMode="fit" variant="active-pill" activePillButtonClassName="h-7" />
{activeTab === 'mr' ? ( <>
{t('contextPanel.gitlabMr.title')}
{t('contextPanel.gitlabMr.listSectionTitle')}
{/* Current-branch merge request */}

{t('contextPanel.gitlabMr.branchSectionTitle')}

{branchMrLoading ? (
{t('contextPanel.gitlabMr.loading')}
) : branchMrError ? (
{t('contextPanel.gitlabMr.error.loadFailed')}
{branchMrError}
) : branchMr ? (
!{branchMr.number} {branchMr.title}
{branchMr.draft ? ( {t('contextPanel.gitlabMr.draft')} ) : null} {branchMrStateLabel} {branchMr.sourceBranch} → {branchMr.targetBranch}
{branchMrAuthor ? (
{branchMrAuthor}
) : null}
{branchMr.state === 'opened' ? ( <>
setMergeSquash((value) => !value)} onKeyDown={(event) => { if (event.key === ' ' || event.key === 'Enter') { event.preventDefault(); setMergeSquash((value) => !value); } }} > setMergeSquash(next)} ariaLabel={t('contextPanel.gitlabMr.mergeMr.squash')} /> {t('contextPanel.gitlabMr.mergeMr.squash')}
) : null}
{updateOpen && branchMr.state === 'opened' ? (