feat(gitea): add Gitea/Forgejo as a git provider

Full parity with the existing GitLab provider:
- Server module packages/web/server/lib/gitea (auth/client/repo/routes + docs + tests)
  with Gitea REST v1 API, PAT + base URL auth, multi-account storage
- Shared GiteaAPI types and web API client
- Provider detection generalized with user-configurable custom domains
  per provider (github/gitlab/gitea), additive with built-in defaults
  (github.com, gitlab.com) and connected-account hosts; precedence
  github -> gitlab -> gitea
- Gitea PR view, issues section, pickers, integration dialog, branch
  PR status helper, settings UI (PAT + base URL + custom domains)
- Magic prompts (gitea.pr.review, gitea.issue.review) and full 11-locale
  i18n parity
This commit is contained in:
2026-08-16 16:27:49 +00:00
parent be13272eb1
commit ca91fd7e2d
65 changed files with 10667 additions and 101 deletions
+29 -4
View File
@@ -63,6 +63,8 @@ import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerD
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
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';
@@ -669,6 +671,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const [prPickerOpen, setPrPickerOpen] = 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;
@@ -686,7 +690,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
instructionsText: string;
contextText: string;
author?: { login: string; avatarUrl?: string };
provider?: 'github' | 'gitlab';
provider?: 'github' | 'gitlab' | 'gitea';
} | null>(null);
// Message queue
@@ -954,6 +958,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const openIssuePicker = React.useCallback(() => {
if (gitProvider === 'gitlab') {
setGitlabIssuePickerOpen(true);
} else if (gitProvider === 'gitea') {
setGiteaIssuePickerOpen(true);
} else {
setIssuePickerOpen(true);
}
@@ -962,6 +968,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const openPrPicker = React.useCallback(() => {
if (gitProvider === 'gitlab') {
setGitlabMrPickerOpen(true);
} else if (gitProvider === 'gitea') {
setGiteaPrPickerOpen(true);
} else {
setPrPickerOpen(true);
}
@@ -2914,6 +2922,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setLinkedIssue(null);
}}
/>
<GiteaIssuePickerDialog
open={giteaIssuePickerOpen}
onOpenChange={setGiteaIssuePickerOpen}
mode="select"
onSelect={(issue) => {
setLinkedIssue(issue);
setLinkedPr(null);
}}
/>
<GiteaPrPickerDialog
open={giteaPrPickerOpen}
onOpenChange={setGiteaPrPickerOpen}
onSelect={(pr) => {
setLinkedPr({ ...pr, provider: 'gitea' as const });
setLinkedIssue(null);
}}
/>
<ReviewFlowDialog
open={reviewDialogOpen}
onOpenChange={setReviewDialogOpen}
@@ -2980,8 +3005,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
requestAnimationFrame(openIssuePicker);
}}
>
<Icon name={gitProvider === 'gitlab' ? 'gitlab' : 'github'} className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
{gitProvider === 'gitlab' ? t('chat.chatInput.actions.linkGitlabIssue') : t('chat.chatInput.actions.linkGithubIssue')}
<Icon name={gitProvider === 'gitlab' ? 'gitlab' : gitProvider === 'gitea' ? 'git-branch' : 'github'} className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
{gitProvider === 'gitlab' ? t('chat.chatInput.actions.linkGitlabIssue') : gitProvider === 'gitea' ? t('chat.chatInput.actions.linkGiteaIssue') : t('chat.chatInput.actions.linkGithubIssue')}
</button>
<button
type="button"
@@ -2993,7 +3018,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}}
>
<Icon name={gitProvider === 'gitlab' ? 'gitlab' : 'git-pull-request'} className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
{gitProvider === 'gitlab' ? t('chat.chatInput.actions.linkGitlabMr') : t('chat.chatInput.actions.linkGithubPr')}
{gitProvider === 'gitlab' ? t('chat.chatInput.actions.linkGitlabMr') : gitProvider === 'gitea' ? t('chat.chatInput.actions.linkGiteaPr') : t('chat.chatInput.actions.linkGithubPr')}
</button>
</div>
</MobileOverlayPanel>
@@ -140,6 +140,25 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
{t('chat.chatInput.actions.linkGitlabMr')}
</DropdownMenuItem>
</>
) : gitProvider === 'gitea' ? (
<>
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(openIssuePicker);
}}
>
<Icon name="git-branch"/>
{t('chat.chatInput.actions.linkGiteaIssue')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(openPrPicker);
}}
>
<Icon name="git-pull-request"/>
{t('chat.chatInput.actions.linkGiteaPr')}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
@@ -5,6 +5,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { runBackgroundNetworkTask } from '@/lib/background-network';
import { getGitHubPrStatusKey, usePrVisualSummary } 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';
@@ -115,11 +116,12 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
);
const prSummary = usePrVisualSummary(prKey);
// GitLab merge 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.
// 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
@@ -210,6 +212,7 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
const cost = typeof session?.cost === 'number' && session.cost > 0 ? session.cost : null;
const hasSession = showSession && (usagePercent !== null || cost !== null || Boolean(goalRow));
const hasGitLabMr = gitProvider === 'gitlab' && gitLabMr !== null;
const hasGiteaPr = gitProvider === 'gitea' && giteaPr !== null;
const gitLabMrVisualState = gitLabMr
? gitLabMr.state === 'merged'
? 'merged'
@@ -219,7 +222,16 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
? 'draft'
: 'open'
: null;
const hasRepository = showRepository && Boolean(branch || changed || prSummary || attentionLabel || hasGitLabMr);
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);
@@ -320,6 +332,24 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
/>
) : null}
{hasGiteaPr && giteaPr ? (
<WorkStatusRow
icon="git-pull-request"
onClick={directory ? () => openSurface('pr') : undefined}
ariaLabel={t('chat.workStatus.action.openPr')}
iconColor={`var(--pr-${giteaPrVisualState})`}
label={giteaPr.title || t('chat.workStatus.pr.untitled')}
value={(
<WorkStatusPill
color={`var(--pr-${giteaPrVisualState})`}
background={`color-mix(in srgb, var(--pr-${giteaPrVisualState}) 18%, transparent)`}
>
{giteaPr.draft ? t('chat.workStatus.pr.draft') : `#${giteaPr.number}`}
</WorkStatusPill>
)}
/>
) : null}
{prSummary ? (
<>
<WorkStatusRow
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
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';
@@ -214,7 +215,7 @@ const getTabIcon = (
}
if (tab.mode === 'pr') {
return <Icon name={gitProvider === 'gitlab' ? 'gitlab' : 'github'} className="h-3.5 w-3.5" />;
return <Icon name={gitProvider === 'gitlab' ? 'gitlab' : gitProvider === 'gitea' ? 'git-branch' : 'github'} className="h-3.5 w-3.5" />;
}
if (tab.mode === 'notes') {
@@ -942,7 +943,7 @@ export const ContextPanel: React.FC = () => {
: activeTab?.mode === 'git'
? <React.Suspense fallback={null}><GitView isActive={isOpen} /></React.Suspense>
: activeTab?.mode === 'pr'
? (gitProvider === 'github' ? <PullRequestView /> : gitProvider === 'gitlab' ? <GitLabMrView /> : null)
? (gitProvider === 'github' ? <PullRequestView /> : gitProvider === 'gitlab' ? <GitLabMrView /> : gitProvider === 'gitea' ? <GiteaPrView /> : null)
: activeTab?.mode === 'notes'
? <ProjectContextPanel />
: activeTab?.mode === 'plan'
@@ -299,15 +299,27 @@ export const ContextPanelRail: React.FC = () => {
{surfaces.map((surface, index) => {
// The 'pr' surface renders the GitLab MR view in GitLab repos, so
// it borrows GitLab's merge-request branding instead of GitHub's.
const gitlabMrSurface: ContextSurfaceDescriptor = surface.id === 'pr' && gitProvider === 'gitlab'
? {
...surface,
icon: 'gitlab' as IconName,
labelKey: 'contextPanel.mode.mr',
descriptionKey: 'contextRail.surface.mr.description',
}
// Gitea keeps the generic pull-request branding but swaps the
// GitHub brand icon for a neutral git icon (there is no Gitea
// brand icon in the sprite).
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: 'git-branch' as IconName,
labelKey: 'contextPanel.mode.pr',
descriptionKey: 'contextRail.surface.pr.description',
}
: surface
: surface;
const label = t(gitlabMrSurface.labelKey);
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
@@ -317,11 +329,11 @@ export const ContextPanelRail: React.FC = () => {
return (
<ContextPanelRailItem
key={surface.id}
surface={gitlabMrSurface}
surface={providerPrSurface}
isActive={activeMode === surface.mode}
showActivityDot={false}
label={label}
description={t(gitlabMrSurface.descriptionKey)}
description={t(providerPrSurface.descriptionKey)}
badgeCount={badgeCount}
badgeAriaLabel={badgeCount !== null
? t(
@@ -21,6 +21,7 @@ import { useShallow } from 'zustand/react/shallow';
import { GitSettings } from '@/components/sections/openchamber/GitSettings';
import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings';
import { GitLabSettings } from '@/components/sections/openchamber/GitLabSettings';
import { GiteaSettings } from '@/components/sections/openchamber/GiteaSettings';
import { GitIdentityEditorDialog } from './GitIdentityEditorDialog';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
@@ -124,6 +125,7 @@ export const GitPage: React.FC = () => {
>
<GitHubSettings />
<GitLabSettings />
<GiteaSettings />
<SettingsSection
title={t('settings.gitIdentities.page.section.title')}
@@ -11,6 +11,7 @@ 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 { CustomDomainsInput } from '@/components/sections/shared/CustomDomainsInput';
type GitHubUser = {
login: string;
@@ -445,6 +446,8 @@ export const GitHubSettings: React.FC = () => {
</div>
)}
<CustomDomainsInput provider="github" />
</SettingsSection>
{ghCli?.available && !ghCli?.active && (!ghCli.user || ghCli.disabled) && (
@@ -11,6 +11,7 @@ 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 { CustomDomainsInput } from '@/components/sections/shared/CustomDomainsInput';
const getBaseUrlHost = (baseUrl?: string | null): string => {
if (!baseUrl) return '';
@@ -288,6 +289,8 @@ export const GitLabSettings: React.FC = () => {
</div>
)}
</div>
<CustomDomainsInput provider="gitlab" />
</SettingsSection>
);
};
@@ -0,0 +1,306 @@
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 { CustomDomainsInput } from '@/components/sections/shared/CustomDomainsInput';
const getBaseUrlHost = (baseUrl?: string | null): string => {
if (!baseUrl) return '';
try {
return new URL(baseUrl).host;
} catch {
return baseUrl;
}
};
export const GiteaSettings: React.FC = () => {
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('');
const [baseUrl, setBaseUrl] = React.useState('');
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);
return (
<SettingsSection
title={t('settings.gitea.page.title')}
description={t('settings.gitea.page.description')}
info={t('settings.gitea.page.tooltip.connectAccount')}
settingsItem="git.gitea-account"
>
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
{connected ? (
<div className={cn('px-4 py-3', isMobile ? 'flex flex-col gap-3' : 'flex items-center justify-between gap-4')}>
<div className={cn('flex min-w-0 items-center gap-4', isMobile ? 'w-full' : undefined)}>
{user?.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.username ? t('settings.gitea.page.avatarAlt.withLogin', { login: user.username }) : t('settings.gitea.page.avatarAlt.fallback')}
className="h-10 w-10 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)]">
<Icon name="server" className="h-4 w-4 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="typography-ui-label text-foreground">
{user?.name?.trim() || user?.username || 'Gitea'}
</div>
<div className={cn('flex items-center gap-2 typography-meta text-muted-foreground mt-0.5', isMobile ? 'flex-wrap' : 'truncate')}>
<Icon name="server" className="h-3.5 w-3.5 shrink-0" />
<span>{t('settings.gitea.page.connectedAs')}</span>
<span className="font-mono">{user?.username || t('settings.gitea.page.label.unknownUser')}</span>
<span className="opacity-50"></span>
<span className="font-mono">{currentBaseUrlHost}</span>
</div>
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={disconnect}
disabled={isBusy}
className={cn('text-[var(--status-error)] hover:text-[var(--status-error)]', isMobile ? 'w-full' : undefined)}
>
{t('settings.gitea.page.actions.disconnect')}
</Button>
</div>
) : (
<div className="flex flex-col gap-4 px-4 py-4">
<div className="flex min-w-0 flex-col gap-1">
<label htmlFor="gitea-access-token" className="typography-settings-field-label text-foreground">
{t('settings.gitea.page.accessToken.label')}
</label>
<Input
id="gitea-access-token"
type="password"
value={accessToken}
onChange={(event) => setAccessToken(event.target.value)}
placeholder={t('settings.gitea.page.accessToken.placeholder')}
className="h-9 max-w-[24rem]"
/>
</div>
<div className="flex min-w-0 flex-col gap-1">
<label htmlFor="gitea-base-url" className="typography-settings-field-label text-foreground">
{t('settings.gitea.page.baseUrl.label')}
</label>
<Input
id="gitea-base-url"
type="text"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder={t('settings.gitea.page.baseUrl.placeholder')}
className="h-9 max-w-[24rem]"
/>
</div>
<div className="flex items-center justify-between gap-4">
<span className="typography-ui-label text-foreground">{t('settings.gitea.page.status.notConnected')}</span>
<Button
size="sm"
variant="default"
onClick={connect}
disabled={isBusy || !accessToken.trim() || !baseUrl.trim()}
>
{t('settings.gitea.page.actions.connect')}
</Button>
</div>
</div>
)}
{otherAccounts.length > 0 && (
<div className="mt-2 border-t border-[var(--surface-subtle)] pt-2 px-2 pb-1">
<div className="typography-micro text-muted-foreground mb-2 px-1">
{t('settings.gitea.page.label.otherAccounts')}
</div>
<div className="space-y-1">
{otherAccounts.map((account) => {
const accountUser = account.user;
return (
<div
key={account.id}
className="flex items-center justify-between gap-3 rounded-md border border-[var(--surface-subtle)] bg-[var(--surface-muted)] px-3 py-2"
>
<div className="flex min-w-0 items-center gap-3">
{accountUser?.avatarUrl ? (
<img
src={accountUser.avatarUrl}
alt={accountUser.username ? t('settings.gitea.page.avatarAlt.withLogin', { login: accountUser.username }) : t('settings.gitea.page.avatarAlt.fallback')}
className="h-6 w-6 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)]">
<Icon name="server" className="h-3 w-3 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex flex-col">
<span className="typography-ui-label text-foreground truncate">
{accountUser?.name?.trim() || accountUser?.username || 'Gitea'}
</span>
{accountUser?.username && (
<span className="typography-micro text-muted-foreground truncate">
<span className="font-mono">{accountUser.username}</span>
<span className="mx-1 opacity-50">·</span>
<span className="font-mono">{getBaseUrlHost(account.baseUrl)}</span>
</span>
)}
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => activateAccount(account.id)}
disabled={isBusy}
>
{t('settings.gitea.page.actions.switch')}
</Button>
</div>
);
})}
</div>
</div>
)}
</div>
<CustomDomainsInput provider="gitea" />
</SettingsSection>
);
};
@@ -0,0 +1,50 @@
import React from 'react';
import { Input } from '@/components/ui/input';
import { useI18n } from '@/lib/i18n';
import { useGitProviderDomainsStore, type GitProviderName } from '@/stores/useGitProviderDomainsStore';
/**
* Comma-separated custom-domain input for a git provider. Commits (normalizes,
* dedupes, persists) on blur or Enter; the field reflects the persisted,
* normalized list joined by ', '.
*/
export const CustomDomainsInput: React.FC<{ provider: GitProviderName }> = ({ provider }) => {
const { t } = useI18n();
const domains = useGitProviderDomainsStore((state) => state.domains[provider]);
const setDomains = useGitProviderDomainsStore((state) => state.setDomains);
const [value, setValue] = React.useState(domains.join(', '));
React.useEffect(() => {
setValue(domains.join(', '));
}, [domains]);
const commit = React.useCallback(() => {
setDomains(provider, value.split(',').map((entry) => entry.trim()).filter(Boolean));
}, [provider, setDomains, value]);
return (
<div className="flex min-w-0 flex-col gap-1">
<label htmlFor={`${provider}-custom-domains`} className="typography-settings-field-label text-foreground">
{t(`settings.${provider}.page.customDomains.label`)}
</label>
<Input
id={`${provider}-custom-domains`}
type="text"
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commit();
}
}}
placeholder={t(`settings.${provider}.page.customDomains.placeholder`)}
className="h-9 max-w-[24rem]"
/>
<span className="typography-micro text-muted-foreground">
{t(`settings.${provider}.page.customDomains.description`)}
</span>
</div>
);
};
@@ -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<GiteaTab>('issues');
const [searchQuery, setSearchQuery] = React.useState('');
const [issues, setIssues] = React.useState<GiteaIssueSummary[]>([]);
const [prs, setPrs] = React.useState<GiteaPullRequestSummary[]>([]);
const [loading, setLoading] = React.useState(false);
const [loadingMore, setLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [selectedIssue, setSelectedIssue] = React.useState<GiteaIssueSummary | null>(null);
const [selectedPr, setSelectedPr] = React.useState<GiteaPullRequestSummary | null>(null);
const [includeDiff, setIncludeDiff] = React.useState(false);
const [validations, setValidations] = React.useState<Map<string, ValidationResult>>(new Map());
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(false);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 350);
const loadData = React.useCallback(async (query?: string) => {
if (!projectDirectory || !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 ? (
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-4">
<Icon name="git-pull-request" className="h-12 w-12 text-muted-foreground" />
<div className="text-center">
<p className="typography-ui-label text-foreground">{t('session.giteaIntegration.connect.title')}</p>
<p className="typography-small text-muted-foreground mt-1">
{t('session.giteaIntegration.connect.description')}
</p>
</div>
<Button onClick={openGiteaSettings} size="sm">{t('session.giteaIntegration.connect.action')}</Button>
</div>
) : (
<>
{/* Search */}
<div className="relative mt-2">
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={activeTab === 'issues'
? t('session.giteaIntegration.search.issuesPlaceholder')
: t('session.giteaIntegration.search.prsPlaceholder')}
className="h-8 pl-9"
/>
</div>
{/* List Content */}
<div className="mt-2 h-[300px] overflow-hidden">
<div className="h-full overflow-y-auto">
{/* Loading */}
{loading && (
<div className="flex items-center justify-center h-full">
<Icon name="loader-4" className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{/* Error */}
{error && (
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-2 p-2 rounded-md bg-destructive/10 text-destructive">
<Icon name="error-warning" className="h-4 w-4" />
<span className="typography-small">{error}</span>
</div>
</div>
)}
{/* Issues List */}
{!loading && !error && activeTab === 'issues' && (
<div className="space-y-0.5 min-h-full">
{issues.length > 0 ? (
issues.map(issue => (
<button
key={issue.number}
onClick={() => handleSelectIssue(issue)}
className={cn(
'w-full text-left px-2 py-1.5 rounded transition-colors',
selectedIssue?.number === issue.number
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'hover:bg-interactive-hover'
)}
>
<div className="flex items-start gap-2">
<span className="text-muted-foreground shrink-0 typography-micro">#{issue.number}</span>
<div className="min-w-0 flex-1">
<span className="typography-small line-clamp-2">{issue.title}</span>
</div>
</div>
</button>
))
) : (
<div className="flex items-center justify-center h-[300px] text-center typography-small text-muted-foreground">
{t('session.giteaIntegration.empty.noIssuesFound')}
</div>
)}
{hasMore && !loadingMore && (
<div className="flex justify-center pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => void loadMore()}
className="h-7 text-xs"
>
{t('session.giteaIntegration.actions.loadMore')}
</Button>
</div>
)}
{loadingMore && (
<div className="flex items-center justify-center py-2">
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
{/* PRs List */}
{!loading && !error && activeTab === 'prs' && (
<div className="space-y-0.5 min-h-full">
{prs.length > 0 ? (
prs.map(pr => {
const blocked = isPrBlocked(pr);
const validation = pr.sourceBranch ? validations.get(pr.sourceBranch) : undefined;
return (
<button
key={pr.number}
onClick={() => !blocked && handleSelectPr(pr)}
disabled={blocked}
className={cn(
'w-full text-left px-2 py-1.5 rounded transition-colors',
selectedPr?.number === pr.number
? 'bg-interactive-selection text-interactive-selection-foreground'
: blocked
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-interactive-hover'
)}
>
<div className="flex items-start gap-2">
<span className="text-muted-foreground shrink-0 typography-micro">#{pr.number}</span>
<div className="min-w-0 flex-1">
<span className="typography-small line-clamp-1">{pr.title}</span>
<div className="flex items-center gap-2 mt-0.5">
<span className="typography-micro text-muted-foreground">
{pr.sourceBranch} {pr.targetBranch}
</span>
{pr.draft && (
<span className="typography-micro px-1 py-0.5 rounded bg-status-info/10 text-status-info">
{t('session.giteaIntegration.draftBadge')}
</span>
)}
{blocked && validation?.error && (
<span className="typography-micro text-destructive">
{validation.error}
</span>
)}
</div>
</div>
</div>
</button>
);
})
) : (
<div className="flex items-center justify-center h-[300px] text-center typography-small text-muted-foreground">
{t('session.giteaIntegration.empty.noPullRequestsFound')}
</div>
)}
{hasMore && !loadingMore && (
<div className="flex justify-center pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => void loadMore()}
className="h-7 text-xs"
>
{t('session.giteaIntegration.actions.loadMore')}
</Button>
</div>
)}
{loadingMore && (
<div className="flex items-center justify-center py-2">
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
</div>
</>
)}
</>
);
// Footer content
const footerContent = (
<div className={cn(
'w-full',
isMobile ? 'flex flex-col gap-2' : 'flex flex-row items-center'
)}>
{/* Left side: Selected Item / Checkbox */}
<div className={cn(
'flex items-center gap-4',
isMobile ? 'w-full justify-center order-1' : 'flex-1'
)}>
{/* Selected Issue/PR display - hidden on mobile (shown in header instead) */}
{!isMobile && (selectedIssue || selectedPr) && (
<div className="flex items-center gap-2 px-2 h-8 rounded-md bg-muted/50 border border-border/50">
<Icon name="check" className="h-3.5 w-3.5 text-status-success shrink-0" />
<span className="typography-small truncate max-w-[150px]">
{selectedIssue
? t('session.giteaIntegration.selected.issueNumber', { number: selectedIssue.number })
: t('session.giteaIntegration.selected.prNumber', { number: selectedPr?.number ?? '' })}
</span>
<button
onClick={handleClear}
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
>
<Icon name="close" className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Include Diff Checkbox - only show when PR tab is active and PR is selected */}
{activeTab === 'prs' && selectedPr && (
<label className="flex items-center gap-2 cursor-pointer h-8">
<Checkbox
checked={includeDiff}
onChange={(checked) => setIncludeDiff(checked)}
ariaLabel={t('session.giteaIntegration.includeDiffAria')}
/>
<span className="typography-small text-foreground">
{t('session.giteaIntegration.includeDiff')}
</span>
</label>
)}
</div>
{/* Right side: Buttons */}
<div className={cn(
'flex gap-2',
isMobile ? 'w-full order-2' : 'justify-end'
)}>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
className={cn(isMobile && 'flex-1')}
>
{t('session.giteaIntegration.actions.cancel')}
</Button>
<Button
size="sm"
onClick={handleConfirm}
disabled={!canConfirm}
className={cn(isMobile && 'flex-1')}
>
{t('session.giteaIntegration.actions.select')}
</Button>
</div>
</div>
);
return (
<>
{isMobile ? (
<MobileOverlayPanel
open={open}
title={t('session.giteaIntegration.title')}
onClose={() => onOpenChange(false)}
footer={!isGiteaConnected ? undefined : footerContent}
renderHeader={(closeButton) => (
<div className="flex flex-col gap-2 px-3 py-2 border-b border-border/40">
<div className="flex items-center justify-between">
<h2 className="typography-ui-label font-semibold text-foreground">{t('session.giteaIntegration.title')}</h2>
{closeButton}
</div>
{/* Tabs - using SortableTabsStrip */}
<div className="w-full">
<SortableTabsStrip
items={[
{ id: 'issues', label: t('session.giteaIntegration.tabs.issues'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
{ id: 'prs', label: t('session.giteaIntegration.tabs.pullRequests'), icon: <Icon name="git-pull-request" className="h-3.5 w-3.5" /> },
]}
activeId={activeTab}
onSelect={(id) => {
setActiveTab(id as GiteaTab);
setSearchQuery('');
}}
variant="active-pill"
layoutMode="fit"
/>
</div>
{/* Selected Item Inline Display */}
{(selectedIssue || selectedPr) && (
<div className="flex items-center gap-2 px-2 py-1 rounded-md bg-muted/50 border border-border/50">
<Icon name="check" className="h-3.5 w-3.5 text-status-success shrink-0" />
<span className="typography-small truncate flex-1">
{selectedIssue
? t('session.giteaIntegration.selected.issueNumber', { number: selectedIssue.number })
: t('session.giteaIntegration.selected.prNumber', { number: selectedPr?.number ?? '' })}
</span>
<button
onClick={handleClear}
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
>
<Icon name="close" className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
)}
>
{dialogContent}
</MobileOverlayPanel>
) : (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<DialogTitle className="flex items-center gap-2 shrink-0">
<Icon name="git-pull-request" className="h-5 w-5" />
{t('session.giteaIntegration.title')}
</DialogTitle>
{/* Tabs - using SortableTabsStrip */}
<div className="w-[220px]">
<SortableTabsStrip
items={[
{ id: 'issues', label: t('session.giteaIntegration.tabs.issues'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
{ id: 'prs', label: t('session.giteaIntegration.tabs.pullRequests'), icon: <Icon name="git-pull-request" className="h-3.5 w-3.5" /> },
]}
activeId={activeTab}
onSelect={(id) => {
setActiveTab(id as GiteaTab);
setSearchQuery('');
}}
variant="active-pill"
layoutMode="fit"
/>
</div>
</div>
</DialogHeader>
{dialogContent}
{/* Footer */}
<DialogFooter className="mt-1">
{footerContent}
</DialogFooter>
</DialogContent>
</Dialog>
)}
</>
);
}
@@ -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<GiteaIssuesListResult | null>(null);
const [issues, setIssues] = React.useState<GiteaIssueSummary[]>([]);
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(false);
const [startingIssueNumber, setStartingIssueNumber] = React.useState<number | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(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<string, unknown>) => (m as { id?: string }).id === modelID) as
| { variants?: Record<string, unknown> }
| 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 = (
<>
<div className="relative mt-2">
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('session.giteaIssuePicker.searchPlaceholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 w-full"
/>
</div>
<div className={cn(isMobile ? 'min-h-0 mt-2' : 'flex-1 overflow-y-auto mt-2')}>
{!projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{t('session.giteaIssuePicker.empty.noActiveProject')}</div>
) : null}
{!gitea ? (
<div className="text-center text-muted-foreground py-8">{t('session.giteaIssuePicker.empty.runtimeUnavailable')}</div>
) : null}
{isLoading ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
{t('session.giteaIssuePicker.loading.issues')}
</div>
) : null}
{connected === false ? (
<div className="text-center text-muted-foreground py-8 space-y-3">
<div>{t('session.giteaIssuePicker.empty.notConnected')}</div>
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={openGiteaSettings}>
{t('session.giteaIssuePicker.actions.openSettings')}
</Button>
</div>
</div>
) : null}
{error ? (
<div className="text-center text-muted-foreground py-8 break-words">{error}</div>
) : null}
{directNumber && projectDirectory && gitea && connected ? (
<div
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
startingIssueNumber === directNumber && 'bg-interactive-selection/30'
)}
onClick={() => void startSession(directNumber)}
>
<span className="typography-meta text-muted-foreground w-5 text-right flex-shrink-0">#</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
{t('session.giteaIssuePicker.actions.useIssue', { number: directNumber })}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingIssueNumber === directNumber ? (
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
) : null}
</div>
</div>
) : null}
{issues.length === 0 && !isLoading && connected && gitea && projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{debouncedQuery.trim() ? t('session.giteaIssuePicker.empty.noIssuesFound') : t('session.giteaIssuePicker.empty.noOpenIssuesFound')}</div>
) : null}
{issues.map((issue) => (
<div
key={issue.number}
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
startingIssueNumber === issue.number && 'bg-interactive-selection/30'
)}
onClick={() => void startSession(issue.number)}
>
<span className="typography-meta text-muted-foreground w-12 text-right flex-shrink-0">
#{issue.number}
</span>
<div className="flex-1 min-w-0 ml-0.5">
<p className="typography-small text-foreground truncate">
{issue.title}
</p>
</div>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingIssueNumber === issue.number ? (
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<a
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors",
alwaysShowActions ? "flex" : "hidden group-hover:flex"
)}
onClick={(e) => e.stopPropagation()}
aria-label={t('session.giteaIssuePicker.actions.openInGiteaAria')}
>
<Icon name="external-link" className="h-4 w-4" />
</a>
)}
</div>
</div>
))}
{hasMore && connected && projectDirectory && gitea ? (
<div className="py-2 flex justify-center">
<button
type="button"
onClick={() => void loadMore()}
disabled={isLoadingMore || Boolean(startingIssueNumber)}
className={cn(
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
(isLoadingMore || Boolean(startingIssueNumber)) && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
)}
>
{isLoadingMore ? (
<span className="inline-flex items-center gap-2">
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
{t('session.giteaIssuePicker.loading.more')}
</span>
) : (
t('session.giteaIssuePicker.actions.loadMore')
)}
</button>
</div>
) : null}
</div>
{mode !== 'select' && (
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('session.giteaIssuePicker.actions.sectionTitle')}</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-2">
<div
className="flex items-center gap-2 cursor-pointer"
role="button"
tabIndex={0}
aria-pressed={createInWorktree}
onClick={() => setCreateInWorktree((v) => !v)}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
setCreateInWorktree((v) => !v);
}
}}
>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCreateInWorktree((v) => !v);
}}
aria-label={t('session.giteaIssuePicker.actions.toggleWorktreeAria')}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{createInWorktree ? (
<Icon name="checkbox" className="h-4 w-4 text-primary" />
) : (
<Icon name="checkbox-blank" className="h-4 w-4" />
)}
</button>
<span className="typography-meta text-muted-foreground">{t('session.giteaIssuePicker.actions.createInWorktree')}</span>
<span className="typography-meta text-muted-foreground/70 hidden sm:inline">(issue-&lt;number&gt;-&lt;slug&gt;)</span>
</div>
<div className="hidden sm:block sm:flex-1" />
<div className="flex items-center gap-2">
{repoUrl ? (
<Button variant="outline" size="sm" asChild>
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
<Icon name="external-link" className="size-4" />
{t('session.giteaIssuePicker.actions.openRepo')}
</a>
</Button>
) : null}
<Button variant="outline" size="sm" onClick={refresh} disabled={isLoading || Boolean(startingIssueNumber)}>
{t('session.giteaIssuePicker.actions.refresh')}
</Button>
</div>
</div>
</div>
)}
</>
);
if (isMobile) {
return (
<MobileOverlayPanel
open={open}
title={title}
onClose={() => onOpenChange(false)}
renderHeader={(closeButton) => (
<div className="flex flex-col gap-1.5 px-3 py-2 border-b border-border/40">
<div className="flex items-center justify-between">
<h2 className="typography-ui-label font-semibold text-foreground">{title}</h2>
{closeButton}
</div>
<p className="typography-small text-muted-foreground">{description}</p>
</div>
)}
>
{content}
</MobileOverlayPanel>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<Icon name="git-branch" className="h-5 w-5" />
{title}
</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
}
@@ -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<GiteaPullRequestsListResult | null>(null);
const [prs, setPrs] = React.useState<GiteaPullRequestSummary[]>([]);
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(false);
const [loadingPrNumber, setLoadingPrNumber] = React.useState<number | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(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 = (
<>
<div className="mt-2 flex items-center gap-3">
<div className="relative flex-1 min-w-0">
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('session.giteaPrPicker.searchPlaceholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 w-full"
/>
</div>
<button
type="button"
onClick={() => setIncludeDiff((prev) => !prev)}
className="h-9 shrink-0 flex items-center gap-2 text-left"
aria-pressed={includeDiff}
aria-label={t('session.giteaPrPicker.includeDiffAria')}
>
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={includeDiff}
onChange={(checked) => setIncludeDiff(checked)}
ariaLabel={t('session.giteaPrPicker.includeDiffAria')}
/>
</span>
<span className="typography-small text-muted-foreground whitespace-nowrap">{t('session.giteaPrPicker.includeDiff')}</span>
</button>
</div>
<div className={cn(isMobile ? 'min-h-0' : 'flex-1 overflow-y-auto')}>
{!projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{t('session.giteaPrPicker.empty.noActiveProject')}</div>
) : null}
{!gitea ? (
<div className="text-center text-muted-foreground py-8">{t('session.giteaPrPicker.empty.runtimeUnavailable')}</div>
) : null}
{isLoading ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
{t('session.giteaPrPicker.loading.pullRequests')}
</div>
) : null}
{connected === false ? (
<div className="text-center text-muted-foreground py-8 space-y-3">
<div>{t('session.giteaPrPicker.empty.notConnected')}</div>
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={openGiteaSettings}>
{t('session.giteaPrPicker.actions.openSettings')}
</Button>
</div>
</div>
) : null}
{error ? (
<div className="text-center text-muted-foreground py-8 break-words">{error}</div>
) : null}
{directNumber && projectDirectory && gitea && connected ? (
<div
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
loadingPrNumber === directNumber && 'bg-interactive-selection/30'
)}
onClick={() => void attachPr(directNumber)}
>
<span className="typography-meta text-muted-foreground w-5 text-right flex-shrink-0">#</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
{t('session.giteaPrPicker.actions.usePullRequest', { number: directNumber })}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{loadingPrNumber === directNumber ? (
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
) : null}
</div>
</div>
) : null}
{prs.length === 0 && !isLoading && connected && gitea && projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{debouncedQuery.trim() ? t('session.giteaPrPicker.empty.noPullRequestsFound') : t('session.giteaPrPicker.empty.noOpenPullRequestsFound')}</div>
) : null}
{prs.map((pr) => (
<div
key={pr.number}
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
loadingPrNumber === pr.number && 'bg-interactive-selection/30'
)}
onClick={() => void attachPr(pr.number)}
>
<div className="flex-1 min-w-0 ml-0.5">
<p className="typography-small text-foreground truncate">
<span className="text-muted-foreground mr-1">#{pr.number}</span>
{pr.title}
</p>
<p className="typography-meta text-muted-foreground truncate">{pr.sourceBranch} {pr.targetBranch}</p>
</div>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{loadingPrNumber === pr.number ? (
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors",
alwaysShowActions ? "flex" : "hidden group-hover:flex"
)}
onClick={(e) => e.stopPropagation()}
aria-label={t('session.giteaPrPicker.actions.openInGiteaAria')}
>
<Icon name="external-link" className="h-4 w-4" />
</a>
)}
</div>
</div>
))}
{hasMore && connected && projectDirectory && gitea ? (
<div className="py-2 flex justify-center">
<button
type="button"
onClick={() => void loadMore()}
disabled={isLoadingMore || Boolean(loadingPrNumber)}
className={cn(
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
(isLoadingMore || Boolean(loadingPrNumber)) && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
)}
>
{isLoadingMore ? (
<span className="inline-flex items-center gap-2">
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
{t('session.giteaPrPicker.loading.more')}
</span>
) : (
t('session.giteaPrPicker.actions.loadMore')
)}
</button>
</div>
) : null}
</div>
</>
);
if (isMobile) {
return (
<MobileOverlayPanel
open={open}
title={title}
onClose={() => onOpenChange(false)}
renderHeader={(closeButton) => (
<div className="flex flex-col gap-1.5 px-3 py-2 border-b border-border/40">
<div className="flex items-center justify-between">
<h2 className="typography-ui-label font-semibold text-foreground">{title}</h2>
{closeButton}
</div>
<p className="typography-small text-muted-foreground">{description}</p>
</div>
)}
>
{content}
</MobileOverlayPanel>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<Icon name="git-pull-request" className="h-5 w-5" />
{title}
</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
}
@@ -28,6 +28,7 @@ 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 { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
@@ -53,6 +54,7 @@ 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 { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Icon } from "@/components/icon/Icon";
@@ -66,6 +68,10 @@ import type {
GitLabIssueComment,
GitLabIssuesListResult,
GitLabMergeRequestContextResult,
GiteaComment,
GiteaIssue,
GiteaIssuesListResult,
GiteaPullRequestContextResult,
} from '@/lib/api/types';
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
import { useI18n } from '@/lib/i18n';
@@ -91,6 +97,9 @@ interface NewBranchState {
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
@@ -232,19 +241,39 @@ 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)}`;
};
export function NewWorktreeDialog({
open,
onOpenChange,
onWorktreeCreated,
}: NewWorktreeDialogProps) {
const { t } = useI18n();
const { github, git, gitlab } = useRuntimeAPIs();
const { github, git, gitlab, gitea } = 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 activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null;
@@ -270,6 +299,9 @@ export function NewWorktreeDialog({
linkedGitLabIssue: null,
linkedGitLabMr: null,
includeGitLabMrDiff: false,
linkedGiteaIssue: null,
linkedGiteaPr: null,
includeGiteaPrDiff: false,
});
const [existingBranchState, setExistingBranchState] = React.useState<ExistingBranchState>({
@@ -320,6 +352,7 @@ export function NewWorktreeDialog({
const [githubDialogOpen, setGithubDialogOpen] = React.useState(false);
const [gitlabDialogOpen, setGitlabDialogOpen] = React.useState(false);
const [giteaDialogOpen, setGiteaDialogOpen] = 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
@@ -329,6 +362,13 @@ export function NewWorktreeDialog({
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);
@@ -523,6 +563,9 @@ export function NewWorktreeDialog({
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;
@@ -771,9 +814,126 @@ export function NewWorktreeDialog({
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,
projectDirectory,
resolveDefaultAgentName,
resolveDefaultModelSelection,
@@ -865,6 +1025,9 @@ export function NewWorktreeDialog({
linkedGitLabIssue: null,
linkedGitLabMr: null,
includeGitLabMrDiff: false,
linkedGiteaIssue: null,
linkedGiteaPr: null,
includeGiteaPrDiff: false,
});
}, [open, generateUniqueSlug]);
@@ -914,11 +1077,13 @@ export function NewWorktreeDialog({
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 || gitLabMrBranch ? 'existing' : 'new',
mode: mode === 'existing-branch' || prConfig || gitLabMrBranch || giteaPrBranch ? 'existing' : 'new',
branchName: normalizedBranch,
worktreeName: normalizedWorktree,
existingBranch: prConfig?.existingBranch ?? (gitLabMrBranch || (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 } : {}),
});
@@ -961,6 +1126,7 @@ export function NewWorktreeDialog({
newBranchState.branchName,
newBranchState.linkedPr,
newBranchState.linkedGitLabMr,
newBranchState.linkedGiteaPr,
existingBranchState.selectedBranch,
currentState.worktreeName,
localBranches,
@@ -1032,7 +1198,10 @@ export function NewWorktreeDialog({
const linkedGitLabIssue = mode === 'new-branch' ? newBranchState.linkedGitLabIssue : null;
const linkedGitLabMr = mode === 'new-branch' ? newBranchState.linkedGitLabMr : null;
const includeGitLabMrDiff = mode === 'new-branch' ? newBranchState.includeGitLabMrDiff : false;
const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedGitLabIssue || linkedGitLabMr);
const 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 || linkedGitLabIssue || linkedGitLabMr || linkedGiteaIssue || linkedGiteaPr);
const setupCommands = await getWorktreeSetupCommands(projectRef);
const sourceBranch = newBranchState.sourceBranch;
@@ -1075,6 +1244,23 @@ export function NewWorktreeDialog({
};
}
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,
@@ -1107,7 +1293,11 @@ export function NewWorktreeDialog({
? `#${linkedGitLabIssue.number} ${linkedGitLabIssue.title}`.trim()
: linkedGitLabMr
? `!${linkedGitLabMr.number} ${linkedGitLabMr.title}`.trim()
: t('session.newWorktree.newSessionTitle');
: 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) {
@@ -1159,7 +1349,12 @@ export function NewWorktreeDialog({
gitLabIssue: linkedGitLabIssue,
gitLabMr: linkedGitLabMr,
includeGitLabMrDiff: includeGitLabMrDiff,
giteaIssue: linkedGiteaIssue,
giteaPr: linkedGiteaPr,
includeGiteaPrDiff: includeGiteaPrDiff,
}).catch((error) => {
// 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 errorKey = isGitLabLink
? 'session.newWorktree.error.sendGitLabContextFailed'
@@ -1199,6 +1394,9 @@ export function NewWorktreeDialog({
linkedGitLabIssue: null,
linkedGitLabMr: null,
includeGitLabMrDiff: false,
linkedGiteaIssue: null,
linkedGiteaPr: null,
includeGiteaPrDiff: false,
branchName: '',
}));
return;
@@ -1215,6 +1413,9 @@ export function NewWorktreeDialog({
linkedGitLabIssue: null,
linkedGitLabMr: null,
includeGitLabMrDiff: false,
linkedGiteaIssue: null,
linkedGiteaPr: null,
includeGiteaPrDiff: false,
branchName: newBranchName,
worktreeName: slugifyWorktreeName(newBranchName),
isSyncingWorktreeName: true,
@@ -1229,6 +1430,9 @@ export function NewWorktreeDialog({
linkedGitLabIssue: null,
linkedGitLabMr: null,
includeGitLabMrDiff: false,
linkedGiteaIssue: null,
linkedGiteaPr: null,
includeGiteaPrDiff: false,
branchName: pr.head,
worktreeName: slugifyWorktreeName(pr.head),
isSyncingWorktreeName: true,
@@ -1259,6 +1463,9 @@ export function NewWorktreeDialog({
linkedIssue: null,
linkedPr: null,
includePrDiff: false,
linkedGiteaIssue: null,
linkedGiteaPr: null,
includeGiteaPrDiff: false,
branchName: '',
}));
return;
@@ -1278,6 +1485,9 @@ export function NewWorktreeDialog({
linkedIssue: null,
linkedPr: null,
includePrDiff: false,
linkedGiteaIssue: null,
linkedGiteaPr: null,
includeGiteaPrDiff: false,
branchName: newBranchName,
worktreeName: slugifyWorktreeName(newBranchName),
isSyncingWorktreeName: true,
@@ -1296,6 +1506,85 @@ export function NewWorktreeDialog({
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,
@@ -1307,6 +1596,8 @@ export function NewWorktreeDialog({
const isGitHubConnected = githubAuthChecked && githubAuthStatus?.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
@@ -1314,6 +1605,7 @@ export function NewWorktreeDialog({
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'
@@ -1329,9 +1621,12 @@ export function NewWorktreeDialog({
linkedPr: null,
linkedGitLabIssue: null,
linkedGitLabMr: null,
linkedGiteaIssue: null,
linkedGiteaPr: null,
branchName: '',
includePrDiff: false,
includeGitLabMrDiff: false,
includeGiteaPrDiff: false,
isSyncingWorktreeName: true,
}));
};
@@ -1565,7 +1860,7 @@ export function NewWorktreeDialog({
<label className="typography-ui-label text-foreground block font-semibold">
{t('session.newWorktree.branchName')}
</label>
{mode === 'new-branch' && (showGitHubStartFrom || showGitLabStartFrom) && (
{mode === 'new-branch' && (showGitHubStartFrom || showGitLabStartFrom || showGiteaStartFrom) && (
<div className="flex items-center gap-2 flex-wrap">
{showGitHubStartFrom && (
<Button
@@ -1589,6 +1884,17 @@ export function NewWorktreeDialog({
{newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitLabIssueMr')}
</Button>
)}
{showGiteaStartFrom && (
<Button
variant="outline"
size="sm"
onClick={() => setGiteaDialogOpen(true)}
className="gap-1.5 h-7"
>
<Icon name="git-pull-request" className="size-4 text-status-success" />
{newBranchState.linkedGiteaIssue || newBranchState.linkedGiteaPr ? t('session.newWorktree.actions.change') : t('session.giteaIntegration.title')}
</Button>
)}
</div>
)}
</div>
@@ -1603,15 +1909,17 @@ export function NewWorktreeDialog({
linkedPr: null,
linkedGitLabIssue: null,
linkedGitLabMr: null,
linkedGiteaIssue: null,
linkedGiteaPr: null,
}));
}}
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
placeholder={t('session.newWorktree.branchNamePlaceholder')}
disabled={!!newBranchState.linkedPr || !!newBranchState.linkedGitLabMr}
disabled={!!newBranchState.linkedPr || !!newBranchState.linkedGitLabMr || !!newBranchState.linkedGiteaPr}
className={cn(
'h-8',
validation.touched && validation.branchError && 'border-destructive',
(newBranchState.linkedPr || newBranchState.linkedGitLabMr) && 'bg-muted text-muted-foreground'
(newBranchState.linkedPr || newBranchState.linkedGitLabMr || newBranchState.linkedGiteaPr) && 'bg-muted text-muted-foreground'
)}
/>
{newBranchState.linkedPr && (
@@ -1630,6 +1938,14 @@ export function NewWorktreeDialog({
</span>
</div>
)}
{newBranchState.linkedGiteaPr && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
{t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedGiteaPr.sourceBranch })}
</span>
</div>
)}
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
@@ -1646,6 +1962,14 @@ export function NewWorktreeDialog({
</span>
</div>
)}
{newBranchState.linkedGiteaIssue && !newBranchState.linkedGiteaPr && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
{t('session.newWorktree.fromIssue', { number: newBranchState.linkedGiteaIssue.number, title: newBranchState.linkedGiteaIssue.title })}
</span>
</div>
)}
</div>
)}
@@ -1705,7 +2029,7 @@ export function NewWorktreeDialog({
</div>
{/* Source Branch - Only for New Branch mode, hide when a linked PR/MR is selected */}
{mode === 'new-branch' && !newBranchState.linkedPr && !newBranchState.linkedGitLabMr && (
{mode === 'new-branch' && !newBranchState.linkedPr && !newBranchState.linkedGitLabMr && !newBranchState.linkedGiteaPr && (
<div className="space-y-1.5">
<label className="typography-ui-label text-foreground block font-semibold">
{t('session.newWorktree.sourceBranch')}
@@ -1844,14 +2168,16 @@ export function NewWorktreeDialog({
)}
{/* Linked Item Preview - Two row minimal display */}
{(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr) && mode === 'new-branch' && (
{(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr || newBranchState.linkedGiteaIssue || newBranchState.linkedGiteaPr) && mode === 'new-branch' && (
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
{/* Row 1: Type, number, title, actions */}
<div className="flex items-center gap-2">
{newBranchState.linkedIssue || newBranchState.linkedPr ? (
<Icon name="github" className="h-3.5 w-3.5 text-status-success shrink-0" />
) : (
) : newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr ? (
<Icon name="gitlab" className="h-3.5 w-3.5 text-status-success shrink-0" />
) : (
<Icon name="git-pull-request" className="h-3.5 w-3.5 text-status-success shrink-0" />
)}
{newBranchState.linkedIssue && (
@@ -1874,13 +2200,23 @@ export function NewWorktreeDialog({
{t('session.newWorktree.mrNumber', { number: newBranchState.linkedGitLabMr.number })}
</span>
)}
{newBranchState.linkedGiteaIssue && (
<span className="typography-micro text-muted-foreground shrink-0">
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedGiteaIssue.number })}
</span>
)}
{newBranchState.linkedGiteaPr && (
<span className="typography-micro text-muted-foreground shrink-0">
{t('session.newWorktree.prNumber', { number: newBranchState.linkedGiteaPr.number })}
</span>
)}
<span className="typography-micro text-foreground truncate flex-1">
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title || newBranchState.linkedGitLabIssue?.title || newBranchState.linkedGitLabMr?.title}
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title || newBranchState.linkedGitLabIssue?.title || newBranchState.linkedGitLabMr?.title || newBranchState.linkedGiteaIssue?.title || newBranchState.linkedGiteaPr?.title}
</span>
<a
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url || newBranchState.linkedGitLabIssue?.url || newBranchState.linkedGitLabMr?.url}
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url || newBranchState.linkedGitLabIssue?.url || newBranchState.linkedGitLabMr?.url || newBranchState.linkedGiteaIssue?.url || newBranchState.linkedGiteaPr?.url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground shrink-0"
@@ -1922,6 +2258,18 @@ export function NewWorktreeDialog({
)}
</div>
)}
{newBranchState.linkedGiteaPr && (
<div className="flex items-center gap-2 mt-0.5 pl-5">
<span className="typography-micro text-muted-foreground">
{newBranchState.linkedGiteaPr.sourceBranch}
</span>
{newBranchState.includeGiteaPrDiff && (
<span className="typography-micro px-1 py-0.5 rounded bg-status-success/10 text-status-success">
{t('session.newWorktree.includeDiffBadge')}
</span>
)}
</div>
)}
</div>
)}
</div>
@@ -2093,7 +2441,7 @@ export function NewWorktreeDialog({
<label className="typography-ui-label text-foreground block font-semibold">
{t('session.newWorktree.branchName')}
</label>
{mode === 'new-branch' && (showGitHubStartFrom || showGitLabStartFrom) && (
{mode === 'new-branch' && (showGitHubStartFrom || showGitLabStartFrom || showGiteaStartFrom) && (
<div className="flex items-center gap-2 flex-wrap">
{showGitHubStartFrom && (
<Button
@@ -2117,6 +2465,17 @@ export function NewWorktreeDialog({
{newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitLabIssueMr')}
</Button>
)}
{showGiteaStartFrom && (
<Button
variant="outline"
size="sm"
onClick={() => setGiteaDialogOpen(true)}
className="gap-1.5 h-7"
>
<Icon name="git-pull-request" className="size-4 text-status-success" />
{newBranchState.linkedGiteaIssue || newBranchState.linkedGiteaPr ? t('session.newWorktree.actions.change') : t('session.giteaIntegration.title')}
</Button>
)}
</div>
)}
</div>
@@ -2131,15 +2490,17 @@ export function NewWorktreeDialog({
linkedPr: null,
linkedGitLabIssue: null,
linkedGitLabMr: null,
linkedGiteaIssue: null,
linkedGiteaPr: null,
}));
}}
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
placeholder={t('session.newWorktree.branchNamePlaceholder')}
disabled={!!newBranchState.linkedPr || !!newBranchState.linkedGitLabMr}
disabled={!!newBranchState.linkedPr || !!newBranchState.linkedGitLabMr || !!newBranchState.linkedGiteaPr}
className={cn(
'h-8',
validation.touched && validation.branchError && 'border-destructive',
(newBranchState.linkedPr || newBranchState.linkedGitLabMr) && 'bg-muted text-muted-foreground'
(newBranchState.linkedPr || newBranchState.linkedGitLabMr || newBranchState.linkedGiteaPr) && 'bg-muted text-muted-foreground'
)}
/>
{newBranchState.linkedPr && (
@@ -2158,6 +2519,14 @@ export function NewWorktreeDialog({
</span>
</div>
)}
{newBranchState.linkedGiteaPr && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
{t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedGiteaPr.sourceBranch })}
</span>
</div>
)}
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
@@ -2174,6 +2543,14 @@ export function NewWorktreeDialog({
</span>
</div>
)}
{newBranchState.linkedGiteaIssue && !newBranchState.linkedGiteaPr && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
{t('session.newWorktree.fromIssue', { number: newBranchState.linkedGiteaIssue.number, title: newBranchState.linkedGiteaIssue.title })}
</span>
</div>
)}
</div>
)}
@@ -2233,7 +2610,7 @@ export function NewWorktreeDialog({
</div>
{/* Source Branch - Only for New Branch mode, hide when a linked PR/MR is selected */}
{mode === 'new-branch' && !newBranchState.linkedPr && !newBranchState.linkedGitLabMr && (
{mode === 'new-branch' && !newBranchState.linkedPr && !newBranchState.linkedGitLabMr && !newBranchState.linkedGiteaPr && (
<div className="space-y-1.5">
<label className="typography-ui-label text-foreground block font-semibold">
{t('session.newWorktree.sourceBranch')}
@@ -2346,14 +2723,16 @@ export function NewWorktreeDialog({
)}
{/* Linked Item Preview - Two row minimal display */}
{(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr) && mode === 'new-branch' && (
{(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr || newBranchState.linkedGiteaIssue || newBranchState.linkedGiteaPr) && mode === 'new-branch' && (
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
{/* Row 1: Type, number, title, actions */}
<div className="flex items-center gap-2">
{newBranchState.linkedIssue || newBranchState.linkedPr ? (
<Icon name="github" className="h-3.5 w-3.5 text-status-success shrink-0" />
) : (
) : newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr ? (
<Icon name="gitlab" className="h-3.5 w-3.5 text-status-success shrink-0" />
) : (
<Icon name="git-pull-request" className="h-3.5 w-3.5 text-status-success shrink-0" />
)}
{newBranchState.linkedIssue && (
@@ -2376,13 +2755,23 @@ export function NewWorktreeDialog({
{t('session.newWorktree.mrNumber', { number: newBranchState.linkedGitLabMr.number })}
</span>
)}
{newBranchState.linkedGiteaIssue && (
<span className="typography-micro text-muted-foreground shrink-0">
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedGiteaIssue.number })}
</span>
)}
{newBranchState.linkedGiteaPr && (
<span className="typography-micro text-muted-foreground shrink-0">
{t('session.newWorktree.prNumber', { number: newBranchState.linkedGiteaPr.number })}
</span>
)}
<span className="typography-micro text-foreground truncate flex-1">
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title || newBranchState.linkedGitLabIssue?.title || newBranchState.linkedGitLabMr?.title}
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title || newBranchState.linkedGitLabIssue?.title || newBranchState.linkedGitLabMr?.title || newBranchState.linkedGiteaIssue?.title || newBranchState.linkedGiteaPr?.title}
</span>
<a
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url || newBranchState.linkedGitLabIssue?.url || newBranchState.linkedGitLabMr?.url}
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url || newBranchState.linkedGitLabIssue?.url || newBranchState.linkedGitLabMr?.url || newBranchState.linkedGiteaIssue?.url || newBranchState.linkedGiteaPr?.url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground shrink-0"
@@ -2424,6 +2813,18 @@ export function NewWorktreeDialog({
)}
</div>
)}
{newBranchState.linkedGiteaPr && (
<div className="flex items-center gap-2 mt-0.5 pl-5">
<span className="typography-micro text-muted-foreground">
{newBranchState.linkedGiteaPr.sourceBranch}
</span>
{newBranchState.includeGiteaPrDiff && (
<span className="typography-micro px-1 py-0.5 rounded bg-status-success/10 text-status-success">
{t('session.newWorktree.includeDiffBadge')}
</span>
)}
</div>
)}
</div>
)}
</div>
@@ -2477,6 +2878,12 @@ export function NewWorktreeDialog({
onOpenChange={setGitlabDialogOpen}
onSelect={handleGitLabSelect}
/>
<GiteaIntegrationDialog
open={giteaDialogOpen}
onOpenChange={setGiteaDialogOpen}
onSelect={handleGiteaSelect}
/>
</>
);
}
@@ -60,6 +60,7 @@ import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIn
import { deriveBaseBranch } from './git/baseBranch';
import { getFreshestPrStatusForBranch, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { useGitLabMrForBranch } from '@/lib/gitlabMrStatus';
import { useGiteaPrForBranch } from '@/lib/giteaPrStatus';
import { createGitIndexMutationQueue, type GitIndexMutationDirection, type GitIndexMutationQueue } from './git/gitIndexMutationQueue';
import type { GitRemote } from '@/lib/gitApi';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
@@ -306,6 +307,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
const prStatusBranch = status?.current ?? null;
const { mr: gitLabMr } = useGitLabMrForBranch(currentDirectory, prStatusBranch);
const { pr: giteaPr } = useGiteaPrForBranch(currentDirectory, prStatusBranch);
const prChipStatus = useGitHubPrStatusStore((state) => {
if (!currentDirectory || !prStatusBranch) {
return null;
@@ -2367,6 +2369,10 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
onOpenGitLabMr={
currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined
}
giteaPr={giteaPr}
onOpenGiteaPr={
currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined
}
/>
{/* In-progress operation banner */}
@@ -0,0 +1,908 @@
import React from 'react';
import { useShallow } from 'zustand/react/shallow';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { GiteaIssuesSection } from '@/components/views/git/GiteaIssuesSection';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useGitStatus, useGitStore } from '@/stores/useGitStore';
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
import { useUIStore } from '@/stores/useUIStore';
import { openExternalUrl } from '@/lib/url';
import { formatDateTimeForPreference } from '@/lib/timeFormat';
import type { GiteaPullRequestContextResult, GiteaPullRequestSummary } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { toast } from '@/components/ui';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
const prStateColor = (state: string): string => {
switch (state) {
case 'merged':
return 'var(--pr-merged)';
case 'closed':
return 'var(--pr-closed)';
default:
return 'var(--pr-open)';
}
};
const prAuthorLabel = (pr: GiteaPullRequestSummary): string => pr.author?.username || '';
const draftBadgeClass =
'inline-flex items-center rounded border border-border/60 bg-surface-elevated px-1.5 py-px typography-micro text-foreground';
/**
* Read-only Gitea pull request surface for the context panel. Resolves the
* same repository context GitView uses (effective directory + current branch
* from the shared git stores) and renders the branch's pull request plus the
* repository's open pull requests. Create, update, and merge actions are
* offered for the current-branch PR.
*/
export const GiteaPrView: React.FC = () => {
const { t } = useI18n();
const { git, gitea } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory();
const status = useGitStatus(currentDirectory ?? null);
const { ensureAll } = useGitStore(useShallow((state) => ({ ensureAll: state.ensureAll })));
const giteaAuthStatus = useGiteaAuthStore((state) => state.status);
const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked);
const refreshGiteaStatus = useGiteaAuthStore((state) => state.refreshStatus);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
React.useEffect(() => {
if (!currentDirectory || !git) {
return;
}
void ensureAll(currentDirectory, git);
}, [currentDirectory, ensureAll, git]);
// Settle the connection state exactly once; the store dedupes in-flight
// refreshes so remounts never pile up status requests.
React.useEffect(() => {
if (giteaAuthChecked) {
return;
}
void refreshGiteaStatus(gitea);
}, [gitea, giteaAuthChecked, refreshGiteaStatus]);
const currentBranch = status?.current ?? null;
const connected = giteaAuthChecked ? giteaAuthStatus?.connected === true : null;
const openGiteaSettings = React.useCallback(() => {
setSettingsPage('git');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]);
// Local tab selection between the pull-request and issues surfaces. Not
// persisted: reopening the panel always lands on pull requests.
const [activeTab, setActiveTab] = React.useState<'pr' | 'issues'>('pr');
// ---- Current-branch pull request --------------------------------------
const [branchPr, setBranchPr] = React.useState<GiteaPullRequestSummary | null>(null);
const [branchPrLoading, setBranchPrLoading] = React.useState(false);
const [branchPrError, setBranchPrError] = React.useState<string | null>(null);
const [retryToken, setRetryToken] = React.useState(0);
const [repoRef, setRepoRef] = React.useState<{ owner: string; repo: string; url?: string } | null>(null);
const retry = React.useCallback(() => setRetryToken((value) => value + 1), []);
React.useEffect(() => {
if (!currentDirectory || !currentBranch || !connected || !gitea?.prsList) {
return;
}
let cancelled = false;
setBranchPrLoading(true);
setBranchPrError(null);
// Re-resolving the repo context invalidates the previously fetched branch
// list so a stale repo's branches never leak into the create form.
setRepoRef(null);
setBranches([]);
setDefaultBranch(null);
void gitea
.prsList(currentDirectory, { sourceBranch: currentBranch })
.then((result) => {
if (cancelled) {
return;
}
const candidates = result.prs ?? [];
// Prefer the open PR for the branch; fall back to a merged one so a
// just-merged branch still shows its request instead of nothing.
const matching =
candidates.find((pr) => pr.state === 'open')
?? candidates.find((pr) => pr.state === 'merged')
?? null;
setBranchPr(matching);
setRepoRef(result.repo ?? null);
})
.catch((error) => {
if (!cancelled) {
setBranchPrError(error instanceof Error ? error.message : String(error));
}
})
.finally(() => {
if (!cancelled) {
setBranchPrLoading(false);
}
});
return () => {
cancelled = true;
};
}, [connected, currentBranch, currentDirectory, gitea, retryToken]);
// ---- Open pull requests in this repository ----------------------------
const [openPrs, setOpenPrs] = React.useState<GiteaPullRequestSummary[]>([]);
const [listPage, setListPage] = React.useState(1);
const [listHasMore, setListHasMore] = React.useState(false);
const [listLoading, setListLoading] = React.useState(false);
const [listLoadingMore, setListLoadingMore] = React.useState(false);
const [listError, setListError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!currentDirectory || !connected || !gitea?.prsList) {
return;
}
let cancelled = false;
setListLoading(true);
setListError(null);
void gitea
.prsList(currentDirectory, { page: 1 })
.then((result) => {
if (cancelled) {
return;
}
setOpenPrs(result.prs ?? []);
setListPage(result.page ?? 1);
setListHasMore(Boolean(result.hasMore));
})
.catch((error) => {
if (!cancelled) {
setListError(error instanceof Error ? error.message : String(error));
}
})
.finally(() => {
if (!cancelled) {
setListLoading(false);
}
});
return () => {
cancelled = true;
};
}, [connected, currentDirectory, gitea, retryToken]);
const loadMore = React.useCallback(async () => {
if (!currentDirectory || !connected || !gitea?.prsList) {
return;
}
if (listLoadingMore || listLoading || !listHasMore) {
return;
}
setListLoadingMore(true);
try {
const next = await gitea.prsList(currentDirectory, { page: listPage + 1 });
setOpenPrs((previous) => [...previous, ...(next.prs ?? [])]);
setListPage(next.page ?? listPage + 1);
setListHasMore(Boolean(next.hasMore));
} catch (error) {
setListError(error instanceof Error ? error.message : String(error));
} finally {
setListLoadingMore(false);
}
}, [connected, currentDirectory, gitea, listHasMore, listLoading, listLoadingMore, listPage]);
// ---- Inline PR context (current-branch PR only) -----------------------
const [contextOpen, setContextOpen] = React.useState(false);
const [contextResult, setContextResult] = React.useState<GiteaPullRequestContextResult | null>(null);
const [contextLoading, setContextLoading] = React.useState(false);
const [contextError, setContextError] = React.useState<string | null>(null);
// A different branch PR invalidates any previously loaded context.
React.useEffect(() => {
setContextOpen(false);
setContextResult(null);
setContextError(null);
}, [branchPr?.number]);
// A different branch PR invalidates the update/merge transient state so the
// previous PR's edit form and in-flight requests don't leak.
React.useEffect(() => {
setUpdateOpen(false);
setEditTitle('');
setEditDescription('');
setEditDescriptionKnown(false);
setEditDescriptionLoading(false);
setUpdating(false);
setMerging(false);
}, [branchPr?.number]);
const toggleContext = React.useCallback(async (pr: GiteaPullRequestSummary) => {
if (!currentDirectory || !gitea?.prContext) {
return;
}
if (contextOpen) {
setContextOpen(false);
setContextResult(null);
setContextError(null);
return;
}
setContextOpen(true);
setContextLoading(true);
setContextError(null);
try {
const result = await gitea.prContext(currentDirectory, pr.number, { includeDiff: false });
if (result.connected === false) {
setContextError(t('contextPanel.giteaPr.error.notConnected'));
} else {
setContextResult(result);
}
} catch (error) {
setContextError(error instanceof Error ? error.message : String(error));
} finally {
setContextLoading(false);
}
}, [contextOpen, currentDirectory, gitea, t]);
// ---- Create / update / merge actions -----------------------------------
const [createTitle, setCreateTitle] = React.useState('');
const [createDescription, setCreateDescription] = React.useState('');
const [createSourceBranch, setCreateSourceBranch] = React.useState(currentBranch ?? '');
const [createTargetBranch, setCreateTargetBranch] = React.useState('main');
const [creating, setCreating] = React.useState(false);
const createTargetTouchedRef = React.useRef(false);
// Repository branches for the source/target dropdowns, fetched lazily once
// the create form is visible.
const [branches, setBranches] = React.useState<string[]>([]);
const [defaultBranch, setDefaultBranch] = React.useState<string | null>(null);
const [branchesLoading, setBranchesLoading] = React.useState(false);
// The current branch is only known after git status resolves, so adopt it as
// the default source branch when it arrives without clobbering a pick.
React.useEffect(() => {
if (currentBranch) {
setCreateSourceBranch((previous) => previous || currentBranch);
}
}, [currentBranch]);
// The default target branch is the target of the repository's previously
// listed open PRs when available; otherwise fall back to main.
const defaultTargetBranch = React.useMemo(
() => openPrs.find((pr) => pr.targetBranch)?.targetBranch ?? 'main',
[openPrs],
);
// Adopt the repository's target branch default once the open-PR list
// resolves, unless the user has already typed into the field.
React.useEffect(() => {
if (branchPrLoading || branchPr || createTargetTouchedRef.current) {
return;
}
setCreateTargetBranch(defaultBranch ?? defaultTargetBranch);
}, [branchPr, branchPrLoading, defaultBranch, defaultTargetBranch]);
// The source dropdown must always offer the picked/current branch, even
// before the branch list resolves.
const sourceBranchOptions = React.useMemo(() => {
if (!createSourceBranch) {
return branches;
}
return branches.includes(createSourceBranch) ? branches : [createSourceBranch, ...branches];
}, [branches, createSourceBranch]);
// A pull request cannot target its own source branch once there is more
// than one branch to choose from.
const targetBranchOptions = React.useMemo(
() => (branches.length >= 2 ? branches.filter((branch) => branch !== createSourceBranch) : branches),
[branches, createSourceBranch],
);
// Fetch the repository's branches lazily once the create form is visible so
// the source/target dropdowns can offer real values. Gitea's branch API is
// keyed by owner/repo, which the PR list result carries. Failure surfaces as
// a toast and leaves the dropdowns on the current-branch fallback.
React.useEffect(() => {
if (!repoRef || branchPr || !connected || !gitea?.repoBranches) {
return;
}
let cancelled = false;
setBranchesLoading(true);
void gitea
.repoBranches(repoRef.owner, repoRef.repo)
.then((result) => {
if (cancelled) {
return;
}
setBranches(result.branches ?? []);
setDefaultBranch(result.defaultBranch ?? null);
})
.catch((error) => {
if (cancelled) {
return;
}
setBranches([]);
setDefaultBranch(null);
toast.error(t('contextPanel.giteaPr.error.loadFailed'), {
description: error instanceof Error ? error.message : String(error),
});
})
.finally(() => {
if (!cancelled) {
setBranchesLoading(false);
}
});
return () => {
cancelled = true;
};
}, [branchPr, connected, gitea, repoRef, t]);
const [updateOpen, setUpdateOpen] = React.useState(false);
const [editTitle, setEditTitle] = React.useState('');
const [editDescription, setEditDescription] = React.useState('');
const [editDescriptionKnown, setEditDescriptionKnown] = React.useState(false);
const [editDescriptionLoading, setEditDescriptionLoading] = React.useState(false);
const [updating, setUpdating] = React.useState(false);
const [merging, setMerging] = React.useState(false);
const createPr = React.useCallback(async () => {
if (!currentDirectory || !currentBranch || !gitea?.prCreate) {
return;
}
const targetBranch = createTargetBranch.trim();
if (!targetBranch) {
return;
}
setCreating(true);
try {
const created = await gitea.prCreate({
directory: currentDirectory,
title: createTitle.trim() || currentBranch,
sourceBranch: createSourceBranch,
targetBranch,
...(createDescription.trim() ? { description: createDescription } : {}),
});
toast.success(t('contextPanel.giteaPr.createPr.toast.created'));
// Show the created PR immediately and refresh both the branch PR and
// the open list so the card flips to the opened state.
setBranchPr(created);
setRetryToken((value) => value + 1);
// Clear the form.
setCreateTitle('');
setCreateDescription('');
createTargetTouchedRef.current = false;
setCreateTargetBranch(defaultBranch ?? defaultTargetBranch);
} catch (error) {
toast.error(t('contextPanel.giteaPr.createPr.toast.createFailed'), {
description: error instanceof Error ? error.message : String(error),
});
} finally {
setCreating(false);
}
}, [createDescription, createSourceBranch, createTargetBranch, createTitle, currentBranch, currentDirectory, defaultBranch, defaultTargetBranch, gitea, t]);
const toggleUpdate = React.useCallback(async () => {
if (!branchPr) {
return;
}
if (updateOpen) {
setUpdateOpen(false);
return;
}
setUpdateOpen(true);
setEditTitle(branchPr.title);
const knownBody = contextResult?.pr?.body;
if (typeof knownBody === 'string') {
setEditDescription(knownBody);
setEditDescriptionKnown(true);
return;
}
setEditDescription('');
setEditDescriptionKnown(false);
if (!currentDirectory || !gitea?.prContext) {
return;
}
setEditDescriptionLoading(true);
try {
const result = await gitea.prContext(currentDirectory, branchPr.number, { includeDiff: false });
if (result.connected === false) {
setEditDescription('');
return;
}
setEditDescription(result.pr?.body ?? '');
setEditDescriptionKnown(true);
} catch {
// Leave the description empty; the title can still be edited.
} finally {
setEditDescriptionLoading(false);
}
}, [branchPr, contextResult?.pr?.body, currentDirectory, gitea, updateOpen]);
const savePr = React.useCallback(async () => {
if (!currentDirectory || !branchPr || !gitea?.prUpdate) {
return;
}
const trimmedTitle = editTitle.trim();
if (!trimmedTitle) {
return;
}
setUpdating(true);
try {
await gitea.prUpdate({
directory: currentDirectory,
number: branchPr.number,
title: trimmedTitle,
// Only send the description when it was actually loaded so an
// unresolved description can never be wiped out by a title-only save.
...(editDescriptionKnown ? { description: editDescription } : {}),
});
toast.success(t('contextPanel.giteaPr.updatePr.toast.updated'));
setUpdateOpen(false);
setRetryToken((value) => value + 1);
} catch (error) {
toast.error(t('contextPanel.giteaPr.updatePr.toast.updateFailed'), {
description: error instanceof Error ? error.message : String(error),
});
} finally {
setUpdating(false);
}
}, [branchPr, currentDirectory, editDescription, editDescriptionKnown, editTitle, gitea, t]);
// Gitea merges with a method (merge/squash/rebase); there are no
// method-selector labels in the gitea key set, so the default 'merge' method
// is used without a selector.
const mergePr = React.useCallback(async () => {
if (!currentDirectory || !branchPr || !gitea?.prMerge) {
return;
}
setMerging(true);
try {
const result = await gitea.prMerge({
directory: currentDirectory,
number: branchPr.number,
method: 'merge',
});
if (result.merged) {
toast.success(t('contextPanel.giteaPr.mergePr.toast.merged'));
} else {
toast.error(t('contextPanel.giteaPr.mergePr.toast.mergeFailed'), {
...(result.message ? { description: result.message } : {}),
});
}
// Refresh the branch PR (flips to the merged state) and the open list.
setRetryToken((value) => value + 1);
} catch (error) {
toast.error(t('contextPanel.giteaPr.mergePr.toast.mergeFailed'), {
description: error instanceof Error ? error.message : String(error),
});
} finally {
setMerging(false);
}
}, [branchPr, currentDirectory, gitea, t]);
const formatTimestamp = React.useCallback((value?: string) => {
if (!value) {
return '';
}
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) {
return value;
}
return formatDateTimeForPreference(timestamp, timeFormatPreference, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}, [timeFormatPreference]);
// ---- Render ------------------------------------------------------------
if (!currentDirectory) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Icon name="git-pull-request" className="h-12 w-12 text-muted-foreground/50" />
<div className="typography-ui-header text-foreground">{t('contextPanel.giteaPr.title')}</div>
<div className="max-w-sm typography-micro text-muted-foreground">{t('contextPanel.giteaPr.empty.noActiveProject')}</div>
</div>
);
}
if (connected === null) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Icon name="loader-4" className="h-6 w-6 animate-spin text-muted-foreground" />
<div className="typography-micro text-muted-foreground">{t('contextPanel.giteaPr.loading')}</div>
</div>
);
}
if (connected === false) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Icon name="git-pull-request" className="h-12 w-12 text-muted-foreground/50" />
<div className="typography-ui-header text-foreground">{t('contextPanel.giteaPr.error.notConnected')}</div>
<Button variant="outline" size="sm" onClick={openGiteaSettings} className="w-fit">
{t('contextPanel.giteaPr.actions.openSettings')}
</Button>
</div>
);
}
const branchPrStateLabel = branchPr
? branchPr.state === 'merged'
? t('contextPanel.giteaPr.state.merged')
: branchPr.state === 'closed'
? t('contextPanel.giteaPr.state.closed')
: t('contextPanel.giteaPr.state.opened')
: '';
const branchPrAuthor = branchPr ? prAuthorLabel(branchPr) : '';
const prComments = contextResult?.comments ?? [];
return (
<ScrollableOverlay
as={ScrollShadow}
outerClassName="h-full min-h-0"
className="px-4 py-3"
disableHorizontal
preventOverscroll
>
<div className="flex flex-col gap-4">
<div className="flex h-8 min-w-0">
<SortableTabsStrip
className="h-full"
items={[
{ id: 'pr', label: t('contextPanel.giteaPr.tabs.pullRequests') },
{ id: 'issues', label: t('contextPanel.giteaPr.tabs.issues') },
]}
activeId={activeTab}
onSelect={(tabId) => setActiveTab(tabId as 'pr' | 'issues')}
layoutMode="fit"
variant="active-pill"
activePillButtonClassName="h-7"
/>
</div>
{activeTab === 'pr' ? (
<>
<div className="flex flex-col gap-0.5">
<div className="typography-ui-header font-semibold text-foreground">{t('contextPanel.giteaPr.title')}</div>
<div className="typography-micro text-muted-foreground">{t('contextPanel.giteaPr.listSectionTitle')}</div>
</div>
{/* Current-branch pull request */}
<section className="flex min-w-0 flex-col gap-2">
<h3 className="typography-ui-label font-semibold text-foreground">{t('contextPanel.giteaPr.branchSectionTitle')}</h3>
{branchPrLoading ? (
<div className="flex items-center gap-2 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
{t('contextPanel.giteaPr.loading')}
</div>
) : branchPrError ? (
<div className="flex flex-col gap-2">
<div className="typography-ui-label text-foreground">{t('contextPanel.giteaPr.error.loadFailed')}</div>
<div className="typography-micro text-muted-foreground break-words">{branchPrError}</div>
<Button variant="outline" size="sm" onClick={retry} className="w-fit">
{t('contextPanel.preview.actions.retry')}
</Button>
</div>
) : branchPr ? (
<div className="flex min-w-0 flex-col gap-2 rounded-md border border-border/40 p-3">
<div className="min-w-0">
<div className="typography-ui-label text-foreground break-words leading-snug">
<span className="text-muted-foreground">#{branchPr.number}</span> {branchPr.title}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 typography-micro text-muted-foreground">
{branchPr.draft ? (
<span className={draftBadgeClass}>{t('contextPanel.giteaPr.draft')}</span>
) : null}
<span className="inline-flex items-center gap-1" style={{ color: prStateColor(branchPr.state) }}>
<span className="size-1.5 rounded-full" style={{ backgroundColor: prStateColor(branchPr.state) }} />
{branchPrStateLabel}
</span>
<span className="min-w-0 truncate">{branchPr.sourceBranch} {branchPr.targetBranch}</span>
</div>
{branchPrAuthor ? (
<div className="mt-0.5 typography-micro text-muted-foreground">{branchPrAuthor}</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Button variant="outline" size="sm" asChild className="h-7 gap-1.5 px-2">
<a href={branchPr.url} target="_blank" rel="noopener noreferrer">
<Icon name="external-link" className="size-4" />
{t('contextPanel.giteaPr.openInGitea')}
</a>
</Button>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 px-2"
onClick={() => void toggleContext(branchPr)}
disabled={contextLoading}
>
{contextLoading ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : contextOpen ? (
<Icon name="arrow-down-s" className="size-4 transition-transform rotate-180" />
) : (
<Icon name="arrow-right-s" className="size-4" />
)}
{contextOpen ? t('contextPanel.giteaPr.hideContext') : t('contextPanel.giteaPr.loadContext')}
</Button>
{branchPr.state === 'open' ? (
<>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 px-2"
onClick={() => void toggleUpdate()}
disabled={updating}
>
<Icon name="edit" className="size-4" />
{t('contextPanel.giteaPr.updatePr.toggle')}
</Button>
<Button
size="sm"
className="h-7 gap-1.5 px-2"
onClick={() => void mergePr()}
disabled={merging || updating}
>
{merging ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="git-merge" className="size-4" />}
{merging ? t('contextPanel.giteaPr.mergePr.merging') : t('contextPanel.giteaPr.mergePr.action')}
</Button>
</>
) : null}
</div>
{updateOpen && branchPr.state === 'open' ? (
<div className="flex min-w-0 flex-col gap-2 border-t border-border/40 pt-3">
<label className="space-y-1">
<div className="typography-micro text-muted-foreground">{t('contextPanel.giteaPr.createPr.titleLabel')}</div>
<Input
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
placeholder={t('contextPanel.giteaPr.createPr.titlePlaceholder')}
/>
</label>
<label className="space-y-1">
<div className="typography-micro text-muted-foreground">{t('contextPanel.giteaPr.createPr.descriptionLabel')}</div>
{editDescriptionLoading ? (
<div className="flex items-center gap-2 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
{t('contextPanel.giteaPr.loading')}
</div>
) : (
<Textarea
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
className="min-h-[80px]"
placeholder={t('gitView.pr.placeholder.whatChanged')}
/>
)}
</label>
<div className="flex justify-end">
<Button
size="sm"
className="h-7 gap-1.5 px-2"
onClick={() => void savePr()}
disabled={updating || editDescriptionLoading || !editTitle.trim()}
>
{updating ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="check" className="size-4" />}
{updating ? t('contextPanel.giteaPr.updatePr.saving') : t('contextPanel.giteaPr.updatePr.save')}
</Button>
</div>
</div>
) : null}
{contextOpen ? (
<div className="flex min-w-0 flex-col gap-3 border-t border-border/40 pt-3">
{contextLoading ? (
<div className="flex items-center gap-2 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
{t('contextPanel.giteaPr.loading')}
</div>
) : contextError ? (
<div className="typography-micro text-muted-foreground break-words">{contextError}</div>
) : (
<>
<div className="flex min-w-0 flex-col gap-1">
<div className="typography-micro font-semibold text-foreground">{t('gitView.pr.field.description')}</div>
{contextResult?.pr?.body?.trim() ? (
<SimpleMarkdownRenderer
content={contextResult.pr.body}
className="typography-markdown-body min-w-0 text-muted-foreground break-words"
enableFileReferences={false}
/>
) : (
<div className="typography-micro text-muted-foreground">{t('gitView.pr.noDescription')}</div>
)}
</div>
<div className="flex min-w-0 flex-col gap-2">
<div className="typography-micro font-semibold text-foreground">{t('gitView.pr.segment.comments')}</div>
{prComments.length > 0 ? (
prComments.map((comment) => (
<div key={comment.id} className="flex min-w-0 flex-col gap-1 rounded-lg bg-surface-elevated px-3 py-2">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 typography-micro text-muted-foreground">
<span className="text-foreground whitespace-nowrap">
{comment.author?.username || ''}
</span>
{comment.createdAt ? (
<span className="whitespace-nowrap">{formatTimestamp(comment.createdAt)}</span>
) : null}
</div>
<SimpleMarkdownRenderer
content={comment.body || ''}
className="typography-markdown-body text-foreground break-words"
enableFileReferences={false}
/>
</div>
))
) : (
<div className="typography-micro text-muted-foreground">{t('gitView.pr.comments.empty')}</div>
)}
</div>
</>
)}
</div>
) : null}
</div>
) : currentBranch ? (
<div className="flex min-w-0 flex-col gap-2 rounded-md border border-border/40 p-3">
<div className="typography-ui-label font-semibold text-foreground">{t('contextPanel.giteaPr.createPr.title')}</div>
<label className="space-y-1">
<div className="typography-micro text-muted-foreground">{t('contextPanel.giteaPr.createPr.sourceBranch')}</div>
<Select value={createSourceBranch} onValueChange={(value) => setCreateSourceBranch(value)}>
<SelectTrigger size="default" className="w-full">
<SelectValue>{branchesLoading ? t('contextPanel.giteaPr.createPr.branchesLoading') : createSourceBranch}</SelectValue>
</SelectTrigger>
<SelectContent>
{sourceBranchOptions.map((branch) => (
<SelectItem key={branch} value={branch}>{branch}</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="space-y-1">
<div className="typography-micro text-muted-foreground">{t('contextPanel.giteaPr.createPr.targetBranch')}</div>
<Select
value={createTargetBranch}
onValueChange={(value) => {
createTargetTouchedRef.current = true;
setCreateTargetBranch(value);
}}
>
<SelectTrigger size="default" className="w-full">
<SelectValue>{branchesLoading ? t('contextPanel.giteaPr.createPr.branchesLoading') : createTargetBranch}</SelectValue>
</SelectTrigger>
<SelectContent>
{targetBranchOptions.map((branch) => (
<SelectItem key={branch} value={branch}>{branch}</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="space-y-1">
<div className="typography-micro text-muted-foreground">{t('contextPanel.giteaPr.createPr.titleLabel')}</div>
<Input
value={createTitle}
onChange={(event) => setCreateTitle(event.target.value)}
placeholder={currentBranch}
/>
</label>
<label className="space-y-1">
<div className="typography-micro text-muted-foreground">{t('contextPanel.giteaPr.createPr.descriptionLabel')}</div>
<Textarea
value={createDescription}
onChange={(event) => setCreateDescription(event.target.value)}
className="min-h-[80px]"
placeholder={t('gitView.pr.placeholder.whatChanged')}
/>
</label>
<div className="flex justify-end">
<Button
size="sm"
className="h-7 gap-1.5 px-2"
onClick={() => void createPr()}
disabled={creating || !createTargetBranch.trim()}
>
{creating ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="git-pull-request" className="size-4" />}
{creating ? t('contextPanel.giteaPr.createPr.submitting') : t('contextPanel.giteaPr.createPr.submit')}
</Button>
</div>
</div>
) : (
<div className="typography-micro text-muted-foreground">{t('contextPanel.giteaPr.noPrForBranch')}</div>
)}
</section>
{/* Open pull requests in this repository */}
<section className="flex min-w-0 flex-col gap-2">
<h3 className="typography-ui-label font-semibold text-foreground">{t('contextPanel.giteaPr.openPrTitle')}</h3>
{listLoading ? (
<div className="flex items-center gap-2 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
{t('contextPanel.giteaPr.loading')}
</div>
) : listError ? (
<div className="flex flex-col gap-2">
<div className="typography-ui-label text-foreground">{t('contextPanel.giteaPr.error.loadFailed')}</div>
<div className="typography-micro text-muted-foreground break-words">{listError}</div>
<Button variant="outline" size="sm" onClick={retry} className="w-fit">
{t('contextPanel.preview.actions.retry')}
</Button>
</div>
) : openPrs.length === 0 ? (
<div className="typography-micro text-muted-foreground">{t('contextPanel.giteaPr.openPrEmpty')}</div>
) : (
<div className="flex min-w-0 flex-col">
{openPrs.map((pr) => (
<div
key={pr.number}
className="group flex cursor-pointer items-center gap-2 rounded py-1.5 transition-colors hover:bg-interactive-hover/30"
onClick={() => void openExternalUrl(pr.url)}
>
<div className="min-w-0 flex-1">
<p className="typography-small truncate text-foreground">
<span className="mr-1 text-muted-foreground">#{pr.number}</span>
{pr.title}
</p>
<p className="typography-meta truncate text-muted-foreground">{pr.sourceBranch} {pr.targetBranch}</p>
</div>
{pr.draft ? (
<span className={draftBadgeClass}>{t('contextPanel.giteaPr.draft')}</span>
) : null}
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
aria-label={t('contextPanel.giteaPr.openInGitea')}
className="hidden size-5 flex-shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground group-hover:flex"
>
<Icon name="external-link" className="size-4" />
</a>
</div>
))}
{listHasMore ? (
<div className="flex justify-center py-2">
<Button variant="ghost" size="sm" onClick={() => void loadMore()} disabled={listLoadingMore}>
{listLoadingMore ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : null}
{t('contextPanel.giteaPr.loadMore')}
</Button>
</div>
) : null}
</div>
)}
</section>
</>
) : (
<GiteaIssuesSection directory={currentDirectory} />
)}
</div>
</ScrollableOverlay>
);
};
@@ -20,6 +20,7 @@ import type {
GitHubPullRequest,
GitHubChecksSummary,
GitLabMergeRequestSummary,
GiteaPullRequestSummary,
} from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
@@ -54,6 +55,8 @@ interface GitHeaderProps {
onOpenPullRequest?: () => void;
gitLabMr?: GitLabMergeRequestSummary | null;
onOpenGitLabMr?: () => void;
giteaPr?: GiteaPullRequestSummary | null;
onOpenGiteaPr?: () => void;
}
const IDENTITY_ICON_MAP: Record<string, IconName> = {
@@ -263,6 +266,8 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
onOpenPullRequest,
gitLabMr,
onOpenGitLabMr,
giteaPr,
onOpenGiteaPr,
}) => {
const { t } = useI18n();
if (!status) {
@@ -410,6 +415,40 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
</Tooltip>
) : null;
// Gitea pull request chip, mirroring the GitLab MR chip above. There is no
// Gitea brand icon in the sprite, so the neutral pull-request icon carries
// the state colour.
const giteaPrVisualState = giteaPr
? giteaPr.state === 'merged'
? 'merged'
: giteaPr.state === 'closed'
? 'closed'
: giteaPr.draft
? 'draft'
: 'open'
: null;
const giteaPrChip = giteaPr && onOpenGiteaPr ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={onOpenGiteaPr}
className="h-8 gap-1.5 px-2 typography-micro"
>
<Icon
name="git-pull-request"
className="size-3.5"
style={{ color: `var(--pr-${giteaPrVisualState})` }}
/>
<span className="tabular-nums text-foreground/80">#{giteaPr.number}</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{t('gitView.header.openPullRequest')}</TooltipContent>
</Tooltip>
) : null;
const syncButtons = (
<SyncActions
syncAction={syncAction}
@@ -475,6 +514,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
<div className="mt-3 flex h-8 min-w-0 items-center gap-2">
{prChip ? <div className="shrink-0">{prChip}</div> : null}
{gitLabMrChip ? <div className="shrink-0">{gitLabMrChip}</div> : null}
{giteaPrChip ? <div className="shrink-0">{giteaPrChip}</div> : null}
<div className="min-w-0 flex-1" />
{upstreamStatusPill ? (
<div className="min-w-0 shrink">{upstreamStatusPill}</div>
@@ -0,0 +1,381 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useUIStore } from '@/stores/useUIStore';
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
import { formatDateTimeForPreference } from '@/lib/timeFormat';
import type { GiteaComment, GiteaIssue, GiteaIssueSummary } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
const issueStateColor = (state: string): string => {
switch (state) {
case 'closed':
return 'var(--pr-closed)';
default:
return 'var(--pr-open)';
}
};
const issueLabelBadgeClass =
'inline-flex items-center rounded border border-border/60 bg-surface-elevated px-1.5 py-px typography-micro text-foreground';
/**
* Open Gitea issues for the context panel's PR view. List and detail are
* fetched lazily: the parent only mounts this component while the Issues tab
* is active. Read-only by design no create, update, or close actions.
*/
export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory }) => {
const { t } = useI18n();
const { gitea } = useRuntimeAPIs();
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const giteaAuthStatus = useGiteaAuthStore((state) => state.status);
const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked);
// ---- Open issues list ----------------------------------------------------
const [issues, setIssues] = React.useState<GiteaIssueSummary[]>([]);
const [listPage, setListPage] = React.useState(1);
const [listHasMore, setListHasMore] = React.useState(false);
const [listLoading, setListLoading] = React.useState(false);
const [listLoadingMore, setListLoadingMore] = React.useState(false);
const [listError, setListError] = React.useState<string | null>(null);
const [listNotConnected, setListNotConnected] = React.useState(false);
const [retryToken, setRetryToken] = React.useState(0);
// ---- Selected issue detail ------------------------------------------------
const [selectedNumber, setSelectedNumber] = React.useState<number | null>(null);
const [issue, setIssue] = React.useState<GiteaIssue | null>(null);
const [comments, setComments] = React.useState<GiteaComment[]>([]);
const [detailLoading, setDetailLoading] = React.useState(false);
const [detailError, setDetailError] = React.useState<string | null>(null);
const retry = React.useCallback(() => setRetryToken((value) => value + 1), []);
const openGiteaSettings = React.useCallback(() => {
setSettingsPage('git');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]);
// The parent PR view already gates on connection, but the auth store is the
// authoritative signal when the list API reports connected without having
// checked the account yet.
const authNotConnected = giteaAuthChecked && giteaAuthStatus?.connected === false;
// A different repository invalidates the previously loaded list and detail so
// a stale repository's issues never leak into the new one.
React.useEffect(() => {
setIssues([]);
setListPage(1);
setListHasMore(false);
setListLoading(false);
setListLoadingMore(false);
setListError(null);
setListNotConnected(false);
setSelectedNumber(null);
setIssue(null);
setComments([]);
setDetailLoading(false);
setDetailError(null);
}, [directory]);
React.useEffect(() => {
if (!gitea?.issuesList) {
return;
}
let cancelled = false;
setListLoading(true);
setListError(null);
setListNotConnected(false);
void gitea
.issuesList(directory, { page: 1 })
.then((result) => {
if (cancelled) {
return;
}
if (result.connected === false) {
setListNotConnected(true);
return;
}
setIssues(result.issues ?? []);
setListPage(result.page ?? 1);
setListHasMore(Boolean(result.hasMore));
})
.catch((error) => {
if (!cancelled) {
setListError(error instanceof Error ? error.message : String(error));
}
})
.finally(() => {
if (!cancelled) {
setListLoading(false);
}
});
return () => {
cancelled = true;
};
}, [directory, gitea, retryToken]);
const loadMore = React.useCallback(async () => {
if (!gitea?.issuesList || listLoadingMore || listLoading || !listHasMore) {
return;
}
setListLoadingMore(true);
try {
const next = await gitea.issuesList(directory, { page: listPage + 1 });
if (next.connected === false) {
setListNotConnected(true);
return;
}
setIssues((previous) => [...previous, ...(next.issues ?? [])]);
setListPage(next.page ?? listPage + 1);
setListHasMore(Boolean(next.hasMore));
} catch (error) {
setListError(error instanceof Error ? error.message : String(error));
} finally {
setListLoadingMore(false);
}
}, [directory, gitea, listHasMore, listLoading, listLoadingMore, listPage]);
// Fetch the issue and its comments in parallel whenever a row is selected. A
// cancelled flag keeps a stale selection from overwriting a newer one.
React.useEffect(() => {
if (selectedNumber === null || !gitea?.issueGet || !gitea.issueComments) {
return;
}
let cancelled = false;
setDetailLoading(true);
setDetailError(null);
setIssue(null);
setComments([]);
void Promise.all([
gitea.issueGet(directory, selectedNumber),
gitea.issueComments(directory, selectedNumber),
])
.then(([issueResult, commentsResult]) => {
if (cancelled) {
return;
}
if (issueResult.connected === false || commentsResult.connected === false) {
setDetailError(t('contextPanel.giteaPr.error.notConnected'));
return;
}
if (!issueResult.issue) {
setDetailError(t('session.giteaIssuePicker.error.issueNotFound'));
return;
}
setIssue(issueResult.issue);
setComments(commentsResult.comments ?? []);
})
.catch((error) => {
if (!cancelled) {
setDetailError(error instanceof Error ? error.message : String(error));
}
})
.finally(() => {
if (!cancelled) {
setDetailLoading(false);
}
});
return () => {
cancelled = true;
};
}, [directory, gitea, selectedNumber, t]);
const backToIssues = React.useCallback(() => {
setSelectedNumber(null);
setIssue(null);
setComments([]);
setDetailLoading(false);
setDetailError(null);
}, []);
const formatTimestamp = React.useCallback((value?: string) => {
if (!value) {
return '';
}
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) {
return value;
}
return formatDateTimeForPreference(timestamp, timeFormatPreference, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}, [timeFormatPreference]);
if (selectedNumber !== null) {
return (
<div className="flex min-w-0 flex-col gap-3">
<div className="flex min-w-0 items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2" onClick={backToIssues}>
<Icon name="arrow-left" className="size-4" />
{t('contextPanel.giteaPr.issues.detail.back')}
</Button>
</div>
{detailLoading ? (
<div className="flex items-center gap-2 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
{t('contextPanel.giteaPr.loading')}
</div>
) : detailError ? (
<div className="flex flex-col gap-2">
<div className="typography-micro text-muted-foreground break-words">{detailError}</div>
<Button variant="outline" size="sm" onClick={backToIssues} className="w-fit">
{t('contextPanel.giteaPr.issues.detail.back')}
</Button>
</div>
) : issue ? (
<>
<div className="flex min-w-0 flex-col gap-1">
<div className="typography-ui-header font-semibold text-foreground break-words leading-snug">
<span className="text-muted-foreground">#{issue.number}</span> {issue.title}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 typography-micro text-muted-foreground">
<span className="inline-flex items-center gap-1" style={{ color: issueStateColor(issue.state) }}>
<span className="size-1.5 rounded-full" style={{ backgroundColor: issueStateColor(issue.state) }} />
{issue.state === 'closed' ? t('contextPanel.giteaPr.state.closed') : t('contextPanel.giteaPr.state.opened')}
</span>
{issue.labels.map((label) => (
<span key={label} className={issueLabelBadgeClass}>{label}</span>
))}
</div>
<Button variant="outline" size="sm" asChild className="h-7 w-fit gap-1.5 px-2">
<a href={issue.url} target="_blank" rel="noopener noreferrer">
<Icon name="external-link" className="size-4" />
{t('contextPanel.giteaPr.openInGitea')}
</a>
</Button>
</div>
<div className="flex min-w-0 flex-col gap-1">
<div className="typography-micro font-semibold text-foreground">{t('gitView.pr.field.description')}</div>
{issue.body?.trim() ? (
<SimpleMarkdownRenderer
content={issue.body}
className="typography-markdown-body min-w-0 text-muted-foreground break-words"
enableFileReferences={false}
/>
) : (
<div className="typography-micro text-muted-foreground">{t('gitView.pr.noDescription')}</div>
)}
</div>
<div className="flex min-w-0 flex-col gap-2">
<div className="typography-micro font-semibold text-foreground">{t('gitView.pr.segment.comments')}</div>
{comments.length > 0 ? (
comments.map((comment) => (
<div key={comment.id} className="flex min-w-0 flex-col gap-1 rounded-lg bg-surface-elevated px-3 py-2">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 typography-micro text-muted-foreground">
<span className="text-foreground whitespace-nowrap">
{comment.author?.username || ''}
</span>
{comment.createdAt ? (
<span className="whitespace-nowrap">{formatTimestamp(comment.createdAt)}</span>
) : null}
</div>
<SimpleMarkdownRenderer
content={comment.body || ''}
className="typography-markdown-body text-foreground break-words"
enableFileReferences={false}
/>
</div>
))
) : (
<div className="typography-micro text-muted-foreground">{t('gitView.pr.comments.empty')}</div>
)}
</div>
</>
) : null}
</div>
);
}
return (
<div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-col gap-0.5">
<div className="typography-ui-header font-semibold text-foreground">{t('contextPanel.giteaPr.issues.listSectionTitle')}</div>
</div>
{!gitea?.issuesList ? (
<div className="typography-micro text-muted-foreground">{t('contextPanel.giteaPr.issues.empty')}</div>
) : listNotConnected || authNotConnected ? (
<div className="flex flex-col items-center justify-center gap-3 p-6 text-center">
<Icon name="git-pull-request" className="h-12 w-12 text-muted-foreground/50" />
<div className="typography-ui-header text-foreground">{t('contextPanel.giteaPr.error.notConnected')}</div>
<Button variant="outline" size="sm" onClick={openGiteaSettings} className="w-fit">
{t('contextPanel.giteaPr.actions.openSettings')}
</Button>
</div>
) : listLoading ? (
<div className="flex items-center gap-2 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
{t('contextPanel.giteaPr.loading')}
</div>
) : listError ? (
<div className="flex flex-col gap-2">
<div className="typography-ui-label text-foreground">{t('contextPanel.giteaPr.issues.error.loadFailed')}</div>
<div className="typography-micro text-muted-foreground break-words">{listError}</div>
<Button variant="outline" size="sm" onClick={retry} className="w-fit">
{t('contextPanel.preview.actions.retry')}
</Button>
</div>
) : issues.length === 0 ? (
<div className="typography-micro text-muted-foreground">{t('contextPanel.giteaPr.issues.empty')}</div>
) : (
<div className="flex min-w-0 flex-col">
{issues.map((item) => (
<div
key={item.number}
className="group flex cursor-pointer items-center gap-2 rounded py-1.5 transition-colors hover:bg-interactive-hover/30"
onClick={() => setSelectedNumber(item.number)}
>
<div className="min-w-0 flex-1">
<p className="typography-small truncate text-foreground">
<span className="mr-1 text-muted-foreground">#{item.number}</span>
{item.title}
</p>
{item.labels.length > 0 ? (
<p className="mt-1 flex min-w-0 flex-wrap gap-1">
{item.labels.map((label) => (
<span key={label} className={issueLabelBadgeClass}>{label}</span>
))}
</p>
) : null}
</div>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
aria-label={t('contextPanel.giteaPr.openInGitea')}
className="hidden size-5 flex-shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground group-hover:flex"
>
<Icon name="external-link" className="size-4" />
</a>
</div>
))}
{listHasMore ? (
<div className="flex justify-center py-2">
<Button variant="ghost" size="sm" onClick={() => void loadMore()} disabled={listLoadingMore}>
{listLoadingMore ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : null}
{t('contextPanel.giteaPr.loadMore')}
</Button>
</div>
) : null}
</div>
)}
</div>
);
};
@@ -15,6 +15,7 @@ import { useI18n, type Locale } from '@/lib/i18n';
import { openExternalUrl } from '@/lib/url';
import { useGitProvider } from '@/lib/gitProvider';
import { useGitLabMrForBranch } from '@/lib/gitlabMrStatus';
import { useGiteaPrForBranch } from '@/lib/giteaPrStatus';
import { buildWalkthroughView } from '@/lib/walkthrough/model';
import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types';
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
@@ -212,6 +213,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
const gitProvider = useGitProvider(directory);
const gitLabMr = useGitLabMrForBranch(directory, currentBranch);
const giteaPr = useGiteaPrForBranch(directory, currentBranch);
useEffect(() => {
if (!directory || !currentBranch || !githubAuthChecked || !githubConnected || gitProvider !== 'github') return;
@@ -254,16 +256,21 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
// Offer whichever pull request or merge request we know about: the one
// already selected, or the one this branch has. GitLab repos get their MR
// number from the branch lookup; everything else falls back to the GitHub PR
// status store, which the polling effect above only fills for GitHub repos.
// number from the branch lookup and Gitea repos their PR number the same
// way; everything else falls back to the GitHub PR status store, which the
// polling effect above only fills for GitHub repos.
const prSource = useMemo<Extract<WalkthroughSource, { kind: 'pr' }> | null>(() => {
if (source.kind === 'pr') return source;
if (gitProvider === 'gitlab') {
const number = gitLabMr.mr?.number;
return number ? { kind: 'pr', number } : null;
}
if (gitProvider === 'gitea') {
const number = giteaPr.pr?.number;
return number ? { kind: 'pr', number } : null;
}
return branchPrNumber ? { kind: 'pr', number: branchPrNumber } : null;
}, [branchPrNumber, gitLabMr.mr, gitProvider, source]);
}, [branchPrNumber, giteaPr.pr, gitLabMr.mr, gitProvider, source]);
const selectWorkingTree = useCallback(
(value: WalkthroughWorkingTreeScope) => {