Skip to content

Commit df55321

Browse files
authored
Merge pull request #205 from AmintaCCCP/feat/multilingual-readme-switch
feat: add multilingual README switching
2 parents a2a985b + d1c7b8e commit df55321

6 files changed

Lines changed: 726 additions & 30 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { ReadmeModal } from './ReadmeModal';
4+
import { backend } from '../services/backendAdapter';
5+
import type { Repository } from '../types';
6+
7+
vi.mock('./BilingualMarkdownRenderer', async () => {
8+
const React = await import('react');
9+
return {
10+
default: React.forwardRef(({ markdown }: { markdown: string }, ref) => {
11+
void ref;
12+
return <div>{markdown}</div>;
13+
}),
14+
};
15+
});
16+
17+
vi.mock('../services/backendAdapter', () => ({
18+
backend: {
19+
isAvailable: true,
20+
getRepositoryReadme: vi.fn(),
21+
getRepositoryReadmeByPath: vi.fn(),
22+
listRepositoryReadmeCandidates: vi.fn(),
23+
},
24+
}));
25+
26+
const mockRepository: Repository = {
27+
id: 1,
28+
name: 'demo',
29+
full_name: 'owner/demo',
30+
description: 'Demo repository',
31+
html_url: 'https://github.com/owner/demo',
32+
stargazers_count: 10,
33+
forks_count: 2,
34+
forks: 2,
35+
language: 'TypeScript',
36+
created_at: '2026-01-01T00:00:00Z',
37+
updated_at: '2026-01-02T00:00:00Z',
38+
pushed_at: '2026-01-03T00:00:00Z',
39+
owner: {
40+
login: 'owner',
41+
avatar_url: 'https://example.com/avatar.png',
42+
},
43+
topics: [],
44+
};
45+
46+
describe('ReadmeModal multilingual README switching', () => {
47+
beforeEach(() => {
48+
vi.clearAllMocks();
49+
(backend.getRepositoryReadme as ReturnType<typeof vi.fn>).mockResolvedValue('Default README content');
50+
(backend.getRepositoryReadmeByPath as ReturnType<typeof vi.fn>).mockResolvedValue('中文 README 内容');
51+
(backend.listRepositoryReadmeCandidates as ReturnType<typeof vi.fn>).mockResolvedValue([
52+
{ path: 'README.md', type: 'blob' },
53+
{ path: 'README_zh.md', type: 'blob' },
54+
{ path: 'docs/README.ja.md', type: 'blob' },
55+
]);
56+
});
57+
58+
it('loads the default README first and then shows all detected README variants', async () => {
59+
render(<ReadmeModal isOpen onClose={vi.fn()} repository={mockRepository} />);
60+
61+
expect(await screen.findByText('Default README content')).toBeInTheDocument();
62+
expect(backend.getRepositoryReadme).toHaveBeenCalledWith('owner', 'demo', expect.any(AbortSignal));
63+
64+
const selector = await screen.findByLabelText('切换 README 语言');
65+
expect(selector).toBeInTheDocument();
66+
expect(screen.getByRole('option', { name: '默认 README' })).toBeInTheDocument();
67+
expect(screen.getByRole('option', { name: '中文 · README_zh.md' })).toBeInTheDocument();
68+
expect(screen.getByRole('option', { name: '日语 · docs/README.ja.md' })).toBeInTheDocument();
69+
});
70+
71+
it('loads the selected README variant by path', async () => {
72+
render(<ReadmeModal isOpen onClose={vi.fn()} repository={mockRepository} />);
73+
74+
await screen.findByText('Default README content');
75+
const selector = await screen.findByLabelText('切换 README 语言');
76+
77+
fireEvent.change(selector, { target: { value: 'README_zh.md' } });
78+
79+
await waitFor(() => {
80+
expect(backend.getRepositoryReadmeByPath).toHaveBeenCalledWith('owner', 'demo', 'README_zh.md', expect.any(AbortSignal));
81+
});
82+
expect(await screen.findByText('中文 README 内容')).toBeInTheDocument();
83+
});
84+
85+
it('does not show the selector when no localized README exists', async () => {
86+
(backend.listRepositoryReadmeCandidates as ReturnType<typeof vi.fn>).mockResolvedValue([
87+
{ path: 'README.md', type: 'blob' },
88+
]);
89+
90+
render(<ReadmeModal isOpen onClose={vi.fn()} repository={mockRepository} />);
91+
92+
expect(await screen.findByText('Default README content')).toBeInTheDocument();
93+
await waitFor(() => {
94+
expect(backend.listRepositoryReadmeCandidates).toHaveBeenCalled();
95+
});
96+
expect(screen.queryByLabelText('切换 README 语言')).not.toBeInTheDocument();
97+
});
98+
});

src/components/ReadmeModal.tsx

Lines changed: 160 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import React, { useEffect, useState, useCallback, useRef } from 'react';
1+
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
22
import { X, Loader2, AlertCircle, FileText, ExternalLink, List, Type, ArrowUp, Languages, Eye } from 'lucide-react';
33
import BilingualMarkdownRenderer, { DisplayMode, BilingualMarkdownRendererHandle, TranslationStatus } from './BilingualMarkdownRenderer';
44
import { stripMarkdownFormatting } from '../utils/markdownUtils';
55
import { Repository } from '../types';
66
import { GitHubApiService } from '../services/githubApi';
77
import { backend } from '../services/backendAdapter';
88
import { useAppStore } from '../store/useAppStore';
9+
import { buildReadmeVariants, DEFAULT_README_VARIANT, type GitHubReadmeCandidateItem, type ReadmeVariant } from '../utils/readmeVariants';
910

1011
interface TocItem {
1112
id: string;
@@ -27,6 +28,11 @@ const FONT_SIZES = [
2728

2829
const TOC_MAX_LEVEL = 6;
2930

31+
const getDefaultReadmeVariant = (language: 'zh' | 'en'): ReadmeVariant => ({
32+
...DEFAULT_README_VARIANT,
33+
label: language === 'zh' ? '默认 README' : 'Default README',
34+
});
35+
3036
export const ReadmeModal: React.FC<ReadmeModalProps> = ({
3137
isOpen,
3238
onClose,
@@ -47,11 +53,18 @@ export const ReadmeModal: React.FC<ReadmeModalProps> = ({
4753
const [errorExpanded, setErrorExpanded] = useState(false);
4854
const [tocWidth, setTocWidth] = useState(224);
4955
const [translatedHeadingMap, setTranslatedHeadingMap] = useState<Map<string, string>>(new Map());
56+
const [readmeVariants, setReadmeVariants] = useState<ReadmeVariant[]>(() => [getDefaultReadmeVariant(language)]);
57+
const [selectedReadmeKey, setSelectedReadmeKey] = useState('default');
58+
const [variantsLoading, setVariantsLoading] = useState(false);
59+
const [readmeCache, setReadmeCache] = useState<Record<string, string>>({});
60+
61+
const defaultReadmeVariant = useMemo(() => getDefaultReadmeVariant(language), [language]);
5062

5163
const modalRef = useRef<HTMLDivElement>(null);
5264
const contentRef = useRef<HTMLDivElement>(null);
5365
const previousFocusRef = useRef<HTMLElement | null>(null);
5466
const abortControllerRef = useRef<AbortController | null>(null);
67+
const variantsAbortControllerRef = useRef<AbortController | null>(null);
5568
const isResizingRef = useRef(false);
5669
const startXRef = useRef(0);
5770
const startWidthRef = useRef(0);
@@ -284,7 +297,26 @@ export const ReadmeModal: React.FC<ReadmeModalProps> = ({
284297
setTranslatedHeadingMap(map);
285298
}, []);
286299

287-
const fetchReadme = useCallback(async () => {
300+
const resetTranslationState = useCallback(() => {
301+
bilingualRef.current?.revert();
302+
setDisplayMode('bilingual');
303+
setTranslateStatus('idle');
304+
setTranslateProgress({ current: 0, total: 0 });
305+
setTranslateError(null);
306+
setTranslatedHeadingMap(new Map());
307+
}, []);
308+
309+
const resetReadmeViewState = useCallback(() => {
310+
resetTranslationState();
311+
setTocItems([]);
312+
setHeadingIdMap(new Map());
313+
setActiveHeadingId(null);
314+
setScrollProgress(0);
315+
setShowBackToTop(false);
316+
scrollToTop();
317+
}, [resetTranslationState, scrollToTop]);
318+
319+
const fetchReadmeContent = useCallback(async (variant: ReadmeVariant) => {
288320
if (!repository) return;
289321

290322
if (abortControllerRef.current) {
@@ -301,39 +333,127 @@ export const ReadmeModal: React.FC<ReadmeModalProps> = ({
301333
let content = '';
302334

303335
if (backend.isAvailable) {
304-
content = await backend.getRepositoryReadme(owner, name);
336+
content = variant.isDefault || !variant.path
337+
? await backend.getRepositoryReadme(owner, name, abortController.signal)
338+
: await backend.getRepositoryReadmeByPath(owner, name, variant.path, abortController.signal);
305339
} else if (githubToken) {
306340
const githubApi = new GitHubApiService(githubToken);
307-
content = await githubApi.getRepositoryReadme(owner, name, abortController.signal);
341+
content = variant.isDefault || !variant.path
342+
? await githubApi.getRepositoryReadme(owner, name, abortController.signal)
343+
: await githubApi.getRepositoryReadmeByPath(owner, name, variant.path, abortController.signal);
308344
} else {
345+
setReadmeContent('');
309346
setError(language === 'zh' ? '未登录且后端不可用,无法加载 README' : 'Not logged in and backend unavailable, cannot load README');
310347
setLoading(false);
311348
return;
312349
}
313350

314351
if (abortController.signal.aborted) return;
315352

353+
setReadmeCache(prev => ({ ...prev, [variant.key]: content }));
354+
316355
if (content.trim()) {
317356
setReadmeContent(content);
357+
setError(null);
318358
} else {
319-
setError(language === 'zh' ? '该仓库没有 README 文件' : 'This repository has no README file');
359+
setReadmeContent('');
360+
setError(variant.isDefault
361+
? (language === 'zh' ? '该仓库没有 README 文件' : 'This repository has no README file')
362+
: (language === 'zh' ? '该 README 文件为空' : 'This README file is empty'));
320363
}
321364
} catch (err) {
322365
if (abortController.signal.aborted) return;
323366
console.error('Failed to fetch README:', err);
324-
setError(language === 'zh' ? '加载 README 失败,请检查网络连接或稍后重试' : 'Failed to load README. Please check your network connection and try again later');
367+
setReadmeContent('');
368+
setError(variant.isDefault
369+
? (language === 'zh' ? '加载 README 失败,请检查网络连接或稍后重试' : 'Failed to load README. Please check your network connection and try again later')
370+
: (language === 'zh' ? '加载所选 README 失败,请稍后重试' : 'Failed to load selected README. Please try again later'));
325371
} finally {
326372
if (!abortController.signal.aborted) {
327373
setLoading(false);
328374
}
329375
}
330376
}, [repository, githubToken, language]);
331377

378+
const fetchReadmeVariants = useCallback(async () => {
379+
if (!repository) return;
380+
381+
if (variantsAbortControllerRef.current) {
382+
variantsAbortControllerRef.current.abort();
383+
}
384+
const abortController = new AbortController();
385+
variantsAbortControllerRef.current = abortController;
386+
387+
setVariantsLoading(true);
388+
389+
try {
390+
const [owner, name] = repository.full_name.split('/');
391+
const defaultBranch = (repository as Repository & { default_branch?: string }).default_branch;
392+
let candidates: GitHubReadmeCandidateItem[] = [];
393+
394+
if (backend.isAvailable) {
395+
candidates = await backend.listRepositoryReadmeCandidates(owner, name, defaultBranch, abortController.signal);
396+
} else if (githubToken) {
397+
const githubApi = new GitHubApiService(githubToken);
398+
candidates = await githubApi.listRepositoryReadmeCandidates(owner, name, defaultBranch, abortController.signal);
399+
} else {
400+
return;
401+
}
402+
403+
if (abortController.signal.aborted) return;
404+
setReadmeVariants(buildReadmeVariants(candidates, language));
405+
} catch (err) {
406+
if (!abortController.signal.aborted) {
407+
console.warn('Failed to detect README variants:', err);
408+
setReadmeVariants([defaultReadmeVariant]);
409+
}
410+
} finally {
411+
if (!abortController.signal.aborted) {
412+
setVariantsLoading(false);
413+
}
414+
}
415+
}, [repository, githubToken, language, defaultReadmeVariant]);
416+
417+
const fetchReadme = useCallback(async () => {
418+
const currentVariant = readmeVariants.find(variant => variant.key === selectedReadmeKey) || defaultReadmeVariant;
419+
await fetchReadmeContent(currentVariant);
420+
}, [readmeVariants, selectedReadmeKey, defaultReadmeVariant, fetchReadmeContent]);
421+
422+
const handleReadmeVariantChange = useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
423+
const nextKey = event.target.value;
424+
if (nextKey === selectedReadmeKey) return;
425+
426+
const nextVariant = readmeVariants.find(variant => variant.key === nextKey);
427+
if (!nextVariant) return;
428+
429+
setSelectedReadmeKey(nextKey);
430+
resetReadmeViewState();
431+
432+
const cachedContent = readmeCache[nextKey];
433+
if (cachedContent !== undefined) {
434+
setReadmeContent(cachedContent);
435+
setError(cachedContent.trim()
436+
? null
437+
: nextVariant.isDefault
438+
? (language === 'zh' ? '该仓库没有 README 文件' : 'This repository has no README file')
439+
: (language === 'zh' ? '该 README 文件为空' : 'This README file is empty'));
440+
return;
441+
}
442+
443+
void fetchReadmeContent(nextVariant);
444+
}, [selectedReadmeKey, readmeVariants, readmeCache, resetReadmeViewState, language, fetchReadmeContent]);
445+
332446
useEffect(() => {
333447
if (isOpen && repository) {
334-
fetchReadme();
448+
const defaultVariant = getDefaultReadmeVariant(language);
449+
setReadmeVariants([defaultVariant]);
450+
setSelectedReadmeKey('default');
451+
setReadmeCache({});
452+
resetReadmeViewState();
453+
void fetchReadmeContent(defaultVariant);
454+
void fetchReadmeVariants();
335455
}
336-
}, [isOpen, repository, fetchReadme]);
456+
}, [isOpen, repository, language, fetchReadmeContent, fetchReadmeVariants, resetReadmeViewState]);
337457

338458
useEffect(() => {
339459
if (displayContent) {
@@ -355,9 +475,17 @@ export const ReadmeModal: React.FC<ReadmeModalProps> = ({
355475
abortControllerRef.current.abort();
356476
abortControllerRef.current = null;
357477
}
478+
if (variantsAbortControllerRef.current) {
479+
variantsAbortControllerRef.current.abort();
480+
variantsAbortControllerRef.current = null;
481+
}
358482
setReadmeContent('');
359483
setError(null);
360484
setLoading(false);
485+
setReadmeVariants([getDefaultReadmeVariant(language)]);
486+
setSelectedReadmeKey('default');
487+
setVariantsLoading(false);
488+
setReadmeCache({});
361489
setTocItems([]);
362490
setHeadingIdMap(new Map());
363491
setScrollProgress(0);
@@ -376,14 +504,18 @@ export const ReadmeModal: React.FC<ReadmeModalProps> = ({
376504
} else {
377505
setShowToc(true);
378506
}
379-
}, [isOpen]);
507+
}, [isOpen, language]);
380508

381509
useEffect(() => {
382510
return () => {
383511
if (abortControllerRef.current) {
384512
abortControllerRef.current.abort();
385513
abortControllerRef.current = null;
386514
}
515+
if (variantsAbortControllerRef.current) {
516+
variantsAbortControllerRef.current.abort();
517+
variantsAbortControllerRef.current = null;
518+
}
387519
};
388520
}, []);
389521

@@ -441,6 +573,7 @@ export const ReadmeModal: React.FC<ReadmeModalProps> = ({
441573
const isTranslating = translateStatus === 'translating';
442574
const isTranslated = translateStatus === 'translated';
443575
const isTranslateError = translateStatus === 'error';
576+
const currentReadmeVariant = readmeVariants.find(variant => variant.key === selectedReadmeKey) || defaultReadmeVariant;
444577

445578
return (
446579
<div className="fixed inset-0 z-50 overflow-y-auto">
@@ -478,12 +611,28 @@ export const ReadmeModal: React.FC<ReadmeModalProps> = ({
478611
<h3 id="readme-modal-title" className="text-lg font-semibold text-gray-900 dark:text-text-primary">
479612
{repository.full_name}
480613
</h3>
481-
<p className="text-sm text-gray-500 dark:text-text-secondary">
482-
README
614+
<p className="text-sm text-gray-500 dark:text-text-secondary truncate max-w-[260px]" title={currentReadmeVariant.path || 'README'}>
615+
{currentReadmeVariant.isDefault ? 'README' : currentReadmeVariant.path}
483616
</p>
484617
</div>
485618
</div>
486619
<div className="flex items-center space-x-1">
620+
{readmeVariants.length > 1 && (
621+
<select
622+
value={selectedReadmeKey}
623+
onChange={handleReadmeVariantChange}
624+
disabled={loading || variantsLoading}
625+
className="w-28 sm:w-auto max-w-[220px] px-2 py-2 text-sm rounded-lg border border-black/[0.06] dark:border-white/[0.08] bg-white dark:bg-panel-dark text-gray-700 dark:text-text-primary hover:bg-light-surface dark:hover:bg-white/5 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
626+
title={t('切换 README 语言', 'Switch README language')}
627+
aria-label={t('切换 README 语言', 'Switch README language')}
628+
>
629+
{readmeVariants.map((variant) => (
630+
<option key={variant.key} value={variant.key}>
631+
{variant.label}
632+
</option>
633+
))}
634+
</select>
635+
)}
487636
{readmeContent && !loading && (
488637
isTranslated ? (
489638
<>

0 commit comments

Comments
 (0)