diff --git a/.github/pr-evidence/dark-git-page.png b/.github/pr-evidence/dark-git-page.png new file mode 100644 index 00000000..00e64fd5 Binary files /dev/null and b/.github/pr-evidence/dark-git-page.png differ diff --git a/.github/pr-evidence/dark-projects-page.png b/.github/pr-evidence/dark-projects-page.png new file mode 100644 index 00000000..ed2af54c Binary files /dev/null and b/.github/pr-evidence/dark-projects-page.png differ diff --git a/.github/pr-evidence/evidence-link-dialog.png b/.github/pr-evidence/evidence-link-dialog.png new file mode 100644 index 00000000..6e73f217 Binary files /dev/null and b/.github/pr-evidence/evidence-link-dialog.png differ diff --git a/.github/pr-evidence/evidence-link-filled.png b/.github/pr-evidence/evidence-link-filled.png new file mode 100644 index 00000000..9741e9f9 Binary files /dev/null and b/.github/pr-evidence/evidence-link-filled.png differ diff --git a/.github/pr-evidence/evidence-session-open.png b/.github/pr-evidence/evidence-session-open.png new file mode 100644 index 00000000..f1de936b Binary files /dev/null and b/.github/pr-evidence/evidence-session-open.png differ diff --git a/.github/pr-evidence/evidence-work-status.png b/.github/pr-evidence/evidence-work-status.png new file mode 100644 index 00000000..dec750fd Binary files /dev/null and b/.github/pr-evidence/evidence-work-status.png differ diff --git a/.github/pr-evidence/forge-gitea-connected.png b/.github/pr-evidence/forge-gitea-connected.png new file mode 100644 index 00000000..ebeff144 Binary files /dev/null and b/.github/pr-evidence/forge-gitea-connected.png differ diff --git a/.github/pr-evidence/light-git-page.png b/.github/pr-evidence/light-git-page.png new file mode 100644 index 00000000..17321dd6 Binary files /dev/null and b/.github/pr-evidence/light-git-page.png differ diff --git a/.github/pr-evidence/light-projects-page.png b/.github/pr-evidence/light-projects-page.png new file mode 100644 index 00000000..27918c7b Binary files /dev/null and b/.github/pr-evidence/light-projects-page.png differ diff --git a/.github/pr-evidence/narrow-dark-projects-page.png b/.github/pr-evidence/narrow-dark-projects-page.png new file mode 100644 index 00000000..ed52fc26 Binary files /dev/null and b/.github/pr-evidence/narrow-dark-projects-page.png differ diff --git a/package.json b/package.json index dc3602d4..0c430683 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "icons:sprite": "node scripts/generate-file-type-sprite.mjs", "icons:generate": "bun run scripts/generate-icon-sprite.mjs", "themes:port:opencode": "tsx scripts/port-opencode-theme.ts", + "gitea:live-test": "bun packages/web/scripts/gitea-live-test.ts", "version:bump": "node scripts/bump-version.mjs", "release:prepare": "bun run build && bun run type-check && bun run lint", "release:test": "./scripts/test-release-build.sh", diff --git a/packages/docs/content/docs/gitlab.mdx b/packages/docs/content/docs/gitlab.mdx new file mode 100644 index 00000000..c519c576 --- /dev/null +++ b/packages/docs/content/docs/gitlab.mdx @@ -0,0 +1,31 @@ +--- +title: GitLab Issues & MRs +description: Connect GitLab and start sessions from issues and merge requests. +--- + +# GitLab Issues & MRs + +Connect your GitLab account and OpenChamber can pull in issues and merge requests, and start a session straight from one. You can also create, update, and merge MRs directly from OpenChamber. + +## Connect GitLab + +1. Open **Settings → Git**. +2. Under GitLab, choose **Connect**. +3. Paste a Personal Access Token. Create one in GitLab under **Profile → Access Tokens** — the `read_api` scope is enough for read-only workflows, and `api` is needed later for writing. +4. For a self-hosted GitLab instance, also enter the instance URL (for example `https://gitlab.example.com`). + +When it's connected, your account shows under the GitLab section. You can connect more than one account and switch between them, or disconnect at any time. + +## Start work from an issue or MR + +When you create a [worktree session](/worktrees/) with GitLab connected, you can choose **Start from GitLab issue/MR**: + +- pick an **issue** and OpenChamber names the branch after it and opens the session with the issue and its comments as the first message +- pick a **merge request** and it checks out the MR's branch; you can include the MR's diff so the agent has the full change + +This drops you straight into a session with the context already loaded. + +## Related + +- [Git & GitHub Workflows](/git/) — commit and manage branches +- [Worktree Sessions](/worktrees/) — where issue and MR sessions start diff --git a/packages/docs/sidebar.config.json b/packages/docs/sidebar.config.json index 828726a0..31546d75 100644 --- a/packages/docs/sidebar.config.json +++ b/packages/docs/sidebar.config.json @@ -308,6 +308,21 @@ "tr": "GitHub issue'ları ve PR'lar" } }, + { + "label": "GitLab Issues & MRs", + "link": "/gitlab/", + "translations": { + "uk": "Завдання та MR GitLab", + "zh-CN": "GitLab 工单与 MR", + "es": "Issues y MRs de GitLab", + "pt-BR": "Issues e MRs do GitLab", + "ko": "GitLab 이슈 및 MR", + "pl": "Zgłoszenia i MR-y GitLab", + "fr": "Issues et MR GitLab", + "ja": "GitLab Issues と MR", + "de": "GitLab-Issues und MRs" + } + }, { "label": "Magic Prompts", "link": "/magic-prompts/", diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 371861f0..8bb58376 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -68,6 +68,11 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog'; import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog'; import { LinearIssuePickerDialog } from '@/components/session/LinearIssuePickerDialog'; +import { GitLabIssuePickerDialog } from '@/components/session/GitLabIssuePickerDialog'; +import { GitLabMrPickerDialog } from '@/components/session/GitLabMrPickerDialog'; +import { GiteaIssuePickerDialog } from '@/components/session/GiteaIssuePickerDialog'; +import { GiteaPrPickerDialog } from '@/components/session/GiteaPrPickerDialog'; +import { useGitProvider } from '@/lib/gitProvider'; import { Icon } from "@/components/icon/Icon"; import { DraftPresetChips } from './DraftPresetChips'; import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory'; @@ -443,6 +448,7 @@ const ChatInputComponent: React.FC = ({ const { currentTheme } = useThemeSystem(); const chatSearchDirectory = useChatSearchDirectory(); const isGitRepo = useIsGitRepo(currentDirectory); + const gitProvider = useGitProvider(currentDirectory); const currentGitStatus = useGitStore((state) => currentDirectory ? state.directories.get(currentDirectory)?.status ?? null : null, ); @@ -732,6 +738,10 @@ const ChatInputComponent: React.FC = ({ const [issuePickerOpen, setIssuePickerOpen] = React.useState(false); const [prPickerOpen, setPrPickerOpen] = React.useState(false); const [linearPickerOpen, setLinearPickerOpen] = React.useState(false); + const [gitlabIssuePickerOpen, setGitlabIssuePickerOpen] = React.useState(false); + const [gitlabMrPickerOpen, setGitlabMrPickerOpen] = React.useState(false); + const [giteaIssuePickerOpen, setGiteaIssuePickerOpen] = React.useState(false); + const [giteaPrPickerOpen, setGiteaPrPickerOpen] = React.useState(false); const [linkedIssue, setLinkedIssue] = React.useState<{ number: number; title: string; @@ -749,6 +759,7 @@ const ChatInputComponent: React.FC = ({ instructionsText: string; contextText: string; author?: { login: string; avatarUrl?: string }; + provider?: 'github' | 'gitlab' | 'gitea'; } | null>(null); const [linkedLinearIssue, setLinkedLinearIssue] = React.useState<{ identifier: string; @@ -982,12 +993,24 @@ const ChatInputComponent: React.FC = ({ }, [isExpandedInput, setExpandedInput]); const openIssuePicker = React.useCallback(() => { - setIssuePickerOpen(true); - }, []); + if (gitProvider === 'gitlab') { + setGitlabIssuePickerOpen(true); + } else if (gitProvider === 'gitea') { + setGiteaIssuePickerOpen(true); + } else { + setIssuePickerOpen(true); + } + }, [gitProvider]); const openPrPicker = React.useCallback(() => { - setPrPickerOpen(true); - }, []); + if (gitProvider === 'gitlab') { + setGitlabMrPickerOpen(true); + } else if (gitProvider === 'gitea') { + setGiteaPrPickerOpen(true); + } else { + setPrPickerOpen(true); + } + }, [gitProvider]); const openLinearPicker = React.useCallback(() => { setLinearPickerOpen(true); @@ -2804,20 +2827,24 @@ const ChatInputComponent: React.FC = ({ author={linkedIssue.author} openInBrowserLabel={t('chat.chatInput.linked.issue.openInBrowserAria')} removeLabel={t('chat.chatInput.linked.issue.removeAria')} - onReopenPicker={() => setIssuePickerOpen(true)} + onReopenPicker={openIssuePicker} onRemove={() => setLinkedIssue(null)} /> ) : null} {linkedPr && !isVSCode ? ( setPrPickerOpen(true)} + onReopenPicker={openPrPicker} onRemove={() => setLinkedPr(null)} /> ) : null} @@ -3168,6 +3195,40 @@ const ChatInputComponent: React.FC = ({ setLinkedPr(null); }} /> + { + setLinkedIssue(issue); + setLinkedPr(null); + }} + /> + { + setLinkedPr({ ...pr, provider: 'gitlab' as const }); + setLinkedIssue(null); + }} + /> + { + setLinkedIssue(issue); + setLinkedPr(null); + }} + /> + { + setLinkedPr({ ...pr, provider: 'gitea' as const }); + setLinkedIssue(null); + }} + /> = ({ requestAnimationFrame(openIssuePicker); }} > - - {t('chat.chatInput.actions.linkGithubIssue')} + + {gitProvider === 'gitlab' ? t('chat.chatInput.actions.linkGitlabIssue') : gitProvider === 'gitea' ? t('chat.chatInput.actions.linkGiteaIssue') : t('chat.chatInput.actions.linkGithubIssue')} {showLinearPicker ? ( + + {live ? ( + + ) : null} + {live?.draft ? {t('chat.workStatus.pr.draft')} : null} + {`#${entry.number}`} + {canLive ? ( + + ) : null} + + + + ); +}; + +/** + * What is loaded into the agent's context: the git-forge threads this session + * was pointed at (live state cards plus link/unlink controls), and how much + * ambient material is available. * * Agents are deliberately absent — an agent is who does the work, not material * the work is done with. Tools are absent for want of an honest source: @@ -36,11 +178,7 @@ type Props = { */ export const WorkStatusContextSection: React.FC = ({ sessionId, directory }) => { const { t } = useI18n(); - const { linear } = useRuntimeAPIs(); - const linearConnected = useLinearAuthStore((state) => state.status?.connected === true); - const mobileActions = useMobileAppActions(); - const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); - const setLinearIssueFocus = useUIStore((state) => state.setLinearIssueFocus); + const [linkDialogOpen, setLinkDialogOpen] = React.useState(false); const session = useSession(sessionId ?? '', directory ?? undefined); const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft); @@ -52,6 +190,9 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory const mcpStatus = useMcpStore( React.useCallback((state) => state.getStatusForDirectory(directory), [directory]), ); + // The session's server-confirmed directory is the authoritative address for + // forge lookups; the prop only covers drafts with no session yet. + const sessionDirectory = session?.directory ?? directory; // Skills were previously fetched only when the composer's slash autocomplete // opened, so this row reported whatever count happened to be cached — often @@ -147,23 +288,6 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory const pinnedCount = visibleKnowledge.notes.length + visibleKnowledge.plans.length; const linked = React.useMemo(() => getLinkedIssues(session), [session]); - const openLinkedIssue = React.useCallback((entry: (typeof linked)[number]) => { - if ( - entry.kind === 'linear' - && directory - && canOpenLinearIssueInContextPanel({ - linearAvailable: Boolean(linear), - linearConnected, - inDedicatedMobileShell: mobileActions != null, - directory, - }) - ) { - setLinearIssueFocus(entry.identifier); - openContextPanelTab(directory, { mode: 'linear' }); - return; - } - window.open(entry.url, '_blank', 'noopener,noreferrer'); - }, [directory, linear, linearConnected, mobileActions, openContextPanelTab, setLinearIssueFocus]); // Connected servers only. A disabled server contributes nothing to the // context, so counting it here contradicts the MCP section right above, // which shows the same servers switched off. @@ -216,42 +340,43 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory } } + const hasSessionContext = Boolean(sessionId && sessionDirectory); + return ( setLinkDialogOpen(true)} + aria-label={t('chat.workStatus.linkedIssues.link')} + title={t('chat.workStatus.linkedIssues.link')} + > + + {t('chat.workStatus.linkedIssues.link')} + + )} > {/* Attached threads first: they are specific to this session, while the counts below describe the workspace. */} {linked.map((entry) => ( - - ) : ( - - )} - label={entry.title} - muted - // GitHub threads still live on github.com. A Linear issue opens in - // the right-hand panel when that rail exists; otherwise the Linear URL. - onClick={() => openLinkedIssue(entry)} - ariaLabel={entry.kind === 'linear' - ? t('chat.workStatus.linkedIssues.openLinear', { identifier: entry.identifier }) - : t('chat.workStatus.linkedIssues.open', { number: entry.number })} - value={( - - {entry.kind === 'linear' ? entry.identifier : `#${entry.number}`} - - )} + entry={entry} + sessionId={sessionId} + directory={sessionDirectory} /> ))} + {linked.length === 0 ? ( + + ) : null} + {/* Named individually: a count alone would not identify this session's context. */} {/* The pin is the control, exactly as in the pinned-messages section above: same icon, same placement, same behaviour. Two pins that look @@ -318,6 +443,13 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory label={t('chat.workStatus.breakdown.mcp')} value={{mcpCount}} /> + + ); }; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusLinkDialog.tsx b/packages/ui/src/components/chat/work-status/WorkStatusLinkDialog.tsx new file mode 100644 index 00000000..49b89c59 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusLinkDialog.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { toast } from '@/components/ui'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Icon } from '@/components/icon/Icon'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { buildForgeProvider } from '@/lib/forge/adapters'; +import { useI18n } from '@/lib/i18n'; +import { buildLinkedIssue, parseForgeEntityUrl } from '@/lib/linkedIssues'; +import { setLinkedIssue } from '@/sync/session-actions'; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + sessionId: string | null; + directory: string | null; +}; + +/** + * Manual "link this issue/PR" control for the context-sources section. + * + * The URL is parsed first (`parseForgeEntityUrl`) and validated against the + * forge before the link is recorded: the live fetch proves the entity exists + * and supplies its real title, so a stale or mistyped URL surfaces as a + * `linkFailed` toast instead of a snapshot row that never resolves. Linking + * rides the same `setLinkedIssue`/session-metadata channel as the attach + * flows, so the row appears through the section's existing session read. + */ +export const WorkStatusLinkDialog: React.FC = ({ open, onOpenChange, sessionId, directory }) => { + const { t } = useI18n(); + const [url, setUrl] = React.useState(''); + const [error, setError] = React.useState(null); + const [busy, setBusy] = React.useState(false); + + const handleOpenChange = React.useCallback((next: boolean) => { + if (!next) { + setUrl(''); + setError(null); + setBusy(false); + } + onOpenChange(next); + }, [onOpenChange]); + + const handleLink = React.useCallback(async () => { + if (!sessionId || !directory || busy) return; + + const parsed = parseForgeEntityUrl(url); + if (!parsed) { + setError(t('chat.workStatus.linkedIssues.linkInvalid')); + return; + } + + setBusy(true); + setError(null); + try { + const apis = getRegisteredRuntimeAPIs(); + const provider = apis ? buildForgeProvider(parsed.provider, apis) : null; + if (!provider) { + toast.error(t('chat.workStatus.linkedIssues.linkFailed')); + return; + } + + // Validate the entity exists on the forge and grab its real title. The + // facade resolves the repo from the session's remotes; an entity the + // forge no longer knows (or a repo the session cannot reach) reports no + // title and the link is refused. + let title: string | null = null; + try { + if (parsed.kind === 'pull') { + const context = await provider.getPullRequestContext(directory, parsed.number); + title = context.pr?.title ?? null; + } else { + const detail = await provider.getIssue(directory, parsed.number); + title = detail.issue?.title ?? null; + } + } catch { + title = null; + } + if (!title) { + toast.error(t('chat.workStatus.linkedIssues.linkFailed')); + return; + } + + const issue = buildLinkedIssue({ + url: url.trim(), + number: parsed.number, + title, + kind: parsed.kind, + provider: parsed.provider, + repo: parsed.repo, + linkedAt: Date.now(), + }); + await setLinkedIssue(sessionId, directory, issue, true); + toast.success(t('chat.workStatus.linkedIssues.linked')); + handleOpenChange(false); + } catch { + toast.error(t('chat.workStatus.linkedIssues.linkFailed')); + } finally { + setBusy(false); + } + }, [busy, directory, handleOpenChange, sessionId, t, url]); + + const canSubmit = Boolean(sessionId && directory) && !busy && url.trim().length > 0; + + return ( + + + + {t('chat.workStatus.linkedIssues.linkDialogTitle')} + + +
+ setUrl(event.target.value)} + placeholder={t('chat.workStatus.linkedIssues.linkPlaceholder')} + aria-invalid={error ? true : undefined} + disabled={busy || !sessionId || !directory} + autoFocus + onKeyDown={(event) => { + if (event.key === 'Enter' && canSubmit) void handleLink(); + }} + /> + {error ?

{error}

: null} +
+ + + + + +
+
+ ); +}; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx index fc0cada6..b6bd05d7 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx @@ -2,10 +2,12 @@ import React from 'react'; import { useI18n } from '@/lib/i18n'; import { useGitStore } from '@/stores/useGitStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; import { runBackgroundNetworkTask } from '@/lib/background-network'; -import { useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore'; -import { useSessionMessages } from '@/sync/sync-context'; +import { getGitHubPrStatusKey, usePrVisualSummary, useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore'; +import { useGitLabMrForBranch } from '@/lib/gitlabMrStatus'; +import { useGiteaPrForBranch } from '@/lib/giteaPrStatus'; +import { useGitProvider } from '@/lib/gitProvider'; +import { useSession, useSessionMessages } from '@/sync/sync-context'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -15,8 +17,6 @@ import { resolveUsageTone } from '@/lib/quota'; import { sessionEvents } from '@/lib/sessionEvents'; import { normalizePath } from '@/lib/pathNormalization'; import { computeContextUsage } from './contextUsage'; -import { formatCost } from './subagentCost'; -import { useSubagentCostRollup } from './useSubagentCostRollup'; import { WorkStatusCallout, WorkStatusMeter, @@ -36,6 +36,11 @@ type Props = { showRepository: boolean; }; +// Spend is read against a budget, so it keeps its real precision instead of +// collapsing to two decimals. Trailing zeros are dropped so exact values stay +// short. +const trimZeros = (value: string): string => (value.includes('.') ? value.replace(/0+$/, '').replace(/\.$/, '') : value); +const formatCost = (cost: number): string => `$${trimZeros(cost.toFixed(4))}`; // Matches the header readout exactly: one decimal, capped the same way, so the // two places that report context fill never disagree by a rounding step. const formatPercent = (percent: number): string => `${Math.min(percent, 999).toFixed(1)}%`; @@ -47,59 +52,43 @@ const formatPercent = (percent: number): string => `${Math.min(percent, 999).toF */ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, goalRow, showSession, showRepository }) => { const { t } = useI18n(); + const session = useSession(sessionId ?? '', directory ?? undefined); const { git } = useRuntimeAPIs(); const ensureStatus = useGitStore((state) => state.ensureStatus); const fetchStatus = useGitStore((state) => state.fetchStatus); const clearDiffCache = useGitStore((state) => state.clearDiffCache); - // The repository the readouts describe. Same resolution as the Git tab: the - // session directory itself when it is a repository, otherwise the nested - // repository selected (or auto-selected) for it, so a session in a plain - // folder of repositories still reports the branch and changes the Git tab - // shows. Navigation below stays keyed on `directory` — context-panel tabs - // are per project root. - const { gitDirectory } = useNestedGitDirectory(directory, { enabled: showRepository }); - const gitStatus = useGitStore( React.useCallback( - (state) => (gitDirectory ? state.directories.get(gitDirectory)?.status ?? null : null), - [gitDirectory], + (state) => (directory ? state.directories.get(directory)?.status ?? null : null), + [directory], ), ); // Warm the shared git cache through the background-network gate so the panel // never competes with the chat's own bootstrap traffic for sockets. React.useEffect(() => { - if (!showRepository || !gitDirectory || !git) return; - void runBackgroundNetworkTask(() => ensureStatus(gitDirectory, git)); - }, [gitDirectory, git, ensureStatus, showRepository]); + if (!showRepository || !directory || !git) return; + void runBackgroundNetworkTask(() => ensureStatus(directory, git)); + }, [directory, git, ensureStatus, showRepository]); // Own the live invalidation for the repository readout. The desktop // composer's changed-files row no longer renders, so this panel must not // depend on ChatInput (or an opened Git surface) to refresh the shared cache // on its behalf. React.useEffect(() => { - if (!showRepository || !gitDirectory || !git) return; + if (!showRepository || !directory || !git) return; return sessionEvents.onGitRefreshHint((hint) => { - if (normalizePath(hint.directory) !== normalizePath(gitDirectory)) return; + if (normalizePath(hint.directory) !== normalizePath(directory)) return; if (hint.paths?.length) { - clearDiffCache(gitDirectory, hint.paths); + clearDiffCache(directory, hint.paths); } - void fetchStatus(gitDirectory, git, { silent: true }); + void fetchStatus(directory, git, { silent: true }); }); - }, [clearDiffCache, gitDirectory, fetchStatus, git, showRepository]); + }, [clearDiffCache, directory, fetchStatus, git, showRepository]); const branch = gitStatus?.current?.trim() || null; - // Which repository under the project the branch belongs to. Only meaningful - // when the readouts come from a nested repository; for a project that is a - // repository itself the section header already names it. - const nestedRepoLabel = React.useMemo(() => { - if (!directory || !gitDirectory || gitDirectory === directory) return null; - const rootPrefix = `${directory}/`; - return gitDirectory.startsWith(rootPrefix) ? gitDirectory.slice(rootPrefix.length) : gitDirectory; - }, [directory, gitDirectory]); - const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); // Worktrees normally sit beside rather than beneath their project directory, // so a prefix match alone cannot find their owning project. Reuse the shared @@ -121,7 +110,14 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, // Read-only: PR watching is owned by the background tracker. Starting a watch // here would multiply GitHub requests per open session, which is exactly the // fan-out the PR-status concurrency gate exists to prevent. - const prSummary = useFreshestPrVisualSummaryForBranch(gitDirectory, branch); + const prSummary = useFreshestPrVisualSummaryForBranch(directory, branch); + + // GitLab merge requests and Gitea pull requests ride the same shared TTL + // cache as the git view and the walkthrough, so every surface that reports + // the branch's request stays consistent without extra requests. + const gitProvider = useGitProvider(directory); + const { mr: gitLabMr } = useGitLabMrForBranch(directory, branch); + const { pr: giteaPr } = useGiteaPrForBranch(directory, branch); // `getCurrentModel` is an imperative getter: its reference never changes, so // calling it in render subscribes to nothing. Subscribe to the selected model @@ -209,18 +205,29 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, : usageTone === 'warn' ? 'var(--status-warning)' : 'var(--status-success)'; - // Rollup total: own cost plus every descendant subagent's cost, recursively - // (see useSubagentCostRollup). Shown here instead of session.cost alone, so - // spend that ran in a spawned subagent doesn't hide from the reader. - const { totalCost, ownCost, subagentCost, subagentCount } = useSubagentCostRollup(sessionId); - const cost = totalCost !== null && totalCost > 0 ? totalCost : null; - // The total answers "what has this cost"; the split answers "why is it more - // than the session I am looking at". Only worth a line once subagents exist — - // without them the total *is* the session's own cost and the row would - // restate the number directly above it. - const showCostBreakdown = cost !== null && subagentCount > 0 && subagentCost > 0; + const cost = typeof session?.cost === 'number' && session.cost > 0 ? session.cost : null; const hasSession = showSession && (usagePercent !== null || cost !== null || Boolean(goalRow)); - const hasRepository = showRepository && Boolean(branch || changed || prSummary || attentionLabel); + const hasGitLabMr = gitProvider === 'gitlab' && gitLabMr !== null; + const hasGiteaPr = gitProvider === 'gitea' && giteaPr !== null; + const gitLabMrVisualState = gitLabMr + ? gitLabMr.state === 'merged' + ? 'merged' + : gitLabMr.state === 'closed' + ? 'closed' + : gitLabMr.draft + ? 'draft' + : 'open' + : null; + const giteaPrVisualState = giteaPr + ? giteaPr.state === 'merged' + ? 'merged' + : giteaPr.state === 'closed' + ? 'closed' + : giteaPr.draft + ? 'draft' + : 'open' + : null; + const hasRepository = showRepository && Boolean(branch || changed || prSummary || attentionLabel || hasGitLabMr || hasGiteaPr); useReportWorkStatusPresence('session-repository', hasSession || hasRepository); @@ -247,17 +254,6 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, )} /> - {/* Caption, not a row: it explains the figure above it rather - than reporting a reading of its own, so it carries no icon - and no label column. */} - {showCostBreakdown ? ( -

- {t('chat.workStatus.cost.breakdown', { - session: formatCost(ownCost), - subagents: formatCost(subagentCost), - })} -

- ) : null} ) : null} {/* Below the context readout: the goal is a standing instruction, @@ -292,16 +288,6 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, /> ) : null} - {nestedRepoLabel ? ( - openSurface('git') : undefined} - ariaLabel={t('chat.workStatus.action.openGit')} - label={nestedRepoLabel} - muted - /> - ) : null} - {changed ? ( = ({ sessionId, directory, /> ) : null} + {hasGitLabMr && gitLabMr ? ( + openSurface('pr') : undefined} + ariaLabel={t('chat.workStatus.action.openMr')} + iconColor={`var(--pr-${gitLabMrVisualState})`} + label={gitLabMr.title || t('chat.workStatus.mr.untitled')} + value={( + + {gitLabMr.draft ? t('chat.workStatus.pr.draft') : `!${gitLabMr.number}`} + + )} + /> + ) : null} + + {hasGiteaPr && giteaPr ? ( + openSurface('pr') : undefined} + ariaLabel={t('chat.workStatus.action.openPr')} + iconColor={`var(--pr-${giteaPrVisualState})`} + label={giteaPr.title || t('chat.workStatus.pr.untitled')} + value={( + + {giteaPr.draft ? t('chat.workStatus.pr.draft') : `#${giteaPr.number}`} + + )} + /> + ) : null} + {prSummary ? ( <> `, "git-pull-request": ``, "git-repository": ``, + "gitea": ``, "github": ``, "github-fill": ``, + "gitlab": ``, "global": ``, "graduation-cap": ``, "hammer": ``, diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 3e86e28b..9f458d65 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button'; import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { PullRequestView } from '@/components/views/PullRequestView'; +import { GitLabMrView } from '@/components/views/GitLabMrView'; +import { GiteaPrView } from '@/components/views/GiteaPrView'; import { TerminalView } from '@/components/views/TerminalView'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; @@ -27,6 +29,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { useBrowserFaviconStore } from '@/stores/useBrowserFaviconStore'; +import { useGitProvider, type GitProvider } from '@/lib/gitProvider'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useUIStore, type ContextPanelMode, type PendingDiffScope } from '@/stores/useUIStore'; import { markSessionViewed } from '@/sync/notification-store'; @@ -112,7 +115,8 @@ const getRelativePathLabel = (filePath: string | null, directory: string): strin const getModeLabel = ( mode: ContextPanelMode, - t: TranslateFn + t: TranslateFn, + gitProvider: GitProvider | null, ): string => { if (mode === 'chat') return t('contextPanel.mode.chat'); if (mode === 'file') return t('contextPanel.mode.files'); @@ -121,7 +125,7 @@ const getModeLabel = ( if (mode === 'plan') return t('contextPanel.mode.plan'); if (mode === 'browser') return t('contextPanel.mode.browser'); if (mode === 'git') return t('layout.rightSidebar.git'); - if (mode === 'pr') return t('contextPanel.mode.pr'); + if (mode === 'pr') return gitProvider === 'gitlab' ? t('contextPanel.mode.mr') : t('contextPanel.mode.pr'); if (mode === 'linear') return t('contextPanel.mode.linear'); if (mode === 'notes') return t('contextRail.surface.notes'); if (mode === 'terminal') return t('layout.mainTab.terminal'); @@ -149,7 +153,8 @@ const getFileNameFromPath = (path: string | null): string | null => { const getTabLabel = ( tab: { mode: ContextPanelMode; label: string | null; targetPath: string | null; dedupeKey?: string; sessionTitleFallback?: string | null; stagedDiff?: boolean }, sessionTitleById: ReadonlyMap, - t: TranslateFn + t: TranslateFn, + gitProvider: GitProvider | null, ): string => { if (tab.mode === 'chat') { const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey); @@ -188,12 +193,13 @@ const getTabLabel = ( return t('contextPanel.mode.diff'); } - return getModeLabel(tab.mode, t); + return getModeLabel(tab.mode, t, gitProvider); }; const getTabIcon = ( tab: { mode: ContextPanelMode; targetPath: string | null }, faviconByOrigin: Record = {}, + gitProvider: GitProvider | null, ): React.ReactNode | undefined => { if (tab.mode === 'file') { return tab.targetPath @@ -214,7 +220,7 @@ const getTabIcon = ( } if (tab.mode === 'pr') { - return ; + return ; } if (tab.mode === 'linear') { @@ -449,6 +455,7 @@ export const ContextPanel: React.FC = () => { const { t } = useI18n(); const effectiveDirectory = useEffectiveDirectory() ?? ''; const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]); + const gitProvider = useGitProvider(effectiveDirectory); const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined)); const closeContextPanel = useUIStore((state) => state.closeContextPanel); @@ -930,24 +937,24 @@ export const ContextPanel: React.FC = () => { ); const tabItems = React.useMemo(() => activeModeTabs.map((tab) => { - const rawLabel = getTabLabel(tab, sessionTitleById, t); + const rawLabel = getTabLabel(tab, sessionTitleById, t, gitProvider); const label = truncateTabLabel(rawLabel, CONTEXT_TAB_LABEL_MAX_CHARS); const tabPathLabel = getRelativePathLabel(tab.targetPath, effectiveDirectory); return { id: tab.id, label, - icon: getTabIcon(tab, faviconByOrigin), + icon: getTabIcon(tab, faviconByOrigin, gitProvider), title: tabPathLabel ? `${rawLabel}: ${tabPathLabel}` : rawLabel, closeLabel: t('contextPanel.tab.closeTabAria', { label }), }; - }), [activeModeTabs, effectiveDirectory, faviconByOrigin, sessionTitleById, t]); + }), [activeModeTabs, effectiveDirectory, faviconByOrigin, gitProvider, sessionTitleById, t]); const activeNonChatContent = activeTab?.mode === 'context' ? : activeTab?.mode === 'git' ? : activeTab?.mode === 'pr' - ? + ? (gitProvider === 'github' ? : gitProvider === 'gitlab' ? : gitProvider === 'gitea' ? : null) : activeTab?.mode === 'linear' ? : activeTab?.mode === 'notes' @@ -1064,9 +1071,9 @@ export const ContextPanel: React.FC = () => { /> ) : (
- {activeTab ? getTabIcon(activeTab, faviconByOrigin) : null} + {activeTab ? getTabIcon(activeTab, faviconByOrigin, gitProvider) : null} - {activeTab ? getModeLabel(activeTab.mode, t) : null} + {activeTab ? getModeLabel(activeTab.mode, t, gitProvider) : null}
)} diff --git a/packages/ui/src/components/layout/ContextPanelRail.tsx b/packages/ui/src/components/layout/ContextPanelRail.tsx index deb92627..e4eb1381 100644 --- a/packages/ui/src/components/layout/ContextPanelRail.tsx +++ b/packages/ui/src/components/layout/ContextPanelRail.tsx @@ -17,11 +17,13 @@ import { import { CSS } from '@dnd-kit/utilities'; import { Icon } from '@/components/icon/Icon'; +import type { IconName } from '@/components/icon/icons'; import { DiffViewIcon } from '@/components/icons/DiffIcon'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useDeviceInfo } from '@/lib/device'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { useGitProvider } from '@/lib/gitProvider'; import { useI18n } from '@/lib/i18n'; import { getVisibleContextRailSurfaces, @@ -176,6 +178,9 @@ export const ContextPanelRail: React.FC = () => { const githubConnected = useGitHubAuthStore((state) => state.status?.connected === true); const { screenWidth } = useDeviceInfo(); const gitStatus = useGitStatus(directoryKey || null); + // Provider-aware 'pr' branding: GitLab repositories get the MR descriptor + // (icon + labels) on the rail instead of the GitHub pull-request branding. + const gitProvider = useGitProvider(directoryKey); const surfaceSwitchPrefix = React.useMemo( () => getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides), @@ -271,9 +276,9 @@ export const ContextPanelRail: React.FC = () => { screenWidth, tabs, linearConnected, - githubConnected, + gitProvider, }); - }, [contextRailHiddenSurfaces, contextRailOrder, githubConnected, linearConnected, planModeEnabled, screenWidth, tabs]); + }, [contextRailHiddenSurfaces, contextRailOrder, gitProvider, linearConnected, planModeEnabled, screenWidth, tabs]); // A surface whose integration disconnected closes rather than lingering as // an active panel with no rail icon. @@ -321,7 +326,28 @@ export const ContextPanelRail: React.FC = () => { surface.id)} strategy={verticalListSortingStrategy}> {surfaces.map((surface, index) => { - const label = t(surface.labelKey); + // The 'pr' surface renders the GitLab MR view in GitLab repos, so + // it borrows GitLab's merge-request branding instead of GitHub's. + // Gitea keeps the generic pull-request branding but swaps the + // GitHub brand icon for the Gitea mark. + const providerPrSurface: ContextSurfaceDescriptor = surface.id === 'pr' + ? gitProvider === 'gitlab' + ? { + ...surface, + icon: 'gitlab' as IconName, + labelKey: 'contextPanel.mode.mr', + descriptionKey: 'contextRail.surface.mr.description', + } + : gitProvider === 'gitea' + ? { + ...surface, + icon: 'gitea' as IconName, + labelKey: 'contextPanel.mode.pr', + descriptionKey: 'contextRail.surface.pr.description', + } + : surface + : surface; + const label = t(providerPrSurface.labelKey); // Git shows a numeric badge instead of the old activity dot. // Other surfaces never inherit git's changed-files signal. // The work-status panel reports the same count in words a few @@ -331,11 +357,11 @@ export const ContextPanelRail: React.FC = () => { return ( = { branch: 'git-branch', @@ -44,7 +49,11 @@ const COLOR_MAP: Record = { type: 'var(--syntax-type)', }; -export const GitPage: React.FC = () => { +export interface GitPageProps { + revealItemId?: string | null; +} + +export const GitPage: React.FC = (props) => { const { t } = useI18n(); const { profiles, @@ -76,6 +85,44 @@ export const GitPage: React.FC = () => { const [deleteDialogProfile, setDeleteDialogProfile] = React.useState(null); const [isDeletePending, setIsDeletePending] = React.useState(false); + const [activeProviderTab, setActiveProviderTab] = React.useState('github'); + + const providerTabs = React.useMemo(() => [ + { + id: 'github', + label: t('settings.git.tabs.github'), + icon: , + }, + { + id: 'gitlab', + label: t('settings.git.tabs.gitlab'), + icon: , + }, + { + id: 'gitea', + label: t('settings.git.tabs.gitea'), + icon: , + }, + ], [t]); + + const revealItemId = props.revealItemId; + const lastHandledRevealRef = React.useRef(null); + + React.useLayoutEffect(() => { + if (revealItemId == null) { + lastHandledRevealRef.current = null; + return; + } + if (lastHandledRevealRef.current === revealItemId) { + return; + } + lastHandledRevealRef.current = revealItemId; + const tab = providerTabForSettingsItem(revealItemId); + if (tab) { + setActiveProviderTab(tab); + } + }, [revealItemId]); + React.useEffect(() => { loadProfiles(); loadGlobalIdentity(); @@ -120,6 +167,31 @@ export const GitPage: React.FC = () => { title={t('settings.page.git.title')} showSaveStatus > +
+
+
+ setActiveProviderTab(tabId as GitProviderTabId)} + layoutMode="fit" + variant="active-pill" + activePillButtonClassName="h-7" + /> +
+
+ + + + +
+ { + test('maps GitHub settings ids to the github tab', () => { + expect(providerTabForSettingsItem('git.github-account')).toBe('github'); + expect(providerTabForSettingsItem('git.github-api-base-url')).toBe('github'); + expect(providerTabForSettingsItem('git.github-detect-urls')).toBe('github'); + }); + + test('maps GitLab settings ids to the gitlab tab', () => { + expect(providerTabForSettingsItem('git.gitlab-account')).toBe('gitlab'); + expect(providerTabForSettingsItem('git.gitlab-api-base-url')).toBe('gitlab'); + expect(providerTabForSettingsItem('git.gitlab-detect-urls')).toBe('gitlab'); + }); + + test('maps Gitea settings ids to the gitea tab', () => { + expect(providerTabForSettingsItem('git.gitea-account')).toBe('gitea'); + expect(providerTabForSettingsItem('git.gitea-api-base-url')).toBe('gitea'); + expect(providerTabForSettingsItem('git.gitea-detect-urls')).toBe('gitea'); + }); + + test('returns null for settings ids below the tabs', () => { + expect(providerTabForSettingsItem('git.identities')).toBeNull(); + expect(providerTabForSettingsItem('git.gitmoji')).toBeNull(); + expect(providerTabForSettingsItem('git.changes-view')).toBeNull(); + expect(providerTabForSettingsItem('git.gitignored-files')).toBeNull(); + }); + + test('returns null for empty or missing ids', () => { + expect(providerTabForSettingsItem(null)).toBeNull(); + expect(providerTabForSettingsItem(undefined)).toBeNull(); + expect(providerTabForSettingsItem('')).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/sections/git-identities/providerTabs.ts b/packages/ui/src/components/sections/git-identities/providerTabs.ts new file mode 100644 index 00000000..fc1e6628 --- /dev/null +++ b/packages/ui/src/components/sections/git-identities/providerTabs.ts @@ -0,0 +1,9 @@ +export type GitProviderTabId = 'github' | 'gitlab' | 'gitea'; + +export const providerTabForSettingsItem = (settingsItemId: string | null | undefined): GitProviderTabId | null => { + if (!settingsItemId) return null; + if (settingsItemId.startsWith('git.github-')) return 'github'; + if (settingsItemId.startsWith('git.gitlab-')) return 'gitlab'; + if (settingsItemId.startsWith('git.gitea-')) return 'gitea'; + return null; +}; diff --git a/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx b/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx index 0f6b7ec3..3cd318a1 100644 --- a/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx +++ b/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx @@ -93,6 +93,22 @@ const PROMPT_PAGE_MAP: Record = { { 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', diff --git a/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx b/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx index 2ea33ba1..68893b29 100644 --- a/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx +++ b/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx @@ -41,6 +41,13 @@ export const MagicPromptsSidebar: React.FC = ({ onItem { id: 'linear.issue.review', titleKey: 'settings.magicPrompts.sidebar.item.linearIssueReview' }, ], }, + { + 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: [ diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx index c72dc48b..34244478 100644 --- a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx @@ -11,6 +11,8 @@ import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { Icon } from "@/components/icon/Icon"; import { SettingsSection, SettingsGroupTitle } from '@/components/sections/shared/SettingsSection'; +import { ProviderApiBaseUrlInput } from '@/components/sections/shared/ProviderApiBaseUrlInput'; +import { ProviderDetectUrlsInput } from '@/components/sections/shared/ProviderDetectUrlsInput'; type GitHubUser = { login: string; @@ -34,12 +36,7 @@ type DeviceFlowCompleteResponse = | { connected: true; user: GitHubUser; scope?: string } | { connected: false; status?: string; error?: string }; -type GitHubSettingsProps = { - /** Rendered inside the Integrations card: no section chrome of its own. */ - embedded?: boolean; -}; - -export const GitHubSettings: React.FC = ({ embedded = false }) => { +export const GitHubSettings: React.FC<{ embedded?: boolean }> = ({ embedded = false }) => { const { t } = useI18n(); const { isMobile } = useDeviceInfo(); const runtimeGitHub = getRegisteredRuntimeAPIs()?.github; @@ -274,7 +271,7 @@ export const GitHubSettings: React.FC = ({ embedded = false ? t('settings.github.page.accountSource.cli') : t('settings.github.page.accountSource.oauth'); - const accountSection = ( + const accountContent = ( <>
{connected ? ( @@ -444,77 +441,86 @@ export const GitHubSettings: React.FC = ({ embedded = false
)} +
+ {connected ? ( + <> + + + + ) : ( +

+ {t('settings.gitProviders.overridesLocked.description', { provider: t('settings.git.tabs.github') })} +

+ )} +
); - const ghCliSection = ghCli?.available && !ghCli?.active && (!ghCli.user || ghCli.disabled) - ? ( - <> -
-
-
- {ghCli.user?.avatarUrl ? ( - {ghCli.user.login - ) : ( -
- -
- )} -
- {!ghCli.disabled && ghCli.user && ( -
- {ghCli.user.name?.trim() || ghCli.user.login || 'GitHub'} -
- )} - {!ghCli.disabled && ghCli.user?.login && ( -
- - {ghCli.user.login} - {ghCli.user.email && } - {ghCli.user.email && {ghCli.user.email}} -
- )} -
- {ghCli.disabled - ? t('settings.github.page.ghCli.disabledDescription') - : t('settings.github.page.ghCli.fallbackDescription')} -
-
+ const ghCliContent = ghCli?.available && !ghCli?.active && (!ghCli.user || ghCli.disabled) ? ( +
+
+
+ {ghCli.user?.avatarUrl ? ( + {ghCli.user.login + ) : ( +
+ +
+ )} +
+ {!ghCli.disabled && ghCli.user && ( +
+ {ghCli.user.name?.trim() || ghCli.user.login || 'GitHub'}
- + )} + {!ghCli.disabled && ghCli.user?.login && ( +
+ + {ghCli.user.login} + {ghCli.user.email && } + {ghCli.user.email && {ghCli.user.email}} +
+ )} +
+ {ghCli.disabled + ? t('settings.github.page.ghCli.disabledDescription') + : t('settings.github.page.ghCli.fallbackDescription')}
- - ) - : null; +
+ +
+
+ ) : null; if (embedded) { return ( -
- {accountSection} - {ghCliSection ? ( -
- {t('settings.github.page.ghCli.title')} - {ghCliSection} + <> +
+ {accountContent} +
+ {ghCliContent && ( +
+ {ghCliContent}
- ) : null} -
+ )} + ); } @@ -526,14 +532,14 @@ export const GitHubSettings: React.FC = ({ embedded = false settingsItem="git.github-account" info={t('settings.github.page.tooltip.connectAccount')} > - {accountSection} + {accountContent} - {ghCliSection ? ( + {ghCliContent && ( - {ghCliSection} + {ghCliContent} - ) : null} + )} ); }; diff --git a/packages/ui/src/components/sections/openchamber/GitLabSettings.tsx b/packages/ui/src/components/sections/openchamber/GitLabSettings.tsx new file mode 100644 index 00000000..b45adfb1 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/GitLabSettings.tsx @@ -0,0 +1,327 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { toast } from '@/components/ui'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; +import type { GitLabAuthStatus } from '@/lib/api/types'; +import { useDeviceInfo } from '@/lib/device'; +import { cn } from '@/lib/utils'; +import { useI18n } from '@/lib/i18n'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { Icon } from '@/components/icon/Icon'; +import { SettingsSection } from '@/components/sections/shared/SettingsSection'; +import { ProviderApiBaseUrlInput } from '@/components/sections/shared/ProviderApiBaseUrlInput'; +import { ProviderDetectUrlsInput } from '@/components/sections/shared/ProviderDetectUrlsInput'; +import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore'; + +const getBaseUrlHost = (baseUrl?: string | null): string => { + if (!baseUrl) return ''; + try { + return new URL(baseUrl).host; + } catch { + return baseUrl; + } +}; + +export const GitLabSettings: React.FC<{ embedded?: boolean }> = ({ embedded = false }) => { + const { t } = useI18n(); + const { isMobile } = useDeviceInfo(); + const runtimeGitLab = getRegisteredRuntimeAPIs()?.gitlab; + const status = useGitLabAuthStore((state) => state.status); + const isLoading = useGitLabAuthStore((state) => state.isLoading); + const hasChecked = useGitLabAuthStore((state) => state.hasChecked); + const refreshStatus = useGitLabAuthStore((state) => state.refreshStatus); + const setStatus = useGitLabAuthStore((state) => state.setStatus); + + const [isBusy, setIsBusy] = React.useState(false); + const [accessToken, setAccessToken] = React.useState(''); + // Prefill the connect form with the server-side default API base URL when the + // user has not typed one yet; the per-account base URL still wins on connect. + const [baseUrl, setBaseUrl] = React.useState( + useGitProviderDomainsStore((state) => state.apiBaseUrls.gitlab), + ); + + React.useEffect(() => { + (async () => { + try { + if (!hasChecked) { + await refreshStatus(runtimeGitLab); + } + } catch (error) { + console.warn('Failed to load GitLab auth status:', error); + } + })(); + }, [hasChecked, refreshStatus, runtimeGitLab]); + + const connect = React.useCallback(async () => { + const trimmedToken = accessToken.trim(); + if (!trimmedToken) { + toast.error(t('settings.gitlab.page.errors.invalidToken')); + return; + } + const trimmedBaseUrl = baseUrl.trim() || undefined; + setIsBusy(true); + try { + const payload = runtimeGitLab + ? await runtimeGitLab.authConnect({ accessToken: trimmedToken, baseUrl: trimmedBaseUrl }) + : await (async () => { + const response = await runtimeFetch('/api/gitlab/auth/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ accessToken: trimmedToken, baseUrl: trimmedBaseUrl }), + }); + const body = (await response.json().catch(() => null)) as GitLabAuthStatus | { error?: string } | null; + if (!response.ok || !body) { + throw new Error((body as { error?: string } | null)?.error || response.statusText); + } + return body as GitLabAuthStatus; + })(); + + setStatus(payload); + setAccessToken(''); + setBaseUrl(''); + toast.success(t('settings.gitlab.page.toast.connected')); + } catch (error) { + console.error('Failed to connect GitLab:', error); + toast.error(t('settings.gitlab.page.errors.failed')); + } finally { + setIsBusy(false); + } + }, [accessToken, baseUrl, runtimeGitLab, setStatus, t]); + + const disconnect = React.useCallback(async () => { + setIsBusy(true); + try { + if (runtimeGitLab) { + await runtimeGitLab.authDisconnect(); + } else { + const response = await runtimeFetch('/api/gitlab/auth', { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(response.statusText); + } + } + toast.success(t('settings.gitlab.page.toast.disconnected')); + await refreshStatus(runtimeGitLab, { force: true }); + } catch (error) { + console.error('Failed to disconnect GitLab:', error); + toast.error(t('settings.gitlab.page.toast.disconnectFailed')); + } finally { + setIsBusy(false); + } + }, [refreshStatus, runtimeGitLab, t]); + + const activateAccount = React.useCallback(async (accountId: string) => { + if (!accountId) return; + setIsBusy(true); + try { + const payload = runtimeGitLab + ? await runtimeGitLab.authActivate(accountId) + : await (async () => { + const response = await runtimeFetch('/api/gitlab/auth/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ accountId }), + }); + const body = (await response.json().catch(() => null)) as GitLabAuthStatus | { error?: string } | null; + if (!response.ok || !body) { + throw new Error((body as { error?: string } | null)?.error || response.statusText); + } + return body as GitLabAuthStatus; + })(); + + setStatus(payload); + toast.success(t('settings.gitlab.page.toast.accountSwitched')); + } catch (error) { + console.error('Failed to switch GitLab account:', error); + toast.error(t('settings.gitlab.page.toast.accountSwitchFailed')); + } finally { + setIsBusy(false); + } + }, [runtimeGitLab, setStatus, t]); + + if (isLoading) { + return null; + } + + const connected = Boolean(status?.connected); + const user = status?.user; + const accounts = status?.accounts ?? []; + const otherAccounts = accounts.filter((account) => !account.current); + const currentAccount = accounts.find((account) => account.current) ?? (accounts.length > 0 ? accounts[0] : null); + const currentBaseUrlHost = getBaseUrlHost(currentAccount?.baseUrl ?? status?.defaultBaseUrl); + + const sectionContent = ( + <> +
+ {connected ? ( +
+
+ {user?.avatarUrl ? ( + {user.username + ) : ( +
+ +
+ )} + +
+
+ {user?.name?.trim() || user?.username || 'GitLab'} +
+
+ + {t('settings.gitlab.page.connectedAs')} + {user?.username || t('settings.gitlab.page.label.unknownUser')} + + {currentBaseUrlHost} +
+
+
+ + +
+ ) : ( +
+
+ + setAccessToken(event.target.value)} + placeholder={t('settings.gitlab.page.accessToken.placeholder')} + className="h-9 max-w-[24rem]" + /> +
+
+ + setBaseUrl(event.target.value)} + placeholder={t('settings.gitlab.page.baseUrl.placeholder')} + className="h-9 max-w-[24rem]" + /> +
+
+ {t('settings.gitlab.page.status.notConnected')} + +
+
+ )} + + {otherAccounts.length > 0 && ( +
+
+ {t('settings.gitlab.page.label.otherAccounts')} +
+
+ {otherAccounts.map((account) => { + const accountUser = account.user; + return ( +
+
+ {accountUser?.avatarUrl ? ( + {accountUser.username + ) : ( +
+ +
+ )} +
+ + {accountUser?.name?.trim() || accountUser?.username || 'GitLab'} + + {accountUser?.username && ( + + {accountUser.username} + · + {getBaseUrlHost(account.baseUrl)} + + )} +
+
+ +
+ ); + })} +
+
+ )} +
+ +
+ {connected ? ( + <> + + + + ) : ( +

+ {t('settings.gitProviders.overridesLocked.description', { provider: t('settings.git.tabs.gitlab') })} +

+ )} +
+ + ); + + if (embedded) { + return ( +
+ {sectionContent} +
+ ); + } + + return ( + + {sectionContent} + + ); +}; diff --git a/packages/ui/src/components/sections/openchamber/GiteaSettings.tsx b/packages/ui/src/components/sections/openchamber/GiteaSettings.tsx new file mode 100644 index 00000000..8f43f302 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/GiteaSettings.tsx @@ -0,0 +1,337 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { toast } from '@/components/ui'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore'; +import type { GiteaAuthStatus } from '@/lib/api/types'; +import { useDeviceInfo } from '@/lib/device'; +import { cn } from '@/lib/utils'; +import { useI18n } from '@/lib/i18n'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { Icon } from '@/components/icon/Icon'; +import { SettingsSection } from '@/components/sections/shared/SettingsSection'; +import { ProviderApiBaseUrlInput } from '@/components/sections/shared/ProviderApiBaseUrlInput'; +import { ProviderDetectUrlsInput } from '@/components/sections/shared/ProviderDetectUrlsInput'; +import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore'; + +const getBaseUrlHost = (baseUrl?: string | null): string => { + if (!baseUrl) return ''; + try { + return new URL(baseUrl).host; + } catch { + return baseUrl; + } +}; + +export const GiteaSettings: React.FC<{ embedded?: boolean }> = ({ embedded = false }) => { + const { t } = useI18n(); + const { isMobile } = useDeviceInfo(); + const runtimeGitea = getRegisteredRuntimeAPIs()?.gitea; + const status = useGiteaAuthStore((state) => state.status); + const isLoading = useGiteaAuthStore((state) => state.isLoading); + const hasChecked = useGiteaAuthStore((state) => state.hasChecked); + const refreshStatus = useGiteaAuthStore((state) => state.refreshStatus); + const setStatus = useGiteaAuthStore((state) => state.setStatus); + + const [isBusy, setIsBusy] = React.useState(false); + const [accessToken, setAccessToken] = React.useState(''); + // Prefill the connect form with the server-side default API base URL when the + // user has not typed one yet; the per-account base URL still wins on connect. + const [baseUrl, setBaseUrl] = React.useState( + useGitProviderDomainsStore((state) => state.apiBaseUrls.gitea), + ); + + React.useEffect(() => { + (async () => { + try { + if (!hasChecked) { + await refreshStatus(runtimeGitea); + } + } catch (error) { + console.warn('Failed to load Gitea auth status:', error); + } + })(); + }, [hasChecked, refreshStatus, runtimeGitea]); + + const connect = React.useCallback(async () => { + const trimmedToken = accessToken.trim(); + const trimmedBaseUrl = baseUrl.trim(); + if (!trimmedToken) { + toast.error(t('settings.gitea.page.errors.invalidToken')); + return; + } + // Base URL is required for Gitea/Forgejo — there is no default instance. + if (!trimmedBaseUrl) { + toast.error(t('settings.gitea.page.errors.failed')); + return; + } + setIsBusy(true); + try { + const payload = runtimeGitea + ? await runtimeGitea.authConnect({ accessToken: trimmedToken, baseUrl: trimmedBaseUrl }) + : await (async () => { + const response = await runtimeFetch('/api/gitea/auth/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ accessToken: trimmedToken, baseUrl: trimmedBaseUrl }), + }); + const body = (await response.json().catch(() => null)) as GiteaAuthStatus | { error?: string } | null; + if (!response.ok || !body) { + throw new Error((body as { error?: string } | null)?.error || response.statusText); + } + return body as GiteaAuthStatus; + })(); + + setStatus(payload); + setAccessToken(''); + setBaseUrl(''); + toast.success(t('settings.gitea.page.toast.connected')); + } catch (error) { + console.error('Failed to connect Gitea:', error); + toast.error(t('settings.gitea.page.errors.failed')); + } finally { + setIsBusy(false); + } + }, [accessToken, baseUrl, runtimeGitea, setStatus, t]); + + const disconnect = React.useCallback(async () => { + setIsBusy(true); + try { + if (runtimeGitea) { + await runtimeGitea.authDisconnect(); + } else { + const response = await runtimeFetch('/api/gitea/auth', { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(response.statusText); + } + } + toast.success(t('settings.gitea.page.toast.disconnected')); + await refreshStatus(runtimeGitea, { force: true }); + } catch (error) { + console.error('Failed to disconnect Gitea:', error); + toast.error(t('settings.gitea.page.toast.disconnectFailed')); + } finally { + setIsBusy(false); + } + }, [refreshStatus, runtimeGitea, t]); + + const activateAccount = React.useCallback(async (accountId: string) => { + if (!accountId) return; + setIsBusy(true); + try { + const payload = runtimeGitea + ? await runtimeGitea.authActivate(accountId) + : await (async () => { + const response = await runtimeFetch('/api/gitea/auth/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ accountId }), + }); + const body = (await response.json().catch(() => null)) as GiteaAuthStatus | { error?: string } | null; + if (!response.ok || !body) { + throw new Error((body as { error?: string } | null)?.error || response.statusText); + } + return body as GiteaAuthStatus; + })(); + + setStatus(payload); + toast.success(t('settings.gitea.page.toast.accountSwitched')); + } catch (error) { + console.error('Failed to switch Gitea account:', error); + toast.error(t('settings.gitea.page.toast.accountSwitchFailed')); + } finally { + setIsBusy(false); + } + }, [runtimeGitea, setStatus, t]); + + if (isLoading) { + return null; + } + + const connected = Boolean(status?.connected); + const user = status?.user; + const accounts = status?.accounts ?? []; + const otherAccounts = accounts.filter((account) => !account.current); + const currentAccount = accounts.find((account) => account.current) ?? (accounts.length > 0 ? accounts[0] : null); + const currentBaseUrlHost = getBaseUrlHost(currentAccount?.baseUrl); + + const sectionContent = ( + <> +
+ {connected ? ( +
+
+ {user?.avatarUrl ? ( + {user.username + ) : ( +
+ +
+ )} + +
+
+ {user?.name?.trim() || user?.username || 'Gitea'} +
+
+ + {t('settings.gitea.page.connectedAs')} + {user?.username || t('settings.gitea.page.label.unknownUser')} + + {currentBaseUrlHost} +
+
+
+ + +
+ ) : ( +
+
+ + setAccessToken(event.target.value)} + placeholder={t('settings.gitea.page.accessToken.placeholder')} + className="h-9 max-w-[24rem]" + /> +
+
+ + setBaseUrl(event.target.value)} + placeholder={t('settings.gitea.page.baseUrl.placeholder')} + className="h-9 max-w-[24rem]" + /> +
+
+ {t('settings.gitea.page.status.notConnected')} + +
+
+ )} + + {otherAccounts.length > 0 && ( +
+
+ {t('settings.gitea.page.label.otherAccounts')} +
+
+ {otherAccounts.map((account) => { + const accountUser = account.user; + return ( +
+
+ {accountUser?.avatarUrl ? ( + {accountUser.username + ) : ( +
+ +
+ )} +
+ + {accountUser?.name?.trim() || accountUser?.username || 'Gitea'} + + {accountUser?.username && ( + + {accountUser.username} + · + {getBaseUrlHost(account.baseUrl)} + + )} +
+
+ +
+ ); + })} +
+
+ )} +
+ +
+ {connected ? ( + <> + + + + ) : ( +

+ {t('settings.gitProviders.overridesLocked.description', { provider: t('settings.git.tabs.gitea') })} +

+ )} +
+ + ); + + if (embedded) { + return ( +
+ {sectionContent} +
+ ); + } + + return ( + + {sectionContent} + + ); +}; diff --git a/packages/ui/src/components/sections/projects/ProjectGitProvidersSection.tsx b/packages/ui/src/components/sections/projects/ProjectGitProvidersSection.tsx new file mode 100644 index 00000000..a2c5b783 --- /dev/null +++ b/packages/ui/src/components/sections/projects/ProjectGitProvidersSection.tsx @@ -0,0 +1,271 @@ +import React from 'react'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useI18n } from '@/lib/i18n'; +import { reportSettingsSaveState } from '@/lib/persistence'; +import { ProjectSettingsSubsection } from '@/components/sections/projects/ProjectSettingsSubsection'; +import { SettingsStackedField } from '@/components/sections/shared/SettingsSection'; +import { getProjectGitProviders, saveProjectGitProviders } from '@/lib/projectGitProviders'; +import { + useGitProviderDomainsStore, + type GitProviderApiBaseUrls, + type GitProviderName, +} from '@/stores/useGitProviderDomainsStore'; +import { Icon } from "@/components/icon/Icon"; +import type { IconName } from "@/components/icon/icons"; +import { useGitProvider } from '@/lib/gitProvider'; +import type { ProjectRef } from '@/lib/openchamberConfig'; + +const GIT_PROVIDERS: GitProviderName[] = ['github', 'gitlab', 'gitea']; + +const EMPTY_API_BASE_URLS: GitProviderApiBaseUrls = { github: '', gitlab: '', gitea: '' }; + +const PROVIDER_ICONS: Record = { + github: 'github-fill', + gitlab: 'gitlab', + gitea: 'gitea', +}; + +/** + * Read the per-provider `apiBaseUrl` overrides out of an untyped server + * `gitProviders` payload. Unknown or malformed entries collapse to ''. + */ +const readProjectApiBaseUrls = (gitProviders: unknown): GitProviderApiBaseUrls => { + const result: GitProviderApiBaseUrls = { ...EMPTY_API_BASE_URLS }; + if (!gitProviders || typeof gitProviders !== 'object' || Array.isArray(gitProviders)) { + return result; + } + const config = gitProviders as Record; + for (const provider of GIT_PROVIDERS) { + const entry = config[provider]; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + continue; + } + const apiBaseUrl = (entry as Record).apiBaseUrl; + result[provider] = typeof apiBaseUrl === 'string' ? apiBaseUrl.trim() : ''; + } + return result; +}; + +/** + * Read the forced `provider` out of an untyped server `gitProviders` payload. + * Anything outside github|gitlab|gitea collapses to null (auto-detect). + */ +const readProjectProvider = (gitProviders: unknown): GitProviderName | null => { + if (!gitProviders || typeof gitProviders !== 'object' || Array.isArray(gitProviders)) { + return null; + } + const provider = (gitProviders as Record).provider; + return typeof provider === 'string' && GIT_PROVIDERS.includes(provider as GitProviderName) + ? (provider as GitProviderName) + : null; +}; + +/** + * Build the full per-project `gitProviders` object for the server. The server + * replaces the whole `gitProviders` key on PUT, so every provider is sent + * together; providers with an empty override are omitted, and the forced + * `provider` is included only when one is selected. + */ +const buildGitProvidersPayload = ( + drafts: GitProviderApiBaseUrls, + provider: GitProviderName | null, +): { provider?: GitProviderName } & Partial> => { + const payload: Partial> & { provider?: GitProviderName } = {}; + for (const entryProvider of GIT_PROVIDERS) { + const url = drafts[entryProvider].trim(); + if (url) { + payload[entryProvider] = { apiBaseUrl: url }; + } + } + if (provider) { + payload.provider = provider; + } + return payload; +}; + +type ProjectGitProvidersSectionProps = { + projectRef: ProjectRef; +}; + +/** + * Per-project git provider overrides on top of auto-detection: a forced + * provider (auto-detect or github/gitlab/gitea) and a single API base URL + * override for the active provider. Persisted through the project-scoped + * `/api/projects/:id/git-providers` route; empty overrides fall back to the + * global server settings value. The one API URL field follows the provider + * selector — the selected provider when forced, otherwise the currently + * detected one. Commits on blur/Enter (or provider selection) and re-hydrates + * the detection store so a new host/provider applies immediately. + */ +export const ProjectGitProvidersSection: React.FC = ({ projectRef }) => { + const { t } = useI18n(); + const globalApiBaseUrls = useGitProviderDomainsStore((state) => state.apiBaseUrls); + const [drafts, setDrafts] = React.useState({ ...EMPTY_API_BASE_URLS }); + const [provider, setProvider] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(true); + const hasEditedRef = React.useRef(false); + const committedSnapshotRef = React.useRef(''); + + React.useEffect(() => { + let cancelled = false; + hasEditedRef.current = false; + setIsLoading(true); + void (async () => { + const { gitProviders } = await getProjectGitProviders(projectRef.id); + if (cancelled) { + return; + } + const loaded = readProjectApiBaseUrls(gitProviders); + const loadedProvider = readProjectProvider(gitProviders); + // Never clobber an edit the user started before the read resolved. + if (!hasEditedRef.current) { + setDrafts(loaded); + setProvider(loadedProvider); + committedSnapshotRef.current = JSON.stringify(buildGitProvidersPayload(loaded, loadedProvider)); + } + setIsLoading(false); + })(); + return () => { + cancelled = true; + }; + }, [projectRef.id]); + + const commit = React.useCallback((nextProvider?: GitProviderName | null) => { + const providerValue = nextProvider === undefined ? provider : nextProvider; + const payload = buildGitProvidersPayload(drafts, providerValue); + const snapshot = JSON.stringify(payload); + if (snapshot === committedSnapshotRef.current) { + // Blur with no real change: drop incidental whitespace from the drafts. + setDrafts({ + github: drafts.github.trim(), + gitlab: drafts.gitlab.trim(), + gitea: drafts.gitea.trim(), + }); + return; + } + const previousSnapshot = committedSnapshotRef.current; + reportSettingsSaveState('saving'); + void saveProjectGitProviders(projectRef.id, payload).then((ok) => { + if (ok) { + committedSnapshotRef.current = snapshot; + useGitProviderDomainsStore.getState().hydrateProjectFromServer(projectRef.id, payload); + reportSettingsSaveState('saved'); + } else { + // Keep the pre-failure snapshot so an unchanged blur can retry. + committedSnapshotRef.current = previousSnapshot; + reportSettingsSaveState('error'); + } + }); + }, [drafts, provider, projectRef.id]); + + const handleProviderChange = React.useCallback((value: string) => { + hasEditedRef.current = true; + const next = value === 'auto' ? null : (value as GitProviderName); + setProvider(next); + commit(next); + }, [commit]); + + // The live auto-detection result for this project's remote (null/'other' + // when nothing recognizable was found). Only feeds the field attribution + // when no provider is forced. + const detectedProvider = useGitProvider(projectRef.path); + const knownDetected = detectedProvider && detectedProvider !== 'other' ? detectedProvider : null; + + // One API URL override, always for the active provider: the selected + // (forced) provider when set, otherwise whatever auto-detection currently + // yields for this project's remote. + const activeUrlProvider = provider ?? knownDetected; + + const renderBaseUrlField = (entryProvider: GitProviderName) => { + const isEmpty = drafts[entryProvider].trim().length === 0; + // Show what the project inherits when no override is set: the global + // setting when present, otherwise the provider's default placeholder. + const inheritedUrl = + globalApiBaseUrls[entryProvider] || t(`settings.${entryProvider}.page.apiBaseUrl.placeholder`); + return ( + + + {t(`settings.git.tabs.${entryProvider}`)} + + )} + settingsItem={`projects.git-providers.${entryProvider}`} + descriptionPlacement="after" + description={ + isEmpty && !isLoading + ? provider + ? t('settings.projects.page.gitProviders.inheritsGlobal', { url: inheritedUrl }) + : t('settings.projects.page.gitProviders.provider.detectedAs', { + provider: t(`settings.git.tabs.${entryProvider}`), + url: inheritedUrl, + }) + : undefined + } + > + { + hasEditedRef.current = true; + setDrafts((prev) => ({ ...prev, [entryProvider]: event.target.value })); + }} + onBlur={() => commit()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commit(); + } + }} + placeholder={t(`settings.${entryProvider}.page.apiBaseUrl.placeholder`)} + aria-label={t(`settings.${entryProvider}.page.apiBaseUrl.label`)} + className="h-9" + /> + + ); + }; + + return ( + +
+ + + + + {activeUrlProvider ? ( + renderBaseUrlField(activeUrlProvider) + ) : ( +

+ {t('settings.projects.page.gitProviders.provider.autoUnknown')} +

+ )} +
+
+ ); +}; diff --git a/packages/ui/src/components/sections/projects/ProjectSettingsPanel.tsx b/packages/ui/src/components/sections/projects/ProjectSettingsPanel.tsx index 4b9c4092..9336f603 100644 --- a/packages/ui/src/components/sections/projects/ProjectSettingsPanel.tsx +++ b/packages/ui/src/components/sections/projects/ProjectSettingsPanel.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent'; import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection'; +import { ProjectGitProvidersSection } from '@/components/sections/projects/ProjectGitProvidersSection'; import { ProjectIdentityFields } from '@/components/sections/projects/ProjectIdentityFields'; import { useProjectIdentityForm, @@ -47,6 +48,7 @@ export const ProjectSettingsPanel: React.FC = ({ return (
+ {showWorktrees ? : null}
diff --git a/packages/ui/src/components/sections/shared/ProviderApiBaseUrlInput.tsx b/packages/ui/src/components/sections/shared/ProviderApiBaseUrlInput.tsx new file mode 100644 index 00000000..7bde84e7 --- /dev/null +++ b/packages/ui/src/components/sections/shared/ProviderApiBaseUrlInput.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { Input } from '@/components/ui/input'; +import { useI18n } from '@/lib/i18n'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { reportSettingsSaveState } from '@/lib/persistence'; +import { SettingsStackedField } from '@/components/sections/shared/SettingsSection'; +import { + useGitProviderDomainsStore, + type GitProviderName, +} from '@/stores/useGitProviderDomainsStore'; + +/** + * Persist the full `gitProviders` settings object. The server replaces the + * whole `gitProviders` key on PUT, so every provider must be sent together — + * sending a single provider alone would wipe the others. + */ +// eslint-disable-next-line react-refresh/only-export-components +export const saveGitProvidersConfig = async (): Promise => { + const { domains, apiBaseUrls } = useGitProviderDomainsStore.getState(); + const payload = { + gitProviders: { + github: { apiBaseUrl: apiBaseUrls.github, detectUrls: domains.github }, + gitlab: { apiBaseUrl: apiBaseUrls.gitlab, detectUrls: domains.gitlab }, + gitea: { apiBaseUrl: apiBaseUrls.gitea, detectUrls: domains.gitea }, + }, + }; + reportSettingsSaveState('saving'); + try { + const response = await runtimeFetch('/api/config/settings', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(response.statusText); + } + reportSettingsSaveState('saved'); + } catch (error) { + console.warn('Failed to persist git provider settings:', error); + reportSettingsSaveState('error'); + } +}; + +/** + * Server-side default API base URL for a git provider's API calls. A per-account + * base URL still wins once an account is connected. Commits (updates the store + * cache optimistically and persists the full gitProviders object) on blur or + * Enter; the settings round-trip re-hydrates the store afterwards. + */ +export const ProviderApiBaseUrlInput: React.FC<{ provider: GitProviderName }> = ({ provider }) => { + const { t } = useI18n(); + const storedValue = useGitProviderDomainsStore((state) => state.apiBaseUrls[provider]); + const setApiBaseUrl = useGitProviderDomainsStore((state) => state.setApiBaseUrl); + const [draft, setDraft] = React.useState(storedValue); + + React.useEffect(() => { + setDraft(storedValue); + }, [storedValue]); + + const commit = React.useCallback(() => { + const next = draft.trim(); + if (next !== storedValue) { + setApiBaseUrl(provider, next); + void saveGitProvidersConfig(); + } else { + // Blur with no real change: drop incidental whitespace from the draft. + setDraft(storedValue); + } + }, [draft, provider, setApiBaseUrl, storedValue]); + + return ( + + setDraft(event.target.value)} + onBlur={commit} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commit(); + } + }} + placeholder={t(`settings.${provider}.page.apiBaseUrl.placeholder`)} + aria-label={t(`settings.${provider}.page.apiBaseUrl.label`)} + className="h-9" + /> + + ); +}; diff --git a/packages/ui/src/components/sections/shared/ProviderDetectUrlsInput.tsx b/packages/ui/src/components/sections/shared/ProviderDetectUrlsInput.tsx new file mode 100644 index 00000000..bb020293 --- /dev/null +++ b/packages/ui/src/components/sections/shared/ProviderDetectUrlsInput.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/icon/Icon'; +import { Input } from '@/components/ui/input'; +import { useI18n } from '@/lib/i18n'; +import { SettingsStackedField } from '@/components/sections/shared/SettingsSection'; +import { + normalizeProviderDomain, + useGitProviderDomainsStore, + type GitProviderName, +} from '@/stores/useGitProviderDomainsStore'; +import { saveGitProvidersConfig } from './ProviderApiBaseUrlInput'; + +/** + * Detection URLs for a git provider: an SSH or HTTPS URL typed into the field + * becomes a chip (displayed as its bare hostname) on Enter or comma, and the X + * on a chip removes it. The hosts feed provider autodetection of repo remotes. + * Each change updates the store cache optimistically and persists the full + * gitProviders object; the settings round-trip re-hydrates the store. + */ +export const ProviderDetectUrlsInput: React.FC<{ provider: GitProviderName }> = ({ provider }) => { + const { t } = useI18n(); + const domains = useGitProviderDomainsStore((state) => state.domains[provider]); + const setDomains = useGitProviderDomainsStore((state) => state.setDomains); + const [draft, setDraft] = React.useState(''); + const [invalid, setInvalid] = React.useState(false); + + const commitDraft = React.useCallback(() => { + const raw = draft.trim(); + if (!raw) { + setDraft(''); + setInvalid(false); + return; + } + const next = [...domains]; + let added = false; + for (const part of raw.split(',')) { + const host = normalizeProviderDomain(part); + if (host && !next.includes(host)) { + next.push(host); + added = true; + } + } + if (added) { + setDomains(provider, next); + setDraft(''); + setInvalid(false); + void saveGitProvidersConfig(); + } else { + // Unparseable input: keep the draft so the user can fix it. + setInvalid(true); + } + }, [draft, domains, provider, setDomains]); + + const removeChip = React.useCallback((host: string) => { + setDomains(provider, domains.filter((entry) => entry !== host)); + void saveGitProvidersConfig(); + }, [domains, provider, setDomains]); + + return ( + +
+ {domains.length > 0 ? ( +
+ {domains.map((host) => ( + + {host} + + + ))} +
+ ) : null} + { + setDraft(event.target.value); + setInvalid(false); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault(); + commitDraft(); + } + }} + onBlur={commitDraft} + placeholder={t(`settings.${provider}.page.detectUrls.placeholder`)} + aria-label={t('settings.gitProviders.detectUrls.add')} + aria-invalid={invalid || undefined} + className="h-9" + /> + {invalid ? ( +

+ {t('settings.gitProviders.detectUrls.invalid')} +

+ ) : null} +
+
+ ); +}; diff --git a/packages/ui/src/components/session/GitLabIntegrationDialog.tsx b/packages/ui/src/components/session/GitLabIntegrationDialog.tsx new file mode 100644 index 00000000..026229a8 --- /dev/null +++ b/packages/ui/src/components/session/GitLabIntegrationDialog.tsx @@ -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('issues'); + const [searchQuery, setSearchQuery] = React.useState(''); + const [issues, setIssues] = React.useState([]); + const [mrs, setMrs] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [loadingMore, setLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + const [selectedIssue, setSelectedIssue] = React.useState(null); + const [selectedMr, setSelectedMr] = React.useState(null); + const [includeDiff, setIncludeDiff] = React.useState(false); + const [validations, setValidations] = React.useState>(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 ? ( +
+ +
+

{t('session.gitlabIntegration.connect.title')}

+

+ {t('session.gitlabIntegration.connect.description')} +

+
+ +
+ ) : ( + <> + {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder={activeTab === 'issues' + ? t('session.gitlabIntegration.search.issuesPlaceholder') + : t('session.gitlabIntegration.search.mrsPlaceholder')} + className="h-8 pl-9" + /> +
+ + {/* List Content */} +
+
+ {/* Loading */} + {loading && ( +
+ +
+ )} + + {/* Error */} + {error && ( +
+
+ + {error} +
+
+ )} + + {/* Issues List */} + {!loading && !error && activeTab === 'issues' && ( +
+ {issues.length > 0 ? ( + issues.map(issue => ( + + )) + ) : ( +
+ {t('session.gitlabIntegration.empty.noIssuesFound')} +
+ )} + + {hasMore && !loadingMore && ( +
+ +
+ )} + {loadingMore && ( +
+ +
+ )} +
+ )} + + {/* MRs List */} + {!loading && !error && activeTab === 'mrs' && ( +
+ {mrs.length > 0 ? ( + mrs.map(mr => { + const blocked = isMrBlocked(mr); + const validation = mr.sourceBranch ? validations.get(mr.sourceBranch) : undefined; + + return ( + + ); + }) + ) : ( +
+ {t('session.gitlabIntegration.empty.noMergeRequestsFound')} +
+ )} + + {hasMore && !loadingMore && ( +
+ +
+ )} + {loadingMore && ( +
+ +
+ )} +
+ )} +
+
+ + )} + + ); + + // Footer content + const footerContent = ( +
+ {/* Left side: Selected Item / Checkbox */} +
+ {/* Selected Issue/MR display - hidden on mobile (shown in header instead) */} + {!isMobile && (selectedIssue || selectedMr) && ( +
+ + + {selectedIssue + ? t('session.gitlabIntegration.selected.issueNumber', { number: selectedIssue.number }) + : t('session.gitlabIntegration.selected.mrNumber', { number: selectedMr?.number ?? '' })} + + +
+ )} + + {/* Include Diff Checkbox - only show when MR tab is active and MR is selected */} + {activeTab === 'mrs' && selectedMr && ( + + )} +
+ + {/* Right side: Buttons */} +
+ + +
+
+ ); + + return ( + <> + {isMobile ? ( + onOpenChange(false)} + footer={!isGitLabConnected ? undefined : footerContent} + renderHeader={(closeButton) => ( +
+
+

{t('session.gitlabIntegration.title')}

+ {closeButton} +
+ {/* Tabs - using SortableTabsStrip */} +
+ }, + { id: 'mrs', label: t('session.gitlabIntegration.tabs.mergeRequests'), icon: }, + ]} + activeId={activeTab} + onSelect={(id) => { + setActiveTab(id as GitLabTab); + setSearchQuery(''); + }} + variant="active-pill" + layoutMode="fit" + /> +
+ + {/* Selected Item Inline Display */} + {(selectedIssue || selectedMr) && ( +
+ + + {selectedIssue + ? t('session.gitlabIntegration.selected.issueNumber', { number: selectedIssue.number }) + : t('session.gitlabIntegration.selected.mrNumber', { number: selectedMr?.number ?? '' })} + + +
+ )} +
+ )} + > + {dialogContent} +
+ ) : ( + + + +
+ + + {t('session.gitlabIntegration.title')} + + + {/* Tabs - using SortableTabsStrip */} +
+ }, + { id: 'mrs', label: t('session.gitlabIntegration.tabs.mergeRequests'), icon: }, + ]} + activeId={activeTab} + onSelect={(id) => { + setActiveTab(id as GitLabTab); + setSearchQuery(''); + }} + variant="active-pill" + layoutMode="fit" + /> +
+
+
+ + {dialogContent} + + {/* Footer */} + + {footerContent} + +
+
+ )} + + ); +} diff --git a/packages/ui/src/components/session/GitLabIssuePickerDialog.tsx b/packages/ui/src/components/session/GitLabIssuePickerDialog.tsx new file mode 100644 index 00000000..f9a84140 --- /dev/null +++ b/packages/ui/src/components/session/GitLabIssuePickerDialog.tsx @@ -0,0 +1,733 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { toast } from '@/components/ui'; +import { Icon } from "@/components/icon/Icon"; +import { cn } from '@/lib/utils'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import * as sessionActions from '@/sync/session-actions'; +import { buildLinkedIssue } from '@/lib/linkedIssues'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; +import { renderMagicPrompt } from '@/lib/magicPrompts'; +import { parseModelIdentifier } from '@/lib/modelIdentifier'; +import { useDeviceInfo } from '@/lib/device'; +import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator'; +import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import type { GitLabIssue, GitLabIssueComment, GitLabIssuesListResult, GitLabIssueSummary } from '@/lib/api/types'; +import { useI18n } from '@/lib/i18n'; + +const parseIssueNumber = (value: string): number | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + + const urlMatch = trimmed.match(/\/issues\/(\d+)(?:\b|\/|$)/i); + if (urlMatch) { + const parsed = Number(urlMatch[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + const hashMatch = trimmed.match(/^#?(\d+)$/); + if (hashMatch) { + const parsed = Number(hashMatch[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + return null; +}; + +const buildIssueContextText = (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)}`; +}; + +export function GitLabIssuePickerDialog({ + open, + onOpenChange, + mode = 'createSession', + onSelect, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + mode?: 'createSession' | 'select'; + onSelect?: (issue: { number: number; title: string; url: string; contextText: string; author?: { login: string; avatarUrl?: string } }) => void; +}) { + const { t } = useI18n(); + const { gitlab } = useRuntimeAPIs(); + 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 isMobile = useUIStore((state) => state.isMobile); + const { isTablet } = useDeviceInfo(); + const alwaysShowActions = isMobile || isTablet; + const activeProject = useProjectsStore((state) => state.getActiveProject()); + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + + const projectDirectory = React.useMemo(() => { + return activeProject?.path?.trim() || currentDirectory?.trim() || null; + }, [activeProject?.path, currentDirectory]); + + const [query, setQuery] = React.useState(''); + const [createInWorktree, setCreateInWorktree] = React.useState(false); + const [result, setResult] = React.useState(null); + const [issues, setIssues] = React.useState([]); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(false); + const [startingIssueNumber, setStartingIssueNumber] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + + const directNumber = React.useMemo(() => parseIssueNumber(query), [query]); + const debouncedQuery = useDebouncedValue(query, 350); + const isTextSearch = debouncedQuery.trim().length > 0 && !directNumber; + + const refresh = React.useCallback(async () => { + if (!projectDirectory) { + setResult(null); + setError(t('session.gitlabIssuePicker.error.noActiveProject')); + return; + } + if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) { + setResult({ connected: false, issues: [], page: 1, hasMore: false }); + setIssues([]); + setHasMore(false); + setPage(1); + setError(null); + return; + } + if (!gitlab?.issuesList) { + setResult(null); + setError(t('session.gitlabIssuePicker.error.runtimeUnavailable')); + return; + } + + setIsLoading(true); + setError(null); + try { + const next = await gitlab.issuesList(projectDirectory, { page: 1 }); + setResult(next); + setIssues(next.issues ?? []); + setPage(next.page ?? 1); + setHasMore(Boolean(next.hasMore)); + if (next.connected === false) { + setError(null); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setIsLoading(false); + } + }, [gitlab, gitlabAuthChecked, gitlabAuthStatus, projectDirectory, t]); + + React.useEffect(() => { + if (!open || !projectDirectory) return; + if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) return; + if (!gitlab?.issuesList) return; + if (!debouncedQuery.trim() || directNumber) { + void refresh(); + return; + } + + const controller = new AbortController(); + setIsLoading(true); + setError(null); + + gitlab.issuesList(projectDirectory, { page: 1, query: debouncedQuery.trim() }) + .then((next) => { + if (controller.signal.aborted) return; + setResult(next); + setIssues(next.issues ?? []); + setPage(next.page ?? 1); + setHasMore(Boolean(next.hasMore)); + }) + .catch((e) => { + if (controller.signal.aborted) return; + setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (!controller.signal.aborted) setIsLoading(false); + }); + + return () => controller.abort(); + }, [open, projectDirectory, gitlab, gitlabAuthChecked, gitlabAuthStatus, debouncedQuery, directNumber, refresh, t]); + + const loadMore = React.useCallback(async () => { + if (!projectDirectory) return; + if (!gitlab?.issuesList) return; + if (isLoadingMore || isLoading) return; + if (!hasMore) return; + + setIsLoadingMore(true); + try { + const nextPage = page + 1; + const next = isTextSearch + ? await gitlab.issuesList(projectDirectory, { page: nextPage, query: debouncedQuery.trim() }) + : await gitlab.issuesList(projectDirectory, { page: nextPage }); + setResult(next); + setIssues((prev) => [...prev, ...(next.issues ?? [])]); + setPage(next.page ?? nextPage); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.gitlabIssuePicker.toast.loadMoreFailed'), { description: message }); + } finally { + setIsLoadingMore(false); + } + }, [gitlab, hasMore, isLoading, isLoadingMore, isTextSearch, debouncedQuery, page, projectDirectory, t]); + + React.useEffect(() => { + if (!open) { + setQuery(''); + setCreateInWorktree(false); + setStartingIssueNumber(null); + setError(null); + setResult(null); + setIssues([]); + setPage(1); + setHasMore(false); + setIsLoading(false); + return; + } + void refresh(); + }, [open, refresh]); + + React.useEffect(() => { + if (!open) return; + if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) { + setResult({ connected: false, issues: [], page: 1, hasMore: false }); + setIssues([]); + setHasMore(false); + setPage(1); + setError(null); + } + }, [gitlabAuthChecked, gitlabAuthStatus, open]); + + const connected = gitlabAuthChecked ? result?.connected !== false : true; + const repoUrl = result?.repo?.url ?? null; + + const openGitLabSettings = React.useCallback(() => { + setSettingsPage('git'); + setSettingsDialogOpen(true); + }, [setSettingsDialogOpen, setSettingsPage]); + + const resolveDefaultAgentName = React.useCallback((): string | undefined => { + const configState = useConfigStore.getState(); + const visibleAgents = configState.getVisibleAgents(); + + if (configState.settingsDefaultAgent) { + const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent); + if (settingsAgent) { + return settingsAgent.name; + } + } + + return ( + visibleAgents.find((agent) => agent.name === 'build')?.name || + visibleAgents[0]?.name + ); + }, []); + + const resolveDefaultModelSelection = React.useCallback((): { providerID: string; modelID: string } | null => { + const configState = useConfigStore.getState(); + const settingsDefaultModel = configState.settingsDefaultModel; + if (!settingsDefaultModel) { + return null; + } + + const parsed = parseModelIdentifier(settingsDefaultModel); + if (!parsed) { + return null; + } + const { providerId: providerID, modelId: modelID } = parsed; + + const modelMetadata = configState.getModelMetadata(providerID, modelID); + if (!modelMetadata) { + return null; + } + + return { providerID, modelID }; + }, []); + + const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => { + const configState = useConfigStore.getState(); + const settingsDefaultVariant = configState.settingsDefaultVariant; + const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID + ? configState.currentVariant + : undefined; + + const provider = configState.providers.find((p) => p.id === providerID); + const model = provider?.models.find((m: Record) => (m as { id?: string }).id === modelID) as + | { variants?: Record } + | undefined; + const variants = model?.variants; + if (!variants) { + return settingsDefaultVariant || currentVariant || undefined; + } + if (settingsDefaultVariant && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) { + return settingsDefaultVariant; + } + if (currentVariant && Object.prototype.hasOwnProperty.call(variants, currentVariant)) { + return currentVariant; + } + return undefined; + }, []); + + const startSession = React.useCallback(async (issueNumber: number) => { + if (mode === 'select') { + // In select mode, fetch full issue details and return via onSelect + if (!projectDirectory) { + toast.error(t('session.gitlabIssuePicker.error.noActiveProject')); + return; + } + if (!gitlab?.issueGet || !gitlab?.issueComments) { + toast.error(t('session.gitlabIssuePicker.error.runtimeUnavailable')); + return; + } + if (startingIssueNumber) return; + setStartingIssueNumber(issueNumber); + try { + const issueRes = await gitlab.issueGet(projectDirectory, issueNumber); + if (issueRes.connected === false) { + toast.error(t('session.gitlabIssuePicker.error.notConnected')); + return; + } + if (!issueRes.repo) { + toast.error(t('session.gitlabIssuePicker.error.repoNotResolvable'), { + description: t('session.gitlabIssuePicker.error.repoMustBeGitlab'), + }); + return; + } + const issue = issueRes.issue; + if (!issue) { + toast.error(t('session.gitlabIssuePicker.error.issueNotFound')); + return; + } + + const commentsRes = await gitlab.issueComments(projectDirectory, issueNumber); + if (commentsRes.connected === false) { + toast.error(t('session.gitlabIssuePicker.error.notConnected')); + return; + } + const comments = commentsRes.comments ?? []; + + // Build full context text like in createSession mode + const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments }); + + if (onSelect) { + onSelect({ + number: issue.number, + title: issue.title, + url: issue.url, + contextText, + author: issue.author ? { + login: issue.author.username, + avatarUrl: issue.author.avatarUrl, + } : undefined, + }); + } + onOpenChange(false); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.gitlabIssuePicker.toast.loadIssueDetailsFailed'), { description: message }); + } finally { + setStartingIssueNumber(null); + } + return; + } + + if (!projectDirectory) { + toast.error(t('session.gitlabIssuePicker.error.noActiveProject')); + return; + } + if (!gitlab?.issueGet || !gitlab?.issueComments) { + toast.error(t('session.gitlabIssuePicker.error.runtimeUnavailable')); + return; + } + if (startingIssueNumber) return; + setStartingIssueNumber(issueNumber); + try { + const issueRes = await gitlab.issueGet(projectDirectory, issueNumber); + if (issueRes.connected === false) { + toast.error(t('session.gitlabIssuePicker.error.notConnected')); + return; + } + if (!issueRes.repo) { + toast.error(t('session.gitlabIssuePicker.error.repoNotResolvable'), { + description: t('session.gitlabIssuePicker.error.repoMustBeGitlab'), + }); + return; + } + const issue = issueRes.issue; + if (!issue) { + toast.error(t('session.gitlabIssuePicker.error.issueNotFound')); + return; + } + + const commentsRes = await gitlab.issueComments(projectDirectory, issueNumber); + if (commentsRes.connected === false) { + toast.error(t('session.gitlabIssuePicker.error.notConnected')); + return; + } + const comments = commentsRes.comments ?? []; + + const sessionTitle = `#${issue.number} ${issue.title}`.trim(); + + const { sessionId, sessionDirectory } = await (async () => { + if (createInWorktree) { + const preferred = `issue-${issue.number}-${generateBranchSlug()}`; + const created = await createWorktreeSessionForNewBranch( + projectDirectory, + preferred, + undefined, + { returnAfterDirectoryCreated: true } + ); + if (!created?.id) { + throw new Error('Failed to create worktree session'); + } + return { sessionId: created.id, sessionDirectory: created.path }; + } + + const session = await sessionActions.createSession(sessionTitle, projectDirectory, null); + if (!session?.id) { + throw new Error('Failed to create session'); + } + return { sessionId: session.id, sessionDirectory: session.directory ?? projectDirectory }; + })(); + + // Ensure worktree-based sessions also get the issue title. + void sessionActions.updateSessionTitle(sessionId, sessionTitle).catch(() => undefined); + + try { + useSessionUIStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents); + } catch { + // ignore + } + + // Close modal immediately after session exists (don't wait for message send). + onOpenChange(false); + + const configState = useConfigStore.getState(); + const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; + + const defaultModel = resolveDefaultModelSelection(); + const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID; + const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID; + const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined; + if (!providerID || !modelID) { + toast.error(t('session.gitlabIssuePicker.error.noModelSelected')); + return; + } + + const variant = resolveDefaultVariant(providerID, modelID); + + const visiblePromptText = await renderMagicPrompt('gitlab.issue.review.visible', { + issue_number: String(issue.number), + }); + const instructionsText = await renderMagicPrompt('gitlab.issue.review.instructions'); + const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments }); + + // Record the thread this session was created for, so it stays visible as + // a context source once the opening message has scrolled away. A + // snapshot, never re-fetched; a failed write must not fail the flow. + void sessionActions.setLinkedIssue( + sessionId, + sessionDirectory, + buildLinkedIssue({ + url: issue.url, + number: issue.number, + title: issue.title, + kind: 'issue', + author: issue.author ? { + login: issue.author.username, + avatarUrl: issue.author.avatarUrl, + } : undefined, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + + void useSessionUIStore.getState().sendMessage( + visiblePromptText, + providerID, + modelID, + agentName, + undefined, + undefined, + [ + { text: instructionsText, synthetic: true }, + { text: contextText, synthetic: true }, + ], + variant, + undefined, + { sessionId }, + ).catch((e) => { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.gitlabIssuePicker.toast.sendContextFailed'), { + description: message, + }); + }); + + toast.success(t('session.gitlabIssuePicker.toast.sessionCreated')); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.gitlabIssuePicker.toast.startSessionFailed'), { description: message }); + } finally { + setStartingIssueNumber(null); + } + }, [createInWorktree, gitlab, mode, onOpenChange, onSelect, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber, t]); + + const title = mode === 'select' ? t('session.gitlabIssuePicker.title.select') : t('session.gitlabIssuePicker.title.createSession'); + const description = mode === 'select' + ? t('session.gitlabIssuePicker.description.select') + : t('session.gitlabIssuePicker.description.createSession'); + + const content = ( + <> +
+ + setQuery(e.target.value)} + className="pl-9 w-full" + /> +
+ +
+ {!projectDirectory ? ( +
{t('session.gitlabIssuePicker.empty.noActiveProject')}
+ ) : null} + + {!gitlab ? ( +
{t('session.gitlabIssuePicker.empty.runtimeUnavailable')}
+ ) : null} + + {isLoading ? ( +
+ + {t('session.gitlabIssuePicker.loading.issues')} +
+ ) : null} + + {connected === false ? ( +
+
{t('session.gitlabIssuePicker.empty.notConnected')}
+
+ +
+
+ ) : null} + + {error ? ( +
{error}
+ ) : null} + + {directNumber && projectDirectory && gitlab && connected ? ( +
void startSession(directNumber)} + > + # +

+ {t('session.gitlabIssuePicker.actions.useIssue', { number: directNumber })} +

+
+ {startingIssueNumber === directNumber ? ( + + ) : null} +
+
+ ) : null} + + {issues.length === 0 && !isLoading && connected && gitlab && projectDirectory ? ( +
{debouncedQuery.trim() ? t('session.gitlabIssuePicker.empty.noIssuesFound') : t('session.gitlabIssuePicker.empty.noOpenIssuesFound')}
+ ) : null} + + {issues.map((issue) => ( +
void startSession(issue.number)} + > + + #{issue.number} + +
+

+ {issue.title} +

+
+ + +
+ ))} + + {hasMore && connected && projectDirectory && gitlab ? ( +
+ +
+ ) : null} +
+ + {mode !== 'select' && ( +
+

{t('session.gitlabIssuePicker.actions.sectionTitle')}

+
+
setCreateInWorktree((v) => !v)} + onKeyDown={(e) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + setCreateInWorktree((v) => !v); + } + }} + > + + {t('session.gitlabIssuePicker.actions.createInWorktree')} + (issue-<number>-<slug>) +
+
+
+ {repoUrl ? ( + + ) : null} + +
+
+
+ )} + + ); + + if (isMobile) { + return ( + onOpenChange(false)} + renderHeader={(closeButton) => ( +
+
+

{title}

+ {closeButton} +
+

{description}

+
+ )} + > + {content} +
+ ); + } + + return ( + + + + + + {title} + + + {description} + + + + {content} + + + ); +} diff --git a/packages/ui/src/components/session/GitLabMrPickerDialog.tsx b/packages/ui/src/components/session/GitLabMrPickerDialog.tsx new file mode 100644 index 00000000..acb1ec21 --- /dev/null +++ b/packages/ui/src/components/session/GitLabMrPickerDialog.tsx @@ -0,0 +1,477 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { toast } from '@/components/ui'; +import { Icon } from "@/components/icon/Icon"; +import { cn } from '@/lib/utils'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; +import { renderMagicPrompt } from '@/lib/magicPrompts'; +import { useDeviceInfo } from '@/lib/device'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import type { GitLabMergeRequestContextResult, GitLabMergeRequestSummary, GitLabMergeRequestsListResult } from '@/lib/api/types'; +import { useI18n } from '@/lib/i18n'; + +const parsePrNumber = (value: string): number | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + + const urlMatch = trimmed.match(/\/merge_requests\/(\d+)(?:\b|\/|$)/i); + if (urlMatch) { + const parsed = Number(urlMatch[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + const shortMatch = trimmed.match(/^!?(\d+)$/); + if (shortMatch) { + const parsed = Number(shortMatch[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + return null; +}; + +const buildMergeRequestContextText = (payload: GitLabMergeRequestContextResult) => { + return `GitLab merge request context (JSON)\n${JSON.stringify(payload, null, 2)}`; +}; + +export function GitLabMrPickerDialog({ + open, + onOpenChange, + onSelect, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect?: (mr: { + number: number; + title: string; + url: string; + head: string; + base: string; + includeDiff: boolean; + instructionsText: string; + contextText: string; + author?: { login: string; avatarUrl?: string }; + }) => void; +}) { + const { t } = useI18n(); + const { gitlab } = useRuntimeAPIs(); + 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 isMobile = useUIStore((state) => state.isMobile); + const { isTablet } = useDeviceInfo(); + const alwaysShowActions = isMobile || isTablet; + const activeProject = useProjectsStore((state) => state.getActiveProject()); + + const projectDirectory = activeProject?.path ?? null; + + const [query, setQuery] = React.useState(''); + const [includeDiff, setIncludeDiff] = React.useState(false); + const [result, setResult] = React.useState(null); + const [mrs, setMrs] = React.useState([]); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(false); + const [loadingMrNumber, setLoadingMrNumber] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + + const directNumber = React.useMemo(() => parsePrNumber(query), [query]); + const debouncedQuery = useDebouncedValue(query, 350); + const isTextSearch = debouncedQuery.trim().length > 0 && !directNumber; + + const refresh = React.useCallback(async () => { + if (!projectDirectory) { + setResult(null); + setError(t('session.gitlabMrPicker.error.noActiveProject')); + return; + } + if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) { + setResult({ connected: false, mrs: [], page: 1, hasMore: false }); + setMrs([]); + setHasMore(false); + setPage(1); + setError(null); + return; + } + if (!gitlab?.mrsList) { + setResult(null); + setError(t('session.gitlabMrPicker.error.runtimeUnavailable')); + return; + } + + setIsLoading(true); + setError(null); + try { + const next = await gitlab.mrsList(projectDirectory, { page: 1 }); + setResult(next); + setMrs(next.mrs ?? []); + setPage(next.page ?? 1); + setHasMore(Boolean(next.hasMore)); + if (next.connected === false) { + setError(null); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setIsLoading(false); + } + }, [gitlab, gitlabAuthChecked, gitlabAuthStatus, projectDirectory, t]); + + React.useEffect(() => { + if (!open || !projectDirectory) return; + if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) return; + if (!gitlab?.mrsList) return; + if (!debouncedQuery.trim() || directNumber) { + void refresh(); + return; + } + + const controller = new AbortController(); + setIsLoading(true); + setError(null); + + gitlab.mrsList(projectDirectory, { page: 1, query: debouncedQuery.trim() }) + .then((next) => { + if (controller.signal.aborted) return; + setResult(next); + setMrs(next.mrs ?? []); + setPage(next.page ?? 1); + setHasMore(Boolean(next.hasMore)); + }) + .catch((e) => { + if (controller.signal.aborted) return; + setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (!controller.signal.aborted) setIsLoading(false); + }); + + return () => controller.abort(); + }, [open, projectDirectory, gitlab, gitlabAuthChecked, gitlabAuthStatus, debouncedQuery, directNumber, refresh, t]); + + const loadMore = React.useCallback(async () => { + if (!projectDirectory) return; + if (!gitlab?.mrsList) return; + if (isLoadingMore || isLoading) return; + if (!hasMore) return; + + setIsLoadingMore(true); + try { + const nextPage = page + 1; + const next = isTextSearch + ? await gitlab.mrsList(projectDirectory, { page: nextPage, query: debouncedQuery.trim() }) + : await gitlab.mrsList(projectDirectory, { page: nextPage }); + setResult(next); + setMrs((prev) => [...prev, ...(next.mrs ?? [])]); + setPage(next.page ?? nextPage); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.gitlabMrPicker.toast.loadMoreFailed'), { description: message }); + } finally { + setIsLoadingMore(false); + } + }, [gitlab, hasMore, isLoading, isLoadingMore, isTextSearch, debouncedQuery, page, projectDirectory, t]); + + React.useEffect(() => { + if (!open) { + setQuery(''); + setIncludeDiff(false); + setLoadingMrNumber(null); + setError(null); + setResult(null); + setMrs([]); + setPage(1); + setHasMore(false); + setIsLoading(false); + return; + } + void refresh(); + }, [open, refresh]); + + React.useEffect(() => { + if (!open) return; + if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) { + setResult({ connected: false, mrs: [], page: 1, hasMore: false }); + setMrs([]); + setHasMore(false); + setPage(1); + setError(null); + } + }, [gitlabAuthChecked, gitlabAuthStatus, open]); + + const connected = gitlabAuthChecked ? result?.connected !== false : true; + + const openGitLabSettings = React.useCallback(() => { + setSettingsPage('git'); + setSettingsDialogOpen(true); + }, [setSettingsDialogOpen, setSettingsPage]); + + const attachMr = React.useCallback(async (mrNumber: number) => { + if (!projectDirectory) { + toast.error(t('session.gitlabMrPicker.error.noActiveProject')); + return; + } + if (!gitlab?.mrContext) { + toast.error(t('session.gitlabMrPicker.error.runtimeUnavailable')); + return; + } + if (loadingMrNumber) return; + + setLoadingMrNumber(mrNumber); + try { + const context = await gitlab.mrContext(projectDirectory, mrNumber, { + includeDiff, + }); + + if (context.connected === false) { + toast.error(t('session.gitlabMrPicker.error.notConnected')); + return; + } + + if (!context.mr) { + toast.error(t('session.gitlabMrPicker.error.mrNotFound')); + return; + } + + if (!context.repo) { + toast.error(t('session.gitlabMrPicker.error.repoNotResolvable'), { + description: t('session.gitlabMrPicker.error.repoMustBeGitlab'), + }); + return; + } + + if (onSelect) { + const instructionsText = await renderMagicPrompt('gitlab.pr.review.instructions'); + onSelect({ + number: context.mr.number, + title: context.mr.title, + url: context.mr.url, + head: context.mr.sourceBranch, + base: context.mr.targetBranch, + includeDiff, + instructionsText, + contextText: buildMergeRequestContextText(context), + author: context.mr.author + ? { + login: context.mr.author.username, + avatarUrl: context.mr.author.avatarUrl, + } + : undefined, + }); + } + onOpenChange(false); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.gitlabMrPicker.toast.loadDetailsFailed'), { description: message }); + } finally { + setLoadingMrNumber(null); + } + }, [gitlab, includeDiff, loadingMrNumber, onOpenChange, onSelect, projectDirectory, t]); + + const title = t('session.gitlabMrPicker.title'); + const description = t('session.gitlabMrPicker.description'); + + const content = ( + <> +
+
+ + setQuery(e.target.value)} + className="pl-9 w-full" + /> +
+ +
+ +
+ {!projectDirectory ? ( +
{t('session.gitlabMrPicker.empty.noActiveProject')}
+ ) : null} + + {!gitlab ? ( +
{t('session.gitlabMrPicker.empty.runtimeUnavailable')}
+ ) : null} + + {isLoading ? ( +
+ + {t('session.gitlabMrPicker.loading.mergeRequests')} +
+ ) : null} + + {connected === false ? ( +
+
{t('session.gitlabMrPicker.empty.notConnected')}
+
+ +
+
+ ) : null} + + {error ? ( +
{error}
+ ) : null} + + {directNumber && projectDirectory && gitlab && connected ? ( +
void attachMr(directNumber)} + > + ! +

+ {t('session.gitlabMrPicker.actions.useMergeRequest', { number: directNumber })} +

+
+ {loadingMrNumber === directNumber ? ( + + ) : null} +
+
+ ) : null} + + {mrs.length === 0 && !isLoading && connected && gitlab && projectDirectory ? ( +
{debouncedQuery.trim() ? t('session.gitlabMrPicker.empty.noMergeRequestsFound') : t('session.gitlabMrPicker.empty.noOpenMergeRequestsFound')}
+ ) : null} + + {mrs.map((mr) => ( +
void attachMr(mr.number)} + > +
+

+ !{mr.number} + {mr.title} +

+

{mr.sourceBranch} → {mr.targetBranch}

+
+ + +
+ ))} + + {hasMore && connected && projectDirectory && gitlab ? ( +
+ +
+ ) : null} +
+ + ); + + if (isMobile) { + return ( + onOpenChange(false)} + renderHeader={(closeButton) => ( +
+
+

{title}

+ {closeButton} +
+

{description}

+
+ )} + > + {content} +
+ ); + } + + return ( + + + + + + {title} + + + {description} + + + + {content} + + + ); +} diff --git a/packages/ui/src/components/session/GiteaIntegrationDialog.tsx b/packages/ui/src/components/session/GiteaIntegrationDialog.tsx new file mode 100644 index 00000000..252941d5 --- /dev/null +++ b/packages/ui/src/components/session/GiteaIntegrationDialog.tsx @@ -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 { useGiteaAuthStore } from '@/stores/useGiteaAuthStore'; +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 { + GiteaIssueSummary, + GiteaPullRequestSummary, +} from '@/lib/api/types'; +import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; +import { useI18n } from '@/lib/i18n'; + +type GiteaTab = 'issues' | 'prs'; + +interface GiteaIntegrationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (result: { + type: 'issue'; + number: number; + title: string; + url: string; + } | { + type: 'pr'; + number: number; + title: string; + url: string; + sourceBranch: string; + includeDiff: boolean; + } | null) => void; +} + +interface ValidationResult { + isValid: boolean; + error: string | null; +} + +export function GiteaIntegrationDialog({ + open, + onOpenChange, + onSelect, +}: GiteaIntegrationDialogProps) { + const { t } = useI18n(); + const isMobile = useUIStore((state) => state.isMobile); + const gitea = getRegisteredRuntimeAPIs()?.gitea; + const giteaAuthStatus = useGiteaAuthStore((state) => state.status); + const giteaAuthChecked = useGiteaAuthStore((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('issues'); + const [searchQuery, setSearchQuery] = React.useState(''); + const [issues, setIssues] = React.useState([]); + const [prs, setPrs] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [loadingMore, setLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + const [selectedIssue, setSelectedIssue] = React.useState(null); + const [selectedPr, setSelectedPr] = React.useState(null); + const [includeDiff, setIncludeDiff] = React.useState(false); + const [validations, setValidations] = React.useState>(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 || !gitea) return; + if (giteaAuthChecked && giteaAuthStatus?.connected === false) return; + + setLoading(true); + setError(null); + setPage(1); + setHasMore(false); + + try { + if (activeTab === 'issues' && gitea.issuesList) { + const result = await gitea.issuesList(projectDirectory, { page: 1, query }); + if (result.connected === false) { + setError(t('session.giteaIntegration.error.notConnected')); + setIssues([]); + } else { + setIssues(result.issues ?? []); + setPage(result.page ?? 1); + setHasMore(Boolean(result.hasMore)); + } + } else if (activeTab === 'prs' && gitea.prsList) { + const result = await gitea.prsList(projectDirectory, { page: 1, query }); + if (result.connected === false) { + setError(t('session.giteaIntegration.error.notConnected')); + setPrs([]); + } else { + setPrs(result.prs ?? []); + setPage(result.page ?? 1); + setHasMore(Boolean(result.hasMore)); + } + } + } catch (err) { + setError(err instanceof Error ? err.message : t('session.giteaIntegration.error.loadDataFailed')); + } finally { + setLoading(false); + } + }, [projectDirectory, gitea, giteaAuthChecked, giteaAuthStatus, activeTab, t]); + + React.useEffect(() => { + if (!open || !projectDirectory) return; + if (giteaAuthChecked && giteaAuthStatus?.connected === false) return; + if (!gitea) return; + if (!debouncedSearchQuery.trim()) { + void loadData(); + return; + } + + const controller = new AbortController(); + setLoading(true); + setError(null); + setPage(1); + setHasMore(false); + + const apiCall = activeTab === 'issues' && gitea.issuesList + ? gitea.issuesList(projectDirectory, { page: 1, query: debouncedSearchQuery.trim() }) + : activeTab === 'prs' && gitea.prsList + ? gitea.prsList(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.giteaIntegration.error.notConnected')); + setIssues([]); + } else { + setIssues(result.issues ?? []); + setPage(result.page ?? 1); + setHasMore(Boolean(result.hasMore)); + } + } else if ('prs' in result) { + if (result.connected === false) { + setError(t('session.giteaIntegration.error.notConnected')); + setPrs([]); + } else { + setPrs(result.prs ?? []); + setPage(result.page ?? 1); + setHasMore(Boolean(result.hasMore)); + } + } + }) + .catch((err) => { + if (controller.signal.aborted) return; + setError(err instanceof Error ? err.message : t('session.giteaIntegration.error.loadDataFailed')); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + + return () => controller.abort(); + }, [open, projectDirectory, gitea, giteaAuthChecked, giteaAuthStatus, activeTab, debouncedSearchQuery, loadData, t]); + + const loadMore = React.useCallback(async () => { + if (!projectDirectory || !gitea) return; + if (loading || loadingMore) return; + if (!hasMore) return; + + setLoadingMore(true); + + try { + const nextPage = page + 1; + + if (activeTab === 'issues' && gitea.issuesList) { + const result = debouncedSearchQuery.trim() + ? await gitea.issuesList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() }) + : await gitea.issuesList(projectDirectory, { page: nextPage }); + if (result.connected !== false) { + setIssues(prev => [...prev, ...(result.issues ?? [])]); + setPage(result.page ?? nextPage); + setHasMore(Boolean(result.hasMore)); + } + } else if (activeTab === 'prs' && gitea.prsList) { + const result = debouncedSearchQuery.trim() + ? await gitea.prsList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() }) + : await gitea.prsList(projectDirectory, { page: nextPage }); + if (result.connected !== false) { + setPrs(prev => [...prev, ...(result.prs ?? [])]); + setPage(result.page ?? nextPage); + setHasMore(Boolean(result.hasMore)); + } + } + } catch { + // Silently fail on load more errors + } finally { + setLoadingMore(false); + } + }, [projectDirectory, gitea, activeTab, page, hasMore, loading, loadingMore, debouncedSearchQuery]); + + // Reset state when dialog opens/closes + React.useEffect(() => { + if (!open) { + setActiveTab('issues'); + setSearchQuery(''); + setIssues([]); + setPrs([]); + setSelectedIssue(null); + setSelectedPr(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.giteaIntegration.validation.branchAlreadyExists' + : 'session.giteaIntegration.validation.branchAlreadyCheckedOut') + : null, + })); + } catch { + setValidations(prev => new Map(prev).set(branchName, { + isValid: false, + error: t('session.giteaIntegration.validation.failed'), + })); + } + }, [projectRef, validations, t]); + + // Validate PR branches when loaded + React.useEffect(() => { + if (!open || activeTab !== 'prs') return; + + prs.forEach(pr => { + if (pr.sourceBranch) { + void validateBranch(pr.sourceBranch); + } + }); + }, [open, activeTab, prs, validateBranch]); + + // Gitea connection check + const isGiteaConnected = giteaAuthChecked && giteaAuthStatus?.connected === true; + + const openGiteaSettings = () => { + setSettingsPage('git'); + setSettingsDialogOpen(true); + }; + + // Handle selection + const handleSelectIssue = (issue: GiteaIssueSummary) => { + setSelectedIssue(issue); + setSelectedPr(null); + }; + + const handleSelectPr = (pr: GiteaPullRequestSummary) => { + setSelectedPr(pr); + setSelectedIssue(null); + }; + + const handleConfirm = () => { + if (selectedIssue) { + onSelect({ + type: 'issue', + number: selectedIssue.number, + title: selectedIssue.title, + url: selectedIssue.url, + }); + } else if (selectedPr) { + onSelect({ + type: 'pr', + number: selectedPr.number, + title: selectedPr.title, + url: selectedPr.url, + sourceBranch: selectedPr.sourceBranch, + includeDiff, + }); + } + onOpenChange(false); + }; + + const handleClear = () => { + setSelectedIssue(null); + setSelectedPr(null); + setIncludeDiff(false); + }; + + // Check if selection is valid + const canConfirm = selectedIssue || (selectedPr && validations.get(selectedPr.sourceBranch)?.isValid !== false); + + // Check if PR is blocked + const isPrBlocked = (pr: GiteaPullRequestSummary): boolean => { + if (!pr.sourceBranch) return true; + const validation = validations.get(pr.sourceBranch); + return validation?.isValid === false; + }; + + // Content for the dialog (shared between mobile and desktop) + const dialogContent = ( + <> + {!isGiteaConnected ? ( +
+ +
+

{t('session.giteaIntegration.connect.title')}

+

+ {t('session.giteaIntegration.connect.description')} +

+
+ +
+ ) : ( + <> + {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder={activeTab === 'issues' + ? t('session.giteaIntegration.search.issuesPlaceholder') + : t('session.giteaIntegration.search.prsPlaceholder')} + className="h-8 pl-9" + /> +
+ + {/* List Content */} +
+
+ {/* Loading */} + {loading && ( +
+ +
+ )} + + {/* Error */} + {error && ( +
+
+ + {error} +
+
+ )} + + {/* Issues List */} + {!loading && !error && activeTab === 'issues' && ( +
+ {issues.length > 0 ? ( + issues.map(issue => ( + + )) + ) : ( +
+ {t('session.giteaIntegration.empty.noIssuesFound')} +
+ )} + + {hasMore && !loadingMore && ( +
+ +
+ )} + {loadingMore && ( +
+ +
+ )} +
+ )} + + {/* PRs List */} + {!loading && !error && activeTab === 'prs' && ( +
+ {prs.length > 0 ? ( + prs.map(pr => { + const blocked = isPrBlocked(pr); + const validation = pr.sourceBranch ? validations.get(pr.sourceBranch) : undefined; + + return ( + + ); + }) + ) : ( +
+ {t('session.giteaIntegration.empty.noPullRequestsFound')} +
+ )} + + {hasMore && !loadingMore && ( +
+ +
+ )} + {loadingMore && ( +
+ +
+ )} +
+ )} +
+
+ + )} + + ); + + // Footer content + const footerContent = ( +
+ {/* Left side: Selected Item / Checkbox */} +
+ {/* Selected Issue/PR display - hidden on mobile (shown in header instead) */} + {!isMobile && (selectedIssue || selectedPr) && ( +
+ + + {selectedIssue + ? t('session.giteaIntegration.selected.issueNumber', { number: selectedIssue.number }) + : t('session.giteaIntegration.selected.prNumber', { number: selectedPr?.number ?? '' })} + + +
+ )} + + {/* Include Diff Checkbox - only show when PR tab is active and PR is selected */} + {activeTab === 'prs' && selectedPr && ( + + )} +
+ + {/* Right side: Buttons */} +
+ + +
+
+ ); + + return ( + <> + {isMobile ? ( + onOpenChange(false)} + footer={!isGiteaConnected ? undefined : footerContent} + renderHeader={(closeButton) => ( +
+
+

{t('session.giteaIntegration.title')}

+ {closeButton} +
+ {/* Tabs - using SortableTabsStrip */} +
+ }, + { id: 'prs', label: t('session.giteaIntegration.tabs.pullRequests'), icon: }, + ]} + activeId={activeTab} + onSelect={(id) => { + setActiveTab(id as GiteaTab); + setSearchQuery(''); + }} + variant="active-pill" + layoutMode="fit" + /> +
+ + {/* Selected Item Inline Display */} + {(selectedIssue || selectedPr) && ( +
+ + + {selectedIssue + ? t('session.giteaIntegration.selected.issueNumber', { number: selectedIssue.number }) + : t('session.giteaIntegration.selected.prNumber', { number: selectedPr?.number ?? '' })} + + +
+ )} +
+ )} + > + {dialogContent} +
+ ) : ( + + + +
+ + + {t('session.giteaIntegration.title')} + + + {/* Tabs - using SortableTabsStrip */} +
+ }, + { id: 'prs', label: t('session.giteaIntegration.tabs.pullRequests'), icon: }, + ]} + activeId={activeTab} + onSelect={(id) => { + setActiveTab(id as GiteaTab); + setSearchQuery(''); + }} + variant="active-pill" + layoutMode="fit" + /> +
+
+
+ + {dialogContent} + + {/* Footer */} + + {footerContent} + +
+
+ )} + + ); +} diff --git a/packages/ui/src/components/session/GiteaIssuePickerDialog.tsx b/packages/ui/src/components/session/GiteaIssuePickerDialog.tsx new file mode 100644 index 00000000..05587e6f --- /dev/null +++ b/packages/ui/src/components/session/GiteaIssuePickerDialog.tsx @@ -0,0 +1,731 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { toast } from '@/components/ui'; +import { Icon } from "@/components/icon/Icon"; +import { cn } from '@/lib/utils'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import * as sessionActions from '@/sync/session-actions'; +import { buildLinkedIssue } from '@/lib/linkedIssues'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore'; +import { renderMagicPrompt } from '@/lib/magicPrompts'; +import { parseModelIdentifier } from '@/lib/modelIdentifier'; +import { useDeviceInfo } from '@/lib/device'; +import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator'; +import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import type { GiteaComment, GiteaIssue, GiteaIssuesListResult, GiteaIssueSummary } from '@/lib/api/types'; +import { useI18n } from '@/lib/i18n'; + +const parseIssueNumber = (value: string): number | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + + const urlMatch = trimmed.match(/\/issues\/(\d+)(?:\b|\/|$)/i); + if (urlMatch) { + const parsed = Number(urlMatch[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + const hashMatch = trimmed.match(/^#?(\d+)$/); + if (hashMatch) { + const parsed = Number(hashMatch[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + return null; +}; + +const buildIssueContextText = (args: { + repo: GiteaIssuesListResult['repo'] | undefined; + issue: GiteaIssue; + comments: GiteaComment[]; +}) => { + const payload = { + repo: args.repo ?? null, + issue: args.issue, + comments: args.comments, + }; + return `Gitea issue context (JSON)\n${JSON.stringify(payload, null, 2)}`; +}; + +export function GiteaIssuePickerDialog({ + open, + onOpenChange, + mode = 'createSession', + onSelect, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + mode?: 'createSession' | 'select'; + onSelect?: (issue: { number: number; title: string; url: string; contextText: string; author?: { login: string; avatarUrl?: string } }) => void; +}) { + const { t } = useI18n(); + const { gitea } = useRuntimeAPIs(); + const giteaAuthStatus = useGiteaAuthStore((state) => state.status); + const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked); + const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const isMobile = useUIStore((state) => state.isMobile); + const { isTablet } = useDeviceInfo(); + const alwaysShowActions = isMobile || isTablet; + const activeProject = useProjectsStore((state) => state.getActiveProject()); + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + + const projectDirectory = React.useMemo(() => { + return activeProject?.path?.trim() || currentDirectory?.trim() || null; + }, [activeProject?.path, currentDirectory]); + + const [query, setQuery] = React.useState(''); + const [createInWorktree, setCreateInWorktree] = React.useState(false); + const [result, setResult] = React.useState(null); + const [issues, setIssues] = React.useState([]); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(false); + const [startingIssueNumber, setStartingIssueNumber] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + + const directNumber = React.useMemo(() => parseIssueNumber(query), [query]); + const debouncedQuery = useDebouncedValue(query, 350); + const isTextSearch = debouncedQuery.trim().length > 0 && !directNumber; + + const refresh = React.useCallback(async () => { + if (!projectDirectory) { + setResult(null); + setError(t('session.giteaIssuePicker.error.noActiveProject')); + return; + } + if (giteaAuthChecked && giteaAuthStatus?.connected === false) { + setResult({ connected: false, issues: [], page: 1, hasMore: false }); + setIssues([]); + setHasMore(false); + setPage(1); + setError(null); + return; + } + if (!gitea?.issuesList) { + setResult(null); + setError(t('session.giteaIssuePicker.error.runtimeUnavailable')); + return; + } + + setIsLoading(true); + setError(null); + try { + const next = await gitea.issuesList(projectDirectory, { page: 1 }); + setResult(next); + setIssues(next.issues ?? []); + setPage(next.page ?? 1); + setHasMore(Boolean(next.hasMore)); + if (next.connected === false) { + setError(null); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setIsLoading(false); + } + }, [gitea, giteaAuthChecked, giteaAuthStatus, projectDirectory, t]); + + React.useEffect(() => { + if (!open || !projectDirectory) return; + if (giteaAuthChecked && giteaAuthStatus?.connected === false) return; + if (!gitea?.issuesList) return; + if (!debouncedQuery.trim() || directNumber) { + void refresh(); + return; + } + + const controller = new AbortController(); + setIsLoading(true); + setError(null); + + gitea.issuesList(projectDirectory, { page: 1, query: debouncedQuery.trim() }) + .then((next) => { + if (controller.signal.aborted) return; + setResult(next); + setIssues(next.issues ?? []); + setPage(next.page ?? 1); + setHasMore(Boolean(next.hasMore)); + }) + .catch((e) => { + if (controller.signal.aborted) return; + setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (!controller.signal.aborted) setIsLoading(false); + }); + + return () => controller.abort(); + }, [open, projectDirectory, gitea, giteaAuthChecked, giteaAuthStatus, debouncedQuery, directNumber, refresh, t]); + + const loadMore = React.useCallback(async () => { + if (!projectDirectory) return; + if (!gitea?.issuesList) return; + if (isLoadingMore || isLoading) return; + if (!hasMore) return; + + setIsLoadingMore(true); + try { + const nextPage = page + 1; + const next = isTextSearch + ? await gitea.issuesList(projectDirectory, { page: nextPage, query: debouncedQuery.trim() }) + : await gitea.issuesList(projectDirectory, { page: nextPage }); + setResult(next); + setIssues((prev) => [...prev, ...(next.issues ?? [])]); + setPage(next.page ?? nextPage); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.giteaIssuePicker.toast.loadMoreFailed'), { description: message }); + } finally { + setIsLoadingMore(false); + } + }, [gitea, hasMore, isLoading, isLoadingMore, isTextSearch, debouncedQuery, page, projectDirectory, t]); + + React.useEffect(() => { + if (!open) { + setQuery(''); + setCreateInWorktree(false); + setStartingIssueNumber(null); + setError(null); + setResult(null); + setIssues([]); + setPage(1); + setHasMore(false); + setIsLoading(false); + return; + } + void refresh(); + }, [open, refresh]); + + React.useEffect(() => { + if (!open) return; + if (giteaAuthChecked && giteaAuthStatus?.connected === false) { + setResult({ connected: false, issues: [], page: 1, hasMore: false }); + setIssues([]); + setHasMore(false); + setPage(1); + setError(null); + } + }, [giteaAuthChecked, giteaAuthStatus, open]); + + const connected = giteaAuthChecked ? result?.connected !== false : true; + const repoUrl = result?.repo?.url ?? null; + + const openGiteaSettings = React.useCallback(() => { + setSettingsPage('git'); + setSettingsDialogOpen(true); + }, [setSettingsDialogOpen, setSettingsPage]); + + const resolveDefaultAgentName = React.useCallback((): string | undefined => { + const configState = useConfigStore.getState(); + const visibleAgents = configState.getVisibleAgents(); + + if (configState.settingsDefaultAgent) { + const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent); + if (settingsAgent) { + return settingsAgent.name; + } + } + + return ( + visibleAgents.find((agent) => agent.name === 'build')?.name || + visibleAgents[0]?.name + ); + }, []); + + const resolveDefaultModelSelection = React.useCallback((): { providerID: string; modelID: string } | null => { + const configState = useConfigStore.getState(); + const settingsDefaultModel = configState.settingsDefaultModel; + if (!settingsDefaultModel) { + return null; + } + + const parsed = parseModelIdentifier(settingsDefaultModel); + if (!parsed) { + return null; + } + const { providerId: providerID, modelId: modelID } = parsed; + + const modelMetadata = configState.getModelMetadata(providerID, modelID); + if (!modelMetadata) { + return null; + } + + return { providerID, modelID }; + }, []); + + const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => { + const configState = useConfigStore.getState(); + const settingsDefaultVariant = configState.settingsDefaultVariant; + const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID + ? configState.currentVariant + : undefined; + + const provider = configState.providers.find((p) => p.id === providerID); + const model = provider?.models.find((m: Record) => (m as { id?: string }).id === modelID) as + | { variants?: Record } + | undefined; + const variants = model?.variants; + if (!variants) { + return settingsDefaultVariant || currentVariant || undefined; + } + if (settingsDefaultVariant && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) { + return settingsDefaultVariant; + } + if (currentVariant && Object.prototype.hasOwnProperty.call(variants, currentVariant)) { + return currentVariant; + } + return undefined; + }, []); + + const startSession = React.useCallback(async (issueNumber: number) => { + if (mode === 'select') { + // In select mode, fetch full issue details and return via onSelect + if (!projectDirectory) { + toast.error(t('session.giteaIssuePicker.error.noActiveProject')); + return; + } + if (!gitea?.issueGet || !gitea?.issueComments) { + toast.error(t('session.giteaIssuePicker.error.runtimeUnavailable')); + return; + } + if (startingIssueNumber) return; + setStartingIssueNumber(issueNumber); + try { + const issueRes = await gitea.issueGet(projectDirectory, issueNumber); + if (issueRes.connected === false) { + toast.error(t('session.giteaIssuePicker.error.notConnected')); + return; + } + if (!issueRes.repo) { + toast.error(t('session.giteaIssuePicker.error.repoNotResolvable'), { + description: t('session.giteaIssuePicker.error.repoMustBeGitea'), + }); + return; + } + const issue = issueRes.issue; + if (!issue) { + toast.error(t('session.giteaIssuePicker.error.issueNotFound')); + return; + } + + const commentsRes = await gitea.issueComments(projectDirectory, issueNumber); + if (commentsRes.connected === false) { + toast.error(t('session.giteaIssuePicker.error.notConnected')); + return; + } + const comments = commentsRes.comments ?? []; + + // Build full context text like in createSession mode + const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments }); + + if (onSelect) { + onSelect({ + number: issue.number, + title: issue.title, + url: issue.url, + contextText, + author: issue.author ? { + login: issue.author.username, + } : undefined, + }); + } + onOpenChange(false); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.giteaIssuePicker.toast.loadIssueDetailsFailed'), { description: message }); + } finally { + setStartingIssueNumber(null); + } + return; + } + + if (!projectDirectory) { + toast.error(t('session.giteaIssuePicker.error.noActiveProject')); + return; + } + if (!gitea?.issueGet || !gitea?.issueComments) { + toast.error(t('session.giteaIssuePicker.error.runtimeUnavailable')); + return; + } + if (startingIssueNumber) return; + setStartingIssueNumber(issueNumber); + try { + const issueRes = await gitea.issueGet(projectDirectory, issueNumber); + if (issueRes.connected === false) { + toast.error(t('session.giteaIssuePicker.error.notConnected')); + return; + } + if (!issueRes.repo) { + toast.error(t('session.giteaIssuePicker.error.repoNotResolvable'), { + description: t('session.giteaIssuePicker.error.repoMustBeGitea'), + }); + return; + } + const issue = issueRes.issue; + if (!issue) { + toast.error(t('session.giteaIssuePicker.error.issueNotFound')); + return; + } + + const commentsRes = await gitea.issueComments(projectDirectory, issueNumber); + if (commentsRes.connected === false) { + toast.error(t('session.giteaIssuePicker.error.notConnected')); + return; + } + const comments = commentsRes.comments ?? []; + + const sessionTitle = `#${issue.number} ${issue.title}`.trim(); + + const { sessionId, sessionDirectory } = await (async () => { + if (createInWorktree) { + const preferred = `issue-${issue.number}-${generateBranchSlug()}`; + const created = await createWorktreeSessionForNewBranch( + projectDirectory, + preferred, + undefined, + { returnAfterDirectoryCreated: true } + ); + if (!created?.id) { + throw new Error('Failed to create worktree session'); + } + return { sessionId: created.id, sessionDirectory: created.path }; + } + + const session = await sessionActions.createSession(sessionTitle, projectDirectory, null); + if (!session?.id) { + throw new Error('Failed to create session'); + } + return { sessionId: session.id, sessionDirectory: session.directory ?? projectDirectory }; + })(); + + // Ensure worktree-based sessions also get the issue title. + void sessionActions.updateSessionTitle(sessionId, sessionTitle).catch(() => undefined); + + try { + useSessionUIStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents); + } catch { + // ignore + } + + // Close modal immediately after session exists (don't wait for message send). + onOpenChange(false); + + const configState = useConfigStore.getState(); + const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; + + const defaultModel = resolveDefaultModelSelection(); + const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID; + const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID; + const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined; + if (!providerID || !modelID) { + toast.error(t('session.giteaIssuePicker.error.noModelSelected')); + return; + } + + const variant = resolveDefaultVariant(providerID, modelID); + + const visiblePromptText = await renderMagicPrompt('gitea.issue.review.visible', { + issue_number: String(issue.number), + }); + const instructionsText = await renderMagicPrompt('gitea.issue.review.instructions'); + const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments }); + + // Record the thread this session was created for, so it stays visible as + // a context source once the opening message has scrolled away. A + // snapshot, never re-fetched; a failed write must not fail the flow. + void sessionActions.setLinkedIssue( + sessionId, + sessionDirectory, + buildLinkedIssue({ + url: issue.url, + number: issue.number, + title: issue.title, + kind: 'issue', + author: issue.author ? { + login: issue.author.username, + } : undefined, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + + void useSessionUIStore.getState().sendMessage( + visiblePromptText, + providerID, + modelID, + agentName, + undefined, + undefined, + [ + { text: instructionsText, synthetic: true }, + { text: contextText, synthetic: true }, + ], + variant, + undefined, + { sessionId }, + ).catch((e) => { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.giteaIssuePicker.toast.sendContextFailed'), { + description: message, + }); + }); + + toast.success(t('session.giteaIssuePicker.toast.sessionCreated')); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.giteaIssuePicker.toast.startSessionFailed'), { description: message }); + } finally { + setStartingIssueNumber(null); + } + }, [createInWorktree, gitea, mode, onOpenChange, onSelect, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber, t]); + + const title = mode === 'select' ? t('session.giteaIssuePicker.title.select') : t('session.giteaIssuePicker.title.createSession'); + const description = mode === 'select' + ? t('session.giteaIssuePicker.description.select') + : t('session.giteaIssuePicker.description.createSession'); + + const content = ( + <> +
+ + setQuery(e.target.value)} + className="pl-9 w-full" + /> +
+ +
+ {!projectDirectory ? ( +
{t('session.giteaIssuePicker.empty.noActiveProject')}
+ ) : null} + + {!gitea ? ( +
{t('session.giteaIssuePicker.empty.runtimeUnavailable')}
+ ) : null} + + {isLoading ? ( +
+ + {t('session.giteaIssuePicker.loading.issues')} +
+ ) : null} + + {connected === false ? ( +
+
{t('session.giteaIssuePicker.empty.notConnected')}
+
+ +
+
+ ) : null} + + {error ? ( +
{error}
+ ) : null} + + {directNumber && projectDirectory && gitea && connected ? ( +
void startSession(directNumber)} + > + # +

+ {t('session.giteaIssuePicker.actions.useIssue', { number: directNumber })} +

+
+ {startingIssueNumber === directNumber ? ( + + ) : null} +
+
+ ) : null} + + {issues.length === 0 && !isLoading && connected && gitea && projectDirectory ? ( +
{debouncedQuery.trim() ? t('session.giteaIssuePicker.empty.noIssuesFound') : t('session.giteaIssuePicker.empty.noOpenIssuesFound')}
+ ) : null} + + {issues.map((issue) => ( +
void startSession(issue.number)} + > + + #{issue.number} + +
+

+ {issue.title} +

+
+ + +
+ ))} + + {hasMore && connected && projectDirectory && gitea ? ( +
+ +
+ ) : null} +
+ + {mode !== 'select' && ( +
+

{t('session.giteaIssuePicker.actions.sectionTitle')}

+
+
setCreateInWorktree((v) => !v)} + onKeyDown={(e) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + setCreateInWorktree((v) => !v); + } + }} + > + + {t('session.giteaIssuePicker.actions.createInWorktree')} + (issue-<number>-<slug>) +
+
+
+ {repoUrl ? ( + + ) : null} + +
+
+
+ )} + + ); + + if (isMobile) { + return ( + onOpenChange(false)} + renderHeader={(closeButton) => ( +
+
+

{title}

+ {closeButton} +
+

{description}

+
+ )} + > + {content} +
+ ); + } + + return ( + + + + + + {title} + + + {description} + + + + {content} + + + ); +} diff --git a/packages/ui/src/components/session/GiteaPrPickerDialog.tsx b/packages/ui/src/components/session/GiteaPrPickerDialog.tsx new file mode 100644 index 00000000..9476b6c8 --- /dev/null +++ b/packages/ui/src/components/session/GiteaPrPickerDialog.tsx @@ -0,0 +1,476 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { toast } from '@/components/ui'; +import { Icon } from "@/components/icon/Icon"; +import { cn } from '@/lib/utils'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore'; +import { renderMagicPrompt } from '@/lib/magicPrompts'; +import { useDeviceInfo } from '@/lib/device'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import type { GiteaPullRequestContextResult, GiteaPullRequestSummary, GiteaPullRequestsListResult } from '@/lib/api/types'; +import { useI18n } from '@/lib/i18n'; + +const parsePrNumber = (value: string): number | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + + const urlMatch = trimmed.match(/\/pulls\/(\d+)(?:\b|\/|$)/i); + if (urlMatch) { + const parsed = Number(urlMatch[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + const shortMatch = trimmed.match(/^#?(\d+)$/); + if (shortMatch) { + const parsed = Number(shortMatch[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + return null; +}; + +const buildPullRequestContextText = (payload: GiteaPullRequestContextResult) => { + return `Gitea pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`; +}; + +export function GiteaPrPickerDialog({ + open, + onOpenChange, + onSelect, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect?: (pr: { + number: number; + title: string; + url: string; + head: string; + base: string; + includeDiff: boolean; + instructionsText: string; + contextText: string; + author?: { login: string; avatarUrl?: string }; + }) => void; +}) { + const { t } = useI18n(); + const { gitea } = useRuntimeAPIs(); + const giteaAuthStatus = useGiteaAuthStore((state) => state.status); + const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked); + const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const isMobile = useUIStore((state) => state.isMobile); + const { isTablet } = useDeviceInfo(); + const alwaysShowActions = isMobile || isTablet; + const activeProject = useProjectsStore((state) => state.getActiveProject()); + + const projectDirectory = activeProject?.path ?? null; + + const [query, setQuery] = React.useState(''); + const [includeDiff, setIncludeDiff] = React.useState(false); + const [result, setResult] = React.useState(null); + const [prs, setPrs] = React.useState([]); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(false); + const [loadingPrNumber, setLoadingPrNumber] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + + const directNumber = React.useMemo(() => parsePrNumber(query), [query]); + const debouncedQuery = useDebouncedValue(query, 350); + const isTextSearch = debouncedQuery.trim().length > 0 && !directNumber; + + const refresh = React.useCallback(async () => { + if (!projectDirectory) { + setResult(null); + setError(t('session.giteaPrPicker.error.noActiveProject')); + return; + } + if (giteaAuthChecked && giteaAuthStatus?.connected === false) { + setResult({ connected: false, prs: [], page: 1, hasMore: false }); + setPrs([]); + setHasMore(false); + setPage(1); + setError(null); + return; + } + if (!gitea?.prsList) { + setResult(null); + setError(t('session.giteaPrPicker.error.runtimeUnavailable')); + return; + } + + setIsLoading(true); + setError(null); + try { + const next = await gitea.prsList(projectDirectory, { page: 1 }); + setResult(next); + setPrs(next.prs ?? []); + setPage(next.page ?? 1); + setHasMore(Boolean(next.hasMore)); + if (next.connected === false) { + setError(null); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setIsLoading(false); + } + }, [gitea, giteaAuthChecked, giteaAuthStatus, projectDirectory, t]); + + React.useEffect(() => { + if (!open || !projectDirectory) return; + if (giteaAuthChecked && giteaAuthStatus?.connected === false) return; + if (!gitea?.prsList) return; + if (!debouncedQuery.trim() || directNumber) { + void refresh(); + return; + } + + const controller = new AbortController(); + setIsLoading(true); + setError(null); + + gitea.prsList(projectDirectory, { page: 1, query: debouncedQuery.trim() }) + .then((next) => { + if (controller.signal.aborted) return; + setResult(next); + setPrs(next.prs ?? []); + setPage(next.page ?? 1); + setHasMore(Boolean(next.hasMore)); + }) + .catch((e) => { + if (controller.signal.aborted) return; + setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (!controller.signal.aborted) setIsLoading(false); + }); + + return () => controller.abort(); + }, [open, projectDirectory, gitea, giteaAuthChecked, giteaAuthStatus, debouncedQuery, directNumber, refresh, t]); + + const loadMore = React.useCallback(async () => { + if (!projectDirectory) return; + if (!gitea?.prsList) return; + if (isLoadingMore || isLoading) return; + if (!hasMore) return; + + setIsLoadingMore(true); + try { + const nextPage = page + 1; + const next = isTextSearch + ? await gitea.prsList(projectDirectory, { page: nextPage, query: debouncedQuery.trim() }) + : await gitea.prsList(projectDirectory, { page: nextPage }); + setResult(next); + setPrs((prev) => [...prev, ...(next.prs ?? [])]); + setPage(next.page ?? nextPage); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.giteaPrPicker.toast.loadMoreFailed'), { description: message }); + } finally { + setIsLoadingMore(false); + } + }, [gitea, hasMore, isLoading, isLoadingMore, isTextSearch, debouncedQuery, page, projectDirectory, t]); + + React.useEffect(() => { + if (!open) { + setQuery(''); + setIncludeDiff(false); + setLoadingPrNumber(null); + setError(null); + setResult(null); + setPrs([]); + setPage(1); + setHasMore(false); + setIsLoading(false); + return; + } + void refresh(); + }, [open, refresh]); + + React.useEffect(() => { + if (!open) return; + if (giteaAuthChecked && giteaAuthStatus?.connected === false) { + setResult({ connected: false, prs: [], page: 1, hasMore: false }); + setPrs([]); + setHasMore(false); + setPage(1); + setError(null); + } + }, [giteaAuthChecked, giteaAuthStatus, open]); + + const connected = giteaAuthChecked ? result?.connected !== false : true; + + const openGiteaSettings = React.useCallback(() => { + setSettingsPage('git'); + setSettingsDialogOpen(true); + }, [setSettingsDialogOpen, setSettingsPage]); + + const attachPr = React.useCallback(async (prNumber: number) => { + if (!projectDirectory) { + toast.error(t('session.giteaPrPicker.error.noActiveProject')); + return; + } + if (!gitea?.prContext) { + toast.error(t('session.giteaPrPicker.error.runtimeUnavailable')); + return; + } + if (loadingPrNumber) return; + + setLoadingPrNumber(prNumber); + try { + const context = await gitea.prContext(projectDirectory, prNumber, { + includeDiff, + }); + + if (context.connected === false) { + toast.error(t('session.giteaPrPicker.error.notConnected')); + return; + } + + if (!context.pr) { + toast.error(t('session.giteaPrPicker.error.prNotFound')); + return; + } + + if (!context.repo) { + toast.error(t('session.giteaPrPicker.error.repoNotResolvable'), { + description: t('session.giteaPrPicker.error.repoMustBeGitea'), + }); + return; + } + + if (onSelect) { + const instructionsText = await renderMagicPrompt('gitea.pr.review.instructions'); + onSelect({ + number: context.pr.number, + title: context.pr.title, + url: context.pr.url, + head: context.pr.sourceBranch, + base: context.pr.targetBranch, + includeDiff, + instructionsText, + contextText: buildPullRequestContextText(context), + author: context.pr.author + ? { + login: context.pr.author.username, + } + : undefined, + }); + } + onOpenChange(false); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.giteaPrPicker.toast.loadDetailsFailed'), { description: message }); + } finally { + setLoadingPrNumber(null); + } + }, [gitea, includeDiff, loadingPrNumber, onOpenChange, onSelect, projectDirectory, t]); + + const title = t('session.giteaPrPicker.title'); + const description = t('session.giteaPrPicker.description'); + + const content = ( + <> +
+
+ + setQuery(e.target.value)} + className="pl-9 w-full" + /> +
+ +
+ +
+ {!projectDirectory ? ( +
{t('session.giteaPrPicker.empty.noActiveProject')}
+ ) : null} + + {!gitea ? ( +
{t('session.giteaPrPicker.empty.runtimeUnavailable')}
+ ) : null} + + {isLoading ? ( +
+ + {t('session.giteaPrPicker.loading.pullRequests')} +
+ ) : null} + + {connected === false ? ( +
+
{t('session.giteaPrPicker.empty.notConnected')}
+
+ +
+
+ ) : null} + + {error ? ( +
{error}
+ ) : null} + + {directNumber && projectDirectory && gitea && connected ? ( +
void attachPr(directNumber)} + > + # +

+ {t('session.giteaPrPicker.actions.usePullRequest', { number: directNumber })} +

+
+ {loadingPrNumber === directNumber ? ( + + ) : null} +
+
+ ) : null} + + {prs.length === 0 && !isLoading && connected && gitea && projectDirectory ? ( +
{debouncedQuery.trim() ? t('session.giteaPrPicker.empty.noPullRequestsFound') : t('session.giteaPrPicker.empty.noOpenPullRequestsFound')}
+ ) : null} + + {prs.map((pr) => ( +
void attachPr(pr.number)} + > +
+

+ #{pr.number} + {pr.title} +

+

{pr.sourceBranch} → {pr.targetBranch}

+
+ + +
+ ))} + + {hasMore && connected && projectDirectory && gitea ? ( +
+ +
+ ) : null} +
+ + ); + + if (isMobile) { + return ( + onOpenChange(false)} + renderHeader={(closeButton) => ( +
+
+

{title}

+ {closeButton} +
+

{description}

+
+ )} + > + {content} +
+ ); + } + + return ( + + + + + + {title} + + + {description} + + + + {content} + + + ); +} diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index c07ec407..4601eff1 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -27,12 +27,15 @@ 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 { useGiteaAuthStore } from '@/stores/useGiteaAuthStore'; import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; import { useUIStore } from '@/stores/useUIStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import * as sessionActions from '@/sync/session-actions'; import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues'; +import { useGitProvider } from '@/lib/gitProvider'; import { useConfigStore } from '@/stores/useConfigStore'; import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager'; import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate'; @@ -52,6 +55,8 @@ import { import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore'; import { GitHubIntegrationDialog } from './GitHubIntegrationDialog'; +import { GitLabIntegrationDialog } from './GitLabIntegrationDialog'; +import { GiteaIntegrationDialog } from './GiteaIntegrationDialog'; import { LinearIssuePickerDialog } from './LinearIssuePickerDialog'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; @@ -62,6 +67,14 @@ import type { GitHubIssuesListResult, GitHubPullRequestContextResult, GitHubPullRequestSummary, + GitLabIssue, + GitLabIssueComment, + GitLabIssuesListResult, + GitLabMergeRequestContextResult, + GiteaComment, + GiteaIssue, + GiteaIssuesListResult, + GiteaPullRequestContextResult, LinearIssue, LinearIssueComment, } from '@/lib/api/types'; @@ -92,8 +105,14 @@ interface NewBranchState { sourceBranch: string; linkedIssue: GitHubIssue | null; linkedPr: GitHubPullRequestSummary | null; - linkedLinearIssue: LinkedLinearWorktreeIssue | null; includePrDiff: boolean; + linkedLinearIssue: LinkedLinearWorktreeIssue | null; + linkedGitLabIssue: { number: number; title: string; url: string } | null; + linkedGitLabMr: { number: number; title: string; url: string; sourceBranch: string } | null; + includeGitLabMrDiff: boolean; + linkedGiteaIssue: { number: number; title: string; url: string } | null; + linkedGiteaPr: { number: number; title: string; url: string; sourceBranch: string } | null; + includeGiteaPrDiff: boolean; } // State for Existing Branch mode @@ -222,6 +241,40 @@ 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)}`; +}; + +const buildGiteaIssueContextText = (args: { + repo: GiteaIssuesListResult['repo'] | undefined; + issue: GiteaIssue; + comments: GiteaComment[]; +}) => { + const payload = { + repo: args.repo ?? null, + issue: args.issue, + comments: args.comments, + }; + return `Gitea issue context (JSON)\n${JSON.stringify(payload, null, 2)}`; +}; + +const buildGiteaPrContextText = (payload: GiteaPullRequestContextResult) => { + return `Gitea pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`; +}; + const buildLinearIssueContextText = (args: { issue: LinearIssue; comments: LinearIssueComment[]; @@ -239,14 +292,20 @@ export function NewWorktreeDialog({ onWorktreeCreated, }: NewWorktreeDialogProps) { const { t } = useI18n(); - const { github, git, linear } = useRuntimeAPIs(); + const { github, git, gitlab, gitea, linear } = 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 refreshGitLabAuth = useGitLabAuthStore((state) => state.refreshStatus); + const giteaAuthStatus = useGiteaAuthStore((state) => state.status); + const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked); + const refreshGiteaAuth = useGiteaAuthStore((state) => state.refreshStatus); const linearAuthStatus = useLinearAuthStore((state) => state.status); const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked); const activeProject = useProjectsStore((state) => state.getActiveProject()); - + const projectDirectory = activeProject?.path ?? null; const projectRef: ProjectRef | null = React.useMemo(() => { if (projectDirectory && activeProject) { @@ -257,7 +316,7 @@ export function NewWorktreeDialog({ // Mode state const [mode, setMode] = React.useState('new-branch'); - + // Separate state for each mode (persisted when switching tabs) const [newBranchState, setNewBranchState] = React.useState({ branchName: '', @@ -266,15 +325,21 @@ export function NewWorktreeDialog({ sourceBranch: '', linkedIssue: null, linkedPr: null, - linkedLinearIssue: null, includePrDiff: false, + linkedLinearIssue: null, + linkedGitLabIssue: null, + linkedGitLabMr: null, + includeGitLabMrDiff: false, + linkedGiteaIssue: null, + linkedGiteaPr: null, + includeGiteaPrDiff: false, }); - + const [existingBranchState, setExistingBranchState] = React.useState({ selectedBranch: '', worktreeName: '', }); - + // Use cached branches from Git store (instant if already fetched) const branches = useGitBranches(projectDirectory); const isLoadingBranches = useGitLoadingBranches(projectDirectory); @@ -287,7 +352,7 @@ export function NewWorktreeDialog({ .filter((branchName: string) => !branchName.startsWith('remotes/')) .sort(); }, [branches]); - + const remoteBranches = React.useMemo(() => { if (!branches?.all) return []; return branches.all @@ -295,7 +360,7 @@ export function NewWorktreeDialog({ .map((branchName: string) => branchName.replace(/^remotes\//, '')) .sort(); }, [branches]); - + // Get existing worktrees for the current project to avoid conflicts const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const existingWorktreeNames = React.useMemo(() => { @@ -303,7 +368,7 @@ export function NewWorktreeDialog({ const worktrees = availableWorktreesByProject.get(projectDirectory) ?? []; return new Set(worktrees.map(wt => wt.name)); }, [availableWorktreesByProject, projectDirectory]); - + // Generate a unique slug that doesn't conflict with existing worktrees const generateUniqueSlug = React.useCallback((maxAttempts = 10): string => { for (let attempt = 0; attempt < maxAttempts; attempt++) { @@ -315,10 +380,28 @@ export function NewWorktreeDialog({ // Fallback: add timestamp if all attempts failed return `${generateBranchSlug()}-${Date.now().toString(36).slice(-4)}`; }, [existingWorktreeNames]); - + const [githubDialogOpen, setGithubDialogOpen] = React.useState(false); + const [gitlabDialogOpen, setGitlabDialogOpen] = React.useState(false); + const [giteaDialogOpen, setGiteaDialogOpen] = React.useState(false); const [linearDialogOpen, setLinearDialogOpen] = React.useState(false); - + + // Populate the GitLab auth status on mount so the "Start from GitLab issue/MR" + // action is available without first visiting Settings. refreshStatus dedupes + // when already checked and falls back to runtimeFetch when the runtime API + // is unavailable. + React.useEffect(() => { + void refreshGitLabAuth(gitlab); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Same for Gitea: the "Start from Gitea issue/PR" action needs the auth + // state without a settings visit. + React.useEffect(() => { + void refreshGiteaAuth(gitea); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Desktop branch picker states const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false); const [sourceBranchDropdownOpen, setSourceBranchDropdownOpen] = React.useState(false); @@ -452,7 +535,7 @@ export function NewWorktreeDialog({ worktreeError: null, touched: false, }); - + // Creation state const [isCreating, setIsCreating] = React.useState(false); const [validationAbortController, setValidationAbortController] = React.useState(null); @@ -508,9 +591,19 @@ export function NewWorktreeDialog({ directory: string; issue: GitHubIssue | null; pr: GitHubPullRequestSummary | null; - linearIssue: LinkedLinearWorktreeIssue | null; includeDiff: boolean; + linearIssue: LinkedLinearWorktreeIssue | null; + gitLabIssue: { number: number; title: string; url: string } | null; + gitLabMr: { number: number; title: string; url: string; sourceBranch: string } | null; + includeGitLabMrDiff: boolean; + giteaIssue: { number: number; title: string; url: string } | null; + giteaPr: { number: number; title: string; url: string; sourceBranch: string } | null; + includeGiteaPrDiff: boolean; }) => { + if (!projectDirectory) { + return; + } + const configState = useConfigStore.getState(); const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; const defaultModel = resolveDefaultModelSelection(); @@ -584,12 +677,8 @@ export function NewWorktreeDialog({ return; } - if (!projectDirectory || !github) { - return; - } - if (args.issue) { - if (!github.issueGet || !github.issueComments) { + if (!github || !github.issueGet || !github.issueComments) { return; } @@ -650,7 +739,7 @@ export function NewWorktreeDialog({ } if (args.pr) { - if (!github.prContext) { + if (!github || !github.prContext) { return; } @@ -701,8 +790,242 @@ 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')); + } + + if (args.giteaIssue) { + if (!gitea || !gitea.issueGet || !gitea.issueComments) { + return; + } + + const issueRes = await gitea.issueGet(projectDirectory, args.giteaIssue.number); + if (issueRes.connected === false || !issueRes.issue) { + throw new Error('Failed to load issue context'); + } + + const commentsRes = await gitea.issueComments(projectDirectory, args.giteaIssue.number); + if (commentsRes.connected === false) { + throw new Error('Failed to load issue comments'); + } + + const visiblePromptText = await renderMagicPrompt('gitea.issue.review.visible', { + issue_number: String(args.giteaIssue.number), + }); + const instructionsText = await renderMagicPrompt('gitea.issue.review.instructions'); + const contextText = buildGiteaIssueContextText({ + 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 } + : null, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + + toast.success(t('session.newWorktree.toast.sessionFromIssue')); + return; + } + + if (args.giteaPr) { + if (!gitea || !gitea.prContext) { + return; + } + + const prContext = await gitea.prContext(projectDirectory, args.giteaPr.number, { + includeDiff: args.includeGiteaPrDiff, + }); + if (prContext.connected === false || !prContext.pr) { + throw new Error('Failed to load PR context'); + } + + const visiblePromptText = await renderMagicPrompt('gitea.pr.review.visible', { + pr_number: String(args.giteaPr.number), + }); + const instructionsText = await renderMagicPrompt('gitea.pr.review.instructions'); + const contextText = buildGiteaPrContextText(prContext); + + 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: prContext.pr.url, + number: prContext.pr.number, + title: prContext.pr.title, + kind: 'pull', + author: prContext.pr.author + ? { login: prContext.pr.author.username } + : null, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + + toast.success(t('session.newWorktree.toast.sessionFromPr')); + } }, [ github, + gitlab, + gitea, linear, projectDirectory, resolveDefaultAgentName, @@ -791,15 +1114,21 @@ export function NewWorktreeDialog({ sourceBranch: '', linkedIssue: null, linkedPr: null, - linkedLinearIssue: null, includePrDiff: false, + linkedLinearIssue: null, + linkedGitLabIssue: null, + linkedGitLabMr: null, + includeGitLabMrDiff: false, + linkedGiteaIssue: null, + linkedGiteaPr: null, + includeGiteaPrDiff: false, }); }, [open, generateUniqueSlug]); // Sync worktree name with branch name for new-branch mode React.useEffect(() => { if (mode !== 'new-branch' || !newBranchState.isSyncingWorktreeName) return; - + const normalizedBranch = normalizeBranchName(newBranchState.branchName); const newWorktreeName = slugifyWorktreeName(normalizedBranch); setNewBranchState(prev => ({ ...prev, worktreeName: newWorktreeName })); @@ -808,26 +1137,26 @@ export function NewWorktreeDialog({ // Validation - only runs after fields are touched const validateInputs = React.useCallback(async () => { if (!projectRef || !validation.touched || isCreating) return; - + // Cancel previous validation if (validationAbortController) { validationAbortController.abort(); } - + const abortController = new AbortController(); setValidationAbortController(abortController); - + setValidation(prev => ({ ...prev, isValidating: true })); - + try { const branchName = mode === 'new-branch' ? newBranchState.branchName : existingBranchState.selectedBranch; const worktreeName = currentState.worktreeName; const normalizedBranch = normalizeBranchName(branchName); const normalizedWorktree = slugifyWorktreeName(worktreeName); - + let branchError: string | null = null; let worktreeError: string | null = null; - + if (!normalizedBranch) { branchError = t('session.newWorktree.error.branchNameRequired'); } @@ -835,22 +1164,26 @@ export function NewWorktreeDialog({ if (!normalizedWorktree) { worktreeError = t('session.newWorktree.error.worktreeDirectoryRequired'); } - + // Only run server validation if we have values 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 linkedGiteaPr = mode === 'new-branch' ? newBranchState.linkedGiteaPr : null; + const giteaPrBranch = linkedGiteaPr ? normalizeBranchName(linkedGiteaPr.sourceBranch || '') : ''; const result = await validateWorktreeCreate(projectRef, { - mode: mode === 'existing-branch' || prConfig ? 'existing' : 'new', + mode: mode === 'existing-branch' || prConfig || gitLabMrBranch || giteaPrBranch ? 'existing' : 'new', branchName: normalizedBranch, worktreeName: normalizedWorktree, - existingBranch: prConfig?.existingBranch ?? (mode === 'existing-branch' ? normalizedBranch : undefined), + existingBranch: prConfig?.existingBranch ?? (gitLabMrBranch || giteaPrBranch || (mode === 'existing-branch' ? normalizedBranch : undefined)), ...(prConfig?.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}), ...(prConfig?.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}), }); - + if (abortController.signal.aborted) return; - + if (!result.ok) { result.errors.forEach((error) => { if (error.code === 'worktree_exists') { @@ -864,7 +1197,7 @@ export function NewWorktreeDialog({ }); } } - + if (!abortController.signal.aborted) { setValidation(prev => ({ ...prev, @@ -886,6 +1219,8 @@ export function NewWorktreeDialog({ mode, newBranchState.branchName, newBranchState.linkedPr, + newBranchState.linkedGitLabMr, + newBranchState.linkedGiteaPr, existingBranchState.selectedBranch, currentState.worktreeName, localBranches, @@ -902,11 +1237,11 @@ export function NewWorktreeDialog({ // Trigger validation on input changes (only after touched) React.useEffect(() => { if (!open || !projectRef || !validation.touched || isCreating) return; - + const timer = setTimeout(() => { void validateInputs(); }, 300); - + return () => clearTimeout(timer); }, [currentState.worktreeName, currentBranchName, open, projectRef, validateInputs, validation.touched, isCreating]); @@ -916,20 +1251,20 @@ export function NewWorktreeDialog({ toast.error(t('session.newWorktree.error.noActiveProject')); return; } - + // Mark as touched and validate immediately setValidation(prev => ({ ...prev, touched: true })); - + const branchName = mode === 'new-branch' ? newBranchState.branchName : existingBranchState.selectedBranch; const worktreeName = currentState.worktreeName; const normalizedBranch = normalizeBranchName(branchName); const normalizedWorktree = slugifyWorktreeName(worktreeName); - + if (!normalizedBranch) { toast.error(t('session.newWorktree.error.branchNameRequired')); return; } - + if (!normalizedWorktree) { toast.error(t('session.newWorktree.error.worktreeDirectoryRequired')); return; @@ -946,16 +1281,22 @@ export function NewWorktreeDialog({ branchError: null, worktreeError: null, })); - + setIsCreating(true); - + try { const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null; const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null; - const linkedLinearIssue = mode === 'new-branch' ? newBranchState.linkedLinearIssue : null; const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null; const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false; - const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedLinearIssue); + const linkedLinearIssue = mode === 'new-branch' ? newBranchState.linkedLinearIssue : null; + 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 linkedGiteaIssue = mode === 'new-branch' ? newBranchState.linkedGiteaIssue : null; + const linkedGiteaPr = mode === 'new-branch' ? newBranchState.linkedGiteaPr : null; + const includeGiteaPrDiff = mode === 'new-branch' ? newBranchState.includeGiteaPrDiff : false; + const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedLinearIssue || linkedGitLabIssue || linkedGitLabMr || linkedGiteaIssue || linkedGiteaPr); const setupCommands = await getWorktreeSetupCommands(projectRef); const sourceBranch = newBranchState.sourceBranch; @@ -981,6 +1322,40 @@ 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, + }; + } + + if (linkedGiteaPr) { + const prBranch = normalizeBranchName(linkedGiteaPr.sourceBranch || ''); + if (!prBranch) { + throw new Error('PR source branch is missing'); + } + sourceLabel = prBranch; + return { + preferredName: normalizedBranch || normalizedWorktree, + mode: 'existing' as const, + branchName: normalizedBranch, + worktreeName: normalizedWorktree, + existingBranch: prBranch, + setupCommands, + returnAfterDirectoryCreated: true, + }; + } + sourceLabel = mode === 'new-branch' ? sourceBranch : ''; return { preferredName: normalizedBranch || normalizedWorktree, @@ -1008,10 +1383,18 @@ export function NewWorktreeDialog({ const sessionTitle = linkedLinearIssue ? `${linkedLinearIssue.identifier} ${linkedLinearIssue.title}`.trim() : linkedIssue - ? `#${linkedIssue.number} ${linkedIssue.title}`.trim() - : linkedPrState - ? `#${linkedPrState.number} ${linkedPrState.title}`.trim() - : t('session.newWorktree.newSessionTitle'); + ? `#${linkedIssue.number} ${linkedIssue.title}`.trim() + : linkedPrState + ? `#${linkedPrState.number} ${linkedPrState.title}`.trim() + : linkedGitLabIssue + ? `#${linkedGitLabIssue.number} ${linkedGitLabIssue.title}`.trim() + : linkedGitLabMr + ? `!${linkedGitLabMr.number} ${linkedGitLabMr.title}`.trim() + : linkedGiteaIssue + ? `#${linkedGiteaIssue.number} ${linkedGiteaIssue.title}`.trim() + : linkedGiteaPr + ? `#${linkedGiteaPr.number} ${linkedGiteaPr.title}`.trim() + : t('session.newWorktree.newSessionTitle'); const session = await sessionActions.createSession(sessionTitle, metadata.path, null); if (!session?.id) { @@ -1034,7 +1417,7 @@ export function NewWorktreeDialog({ onOpenChange(false); setIsCreating(false); } - + // Save the last source-branch choice for the next open. const lastSourceBranch = resolveWorktreeSourceBranchToPersist({ mode, @@ -1046,7 +1429,7 @@ export function NewWorktreeDialog({ if (lastSourceBranch) { localStorage.setItem(LAST_WORKTREE_SOURCE_BRANCH_KEY, lastSourceBranch); } - + toast.success(t('session.newWorktree.toast.worktreeCreated'), { description: t('session.newWorktree.toast.worktreeCreatedDescription', { target: `${metadata.branch || metadata.name}${sourceLabel ? ` ${t('session.newWorktree.fromSource', { source: sourceLabel })}` : ''}`, @@ -1059,14 +1442,26 @@ export function NewWorktreeDialog({ directory: metadata.path, issue: linkedIssue, pr: linkedPrState, - linearIssue: linkedLinearIssue, includeDiff: includePrDiff, + linearIssue: linkedLinearIssue, + gitLabIssue: linkedGitLabIssue, + gitLabMr: linkedGitLabMr, + includeGitLabMrDiff: includeGitLabMrDiff, + giteaIssue: linkedGiteaIssue, + giteaPr: linkedGiteaPr, + includeGiteaPrDiff: includeGiteaPrDiff, }).catch((error) => { - const fallback = linkedLinearIssue - ? t('session.newWorktree.error.sendLinearContextFailed') - : t('session.newWorktree.error.sendGitHubContextFailed'); - const message = error instanceof Error ? error.message : fallback; - toast.error(fallback, { description: message }); + // There is no Gitea-branded send-context error key in the frozen + // catalogs; the gitea path reuses the generic GitHub wording. + const isGitLabLink = Boolean(linkedGitLabIssue || linkedGitLabMr); + const isLinearLink = Boolean(linkedLinearIssue); + const errorKey = isLinearLink + ? 'session.newWorktree.error.sendLinearContextFailed' + : 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); @@ -1096,8 +1491,13 @@ export function NewWorktreeDialog({ ...prev, linkedIssue: null, linkedPr: null, - linkedLinearIssue: null, includePrDiff: false, + linkedGitLabIssue: null, + linkedGitLabMr: null, + includeGitLabMrDiff: false, + linkedGiteaIssue: null, + linkedGiteaPr: null, + includeGiteaPrDiff: false, branchName: '', })); return; @@ -1110,8 +1510,13 @@ export function NewWorktreeDialog({ ...prev, linkedIssue: issue, linkedPr: null, - linkedLinearIssue: null, includePrDiff: false, + linkedGitLabIssue: null, + linkedGitLabMr: null, + includeGitLabMrDiff: false, + linkedGiteaIssue: null, + linkedGiteaPr: null, + includeGiteaPrDiff: false, branchName: newBranchName, worktreeName: slugifyWorktreeName(newBranchName), isSyncingWorktreeName: true, @@ -1122,8 +1527,13 @@ export function NewWorktreeDialog({ ...prev, linkedPr: pr, linkedIssue: null, - linkedLinearIssue: null, includePrDiff: result.includeDiff ?? false, + linkedGitLabIssue: null, + linkedGitLabMr: null, + includeGitLabMrDiff: false, + linkedGiteaIssue: null, + linkedGiteaPr: null, + includeGiteaPrDiff: false, branchName: pr.head, worktreeName: slugifyWorktreeName(pr.head), isSyncingWorktreeName: true, @@ -1131,33 +1541,172 @@ export function NewWorktreeDialog({ } }; - const handleLinearSelect = (issue: { - identifier: string; + // Handle GitLab selection + const handleGitLabSelect = (result: { + type: 'issue'; + number: number; title: string; url: string; - author?: { login: string; avatarUrl?: string }; - }) => { - const newBranchName = `issue-${issue.identifier}-${generateBranchSlug()}`; - setNewBranchState(prev => ({ - ...prev, - linkedLinearIssue: { - identifier: issue.identifier, - title: issue.title, - url: issue.url, - author: issue.author, - }, - linkedIssue: null, - linkedPr: null, - includePrDiff: false, - branchName: newBranchName, - worktreeName: slugifyWorktreeName(newBranchName), - isSyncingWorktreeName: true, - })); + } | { + 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, + linkedGiteaIssue: null, + linkedGiteaPr: null, + includeGiteaPrDiff: 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, + linkedGiteaIssue: null, + linkedGiteaPr: null, + includeGiteaPrDiff: 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, + linkedGiteaIssue: null, + linkedGiteaPr: null, + includeGiteaPrDiff: false, + branchName: result.sourceBranch, + worktreeName: slugifyWorktreeName(result.sourceBranch), + isSyncingWorktreeName: true, + })); + } + }; + + // Handle Gitea selection + const handleGiteaSelect = (result: { + type: 'issue'; + number: number; + title: string; + url: string; + } | { + type: 'pr'; + number: number; + title: string; + url: string; + sourceBranch: string; + includeDiff: boolean; + } | null) => { + if (!result) { + setNewBranchState(prev => ({ + ...prev, + linkedGiteaIssue: null, + linkedGiteaPr: null, + includeGiteaPrDiff: false, + linkedIssue: null, + linkedPr: null, + includePrDiff: false, + linkedGitLabIssue: null, + linkedGitLabMr: null, + includeGitLabMrDiff: false, + branchName: '', + })); + return; + } + + if (result.type === 'issue') { + const newBranchName = `issue-${result.number}-${generateBranchSlug()}`; + setNewBranchState(prev => ({ + ...prev, + linkedGiteaIssue: { + number: result.number, + title: result.title, + url: result.url, + }, + linkedGiteaPr: null, + includeGiteaPrDiff: false, + linkedIssue: null, + linkedPr: null, + includePrDiff: false, + linkedGitLabIssue: null, + linkedGitLabMr: null, + includeGitLabMrDiff: false, + branchName: newBranchName, + worktreeName: slugifyWorktreeName(newBranchName), + isSyncingWorktreeName: true, + })); + } else if (result.type === 'pr') { + setNewBranchState(prev => ({ + ...prev, + linkedGiteaPr: { + number: result.number, + title: result.title, + url: result.url, + sourceBranch: result.sourceBranch, + }, + linkedGiteaIssue: null, + includeGiteaPrDiff: result.includeDiff, + linkedIssue: null, + linkedPr: null, + includePrDiff: false, + linkedGitLabIssue: null, + linkedGitLabMr: null, + includeGitLabMrDiff: false, + branchName: result.sourceBranch, + worktreeName: slugifyWorktreeName(result.sourceBranch), + isSyncingWorktreeName: true, + })); + } }; // GitHub connection check const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true; - const isLinearConnected = Boolean(linear) && linearAuthChecked && linearAuthStatus?.connected === true; + // GitLab connection check + const isGitLabConnected = gitlabAuthChecked && gitlabAuthStatus?.connected === true; + // Gitea connection check + const isGiteaConnected = giteaAuthChecked && giteaAuthStatus?.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'; + const showGiteaStartFrom = isGiteaConnected && gitProvider === 'gitea'; // Check if form is valid for submission const isFormValid = mode === 'existing-branch' @@ -1171,47 +1720,23 @@ export function NewWorktreeDialog({ ...prev, linkedIssue: null, linkedPr: null, - linkedLinearIssue: null, + linkedGitLabIssue: null, + linkedGitLabMr: null, + linkedGiteaIssue: null, + linkedGiteaPr: null, branchName: '', includePrDiff: false, + includeGitLabMrDiff: false, + includeGiteaPrDiff: false, isSyncingWorktreeName: true, })); }; - const startFromIssueButtons = mode === 'new-branch' && (isGitHubConnected || isLinearConnected) ? ( -
- {isGitHubConnected && ( - - )} - {isLinearConnected && ( - - )} -
- ) : null; - // Footer content const footerContent = (
{/* Validation error */} -
+
{validation.touched && (validation.branchError || validation.worktreeError) && ( <> @@ -1221,7 +1746,7 @@ export function NewWorktreeDialog({ )}
- + {/* Buttons */}
- + {/* Mobile Branch Picker Overlay */} )} - {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && ( + {existingBranchRankedGroups.otherLocal.length > 0 && (
- {t('session.newWorktree.localBranches')} + {hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
{existingBranchRankedGroups.otherLocal.map((branch) => ( @@ -1394,10 +1919,10 @@ export function NewWorktreeDialog({
)} - {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && ( + {existingBranchRankedGroups.otherRemote.length > 0 && (
- {t('session.newWorktree.remoteBranches')} + {hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
{existingBranchRankedGroups.otherRemote.map((branch) => ( @@ -1432,11 +1957,47 @@ export function NewWorktreeDialog({
) : (
-
-
)} + {newBranchState.linkedGitLabMr && ( +
+ + + {t('session.newWorktree.usingMrBranch', { branch: newBranchState.linkedGitLabMr.sourceBranch })} + +
+ )} + {newBranchState.linkedGiteaPr && ( +
+ + + {t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedGiteaPr.sourceBranch })} + +
+ )} {newBranchState.linkedIssue && !newBranchState.linkedPr && (
@@ -1475,14 +2055,19 @@ export function NewWorktreeDialog({
)} - {newBranchState.linkedLinearIssue && ( + {newBranchState.linkedGitLabIssue && !newBranchState.linkedGitLabMr && (
- {t('session.newWorktree.fromLinearIssue', { - identifier: newBranchState.linkedLinearIssue.identifier, - title: newBranchState.linkedLinearIssue.title, - })} + {t('session.newWorktree.fromIssue', { number: newBranchState.linkedGitLabIssue.number, title: newBranchState.linkedGitLabIssue.title })} + +
+ )} + {newBranchState.linkedGiteaIssue && !newBranchState.linkedGiteaPr && ( +
+ + + {t('session.newWorktree.fromIssue', { number: newBranchState.linkedGiteaIssue.number, title: newBranchState.linkedGiteaIssue.title })}
)} @@ -1544,8 +2129,8 @@ export function NewWorktreeDialog({ />
- {/* 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 && !newBranchState.linkedGiteaPr && (
)} - + {/* Mobile Source Branch Picker Overlay */} )} - {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && ( + {sourceBranchRankedGroups.otherLocal.length > 0 && (
- {t('session.newWorktree.localBranches')} + {hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
{sourceBranchRankedGroups.otherLocal.map((branch) => ( @@ -1650,10 +2235,10 @@ export function NewWorktreeDialog({
)} - {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && ( + {sourceBranchRankedGroups.otherRemote.length > 0 && (
- {t('session.newWorktree.remoteBranches')} + {hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
{sourceBranchRankedGroups.otherRemote.map((branch) => ( @@ -1684,23 +2269,18 @@ export function NewWorktreeDialog({ )} {/* Linked Item Preview - Two row minimal display */} - {(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedLinearIssue) && mode === 'new-branch' && ( + {(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr || newBranchState.linkedGiteaIssue || newBranchState.linkedGiteaPr) && mode === 'new-branch' && (
{/* Row 1: Type, number, title, actions */}
- - - {newBranchState.linkedLinearIssue && ( - - {newBranchState.linkedLinearIssue.identifier} - - )} + {newBranchState.linkedIssue || newBranchState.linkedPr ? ( + + ) : newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr ? ( + + ) : ( + + )} + {newBranchState.linkedIssue && ( {t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })} @@ -1711,13 +2291,33 @@ export function NewWorktreeDialog({ {t('session.newWorktree.prNumber', { number: newBranchState.linkedPr.number })} )} - + {newBranchState.linkedGitLabIssue && ( + + {t('session.newWorktree.issueNumber', { number: newBranchState.linkedGitLabIssue.number })} + + )} + {newBranchState.linkedGitLabMr && ( + + {t('session.newWorktree.mrNumber', { number: newBranchState.linkedGitLabMr.number })} + + )} + {newBranchState.linkedGiteaIssue && ( + + {t('session.newWorktree.issueNumber', { number: newBranchState.linkedGiteaIssue.number })} + + )} + {newBranchState.linkedGiteaPr && ( + + {t('session.newWorktree.prNumber', { number: newBranchState.linkedGiteaPr.number })} + + )} + - {newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title} + {newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title || newBranchState.linkedGitLabIssue?.title || newBranchState.linkedGitLabMr?.title || newBranchState.linkedGiteaIssue?.title || newBranchState.linkedGiteaPr?.title} - + - +
- - {/* Row 2: PR branch info + diff indicator */} + + {/* Row 2: PR/MR branch info + diff indicator */} {newBranchState.linkedPr && (
@@ -1747,6 +2347,30 @@ export function NewWorktreeDialog({ )}
)} + {newBranchState.linkedGitLabMr && ( +
+ + {newBranchState.linkedGitLabMr.sourceBranch} + + {newBranchState.includeGitLabMrDiff && ( + + {t('session.newWorktree.includeDiffBadge')} + + )} +
+ )} + {newBranchState.linkedGiteaPr && ( +
+ + {newBranchState.linkedGiteaPr.sourceBranch} + + {newBranchState.includeGiteaPrDiff && ( + + {t('session.newWorktree.includeDiffBadge')} + + )} +
+ )}
)}
@@ -1760,7 +2384,7 @@ export function NewWorktreeDialog({ {t('session.newWorktree.title')} - + {/* Mode Selection - using SortableTabsStrip */}
)} - {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && ( + {existingBranchRankedGroups.otherLocal.length > 0 && ( <> - + {hasExistingBranchQuery && } + {existingBranchRankedGroups.otherLocal.map((branch) => ( )} - {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && ( + {existingBranchRankedGroups.otherRemote.length > 0 && ( <> - {existingBranchRankedGroups.otherLocal.length > 0 && ( + {(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && ( )} - + {existingBranchRankedGroups.otherRemote.map((branch) => ( ) : (
-
-
)} + {newBranchState.linkedGitLabMr && ( +
+ + + {t('session.newWorktree.usingMrBranch', { branch: newBranchState.linkedGitLabMr.sourceBranch })} + +
+ )} + {newBranchState.linkedGiteaPr && ( +
+ + + {t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedGiteaPr.sourceBranch })} + +
+ )} {newBranchState.linkedIssue && !newBranchState.linkedPr && (
@@ -1956,14 +2636,19 @@ export function NewWorktreeDialog({
)} - {newBranchState.linkedLinearIssue && ( + {newBranchState.linkedGitLabIssue && !newBranchState.linkedGitLabMr && (
- {t('session.newWorktree.fromLinearIssue', { - identifier: newBranchState.linkedLinearIssue.identifier, - title: newBranchState.linkedLinearIssue.title, - })} + {t('session.newWorktree.fromIssue', { number: newBranchState.linkedGitLabIssue.number, title: newBranchState.linkedGitLabIssue.title })} + +
+ )} + {newBranchState.linkedGiteaIssue && !newBranchState.linkedGiteaPr && ( +
+ + + {t('session.newWorktree.fromIssue', { number: newBranchState.linkedGiteaIssue.number, title: newBranchState.linkedGiteaIssue.title })}
)} @@ -2025,8 +2710,8 @@ export function NewWorktreeDialog({ />
- {/* 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 && !newBranchState.linkedGiteaPr && (
)} - {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && ( + {sourceBranchRankedGroups.otherLocal.length > 0 && ( <> - + {hasSourceBranchQuery && } + {sourceBranchRankedGroups.otherLocal.map((branch) => ( )} - {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && ( + {sourceBranchRankedGroups.otherRemote.length > 0 && ( <> - {sourceBranchRankedGroups.otherLocal.length > 0 && ( + {(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && ( )} - + {sourceBranchRankedGroups.otherRemote.map((branch) => ( {/* Row 1: Type, number, title, actions */}
- - - {newBranchState.linkedLinearIssue && ( - - {newBranchState.linkedLinearIssue.identifier} - + {newBranchState.linkedIssue || newBranchState.linkedPr ? ( + + ) : newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr ? ( + + ) : ( + )} + {newBranchState.linkedIssue && ( {t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })} @@ -2165,13 +2846,33 @@ export function NewWorktreeDialog({ {t('session.newWorktree.prNumber', { number: newBranchState.linkedPr.number })} )} - + {newBranchState.linkedGitLabIssue && ( + + {t('session.newWorktree.issueNumber', { number: newBranchState.linkedGitLabIssue.number })} + + )} + {newBranchState.linkedGitLabMr && ( + + {t('session.newWorktree.mrNumber', { number: newBranchState.linkedGitLabMr.number })} + + )} + {newBranchState.linkedGiteaIssue && ( + + {t('session.newWorktree.issueNumber', { number: newBranchState.linkedGiteaIssue.number })} + + )} + {newBranchState.linkedGiteaPr && ( + + {t('session.newWorktree.prNumber', { number: newBranchState.linkedGiteaPr.number })} + + )} + - {newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title} + {newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title || newBranchState.linkedGitLabIssue?.title || newBranchState.linkedGitLabMr?.title || newBranchState.linkedGiteaIssue?.title || newBranchState.linkedGiteaPr?.title} - + - +
- - {/* Row 2: PR branch info + diff indicator */} + + {/* Row 2: PR/MR branch info + diff indicator */} {newBranchState.linkedPr && (
@@ -2201,6 +2902,30 @@ export function NewWorktreeDialog({ )}
)} + {newBranchState.linkedGitLabMr && ( +
+ + {newBranchState.linkedGitLabMr.sourceBranch} + + {newBranchState.includeGitLabMrDiff && ( + + {t('session.newWorktree.includeDiffBadge')} + + )} +
+ )} + {newBranchState.linkedGiteaPr && ( +
+ + {newBranchState.linkedGiteaPr.sourceBranch} + + {newBranchState.includeGiteaPrDiff && ( + + {t('session.newWorktree.includeDiffBadge')} + + )} +
+ )}
)}
@@ -2218,7 +2943,7 @@ export function NewWorktreeDialog({ )}
- +
+
+ ); + } + + 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' ? ( +
+ +