diff --git a/packages/ui/src/components/views/GitLabMrView.tsx b/packages/ui/src/components/views/GitLabMrView.tsx index c81d74fb..2628e1c9 100644 --- a/packages/ui/src/components/views/GitLabMrView.tsx +++ b/packages/ui/src/components/views/GitLabMrView.tsx @@ -14,6 +14,10 @@ import { openExternalUrl } from '@/lib/url'; import { formatDateTimeForPreference } from '@/lib/timeFormat'; import type { GitLabMergeRequestContextResult, GitLabMergeRequestSummary } 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 { Textarea } from '@/components/ui/textarea'; const mrStateColor = (state: string): string => { switch (state) { @@ -199,6 +203,19 @@ export const GitLabMrView: React.FC = () => { setContextError(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; @@ -226,6 +243,175 @@ export const GitLabMrView: React.FC = () => { } }, [contextOpen, currentDirectory, gitlab, t]); + // ---- Create / update / merge actions ----------------------------------- + + const [createTitle, setCreateTitle] = React.useState(''); + const [createDescription, setCreateDescription] = React.useState(''); + const [createTargetBranch, setCreateTargetBranch] = React.useState('main'); + const [createRemoveSourceBranch, setCreateRemoveSourceBranch] = React.useState(false); + const [creating, setCreating] = React.useState(false); + const createTargetTouchedRef = React.useRef(false); + + // 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(defaultTargetBranch); + }, [branchMr, branchMrLoading, defaultTargetBranch]); + + 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: currentBranch, + 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(defaultTargetBranch); + } catch (error) { + toast.error(t('contextPanel.gitlabMr.createMr.toast.createFailed'), { + description: error instanceof Error ? error.message : String(error), + }); + } finally { + setCreating(false); + } + }, [createDescription, createRemoveSourceBranch, createTargetBranch, createTitle, currentBranch, currentDirectory, 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]); + const formatTimestamp = React.useCallback((value?: string) => { if (!value) { return ''; @@ -361,8 +547,92 @@ export const GitLabMrView: React.FC = () => { )} {contextOpen ? t('contextPanel.gitlabMr.hideContext') : t('contextPanel.gitlabMr.loadContext')} + {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' ? ( +
+ +