diff --git a/src/components/ForkTimeline.test.tsx b/src/components/ForkTimeline.test.tsx new file mode 100644 index 00000000..a1f2e9de --- /dev/null +++ b/src/components/ForkTimeline.test.tsx @@ -0,0 +1,180 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ForkTimeline } from './ForkTimeline'; +import { useAppStore } from '../store/useAppStore'; +import { GitHubApiService } from '../services/githubApi'; +import type { ForkRepo } from '../types'; + +vi.mock('../store/useAppStore', () => ({ + useAppStore: vi.fn(), +})); + +vi.mock('../services/githubApi', () => ({ + GitHubApiService: vi.fn(), +})); + +const toastMock = vi.fn(); +const confirmMock = vi.fn(); + +vi.mock('../hooks/useDialog', () => ({ + useDialog: () => ({ + toast: toastMock, + confirm: confirmMock, + }), +})); + +const createFork = (id: number, owner: string, name: string): ForkRepo => ({ + id, + name, + fork: true, + full_name: `${owner}/${name}`, + description: `${name} description`, + html_url: `https://github.com/${owner}/${name}`, + stargazers_count: 1, + forks_count: 1, + forks: 1, + language: 'TypeScript', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-02T00:00:00.000Z', + pushed_at: '2026-01-03T00:00:00.000Z', + default_branch: 'main', + owner: { + login: owner, + avatar_url: `https://github.com/${owner}.png`, + }, + source: { + id: id + 1000, + full_name: `upstream/${name}`, + name, + description: `${name} upstream`, + html_url: `https://github.com/upstream/${name}`, + stargazers_count: 10, + forks_count: 2, + updated_at: '2026-01-04T00:00:00.000Z', + owner: { + login: 'upstream', + avatar_url: 'https://github.com/upstream.png', + }, + }, +}); + +const personalFork = createFork(1, 'tamina', 'personal-fork'); +const orgFork = createFork(2, 'team-org', 'org-fork'); + +const mockUseAppStore = vi.mocked(useAppStore); +const MockGitHubApiService = vi.mocked(GitHubApiService); + +let storeState: ReturnType; + +const createStoreState = (overrides: Partial> = {}) => ({ + ...baseStoreState(), + ...overrides, +}); + +const baseStoreState = () => ({ + user: { + id: 1, + login: 'tamina', + name: 'Tamina', + avatar_url: 'https://github.com/tamina.png', + email: null, + }, + forks: [personalFork, orgFork], + readForks: new Set(), + githubToken: 'token', + language: 'zh' as const, + setForks: vi.fn(), + markForkAsRead: vi.fn(), + forkSearchQuery: '', + forkIsRefreshing: false, + setForkSearchQuery: vi.fn(), + setForkIsRefreshing: vi.fn(), +}); + +describe('ForkTimeline owner filtering', () => { + beforeEach(() => { + vi.clearAllMocks(); + storeState = createStoreState(); + mockUseAppStore.mockImplementation(() => storeState as ReturnType); + Object.assign(mockUseAppStore, { + getState: vi.fn(() => storeState), + setState: vi.fn((updater: unknown) => { + if (typeof updater === 'function') { + Object.assign(storeState, (updater as (state: typeof storeState) => Partial)(storeState)); + } else if (updater && typeof updater === 'object') { + Object.assign(storeState, updater); + } + }), + }); + storeState.setForks = vi.fn((forks: ForkRepo[]) => { + storeState.forks = forks; + }); + storeState.setForkIsRefreshing = vi.fn((refreshing: boolean) => { + storeState.forkIsRefreshing = refreshing; + }); + MockGitHubApiService.mockImplementation(() => ({ + getUserOrganizations: vi.fn().mockResolvedValue([ + { + id: 10, + login: 'team-org', + avatar_url: 'https://github.com/team-org.png', + description: null, + html_url: 'https://github.com/team-org', + }, + ]), + getUserForks: vi.fn().mockResolvedValue([personalFork, orgFork]), + getOrganizationForks: vi.fn().mockResolvedValue([orgFork]), + checkForkSyncNeeded: vi.fn().mockResolvedValue({ needsSync: false }), + getRepositoryWorkflows: vi.fn().mockResolvedValue([]), + getBranches: vi.fn().mockResolvedValue(['main']), + syncFork: vi.fn().mockResolvedValue({ hasUpdates: false, sourceUpdatedAt: null, mergeType: 'none' }), + triggerWorkflowRun: vi.fn().mockResolvedValue(undefined), + } as unknown as GitHubApiService)); + }); + + it('shows only personal-account forks by default', () => { + render(); + + expect(screen.getByText('personal-fork')).toBeInTheDocument(); + expect(screen.queryByText('org-fork')).not.toBeInTheDocument(); + }); + + it('switches to organization-owned forks without mixing personal forks', async () => { + render(); + + const ownerSelector = await screen.findByLabelText('选择 Fork 拥有者'); + fireEvent.change(ownerSelector, { target: { value: 'team-org' } }); + + expect(await screen.findByText('org-fork')).toBeInTheDocument(); + expect(screen.queryByText('personal-fork')).not.toBeInTheDocument(); + }); + + it('filters personal refresh results to the personal owner before caching', async () => { + storeState.forks = []; + + render(); + + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + + await waitFor(() => { + expect(storeState.forks).toHaveLength(1); + }); + expect(storeState.forks[0]).toMatchObject({ + id: personalFork.id, + full_name: personalFork.full_name, + owner: { login: 'tamina' }, + }); + }); + + it('warns when organization owners cannot be loaded', async () => { + MockGitHubApiService.mockImplementation(() => ({ + getUserOrganizations: vi.fn().mockRejectedValue(new Error('missing scope')), + } as unknown as GitHubApiService)); + + render(); + + await waitFor(() => { + expect(toastMock).toHaveBeenCalledWith('组织列表加载失败,请检查 GitHub token 权限。', 'warning'); + }); + }); +}); diff --git a/src/components/ForkTimeline.tsx b/src/components/ForkTimeline.tsx index 9cc8637b..c125e4a2 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, Loader2, GitFork } from 'lucide-react'; -import { ForkRepo, WorkflowDefinition } from '../types'; +import { Package, Search, X, RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2 } from 'lucide-react'; +import { ForkRepo, GitHubOrganization, WorkflowDefinition } from '../types'; import { useAppStore } from '../store/useAppStore'; import { GitHubApiService } from '../services/githubApi'; import { logger } from '../services/logger'; @@ -11,31 +11,24 @@ import { Modal } from './Modal'; export const ForkTimeline: React.FC = () => { const { + user, 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 { toast } = useDialog(); + const personalOwnerLogin = user?.login || ''; + const [organizations, setOrganizations] = useState([]); + const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false); + const [selectedForkOwner, setSelectedForkOwner] = useState(personalOwnerLogin); const [lastRefreshTime, setLastRefreshTime] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(20); @@ -47,6 +40,7 @@ export const ForkTimeline: React.FC = () => { const [runningWorkflows, setRunningWorkflows] = useState>(new Set()); // Track which forks need sync (out-of-date vs already-up-to-date) const [needsSyncMap, setNeedsSyncMap] = useState>({}); + const [loadedForkOwners, setLoadedForkOwners] = useState>(new Set()); // Sync Modal state const [syncModal, setSyncModal] = useState<{ @@ -67,24 +61,86 @@ export const ForkTimeline: React.FC = () => { 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 searchQuery = forkSearchQuery; + const activeForkOwner = selectedForkOwner || personalOwnerLogin; + const currentOwnerLabel = activeForkOwner || t('个人账号', 'Personal account'); const isForkUnread = useCallback((forkId: number) => { return !readForks.has(forkId); }, [readForks]); + useEffect(() => { + setSelectedForkOwner(personalOwnerLogin); + setCurrentPage(1); + setLoadedForkOwners(new Set()); + }, [personalOwnerLogin]); + + useEffect(() => { + if (!githubToken || !personalOwnerLogin) { + setOrganizations([]); + setIsLoadingOrganizations(false); + return; + } + + let isCancelled = false; + const loadOrganizations = async () => { + setIsLoadingOrganizations(true); + try { + const githubApi = new GitHubApiService(githubToken); + const userOrganizations = await githubApi.getUserOrganizations(); + if (!isCancelled) { + setOrganizations(userOrganizations); + } + } catch (error) { + logger.warn('githubApi', 'Failed to load fork owner organizations', error); + if (!isCancelled) { + setOrganizations([]); + toast( + language === 'zh' + ? '组织列表加载失败,请检查 GitHub token 权限。' + : 'Failed to load organizations. Please check GitHub token permissions.', + 'warning' + ); + } + } finally { + if (!isCancelled) { + setIsLoadingOrganizations(false); + } + } + }; + + loadOrganizations(); + + return () => { + isCancelled = true; + }; + }, [githubToken, language, personalOwnerLogin, toast]); + + const ownerForks = useMemo(() => { + if (!activeForkOwner) return []; + return forks.filter(fork => fork.fork === true && fork.owner.login === activeForkOwner); + }, [forks, activeForkOwner]); + + const forkOwnerOptions = useMemo(() => { + const options = new Map(); + if (personalOwnerLogin) { + options.set(personalOwnerLogin, { id: `user-${personalOwnerLogin}`, login: personalOwnerLogin, isPersonal: true }); + } + organizations.forEach(org => { + options.set(org.login, { id: `org-${org.id}`, login: org.login, isPersonal: false }); + }); + forks.forEach(fork => { + if (fork.fork && fork.owner.login !== personalOwnerLogin && !options.has(fork.owner.login)) { + options.set(fork.owner.login, { id: `cached-${fork.owner.login}`, login: fork.owner.login, isPersonal: false }); + } + }); + return Array.from(options.values()); + }, [forks, organizations, personalOwnerLogin]); + // 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); + let filtered = [...ownerForks]; // Sort by source.updated_at desc (upstream latest update first) filtered.sort((a, b) => { @@ -106,13 +162,15 @@ export const ForkTimeline: React.FC = () => { } return filtered; - }, [forks, searchQuery]); + }, [ownerForks, 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 startIndex = filteredForks.length === 0 ? 0 : (clampedPage - 1) * itemsPerPage; const paginatedForks = filteredForks.slice(startIndex, startIndex + itemsPerPage); + const displayStart = filteredForks.length === 0 ? 0 : startIndex + 1; + const displayEnd = Math.min(startIndex + itemsPerPage, filteredForks.length); // Sync currentPage when data changes useEffect(() => { @@ -153,98 +211,109 @@ export const ForkTimeline: React.FC = () => { return rangeWithDots; }; - const handleRefresh = async () => { + const loadForksForOwner = useCallback(async (ownerLogin: string) => { if (!githubToken) { toast(language === 'zh' ? 'GitHub token 未找到,请重新登录。' : 'GitHub token not found. Please login again.', 'error'); return; } + if (!ownerLogin) { + toast(language === 'zh' ? 'Fork 仓库拥有者未找到,请重新登录。' : 'Fork owner not found. Please login again.', 'error'); + return; + } + const startTime = Date.now(); setForkIsRefreshing(true); try { const githubApi = new GitHubApiService(githubToken); - const newForks = await githubApi.getUserForks(); - logger.info('githubApi', 'Refresh forks completed', { forkCount: newForks.length, durationMs: Date.now() - startTime }); - - // 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 + const fetchedForks = ownerLogin === personalOwnerLogin + ? await githubApi.getUserForks() + : await githubApi.getOrganizationForks(ownerLogin); + const newForks = fetchedForks.filter(fork => fork.fork === true && fork.owner.login === ownerLogin); + logger.info('githubApi', 'Refresh forks completed', { owner: ownerLogin, forkCount: newForks.length, durationMs: Date.now() - startTime }); + + // Merge with existing forks, preserving read status from the latest store state + let updatedForks: ForkRepo[] = []; + let newCount = 0; + useAppStore.setState(state => { + const existingForkMap = new Map(state.forks.map(f => [f.id, f])); + const nextReadForks = new Set(state.readForks); + newCount = newForks.filter(f => !existingForkMap.has(f.id)).length; + + updatedForks = newForks.map(newFork => { + const existing = existingForkMap.get(newFork.id); + if (!existing) { + // New fork — mark as unread if upstream has updates + return { + ...newFork, + has_unread: false, + upstream_updated_at: newFork.source?.updated_at, + }; + } + + const prevUpstreamTime = existing.upstream_updated_at; + const currentUpstreamTime = newFork.source?.updated_at; + const hasNewUpdates = !!prevUpstreamTime && !!currentUpstreamTime + && new Date(currentUpstreamTime) > new Date(prevUpstreamTime); + + if (hasNewUpdates) { + nextReadForks.delete(newFork.id); + return { + ...newFork, + has_unread: existing.has_unread, + upstream_updated_at: currentUpstreamTime, + }; + } + return { ...newFork, has_unread: existing.has_unread, - upstream_updated_at: existing.upstream_updated_at, + upstream_updated_at: existing.upstream_updated_at || currentUpstreamTime, }; - } - // New fork — mark as unread if upstream has updates + }); + return { - ...newFork, - has_unread: false, - upstream_updated_at: newFork.source?.updated_at, + forks: [ + ...state.forks.filter(fork => fork.owner.login !== ownerLogin || fork.fork !== true), + ...updatedForks, + ], + readForks: nextReadForks, }; }); - - // 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; + setLoadedForkOwners(prev => { + const next = new Set(prev); + next.add(ownerLogin); + return next; }); - - setForks(updatedForks); const now = new Date().toISOString(); setLastRefreshTime(now); - // Pre-check sync status for all forks (out-of-date vs already up-to-date) + // Pre-check sync status for refreshed owner 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, + 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)); + useAppStore.setState(state => ({ + forks: state.forks.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 })); @@ -252,8 +321,6 @@ export const ForkTimeline: React.FC = () => { }); 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。` @@ -269,7 +336,7 @@ export const ForkTimeline: React.FC = () => { } } catch (error) { console.error('Fork refresh failed:', error); - logger.error('githubApi', 'Refresh forks failed', { error: error instanceof Error ? error.message : String(error), durationMs: Date.now() - startTime }); + logger.error('githubApi', 'Refresh forks failed', { owner: ownerLogin, error: error instanceof Error ? error.message : String(error), durationMs: Date.now() - startTime }); toast(language === 'zh' ? 'Fork刷新失败,请检查网络连接。' : 'Fork refresh failed. Please check your network connection.', @@ -278,6 +345,20 @@ export const ForkTimeline: React.FC = () => { } finally { setForkIsRefreshing(false); } + }, [githubToken, language, personalOwnerLogin, setForkIsRefreshing, toast]); + + const handleRefresh = () => { + loadForksForOwner(activeForkOwner); + }; + + const handleForkOwnerChange = (ownerLogin: string) => { + setSelectedForkOwner(ownerLogin); + setCurrentPage(1); + + const hasCachedOwnerForks = useAppStore.getState().forks.some(fork => fork.fork === true && fork.owner.login === ownerLogin); + if (!hasCachedOwnerForks && !loadedForkOwners.has(ownerLogin)) { + loadForksForOwner(ownerLogin); + } }; const toggleWorkflows = async (forkId: number) => { @@ -457,37 +538,6 @@ export const ForkTimeline: React.FC = () => { } }; - 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 */} @@ -498,10 +548,35 @@ export const ForkTimeline: React.FC = () => { {t('复刻', 'Fork')}

- {t(`管理您的 ${forks.filter(f => f.fork).length} 个Fork仓库`, `Manage your ${forks.filter(f => f.fork).length} forked repositories`)} + {t(`管理 ${currentOwnerLabel} 的 ${ownerForks.length} 个Fork仓库`, `Manage ${ownerForks.length} forked repositories for ${currentOwnerLabel}`)}

+ {/* Fork owner selector */} +
+ {t('拥有者:', 'Owner:')} + +
+ + {isLoadingOrganizations && ( + + + {t('加载组织中...', 'Loading organizations...')} + + )} + {/* Last Refresh Time */} {lastRefreshTime && ( @@ -554,8 +629,8 @@ export const ForkTimeline: React.FC = () => {
{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` + `显示 ${displayStart}-${displayEnd} 共 ${filteredForks.length} 个Fork`, + `Showing ${displayStart}-${displayEnd} of ${filteredForks.length} forks` )} {searchQuery && ( @@ -645,10 +720,12 @@ export const ForkTimeline: React.FC = () => {

- {t('无符合条件的结果', 'No matching results')} + {searchQuery ? t('无符合条件的结果', 'No matching results') : t('没有Fork仓库', 'No Forked Repositories')}

- {t('没有找到匹配的 Fork', 'No matching forks found.')} + {searchQuery + ? t('没有找到匹配的 Fork', 'No matching forks found.') + : t(`${currentOwnerLabel} 下暂无 Fork 仓库,请刷新或切换拥有者。`, `No forked repositories found for ${currentOwnerLabel}. Refresh or switch owner.`)}

{searchQuery && (