diff --git a/package-lock.json b/package-lock.json index c46a2d2..4620d13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "github-stars-manager", - "version": "0.5.5", + "version": "0.5.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-stars-manager", - "version": "0.5.5", + "version": "0.5.6", "dependencies": { "@types/query-string": "^6.2.0", "date-fns": "^3.3.1", 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..a4881b4 --- /dev/null +++ b/src/components/ForkCard.tsx @@ -0,0 +1,275 @@ +import React, { memo, useCallback } from 'react'; +import { ExternalLink, GitFork, RefreshCw, ChevronDown, ChevronUp, FolderOpen, Folder, Play, Loader2 } from 'lucide-react'; +import { ForkRepo, WorkflowDefinition } from '../types'; +import { formatDistanceToNow } from 'date-fns'; + +interface ForkCardProps { + fork: ForkRepo; + isUnread: boolean; + isWorkflowsExpanded: boolean; + onToggleWorkflows: () => void; + onSyncUpstream: () => void; + onMarkAsRead: () => void; + onRunWorkflow: (workflowPath: string, workflowName: string) => void; + workflows: WorkflowDefinition[]; + isLoadingWorkflows: boolean; + isSyncing: boolean; + isRunningWorkflow: boolean; + needsSync: boolean; // true = out-of-date, can sync; false = already up-to-date + language: 'zh' | 'en'; +} + +const ForkCard: React.FC = memo(({ + fork, + isUnread, + isWorkflowsExpanded, + onToggleWorkflows, + onSyncUpstream, + onMarkAsRead, + onRunWorkflow, + workflows, + 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 || ''; + // 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 ( +
+ {/* Header */} +
+
+
+ {isUnread && ( +
+ )} +
+ +
+
+
+

+ {fork.name} +

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

+ {fork.full_name} +

+ {sourceFullName && ( +

+ {t('Forked from', 'Forked from')} + {fork.parent?.html_url || fork.source?.html_url ? ( + { + e.stopPropagation(); + onMarkAsRead(); + }} + > + {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 — enabled only when fork needs sync (out-of-date) */} + + + {/* 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.path} +

+
+
+ +
+ ))} +
+
+ )} +
+
+
+
+ ); +}); + +ForkCard.displayName = 'ForkCard'; + +export default ForkCard; diff --git a/src/components/ForkTimeline.tsx b/src/components/ForkTimeline.tsx new file mode 100644 index 0000000..622f654 --- /dev/null +++ b/src/components/ForkTimeline.tsx @@ -0,0 +1,796 @@ +import React, { useState, useMemo, useCallback, useEffect } from '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 { Modal } from './Modal'; + +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, 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 [loadingWorkflows, setLoadingWorkflows] = useState>(new Set()); + const [syncingForks, setSyncingForks] = useState>(new Set()); + const [runningWorkflows, setRunningWorkflows] = useState>(new Set()); + // 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; + 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]; + + // Only show actual forks (not user-created repos) + filtered = filtered.filter(fork => fork.fork === true); + + // 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); + + // Pre-check sync status for all forks (out-of-date vs already up-to-date) + const syncChecks: Promise[] = updatedForks.map(async (fork) => { + if (!fork.fork) return; + const [owner, repo] = fork.full_name.split('/'); + const branch = fork.default_branch || 'main'; + try { + const result = await githubApi.checkForkSyncNeeded( + owner, + repo, + branch, + fork.parent?.full_name || fork.source?.full_name + ); + setNeedsSyncMap(prev => ({ ...prev, [fork.id]: result.needsSync })); + + if (result.parentFullName && result.parentHtmlUrl && !fork.parent && !fork.source) { + const currentForks = useAppStore.getState().forks; + setForks(currentForks.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 })); + } + }); + await Promise.all(syncChecks); + + // 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 || !githubToken) 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; + } + + 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 githubApi = new GitHubApiService(githubToken); + const result = await githubApi.syncFork(owner, repo, branch); + + // Mark fork as up-to-date in UI + setNeedsSyncMap(prev => ({ ...prev, [fork.id]: false })); + + 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} has been successfully updated from upstream.`, + 'success' + ); + } + } catch (error) { + console.error('Sync failed:', error); + const errorMsg = error instanceof Error ? error.message : String(error); + if (errorMsg === 'NOT_A_FORK') { + toast(language === 'zh' + ? `${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' + ? `同步失败: ${errorMsg}` + : `Sync failed: ${errorMsg}`, + 'error' + ); + } + } finally { + setSyncingForks(prev => { + const newSet = new Set(prev); + newSet.delete(fork.id); + return newSet; + }); + } + }; + + 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; + } + + const fork = forks.find(f => f.id === forkId); + 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); + await githubApi.triggerWorkflowRun(owner, repo, workflowPath, 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' + ); + } finally { + setRunningWorkflows(prev => { + const next = new Set(prev); + next.delete(forkId); + return next; + }); + } + }; + + 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')} +

+

+ {t(`管理您的 ${forks.filter(f => f.fork).length} 个Fork仓库`, `Manage your ${forks.filter(f => f.fork).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); + const isRunningWf = runningWorkflows.has(fork.id); + const needsSync = needsSyncMap[fork.id] ?? true; + + return ( + toggleWorkflows(fork.id)} + onSyncUpstream={() => handleSyncUpstream(fork)} + onMarkAsRead={() => markForkAsRead(fork.id)} + onRunWorkflow={(workflowPath, workflowName) => handleRunWorkflow(fork.id, workflowPath, workflowName)} + workflows={workflows} + isLoadingWorkflows={isLoadingWf} + isSyncing={isSyncing} + isRunningWorkflow={isRunningWf} + needsSync={needsSync} + language={language} + /> + ); + }) + )} +
+ + {/* Bottom Pagination */} + {totalPages > 1 && ( +
+
+ + + + {getPageNumbers().map((page, index) => ( + + ))} + + + +
+
+ )} + + {/* 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/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')} + + +