From c36979eeebd40fbb30cec0fa7543ceaff4087ce8 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Mon, 4 May 2026 20:39:01 +0800 Subject: [PATCH 01/11] feat: add Fork page with sync upstream and workflow management - Add Fork tab between Release and Trending tabs in the header - Create ForkTimeline component with search, refresh, and pagination - Create ForkCard component with workflow dropdown, sync upstream, and GitHub link - Add GitHub API methods: getUserForks, syncFork (merge upstream), getRepositoryWorkflows, triggerWorkflowRun - Add fork state and actions to Zustand store (forks, readForks, forkViewMode, forkSearchQuery, etc.) - Fork cards show upstream source info, sorted by upstream updated_at desc - Unread badges shown when upstream repo has new updates since last refresh - Mark forks as read on any user interaction (click, expand workflows, sync) Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 6 + src/components/ForkCard.tsx | 240 ++++++++++++ src/components/ForkTimeline.tsx | 633 ++++++++++++++++++++++++++++++++ src/components/Header.tsx | 42 ++- src/services/githubApi.ts | 56 ++- src/store/useAppStore.ts | 105 +++++- src/types/index.ts | 69 +++- 7 files changed, 1143 insertions(+), 8 deletions(-) create mode 100644 src/components/ForkCard.tsx create mode 100644 src/components/ForkTimeline.tsx diff --git a/src/App.tsx b/src/App.tsx index 36016a7..6e7ac4a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { SearchBar } from './components/SearchBar'; import { RepositoryList } from './components/RepositoryList'; import { CategorySidebar } from './components/CategorySidebar'; import { ReleaseTimeline } from './components/ReleaseTimeline'; +import { ForkTimeline } from './components/ForkTimeline'; import { SettingsPanel } from './components/SettingsPanel'; import { DiscoveryView } from './components/DiscoveryView'; import { BackToTop } from './components/BackToTop'; @@ -47,6 +48,9 @@ RepositoriesView.displayName = 'RepositoriesView'; const ReleasesView = React.memo(() => ); ReleasesView.displayName = 'ReleasesView'; +const ForksView = React.memo(() => ); +ForksView.displayName = 'ForksView'; + const SettingsView = React.memo(() => ); SettingsView.displayName = 'SettingsView'; @@ -117,6 +121,8 @@ function App() { ); case 'releases': return ; + case 'forks': + return ; case 'subscription': return ( diff --git a/src/components/ForkCard.tsx b/src/components/ForkCard.tsx new file mode 100644 index 0000000..c31776a --- /dev/null +++ b/src/components/ForkCard.tsx @@ -0,0 +1,240 @@ +import React, { memo, useState, useCallback } from 'react'; +import { ExternalLink, GitFork, RefreshCw, ChevronDown, ChevronUp, FolderOpen, Folder, Play, Loader2 } from 'lucide-react'; +import { ForkRepo, WorkflowRun } from '../types'; +import { formatDistanceToNow } from 'date-fns'; + +interface ForkCardProps { + fork: ForkRepo; + isUnread: boolean; + isWorkflowsExpanded: boolean; + onToggleWorkflows: () => void; + onSyncUpstream: () => void; + onMarkAsRead: () => void; + onRunWorkflow: (workflowId: string, workflowName: string, branch: string) => void; + workflows: WorkflowRun[]; + isLoadingWorkflows: boolean; + isSyncing: boolean; + language: 'zh' | 'en'; +} + +const ForkCard: React.FC = memo(({ + fork, + isUnread, + isWorkflowsExpanded, + onToggleWorkflows, + onSyncUpstream, + onMarkAsRead, + onRunWorkflow, + workflows, + isLoadingWorkflows, + isSyncing, + language, +}) => { + const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]); + + const sourceFullName = fork.source?.full_name || fork.parent?.full_name || ''; + const sourceName = fork.source?.name || fork.parent?.name || ''; + + return ( +
+ {/* Header */} +
+
+
+ {isUnread && ( +
+ )} +
+ +
+
+
+

+ {fork.name} +

+ {fork.language && ( + + {fork.language} + + )} +
+

+ {fork.full_name} +

+ {sourceFullName && ( +

+ + {sourceFullName} +

+ )} +
+
+ +
+
+
+ + + {fork.updated_at + ? formatDistanceToNow(new Date(fork.updated_at), { addSuffix: true }) + : '-'} + +
+ {fork.source?.updated_at && ( +
+ + + {formatDistanceToNow(new Date(fork.source.updated_at), { addSuffix: true })} + +
+ )} +
+
+ {/* Workflows dropdown */} + + + {/* Sync Upstream button */} + + + {/* View on GitHub link */} + { + e.stopPropagation(); + onMarkAsRead(); + }} + > + + +
+
+
+
+ + {/* Expandable Workflows section */} +
+
+
+ {isLoadingWorkflows ? ( +
+ + + {t('加载工作流中...', 'Loading workflows...')} + +
+ ) : workflows.length === 0 ? ( +
+ {t('暂无工作流', 'No workflows')} +
+ ) : ( +
+
+ + + {t('工作流', 'Workflows')} + + + ({workflows.length}) + +
+ +
+ {workflows.map((workflow) => ( +
e.stopPropagation()} + > +
+ +
+

+ {workflow.name} +

+

+ #{workflow.run_number} • {workflow.head_branch} • {formatDistanceToNow(new Date(workflow.created_at), { addSuffix: true })} +

+
+
+ +
+ ))} +
+
+ )} +
+
+
+
+ ); +}); + +ForkCard.displayName = 'ForkCard'; + +export default ForkCard; \ No newline at end of file diff --git a/src/components/ForkTimeline.tsx b/src/components/ForkTimeline.tsx new file mode 100644 index 0000000..dd9efb7 --- /dev/null +++ b/src/components/ForkTimeline.tsx @@ -0,0 +1,633 @@ +import React, { useState, useMemo, useCallback, useEffect } from 'react'; +import { Package, Search, X, RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; +import { ForkRepo, WorkflowRun } from '../types'; +import { useAppStore } from '../store/useAppStore'; +import { GitHubApiService } from '../services/githubApi'; +import { formatDistanceToNow } from 'date-fns'; +import ForkCard from './ForkCard'; +import { useDialog } from '../hooks/useDialog'; +import { GitFork } from 'lucide-react'; + +export const ForkTimeline: React.FC = () => { + const { + forks, + readForks, + githubToken, + language, + setForks, + addForks, + markForkAsRead, + markAllForksAsRead, + updateFork, + // Fork Timeline View State from global store + forkViewMode, + forkSelectedFilters, + forkSearchQuery, + forkExpandedRepositories, + forkIsRefreshing, + setForkViewMode, + toggleForkSelectedFilter, + clearForkSelectedFilters, + setForkSearchQuery, + toggleForkExpandedRepository, + setForkIsRefreshing, + } = useAppStore(); + + const { toast } = useDialog(); + + const [lastRefreshTime, setLastRefreshTime] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(20); + // Workflow expansion state (local UI state) + const [expandedWorkflows, setExpandedWorkflows] = useState>(new Set()); + const [workflowsMap, setWorkflowsMap] = useState>({}); + const [loadingWorkflows, setLoadingWorkflows] = useState>(new Set()); + const [syncingForks, setSyncingForks] = useState>(new Set()); + + // Alias global state for local use + const viewMode = forkViewMode; + const selectedFilters = forkSelectedFilters; + const searchQuery = forkSearchQuery; + const expandedRepositories = forkExpandedRepositories; + + const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]); + + const isForkUnread = useCallback((forkId: number) => { + return !readForks.has(forkId); + }, [readForks]); + + // Filter and sort forks + const filteredForks = useMemo(() => { + let filtered = [...forks]; + + // Sort by source.updated_at desc (upstream latest update first) + filtered.sort((a, b) => { + const aTime = a.source?.updated_at ? new Date(a.source.updated_at).getTime() : 0; + const bTime = b.source?.updated_at ? new Date(b.source.updated_at).getTime() : 0; + return bTime - aTime; + }); + + // Search filter + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter(fork => + fork.name.toLowerCase().includes(query) || + fork.full_name.toLowerCase().includes(query) || + (fork.source?.full_name || '').toLowerCase().includes(query) || + (fork.description || '').toLowerCase().includes(query) || + (fork.source?.description || '').toLowerCase().includes(query) + ); + } + + return filtered; + }, [forks, searchQuery]); + + // Pagination + const totalPages = Math.ceil(filteredForks.length / itemsPerPage); + const clampedPage = Math.max(1, Math.min(currentPage, totalPages || 1)); + const startIndex = (clampedPage - 1) * itemsPerPage; + const paginatedForks = filteredForks.slice(startIndex, startIndex + itemsPerPage); + + // Sync currentPage when data changes + useEffect(() => { + const maxPage = Math.max(totalPages, 1); + if (currentPage < 1 || currentPage > maxPage) { + setCurrentPage(Math.min(Math.max(currentPage, 1), maxPage)); + } + }, [totalPages, currentPage]); + + const handlePageChange = (page: number) => { + setCurrentPage(Math.max(1, Math.min(page, totalPages))); + }; + + const getPageNumbers = () => { + const delta = 2; + const range = []; + const rangeWithDots = []; + const activePage = clampedPage; + + for (let i = Math.max(2, activePage - delta); i <= Math.min(totalPages - 1, activePage + delta); i++) { + range.push(i); + } + + if (activePage - delta > 2) { + rangeWithDots.push(1, '...'); + } else { + rangeWithDots.push(1); + } + + rangeWithDots.push(...range); + + if (activePage + delta < totalPages - 1) { + rangeWithDots.push('...', totalPages); + } else if (totalPages > 1) { + rangeWithDots.push(totalPages); + } + + return rangeWithDots; + }; + + const handleRefresh = async () => { + if (!githubToken) { + toast(language === 'zh' ? 'GitHub token 未找到,请重新登录。' : 'GitHub token not found. Please login again.', 'error'); + return; + } + + setForkIsRefreshing(true); + try { + const githubApi = new GitHubApiService(githubToken); + const newForks = await githubApi.getUserForks(); + + // Merge with existing forks, preserving read status + const existingForkMap = new Map(forks.map(f => [f.id, f])); + const mergedForks: ForkRepo[] = newForks.map(newFork => { + const existing = existingForkMap.get(newFork.id); + if (existing) { + // Preserve local state + return { + ...newFork, + has_unread: existing.has_unread, + upstream_updated_at: existing.upstream_updated_at, + }; + } + // New fork — mark as unread if upstream has updates + return { + ...newFork, + has_unread: false, + upstream_updated_at: newFork.source?.updated_at, + }; + }); + + // Check for upstream updates on existing forks - mark as unread if source has newer commits + const updatedForks = mergedForks.map(fork => { + const existing = existingForkMap.get(fork.id); + if (existing) { + // Compare: if source updated since last check, mark as unread + const prevUpstreamTime = existing.upstream_updated_at; + const currentUpstreamTime = fork.source?.updated_at; + if (prevUpstreamTime && currentUpstreamTime) { + const hasNewUpdates = new Date(currentUpstreamTime) > new Date(prevUpstreamTime); + if (hasNewUpdates) { + // Mark as unread by removing from readForks + useAppStore.setState(state => { + const newReadForks = new Set(state.readForks); + newReadForks.delete(fork.id); + return { readForks: newReadForks }; + }); + return { + ...fork, + upstream_updated_at: currentUpstreamTime, + }; + } + } + return { + ...fork, + upstream_updated_at: existing.upstream_updated_at || fork.source?.updated_at, + }; + } + return fork; + }); + + setForks(updatedForks); + const now = new Date().toISOString(); + setLastRefreshTime(now); + + // Count new forks + const newCount = newForks.filter(f => !existingForkMap.has(f.id)).length; + if (newCount > 0) { + toast(language === 'zh' + ? `刷新完成!发现 ${newCount} 个新Fork。` + : `Refresh completed! Found ${newCount} new forks.`, + 'success' + ); + } else { + toast(language === 'zh' + ? `刷新完成!` + : `Refresh completed!`, + 'info' + ); + } + } catch (error) { + console.error('Fork refresh failed:', error); + toast(language === 'zh' + ? 'Fork刷新失败,请检查网络连接。' + : 'Fork refresh failed. Please check your network connection.', + 'error' + ); + } finally { + setForkIsRefreshing(false); + } + }; + + const toggleWorkflows = async (forkId: number) => { + setExpandedWorkflows(prev => { + const newSet = new Set(prev); + if (newSet.has(forkId)) { + newSet.delete(forkId); + } else { + newSet.add(forkId); + // Fetch workflows if not loaded yet + if (!workflowsMap[forkId]) { + loadWorkflows(forkId); + } + } + return newSet; + }); + }; + + const loadWorkflows = async (forkId: number) => { + const fork = forks.find(f => f.id === forkId); + if (!fork) return; + + setLoadingWorkflows(prev => new Set(prev).add(forkId)); + try { + const [owner, repo] = fork.full_name.split('/'); + const githubApi = new GitHubApiService(githubToken!); + const workflows = await githubApi.getRepositoryWorkflows(owner, repo); + setWorkflowsMap(prev => ({ ...prev, [forkId]: workflows })); + } catch (error) { + console.error('Failed to load workflows:', error); + } finally { + setLoadingWorkflows(prev => { + const newSet = new Set(prev); + newSet.delete(forkId); + return newSet; + }); + } + }; + + const handleSyncUpstream = async (fork: ForkRepo) => { + if (!githubToken) { + toast(language === 'zh' ? 'GitHub token 未找到,请重新登录。' : 'GitHub token not found. Please login again.', 'error'); + return; + } + + setSyncingForks(prev => new Set(prev).add(fork.id)); + try { + const [owner, repo] = fork.full_name.split('/'); + const githubApi = new GitHubApiService(githubToken); + const result = await githubApi.syncFork(owner, repo); + + // Update fork's upstream_updated_at and clear unread badge (user took action) + if (result.sourceUpdatedAt) { + updateFork({ + ...fork, + upstream_updated_at: result.sourceUpdatedAt, + }); + // Clear unread badge after sync + useAppStore.setState(state => { + const newReadForks = new Set(state.readForks); + newReadForks.add(fork.id); + return { readForks: newReadForks }; + }); + } + + toast(language === 'zh' + ? `已同步 ${fork.name} 到最新版本。` + : `${fork.name} synced to latest.`, + 'success' + ); + } catch (error) { + console.error('Sync failed:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + // 409 means already up to date + if (errorMsg.includes('409') || errorMsg.toLowerCase().includes('already up to date')) { + toast(language === 'zh' + ? `${fork.name} 已是最新版本。` + : `${fork.name} is already up to date.`, + 'info' + ); + } else { + toast(language === 'zh' + ? `同步失败: ${errorMsg}` + : `Sync failed: ${errorMsg}`, + 'error' + ); + } + } finally { + setSyncingForks(prev => { + const newSet = new Set(prev); + newSet.delete(fork.id); + return newSet; + }); + } + }; + + const handleRunWorkflow = async (forkId: number, workflowId: string, workflowName: string, branch: string) => { + if (!githubToken) { + toast(language === 'zh' ? 'GitHub token 未找到,请重新登录。' : 'GitHub token not found. Please login again.', 'error'); + return; + } + + const fork = forks.find(f => f.id === forkId); + if (!fork) return; + + try { + const [owner, repo] = fork.full_name.split('/'); + const githubApi = new GitHubApiService(githubToken); + await githubApi.triggerWorkflowRun(owner, repo, workflowId, branch); + + toast(language === 'zh' + ? `已触发工作流 "${workflowName}" 在 ${branch} 分支。` + : `Triggered workflow "${workflowName}" on branch ${branch}.`, + 'success' + ); + + // Reload workflows after triggering + await loadWorkflows(forkId); + } catch (error) { + console.error('Failed to run workflow:', error); + toast(language === 'zh' + ? `运行工作流失败。` + : `Failed to run workflow.`, + 'error' + ); + } + }; + + if (forks.length === 0) { + return ( +
+ +

+ {t('没有Fork仓库', 'No Forked Repositories')} +

+

+ {t('您还没有Fork任何仓库。Fork一个仓库后即可在此处管理。', 'You have not forked any repositories. Fork a repository to manage it here.')} +

+ + {/* Refresh button */} +
+ + {lastRefreshTime && ( +

+ {t('上次刷新:', 'Last refresh:')} {formatDistanceToNow(new Date(lastRefreshTime), { addSuffix: true })} +

+ )} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

+ {t('复刻时间线', 'Fork Timeline')} +

+

+ {t(`管理您的 ${forks.length} 个Fork仓库`, `Manage your ${forks.length} forked repositories`)} +

+
+
+ {/* Last Refresh Time */} + {lastRefreshTime && ( + + {t('上次刷新:', 'Last refresh:')} {formatDistanceToNow(new Date(lastRefreshTime), { addSuffix: true })} + + )} + + {/* Refresh Button */} + +
+
+ + {/* Search Bar */} +
+
+ + { + setForkSearchQuery(e.target.value); + setCurrentPage(1); + }} + className="w-full pl-10 pr-10 py-2 border border-black/[0.06] dark:border-white/[0.04] rounded-lg focus:ring-2 focus:ring-brand-violet focus:border-transparent bg-white dark:bg-white/[0.04] text-gray-900 dark:text-text-primary" + /> + {searchQuery && ( + + )} +
+
+ + {/* Results Info and Pagination Controls */} +
+
+ + {t( + `显示 ${startIndex + 1}-${Math.min(startIndex + itemsPerPage, filteredForks.length)} 共 ${filteredForks.length} 个Fork`, + `Showing ${startIndex + 1}-${Math.min(startIndex + itemsPerPage, filteredForks.length)} of ${filteredForks.length} forks` + )} + + {searchQuery && ( + + ({t('已筛选', 'filtered')}) + + )} +
+ +
+ {/* Items per page selector */} +
+ {t('每页:', 'Per page:')} + +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + + {getPageNumbers().map((page, index) => ( + + ))} + + + +
+ )} +
+
+
+ + {/* Fork List */} +
+ {paginatedForks.length === 0 ? ( +
+ +

+ {t('无符合条件的结果', 'No matching results')} +

+

+ {t('没有找到匹配的 Fork', 'No matching forks found.')} +

+ {searchQuery && ( + + )} +
+ ) : ( + paginatedForks.map((fork) => { + const isUnread = isForkUnread(fork.id); + const isWorkflowsExpanded = expandedWorkflows.has(fork.id); + const workflows = workflowsMap[fork.id] || []; + const isLoadingWf = loadingWorkflows.has(fork.id); + const isSyncing = syncingForks.has(fork.id); + + return ( + toggleWorkflows(fork.id)} + onSyncUpstream={() => handleSyncUpstream(fork)} + onMarkAsRead={() => markForkAsRead(fork.id)} + onRunWorkflow={(workflowId, workflowName, branch) => handleRunWorkflow(fork.id, workflowId, workflowName, branch)} + workflows={workflows} + isLoadingWorkflows={isLoadingWf} + isSyncing={isSyncing} + language={language} + /> + ); + }) + )} +
+ + {/* Bottom Pagination */} + {totalPages > 1 && ( +
+
+ + + + {getPageNumbers().map((page, index) => ( + + ))} + + + +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/Header.tsx b/src/components/Header.tsx index a2de590..852b40b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect } from 'react'; -import { Settings, Calendar, Search, Moon, Sun, LogOut, RefreshCw, TrendingUp } from 'lucide-react'; +import { Settings, Calendar, Search, Moon, Sun, LogOut, RefreshCw, TrendingUp, GitFork } from 'lucide-react'; import { useAppStore } from '../store/useAppStore'; import { GitHubApiService } from '../services/githubApi'; import { useDialog } from '../hooks/useDialog'; @@ -207,6 +207,19 @@ export const Header: React.FC = () => { {!isTextWrapped && t('发布', 'Releases')} + + + - {/* Sync Upstream button */} + {/* Sync Upstream button — only enabled for actual forks */} ))} @@ -237,4 +246,4 @@ const ForkCard: React.FC = memo(({ ForkCard.displayName = 'ForkCard'; -export default ForkCard; \ No newline at end of file +export default ForkCard; diff --git a/src/components/ForkTimeline.tsx b/src/components/ForkTimeline.tsx index fa434cf..5cbd384 100644 --- a/src/components/ForkTimeline.tsx +++ b/src/components/ForkTimeline.tsx @@ -1,6 +1,6 @@ import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { Package, Search, X, RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; -import { ForkRepo, WorkflowRun } from '../types'; +import { ForkRepo, WorkflowDefinition } from '../types'; import { useAppStore } from '../store/useAppStore'; import { GitHubApiService } from '../services/githubApi'; import { formatDistanceToNow } from 'date-fns'; @@ -33,14 +33,14 @@ export const ForkTimeline: React.FC = () => { setForkIsRefreshing, } = useAppStore(); - const { toast } = useDialog(); + const { toast, confirm } = useDialog(); const [lastRefreshTime, setLastRefreshTime] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(20); // Workflow expansion state (local UI state) const [expandedWorkflows, setExpandedWorkflows] = useState>(new Set()); - const [workflowsMap, setWorkflowsMap] = useState>({}); + const [workflowsMap, setWorkflowsMap] = useState>({}); const [loadingWorkflows, setLoadingWorkflows] = useState>(new Set()); const [syncingForks, setSyncingForks] = useState>(new Set()); @@ -263,6 +263,25 @@ export const ForkTimeline: React.FC = () => { return; } + // Only forks (those with a parent/source) can be synced + if (!fork.parent && !fork.source) { + toast(language === 'zh' + ? `${fork.name} 不是 Fork 仓库,无法同步上游。` + : `${fork.name} is not a fork. Cannot sync upstream.`, + 'error' + ); + return; + } + + const confirmed = await confirm( + language === 'zh' ? '确认同步' : 'Confirm Sync', + language === 'zh' + ? `确定要将 "${fork.name}" 同步到上游仓库 "${fork.source?.full_name || fork.parent?.full_name}" 吗?` + : `Sync "${fork.name}" with upstream "${fork.source?.full_name || fork.parent?.full_name}"?`, + { type: 'info' } + ); + if (!confirmed) return; + setSyncingForks(prev => new Set(prev).add(fork.id)); try { const [owner, repo] = fork.full_name.split('/'); @@ -283,20 +302,33 @@ export const ForkTimeline: React.FC = () => { }); } - toast(language === 'zh' - ? `已同步 ${fork.name} 到最新版本。` - : `${fork.name} synced to latest.`, - 'success' - ); + if (result.mergeType === 'none') { + toast(language === 'zh' + ? `${fork.name} 已是最新版本,无需同步。` + : `${fork.name} is already up to date.`, + 'info' + ); + } else { + toast(language === 'zh' + ? `已同步 ${fork.name} 到最新版本。` + : `${fork.name} synced to latest.`, + 'success' + ); + } } catch (error) { console.error('Sync failed:', error); const errorMsg = error instanceof Error ? error.message : String(error); - // 409 means already up to date - if (errorMsg.includes('409') || errorMsg.toLowerCase().includes('already up to date')) { + if (errorMsg === 'NOT_A_FORK') { toast(language === 'zh' - ? `${fork.name} 已是最新版本。` - : `${fork.name} is already up to date.`, - 'info' + ? `${fork.name} 不是 Fork 仓库,无法同步上游。` + : `${fork.name} is not a fork. Cannot sync upstream.`, + 'error' + ); + } else if (errorMsg === 'MERGE_CONFLICT') { + toast(language === 'zh' + ? `同步失败:${fork.name} 与上游仓库存在合并冲突,请手动解决后重试。` + : `Sync failed: ${fork.name} has merge conflicts with upstream. Please resolve manually.`, + 'error' ); } else { toast(language === 'zh' @@ -314,7 +346,7 @@ export const ForkTimeline: React.FC = () => { } }; - const handleRunWorkflow = async (forkId: number, workflowPath: string, workflowName: string, branch: string) => { + const handleRunWorkflow = async (forkId: number, workflowPath: string, workflowName: string) => { if (!githubToken) { toast(language === 'zh' ? 'GitHub token 未找到,请重新登录。' : 'GitHub token not found. Please login again.', 'error'); return; @@ -323,6 +355,7 @@ export const ForkTimeline: React.FC = () => { const fork = forks.find(f => f.id === forkId); if (!fork) return; + const branch = fork.default_branch || 'main'; try { const [owner, repo] = fork.full_name.split('/'); const githubApi = new GitHubApiService(githubToken); @@ -565,7 +598,7 @@ export const ForkTimeline: React.FC = () => { onToggleWorkflows={() => toggleWorkflows(fork.id)} onSyncUpstream={() => handleSyncUpstream(fork)} onMarkAsRead={() => markForkAsRead(fork.id)} - onRunWorkflow={(workflowId, workflowName, branch) => handleRunWorkflow(fork.id, workflowId, workflowName, branch)} + onRunWorkflow={(workflowPath, workflowName) => handleRunWorkflow(fork.id, workflowPath, workflowName)} workflows={workflows} isLoadingWorkflows={isLoadingWf} isSyncing={isSyncing} diff --git a/src/services/githubApi.ts b/src/services/githubApi.ts index 5bbf761..c2b4835 100644 --- a/src/services/githubApi.ts +++ b/src/services/githubApi.ts @@ -14,7 +14,7 @@ import { GitHubSearchUserResponse, GitHubUserDetail, ForkRepo, - WorkflowRun, + WorkflowDefinition, } from '../types'; interface GitHubStarredItem { @@ -956,21 +956,17 @@ export class GitHubApiService { async getUserForks(): Promise { try { - // Use the search API to find all forks owned by the authenticated user - // The /user/forks endpoint doesn't exist; use search: user:{login} fork:true - const user = await this.makeRequest<{ login: string }>('/user'); - const login = user.login; - + // Use /user/repos?type=forks to get only repositories the user has forked let allForks: ForkRepo[] = []; let page = 1; const perPage = 100; while (true) { - const data = await this.makeRequest<{ items: ForkRepo[]; total_count: number }>( - `/search/repositories?q=user:${login}+fork:true&sort=updated&per_page=${perPage}&page=${page}` + const forks = await this.makeRequest( + `/user/repos?type=forks&sort=updated&per_page=${perPage}&page=${page}` ); - allForks = [...allForks, ...data.items]; - if (data.items.length < perPage) break; + allForks = [...allForks, ...forks]; + if (forks.length < perPage) break; page++; // Rate limiting protection await new Promise(resolve => setTimeout(resolve, 100)); @@ -983,10 +979,10 @@ async getUserForks(): Promise { } } - async syncFork(owner: string, repo: string, branch: string): Promise<{ hasUpdates: boolean; sourceUpdatedAt: string | null }> { + async syncFork(owner: string, repo: string, branch: string): Promise<{ hasUpdates: boolean; sourceUpdatedAt: string | null; mergeType?: string }> { // Use GitHub's merge upstream API to sync the fork with its upstream try { - await this.makeRequest<{ message: string }>( + const result = await this.makeRequest<{ merge_type: string; message?: string }>( `/repos/${owner}/${repo}/merge_upstream`, { method: 'POST', @@ -994,32 +990,45 @@ async getUserForks(): Promise { } ); return { - hasUpdates: false, + hasUpdates: result.merge_type !== 'none', sourceUpdatedAt: new Date().toISOString(), + mergeType: result.merge_type, }; } catch (error) { - // 409 Conflict means nothing to merge (already up to date) - not an error - // Any other error should be thrown + if (error instanceof Error) { + // Check for HTTP status in error message + const msg = error.message; + // 404: not a fork (no upstream configured) — treat as not syncable + if (msg.includes('404')) { + throw new Error('NOT_A_FORK'); + } + // 409: merge conflict — can't auto-merge + if (msg.includes('409')) { + throw new Error('MERGE_CONFLICT'); + } + } throw error; } } - async getRepositoryWorkflows(owner: string, repo: string): Promise { + async getRepositoryWorkflows(owner: string, repo: string): Promise { try { - // Use expand=run to include workflow path and definition ID in the response - const runs = await this.makeRequest<{ workflow_runs: WorkflowRun[] }>( - `/repos/${owner}/${repo}/actions/runs?per_page=20&expand=run` + // GET /repos/{owner}/{repo}/actions/workflows lists workflow files (definitions), not runs + const data = await this.makeRequest<{ workflows: WorkflowDefinition[] }>( + `/repos/${owner}/${repo}/actions/workflows?per_page=100` ); - return runs.workflow_runs || []; + return data.workflows || []; } catch (error) { console.warn(`Failed to fetch workflows for ${owner}/${repo}:`, error); return []; } } - async triggerWorkflowRun(owner: string, repo: string, workflowId: string, branch: string): Promise { + async triggerWorkflowRun(owner: string, repo: string, workflowPath: string, branch: string): Promise { + // workflowPath is the file path (e.g. ".github/workflows/ci.yml") — URL-encode it + const encodedPath = encodeURIComponent(workflowPath); await this.makeRequest( - `/repos/${owner}/${repo}/actions/workflows/${workflowId}/dispatches`, + `/repos/${owner}/${repo}/actions/workflows/${encodedPath}/dispatches`, { method: 'POST', body: JSON.stringify({ ref: branch }), diff --git a/src/types/index.ts b/src/types/index.ts index 4c6dddc..dbff1f2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -105,20 +105,16 @@ export interface ForkRepo { upstream_updated_at?: string; // last time we checked/fetched upstream updates } -export interface WorkflowRun { +export interface WorkflowDefinition { id: number; name: string; - status: string; - conclusion: string | null; - head_branch: string; - head_sha: string; - html_url: string; + path: string; // workflow file path, e.g. ".github/workflows/ci.yml" + state: string; // "active" | "disabled" | "warning" created_at: string; updated_at: string; - run_number: number; - event: string; - path: string; // workflow file path, e.g. ".github/workflows/ci.yml" - workflow_id: number; // workflow definition ID (not the run ID) + url: string; + html_url: string; + badge_url: string; } export interface GitHubUser { From 6afc7a3340dfd062c851de6090b47eb907f3a781 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Mon, 4 May 2026 21:45:29 +0800 Subject: [PATCH 04/11] fix: title, isFork detection, and workflow loading state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change title from "复刻时间线"/"Fork Timeline" to "复刻"/"Fork" - Fix isFork check: also check fork.fork boolean (always present in GitHub API) - Add fork: boolean field to ForkRepo type - Add isRunningWorkflow state with Loader2 spinner on run workflow button - Add setRunningWorkflows state to ForkTimeline with finally block Co-Authored-By: Claude Sonnet 4.6 --- src/components/ForkCard.tsx | 14 ++++++++++---- src/components/ForkTimeline.tsx | 12 +++++++++++- src/types/index.ts | 1 + 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/components/ForkCard.tsx b/src/components/ForkCard.tsx index 7918fe6..4473ada 100644 --- a/src/components/ForkCard.tsx +++ b/src/components/ForkCard.tsx @@ -14,6 +14,7 @@ interface ForkCardProps { workflows: WorkflowDefinition[]; isLoadingWorkflows: boolean; isSyncing: boolean; + isRunningWorkflow: boolean; language: 'zh' | 'en'; } @@ -28,14 +29,15 @@ const ForkCard: React.FC = memo(({ workflows, isLoadingWorkflows, isSyncing, + isRunningWorkflow, language, }) => { const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]); const sourceFullName = fork.source?.full_name || fork.parent?.full_name || ''; const sourceName = fork.source?.name || fork.parent?.name || ''; - // A repo is only a fork if it has a parent or source - const isFork = !!fork.parent || !!fork.source; + // A repo is only a fork if it has a parent/source OR the fork boolean is true + const isFork = !!fork.parent || !!fork.source || fork.fork === true; return (
= memo(({ e.stopPropagation(); onRunWorkflow(workflow.path, workflow.name); }} - disabled={workflow.state === 'disabled'} + disabled={workflow.state === 'disabled' || isRunningWorkflow} className="ml-2 p-1.5 rounded bg-brand-indigo text-white hover:bg-brand-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex-shrink-0" title={workflow.state === 'disabled' ? (language === 'zh' ? '工作流已禁用' : 'Workflow disabled') : (language === 'zh' ? '运行工作流' : 'Run workflow') } > - + {isRunningWorkflow ? ( + + ) : ( + + )}
))} diff --git a/src/components/ForkTimeline.tsx b/src/components/ForkTimeline.tsx index 5cbd384..83f91ce 100644 --- a/src/components/ForkTimeline.tsx +++ b/src/components/ForkTimeline.tsx @@ -43,6 +43,7 @@ export const ForkTimeline: React.FC = () => { const [workflowsMap, setWorkflowsMap] = useState>({}); const [loadingWorkflows, setLoadingWorkflows] = useState>(new Set()); const [syncingForks, setSyncingForks] = useState>(new Set()); + const [runningWorkflows, setRunningWorkflows] = useState>(new Set()); // Alias global state for local use const viewMode = forkViewMode; @@ -356,6 +357,7 @@ export const ForkTimeline: React.FC = () => { if (!fork) return; const branch = fork.default_branch || 'main'; + setRunningWorkflows(prev => new Set(prev).add(forkId)); try { const [owner, repo] = fork.full_name.split('/'); const githubApi = new GitHubApiService(githubToken); @@ -376,6 +378,12 @@ export const ForkTimeline: React.FC = () => { : `Failed to run workflow.`, 'error' ); + } finally { + setRunningWorkflows(prev => { + const next = new Set(prev); + next.delete(forkId); + return next; + }); } }; @@ -417,7 +425,7 @@ export const ForkTimeline: React.FC = () => {

- {t('复刻时间线', 'Fork Timeline')} + {t('复刻', 'Fork')}

{t(`管理您的 ${forks.length} 个Fork仓库`, `Manage your ${forks.length} forked repositories`)} @@ -588,6 +596,7 @@ export const ForkTimeline: React.FC = () => { const workflows = workflowsMap[fork.id] || []; const isLoadingWf = loadingWorkflows.has(fork.id); const isSyncing = syncingForks.has(fork.id); + const isRunningWf = runningWorkflows.has(fork.id); return ( { workflows={workflows} isLoadingWorkflows={isLoadingWf} isSyncing={isSyncing} + isRunningWorkflow={isRunningWf} language={language} /> ); diff --git a/src/types/index.ts b/src/types/index.ts index dbff1f2..6f27b90 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -66,6 +66,7 @@ export interface Release { export interface ForkRepo { id: number; name: string; + fork: boolean; full_name: string; description: string | null; html_url: string; From 82f362bfb25205f00a902e438ab8160358d741e9 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Mon, 4 May 2026 22:05:47 +0800 Subject: [PATCH 05/11] fix: fork detection, sync error, coderabbit audit items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ForkCard: remove "非Fork" label entirely - ForkCard: show "Forked from Owner/Repo" with clickable link to upstream - ForkCard: add aria-label for run workflow button - ForkTimeline: use fork.fork instead of parent/source for sync guard - ForkTimeline: fix invalid Tailwind class border-black/[0.06]-alt - githubApi: handle HTTP 422 as "already up to date" (none merge type) GitHub returns 422 when branch is already up-to-date, not 409 Co-Authored-By: Claude Sonnet 4.6 --- src/components/ForkCard.tsx | 23 +++++++++++++++++++++-- src/components/ForkTimeline.tsx | 6 +++--- src/services/githubApi.ts | 8 ++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/components/ForkCard.tsx b/src/components/ForkCard.tsx index 4473ada..d96eca9 100644 --- a/src/components/ForkCard.tsx +++ b/src/components/ForkCard.tsx @@ -79,8 +79,23 @@ const ForkCard: React.FC = memo(({

{sourceFullName && (

- - {sourceFullName} + {t('Forked from', 'Forked from')} + {fork.parent?.html_url || fork.source?.html_url ? ( + { + e.stopPropagation(); + onMarkAsRead(); + }} + > + {sourceFullName} + + ) : ( + {sourceFullName} + )}

)}
@@ -227,6 +242,10 @@ const ForkCard: React.FC = memo(({ }} disabled={workflow.state === 'disabled' || isRunningWorkflow} className="ml-2 p-1.5 rounded bg-brand-indigo text-white hover:bg-brand-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex-shrink-0" + aria-label={workflow.state === 'disabled' + ? (language === 'zh' ? '工作流已禁用' : 'Workflow disabled') + : `${language === 'zh' ? '运行工作流' : 'Run workflow'}: ${workflow.name}` + } title={workflow.state === 'disabled' ? (language === 'zh' ? '工作流已禁用' : 'Workflow disabled') : (language === 'zh' ? '运行工作流' : 'Run workflow') diff --git a/src/components/ForkTimeline.tsx b/src/components/ForkTimeline.tsx index 83f91ce..fb8405c 100644 --- a/src/components/ForkTimeline.tsx +++ b/src/components/ForkTimeline.tsx @@ -264,8 +264,8 @@ export const ForkTimeline: React.FC = () => { return; } - // Only forks (those with a parent/source) can be synced - if (!fork.parent && !fork.source) { + // Only actual forks can be synced + if (!fork.fork) { toast(language === 'zh' ? `${fork.name} 不是 Fork 仓库,无法同步上游。` : `${fork.name} is not a fork. Cannot sync upstream.`, @@ -572,7 +572,7 @@ export const ForkTimeline: React.FC = () => { {/* Fork List */}
{paginatedForks.length === 0 ? ( -
+

{t('无符合条件的结果', 'No matching results')} diff --git a/src/services/githubApi.ts b/src/services/githubApi.ts index c2b4835..dee8da6 100644 --- a/src/services/githubApi.ts +++ b/src/services/githubApi.ts @@ -1006,6 +1006,14 @@ async getUserForks(): Promise { if (msg.includes('409')) { throw new Error('MERGE_CONFLICT'); } + // 422: branch is already up to date (GitHub returns 422 Unprocessable Entity for this) + if (msg.includes('422')) { + return { + hasUpdates: false, + sourceUpdatedAt: null, + mergeType: 'none', + }; + } } throw error; } From 359b5058cb7faf4b63500ac1c6cb0bcd5cba1353 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Mon, 4 May 2026 22:36:33 +0800 Subject: [PATCH 06/11] fix: sync button state, confirm dialog, and unread badge clears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-reported BUG fixes: 1. Filter only fork repos: filteredForks now filters fork.fork===true (removes self-created repos from list, no "非Fork" label needed) 2. Show "Forked from Owner/Repo" with clickable link to upstream repo 3. Sync button disabled by needsSync state (via checkForkSyncNeeded API) Only enabled when GitHub returns non-422 (fork is out-of-date) 4. Confirm dialog: title "Update branch", message "Update {repo}'s {branch} branch by merging upstream changes?" per GitHub UI copy 5. Clear unread badge on: toggle workflows, sync, run workflow (stopPropagation handlers now call onMarkAsRead) Also add checkForkSyncNeeded() to githubApi.ts — calls merge_upstream, returns true if not-422 (out-of-date), false if 422 (already up-to-date). Pre-check runs on refresh for all forks in parallel. Co-Authored-By: Claude Sonnet 4.6 --- src/components/ForkCard.tsx | 23 +++++++------ src/components/ForkTimeline.tsx | 61 +++++++++++++++++---------------- src/services/githubApi.ts | 34 ++++++++++-------- 3 files changed, 62 insertions(+), 56 deletions(-) diff --git a/src/components/ForkCard.tsx b/src/components/ForkCard.tsx index d96eca9..a4881b4 100644 --- a/src/components/ForkCard.tsx +++ b/src/components/ForkCard.tsx @@ -15,6 +15,7 @@ interface ForkCardProps { isLoadingWorkflows: boolean; isSyncing: boolean; isRunningWorkflow: boolean; + needsSync: boolean; // true = out-of-date, can sync; false = already up-to-date language: 'zh' | 'en'; } @@ -30,12 +31,12 @@ const ForkCard: React.FC = memo(({ isLoadingWorkflows, isSyncing, isRunningWorkflow, + needsSync, language, }) => { const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]); const sourceFullName = fork.source?.full_name || fork.parent?.full_name || ''; - const sourceName = fork.source?.name || fork.parent?.name || ''; // A repo is only a fork if it has a parent/source OR the fork boolean is true const isFork = !!fork.parent || !!fork.source || fork.fork === true; @@ -68,11 +69,6 @@ const ForkCard: React.FC = memo(({ {fork.language} )} - {!isFork && ( - - {t('非Fork', 'Not Fork')} - - )}

{fork.full_name} @@ -126,6 +122,7 @@ const ForkCard: React.FC = memo(({ onClick={(e) => { e.stopPropagation(); onToggleWorkflows(); + onMarkAsRead(); }} className={`flex items-center space-x-0.5 px-1.5 py-1 rounded transition-all duration-200 whitespace-nowrap ${ isWorkflowsExpanded @@ -141,20 +138,23 @@ const ForkCard: React.FC = memo(({ {isWorkflowsExpanded ? : } - {/* Sync Upstream button — only enabled for actual forks */} + {/* Sync Upstream button — enabled only when fork needs sync (out-of-date) */}

diff --git a/src/services/githubApi.ts b/src/services/githubApi.ts index 56c1a29..69315ef 100644 --- a/src/services/githubApi.ts +++ b/src/services/githubApi.ts @@ -1008,18 +1008,25 @@ async getUserForks(): Promise { } // Check if a fork's branch is behind its upstream — returns true if "out-of-date" - async checkForkSyncNeeded(owner: string, repo: string, branch: string): Promise { + async checkForkSyncNeeded(owner: string, repo: string, branch: string, parentFullName?: string): Promise { try { - await this.makeRequest<{ merge_type: string }>( - `/repos/${owner}/${repo}/merge_upstream`, - { method: 'POST', body: JSON.stringify({ ref: branch }) } + let parentOwner = ''; + if (parentFullName) { + parentOwner = parentFullName.split('/')[0]; + } else { + const repoData = await this.makeRequest<{ parent?: { owner: { login: string } } }>(`/repos/${owner}/${repo}`); + if (!repoData.parent) return false; + parentOwner = repoData.parent.owner.login; + } + + const compareData = await this.makeRequest<{ behind_by: number }>( + `/repos/${owner}/${repo}/compare/${parentOwner}:${branch}...${owner}:${branch}` ); - return true; + + return compareData.behind_by > 0; } catch (error) { - if (error instanceof Error && error.message.includes('422')) { - return false; - } - throw error; + console.warn(`Failed to check sync status for ${owner}/${repo}:`, error); + return false; } } From 083482c1b877daef91b5126561fc38edd7b6dfbc Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Mon, 4 May 2026 23:23:37 +0800 Subject: [PATCH 09/11] fix: populate fork parent info and fix merge_upstream branch param --- src/components/ForkTimeline.tsx | 16 ++++++++++++++-- src/services/githubApi.ts | 21 +++++++++++++++------ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/components/ForkTimeline.tsx b/src/components/ForkTimeline.tsx index b1708ab..c7629f6 100644 --- a/src/components/ForkTimeline.tsx +++ b/src/components/ForkTimeline.tsx @@ -204,13 +204,25 @@ export const ForkTimeline: React.FC = () => { const [owner, repo] = fork.full_name.split('/'); const branch = fork.default_branch || 'main'; try { - const needsSync = await githubApi.checkForkSyncNeeded( + const result = await githubApi.checkForkSyncNeeded( owner, repo, branch, fork.parent?.full_name || fork.source?.full_name ); - setNeedsSyncMap(prev => ({ ...prev, [fork.id]: needsSync })); + setNeedsSyncMap(prev => ({ ...prev, [fork.id]: result.needsSync })); + + if (result.parentFullName && result.parentHtmlUrl && !fork.parent && !fork.source) { + setForks(prev => prev.map(f => f.id === fork.id ? { + ...f, + parent: { + id: 0, + full_name: result.parentFullName as string, + name: (result.parentFullName as string).split('/')[1], + html_url: result.parentHtmlUrl as string + } + } : f)); + } } catch { setNeedsSyncMap(prev => ({ ...prev, [fork.id]: false })); } diff --git a/src/services/githubApi.ts b/src/services/githubApi.ts index 69315ef..43dd6ba 100644 --- a/src/services/githubApi.ts +++ b/src/services/githubApi.ts @@ -986,7 +986,7 @@ async getUserForks(): Promise { `/repos/${owner}/${repo}/merge_upstream`, { method: 'POST', - body: JSON.stringify({ ref: branch }), + body: JSON.stringify({ branch }), } ); return { @@ -1008,25 +1008,34 @@ async getUserForks(): Promise { } // Check if a fork's branch is behind its upstream — returns true if "out-of-date" - async checkForkSyncNeeded(owner: string, repo: string, branch: string, parentFullName?: string): Promise { + async checkForkSyncNeeded(owner: string, repo: string, branch: string, parentFullName?: string): Promise<{ needsSync: boolean, parentFullName?: string, parentHtmlUrl?: string }> { try { let parentOwner = ''; + let resultParentFullName = parentFullName; + let resultParentHtmlUrl: string | undefined = undefined; + if (parentFullName) { parentOwner = parentFullName.split('/')[0]; } else { - const repoData = await this.makeRequest<{ parent?: { owner: { login: string } } }>(`/repos/${owner}/${repo}`); - if (!repoData.parent) return false; + const repoData = await this.makeRequest<{ parent?: { owner: { login: string }, full_name: string, html_url: string } }>(`/repos/${owner}/${repo}`); + if (!repoData.parent) return { needsSync: false }; parentOwner = repoData.parent.owner.login; + resultParentFullName = repoData.parent.full_name; + resultParentHtmlUrl = repoData.parent.html_url; } const compareData = await this.makeRequest<{ behind_by: number }>( `/repos/${owner}/${repo}/compare/${parentOwner}:${branch}...${owner}:${branch}` ); - return compareData.behind_by > 0; + return { + needsSync: compareData.behind_by > 0, + parentFullName: resultParentFullName, + parentHtmlUrl: resultParentHtmlUrl + }; } catch (error) { console.warn(`Failed to check sync status for ${owner}/${repo}:`, error); - return false; + return { needsSync: false }; } } From 34329b0dbb4824b3835f53395722d01790bb6f05 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Mon, 4 May 2026 23:36:58 +0800 Subject: [PATCH 10/11] feat: add branch selection modal for fork sync and fix useMemo TypeError --- src/components/ForkTimeline.tsx | 133 ++++++++++++++++++++++++++++---- src/services/githubApi.ts | 10 +++ 2 files changed, 128 insertions(+), 15 deletions(-) diff --git a/src/components/ForkTimeline.tsx b/src/components/ForkTimeline.tsx index c7629f6..622f654 100644 --- a/src/components/ForkTimeline.tsx +++ b/src/components/ForkTimeline.tsx @@ -1,12 +1,12 @@ import React, { useState, useMemo, useCallback, useEffect } from 'react'; -import { Package, Search, X, RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; +import { Package, Search, X, RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2, GitFork } from 'lucide-react'; import { ForkRepo, WorkflowDefinition } from '../types'; import { useAppStore } from '../store/useAppStore'; import { GitHubApiService } from '../services/githubApi'; import { formatDistanceToNow } from 'date-fns'; import ForkCard from './ForkCard'; import { useDialog } from '../hooks/useDialog'; -import { GitFork } from 'lucide-react'; +import { Modal } from './Modal'; export const ForkTimeline: React.FC = () => { const { @@ -47,6 +47,25 @@ export const ForkTimeline: React.FC = () => { // Track which forks need sync (out-of-date vs already-up-to-date) const [needsSyncMap, setNeedsSyncMap] = useState>({}); + // Sync Modal state + const [syncModal, setSyncModal] = useState<{ + isOpen: boolean; + forkId: number | null; + owner: string; + repo: string; + branch: string; + full_name: string; + }>({ + isOpen: false, + forkId: null, + owner: '', + repo: '', + branch: 'main', + full_name: '' + }); + const [syncModalBranches, setSyncModalBranches] = useState([]); + const [isFetchingBranches, setIsFetchingBranches] = useState(false); + // Alias global state for local use const viewMode = forkViewMode; const selectedFilters = forkSelectedFilters; @@ -213,7 +232,8 @@ export const ForkTimeline: React.FC = () => { setNeedsSyncMap(prev => ({ ...prev, [fork.id]: result.needsSync })); if (result.parentFullName && result.parentHtmlUrl && !fork.parent && !fork.source) { - setForks(prev => prev.map(f => f.id === fork.id ? { + const currentForks = useAppStore.getState().forks; + setForks(currentForks.map(f => f.id === fork.id ? { ...f, parent: { id: 0, @@ -299,20 +319,46 @@ export const ForkTimeline: React.FC = () => { return; } - const confirmed = await confirm( - language === 'zh' ? 'Update branch' : 'Update branch', - language === 'zh' - ? `将 ${fork.name} 的 ${fork.default_branch || 'main'} 分支更新到上游仓库的最新版本?` - : `Update ${fork.name}'s ${fork.default_branch || 'main'} branch by merging upstream changes?`, - { type: 'info' } - ); - if (!confirmed) return; + const defaultBranch = fork.default_branch || 'main'; + const [owner, repo] = fork.full_name.split('/'); + + setSyncModal({ + isOpen: true, + forkId: fork.id, + owner, + repo, + branch: defaultBranch, + full_name: fork.full_name + }); + setSyncModalBranches([]); + setIsFetchingBranches(true); + + try { + const githubApi = new GitHubApiService(githubToken); + const branches = await githubApi.getBranches(owner, repo); + setSyncModalBranches(branches); + if (branches.length > 0 && !branches.includes(defaultBranch)) { + setSyncModal(prev => ({ ...prev, branch: branches[0] })); + } + } finally { + setIsFetchingBranches(false); + } + }; + + const confirmSyncUpstream = async () => { + if (!githubToken || !syncModal.forkId) return; + + const fork = forks.find(f => f.id === syncModal.forkId); + if (!fork) return; + const { owner, repo, branch } = syncModal; + + setSyncModal(prev => ({ ...prev, isOpen: false })); setSyncingForks(prev => new Set(prev).add(fork.id)); + try { - const [owner, repo] = fork.full_name.split('/'); const githubApi = new GitHubApiService(githubToken); - const result = await githubApi.syncFork(owner, repo, fork.default_branch || 'main'); + const result = await githubApi.syncFork(owner, repo, branch); // Mark fork as up-to-date in UI setNeedsSyncMap(prev => ({ ...prev, [fork.id]: false })); @@ -325,8 +371,8 @@ export const ForkTimeline: React.FC = () => { ); } else { toast(language === 'zh' - ? `已将 ${fork.name} 更新到上游最新版本。` - : `${fork.name} has been updated from upstream.`, + ? `已将 ${fork.name} 成功更新到上游最新版本。` + : `${fork.name} has been successfully updated from upstream.`, 'success' ); } @@ -688,6 +734,63 @@ export const ForkTimeline: React.FC = () => {
)} + + {/* Sync Branch Modal */} + setSyncModal(prev => ({ ...prev, isOpen: false }))} + title={language === 'zh' ? '同步上游代码 (Sync upstream)' : 'Sync Upstream'} + > +
+

+ {language === 'zh' + ? `选择要将上游变更合并到的分支 (${syncModal.full_name}):` + : `Select the branch to merge upstream changes into for ${syncModal.full_name}:`} +

+ +
+ + {isFetchingBranches ? ( +
+ + {language === 'zh' ? '加载分支列表中...' : 'Loading branches...'} +
+ ) : ( + + )} +
+ +
+ + +
+
+
); }; \ No newline at end of file diff --git a/src/services/githubApi.ts b/src/services/githubApi.ts index 43dd6ba..d14b048 100644 --- a/src/services/githubApi.ts +++ b/src/services/githubApi.ts @@ -1039,6 +1039,16 @@ async getUserForks(): Promise { } } + async getBranches(owner: string, repo: string): Promise { + try { + const branches = await this.makeRequest<{ name: string }[]>(`/repos/${owner}/${repo}/branches?per_page=100`); + return branches.map(b => b.name); + } catch (error) { + console.warn(`Failed to fetch branches for ${owner}/${repo}:`, error); + return []; + } + } + async getRepositoryWorkflows(owner: string, repo: string): Promise { try { // GET /repos/{owner}/{repo}/actions/workflows lists workflow files (definitions), not runs From 337e9f8132bf43bf4cee7e10d80458735690002c Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Mon, 4 May 2026 23:47:18 +0800 Subject: [PATCH 11/11] fix: correct github api endpoint to merge-upstream --- src/services/githubApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/githubApi.ts b/src/services/githubApi.ts index d14b048..6efd5a4 100644 --- a/src/services/githubApi.ts +++ b/src/services/githubApi.ts @@ -983,7 +983,7 @@ async getUserForks(): Promise { // Use GitHub's merge upstream API to sync the fork with its upstream try { const result = await this.makeRequest<{ merge_type: string; message?: string }>( - `/repos/${owner}/${repo}/merge_upstream`, + `/repos/${owner}/${repo}/merge-upstream`, { method: 'POST', body: JSON.stringify({ branch }),