feat(ui): start worktree sessions from GitLab issues and merge requests
This commit is contained in:
@@ -85,6 +85,22 @@ const PROMPT_PAGE_MAP: Record<string, PromptPageConfig> = {
|
||||
{ id: 'github.pr.comment.single.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'gitlab.pr.review': {
|
||||
titleKey: 'settings.magicPrompts.page.group.gitlabPrReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.gitlabPrReview.description',
|
||||
blocks: [
|
||||
{ id: 'gitlab.pr.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'gitlab.pr.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'gitlab.issue.review': {
|
||||
titleKey: 'settings.magicPrompts.page.group.gitlabIssueReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.gitlabIssueReview.description',
|
||||
blocks: [
|
||||
{ id: 'gitlab.issue.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'gitlab.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'git.conflict.resolve': {
|
||||
titleKey: 'settings.magicPrompts.page.group.gitConflictResolve.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.gitConflictResolve.description',
|
||||
|
||||
@@ -35,6 +35,13 @@ export const MagicPromptsSidebar: React.FC<MagicPromptsSidebarProps> = ({ onItem
|
||||
{ id: 'github.pr.comment.single', titleKey: 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview' },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupKey: 'settings.magicPrompts.sidebar.group.gitlab',
|
||||
items: [
|
||||
{ id: 'gitlab.pr.review', titleKey: 'settings.magicPrompts.sidebar.item.gitlabPrReview' },
|
||||
{ id: 'gitlab.issue.review', titleKey: 'settings.magicPrompts.sidebar.item.gitlabIssueReview' },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupKey: 'settings.magicPrompts.sidebar.group.planning',
|
||||
items: [
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
|
||||
import { validateWorktreeCreate } from '@/lib/worktrees/worktreeManager';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import type {
|
||||
GitLabIssueSummary,
|
||||
GitLabMergeRequestSummary,
|
||||
} from '@/lib/api/types';
|
||||
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type GitLabTab = 'issues' | 'mrs';
|
||||
|
||||
interface GitLabIntegrationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (result: {
|
||||
type: 'issue';
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
} | {
|
||||
type: 'mr';
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
sourceBranch: string;
|
||||
includeDiff: boolean;
|
||||
} | null) => void;
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
isValid: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function GitLabIntegrationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: GitLabIntegrationDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const gitlab = getRegisteredRuntimeAPIs()?.gitlab;
|
||||
const gitlabAuthStatus = useGitLabAuthStore((state) => state.status);
|
||||
const gitlabAuthChecked = useGitLabAuthStore((state) => state.hasChecked);
|
||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||
|
||||
const projectDirectory = activeProject?.path ?? null;
|
||||
const projectRef: ProjectRef | null = React.useMemo(() => {
|
||||
if (projectDirectory && activeProject) {
|
||||
return { id: activeProject.id, path: projectDirectory };
|
||||
}
|
||||
return null;
|
||||
}, [activeProject, projectDirectory]);
|
||||
|
||||
// State
|
||||
const [activeTab, setActiveTab] = React.useState<GitLabTab>('issues');
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [issues, setIssues] = React.useState<GitLabIssueSummary[]>([]);
|
||||
const [mrs, setMrs] = React.useState<GitLabMergeRequestSummary[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [selectedIssue, setSelectedIssue] = React.useState<GitLabIssueSummary | null>(null);
|
||||
const [selectedMr, setSelectedMr] = React.useState<GitLabMergeRequestSummary | null>(null);
|
||||
const [includeDiff, setIncludeDiff] = React.useState(false);
|
||||
const [validations, setValidations] = React.useState<Map<string, ValidationResult>>(new Map());
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [hasMore, setHasMore] = React.useState(false);
|
||||
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 350);
|
||||
|
||||
const loadData = React.useCallback(async (query?: string) => {
|
||||
if (!projectDirectory || !gitlab) return;
|
||||
if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPage(1);
|
||||
setHasMore(false);
|
||||
|
||||
try {
|
||||
if (activeTab === 'issues' && gitlab.issuesList) {
|
||||
const result = await gitlab.issuesList(projectDirectory, { page: 1, query });
|
||||
if (result.connected === false) {
|
||||
setError(t('session.gitlabIntegration.error.notConnected'));
|
||||
setIssues([]);
|
||||
} else {
|
||||
setIssues(result.issues ?? []);
|
||||
setPage(result.page ?? 1);
|
||||
setHasMore(Boolean(result.hasMore));
|
||||
}
|
||||
} else if (activeTab === 'mrs' && gitlab.mrsList) {
|
||||
const result = await gitlab.mrsList(projectDirectory, { page: 1, query });
|
||||
if (result.connected === false) {
|
||||
setError(t('session.gitlabIntegration.error.notConnected'));
|
||||
setMrs([]);
|
||||
} else {
|
||||
setMrs(result.mrs ?? []);
|
||||
setPage(result.page ?? 1);
|
||||
setHasMore(Boolean(result.hasMore));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('session.gitlabIntegration.error.loadDataFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectDirectory, gitlab, gitlabAuthChecked, gitlabAuthStatus, activeTab, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !projectDirectory) return;
|
||||
if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) return;
|
||||
if (!gitlab) return;
|
||||
if (!debouncedSearchQuery.trim()) {
|
||||
void loadData();
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPage(1);
|
||||
setHasMore(false);
|
||||
|
||||
const apiCall = activeTab === 'issues' && gitlab.issuesList
|
||||
? gitlab.issuesList(projectDirectory, { page: 1, query: debouncedSearchQuery.trim() })
|
||||
: activeTab === 'mrs' && gitlab.mrsList
|
||||
? gitlab.mrsList(projectDirectory, { page: 1, query: debouncedSearchQuery.trim() })
|
||||
: null;
|
||||
|
||||
if (!apiCall) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
apiCall
|
||||
.then((result) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if ('issues' in result) {
|
||||
if (result.connected === false) {
|
||||
setError(t('session.gitlabIntegration.error.notConnected'));
|
||||
setIssues([]);
|
||||
} else {
|
||||
setIssues(result.issues ?? []);
|
||||
setPage(result.page ?? 1);
|
||||
setHasMore(Boolean(result.hasMore));
|
||||
}
|
||||
} else if ('mrs' in result) {
|
||||
if (result.connected === false) {
|
||||
setError(t('session.gitlabIntegration.error.notConnected'));
|
||||
setMrs([]);
|
||||
} else {
|
||||
setMrs(result.mrs ?? []);
|
||||
setPage(result.page ?? 1);
|
||||
setHasMore(Boolean(result.hasMore));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(err instanceof Error ? err.message : t('session.gitlabIntegration.error.loadDataFailed'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [open, projectDirectory, gitlab, gitlabAuthChecked, gitlabAuthStatus, activeTab, debouncedSearchQuery, loadData, t]);
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (!projectDirectory || !gitlab) return;
|
||||
if (loading || loadingMore) return;
|
||||
if (!hasMore) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
|
||||
try {
|
||||
const nextPage = page + 1;
|
||||
|
||||
if (activeTab === 'issues' && gitlab.issuesList) {
|
||||
const result = debouncedSearchQuery.trim()
|
||||
? await gitlab.issuesList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() })
|
||||
: await gitlab.issuesList(projectDirectory, { page: nextPage });
|
||||
if (result.connected !== false) {
|
||||
setIssues(prev => [...prev, ...(result.issues ?? [])]);
|
||||
setPage(result.page ?? nextPage);
|
||||
setHasMore(Boolean(result.hasMore));
|
||||
}
|
||||
} else if (activeTab === 'mrs' && gitlab.mrsList) {
|
||||
const result = debouncedSearchQuery.trim()
|
||||
? await gitlab.mrsList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() })
|
||||
: await gitlab.mrsList(projectDirectory, { page: nextPage });
|
||||
if (result.connected !== false) {
|
||||
setMrs(prev => [...prev, ...(result.mrs ?? [])]);
|
||||
setPage(result.page ?? nextPage);
|
||||
setHasMore(Boolean(result.hasMore));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail on load more errors
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [projectDirectory, gitlab, activeTab, page, hasMore, loading, loadingMore, debouncedSearchQuery]);
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveTab('issues');
|
||||
setSearchQuery('');
|
||||
setIssues([]);
|
||||
setMrs([]);
|
||||
setSelectedIssue(null);
|
||||
setSelectedMr(null);
|
||||
setIncludeDiff(false);
|
||||
setError(null);
|
||||
setValidations(new Map());
|
||||
setPage(1);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadData();
|
||||
}, [open, loadData]);
|
||||
|
||||
// Validate branches for worktree creation
|
||||
const validateBranch = React.useCallback(async (branchName: string) => {
|
||||
if (!projectRef || !branchName) return;
|
||||
|
||||
// Check cache first
|
||||
if (validations.has(branchName)) return;
|
||||
|
||||
try {
|
||||
const result = await validateWorktreeCreate(projectRef, {
|
||||
mode: 'new',
|
||||
branchName,
|
||||
worktreeName: branchName,
|
||||
});
|
||||
|
||||
const blockingError = result.errors.find((entry) => entry.code === 'branch_in_use');
|
||||
|
||||
setValidations(prev => new Map(prev).set(branchName, {
|
||||
isValid: !blockingError,
|
||||
error: blockingError
|
||||
? t(blockingError.code === 'branch_exists'
|
||||
? 'session.gitlabIntegration.validation.branchAlreadyExists'
|
||||
: 'session.gitlabIntegration.validation.branchAlreadyCheckedOut')
|
||||
: null,
|
||||
}));
|
||||
} catch {
|
||||
setValidations(prev => new Map(prev).set(branchName, {
|
||||
isValid: false,
|
||||
error: t('session.gitlabIntegration.validation.failed'),
|
||||
}));
|
||||
}
|
||||
}, [projectRef, validations, t]);
|
||||
|
||||
// Validate MR branches when loaded
|
||||
React.useEffect(() => {
|
||||
if (!open || activeTab !== 'mrs') return;
|
||||
|
||||
mrs.forEach(mr => {
|
||||
if (mr.sourceBranch) {
|
||||
void validateBranch(mr.sourceBranch);
|
||||
}
|
||||
});
|
||||
}, [open, activeTab, mrs, validateBranch]);
|
||||
|
||||
// GitLab connection check
|
||||
const isGitLabConnected = gitlabAuthChecked && gitlabAuthStatus?.connected === true;
|
||||
|
||||
const openGitLabSettings = () => {
|
||||
setSettingsPage('git');
|
||||
setSettingsDialogOpen(true);
|
||||
};
|
||||
|
||||
// Handle selection
|
||||
const handleSelectIssue = (issue: GitLabIssueSummary) => {
|
||||
setSelectedIssue(issue);
|
||||
setSelectedMr(null);
|
||||
};
|
||||
|
||||
const handleSelectMr = (mr: GitLabMergeRequestSummary) => {
|
||||
setSelectedMr(mr);
|
||||
setSelectedIssue(null);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedIssue) {
|
||||
onSelect({
|
||||
type: 'issue',
|
||||
number: selectedIssue.number,
|
||||
title: selectedIssue.title,
|
||||
url: selectedIssue.url,
|
||||
});
|
||||
} else if (selectedMr) {
|
||||
onSelect({
|
||||
type: 'mr',
|
||||
number: selectedMr.number,
|
||||
title: selectedMr.title,
|
||||
url: selectedMr.url,
|
||||
sourceBranch: selectedMr.sourceBranch,
|
||||
includeDiff,
|
||||
});
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSelectedIssue(null);
|
||||
setSelectedMr(null);
|
||||
setIncludeDiff(false);
|
||||
};
|
||||
|
||||
// Check if selection is valid
|
||||
const canConfirm = selectedIssue || (selectedMr && validations.get(selectedMr.sourceBranch)?.isValid !== false);
|
||||
|
||||
// Check if MR is blocked
|
||||
const isMrBlocked = (mr: GitLabMergeRequestSummary): boolean => {
|
||||
if (!mr.sourceBranch) return true;
|
||||
const validation = validations.get(mr.sourceBranch);
|
||||
return validation?.isValid === false;
|
||||
};
|
||||
|
||||
// Content for the dialog (shared between mobile and desktop)
|
||||
const dialogContent = (
|
||||
<>
|
||||
{!isGitLabConnected ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-4">
|
||||
<Icon name="git-branch" className="h-12 w-12 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<p className="typography-ui-label text-foreground">{t('session.gitlabIntegration.connect.title')}</p>
|
||||
<p className="typography-small text-muted-foreground mt-1">
|
||||
{t('session.gitlabIntegration.connect.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openGitLabSettings} size="sm">{t('session.gitlabIntegration.connect.action')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Search */}
|
||||
<div className="relative mt-2">
|
||||
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={activeTab === 'issues'
|
||||
? t('session.gitlabIntegration.search.issuesPlaceholder')
|
||||
: t('session.gitlabIntegration.search.mrsPlaceholder')}
|
||||
className="h-8 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* List Content */}
|
||||
<div className="mt-2 h-[300px] overflow-hidden">
|
||||
<div className="h-full overflow-y-auto">
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Icon name="loader-4" className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-destructive/10 text-destructive">
|
||||
<Icon name="error-warning" className="h-4 w-4" />
|
||||
<span className="typography-small">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issues List */}
|
||||
{!loading && !error && activeTab === 'issues' && (
|
||||
<div className="space-y-0.5 min-h-full">
|
||||
{issues.length > 0 ? (
|
||||
issues.map(issue => (
|
||||
<button
|
||||
key={issue.number}
|
||||
onClick={() => handleSelectIssue(issue)}
|
||||
className={cn(
|
||||
'w-full text-left px-2 py-1.5 rounded transition-colors',
|
||||
selectedIssue?.number === issue.number
|
||||
? 'bg-interactive-selection text-interactive-selection-foreground'
|
||||
: 'hover:bg-interactive-hover'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground shrink-0 typography-micro">#{issue.number}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="typography-small line-clamp-2">{issue.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[300px] text-center typography-small text-muted-foreground">
|
||||
{t('session.gitlabIntegration.empty.noIssuesFound')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && !loadingMore && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void loadMore()}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{t('session.gitlabIntegration.actions.loadMore')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{loadingMore && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MRs List */}
|
||||
{!loading && !error && activeTab === 'mrs' && (
|
||||
<div className="space-y-0.5 min-h-full">
|
||||
{mrs.length > 0 ? (
|
||||
mrs.map(mr => {
|
||||
const blocked = isMrBlocked(mr);
|
||||
const validation = mr.sourceBranch ? validations.get(mr.sourceBranch) : undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mr.number}
|
||||
onClick={() => !blocked && handleSelectMr(mr)}
|
||||
disabled={blocked}
|
||||
className={cn(
|
||||
'w-full text-left px-2 py-1.5 rounded transition-colors',
|
||||
selectedMr?.number === mr.number
|
||||
? 'bg-interactive-selection text-interactive-selection-foreground'
|
||||
: blocked
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-interactive-hover'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground shrink-0 typography-micro">!{mr.number}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="typography-small line-clamp-1">{mr.title}</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{mr.sourceBranch} → {mr.targetBranch}
|
||||
</span>
|
||||
{mr.draft && (
|
||||
<span className="typography-micro px-1 py-0.5 rounded bg-status-info/10 text-status-info">
|
||||
{t('session.gitlabIntegration.draftBadge')}
|
||||
</span>
|
||||
)}
|
||||
{blocked && validation?.error && (
|
||||
<span className="typography-micro text-destructive">
|
||||
{validation.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[300px] text-center typography-small text-muted-foreground">
|
||||
{t('session.gitlabIntegration.empty.noMergeRequestsFound')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && !loadingMore && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void loadMore()}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{t('session.gitlabIntegration.actions.loadMore')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{loadingMore && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Footer content
|
||||
const footerContent = (
|
||||
<div className={cn(
|
||||
'w-full',
|
||||
isMobile ? 'flex flex-col gap-2' : 'flex flex-row items-center'
|
||||
)}>
|
||||
{/* Left side: Selected Item / Checkbox */}
|
||||
<div className={cn(
|
||||
'flex items-center gap-4',
|
||||
isMobile ? 'w-full justify-center order-1' : 'flex-1'
|
||||
)}>
|
||||
{/* Selected Issue/MR display - hidden on mobile (shown in header instead) */}
|
||||
{!isMobile && (selectedIssue || selectedMr) && (
|
||||
<div className="flex items-center gap-2 px-2 h-8 rounded-md bg-muted/50 border border-border/50">
|
||||
<Icon name="check" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
||||
<span className="typography-small truncate max-w-[150px]">
|
||||
{selectedIssue
|
||||
? t('session.gitlabIntegration.selected.issueNumber', { number: selectedIssue.number })
|
||||
: t('session.gitlabIntegration.selected.mrNumber', { number: selectedMr?.number ?? '' })}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<Icon name="close" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Include Diff Checkbox - only show when MR tab is active and MR is selected */}
|
||||
{activeTab === 'mrs' && selectedMr && (
|
||||
<label className="flex items-center gap-2 cursor-pointer h-8">
|
||||
<Checkbox
|
||||
checked={includeDiff}
|
||||
onChange={(checked) => setIncludeDiff(checked)}
|
||||
ariaLabel={t('session.gitlabIntegration.includeDiffAria')}
|
||||
/>
|
||||
<span className="typography-small text-foreground">
|
||||
{t('session.gitlabIntegration.includeDiff')}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side: Buttons */}
|
||||
<div className={cn(
|
||||
'flex gap-2',
|
||||
isMobile ? 'w-full order-2' : 'justify-end'
|
||||
)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className={cn(isMobile && 'flex-1')}
|
||||
>
|
||||
{t('session.gitlabIntegration.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleConfirm}
|
||||
disabled={!canConfirm}
|
||||
className={cn(isMobile && 'flex-1')}
|
||||
>
|
||||
{t('session.gitlabIntegration.actions.select')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<MobileOverlayPanel
|
||||
open={open}
|
||||
title={t('session.gitlabIntegration.title')}
|
||||
onClose={() => onOpenChange(false)}
|
||||
footer={!isGitLabConnected ? undefined : footerContent}
|
||||
renderHeader={(closeButton) => (
|
||||
<div className="flex flex-col gap-2 px-3 py-2 border-b border-border/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="typography-ui-label font-semibold text-foreground">{t('session.gitlabIntegration.title')}</h2>
|
||||
{closeButton}
|
||||
</div>
|
||||
{/* Tabs - using SortableTabsStrip */}
|
||||
<div className="w-full">
|
||||
<SortableTabsStrip
|
||||
items={[
|
||||
{ id: 'issues', label: t('session.gitlabIntegration.tabs.issues'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'mrs', label: t('session.gitlabIntegration.tabs.mergeRequests'), icon: <Icon name="git-merge" className="h-3.5 w-3.5" /> },
|
||||
]}
|
||||
activeId={activeTab}
|
||||
onSelect={(id) => {
|
||||
setActiveTab(id as GitLabTab);
|
||||
setSearchQuery('');
|
||||
}}
|
||||
variant="active-pill"
|
||||
layoutMode="fit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selected Item Inline Display */}
|
||||
{(selectedIssue || selectedMr) && (
|
||||
<div className="flex items-center gap-2 px-2 py-1 rounded-md bg-muted/50 border border-border/50">
|
||||
<Icon name="check" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
||||
<span className="typography-small truncate flex-1">
|
||||
{selectedIssue
|
||||
? t('session.gitlabIntegration.selected.issueNumber', { number: selectedIssue.number })
|
||||
: t('session.gitlabIntegration.selected.mrNumber', { number: selectedMr?.number ?? '' })}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<Icon name="close" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{dialogContent}
|
||||
</MobileOverlayPanel>
|
||||
) : (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
|
||||
<DialogHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<DialogTitle className="flex items-center gap-2 shrink-0">
|
||||
<Icon name="git-branch" className="h-5 w-5" />
|
||||
{t('session.gitlabIntegration.title')}
|
||||
</DialogTitle>
|
||||
|
||||
{/* Tabs - using SortableTabsStrip */}
|
||||
<div className="w-[220px]">
|
||||
<SortableTabsStrip
|
||||
items={[
|
||||
{ id: 'issues', label: t('session.gitlabIntegration.tabs.issues'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'mrs', label: t('session.gitlabIntegration.tabs.mergeRequests'), icon: <Icon name="git-merge" className="h-3.5 w-3.5" /> },
|
||||
]}
|
||||
activeId={activeTab}
|
||||
onSelect={(id) => {
|
||||
setActiveTab(id as GitLabTab);
|
||||
setSearchQuery('');
|
||||
}}
|
||||
variant="active-pill"
|
||||
layoutMode="fit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{dialogContent}
|
||||
|
||||
{/* Footer */}
|
||||
<DialogFooter className="mt-1">
|
||||
{footerContent}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { cn } from '@/lib/utils';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore';
|
||||
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
|
||||
import { GitLabIntegrationDialog } from './GitLabIntegrationDialog';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -59,6 +61,10 @@ import type {
|
||||
GitHubIssuesListResult,
|
||||
GitHubPullRequestContextResult,
|
||||
GitHubPullRequestSummary,
|
||||
GitLabIssue,
|
||||
GitLabIssueComment,
|
||||
GitLabIssuesListResult,
|
||||
GitLabMergeRequestContextResult,
|
||||
} from '@/lib/api/types';
|
||||
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -81,6 +87,9 @@ interface NewBranchState {
|
||||
linkedIssue: GitHubIssue | null;
|
||||
linkedPr: GitHubPullRequestSummary | null;
|
||||
includePrDiff: boolean;
|
||||
linkedGitLabIssue: { number: number; title: string; url: string } | null;
|
||||
linkedGitLabMr: { number: number; title: string; url: string; sourceBranch: string } | null;
|
||||
includeGitLabMrDiff: boolean;
|
||||
}
|
||||
|
||||
// State for Existing Branch mode
|
||||
@@ -205,16 +214,35 @@ const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) =>
|
||||
return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
||||
};
|
||||
|
||||
const buildGitLabIssueContextText = (args: {
|
||||
repo: GitLabIssuesListResult['repo'] | undefined;
|
||||
issue: GitLabIssue;
|
||||
comments: GitLabIssueComment[];
|
||||
}) => {
|
||||
const payload = {
|
||||
repo: args.repo ?? null,
|
||||
issue: args.issue,
|
||||
comments: args.comments,
|
||||
};
|
||||
return `GitLab issue context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
||||
};
|
||||
|
||||
const buildGitLabMrContextText = (payload: GitLabMergeRequestContextResult) => {
|
||||
return `GitLab merge request context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
||||
};
|
||||
|
||||
export function NewWorktreeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onWorktreeCreated,
|
||||
}: NewWorktreeDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const { github, git } = useRuntimeAPIs();
|
||||
const { github, git, gitlab } = useRuntimeAPIs();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
const gitlabAuthStatus = useGitLabAuthStore((state) => state.status);
|
||||
const gitlabAuthChecked = useGitLabAuthStore((state) => state.hasChecked);
|
||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||
|
||||
const projectDirectory = activeProject?.path ?? null;
|
||||
@@ -237,6 +265,9 @@ export function NewWorktreeDialog({
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
includePrDiff: false,
|
||||
linkedGitLabIssue: null,
|
||||
linkedGitLabMr: null,
|
||||
includeGitLabMrDiff: false,
|
||||
});
|
||||
|
||||
const [existingBranchState, setExistingBranchState] = React.useState<ExistingBranchState>({
|
||||
@@ -286,6 +317,7 @@ export function NewWorktreeDialog({
|
||||
}, [existingWorktreeNames]);
|
||||
|
||||
const [githubDialogOpen, setGithubDialogOpen] = React.useState(false);
|
||||
const [gitlabDialogOpen, setGitlabDialogOpen] = React.useState(false);
|
||||
|
||||
// Desktop branch picker states
|
||||
const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false);
|
||||
@@ -477,8 +509,11 @@ export function NewWorktreeDialog({
|
||||
issue: GitHubIssue | null;
|
||||
pr: GitHubPullRequestSummary | null;
|
||||
includeDiff: boolean;
|
||||
gitLabIssue: { number: number; title: string; url: string } | null;
|
||||
gitLabMr: { number: number; title: string; url: string; sourceBranch: string } | null;
|
||||
includeGitLabMrDiff: boolean;
|
||||
}) => {
|
||||
if (!projectDirectory || !github) {
|
||||
if (!projectDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -497,7 +532,7 @@ export function NewWorktreeDialog({
|
||||
const variant = resolveDefaultVariant(providerID, modelID);
|
||||
|
||||
if (args.issue) {
|
||||
if (!github.issueGet || !github.issueComments) {
|
||||
if (!github || !github.issueGet || !github.issueComments) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -558,7 +593,7 @@ export function NewWorktreeDialog({
|
||||
}
|
||||
|
||||
if (args.pr) {
|
||||
if (!github.prContext) {
|
||||
if (!github || !github.prContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -609,8 +644,125 @@ export function NewWorktreeDialog({
|
||||
|
||||
toast.success(t('session.newWorktree.toast.sessionFromPr'));
|
||||
}
|
||||
|
||||
if (args.gitLabIssue) {
|
||||
if (!gitlab || !gitlab.issueGet || !gitlab.issueComments) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issueRes = await gitlab.issueGet(projectDirectory, args.gitLabIssue.number);
|
||||
if (issueRes.connected === false || !issueRes.issue) {
|
||||
throw new Error('Failed to load issue context');
|
||||
}
|
||||
|
||||
const commentsRes = await gitlab.issueComments(projectDirectory, args.gitLabIssue.number);
|
||||
if (commentsRes.connected === false) {
|
||||
throw new Error('Failed to load issue comments');
|
||||
}
|
||||
|
||||
const visiblePromptText = await renderMagicPrompt('gitlab.issue.review.visible', {
|
||||
issue_number: String(args.gitLabIssue.number),
|
||||
});
|
||||
const instructionsText = await renderMagicPrompt('gitlab.issue.review.instructions');
|
||||
const contextText = buildGitLabIssueContextText({
|
||||
repo: issueRes.repo,
|
||||
issue: issueRes.issue,
|
||||
comments: commentsRes.comments ?? [],
|
||||
});
|
||||
|
||||
await useSessionUIStore.getState().sendMessage(
|
||||
visiblePromptText,
|
||||
providerID,
|
||||
modelID,
|
||||
agentName,
|
||||
undefined,
|
||||
undefined,
|
||||
[
|
||||
{ text: instructionsText, synthetic: true },
|
||||
{ text: contextText, synthetic: true },
|
||||
],
|
||||
variant,
|
||||
undefined,
|
||||
{ sessionId: args.sessionId },
|
||||
);
|
||||
|
||||
// Record the thread this worktree session was created for, so it stays
|
||||
// visible as a context source after the opening message scrolls away.
|
||||
void sessionActions.setLinkedIssue(
|
||||
args.sessionId,
|
||||
args.directory,
|
||||
buildLinkedIssue({
|
||||
url: issueRes.issue.url,
|
||||
number: issueRes.issue.number,
|
||||
title: issueRes.issue.title,
|
||||
kind: 'issue',
|
||||
author: issueRes.issue.author
|
||||
? { login: issueRes.issue.author.username, avatarUrl: issueRes.issue.author.avatarUrl }
|
||||
: null,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
|
||||
toast.success(t('session.newWorktree.toast.sessionFromIssue'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.gitLabMr) {
|
||||
if (!gitlab || !gitlab.mrContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mrContext = await gitlab.mrContext(projectDirectory, args.gitLabMr.number, {
|
||||
includeDiff: args.includeGitLabMrDiff,
|
||||
});
|
||||
if (mrContext.connected === false || !mrContext.mr) {
|
||||
throw new Error('Failed to load MR context');
|
||||
}
|
||||
|
||||
const visiblePromptText = await renderMagicPrompt('gitlab.pr.review.visible', {
|
||||
mr_number: String(args.gitLabMr.number),
|
||||
});
|
||||
const instructionsText = await renderMagicPrompt('gitlab.pr.review.instructions');
|
||||
const contextText = buildGitLabMrContextText(mrContext);
|
||||
|
||||
await useSessionUIStore.getState().sendMessage(
|
||||
visiblePromptText,
|
||||
providerID,
|
||||
modelID,
|
||||
agentName,
|
||||
undefined,
|
||||
undefined,
|
||||
[
|
||||
{ text: instructionsText, synthetic: true },
|
||||
{ text: contextText, synthetic: true },
|
||||
],
|
||||
variant,
|
||||
undefined,
|
||||
{ sessionId: args.sessionId },
|
||||
);
|
||||
|
||||
void sessionActions.setLinkedIssue(
|
||||
args.sessionId,
|
||||
args.directory,
|
||||
buildLinkedIssue({
|
||||
url: mrContext.mr.url,
|
||||
number: mrContext.mr.number,
|
||||
title: mrContext.mr.title,
|
||||
kind: 'pull',
|
||||
author: mrContext.mr.author
|
||||
? { login: mrContext.mr.author.username, avatarUrl: mrContext.mr.author.avatarUrl }
|
||||
: null,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
|
||||
toast.success(t('session.newWorktree.toast.sessionFromMr'));
|
||||
}
|
||||
}, [
|
||||
github,
|
||||
gitlab,
|
||||
projectDirectory,
|
||||
resolveDefaultAgentName,
|
||||
resolveDefaultModelSelection,
|
||||
@@ -699,6 +851,9 @@ export function NewWorktreeDialog({
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
includePrDiff: false,
|
||||
linkedGitLabIssue: null,
|
||||
linkedGitLabMr: null,
|
||||
includeGitLabMrDiff: false,
|
||||
});
|
||||
}, [open, generateUniqueSlug]);
|
||||
|
||||
@@ -746,11 +901,13 @@ export function NewWorktreeDialog({
|
||||
if (normalizedBranch && normalizedWorktree) {
|
||||
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
|
||||
const prConfig = linkedPr ? resolvePrWorktreeConfig(linkedPr, localBranches, remoteBranches) : null;
|
||||
const linkedGitLabMr = mode === 'new-branch' ? newBranchState.linkedGitLabMr : null;
|
||||
const gitLabMrBranch = linkedGitLabMr ? normalizeBranchName(linkedGitLabMr.sourceBranch || '') : '';
|
||||
const result = await validateWorktreeCreate(projectRef, {
|
||||
mode: mode === 'existing-branch' || prConfig ? 'existing' : 'new',
|
||||
mode: mode === 'existing-branch' || prConfig || gitLabMrBranch ? 'existing' : 'new',
|
||||
branchName: normalizedBranch,
|
||||
worktreeName: normalizedWorktree,
|
||||
existingBranch: prConfig?.existingBranch ?? (mode === 'existing-branch' ? normalizedBranch : undefined),
|
||||
existingBranch: prConfig?.existingBranch ?? (gitLabMrBranch || (mode === 'existing-branch' ? normalizedBranch : undefined)),
|
||||
...(prConfig?.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}),
|
||||
...(prConfig?.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}),
|
||||
});
|
||||
@@ -792,6 +949,7 @@ export function NewWorktreeDialog({
|
||||
mode,
|
||||
newBranchState.branchName,
|
||||
newBranchState.linkedPr,
|
||||
newBranchState.linkedGitLabMr,
|
||||
existingBranchState.selectedBranch,
|
||||
currentState.worktreeName,
|
||||
localBranches,
|
||||
@@ -860,7 +1018,10 @@ export function NewWorktreeDialog({
|
||||
const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null;
|
||||
const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null;
|
||||
const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false;
|
||||
const shouldCreateSession = Boolean(linkedIssue || linkedPrState);
|
||||
const linkedGitLabIssue = mode === 'new-branch' ? newBranchState.linkedGitLabIssue : null;
|
||||
const linkedGitLabMr = mode === 'new-branch' ? newBranchState.linkedGitLabMr : null;
|
||||
const includeGitLabMrDiff = mode === 'new-branch' ? newBranchState.includeGitLabMrDiff : false;
|
||||
const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedGitLabIssue || linkedGitLabMr);
|
||||
|
||||
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||
const sourceBranch = newBranchState.sourceBranch;
|
||||
@@ -886,6 +1047,23 @@ export function NewWorktreeDialog({
|
||||
};
|
||||
}
|
||||
|
||||
if (linkedGitLabMr) {
|
||||
const mrBranch = normalizeBranchName(linkedGitLabMr.sourceBranch || '');
|
||||
if (!mrBranch) {
|
||||
throw new Error('MR source branch is missing');
|
||||
}
|
||||
sourceLabel = mrBranch;
|
||||
return {
|
||||
preferredName: normalizedBranch || normalizedWorktree,
|
||||
mode: 'existing' as const,
|
||||
branchName: normalizedBranch,
|
||||
worktreeName: normalizedWorktree,
|
||||
existingBranch: mrBranch,
|
||||
setupCommands,
|
||||
returnAfterDirectoryCreated: true,
|
||||
};
|
||||
}
|
||||
|
||||
sourceLabel = mode === 'new-branch' ? sourceBranch : '';
|
||||
return {
|
||||
preferredName: normalizedBranch || normalizedWorktree,
|
||||
@@ -914,7 +1092,11 @@ export function NewWorktreeDialog({
|
||||
? `#${linkedIssue.number} ${linkedIssue.title}`.trim()
|
||||
: linkedPrState
|
||||
? `#${linkedPrState.number} ${linkedPrState.title}`.trim()
|
||||
: t('session.newWorktree.newSessionTitle');
|
||||
: linkedGitLabIssue
|
||||
? `#${linkedGitLabIssue.number} ${linkedGitLabIssue.title}`.trim()
|
||||
: linkedGitLabMr
|
||||
? `!${linkedGitLabMr.number} ${linkedGitLabMr.title}`.trim()
|
||||
: t('session.newWorktree.newSessionTitle');
|
||||
|
||||
const session = await sessionActions.createSession(sessionTitle, metadata.path, null);
|
||||
if (!session?.id) {
|
||||
@@ -963,9 +1145,16 @@ export function NewWorktreeDialog({
|
||||
issue: linkedIssue,
|
||||
pr: linkedPrState,
|
||||
includeDiff: includePrDiff,
|
||||
gitLabIssue: linkedGitLabIssue,
|
||||
gitLabMr: linkedGitLabMr,
|
||||
includeGitLabMrDiff: includeGitLabMrDiff,
|
||||
}).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : t('session.newWorktree.error.sendGitHubContextFailed');
|
||||
toast.error(t('session.newWorktree.error.sendGitHubContextFailed'), { description: message });
|
||||
const isGitLabLink = Boolean(linkedGitLabIssue || linkedGitLabMr);
|
||||
const errorKey = isGitLabLink
|
||||
? 'session.newWorktree.error.sendGitLabContextFailed'
|
||||
: 'session.newWorktree.error.sendGitHubContextFailed';
|
||||
const message = error instanceof Error ? error.message : t(errorKey);
|
||||
toast.error(t(errorKey), { description: message });
|
||||
});
|
||||
} else {
|
||||
onWorktreeCreated?.(metadata.path);
|
||||
@@ -996,6 +1185,9 @@ export function NewWorktreeDialog({
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
includePrDiff: false,
|
||||
linkedGitLabIssue: null,
|
||||
linkedGitLabMr: null,
|
||||
includeGitLabMrDiff: false,
|
||||
branchName: '',
|
||||
}));
|
||||
return;
|
||||
@@ -1009,6 +1201,9 @@ export function NewWorktreeDialog({
|
||||
linkedIssue: issue,
|
||||
linkedPr: null,
|
||||
includePrDiff: false,
|
||||
linkedGitLabIssue: null,
|
||||
linkedGitLabMr: null,
|
||||
includeGitLabMrDiff: false,
|
||||
branchName: newBranchName,
|
||||
worktreeName: slugifyWorktreeName(newBranchName),
|
||||
isSyncingWorktreeName: true,
|
||||
@@ -1020,6 +1215,9 @@ export function NewWorktreeDialog({
|
||||
linkedPr: pr,
|
||||
linkedIssue: null,
|
||||
includePrDiff: result.includeDiff ?? false,
|
||||
linkedGitLabIssue: null,
|
||||
linkedGitLabMr: null,
|
||||
includeGitLabMrDiff: false,
|
||||
branchName: pr.head,
|
||||
worktreeName: slugifyWorktreeName(pr.head),
|
||||
isSyncingWorktreeName: true,
|
||||
@@ -1027,8 +1225,77 @@ export function NewWorktreeDialog({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle GitLab selection
|
||||
const handleGitLabSelect = (result: {
|
||||
type: 'issue';
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
} | {
|
||||
type: 'mr';
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
sourceBranch: string;
|
||||
includeDiff: boolean;
|
||||
} | null) => {
|
||||
if (!result) {
|
||||
setNewBranchState(prev => ({
|
||||
...prev,
|
||||
linkedGitLabIssue: null,
|
||||
linkedGitLabMr: null,
|
||||
includeGitLabMrDiff: false,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
includePrDiff: false,
|
||||
branchName: '',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.type === 'issue') {
|
||||
const newBranchName = `issue-${result.number}-${generateBranchSlug()}`;
|
||||
setNewBranchState(prev => ({
|
||||
...prev,
|
||||
linkedGitLabIssue: {
|
||||
number: result.number,
|
||||
title: result.title,
|
||||
url: result.url,
|
||||
},
|
||||
linkedGitLabMr: null,
|
||||
includeGitLabMrDiff: false,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
includePrDiff: false,
|
||||
branchName: newBranchName,
|
||||
worktreeName: slugifyWorktreeName(newBranchName),
|
||||
isSyncingWorktreeName: true,
|
||||
}));
|
||||
} else if (result.type === 'mr') {
|
||||
setNewBranchState(prev => ({
|
||||
...prev,
|
||||
linkedGitLabMr: {
|
||||
number: result.number,
|
||||
title: result.title,
|
||||
url: result.url,
|
||||
sourceBranch: result.sourceBranch,
|
||||
},
|
||||
linkedGitLabIssue: null,
|
||||
includeGitLabMrDiff: result.includeDiff,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
includePrDiff: false,
|
||||
branchName: result.sourceBranch,
|
||||
worktreeName: slugifyWorktreeName(result.sourceBranch),
|
||||
isSyncingWorktreeName: true,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// GitHub connection check
|
||||
const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true;
|
||||
// GitLab connection check
|
||||
const isGitLabConnected = gitlabAuthChecked && gitlabAuthStatus?.connected === true;
|
||||
|
||||
// Check if form is valid for submission
|
||||
const isFormValid = mode === 'existing-branch'
|
||||
@@ -1042,8 +1309,11 @@ export function NewWorktreeDialog({
|
||||
...prev,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedGitLabIssue: null,
|
||||
linkedGitLabMr: null,
|
||||
branchName: '',
|
||||
includePrDiff: false,
|
||||
includeGitLabMrDiff: false,
|
||||
isSyncingWorktreeName: true,
|
||||
}));
|
||||
};
|
||||
@@ -1277,16 +1547,31 @@ export function NewWorktreeDialog({
|
||||
<label className="typography-ui-label text-foreground block font-semibold">
|
||||
{t('session.newWorktree.branchName')}
|
||||
</label>
|
||||
{mode === 'new-branch' && isGitHubConnected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setGithubDialogOpen(true)}
|
||||
className="gap-1.5 h-7"
|
||||
>
|
||||
<Icon name="github" className="size-4 text-status-success" />
|
||||
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
||||
</Button>
|
||||
{mode === 'new-branch' && (isGitHubConnected || isGitLabConnected) && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isGitHubConnected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setGithubDialogOpen(true)}
|
||||
className="gap-1.5 h-7"
|
||||
>
|
||||
<Icon name="github" className="size-4 text-status-success" />
|
||||
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
||||
</Button>
|
||||
)}
|
||||
{isGitLabConnected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setGitlabDialogOpen(true)}
|
||||
className="gap-1.5 h-7"
|
||||
>
|
||||
<Icon name="git-merge" className="size-4 text-status-success" />
|
||||
{newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitLabIssueMr')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
@@ -1298,15 +1583,17 @@ export function NewWorktreeDialog({
|
||||
isSyncingWorktreeName: true,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedGitLabIssue: null,
|
||||
linkedGitLabMr: null,
|
||||
}));
|
||||
}}
|
||||
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
|
||||
placeholder={t('session.newWorktree.branchNamePlaceholder')}
|
||||
disabled={!!newBranchState.linkedPr}
|
||||
disabled={!!newBranchState.linkedPr || !!newBranchState.linkedGitLabMr}
|
||||
className={cn(
|
||||
'h-8',
|
||||
validation.touched && validation.branchError && 'border-destructive',
|
||||
newBranchState.linkedPr && 'bg-muted text-muted-foreground'
|
||||
(newBranchState.linkedPr || newBranchState.linkedGitLabMr) && 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
{newBranchState.linkedPr && (
|
||||
@@ -1317,6 +1604,14 @@ export function NewWorktreeDialog({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{newBranchState.linkedGitLabMr && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
||||
<span className="typography-micro">
|
||||
{t('session.newWorktree.usingMrBranch', { branch: newBranchState.linkedGitLabMr.sourceBranch })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
||||
@@ -1325,6 +1620,14 @@ export function NewWorktreeDialog({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{newBranchState.linkedGitLabIssue && !newBranchState.linkedGitLabMr && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
||||
<span className="typography-micro">
|
||||
{t('session.newWorktree.fromIssue', { number: newBranchState.linkedGitLabIssue.number, title: newBranchState.linkedGitLabIssue.title })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1383,8 +1686,8 @@ export function NewWorktreeDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source Branch - Only for New Branch mode, hide when PR is selected */}
|
||||
{mode === 'new-branch' && !newBranchState.linkedPr && (
|
||||
{/* Source Branch - Only for New Branch mode, hide when a linked PR/MR is selected */}
|
||||
{mode === 'new-branch' && !newBranchState.linkedPr && !newBranchState.linkedGitLabMr && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="typography-ui-label text-foreground block font-semibold">
|
||||
{t('session.newWorktree.sourceBranch')}
|
||||
@@ -1523,11 +1826,15 @@ export function NewWorktreeDialog({
|
||||
)}
|
||||
|
||||
{/* Linked Item Preview - Two row minimal display */}
|
||||
{(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && (
|
||||
{(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr) && mode === 'new-branch' && (
|
||||
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
|
||||
{/* Row 1: Type, number, title, actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="github" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
||||
{newBranchState.linkedIssue || newBranchState.linkedPr ? (
|
||||
<Icon name="github" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
||||
) : (
|
||||
<Icon name="git-merge" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
||||
)}
|
||||
|
||||
{newBranchState.linkedIssue && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
@@ -1539,13 +1846,23 @@ export function NewWorktreeDialog({
|
||||
{t('session.newWorktree.prNumber', { number: newBranchState.linkedPr.number })}
|
||||
</span>
|
||||
)}
|
||||
{newBranchState.linkedGitLabIssue && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedGitLabIssue.number })}
|
||||
</span>
|
||||
)}
|
||||
{newBranchState.linkedGitLabMr && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
{t('session.newWorktree.mrNumber', { number: newBranchState.linkedGitLabMr.number })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="typography-micro text-foreground truncate flex-1">
|
||||
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
|
||||
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title || newBranchState.linkedGitLabIssue?.title || newBranchState.linkedGitLabMr?.title}
|
||||
</span>
|
||||
|
||||
<a
|
||||
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
|
||||
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url || newBranchState.linkedGitLabIssue?.url || newBranchState.linkedGitLabMr?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
@@ -1562,7 +1879,7 @@ export function NewWorktreeDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: PR branch info + diff indicator */}
|
||||
{/* Row 2: PR/MR branch info + diff indicator */}
|
||||
{newBranchState.linkedPr && (
|
||||
<div className="flex items-center gap-2 mt-0.5 pl-5">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
@@ -1575,6 +1892,18 @@ export function NewWorktreeDialog({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{newBranchState.linkedGitLabMr && (
|
||||
<div className="flex items-center gap-2 mt-0.5 pl-5">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{newBranchState.linkedGitLabMr.sourceBranch}
|
||||
</span>
|
||||
{newBranchState.includeGitLabMrDiff && (
|
||||
<span className="typography-micro px-1 py-0.5 rounded bg-status-success/10 text-status-success">
|
||||
{t('session.newWorktree.includeDiffBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1746,16 +2075,31 @@ export function NewWorktreeDialog({
|
||||
<label className="typography-ui-label text-foreground block font-semibold">
|
||||
{t('session.newWorktree.branchName')}
|
||||
</label>
|
||||
{mode === 'new-branch' && isGitHubConnected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setGithubDialogOpen(true)}
|
||||
className="gap-1.5 h-7"
|
||||
>
|
||||
<Icon name="github" className="size-4 text-status-success" />
|
||||
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
||||
</Button>
|
||||
{mode === 'new-branch' && (isGitHubConnected || isGitLabConnected) && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isGitHubConnected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setGithubDialogOpen(true)}
|
||||
className="gap-1.5 h-7"
|
||||
>
|
||||
<Icon name="github" className="size-4 text-status-success" />
|
||||
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
||||
</Button>
|
||||
)}
|
||||
{isGitLabConnected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setGitlabDialogOpen(true)}
|
||||
className="gap-1.5 h-7"
|
||||
>
|
||||
<Icon name="git-merge" className="size-4 text-status-success" />
|
||||
{newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitLabIssueMr')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
@@ -1767,15 +2111,17 @@ export function NewWorktreeDialog({
|
||||
isSyncingWorktreeName: true,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedGitLabIssue: null,
|
||||
linkedGitLabMr: null,
|
||||
}));
|
||||
}}
|
||||
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
|
||||
placeholder={t('session.newWorktree.branchNamePlaceholder')}
|
||||
disabled={!!newBranchState.linkedPr}
|
||||
disabled={!!newBranchState.linkedPr || !!newBranchState.linkedGitLabMr}
|
||||
className={cn(
|
||||
'h-8',
|
||||
validation.touched && validation.branchError && 'border-destructive',
|
||||
newBranchState.linkedPr && 'bg-muted text-muted-foreground'
|
||||
(newBranchState.linkedPr || newBranchState.linkedGitLabMr) && 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
{newBranchState.linkedPr && (
|
||||
@@ -1786,6 +2132,14 @@ export function NewWorktreeDialog({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{newBranchState.linkedGitLabMr && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
||||
<span className="typography-micro">
|
||||
{t('session.newWorktree.usingMrBranch', { branch: newBranchState.linkedGitLabMr.sourceBranch })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
||||
@@ -1794,6 +2148,14 @@ export function NewWorktreeDialog({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{newBranchState.linkedGitLabIssue && !newBranchState.linkedGitLabMr && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
||||
<span className="typography-micro">
|
||||
{t('session.newWorktree.fromIssue', { number: newBranchState.linkedGitLabIssue.number, title: newBranchState.linkedGitLabIssue.title })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1852,8 +2214,8 @@ export function NewWorktreeDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source Branch - Only for New Branch mode, hide when PR is selected */}
|
||||
{mode === 'new-branch' && !newBranchState.linkedPr && (
|
||||
{/* Source Branch - Only for New Branch mode, hide when a linked PR/MR is selected */}
|
||||
{mode === 'new-branch' && !newBranchState.linkedPr && !newBranchState.linkedGitLabMr && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="typography-ui-label text-foreground block font-semibold">
|
||||
{t('session.newWorktree.sourceBranch')}
|
||||
@@ -1966,11 +2328,15 @@ export function NewWorktreeDialog({
|
||||
)}
|
||||
|
||||
{/* Linked Item Preview - Two row minimal display */}
|
||||
{(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && (
|
||||
{(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr) && mode === 'new-branch' && (
|
||||
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
|
||||
{/* Row 1: Type, number, title, actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="github" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
||||
{newBranchState.linkedIssue || newBranchState.linkedPr ? (
|
||||
<Icon name="github" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
||||
) : (
|
||||
<Icon name="git-merge" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
||||
)}
|
||||
|
||||
{newBranchState.linkedIssue && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
@@ -1982,13 +2348,23 @@ export function NewWorktreeDialog({
|
||||
{t('session.newWorktree.prNumber', { number: newBranchState.linkedPr.number })}
|
||||
</span>
|
||||
)}
|
||||
{newBranchState.linkedGitLabIssue && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedGitLabIssue.number })}
|
||||
</span>
|
||||
)}
|
||||
{newBranchState.linkedGitLabMr && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
{t('session.newWorktree.mrNumber', { number: newBranchState.linkedGitLabMr.number })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="typography-micro text-foreground truncate flex-1">
|
||||
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
|
||||
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title || newBranchState.linkedGitLabIssue?.title || newBranchState.linkedGitLabMr?.title}
|
||||
</span>
|
||||
|
||||
<a
|
||||
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
|
||||
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url || newBranchState.linkedGitLabIssue?.url || newBranchState.linkedGitLabMr?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
@@ -2005,7 +2381,7 @@ export function NewWorktreeDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: PR branch info + diff indicator */}
|
||||
{/* Row 2: PR/MR branch info + diff indicator */}
|
||||
{newBranchState.linkedPr && (
|
||||
<div className="flex items-center gap-2 mt-0.5 pl-5">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
@@ -2018,6 +2394,18 @@ export function NewWorktreeDialog({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{newBranchState.linkedGitLabMr && (
|
||||
<div className="flex items-center gap-2 mt-0.5 pl-5">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{newBranchState.linkedGitLabMr.sourceBranch}
|
||||
</span>
|
||||
{newBranchState.includeGitLabMrDiff && (
|
||||
<span className="typography-micro px-1 py-0.5 rounded bg-status-success/10 text-status-success">
|
||||
{t('session.newWorktree.includeDiffBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2065,6 +2453,12 @@ export function NewWorktreeDialog({
|
||||
onOpenChange={setGithubDialogOpen}
|
||||
onSelect={handleGitHubSelect}
|
||||
/>
|
||||
|
||||
<GitLabIntegrationDialog
|
||||
open={gitlabDialogOpen}
|
||||
onOpenChange={setGitlabDialogOpen}
|
||||
onSelect={handleGitLabSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user