From 3b2a0de140ae3473447f7cec2367f563180a6010 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Feb 2026 21:23:16 +0200 Subject: [PATCH] feat: enhance TeamConfigReader with improved file handling and concurrency - Introduced `mapLimit` function to manage concurrent processing of team directories, optimizing performance. - Added `readFileHead` function to read the beginning of large configuration files efficiently. - Implemented `extractQuotedString` to safely extract values from JSON strings in configuration headers. - Enhanced error handling and validation for team configuration files, ensuring robust processing of team data. - Updated logic to handle large configuration files differently, improving overall reliability and performance. --- src/main/services/team/TeamConfigReader.ts | 265 +++++++++++++-------- src/renderer/App.tsx | 10 +- src/renderer/main.tsx | 15 ++ 3 files changed, 188 insertions(+), 102 deletions(-) diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 91292151..53977602 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -9,6 +9,55 @@ import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@sh const logger = createLogger('Service:TeamConfigReader'); +const TEAM_LIST_CONCURRENCY = process.platform === 'win32' ? 4 : 12; +const LARGE_CONFIG_BYTES = 512 * 1024; +const CONFIG_HEAD_BYTES = 64 * 1024; + +async function mapLimit( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results = new Array(items.length); + let index = 0; + const workerCount = Math.max(1, Math.min(limit, items.length)); + const workers = new Array(workerCount).fill(0).map(async () => { + while (true) { + const i = index++; + if (i >= items.length) return; + results[i] = await fn(items[i]); + } + }); + await Promise.all(workers); + return results; +} + +async function readFileHead(filePath: string, maxBytes: number): Promise { + const handle = await fs.promises.open(filePath, 'r'); + try { + const stat = await handle.stat(); + const bytesToRead = Math.max(0, Math.min(stat.size, maxBytes)); + if (bytesToRead === 0) return ''; + const buffer = Buffer.alloc(bytesToRead); + await handle.read(buffer, 0, bytesToRead, 0); + return buffer.toString('utf8'); + } finally { + await handle.close(); + } +} + +function extractQuotedString(head: string, key: string): string | null { + const re = new RegExp(`"${key}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`); + const match = re.exec(head); + if (!match?.[1]) return null; + try { + const value = JSON.parse(match[1]) as unknown; + return typeof value === 'string' ? value : null; + } catch { + return null; + } +} + export class TeamConfigReader { constructor( private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore() @@ -24,111 +73,141 @@ export class TeamConfigReader { return []; } - const summaries: TeamSummary[] = []; + const teamDirs = entries.filter((e) => e.isDirectory()); - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } + const perTeam: (TeamSummary | null)[] = await mapLimit( + teamDirs, + TEAM_LIST_CONCURRENCY, + async (entry): Promise => { + const teamName = entry.name; + const configPath = path.join(teamsDir, teamName, 'config.json'); - const configPath = path.join(teamsDir, entry.name, 'config.json'); - try { - const raw = await fs.promises.readFile(configPath, 'utf8'); - const config = JSON.parse(raw) as TeamConfig; - if (typeof config.name !== 'string' || config.name.trim() === '') { - logger.debug(`Skipping team dir with invalid config name: ${entry.name}`); - continue; - } - - const memberMap = new Map(); - - const addMember = (m: TeamMember): void => { - const name = m.name?.trim(); - if (!name) return; - const existing = memberMap.get(name); - memberMap.set(name, { - name, - role: m.role?.trim() || existing?.role, - color: m.color?.trim() || existing?.color, - }); - }; - - if (Array.isArray(config.members)) { - for (const member of config.members) { - if (member && typeof member.name === 'string') { - addMember(member); - } - } - } - - const removedNames = new Set(); try { - const metaMembers = await this.membersMetaStore.getMembers(entry.name); - for (const member of metaMembers) { - if (member.removedAt) { - removedNames.add(member.name.trim()); - } else { - addMember(member); + let config: TeamConfig | null = null; + let displayName: string | null = null; + let description = ''; + let color: string | undefined; + let projectPath: string | undefined; + let leadSessionId: string | undefined; + let deletedAt: string | undefined; + let projectPathHistory: TeamConfig['projectPathHistory'] | undefined; + let sessionHistory: TeamConfig['sessionHistory'] | undefined; + + let stat: fs.Stats | null = null; + try { + stat = await fs.promises.stat(configPath); + } catch { + stat = null; + } + + if (stat && stat.isFile() && stat.size > LARGE_CONFIG_BYTES) { + const head = await readFileHead(configPath, CONFIG_HEAD_BYTES); + displayName = extractQuotedString(head, 'name'); + const desc = extractQuotedString(head, 'description'); + description = typeof desc === 'string' ? desc : ''; + const c = extractQuotedString(head, 'color'); + color = typeof c === 'string' && c.trim().length > 0 ? c : undefined; + const pp = extractQuotedString(head, 'projectPath'); + projectPath = typeof pp === 'string' && pp.trim().length > 0 ? pp : undefined; + const lead = extractQuotedString(head, 'leadSessionId'); + leadSessionId = typeof lead === 'string' && lead.trim().length > 0 ? lead : undefined; + const del = extractQuotedString(head, 'deletedAt'); + deletedAt = typeof del === 'string' ? del : undefined; + } else { + const raw = await fs.promises.readFile(configPath, 'utf8'); + config = JSON.parse(raw) as TeamConfig; + displayName = typeof config.name === 'string' ? config.name : null; + description = typeof config.description === 'string' ? config.description : ''; + color = + typeof config.color === 'string' && config.color.trim().length > 0 + ? config.color + : undefined; + projectPath = + typeof config.projectPath === 'string' && config.projectPath.trim().length > 0 + ? config.projectPath + : undefined; + leadSessionId = + typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 + ? config.leadSessionId + : undefined; + projectPathHistory = Array.isArray(config.projectPathHistory) + ? config.projectPathHistory + : undefined; + sessionHistory = Array.isArray(config.sessionHistory) + ? config.sessionHistory + : undefined; + deletedAt = typeof config.deletedAt === 'string' ? config.deletedAt : undefined; + } + + if (typeof displayName !== 'string' || displayName.trim() === '') { + logger.debug(`Skipping team dir with invalid config name: ${teamName}`); + return null; + } + + const memberMap = new Map(); + + const addMember = (m: TeamMember): void => { + const name = m.name?.trim(); + if (!name) return; + const existing = memberMap.get(name); + memberMap.set(name, { + name, + role: m.role?.trim() || existing?.role, + color: m.color?.trim() || existing?.color, + }); + }; + + if (config && Array.isArray(config.members)) { + for (const member of config.members) { + if (member && typeof member.name === 'string') { + addMember(member); + } } } - } catch { - logger.debug(`Failed to read members.meta.json for team: ${entry.name}`); - } - const inboxDir = path.join(teamsDir, entry.name, 'inboxes'); - try { - const inboxEntries = await fs.promises.readdir(inboxDir); - for (const inbox of inboxEntries) { - if (!inbox.endsWith('.json') || inbox.startsWith('.')) { - continue; - } - const inboxName = inbox.slice(0, -'.json'.length).trim(); - if (inboxName.length > 0 && !memberMap.has(inboxName)) { - memberMap.set(inboxName, { name: inboxName }); + const removedNames = new Set(); + try { + const metaMembers = await this.membersMetaStore.getMembers(teamName); + for (const member of metaMembers) { + if (member.removedAt) { + removedNames.add(member.name.trim()); + } else { + addMember(member); + } } + } catch { + logger.debug(`Failed to read members.meta.json for team: ${teamName}`); } + + for (const name of removedNames) { + memberMap.delete(name); + } + + const members = Array.from(memberMap.values()); + const summary: TeamSummary = { + teamName, + displayName, + description, + memberCount: memberMap.size, + taskCount: 0, + lastActivity: null, + ...(members.length > 0 ? { members } : {}), + ...(color ? { color } : {}), + ...(projectPath ? { projectPath } : {}), + ...(leadSessionId ? { leadSessionId } : {}), + ...(projectPathHistory ? { projectPathHistory } : {}), + ...(sessionHistory ? { sessionHistory } : {}), + ...(deletedAt ? { deletedAt } : {}), + }; + return summary; } catch { - // Inbox folder may not exist yet. + logger.debug(`Skipping team dir without valid config: ${teamName}`); + return null; } - - for (const name of removedNames) { - memberMap.delete(name); - } - - const memberCount = memberMap.size; - const members = Array.from(memberMap.values()); - summaries.push({ - teamName: entry.name, - displayName: config.name, - description: typeof config.description === 'string' ? config.description : '', - color: - typeof config.color === 'string' && config.color.trim().length > 0 - ? config.color - : undefined, - memberCount, - members: members.length > 0 ? members : undefined, - taskCount: 0, - lastActivity: null, - projectPath: - typeof config.projectPath === 'string' && config.projectPath.trim().length > 0 - ? config.projectPath - : undefined, - leadSessionId: - typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 - ? config.leadSessionId - : undefined, - projectPathHistory: Array.isArray(config.projectPathHistory) - ? config.projectPathHistory - : undefined, - sessionHistory: Array.isArray(config.sessionHistory) ? config.sessionHistory : undefined, - deletedAt: typeof config.deletedAt === 'string' ? config.deletedAt : undefined, - }); - } catch { - logger.debug(`Skipping team dir without valid config: ${entry.name}`); } - } + ); - return summaries; + return perTeam.filter((t): t is TeamSummary => t !== null); } async getConfig(teamName: string): Promise { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index ece32b9b..a66f4a40 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -8,7 +8,7 @@ import { ErrorBoundary } from './components/common/ErrorBoundary'; import { TabbedLayout } from './components/layout/TabbedLayout'; import { useTheme } from './hooks/useTheme'; import { api } from './api'; -import { initializeNotificationListeners, useStore } from './store'; +import { useStore } from './store'; export const App = (): React.JSX.Element => { // Initialize theme on app load @@ -23,14 +23,6 @@ export const App = (): React.JSX.Element => { } }, []); - // Initialize IPC listeners and start sequential data fetch chain. - // No delay needed: UV_THREADPOOL_SIZE=16 prevents thread pool saturation, - // and the init chain fetches data sequentially to avoid concurrent I/O spikes. - useEffect(() => { - const cleanup = initializeNotificationListeners(); - return cleanup; - }, []); - // Initialize context system lazily when SSH connection state changes. // Local-only users never pay the cost of IndexedDB init + context IPC calls. useEffect(() => { diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index b0a79fac..63e6504d 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -4,6 +4,21 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { App } from './App'; +import { initializeNotificationListeners } from './store'; + +declare global { + interface Window { + __claudeTeamsUiDidInit?: boolean; + } +} + +// React 18 StrictMode intentionally mounts/unmounts effects twice in dev, +// which can start duplicate IPC init chains. Make initialization a one-time +// module-level side effect guarded by a global flag. +if (!window.__claudeTeamsUiDidInit) { + window.__claudeTeamsUiDidInit = true; + initializeNotificationListeners(); +} ReactDOM.createRoot(document.getElementById('root')!).render(