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:
@@ -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-<number>-<slug>)</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) => {
|
||||
|
||||
Reference in New Issue
Block a user