From 0f93ef2781168c8b9d253f5d77b11449b266972b Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:53 +0300 Subject: [PATCH] refactor: improve session limit and offset validation in provider routes --- server/modules/providers/provider.routes.ts | 33 ++++++---- .../session-conversations-search.service.ts | 62 ++++++++++++------- .../sidebar/hooks/useSidebarController.ts | 34 +++++++--- src/hooks/useProjectsState.ts | 24 +++++-- 4 files changed, 105 insertions(+), 48 deletions(-) diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index dfc9247e..af6d16d6 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -338,21 +338,28 @@ router.get( const limitRaw = readOptionalQueryString(req.query.limit); const offsetRaw = readOptionalQueryString(req.query.offset); - const limit = limitRaw === undefined ? null : Number.parseInt(limitRaw, 10); - const offset = offsetRaw === undefined ? 0 : Number.parseInt(offsetRaw, 10); - - if (limitRaw !== undefined && Number.isNaN(limit)) { - throw new AppError('limit must be a valid integer.', { - code: 'INVALID_QUERY_PARAMETER', - statusCode: 400, - }); + let limit: number | null = null; + if (limitRaw !== undefined) { + const parsedLimit = Number.parseInt(limitRaw, 10); + if (Number.isNaN(parsedLimit) || parsedLimit < 0) { + throw new AppError('limit must be a non-negative integer.', { + code: 'INVALID_QUERY_PARAMETER', + statusCode: 400, + }); + } + limit = parsedLimit; } - if (offsetRaw !== undefined && Number.isNaN(offset)) { - throw new AppError('offset must be a valid integer.', { - code: 'INVALID_QUERY_PARAMETER', - statusCode: 400, - }); + let offset = 0; + if (offsetRaw !== undefined) { + const parsedOffset = Number.parseInt(offsetRaw, 10); + if (Number.isNaN(parsedOffset) || parsedOffset < 0) { + throw new AppError('offset must be a non-negative integer.', { + code: 'INVALID_QUERY_PARAMETER', + statusCode: 400, + }); + } + offset = parsedOffset; } const result = await sessionsService.fetchHistory(sessionId, { diff --git a/server/modules/providers/services/session-conversations-search.service.ts b/server/modules/providers/services/session-conversations-search.service.ts index 65883e5e..afc8bdac 100644 --- a/server/modules/providers/services/session-conversations-search.service.ts +++ b/server/modules/providers/services/session-conversations-search.service.ts @@ -196,10 +196,14 @@ function createWordMatcher( return phraseRegex.test(text); } - if (phraseRegex.test(text) || words.length === 1) { + if (phraseRegex.test(text)) { return true; } + if (words.length === 1) { + return allWordsMatch(text.toLowerCase()); + } + return allWordsMatch(text.toLowerCase()); }; @@ -534,31 +538,47 @@ async function findMatchedFileKeys( const requireExactPhrase = words.length > 1 && normalizedQuery.length > 0; if (requireExactPhrase) { - const matchedForPhrase = new Set(); - const fileChunks = chunkArray( - searchablePathEntries.map((entry) => entry.absolutePath), - RIPGREP_FILE_CHUNK_SIZE, - ); + let matchedForPhrase = searchablePathEntries.slice(); - let nextChunkIndex = 0; - const workerCount = Math.min(RIPGREP_CHUNK_CONCURRENCY, fileChunks.length); - const workers = Array.from({ length: workerCount }, async () => { - while (nextChunkIndex < fileChunks.length && !signal?.aborted) { - const currentIndex = nextChunkIndex; - nextChunkIndex += 1; - const chunkMatches = await runRipgrepFilesWithMatches(normalizedQuery, fileChunks[currentIndex], signal); - for (const matchedPath of chunkMatches) { - matchedForPhrase.add(matchedPath); - } + // Keep ripgrep as an over-approximation for exact phrase mode by requiring + // each word to appear somewhere in the file, then defer strict phrase + // validation to the in-memory matcher. + for (const word of words) { + if (signal?.aborted) { + return new Set(); } - }); - await Promise.all(workers); - if (signal?.aborted) { - return new Set(); + const matchedForWord = new Set(); + const fileChunks = chunkArray( + matchedForPhrase.map((entry) => entry.absolutePath), + RIPGREP_FILE_CHUNK_SIZE, + ); + + let nextChunkIndex = 0; + const workerCount = Math.min(RIPGREP_CHUNK_CONCURRENCY, fileChunks.length); + const workers = Array.from({ length: workerCount }, async () => { + while (nextChunkIndex < fileChunks.length && !signal?.aborted) { + const currentIndex = nextChunkIndex; + nextChunkIndex += 1; + const chunkMatches = await runRipgrepFilesWithMatches(word, fileChunks[currentIndex], signal); + for (const matchedPath of chunkMatches) { + matchedForWord.add(matchedPath); + } + } + }); + + await Promise.all(workers); + if (signal?.aborted) { + return new Set(); + } + + matchedForPhrase = matchedForPhrase.filter((entry) => matchedForWord.has(entry.normalizedPath)); + if (matchedForPhrase.length === 0) { + break; + } } - return matchedForPhrase; + return new Set(matchedForPhrase.map((entry) => entry.normalizedPath)); } let remainingEntries = searchablePathEntries.slice(); diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts index b99ffe27..514cf91b 100644 --- a/src/components/sidebar/hooks/useSidebarController.ts +++ b/src/components/sidebar/hooks/useSidebarController.ts @@ -120,6 +120,8 @@ export function useSidebarController({ const searchSeqRef = useRef(0); const eventSourceRef = useRef(null); const starToggleSequenceByProjectRef = useRef>(new Map()); + const migrationStartedRef = useRef(false); + const onRefreshRef = useRef(onRefresh); const isSidebarCollapsed = !isMobile && !sidebarVisible; @@ -194,19 +196,25 @@ export function useSidebarController({ }, []); useEffect(() => { + onRefreshRef.current = onRefresh; + }, [onRefresh]); + + useEffect(() => { + if (migrationStartedRef.current) { + return; + } + const legacyStarredProjectIds = readLegacyStarredProjectIds(); if (legacyStarredProjectIds.length === 0) { return; } - let active = true; + migrationStartedRef.current = true; const migrateLegacyStars = async () => { try { await api.migrateLegacyProjectStars(legacyStarredProjectIds); - if (active) { - await onRefresh(); - } + await onRefreshRef.current(); } catch (error) { console.error('[Sidebar] Failed to migrate legacy starred projects:', error); } finally { @@ -215,10 +223,6 @@ export function useSidebarController({ }; void migrateLegacyStars(); - - return () => { - active = false; - }; }, [onRefresh]); useEffect(() => { @@ -446,16 +450,26 @@ export function useSidebarController({ const getProjectSessions = useCallback((project: Project) => getAllSessions(project), []); const loadMoreSessionsForProject = useCallback(async (projectId: string) => { - if (!onLoadMoreSessions || loadingMoreProjects.has(projectId)) { + if (!onLoadMoreSessions) { return; } + let shouldLoad = false; setLoadingMoreProjects((previous) => { + if (previous.has(projectId)) { + return previous; + } + + shouldLoad = true; const next = new Set(previous); next.add(projectId); return next; }); + if (!shouldLoad) { + return; + } + try { await onLoadMoreSessions(projectId); } catch (error) { @@ -468,7 +482,7 @@ export function useSidebarController({ return next; }); } - }, [loadingMoreProjects, onLoadMoreSessions, t]); + }, [onLoadMoreSessions, t]); const projectsWithResolvedStarState = useMemo(() => { if (optimisticStarByProjectId.size === 0) { diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 8c820e27..c1c9344c 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -605,12 +605,28 @@ export function useProjectsState({ setProjects((prevProjects) => prevProjects.map((project) => { + const sessions = project.sessions?.filter((session) => session.id !== sessionIdToDelete) ?? []; + const cursorSessions = project.cursorSessions?.filter((session) => session.id !== sessionIdToDelete) ?? []; + const codexSessions = project.codexSessions?.filter((session) => session.id !== sessionIdToDelete) ?? []; + const geminiSessions = project.geminiSessions?.filter((session) => session.id !== sessionIdToDelete) ?? []; + + const removedFromProject = ( + sessions.length !== (project.sessions?.length ?? 0) + || cursorSessions.length !== (project.cursorSessions?.length ?? 0) + || codexSessions.length !== (project.codexSessions?.length ?? 0) + || geminiSessions.length !== (project.geminiSessions?.length ?? 0) + ); + + if (!removedFromProject) { + return project; + } + const updatedProject: Project = { ...project, - sessions: project.sessions?.filter((session) => session.id !== sessionIdToDelete) ?? [], - cursorSessions: project.cursorSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [], - codexSessions: project.codexSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [], - geminiSessions: project.geminiSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [], + sessions, + cursorSessions, + codexSessions, + geminiSessions, }; const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1);