Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 48 additions & 7 deletions src/components/RepositoryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ReadmeModal } from './ReadmeModal';
import { FloatingTooltip } from './FloatingTooltip';
import { shallow } from 'zustand/shallow';
import { useDialog } from '../hooks/useDialog';
import { logger } from '../services/logger';

// Selection-aware button component to centralize selectionMode disable logic
interface SelectionAwareButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
Expand Down Expand Up @@ -89,25 +90,28 @@ const RepositoryCardComponent: React.FC<RepositoryCardProps> = ({
allCategories
}) => {
const repoId = repository.id;


const isSubscribed = useAppStore(
useCallback((state) => state.releaseSubscriptions.has(repoId), [repoId])
);
const isStoreAnalyzing = useAppStore(
useCallback((state) => state.analyzingRepositoryIds.has(repoId), [repoId])
);

const {
isSubscribed,
toggleReleaseSubscription,
githubToken,
activeAIConfig,
analyzingRepositoryIds,
setAnalyzingRepository,
language,
updateRepository,
deleteRepository
} = useAppStore(
useCallback(
(state) => ({
isSubscribed: state.releaseSubscriptions.has(repoId),
toggleReleaseSubscription: state.toggleReleaseSubscription,
githubToken: state.githubToken,
activeAIConfig: state.activeAIConfig,
analyzingRepositoryIds: state.analyzingRepositoryIds,
setAnalyzingRepository: state.setAnalyzingRepository,
language: state.language,
updateRepository: state.updateRepository,
Expand All @@ -118,8 +122,6 @@ const RepositoryCardComponent: React.FC<RepositoryCardProps> = ({
shallow
);

const isAnalyzing = analyzingRepositoryIds.has(repoId);

const abortControllerRef = useRef<AbortController | null>(null);

useEffect(() => {
Expand All @@ -141,6 +143,8 @@ const RepositoryCardComponent: React.FC<RepositoryCardProps> = ({
const [unstarring, setUnstarring] = useState(false);
const [showDragHint, setShowDragHint] = useState(false);
const dragHintTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isLocallyAnalyzing, setIsLocallyAnalyzing] = useState(false);
const isAnalyzing = isLocallyAnalyzing || isStoreAnalyzing;

// 高亮搜索关键词的工具函数 - 使用缓存优化
const highlightSearchTerm = useCallback((text: string, searchTerm: string): React.ReactNode => {
Expand Down Expand Up @@ -295,6 +299,15 @@ const RepositoryCardComponent: React.FC<RepositoryCardProps> = ({
const controller = new AbortController();
abortControllerRef.current = controller;

const analysisStartedAt = performance.now();
setIsLocallyAnalyzing(true);
requestAnimationFrame(() => {
logger.info('ai.performance', 'Repository card AI spinner painted', {
repoId,
fullName: repository.full_name,
elapsedMs: Math.round(performance.now() - analysisStartedAt),
});
});
setAnalyzingRepository(repoId, true);
try {
const result = await analyzeRepository({
Expand All @@ -303,8 +316,21 @@ const RepositoryCardComponent: React.FC<RepositoryCardProps> = ({
aiConfig: activeConfig,
language,
categories: allCategories,
onProgress: (status) => {
logger.info('ai.performance', 'Repository card AI analysis step', {
repoId,
fullName: repository.full_name,
status,
elapsedMs: Math.round(performance.now() - analysisStartedAt),
});
},
signal: controller.signal,
});
logger.info('ai.performance', 'Repository card AI request completed', {
repoId,
fullName: repository.full_name,
elapsedMs: Math.round(performance.now() - analysisStartedAt),
});

if (controller.signal.aborted) return;

Expand All @@ -320,7 +346,14 @@ const RepositoryCardComponent: React.FC<RepositoryCardProps> = ({
analysis_error: undefined,
};

const updateStartedAt = performance.now();
updateRepository(updatedRepo);
logger.info('ai.performance', 'Repository card AI result stored', {
repoId,
fullName: repository.full_name,
updateMs: Math.round(performance.now() - updateStartedAt),
elapsedMs: Math.round(performance.now() - analysisStartedAt),
});

const successMessage = repository.analyzed_at
? (language === 'zh' ? 'AI重新分析完成!' : 'AI re-analysis completed!')
Expand All @@ -342,11 +375,19 @@ const RepositoryCardComponent: React.FC<RepositoryCardProps> = ({
analysis_error: failedResult.analysis_error,
};

const updateStartedAt = performance.now();
updateRepository(failedRepo);
logger.info('ai.performance', 'Repository card AI failure stored', {
repoId,
fullName: repository.full_name,
updateMs: Math.round(performance.now() - updateStartedAt),
elapsedMs: Math.round(performance.now() - analysisStartedAt),
});

toast(language === 'zh' ? 'AI分析失败,请检查AI配置和网络连接。' : 'AI analysis failed. Please check AI configuration and network connection.', 'error');
}
} finally {
setIsLocallyAnalyzing(false);
if (!controller.signal.aborted) {
setAnalyzingRepository(repoId, false);
}
Expand Down
18 changes: 10 additions & 8 deletions src/components/RepositoryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({

const selectedCategoryObj = allCategories.find(cat => cat.id === selectedCategory);
if (!selectedCategoryObj) return [];
const selectedCategoryKeywords = selectedCategoryObj.keywords.map(keyword => keyword.toLowerCase());

return repositories.filter(repo => {
if (repo.custom_category !== undefined) {
Expand All @@ -87,12 +88,13 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
// 如果没有自定义分类,使用AI标签和关键词匹配
// 优先使用AI标签进行匹配
if (repo.ai_tags && repo.ai_tags.length > 0) {
return repo.ai_tags.some(tag =>
selectedCategoryObj.keywords.some(keyword =>
tag.toLowerCase().includes(keyword.toLowerCase()) ||
keyword.toLowerCase().includes(tag.toLowerCase())
return repo.ai_tags.some(tag => {
const tagLower = tag.toLowerCase();
return selectedCategoryKeywords.some(keyword =>
tagLower.includes(keyword) ||
keyword.includes(tagLower)
)
);
});
}

// 如果没有AI标签,使用传统方式匹配
Expand All @@ -104,8 +106,8 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
repo.ai_summary || ''
].join(' ').toLowerCase();

return selectedCategoryObj.keywords.some(keyword =>
repoText.includes(keyword.toLowerCase())
return selectedCategoryKeywords.some(keyword =>
repoText.includes(keyword)
);
});
}, [repositories, selectedCategory, allCategories]);
Expand Down Expand Up @@ -164,7 +166,7 @@ export const RepositoryList: React.FC<RepositoryListProps> = ({
return { unanalyzedCount, analyzedCount, failedCount };
}, [filteredRepositories]);

const filterResetKey = useMemo(() => JSON.stringify({
const filterResetKey = useMemo(() => ({
selectedCategory,
query: searchFilters.query,
languages: searchFilters.languages,
Expand Down
13 changes: 2 additions & 11 deletions src/services/indexedDbStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,6 @@ const safeLocalStorageGet = (key: string): string | null => {
}
};

const safeLocalStorageSet = (key: string, value: string): void => {
try {
window.localStorage.setItem(key, value);
} catch {
// Quota/security errors are expected in some environments; ignore.
}
};

const safeLocalStorageRemove = (key: string): void => {
try {
window.localStorage.removeItem(key);
Expand Down Expand Up @@ -108,7 +100,8 @@ const idbDelete = async (key: string): Promise<void> => {
* IndexedDB-backed Zustand persist storage with seamless migration + dual write:
* - First read from IndexedDB
* - If empty, fall back to existing localStorage snapshot and migrate to IndexedDB
* - Every write goes to IndexedDB and localStorage (backward compatibility window)
* - Writes go to IndexedDB only; localStorage is read as a one-time legacy fallback.
* Keeping large repository snapshots out of localStorage avoids synchronous main-thread stalls.
*/
export const indexedDBStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
Expand Down Expand Up @@ -148,8 +141,6 @@ export const indexedDBStorage: StateStorage = {
}
}

// Secondary compatibility backup (best effort only)
safeLocalStorageSet(name, value);
},

removeItem: async (name: string): Promise<void> => {
Expand Down
85 changes: 85 additions & 0 deletions src/store/useAppStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { Repository } from '../types';

let useAppStore: typeof import('./useAppStore').useAppStore;

beforeAll(async () => {
({ useAppStore } = await vi.importActual<typeof import('./useAppStore')>('./useAppStore'));
});

const createRepository = (id: number, overrides: Partial<Repository> = {}): Repository => ({
id,
name: `repo-${id}`,
full_name: `owner/repo-${id}`,
description: 'A test repository',
html_url: `https://github.com/owner/repo-${id}`,
stargazers_count: 10,
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',
owner: {
login: 'owner',
avatar_url: 'https://github.com/avatar.png',
},
topics: ['test'],
...overrides,
});

describe('useAppStore repository performance guards', () => {
beforeEach(() => {
useAppStore.setState({
repositories: [],
searchResults: [],
analyzingRepositoryIds: new Set(),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it('does not notify subscribers when updateRepository receives an equivalent repository', () => {
const repo = createRepository(1);
useAppStore.setState({ repositories: [repo], searchResults: [repo] });

const previousRepositories = useAppStore.getState().repositories;
const previousSearchResults = useAppStore.getState().searchResults;
let notifications = 0;
const unsubscribe = useAppStore.subscribe(() => {
notifications++;
});

useAppStore.getState().updateRepository({ ...repo });
unsubscribe();

expect(notifications).toBe(0);
expect(useAppStore.getState().repositories).toBe(previousRepositories);
expect(useAppStore.getState().searchResults).toBe(previousSearchResults);
});

it('updates only lists that contain the repository', () => {
const repo = createRepository(1);
useAppStore.setState({ repositories: [repo], searchResults: [] });

const previousSearchResults = useAppStore.getState().searchResults;
useAppStore.getState().updateRepository({ ...repo, ai_summary: 'Updated summary' });

expect(useAppStore.getState().repositories[0].ai_summary).toBe('Updated summary');
expect(useAppStore.getState().searchResults).toBe(previousSearchResults);
});

it('does not notify subscribers when analyzing state is unchanged', () => {
useAppStore.setState({ analyzingRepositoryIds: new Set([1]) });

const previousAnalyzingIds = useAppStore.getState().analyzingRepositoryIds;
let notifications = 0;
const unsubscribe = useAppStore.subscribe(() => {
notifications++;
});

useAppStore.getState().setAnalyzingRepository(1, true);
unsubscribe();

expect(notifications).toBe(0);
expect(useAppStore.getState().analyzingRepositoryIds).toBe(previousAnalyzingIds);
});
});
Loading