From 824ae09fee49def66f0caf8606b27dab9000ea18 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Sat, 12 Sep 2026 16:18:55 +0800 Subject: [PATCH 1/6] =?UTF-8?q?refactor(frontend):=20hook=20=E4=B8=8E?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E5=AD=98=E5=82=A8=E6=A8=A1=E5=9D=97=E6=8C=89?= =?UTF-8?q?=20ADR=200001=20=E5=BD=92=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useAuthSessionGeneration 挪入 src/hooks/:它被 forks 与 discovery 两个 feature 的 hook 跨 feature 复用,只有共享 hook 位置(ADR 脚注²)能同时 满足归位与既有引用 - useBackendLifecycle 挪入 lifecycle/hooks/,消费方仅 App.tsx - repository-chat/repositories/sessionRepository 更名迁至 src/services/repositoryChatStorage,对齐 discoveryAnalysisStorage / weeklyIssuesStorage / indexedDbStorage 三个既有领域存储先例, 消除与 features/repositories 的命名撞车;导出名同步改为 repositoryChatStorage - 纯移动与路径替换,无行为变更 --- src/App.tsx | 2 +- .../GlobalChatHistorySheet.test.tsx | 14 ++-- src/components/GlobalChatHistorySheet.tsx | 6 +- src/components/SearchBar.tsx | 4 +- .../discovery/hooks/useDiscoveryActions.ts | 2 +- .../forks/hooks/useForkTimelineActions.ts | 2 +- .../{ => hooks}/useBackendLifecycle.test.tsx | 6 +- .../{ => hooks}/useBackendLifecycle.ts | 6 +- .../hooks/useRepositoryChat.test.ts | 4 +- .../hooks/useRepositoryChat.ts | 20 +++--- .../hooks/useRepositoryChatSessions.ts | 14 ++-- .../useAuthSessionGeneration.ts | 0 .../repositoryChatStorage.test.ts} | 70 +++++++++---------- .../repositoryChatStorage.ts} | 4 +- 14 files changed, 77 insertions(+), 77 deletions(-) rename src/features/lifecycle/{ => hooks}/useBackendLifecycle.test.tsx (94%) rename src/features/lifecycle/{ => hooks}/useBackendLifecycle.ts (93%) rename src/{features/lifecycle => hooks}/useAuthSessionGeneration.ts (100%) rename src/{features/repository-chat/repositories/sessionRepository.test.ts => services/repositoryChatStorage.test.ts} (54%) rename src/{features/repository-chat/repositories/sessionRepository.ts => services/repositoryChatStorage.ts} (99%) diff --git a/src/App.tsx b/src/App.tsx index 0c54bc177..cd2c69983 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,7 @@ import { useAutoUpdateCheck } from './hooks/useAutoUpdateCheck'; import { logger } from './services/logger'; import { UpdateNotificationBanner } from './components/UpdateNotificationBanner'; import { ListsPushIndicator } from './components/ListsPushIndicator'; -import { useBackendLifecycle } from './features/lifecycle/useBackendLifecycle'; +import { useBackendLifecycle } from './features/lifecycle/hooks/useBackendLifecycle'; import type { AppState } from './types'; import { hasActiveSearchFilters } from './utils/repoSearch'; diff --git a/src/components/GlobalChatHistorySheet.test.tsx b/src/components/GlobalChatHistorySheet.test.tsx index 6881bb269..5189cbd18 100644 --- a/src/components/GlobalChatHistorySheet.test.tsx +++ b/src/components/GlobalChatHistorySheet.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { GlobalChatHistorySheet } from './GlobalChatHistorySheet'; -import { repositoryChatSessionRepository } from '../features/repository-chat/repositories/sessionRepository'; +import { repositoryChatStorage } from '../services/repositoryChatStorage'; import type { Repository } from '../types'; import type { RepositoryChatSession } from '../types/repositoryChat'; @@ -35,8 +35,8 @@ describe('GlobalChatHistorySheet', () => { }); it('按更新时间倒序列出跨仓会话', async () => { - await repositoryChatSessionRepository.saveSession(createSession('older', 1, 'owner/repo-one', '2026-08-24T00:00:00.000Z')); - await repositoryChatSessionRepository.saveSession(createSession('newer', 2, 'owner/repo-two', '2026-08-26T00:00:00.000Z')); + await repositoryChatStorage.saveSession(createSession('older', 1, 'owner/repo-one', '2026-08-24T00:00:00.000Z')); + await repositoryChatStorage.saveSession(createSession('newer', 2, 'owner/repo-two', '2026-08-26T00:00:00.000Z')); render( {}} onSelectSession={() => {}} />); @@ -48,8 +48,8 @@ describe('GlobalChatHistorySheet', () => { }); it('按标题或仓库名过滤并进入对应仓库会话', async () => { - await repositoryChatSessionRepository.saveSession(createSession('s1', 1, 'owner/repo-one', '2026-08-24T00:00:00.000Z')); - await repositoryChatSessionRepository.saveSession(createSession('s2', 2, 'owner/repo-two', '2026-08-26T00:00:00.000Z')); + await repositoryChatStorage.saveSession(createSession('s1', 1, 'owner/repo-one', '2026-08-24T00:00:00.000Z')); + await repositoryChatStorage.saveSession(createSession('s2', 2, 'owner/repo-two', '2026-08-26T00:00:00.000Z')); const onSelectSession = vi.fn(); render( {}} onSelectSession={onSelectSession} />); @@ -62,7 +62,7 @@ describe('GlobalChatHistorySheet', () => { expect(onSelectSession).toHaveBeenCalledWith(repositories[1], 's2'); }); - it('删除会话后从列表移除', async () => { await repositoryChatSessionRepository.saveSession(createSession('doomed', 1, 'owner/repo-one', '2026-08-26T00:00:00.000Z')); + it('删除会话后从列表移除', async () => { await repositoryChatStorage.saveSession(createSession('doomed', 1, 'owner/repo-one', '2026-08-26T00:00:00.000Z')); render( {}} onSelectSession={() => {}} />); await screen.findByTitle('进入 owner/repo-one 的会话'); @@ -75,7 +75,7 @@ describe('GlobalChatHistorySheet', () => { }); it('读取失败时显示错误与重试,恢复后可重载', async () => { - const listSpy = vi.spyOn(repositoryChatSessionRepository, 'listRecentSessions'); + const listSpy = vi.spyOn(repositoryChatStorage, 'listRecentSessions'); listSpy.mockRejectedValueOnce(new Error('IndexedDB unavailable')); render( {}} onSelectSession={() => {}} />); diff --git a/src/components/GlobalChatHistorySheet.tsx b/src/components/GlobalChatHistorySheet.tsx index 277bd825f..3d0a3f614 100644 --- a/src/components/GlobalChatHistorySheet.tsx +++ b/src/components/GlobalChatHistorySheet.tsx @@ -3,7 +3,7 @@ import { History, Search, Trash2 } from 'lucide-react'; import type { Repository } from '../types'; import type { RepositoryChatSession } from '../types/repositoryChat'; import { useAppStore } from '../store/useAppStore'; -import { repositoryChatSessionRepository } from '../features/repository-chat/repositories/sessionRepository'; +import { repositoryChatStorage } from '../services/repositoryChatStorage'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; @@ -54,7 +54,7 @@ export const GlobalChatHistorySheet: React.FC = ({ setIsLoading(true); setLoadError(null); try { - const nextSessions = await repositoryChatSessionRepository.listRecentSessions(50); + const nextSessions = await repositoryChatStorage.listRecentSessions(50); if (requestId !== requestIdRef.current) return; setSessions(nextSessions); } catch { @@ -73,7 +73,7 @@ export const GlobalChatHistorySheet: React.FC = ({ }, [isOpen, refresh]); const handleDelete = useCallback(async (sessionId: string) => { - await repositoryChatSessionRepository.permanentlyDeleteSession(sessionId); + await repositoryChatStorage.permanentlyDeleteSession(sessionId); setSessions((previous) => previous.filter((session) => session.id !== sessionId)); window.dispatchEvent(new CustomEvent(HISTORY_CHANGE_EVENT)); }, []); diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index c4506ead4..b3486278f 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -8,7 +8,7 @@ import { useSearchShortcuts } from '../hooks/useSearchShortcuts'; import { useSearchActions } from '../features/repositories/hooks/useSearchActions'; import { useDialog } from '../hooks/useDialog'; import { isRepoCustomized } from '../utils/repoUtils'; -import { repositoryChatSessionRepository } from '../features/repository-chat/repositories/sessionRepository'; +import { repositoryChatStorage } from '../services/repositoryChatStorage'; import { applyRepoFilters, performBasicTextSearch as basicTextSearch, sortRepositories } from '../utils/repoSearch'; import { NO_LICENSE_SENTINEL, normalizeLicense } from '../utils/licenseFilter'; import { NumberInput } from './ui/NumberInput'; @@ -125,7 +125,7 @@ export const SearchBar: React.FC = () => { const refreshGlobalHistoryCount = async () => { const requestId = ++globalHistoryRequestRef.current; try { - const sessions = await repositoryChatSessionRepository.listRecentSessions(100); + const sessions = await repositoryChatStorage.listRecentSessions(100); if (!cancelled && requestId === globalHistoryRequestRef.current) setGlobalHistoryCount(sessions.length); } catch { if (!cancelled && requestId === globalHistoryRequestRef.current) setGlobalHistoryCount(0); diff --git a/src/features/discovery/hooks/useDiscoveryActions.ts b/src/features/discovery/hooks/useDiscoveryActions.ts index f647d3121..2b90e299d 100644 --- a/src/features/discovery/hooks/useDiscoveryActions.ts +++ b/src/features/discovery/hooks/useDiscoveryActions.ts @@ -11,7 +11,7 @@ import { discoveryAnalysisStorage } from '../../../services/discoveryAnalysisSto import { buildCategoryHints, resolveCategoryAssignment } from '../../../utils/categoryUtils'; import { getAllCategories } from '../../../store/useAppStore'; import { useDialog } from '../../../hooks/useDialog'; -import { useAuthSessionGeneration } from '../../lifecycle/useAuthSessionGeneration'; +import { useAuthSessionGeneration } from '../../../hooks/useAuthSessionGeneration'; const getChannelRequestSignature = (state: ReturnType, channelId: DiscoveryChannelId) => { const common = [state.githubToken, state.discoveryPlatform]; diff --git a/src/features/forks/hooks/useForkTimelineActions.ts b/src/features/forks/hooks/useForkTimelineActions.ts index 83eb96fc3..08174c1bb 100644 --- a/src/features/forks/hooks/useForkTimelineActions.ts +++ b/src/features/forks/hooks/useForkTimelineActions.ts @@ -6,7 +6,7 @@ import { selectForkTimelineState } from '../../../store/selectors'; import { GitHubApiService } from '../../../services/githubApi'; import { logger } from '../../../services/logger'; import { useDialog } from '../../../hooks/useDialog'; -import { useAuthSessionGeneration, type AuthSessionGeneration } from '../../lifecycle/useAuthSessionGeneration'; +import { useAuthSessionGeneration, type AuthSessionGeneration } from '../../../hooks/useAuthSessionGeneration'; interface SyncModalState { isOpen: boolean; diff --git a/src/features/lifecycle/useBackendLifecycle.test.tsx b/src/features/lifecycle/hooks/useBackendLifecycle.test.tsx similarity index 94% rename from src/features/lifecycle/useBackendLifecycle.test.tsx rename to src/features/lifecycle/hooks/useBackendLifecycle.test.tsx index e8c36ac26..e9e4bb148 100644 --- a/src/features/lifecycle/useBackendLifecycle.test.tsx +++ b/src/features/lifecycle/hooks/useBackendLifecycle.test.tsx @@ -22,15 +22,15 @@ const mocks = vi.hoisted(() => { }; }); -vi.mock('../../services/backendAdapter', () => ({ backend: mocks.backend })); -vi.mock('../../services/autoSync', () => ({ +vi.mock('../../../services/backendAdapter', () => ({ backend: mocks.backend })); +vi.mock('../../../services/autoSync', () => ({ tryRestoreAuthFromBackend: mocks.tryRestoreAuthFromBackend, syncLocalGitHubTokenToBackend: mocks.syncLocalGitHubTokenToBackend, syncFromBackend: mocks.syncFromBackend, startAutoSync: mocks.startAutoSync, stopAutoSync: mocks.stopAutoSync, })); -vi.mock('../../services/mcpElectronBridge', () => ({ +vi.mock('../../../services/mcpElectronBridge', () => ({ startMcpElectronBridge: mocks.startMcpElectronBridge, refreshMcpElectronBridge: mocks.refreshMcpElectronBridge, stopMcpElectronBridge: mocks.stopMcpElectronBridge, diff --git a/src/features/lifecycle/useBackendLifecycle.ts b/src/features/lifecycle/hooks/useBackendLifecycle.ts similarity index 93% rename from src/features/lifecycle/useBackendLifecycle.ts rename to src/features/lifecycle/hooks/useBackendLifecycle.ts index 25dd5d8ad..52ea55224 100644 --- a/src/features/lifecycle/useBackendLifecycle.ts +++ b/src/features/lifecycle/hooks/useBackendLifecycle.ts @@ -1,17 +1,17 @@ import { useEffect } from 'react'; -import { backend } from '../../services/backendAdapter'; +import { backend } from '../../../services/backendAdapter'; import { startAutoSync, stopAutoSync, syncFromBackend, syncLocalGitHubTokenToBackend, tryRestoreAuthFromBackend, -} from '../../services/autoSync'; +} from '../../../services/autoSync'; import { refreshMcpElectronBridge, startMcpElectronBridge, stopMcpElectronBridge, -} from '../../services/mcpElectronBridge'; +} from '../../../services/mcpElectronBridge'; /** * Owns application-wide backend and Electron MCP startup after Store hydration. diff --git a/src/features/repository-chat/hooks/useRepositoryChat.test.ts b/src/features/repository-chat/hooks/useRepositoryChat.test.ts index 1bf273915..24afe157c 100644 --- a/src/features/repository-chat/hooks/useRepositoryChat.test.ts +++ b/src/features/repository-chat/hooks/useRepositoryChat.test.ts @@ -21,8 +21,8 @@ vi.mock('../../../services/repositoryChatRunner', () => ({ runRepositoryChatTurn: mocks.runRepositoryChatTurn, })); -vi.mock('../repositories/sessionRepository', () => ({ - repositoryChatSessionRepository: { +vi.mock('../../../services/repositoryChatStorage', () => ({ + repositoryChatStorage: { listEvidence: mocks.listEvidence, listToolEvents: mocks.listToolEvents, saveMessage: mocks.saveMessage, diff --git a/src/features/repository-chat/hooks/useRepositoryChat.ts b/src/features/repository-chat/hooks/useRepositoryChat.ts index 3e628d7b7..86e35a300 100644 --- a/src/features/repository-chat/hooks/useRepositoryChat.ts +++ b/src/features/repository-chat/hooks/useRepositoryChat.ts @@ -9,7 +9,7 @@ import type { ToolEvidence, } from '../../../types/repositoryChat'; import { runRepositoryChatTurn } from '../../../services/repositoryChatRunner'; -import { repositoryChatSessionRepository } from '../repositories/sessionRepository'; +import { repositoryChatStorage } from '../../../services/repositoryChatStorage'; const createId = (prefix: string): string => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return `${prefix}-${crypto.randomUUID()}`; @@ -85,7 +85,7 @@ export const useRepositoryChat = ({ return; } let active = true; - void repositoryChatSessionRepository.listEvidence(evidenceIds).then((evidences) => { + void repositoryChatStorage.listEvidence(evidenceIds).then((evidences) => { if (!active) return; setEvidenceById(Object.fromEntries(evidences.map((evidence) => [evidence.id, evidence]))); }); @@ -98,7 +98,7 @@ export const useRepositoryChat = ({ return; } let active = true; - void repositoryChatSessionRepository.listToolEvents(session.id).then((events) => { + void repositoryChatStorage.listToolEvents(session.id).then((events) => { if (active) setToolEvents(events); }); return () => { active = false; }; @@ -144,7 +144,7 @@ export const useRepositoryChat = ({ // one event ID so an older delayed running write cannot overwrite success. const previousWrite = toolEventWriteChainsRef.current.get(toolEvent.id) ?? Promise.resolve(); const write = previousWrite.catch(() => undefined).then(async () => { - await repositoryChatSessionRepository.saveToolEvent(toolEvent); + await repositoryChatStorage.saveToolEvent(toolEvent); }); toolEventWriteChainsRef.current.set(toolEvent.id, write); try { @@ -200,8 +200,8 @@ export const useRepositoryChat = ({ let streamedContent = ''; try { await Promise.all([ - repositoryChatSessionRepository.saveMessage(userMessage), - repositoryChatSessionRepository.saveMessage(assistantMessage), + repositoryChatStorage.saveMessage(userMessage), + repositoryChatStorage.saveMessage(assistantMessage), ]); let streamFlushTimer: ReturnType | null = null; const flushStreamedContent = (content: string) => { @@ -253,14 +253,14 @@ export const useRepositoryChat = ({ globalThis.clearTimeout(streamFlushTimer); streamFlushTimer = null; } - await Promise.all(result.evidences.map((evidence) => repositoryChatSessionRepository.saveEvidence(evidence))); + await Promise.all(result.evidences.map((evidence) => repositoryChatStorage.saveEvidence(evidence))); const completedAssistant: RepositoryChatMessage = { ...assistantMessage, content: result.content, status: 'complete', evidenceIds: result.evidences.map((evidence) => evidence.id), }; - await repositoryChatSessionRepository.saveMessage(completedAssistant); + await repositoryChatStorage.saveMessage(completedAssistant); onMessagesChange([...baseMessages, userMessage, completedAssistant]); await onSessionChange({ ...session, @@ -287,7 +287,7 @@ export const useRepositoryChat = ({ // is unavailable and cannot record the terminal failure state. onMessagesChange([...baseMessages, userMessage, failedAssistant]); try { - await repositoryChatSessionRepository.saveMessage(failedAssistant); + await repositoryChatStorage.saveMessage(failedAssistant); } catch { // The original error is already represented in the transcript and banner. } @@ -312,7 +312,7 @@ export const useRepositoryChat = ({ const baseMessages = messages.slice(0, messages.length - 2); retryInFlightRef.current = true; try { - await repositoryChatSessionRepository.permanentlyDeleteMessages([lastUser.id, lastAssistant.id]); + await repositoryChatStorage.permanentlyDeleteMessages([lastUser.id, lastAssistant.id]); onMessagesChange(baseMessages); await send(lastUser.content, baseMessages, true); } finally { diff --git a/src/features/repository-chat/hooks/useRepositoryChatSessions.ts b/src/features/repository-chat/hooks/useRepositoryChatSessions.ts index c87a95886..47ec4aaa7 100644 --- a/src/features/repository-chat/hooks/useRepositoryChatSessions.ts +++ b/src/features/repository-chat/hooks/useRepositoryChatSessions.ts @@ -3,7 +3,7 @@ import type { Repository } from '../../../types'; import { useAppStore } from '../../../store/useAppStore'; import { resolveRepositoryChatHeadSha } from '../../../services/repositoryChatService'; import type { RepositoryChatMessage, RepositoryChatSession } from '../../../types/repositoryChat'; -import { repositoryChatSessionRepository } from '../repositories/sessionRepository'; +import { repositoryChatStorage } from '../../../services/repositoryChatStorage'; const createId = (): string => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID(); @@ -44,7 +44,7 @@ export const useRepositoryChatSessions = ({ if (operationId === operationIdRef.current) setMessages([]); return; } - const nextMessages = await repositoryChatSessionRepository.listMessages(session.id); + const nextMessages = await repositoryChatStorage.listMessages(session.id); if (operationId === operationIdRef.current) setMessages(nextMessages); }, []); @@ -60,8 +60,8 @@ export const useRepositoryChatSessions = ({ setIsLoading(true); setError(null); try { - await repositoryChatSessionRepository.purgeExpiredSessions(repository.id, retainSessionDays); - const nextSessions = await repositoryChatSessionRepository.listSessionsByRepository(repository.id); + await repositoryChatStorage.purgeExpiredSessions(repository.id, retainSessionDays); + const nextSessions = await repositoryChatStorage.listSessionsByRepository(repository.id); if (operationId !== operationIdRef.current) return; setSessions(nextSessions); const mostRecent = nextSessions[0] ?? null; @@ -100,7 +100,7 @@ export const useRepositoryChatSessions = ({ createdAt: now, updatedAt: now, }; - await repositoryChatSessionRepository.saveSession(session); + await repositoryChatStorage.saveSession(session); if (operationId !== operationIdRef.current) return null; setSessions((previous) => [session, ...previous]); setActiveSession(session); @@ -127,7 +127,7 @@ export const useRepositoryChatSessions = ({ setIsLoading(true); setError(null); try { - await repositoryChatSessionRepository.permanentlyDeleteSession(sessionId); + await repositoryChatStorage.permanentlyDeleteSession(sessionId); notifyGlobalHistoryChanged(); if (operationId !== operationIdRef.current) return; const nextSessions = sessions.filter((session) => session.id !== sessionId); @@ -143,7 +143,7 @@ export const useRepositoryChatSessions = ({ }, [activeSession, loadSessionMessages, sessions]); const updateSession = useCallback(async (session: RepositoryChatSession) => { - await repositoryChatSessionRepository.saveSession(session); + await repositoryChatStorage.saveSession(session); setSessions((previous) => previous .map((item) => item.id === session.id ? session : item) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))); diff --git a/src/features/lifecycle/useAuthSessionGeneration.ts b/src/hooks/useAuthSessionGeneration.ts similarity index 100% rename from src/features/lifecycle/useAuthSessionGeneration.ts rename to src/hooks/useAuthSessionGeneration.ts diff --git a/src/features/repository-chat/repositories/sessionRepository.test.ts b/src/services/repositoryChatStorage.test.ts similarity index 54% rename from src/features/repository-chat/repositories/sessionRepository.test.ts rename to src/services/repositoryChatStorage.test.ts index aa3cfe952..c3d20f806 100644 --- a/src/features/repository-chat/repositories/sessionRepository.test.ts +++ b/src/services/repositoryChatStorage.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { repositoryChatSessionRepository } from './sessionRepository'; -import type { RepositoryChatMessage, RepositoryChatSession, RepositoryChatToolEvent, ToolEvidence } from '../../../types/repositoryChat'; +import { repositoryChatStorage } from './repositoryChatStorage'; +import type { RepositoryChatMessage, RepositoryChatSession, RepositoryChatToolEvent, ToolEvidence } from '../types/repositoryChat'; const createSession = (id: string, repoId: number, updatedAt: string): RepositoryChatSession => ({ id, @@ -46,7 +46,7 @@ const createEvidence = (id: string): ToolEvidence => ({ retrievedAt: '2026-08-26T00:00:00.000Z', }); -describe('repositoryChatSessionRepository local fallback', () => { +describe('repositoryChatStorage local fallback', () => { const originalIndexedDb = Object.getOwnPropertyDescriptor(window, 'indexedDB'); const originalLocalStorage = Object.getOwnPropertyDescriptor(window, 'localStorage'); @@ -63,69 +63,69 @@ describe('repositoryChatSessionRepository local fallback', () => { }); it('filters, orders, and soft-deletes sessions strictly within the selected repository', async () => { - await repositoryChatSessionRepository.saveSession(createSession('older', 1, '2026-08-24T00:00:00.000Z')); - await repositoryChatSessionRepository.saveSession(createSession('other-repository', 2, '2026-08-26T00:00:00.000Z')); - await repositoryChatSessionRepository.saveSession(createSession('newer', 1, '2026-08-25T00:00:00.000Z')); + await repositoryChatStorage.saveSession(createSession('older', 1, '2026-08-24T00:00:00.000Z')); + await repositoryChatStorage.saveSession(createSession('other-repository', 2, '2026-08-26T00:00:00.000Z')); + await repositoryChatStorage.saveSession(createSession('newer', 1, '2026-08-25T00:00:00.000Z')); - await expect(repositoryChatSessionRepository.listSessionsByRepository(1)).resolves.toMatchObject([ + await expect(repositoryChatStorage.listSessionsByRepository(1)).resolves.toMatchObject([ { id: 'newer', repoId: 1 }, { id: 'older', repoId: 1 }, ]); - await repositoryChatSessionRepository.softDeleteSession('newer'); - await expect(repositoryChatSessionRepository.listSessionsByRepository(1)).resolves.toMatchObject([ + await repositoryChatStorage.softDeleteSession('newer'); + await expect(repositoryChatStorage.listSessionsByRepository(1)).resolves.toMatchObject([ { id: 'older', repoId: 1 }, ]); - await expect(repositoryChatSessionRepository.listSessionsByRepository(2)).resolves.toMatchObject([ + await expect(repositoryChatStorage.listSessionsByRepository(2)).resolves.toMatchObject([ { id: 'other-repository', repoId: 2 }, ]); }); it('purges expired sessions for the active repository while retaining current sessions', async () => { - await repositoryChatSessionRepository.saveSession(createSession('expired', 1, '2026-01-01T00:00:00.000Z')); - await repositoryChatSessionRepository.saveSession(createSession('current', 1, new Date().toISOString())); + await repositoryChatStorage.saveSession(createSession('expired', 1, '2026-01-01T00:00:00.000Z')); + await repositoryChatStorage.saveSession(createSession('current', 1, new Date().toISOString())); - await repositoryChatSessionRepository.purgeExpiredSessions(1, 1); + await repositoryChatStorage.purgeExpiredSessions(1, 1); - await expect(repositoryChatSessionRepository.getSession('expired')).resolves.toBeNull(); - await expect(repositoryChatSessionRepository.getSession('current')).resolves.toMatchObject({ id: 'current' }); + await expect(repositoryChatStorage.getSession('expired')).resolves.toBeNull(); + await expect(repositoryChatStorage.getSession('current')).resolves.toMatchObject({ id: 'current' }); }); it('physically deletes all messages, tool events, and evidence connected to the deleted session', async () => { const session = createSession('session-1', 1, '2026-08-26T00:00:00.000Z'); const messageEvidence = createEvidence('message-evidence'); const toolEvidence = createEvidence('tool-evidence'); - await repositoryChatSessionRepository.saveSession(session); - await repositoryChatSessionRepository.saveMessage(createMessage('message-1', session.id, [messageEvidence.id])); - await repositoryChatSessionRepository.saveToolEvent(createToolEvent(session.id, toolEvidence.id)); - await repositoryChatSessionRepository.saveEvidence(messageEvidence); - await repositoryChatSessionRepository.saveEvidence(toolEvidence); - - await repositoryChatSessionRepository.permanentlyDeleteSession(session.id); - - await expect(repositoryChatSessionRepository.getSession(session.id)).resolves.toBeNull(); - await expect(repositoryChatSessionRepository.listMessages(session.id)).resolves.toEqual([]); - await expect(repositoryChatSessionRepository.listToolEvents(session.id)).resolves.toEqual([]); - await expect(repositoryChatSessionRepository.listEvidence([messageEvidence.id, toolEvidence.id])).resolves.toEqual([]); + await repositoryChatStorage.saveSession(session); + await repositoryChatStorage.saveMessage(createMessage('message-1', session.id, [messageEvidence.id])); + await repositoryChatStorage.saveToolEvent(createToolEvent(session.id, toolEvidence.id)); + await repositoryChatStorage.saveEvidence(messageEvidence); + await repositoryChatStorage.saveEvidence(toolEvidence); + + await repositoryChatStorage.permanentlyDeleteSession(session.id); + + await expect(repositoryChatStorage.getSession(session.id)).resolves.toBeNull(); + await expect(repositoryChatStorage.listMessages(session.id)).resolves.toEqual([]); + await expect(repositoryChatStorage.listToolEvents(session.id)).resolves.toEqual([]); + await expect(repositoryChatStorage.listEvidence([messageEvidence.id, toolEvidence.id])).resolves.toEqual([]); }); it('lists recent sessions across repositories in recency order with a limit', async () => { - await repositoryChatSessionRepository.saveSession(createSession('repo1-older', 1, '2026-08-24T00:00:00.000Z')); - await repositoryChatSessionRepository.saveSession(createSession('repo2-newer', 2, '2026-08-26T00:00:00.000Z')); - await repositoryChatSessionRepository.saveSession(createSession('repo1-middle', 1, '2026-08-25T00:00:00.000Z')); + await repositoryChatStorage.saveSession(createSession('repo1-older', 1, '2026-08-24T00:00:00.000Z')); + await repositoryChatStorage.saveSession(createSession('repo2-newer', 2, '2026-08-26T00:00:00.000Z')); + await repositoryChatStorage.saveSession(createSession('repo1-middle', 1, '2026-08-25T00:00:00.000Z')); - await expect(repositoryChatSessionRepository.listRecentSessions(2)).resolves.toMatchObject([ + await expect(repositoryChatStorage.listRecentSessions(2)).resolves.toMatchObject([ { id: 'repo2-newer', repoId: 2 }, { id: 'repo1-middle', repoId: 1 }, ]); - await expect(repositoryChatSessionRepository.listRecentSessions()).resolves.toMatchObject([ + await expect(repositoryChatStorage.listRecentSessions()).resolves.toMatchObject([ { id: 'repo2-newer', repoId: 2 }, { id: 'repo1-middle', repoId: 1 }, { id: 'repo1-older', repoId: 1 }, ]); - await repositoryChatSessionRepository.softDeleteSession('repo2-newer'); - await expect(repositoryChatSessionRepository.listRecentSessions()).resolves.toMatchObject([ + await repositoryChatStorage.softDeleteSession('repo2-newer'); + await expect(repositoryChatStorage.listRecentSessions()).resolves.toMatchObject([ { id: 'repo1-middle', repoId: 1 }, { id: 'repo1-older', repoId: 1 }, ]); @@ -145,7 +145,7 @@ describe('repositoryChatSessionRepository local fallback', () => { }; Object.defineProperty(window, 'localStorage', { configurable: true, value: throwingStorage }); - await expect(repositoryChatSessionRepository.saveSession(createSession('cannot-persist', 1, '2026-08-26T00:00:00.000Z'))) + await expect(repositoryChatStorage.saveSession(createSession('cannot-persist', 1, '2026-08-26T00:00:00.000Z'))) .rejects.toThrow('unable to persist fallback snapshot'); }); }); diff --git a/src/features/repository-chat/repositories/sessionRepository.ts b/src/services/repositoryChatStorage.ts similarity index 99% rename from src/features/repository-chat/repositories/sessionRepository.ts rename to src/services/repositoryChatStorage.ts index ba5da7dda..75c3f4670 100644 --- a/src/features/repository-chat/repositories/sessionRepository.ts +++ b/src/services/repositoryChatStorage.ts @@ -3,7 +3,7 @@ import type { RepositoryChatSession, RepositoryChatToolEvent, ToolEvidence, -} from '../../../types/repositoryChat'; +} from '../types/repositoryChat'; const DB_NAME = 'gsm-repository-chat-db'; const DB_VERSION = 1; @@ -179,7 +179,7 @@ const byCreatedAt = (left: T, right: T) => right.updatedAt.localeCompare(left.updatedAt); -export const repositoryChatSessionRepository = { +export const repositoryChatStorage = { async listSessionsByRepository(repoId: number): Promise { const fallback = () => fallbackList('sessions', (session) => session.repoId === repoId && !session.deletedAt).sort(byUpdatedAtDescending); if (useFallbackStorage || !canUseIndexedDb()) { From 53b6a8f7a71cd6d8882bad229053d892a73f8fe6 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Sat, 12 Sep 2026 16:19:11 +0800 Subject: [PATCH 2/6] =?UTF-8?q?refactor(releases):=20=E6=8A=BD=E5=87=BA=20?= =?UTF-8?q?useWatchedSourcesSync,=E4=BF=AE=E5=A4=8D=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E8=BF=87=E5=BA=A6=E9=87=8D=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReleaseSourceSettingsModal 只需要 syncWatchedSources / isSyncingWatchedSources 两项,却经 useReleaseTimelineActions 订阅了 selectReleaseTimelineState 全部 35 个字段,任一 Release 状态变更都会让它重渲染。把同步逻辑整体搬入窄订阅 hook useWatchedSourcesSync(仅订阅 githubToken/language/setReleaseSource- Repositories 三个单值 selector),modal 改为直接消费;同步用例测试随逻辑 迁至 useWatchedSourcesSync.test.tsx。 ReleaseTimeline 不消费 sync 相关字段,行为不受影响;同步回调逐字保留。 --- src/components/ReleaseSourceSettingsModal.tsx | 4 +- .../hooks/useReleaseTimelineActions.ts | 43 ------------- ...est.tsx => useWatchedSourcesSync.test.tsx} | 59 +++--------------- .../releases/hooks/useWatchedSourcesSync.ts | 60 +++++++++++++++++++ 4 files changed, 71 insertions(+), 95 deletions(-) rename src/features/releases/hooks/{useReleaseTimelineActions.test.tsx => useWatchedSourcesSync.test.tsx} (76%) create mode 100644 src/features/releases/hooks/useWatchedSourcesSync.ts diff --git a/src/components/ReleaseSourceSettingsModal.tsx b/src/components/ReleaseSourceSettingsModal.tsx index 8e7b2cd67..8a6d2d70d 100644 --- a/src/components/ReleaseSourceSettingsModal.tsx +++ b/src/components/ReleaseSourceSettingsModal.tsx @@ -6,7 +6,7 @@ import type { CustomReleaseRepository, ReleaseSourceId } from '../types'; import { useAppStore } from '../store/useAppStore'; import { Modal } from './Modal'; import { useDialog } from '../hooks/useDialog'; -import { useReleaseTimelineActions } from '../features/releases/hooks/useReleaseTimelineActions'; +import { useWatchedSourcesSync } from '../features/releases/hooks/useWatchedSourcesSync'; import { CUSTOM_RELEASE_SOURCE_ID, RELEASE_SOURCE_LABELS, @@ -211,7 +211,7 @@ interface WatchCustomReleaseSyncPanelProps { const WatchCustomReleaseSyncPanel: React.FC = ({ repos, language }) => { const githubToken = useAppStore(state => state.githubToken); const updateReleaseSourceRepository = useAppStore(state => state.updateReleaseSourceRepository); - const { syncWatchedSources, isSyncingWatchedSources } = useReleaseTimelineActions(); + const { syncWatchedSources, isSyncingWatchedSources } = useWatchedSourcesSync(); const isSyncing = isSyncingWatchedSources; const t = (zh: string, en: string) => language === 'zh' ? zh : en; diff --git a/src/features/releases/hooks/useReleaseTimelineActions.ts b/src/features/releases/hooks/useReleaseTimelineActions.ts index 26e8a3b95..f31dd5d11 100644 --- a/src/features/releases/hooks/useReleaseTimelineActions.ts +++ b/src/features/releases/hooks/useReleaseTimelineActions.ts @@ -12,7 +12,6 @@ import { getSourcesForReleaseRepository, normalizeRepoKey, releaseBelongsToResolvedSources, - repositoryToCustomReleaseRepository, resolveReleaseSources, STARRED_RELEASE_SOURCE_ID, WATCH_CUSTOM_RELEASE_SOURCE_ID, @@ -25,13 +24,9 @@ import { findReleasesWithChangedAssets } from '../../../utils/releaseAssets'; */ export const useReleaseTimelineActions = () => { const state = useAppStore(useShallow(selectReleaseTimelineState)); - // 共享 selector 未收录的 store 写入用独立单值 selector 获取(B1); - // store action 引用稳定,单值订阅不会引入多余重渲染。 - const setReleaseSourceRepositories = useAppStore((s) => s.setReleaseSourceRepositories); const { toast, confirm } = useDialog(); const [lastRefreshTime, setLastRefreshTime] = useState(null); const [isMarkingAllRead, setIsMarkingAllRead] = useState(false); - const [isSyncingWatchedSources, setIsSyncingWatchedSources] = useState(false); const t = useCallback((zh: string, en: string) => state.language === 'zh' ? zh : en, [state.language]); const handleRefresh = useCallback(async () => { @@ -194,51 +189,13 @@ export const useReleaseTimelineActions = () => { toast(t('已取消订阅该仓库的 Release。', 'Unsubscribed from repository releases.'), 'success'); }, [state, t, toast, confirm]); - // 同步 Watch 仓库为 Release 来源(原 ReleaseSourceSettingsModal → - // WatchCustomReleaseSyncPanel.handleSync):无 confirm、无 forceSync、无 Abort。 - const syncWatchedSources = useCallback(async () => { - if (!state.githubToken || isSyncingWatchedSources) return; - - setIsSyncingWatchedSources(true); - try { - const githubApi = new GitHubApiService(state.githubToken); - // 只拉 /user/subscriptions(含私有仓)。/users/{login}/subscriptions 已被 GitHub 改为 - // 恒定返回 204 空响应体,且其结果本就是前者的公开子集,并行合并只会拖垮整个同步。 - const watchedRepos = await githubApi.getAllWatchedRepositories(); - // hiddenByRepo 在 await 之后从最新 state 读取:同步期间用户仍可在设置面板 - // 切换 release_hidden,旧快照会在 setReleaseSourceRepositories 时覆盖该修改。 - const hiddenByRepo = new Map( - useAppStore.getState().releaseSourceSettings.watchCustomReleaseRepos.map(repo => [normalizeRepoKey(repo.full_name), repo.release_hidden]), - ); - const sourceRepos = watchedRepos.map(repo => ({ - ...repositoryToCustomReleaseRepository(repo, WATCH_CUSTOM_RELEASE_SOURCE_ID), - release_hidden: hiddenByRepo.get(normalizeRepoKey(repo.full_name)) || undefined, - })); - setReleaseSourceRepositories(WATCH_CUSTOM_RELEASE_SOURCE_ID, sourceRepos); - toast( - t( - `已同步 ${sourceRepos.length} 个 Watch 仓库。`, - `Synced ${sourceRepos.length} Watch repositories.` - ), - 'success' - ); - } catch (error) { - console.error('Failed to sync watched repositories:', error); - toast(t('同步 Watch 仓库失败,请检查网络或 Token 权限。', 'Failed to sync Watch repositories. Check network or token permissions.'), 'error'); - } finally { - setIsSyncingWatchedSources(false); - } - }, [state, isSyncingWatchedSources, setReleaseSourceRepositories, t, toast]); - return { ...state, lastRefreshTime, isMarkingAllRead, - isSyncingWatchedSources, handleRefresh, handleMarkAllRead, handleUnsubscribeRelease, - syncWatchedSources, }; }; diff --git a/src/features/releases/hooks/useReleaseTimelineActions.test.tsx b/src/features/releases/hooks/useWatchedSourcesSync.test.tsx similarity index 76% rename from src/features/releases/hooks/useReleaseTimelineActions.test.tsx rename to src/features/releases/hooks/useWatchedSourcesSync.test.tsx index 4e44d3d78..dcdcbf241 100644 --- a/src/features/releases/hooks/useReleaseTimelineActions.test.tsx +++ b/src/features/releases/hooks/useWatchedSourcesSync.test.tsx @@ -1,6 +1,6 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useReleaseTimelineActions } from './useReleaseTimelineActions'; +import { useWatchedSourcesSync } from './useWatchedSourcesSync'; const mocks = vi.hoisted(() => ({ useAppStore: vi.fn(), @@ -8,7 +8,6 @@ const mocks = vi.hoisted(() => ({ confirm: vi.fn(), getAllWatchedRepositories: vi.fn(), setReleaseSourceRepositories: vi.fn(), - forceSyncToBackend: vi.fn(), })); vi.mock('../../../store/useAppStore', () => ({ @@ -25,19 +24,10 @@ vi.mock('../../../services/githubApi', () => ({ }, })); -vi.mock('../../../services/autoSync', () => ({ - forceSyncToBackend: mocks.forceSyncToBackend, -})); - -vi.mock('../../../services/backendAdapter', () => ({ - backend: { isAvailable: false, markAllReleasesAsRead: vi.fn() }, -})); - const createStoreState = () => ({ - // selectReleaseTimelineState 全字段 - releases: [], - repositories: [], - releaseSubscriptions: new Set(), + // useWatchedSourcesSync 的全部订阅:githubToken / language / + // setReleaseSourceRepositories 三个单值 selector + getState() 里的 + // releaseSourceSettings。窄订阅不触碰其余 Release 时间线状态。 releaseSourceSettings: { enabledSourceIds: ['watch-custom-release'], watchCustomReleaseRepos: [ @@ -45,38 +35,8 @@ const createStoreState = () => ({ ], customReleaseRepos: [], }, - readReleases: new Set(), githubToken: 'github-token' as string | null, language: 'zh' as const, - assetFilters: new Map(), - addReleases: vi.fn(), - upsertReleases: vi.fn(), - markReleaseAsRead: vi.fn(), - markAssetAsRead: vi.fn(), - markAllReleasesAsRead: vi.fn(), - batchUnsubscribeReleases: vi.fn(), - removeReleasesByRepoFullName: vi.fn(), - updateRepository: vi.fn(), - removeReleaseSourceRepository: vi.fn(), - updateReleaseSourceRepository: vi.fn(), - releaseViewMode: 'grouped' as const, - releaseSelectedFilters: new Set(), - releaseSearchQuery: '', - releaseExpandedRepositories: new Set(), - releaseIsRefreshing: false, - setReleaseViewMode: vi.fn(), - toggleReleaseSelectedFilter: vi.fn(), - clearReleaseSelectedFilters: vi.fn(), - setReleaseSearchQuery: vi.fn(), - toggleReleaseExpandedRepository: vi.fn(), - setReleaseIsRefreshing: vi.fn(), - includePreRelease: false, - setIncludePreRelease: vi.fn(), - releaseShowMode: 'all' as const, - setReleaseShowMode: vi.fn(), - releaseLatestMode: 'latest' as const, - setReleaseLatestMode: vi.fn(), - // 独立单值 selector setReleaseSourceRepositories: mocks.setReleaseSourceRepositories, }); @@ -86,7 +46,7 @@ mockUseAppStore.mockImplementation((selector?: (state: typeof storeState) => unk selector ? selector(storeState) : storeState); (mockUseAppStore as unknown as { getState: () => typeof storeState }).getState = () => storeState; -describe('useReleaseTimelineActions.syncWatchedSources', () => { +describe('useWatchedSourcesSync', () => { beforeEach(() => { vi.clearAllMocks(); storeState = createStoreState(); @@ -94,7 +54,7 @@ describe('useReleaseTimelineActions.syncWatchedSources', () => { it('returns silently without token or while a sync is running', async () => { storeState.githubToken = null; - const { result } = renderHook(() => useReleaseTimelineActions()); + const { result } = renderHook(() => useWatchedSourcesSync()); await act(async () => { await result.current.syncWatchedSources(); }); expect(mocks.getAllWatchedRepositories).not.toHaveBeenCalled(); expect(mocks.toast).not.toHaveBeenCalled(); @@ -136,7 +96,7 @@ describe('useReleaseTimelineActions.syncWatchedSources', () => { topics: [], }, ]); - const { result } = renderHook(() => useReleaseTimelineActions()); + const { result } = renderHook(() => useWatchedSourcesSync()); await act(async () => { await result.current.syncWatchedSources(); }); expect(mocks.setReleaseSourceRepositories).toHaveBeenCalledTimes(1); @@ -149,7 +109,6 @@ describe('useReleaseTimelineActions.syncWatchedSources', () => { expect(fresh.release_hidden).toBeUndefined(); expect(mocks.toast).toHaveBeenCalledWith('已同步 2 个 Watch 仓库。', 'success'); expect(result.current.isSyncingWatchedSources).toBe(false); - expect(mocks.forceSyncToBackend).not.toHaveBeenCalled(); }); it('reads release_hidden from the latest state after the request resolves', async () => { @@ -159,7 +118,7 @@ describe('useReleaseTimelineActions.syncWatchedSources', () => { mocks.getAllWatchedRepositories.mockImplementation(() => new Promise((resolve) => { resolveWatched = resolve; })); - const { result } = renderHook(() => useReleaseTimelineActions()); + const { result } = renderHook(() => useWatchedSourcesSync()); let pending!: Promise; act(() => { @@ -201,7 +160,7 @@ describe('useReleaseTimelineActions.syncWatchedSources', () => { it('toasts a failure without writing the store when the api rejects', async () => { mocks.getAllWatchedRepositories.mockRejectedValue(new Error('rate limited')); - const { result } = renderHook(() => useReleaseTimelineActions()); + const { result } = renderHook(() => useWatchedSourcesSync()); await act(async () => { await result.current.syncWatchedSources(); }); expect(mocks.setReleaseSourceRepositories).not.toHaveBeenCalled(); diff --git a/src/features/releases/hooks/useWatchedSourcesSync.ts b/src/features/releases/hooks/useWatchedSourcesSync.ts new file mode 100644 index 000000000..6b5cdd14f --- /dev/null +++ b/src/features/releases/hooks/useWatchedSourcesSync.ts @@ -0,0 +1,60 @@ +import { useCallback, useState } from 'react'; +import { useAppStore } from '../../../store/useAppStore'; +import { GitHubApiService } from '../../../services/githubApi'; +import { useDialog } from '../../../hooks/useDialog'; +import { + normalizeRepoKey, + repositoryToCustomReleaseRepository, + WATCH_CUSTOM_RELEASE_SOURCE_ID, +} from '../../../utils/releaseSources'; + +/** + * 同步 Watch 仓库为 Release 来源(原 ReleaseSourceSettingsModal → + * WatchCustomReleaseSyncPanel.handleSync):无 confirm、无 forceSync、无 Abort。 + * 独立于 useReleaseTimelineActions 的窄订阅 hook:设置弹窗只需要这两项, + * 不应随整个 Release 时间线状态重渲染。 + */ +export const useWatchedSourcesSync = () => { + const githubToken = useAppStore((s) => s.githubToken); + const setReleaseSourceRepositories = useAppStore((s) => s.setReleaseSourceRepositories); + const language = useAppStore((s) => s.language); + const { toast } = useDialog(); + const [isSyncingWatchedSources, setIsSyncingWatchedSources] = useState(false); + const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]); + + const syncWatchedSources = useCallback(async () => { + if (!githubToken || isSyncingWatchedSources) return; + + setIsSyncingWatchedSources(true); + try { + const githubApi = new GitHubApiService(githubToken); + // 只拉 /user/subscriptions(含私有仓)。/users/{login}/subscriptions 已被 GitHub 改为 + // 恒定返回 204 空响应体,且其结果本就是前者的公开子集,并行合并只会拖垮整个同步。 + const watchedRepos = await githubApi.getAllWatchedRepositories(); + // hiddenByRepo 在 await 之后从最新 state 读取:同步期间用户仍可在设置面板 + // 切换 release_hidden,旧快照会在 setReleaseSourceRepositories 时覆盖该修改。 + const hiddenByRepo = new Map( + useAppStore.getState().releaseSourceSettings.watchCustomReleaseRepos.map(repo => [normalizeRepoKey(repo.full_name), repo.release_hidden]), + ); + const sourceRepos = watchedRepos.map(repo => ({ + ...repositoryToCustomReleaseRepository(repo, WATCH_CUSTOM_RELEASE_SOURCE_ID), + release_hidden: hiddenByRepo.get(normalizeRepoKey(repo.full_name)) || undefined, + })); + setReleaseSourceRepositories(WATCH_CUSTOM_RELEASE_SOURCE_ID, sourceRepos); + toast( + t( + `已同步 ${sourceRepos.length} 个 Watch 仓库。`, + `Synced ${sourceRepos.length} Watch repositories.` + ), + 'success' + ); + } catch (error) { + console.error('Failed to sync watched repositories:', error); + toast(t('同步 Watch 仓库失败,请检查网络或 Token 权限。', 'Failed to sync Watch repositories. Check network or token permissions.'), 'error'); + } finally { + setIsSyncingWatchedSources(false); + } + }, [githubToken, isSyncingWatchedSources, setReleaseSourceRepositories, t, toast]); + + return { syncWatchedSources, isSyncingWatchedSources }; +}; From 119d88ee8f813a6a5d6bd90508a41e817beea4f9 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Sat, 12 Sep 2026 16:19:11 +0800 Subject: [PATCH 3/6] =?UTF-8?q?chore(boundaries):=20=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E8=A6=86=E7=9B=96=20feature=20=E5=86=85=20co?= =?UTF-8?q?mponents=20=E4=B8=8E=20hook=20=E5=BD=92=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前 no-restricted-imports 与 check-boundaries.cjs 只覆盖 src/components/** 与 src/features/*/application/**,feature 内其他目录处于无人看管的灰色地带: - eslint View 边界 block 与 checkComponentFile 的范围加入 src/features/*/components/**(feature 内嵌组件属 View 层,适用同一业务 服务禁令) - check-boundaries.cjs 新增 hook 归位检查:use*.ts(x) 直接位于 feature 根目录即报错(hooks 必须在 features/*/hooks/**,共享 hook 在 src/hooks/**) 两种检查均以临时探针文件验证过正反两向。 --- eslint.config.js | 9 ++++++++- scripts/check-boundaries.cjs | 30 ++++++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 8d62e01c9..11bd54aa0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -72,9 +72,16 @@ export default tseslint.config( * `no-restricted-syntax` covers dynamic `import('...')` (ImportExpression), which * no-restricted-imports does not inspect. */ - files: ['src/components/**/*.{ts,tsx}'], + // Covers the shared View tier (src/components/**) and feature-local view + // components (src/features/*/components/**), which are View tier per ADR 0001 + // and must not import business services either. + files: [ + 'src/components/**/*.{ts,tsx}', + 'src/features/*/components/**/*.{ts,tsx}', + ], ignores: [ 'src/components/**/*.test.{ts,tsx}', + 'src/features/*/components/**/*.test.{ts,tsx}', ], rules: { 'no-restricted-imports': [ diff --git a/scripts/check-boundaries.cjs b/scripts/check-boundaries.cjs index 921bad9ea..1c0894abf 100644 --- a/scripts/check-boundaries.cjs +++ b/scripts/check-boundaries.cjs @@ -5,16 +5,20 @@ * A standalone, offline scanner that mirrors the ESLint `no-restricted-imports` * rules in eslint.config.js. It runs in CI as a defense-in-depth check so that * a misconfigured lint pass (or a contributor who disables the rule inline) - * cannot silently let a View component import a business service, or an - * application command import React/JSX/the Store/a service. + * cannot silently let a View component (shared or feature-local) import a + * business service, an application command import React/JSX/the Store/a service, + * or a feature hook sit outside its feature's hooks/ directory (or src/hooks/ + * when shared across features). * * Contract source of truth: docs/adr/0001-frontend-layering.md * * Properties: * - No network, no dynamic import, no execution of repo code. It only reads * source files as text and pattern-matches import statements. - * - Exempts test files (*.test.ts / *.test.tsx) so vi.mock('../services/...') - * stays legal. + * - Import checks exempt test files (*.test.ts / *.test.tsx) so + * vi.mock('../services/...') stays legal. The hook-placement check applies + * to test files too: a use* test at a feature root means the code is + * misplaced regardless. * * Exit code: 0 = clean, 1 = violations found (or unreadable file). */ @@ -167,7 +171,25 @@ const files = walk(path.join(ROOT, 'src')); for (const full of files) { const relPath = rel(full); if (relPath.startsWith('src/components/')) checkComponentFile(full, relPath); + // Feature-local view components are View tier too (ADR 0001): same service ban. + else if (/^src\/features\/[^/]+\/components\//.test(relPath)) checkComponentFile(full, relPath); else if (/^src\/features\/[^/]+\/application\//.test(relPath)) checkApplicationFile(full, relPath); + else if (/^src\/features\/[^/]+\/[^/]+$/.test(relPath)) checkFeatureRootFile(relPath); +} + +/** + * ADR 0001: domain hooks live in src/features//hooks/**; hooks shared + * across features live in src/hooks/**. A use* module sitting directly at a + * feature's root is a placement violation — historically it also hid cross-feature + * hook imports (ADR footnote 2) from every automated check. + */ +function checkFeatureRootFile(relPath) { + const basename = relPath.split('/').pop(); + if (!/^use[A-Z].*\.tsx?$/.test(basename)) return; + console.error( + `✖ ${relPath}: feature hooks must live in src/features/*/hooks/** (or src/hooks/** when shared across features). See docs/adr/0001-frontend-layering.md.`, + ); + violations++; } if (violations > 0) { From 8d4d2bb1ee01a42e6dd9e45b327082d4439f5fbf Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Sat, 12 Sep 2026 16:19:11 +0800 Subject: [PATCH 4/6] =?UTF-8?q?docs(adr):=200001=20=E7=BF=BB=E4=B8=BA=20Ac?= =?UTF-8?q?cepted,=E8=A1=A5=E6=96=87=E4=BB=B6=E6=94=BE=E7=BD=AE=E8=A7=84?= =?UTF-8?q?=E5=88=99=E4=B8=8E=E9=A1=B5=E7=BA=A7=E9=80=89=E6=8B=A9=E5=99=A8?= =?UTF-8?q?=E7=BA=A6=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Status: Proposed → Accepted(对应 PR 早已合并,状态一直停在过渡表述) - 新增 File placement 小节:hooks/**、feature 内 components/**、 src/services/*Storage.ts 的归属与理由,记录本次三处归位的历史背景 - 新增 Page view-model selectors 小节:页级聚合 selector 只能被对应页面 的编排 hook 消费,子集消费者须用窄 selector/小 hook - Consequences 补充新检查项;follow-up 记录 discovery → repositories/ application 的同级横向导入待办 --- docs/adr/0001-frontend-layering.md | 48 ++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/docs/adr/0001-frontend-layering.md b/docs/adr/0001-frontend-layering.md index f08717d31..cbbc4d64e 100644 --- a/docs/adr/0001-frontend-layering.md +++ b/docs/adr/0001-frontend-layering.md @@ -1,11 +1,9 @@ # ADR 0001 — Frontend layering and dependency direction -Status: Proposed +Status: Accepted Date: 2026-08-26 Supersedes: none -Applies to: `src/components/**`, `src/features/**`, `src/store/**`, `src/services/**` - -> Status is **Proposed** until this PR lands; it flips to **Accepted** on merge. +Applies to: `src/components/**`, `src/features/**`, `src/hooks/**`, `src/store/**`, `src/services/**` ## Context @@ -74,6 +72,38 @@ Store, or any service — that is exactly the responsibility split the migration ⁵ Services may read Store state through `useAppStore.getState()` (non-reactive) but must not subscribe reactively. This is the existing pattern; nothing changes here. +### File placement (enforced by `check-boundaries.cjs`) + +The dependency table only means something if every module has an unambiguous tier. These +placement rules close the gray zones that existed before they were written down: + +- **Hooks** live in `src/features/*/hooks/**`. A `use*.ts(x)` module directly under a feature's + root directory fails `check-boundaries.cjs`. A hook that is genuinely shared across features + lives in `src/hooks/**` (footnote ² above) — importing it from another feature's `hooks/**` + is the sanctioned cross-feature path. (History: `useAuthSessionGeneration` and + `useBackendLifecycle` sat at `src/features/lifecycle/` root; the first hid two cross-feature + hook imports from every check, the second just set a bad example.) +- **Feature-local view components** may live in `src/features/*/components/**`. They are View + tier: the business-service import ban for `src/components/**` applies to them identically, + in both ESLint and `check-boundaries.cjs`. +- **Domain persistence modules** (IndexedDB/localStorage wrappers for one feature's data — + `repositoryChatStorage`, `discoveryAnalysisStorage`, `weeklyIssuesStorage`, + `indexedDbStorage`) live in `src/services/*Storage.ts`. By the rule of thumb below they are + infrastructure (local persistence, no remote calls, no Store mutation), so any tier may + import them. Do not invent new top-level directories inside a feature beyond `hooks/`, + `components/`, `application/`, `__tests__/` without an ADR amendment. (History: + `repository-chat/repositories/` invented an undocumented "repositories" tier whose name also + collided with the `repositories` feature.) + +### Page view-model selectors + +Selectors that bundle a whole page's state and actions (e.g. `selectReleaseTimelineState`, +`selectDiscoveryViewState` in `src/store/selectors.ts`) are page-scoped contracts: their only +reactive consumer is that page's orchestration hook in `src/features/*/hooks/**`. A component +or hook that needs a subset must not subscribe through them — every field change re-renders +it. Add a narrow single-value selector, or a small hook next to the page's action hooks (see +`useWatchedSourcesSync`), instead. + ### Why one persisted Store is retained The Store is split into *slices* for readability, but there is exactly **one** `create()(persist(...))` @@ -154,8 +184,11 @@ later PR that migrates a tail component also removes it from the allowlist. ## Consequences - New components in migrated directories that try to import a business service fail lint **and** - the `check-boundaries.cjs` CI step. + the `check-boundaries.cjs` CI step. The same gates cover feature-local components under + `src/features/*/components/**`. - New code in `src/features/*/application/**` that imports React/JSX/DOM fails the same gates. +- A `use*.ts(x)` module placed at a feature's root directory fails the `check-boundaries.cjs` + CI step. - A reviewer can point at this ADR instead of re-arguing the layering on every PR. - The allowlist is technical debt with an expiration date: each entry is a component that still needs its operations lifted into a `src/features/*/hooks/*` hook. @@ -165,5 +198,10 @@ later PR that migrates a tail component also removes it from the allowlist. - Migrate the allowlist tail components into hooks, one feature per PR, removing each from the allowlist as it lands. This ADR does not schedule that work; it only forbids *new* direct imports. +- `src/features/discovery/hooks/useDiscoveryRepoActions.ts` imports + `src/features/repositories/application/discoveryRepoPatches` — a sideways import into a + sibling feature's internals (forbidden by the Decision above, not yet enforced by any tool). + Either lift the shared patch into a neutral module or route it through the Store; not + scheduled here. - If a future PR genuinely needs a component to call a business service (e.g. a throwaway debug component), the answer is a new hook in the right feature, not an `eslint-disable` comment. From 18179d2f64a6b3455dee7368109f106e72f70c91 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Sat, 12 Sep 2026 16:34:25 +0800 Subject: [PATCH 5/6] =?UTF-8?q?docs(adr):=20=E8=A1=A5=E8=AE=B0=20updateSer?= =?UTF-8?q?vice/translateService=20=E7=A6=81=E4=BB=A4=E4=B8=8E=20allowlist?= =?UTF-8?q?=20=E5=BD=92=E9=9B=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0001 仍停留在 PR 9 的过渡期表述("ten services,另两个暂不禁止"),而 PR #326 早已把末批 3 个组件迁移完毕并将这两个服务并入禁令清单,eslint 与 check-boundaries 两处配置自那时起一致。按 CodeRabbit 建议同步 ADR 而非 放松配置:禁令清单补全为 12 项并注明与脚本互为镜像,Phased enforcement 与 Consequences 标注 allowlist 机制已完成退场,follow-up 移除已完成项。 --- docs/adr/0001-frontend-layering.md | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/adr/0001-frontend-layering.md b/docs/adr/0001-frontend-layering.md index cbbc4d64e..3076c4b70 100644 --- a/docs/adr/0001-frontend-layering.md +++ b/docs/adr/0001-frontend-layering.md @@ -145,9 +145,12 @@ guarantee for the v2 backend/electron split. ### Infrastructure vs business service — the import carve-out -`no-restricted-imports` (PR 9) bans `src/components/**` from importing these **business services**: -`githubApi`, `aiService`, `aiAnalysisHelper`, `aiAnalysisOptimizer`, `vectorSearchService`, -`autoSync`, `webdavService`, `backendAdapter`, `rpcDownloadService`, `githubApiFactory`. +`no-restricted-imports` (PR 9) bans View components — `src/components/**` and, since the +placement rules above, `src/features/*/components/**` — from importing these **business +services**: `githubApi`, `aiService`, `aiAnalysisHelper`, `aiAnalysisOptimizer`, +`vectorSearchService`, `autoSync`, `webdavService`, `backendAdapter`, `rpcDownloadService`, +`githubApiFactory`, `updateService`, `translateService`. The same list is mirrored in +`scripts/check-boundaries.cjs`; keep the two in sync. These remain importable from components because they are **tools, not orchestration**: `logger`, `electronProxy` (`isElectron`), `indexedDbStorage`, `mcpElectronBridge`, @@ -157,12 +160,11 @@ These remain importable from components because they are **tools, not orchestrat > a business service and belongs behind a hook. If it is a sync utility (`logger`, `isElectron`, > `indexedDBStorage`), it is infrastructure and may be imported anywhere. -This PR's ban list is the ten services above. Two further services — `updateService` and -`translateService` — are business services by the same rule (they make remote calls) but are -*not* banned in this PR; `BilingualMarkdownRenderer`, `UpdateChecker`, and -`UpdateNotificationBanner` still import them directly. They are phased out alongside the -component tail below. The ban list is deliberately the set the migration already covered; -expanding it is a follow-up PR, not this one. +When this ADR landed, the ban list was the first ten services above; `updateService` and +`translateService` (business services by the same rule — they make remote calls) were left +unbanned because `BilingualMarkdownRenderer`, `UpdateChecker`, and `UpdateNotificationBanner` +still imported them directly. That phase is complete: PR #326 migrated the last three +components and folded both services into the ban list, and the allowlist no longer exists. ### Phased enforcement @@ -181,6 +183,8 @@ would light up a dozen files at once and force a rushed migration in a boundary- "don't mask 33 violations in one go" failure mode. The allowlist is the phasing mechanism; each later PR that migrates a tail component also removes it from the allowlist. +*(Completed: the tail was migrated and the allowlist retired in PR #326.)* + ## Consequences - New components in migrated directories that try to import a business service fail lint **and** @@ -190,14 +194,12 @@ later PR that migrates a tail component also removes it from the allowlist. - A `use*.ts(x)` module placed at a feature's root directory fails the `check-boundaries.cjs` CI step. - A reviewer can point at this ADR instead of re-arguing the layering on every PR. -- The allowlist is technical debt with an expiration date: each entry is a component that still - needs its operations lifted into a `src/features/*/hooks/*` hook. + +*(The allowlist-as-technical-debt mechanism described in the original decision ran its course: +PR #326 migrated the tail and emptied the allowlist, so no entries remain.)* ## Open issues / follow-up -- Migrate the allowlist tail components into hooks, one feature per PR, removing each from the - allowlist as it lands. This ADR does not schedule that work; it only forbids *new* direct - imports. - `src/features/discovery/hooks/useDiscoveryRepoActions.ts` imports `src/features/repositories/application/discoveryRepoPatches` — a sideways import into a sibling feature's internals (forbidden by the Decision above, not yet enforced by any tool). From 58051f8aab2381635e0d9bbf1d79646e3eb17ee8 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Sat, 12 Sep 2026 16:44:46 +0800 Subject: [PATCH 6/6] =?UTF-8?q?docs(adr):=20Phased=20enforcement=20?= =?UTF-8?q?=E4=B8=80=E8=8A=82=E6=A0=87=E8=AE=B0=E4=B8=BA=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E8=83=8C=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 CodeRabbit 复审意见:该节原先仍以现在时描述未迁移组件尾巴与 allowlist 机制,与后文"PR #326 已完成"的补注自相矛盾。改为节标题即标注 historical, 正文改用过去时叙述分阶段机制的设计与收尾(PR #326 迁完末批 3 组件、并入 两个服务、清空 allowlist),删除 Consequences 中重复的补注。 --- docs/adr/0001-frontend-layering.md | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/docs/adr/0001-frontend-layering.md b/docs/adr/0001-frontend-layering.md index 3076c4b70..c9e5f8057 100644 --- a/docs/adr/0001-frontend-layering.md +++ b/docs/adr/0001-frontend-layering.md @@ -166,24 +166,26 @@ unbanned because `BilingualMarkdownRenderer`, `UpdateChecker`, and `UpdateNotifi still imported them directly. That phase is complete: PR #326 migrated the last three components and folded both services into the ban list, and the allowlist no longer exists. -### Phased enforcement +### Phased enforcement (historical — completed in PR #326) + +The rollout was phased so that enforcement never masked a pile of pre-existing violations. This +section records those mechanics; the phase is over and the allowlist no longer exists. PR 4–8 migrated the high-traffic components (`RepositoryCard`, `RepositoryList`, the settings panels, the timeline views). A snapshot at the `7337df0` baseline showed 33 components importing -services; after the migration that number is down, but a tail of components (e.g. +services; a tail of components (e.g. `SearchBar`, `ReadmeModal`, `LoginScreen`, `GistCard`, `ReleaseCard`, `SubscriptionRepoCard`, `CategorySidebar`, `RepositoryEditModal`, `DebugModeIndicator`, `SettingsPanel`, -`ReleaseSourceSettingsModal`, `GistEditorModal`, `GistDetailModal`) still import business +`ReleaseSourceSettingsModal`, `GistEditorModal`, `GistDetailModal`) still imported business services directly. -PR 9 enforces the rule only on **already-migrated** component directories and the -`src/components/ui/**` primitives (which should never touch a business service), and leaves the -un-migrated tail on an explicit allowlist for a later PR. One-shot banning all remaining imports -would light up a dozen files at once and force a rushed migration in a boundary-PR — exactly the -"don't mask 33 violations in one go" failure mode. The allowlist is the phasing mechanism; each -later PR that migrates a tail component also removes it from the allowlist. - -*(Completed: the tail was migrated and the allowlist retired in PR #326.)* +PR 9 therefore enforced the rule only on **already-migrated** component directories and the +`src/components/ui/**` primitives (which should never touch a business service), leaving the +un-migrated tail on an explicit allowlist: one-shot banning all remaining imports would have +forced a rushed migration in a boundary-PR — exactly the +"don't mask 33 violations in one go" failure mode. Each later PR migrated a tail component and +removed it from the allowlist, until PR #326 migrated the last three, folded `updateService` and +`translateService` into the ban list, and emptied the allowlist. ## Consequences @@ -195,9 +197,6 @@ later PR that migrates a tail component also removes it from the allowlist. CI step. - A reviewer can point at this ADR instead of re-arguing the layering on every PR. -*(The allowlist-as-technical-debt mechanism described in the original decision ran its course: -PR #326 migrated the tail and emptied the allowlist, so no entries remain.)* - ## Open issues / follow-up - `src/features/discovery/hooks/useDiscoveryRepoActions.ts` imports