diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index fb6976d8..d5e4a900 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ onOpenSettings, scrollTo setLinkedIssue(null); }} /> + { + setLinkedIssue(issue); + setLinkedPr(null); + }} + /> + { + setLinkedPr({ ...pr, provider: 'gitea' as const }); + setLinkedIssue(null); + }} + /> = ({ onOpenSettings, scrollTo requestAnimationFrame(openIssuePicker); }} > - - {gitProvider === 'gitlab' ? t('chat.chatInput.actions.linkGitlabIssue') : t('chat.chatInput.actions.linkGithubIssue')} + + {gitProvider === 'gitlab' ? t('chat.chatInput.actions.linkGitlabIssue') : gitProvider === 'gitea' ? t('chat.chatInput.actions.linkGiteaIssue') : t('chat.chatInput.actions.linkGithubIssue')} diff --git a/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx b/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx index 14e4578d..ba237472 100644 --- a/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx +++ b/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx @@ -140,6 +140,25 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment {t('chat.chatInput.actions.linkGitlabMr')} + ) : gitProvider === 'gitea' ? ( + <> + { + requestAnimationFrame(openIssuePicker); + }} + > + + {t('chat.chatInput.actions.linkGiteaIssue')} + + { + requestAnimationFrame(openPrPicker); + }} + > + + {t('chat.chatInput.actions.linkGiteaPr')} + + ) : null} diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx index 0dfd1657..d8951132 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ sessionId, directory, /> ) : null} + {hasGiteaPr && giteaPr ? ( + openSurface('pr') : undefined} + ariaLabel={t('chat.workStatus.action.openPr')} + iconColor={`var(--pr-${giteaPrVisualState})`} + label={giteaPr.title || t('chat.workStatus.pr.untitled')} + value={( + + {giteaPr.draft ? t('chat.workStatus.pr.draft') : `#${giteaPr.number}`} + + )} + /> + ) : null} + {prSummary ? ( <> ; + return ; } if (tab.mode === 'notes') { @@ -942,7 +943,7 @@ export const ContextPanel: React.FC = () => { : activeTab?.mode === 'git' ? : activeTab?.mode === 'pr' - ? (gitProvider === 'github' ? : gitProvider === 'gitlab' ? : null) + ? (gitProvider === 'github' ? : gitProvider === 'gitlab' ? : gitProvider === 'gitea' ? : null) : activeTab?.mode === 'notes' ? : activeTab?.mode === 'plan' diff --git a/packages/ui/src/components/layout/ContextPanelRail.tsx b/packages/ui/src/components/layout/ContextPanelRail.tsx index 8660fc87..ca5662b8 100644 --- a/packages/ui/src/components/layout/ContextPanelRail.tsx +++ b/packages/ui/src/components/layout/ContextPanelRail.tsx @@ -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 ( { > + { )} + + {ghCli?.available && !ghCli?.active && (!ghCli.user || ghCli.disabled) && ( diff --git a/packages/ui/src/components/sections/openchamber/GitLabSettings.tsx b/packages/ui/src/components/sections/openchamber/GitLabSettings.tsx index 516b4fca..18f7a1a4 100644 --- a/packages/ui/src/components/sections/openchamber/GitLabSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitLabSettings.tsx @@ -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 = () => { )} + + ); }; diff --git a/packages/ui/src/components/sections/openchamber/GiteaSettings.tsx b/packages/ui/src/components/sections/openchamber/GiteaSettings.tsx new file mode 100644 index 00000000..18e8eb4f --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/GiteaSettings.tsx @@ -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 ( + +
+ {connected ? ( +
+
+ {user?.avatarUrl ? ( + {user.username + ) : ( +
+ +
+ )} + +
+
+ {user?.name?.trim() || user?.username || 'Gitea'} +
+
+ + {t('settings.gitea.page.connectedAs')} + {user?.username || t('settings.gitea.page.label.unknownUser')} + + {currentBaseUrlHost} +
+
+
+ + +
+ ) : ( +
+
+ + setAccessToken(event.target.value)} + placeholder={t('settings.gitea.page.accessToken.placeholder')} + className="h-9 max-w-[24rem]" + /> +
+
+ + setBaseUrl(event.target.value)} + placeholder={t('settings.gitea.page.baseUrl.placeholder')} + className="h-9 max-w-[24rem]" + /> +
+
+ {t('settings.gitea.page.status.notConnected')} + +
+
+ )} + + {otherAccounts.length > 0 && ( +
+
+ {t('settings.gitea.page.label.otherAccounts')} +
+
+ {otherAccounts.map((account) => { + const accountUser = account.user; + return ( +
+
+ {accountUser?.avatarUrl ? ( + {accountUser.username + ) : ( +
+ +
+ )} +
+ + {accountUser?.name?.trim() || accountUser?.username || 'Gitea'} + + {accountUser?.username && ( + + {accountUser.username} + · + {getBaseUrlHost(account.baseUrl)} + + )} +
+
+ +
+ ); + })} +
+
+ )} +
+ + +
+ ); +}; diff --git a/packages/ui/src/components/sections/shared/CustomDomainsInput.tsx b/packages/ui/src/components/sections/shared/CustomDomainsInput.tsx new file mode 100644 index 00000000..b84933e6 --- /dev/null +++ b/packages/ui/src/components/sections/shared/CustomDomainsInput.tsx @@ -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 ( +
+ + 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]" + /> + + {t(`settings.${provider}.page.customDomains.description`)} + +
+ ); +}; diff --git a/packages/ui/src/components/session/GiteaIntegrationDialog.tsx b/packages/ui/src/components/session/GiteaIntegrationDialog.tsx new file mode 100644 index 00000000..252941d5 --- /dev/null +++ b/packages/ui/src/components/session/GiteaIntegrationDialog.tsx @@ -0,0 +1,684 @@ +import * as React from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { cn } from '@/lib/utils'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore'; +import { validateWorktreeCreate } from '@/lib/worktrees/worktreeManager'; +import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { Icon } from "@/components/icon/Icon"; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import type { + GiteaIssueSummary, + GiteaPullRequestSummary, +} from '@/lib/api/types'; +import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; +import { useI18n } from '@/lib/i18n'; + +type GiteaTab = 'issues' | 'prs'; + +interface GiteaIntegrationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (result: { + type: 'issue'; + number: number; + title: string; + url: string; + } | { + type: 'pr'; + number: number; + title: string; + url: string; + sourceBranch: string; + includeDiff: boolean; + } | null) => void; +} + +interface ValidationResult { + isValid: boolean; + error: string | null; +} + +export function GiteaIntegrationDialog({ + open, + onOpenChange, + onSelect, +}: GiteaIntegrationDialogProps) { + const { t } = useI18n(); + const isMobile = useUIStore((state) => state.isMobile); + const gitea = getRegisteredRuntimeAPIs()?.gitea; + const giteaAuthStatus = useGiteaAuthStore((state) => state.status); + const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked); + const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const activeProject = useProjectsStore((state) => state.getActiveProject()); + + const projectDirectory = activeProject?.path ?? null; + const projectRef: ProjectRef | null = React.useMemo(() => { + if (projectDirectory && activeProject) { + return { id: activeProject.id, path: projectDirectory }; + } + return null; + }, [activeProject, projectDirectory]); + + // State + const [activeTab, setActiveTab] = React.useState('issues'); + const [searchQuery, setSearchQuery] = React.useState(''); + const [issues, setIssues] = React.useState([]); + const [prs, setPrs] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [loadingMore, setLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + const [selectedIssue, setSelectedIssue] = React.useState(null); + const [selectedPr, setSelectedPr] = React.useState(null); + const [includeDiff, setIncludeDiff] = React.useState(false); + const [validations, setValidations] = React.useState>(new Map()); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(false); + + const debouncedSearchQuery = useDebouncedValue(searchQuery, 350); + + const loadData = React.useCallback(async (query?: string) => { + if (!projectDirectory || !gitea) return; + if (giteaAuthChecked && giteaAuthStatus?.connected === false) return; + + setLoading(true); + setError(null); + setPage(1); + setHasMore(false); + + try { + if (activeTab === 'issues' && gitea.issuesList) { + const result = await gitea.issuesList(projectDirectory, { page: 1, query }); + if (result.connected === false) { + setError(t('session.giteaIntegration.error.notConnected')); + setIssues([]); + } else { + setIssues(result.issues ?? []); + setPage(result.page ?? 1); + setHasMore(Boolean(result.hasMore)); + } + } else if (activeTab === 'prs' && gitea.prsList) { + const result = await gitea.prsList(projectDirectory, { page: 1, query }); + if (result.connected === false) { + setError(t('session.giteaIntegration.error.notConnected')); + setPrs([]); + } else { + setPrs(result.prs ?? []); + setPage(result.page ?? 1); + setHasMore(Boolean(result.hasMore)); + } + } + } catch (err) { + setError(err instanceof Error ? err.message : t('session.giteaIntegration.error.loadDataFailed')); + } finally { + setLoading(false); + } + }, [projectDirectory, gitea, giteaAuthChecked, giteaAuthStatus, activeTab, t]); + + React.useEffect(() => { + if (!open || !projectDirectory) return; + if (giteaAuthChecked && giteaAuthStatus?.connected === false) return; + if (!gitea) return; + if (!debouncedSearchQuery.trim()) { + void loadData(); + return; + } + + const controller = new AbortController(); + setLoading(true); + setError(null); + setPage(1); + setHasMore(false); + + const apiCall = activeTab === 'issues' && gitea.issuesList + ? gitea.issuesList(projectDirectory, { page: 1, query: debouncedSearchQuery.trim() }) + : activeTab === 'prs' && gitea.prsList + ? gitea.prsList(projectDirectory, { page: 1, query: debouncedSearchQuery.trim() }) + : null; + + if (!apiCall) { + setLoading(false); + return; + } + + apiCall + .then((result) => { + if (controller.signal.aborted) return; + if ('issues' in result) { + if (result.connected === false) { + setError(t('session.giteaIntegration.error.notConnected')); + setIssues([]); + } else { + setIssues(result.issues ?? []); + setPage(result.page ?? 1); + setHasMore(Boolean(result.hasMore)); + } + } else if ('prs' in result) { + if (result.connected === false) { + setError(t('session.giteaIntegration.error.notConnected')); + setPrs([]); + } else { + setPrs(result.prs ?? []); + setPage(result.page ?? 1); + setHasMore(Boolean(result.hasMore)); + } + } + }) + .catch((err) => { + if (controller.signal.aborted) return; + setError(err instanceof Error ? err.message : t('session.giteaIntegration.error.loadDataFailed')); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + + return () => controller.abort(); + }, [open, projectDirectory, gitea, giteaAuthChecked, giteaAuthStatus, activeTab, debouncedSearchQuery, loadData, t]); + + const loadMore = React.useCallback(async () => { + if (!projectDirectory || !gitea) return; + if (loading || loadingMore) return; + if (!hasMore) return; + + setLoadingMore(true); + + try { + const nextPage = page + 1; + + if (activeTab === 'issues' && gitea.issuesList) { + const result = debouncedSearchQuery.trim() + ? await gitea.issuesList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() }) + : await gitea.issuesList(projectDirectory, { page: nextPage }); + if (result.connected !== false) { + setIssues(prev => [...prev, ...(result.issues ?? [])]); + setPage(result.page ?? nextPage); + setHasMore(Boolean(result.hasMore)); + } + } else if (activeTab === 'prs' && gitea.prsList) { + const result = debouncedSearchQuery.trim() + ? await gitea.prsList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() }) + : await gitea.prsList(projectDirectory, { page: nextPage }); + if (result.connected !== false) { + setPrs(prev => [...prev, ...(result.prs ?? [])]); + setPage(result.page ?? nextPage); + setHasMore(Boolean(result.hasMore)); + } + } + } catch { + // Silently fail on load more errors + } finally { + setLoadingMore(false); + } + }, [projectDirectory, gitea, activeTab, page, hasMore, loading, loadingMore, debouncedSearchQuery]); + + // Reset state when dialog opens/closes + React.useEffect(() => { + if (!open) { + setActiveTab('issues'); + setSearchQuery(''); + setIssues([]); + setPrs([]); + setSelectedIssue(null); + setSelectedPr(null); + setIncludeDiff(false); + setError(null); + setValidations(new Map()); + setPage(1); + setHasMore(false); + return; + } + + void loadData(); + }, [open, loadData]); + + // Validate branches for worktree creation + const validateBranch = React.useCallback(async (branchName: string) => { + if (!projectRef || !branchName) return; + + // Check cache first + if (validations.has(branchName)) return; + + try { + const result = await validateWorktreeCreate(projectRef, { + mode: 'new', + branchName, + worktreeName: branchName, + }); + + const blockingError = result.errors.find((entry) => entry.code === 'branch_in_use'); + + setValidations(prev => new Map(prev).set(branchName, { + isValid: !blockingError, + error: blockingError + ? t(blockingError.code === 'branch_exists' + ? 'session.giteaIntegration.validation.branchAlreadyExists' + : 'session.giteaIntegration.validation.branchAlreadyCheckedOut') + : null, + })); + } catch { + setValidations(prev => new Map(prev).set(branchName, { + isValid: false, + error: t('session.giteaIntegration.validation.failed'), + })); + } + }, [projectRef, validations, t]); + + // Validate PR branches when loaded + React.useEffect(() => { + if (!open || activeTab !== 'prs') return; + + prs.forEach(pr => { + if (pr.sourceBranch) { + void validateBranch(pr.sourceBranch); + } + }); + }, [open, activeTab, prs, validateBranch]); + + // Gitea connection check + const isGiteaConnected = giteaAuthChecked && giteaAuthStatus?.connected === true; + + const openGiteaSettings = () => { + setSettingsPage('git'); + setSettingsDialogOpen(true); + }; + + // Handle selection + const handleSelectIssue = (issue: GiteaIssueSummary) => { + setSelectedIssue(issue); + setSelectedPr(null); + }; + + const handleSelectPr = (pr: GiteaPullRequestSummary) => { + setSelectedPr(pr); + setSelectedIssue(null); + }; + + const handleConfirm = () => { + if (selectedIssue) { + onSelect({ + type: 'issue', + number: selectedIssue.number, + title: selectedIssue.title, + url: selectedIssue.url, + }); + } else if (selectedPr) { + onSelect({ + type: 'pr', + number: selectedPr.number, + title: selectedPr.title, + url: selectedPr.url, + sourceBranch: selectedPr.sourceBranch, + includeDiff, + }); + } + onOpenChange(false); + }; + + const handleClear = () => { + setSelectedIssue(null); + setSelectedPr(null); + setIncludeDiff(false); + }; + + // Check if selection is valid + const canConfirm = selectedIssue || (selectedPr && validations.get(selectedPr.sourceBranch)?.isValid !== false); + + // Check if PR is blocked + const isPrBlocked = (pr: GiteaPullRequestSummary): boolean => { + if (!pr.sourceBranch) return true; + const validation = validations.get(pr.sourceBranch); + return validation?.isValid === false; + }; + + // Content for the dialog (shared between mobile and desktop) + const dialogContent = ( + <> + {!isGiteaConnected ? ( +
+ +
+

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

+

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

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

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

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

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

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

+ {issue.title} +

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

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

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

{title}

+ {closeButton} +
+

{description}

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

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

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

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

+

{pr.sourceBranch} → {pr.targetBranch}

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

{title}

+ {closeButton} +
+

{description}

+
+ )} + > + {content} +
+ ); + } + + return ( + + + + + + {title} + + + {description} + + + + {content} + + + ); +} diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 015c8c29..df5b7b4a 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -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({ @@ -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({ - {mode === 'new-branch' && (showGitHubStartFrom || showGitLabStartFrom) && ( + {mode === 'new-branch' && (showGitHubStartFrom || showGitLabStartFrom || showGiteaStartFrom) && (
{showGitHubStartFrom && ( + )}
)}
@@ -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({ )} + {newBranchState.linkedGiteaPr && ( +
+ + + {t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedGiteaPr.sourceBranch })} + +
+ )} {newBranchState.linkedIssue && !newBranchState.linkedPr && (
@@ -1646,6 +1962,14 @@ export function NewWorktreeDialog({
)} + {newBranchState.linkedGiteaIssue && !newBranchState.linkedGiteaPr && ( +
+ + + {t('session.newWorktree.fromIssue', { number: newBranchState.linkedGiteaIssue.number, title: newBranchState.linkedGiteaIssue.title })} + +
+ )} )} @@ -1705,7 +2029,7 @@ export function NewWorktreeDialog({ {/* 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 && (
@@ -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({ )} + {newBranchState.linkedGiteaPr && ( +
+ + + {t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedGiteaPr.sourceBranch })} + +
+ )} {newBranchState.linkedIssue && !newBranchState.linkedPr && (
@@ -2174,6 +2543,14 @@ export function NewWorktreeDialog({
)} + {newBranchState.linkedGiteaIssue && !newBranchState.linkedGiteaPr && ( +
+ + + {t('session.newWorktree.fromIssue', { number: newBranchState.linkedGiteaIssue.number, title: newBranchState.linkedGiteaIssue.title })} + +
+ )} )} @@ -2233,7 +2610,7 @@ export function NewWorktreeDialog({ {/* 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 && (
{/* Row 1: Type, number, title, actions */}
{newBranchState.linkedIssue || newBranchState.linkedPr ? ( - ) : ( + ) : newBranchState.linkedGitLabIssue || newBranchState.linkedGitLabMr ? ( + ) : ( + )} {newBranchState.linkedIssue && ( @@ -2376,13 +2755,23 @@ export function NewWorktreeDialog({ {t('session.newWorktree.mrNumber', { number: newBranchState.linkedGitLabMr.number })} )} + {newBranchState.linkedGiteaIssue && ( + + {t('session.newWorktree.issueNumber', { number: newBranchState.linkedGiteaIssue.number })} + + )} + {newBranchState.linkedGiteaPr && ( + + {t('session.newWorktree.prNumber', { number: newBranchState.linkedGiteaPr.number })} + + )} - {newBranchState.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} )} + {newBranchState.linkedGiteaPr && ( +
+ + {newBranchState.linkedGiteaPr.sourceBranch} + + {newBranchState.includeGiteaPrDiff && ( + + {t('session.newWorktree.includeDiffBadge')} + + )} +
+ )}
)}
@@ -2477,6 +2878,12 @@ export function NewWorktreeDialog({ onOpenChange={setGitlabDialogOpen} onSelect={handleGitLabSelect} /> + + ); } diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index d61d996a..7fa62665 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -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 = ({ 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 = ({ isActive }) => { onOpenGitLabMr={ currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined } + giteaPr={giteaPr} + onOpenGiteaPr={ + currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined + } /> {/* In-progress operation banner */} diff --git a/packages/ui/src/components/views/GiteaPrView.tsx b/packages/ui/src/components/views/GiteaPrView.tsx new file mode 100644 index 00000000..2ae51946 --- /dev/null +++ b/packages/ui/src/components/views/GiteaPrView.tsx @@ -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(null); + const [branchPrLoading, setBranchPrLoading] = React.useState(false); + const [branchPrError, setBranchPrError] = React.useState(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([]); + 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(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(null); + const [contextLoading, setContextLoading] = React.useState(false); + const [contextError, setContextError] = React.useState(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([]); + const [defaultBranch, setDefaultBranch] = React.useState(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 ( +
+ +
{t('contextPanel.giteaPr.title')}
+
{t('contextPanel.giteaPr.empty.noActiveProject')}
+
+ ); + } + + if (connected === null) { + return ( +
+ +
{t('contextPanel.giteaPr.loading')}
+
+ ); + } + + if (connected === false) { + return ( +
+ +
{t('contextPanel.giteaPr.error.notConnected')}
+ +
+ ); + } + + 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 ( + +
+
+ setActiveTab(tabId as 'pr' | 'issues')} + layoutMode="fit" + variant="active-pill" + activePillButtonClassName="h-7" + /> +
+ + {activeTab === 'pr' ? ( + <> +
+
{t('contextPanel.giteaPr.title')}
+
{t('contextPanel.giteaPr.listSectionTitle')}
+
+ + {/* Current-branch pull request */} +
+

{t('contextPanel.giteaPr.branchSectionTitle')}

+ + {branchPrLoading ? ( +
+ + {t('contextPanel.giteaPr.loading')} +
+ ) : branchPrError ? ( +
+
{t('contextPanel.giteaPr.error.loadFailed')}
+
{branchPrError}
+ +
+ ) : branchPr ? ( +
+
+
+ #{branchPr.number} {branchPr.title} +
+
+ {branchPr.draft ? ( + {t('contextPanel.giteaPr.draft')} + ) : null} + + + {branchPrStateLabel} + + {branchPr.sourceBranch} → {branchPr.targetBranch} +
+ {branchPrAuthor ? ( +
{branchPrAuthor}
+ ) : null} +
+ +
+ + + {branchPr.state === 'open' ? ( + <> + + + + ) : null} +
+ + {updateOpen && branchPr.state === 'open' ? ( +
+ +