From 2d05396dd22bd8c129d19b1ba2a0928f32c5e9c8 Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Fri, 13 Mar 2026 02:22:52 -0400 Subject: [PATCH] refactor(ui): extract pure session browser utilities (#22256) --- .../cli/src/ui/components/SessionBrowser.tsx | 122 +--------------- .../components/SessionBrowser/utils.test.ts | 132 ++++++++++++++++++ .../src/ui/components/SessionBrowser/utils.ts | 130 +++++++++++++++++ 3 files changed, 264 insertions(+), 120 deletions(-) create mode 100644 packages/cli/src/ui/components/SessionBrowser/utils.test.ts create mode 100644 packages/cli/src/ui/components/SessionBrowser/utils.ts diff --git a/packages/cli/src/ui/components/SessionBrowser.tsx b/packages/cli/src/ui/components/SessionBrowser.tsx index 72eb5ef55c..9e2843c570 100644 --- a/packages/cli/src/ui/components/SessionBrowser.tsx +++ b/packages/cli/src/ui/components/SessionBrowser.tsx @@ -13,9 +13,8 @@ import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useKeypress } from '../hooks/useKeypress.js'; import path from 'node:path'; import type { Config } from '@google/gemini-cli-core'; -import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js'; +import type { SessionInfo } from '../../utils/sessionUtils.js'; import { - cleanMessage, formatRelativeTime, getSessionFiles, } from '../../utils/sessionUtils.js'; @@ -150,124 +149,7 @@ const SessionBrowserEmpty = (): React.JSX.Element => ( ); -/** - * Sorts an array of sessions by the specified criteria. - * @param sessions - Array of sessions to sort - * @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName) - * @param reverse - Whether to reverse the sort order (ascending instead of descending) - * @returns New sorted array of sessions - */ -const sortSessions = ( - sessions: SessionInfo[], - sortBy: 'date' | 'messages' | 'name', - reverse: boolean, -): SessionInfo[] => { - const sorted = [...sessions].sort((a, b) => { - switch (sortBy) { - case 'date': - return ( - new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime() - ); - case 'messages': - return b.messageCount - a.messageCount; - case 'name': - return a.displayName.localeCompare(b.displayName); - default: - return 0; - } - }); - - return reverse ? sorted.reverse() : sorted; -}; - -/** - * Finds all text matches for a search query within conversation messages. - * Creates TextMatch objects with context (10 chars before/after) and role information. - * @param messages - Array of messages to search through - * @param query - Search query string (case-insensitive) - * @returns Array of TextMatch objects containing match context and metadata - */ -const findTextMatches = ( - messages: Array<{ role: 'user' | 'assistant'; content: string }>, - query: string, -): TextMatch[] => { - if (!query.trim()) return []; - - const lowerQuery = query.toLowerCase(); - const matches: TextMatch[] = []; - - for (const message of messages) { - const m = cleanMessage(message.content); - const lowerContent = m.toLowerCase(); - let startIndex = 0; - - while (true) { - const matchIndex = lowerContent.indexOf(lowerQuery, startIndex); - if (matchIndex === -1) break; - - const contextStart = Math.max(0, matchIndex - 10); - const contextEnd = Math.min(m.length, matchIndex + query.length + 10); - - const snippet = m.slice(contextStart, contextEnd); - const relativeMatchStart = matchIndex - contextStart; - const relativeMatchEnd = relativeMatchStart + query.length; - - let before = snippet.slice(0, relativeMatchStart); - const match = snippet.slice(relativeMatchStart, relativeMatchEnd); - let after = snippet.slice(relativeMatchEnd); - - if (contextStart > 0) before = '…' + before; - if (contextEnd < m.length) after = after + '…'; - - matches.push({ before, match, after, role: message.role }); - startIndex = matchIndex + 1; - } - } - - return matches; -}; - -/** - * Filters sessions based on a search query, checking titles, IDs, and full content. - * Also populates matchSnippets and matchCount for sessions with content matches. - * @param sessions - Array of sessions to filter - * @param query - Search query string (case-insensitive) - * @returns Filtered array of sessions that match the query - */ -const filterSessions = ( - sessions: SessionInfo[], - query: string, -): SessionInfo[] => { - if (!query.trim()) { - return sessions.map((session) => ({ - ...session, - matchSnippets: undefined, - matchCount: undefined, - })); - } - - const lowerQuery = query.toLowerCase(); - return sessions.filter((session) => { - const titleMatch = - session.displayName.toLowerCase().includes(lowerQuery) || - session.id.toLowerCase().includes(lowerQuery) || - session.firstUserMessage.toLowerCase().includes(lowerQuery); - - const contentMatch = session.fullContent - ?.toLowerCase() - .includes(lowerQuery); - - if (titleMatch || contentMatch) { - if (session.messages) { - session.matchSnippets = findTextMatches(session.messages, query); - session.matchCount = session.matchSnippets.length; - } - return true; - } - - return false; - }); -}; +import { sortSessions, filterSessions } from './SessionBrowser/utils.js'; /** * Search input display component. diff --git a/packages/cli/src/ui/components/SessionBrowser/utils.test.ts b/packages/cli/src/ui/components/SessionBrowser/utils.test.ts new file mode 100644 index 0000000000..e6da97cc20 --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/utils.test.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { sortSessions, findTextMatches, filterSessions } from './utils.js'; +import type { SessionInfo } from '../../../utils/sessionUtils.js'; + +describe('SessionBrowser utils', () => { + const createTestSession = (overrides: Partial): SessionInfo => ({ + id: 'test-id', + file: 'test-file', + fileName: 'test-file.json', + startTime: '2025-01-01T10:00:00Z', + lastUpdated: '2025-01-01T10:00:00Z', + messageCount: 1, + displayName: 'Test Session', + firstUserMessage: 'Hello', + isCurrentSession: false, + index: 0, + ...overrides, + }); + + describe('sortSessions', () => { + it('sorts by date ascending/descending', () => { + const older = createTestSession({ + id: '1', + lastUpdated: '2025-01-01T10:00:00Z', + }); + const newer = createTestSession({ + id: '2', + lastUpdated: '2025-01-02T10:00:00Z', + }); + + const desc = sortSessions([older, newer], 'date', false); + expect(desc[0].id).toBe('2'); + + const asc = sortSessions([older, newer], 'date', true); + expect(asc[0].id).toBe('1'); + }); + + it('sorts by message count ascending/descending', () => { + const more = createTestSession({ id: '1', messageCount: 10 }); + const less = createTestSession({ id: '2', messageCount: 2 }); + + const desc = sortSessions([more, less], 'messages', false); + expect(desc[0].id).toBe('1'); + + const asc = sortSessions([more, less], 'messages', true); + expect(asc[0].id).toBe('2'); + }); + + it('sorts by name ascending/descending', () => { + const apple = createTestSession({ id: '1', displayName: 'Apple' }); + const banana = createTestSession({ id: '2', displayName: 'Banana' }); + + const asc = sortSessions([apple, banana], 'name', true); + expect(asc[0].id).toBe('2'); // Reversed alpha + + const desc = sortSessions([apple, banana], 'name', false); + expect(desc[0].id).toBe('1'); + }); + }); + + describe('findTextMatches', () => { + it('returns empty array if query is practically empty', () => { + expect( + findTextMatches([{ role: 'user', content: 'hello world' }], ' '), + ).toEqual([]); + }); + + it('finds simple matches with surrounding context', () => { + const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [ + { role: 'user', content: 'What is the capital of France?' }, + ]; + + const matches = findTextMatches(messages, 'capital'); + expect(matches.length).toBe(1); + expect(matches[0].match).toBe('capital'); + expect(matches[0].before.endsWith('the ')).toBe(true); + expect(matches[0].after.startsWith(' of')).toBe(true); + expect(matches[0].role).toBe('user'); + }); + + it('finds multiple matches in a single message', () => { + const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [ + { role: 'user', content: 'test here test there' }, + ]; + + const matches = findTextMatches(messages, 'test'); + expect(matches.length).toBe(2); + }); + }); + + describe('filterSessions', () => { + it('returns all sessions when query is blank and clears existing snippets', () => { + const sessions = [createTestSession({ id: '1', matchCount: 5 })]; + + const result = filterSessions(sessions, ' '); + expect(result.length).toBe(1); + expect(result[0].matchCount).toBeUndefined(); + }); + + it('filters by displayName', () => { + const session1 = createTestSession({ + id: '1', + displayName: 'Cats and Dogs', + }); + const session2 = createTestSession({ id: '2', displayName: 'Fish' }); + + const result = filterSessions([session1, session2], 'cat'); + expect(result.length).toBe(1); + expect(result[0].id).toBe('1'); + }); + + it('populates match snippets if it matches content inside messages array', () => { + const sessionWithMessages = createTestSession({ + id: '1', + displayName: 'Unrelated Title', + fullContent: 'This mentions a giraffe', + messages: [{ role: 'user', content: 'This mentions a giraffe' }], + }); + + const result = filterSessions([sessionWithMessages], 'giraffe'); + expect(result.length).toBe(1); + expect(result[0].matchCount).toBe(1); + expect(result[0].matchSnippets?.[0].match).toBe('giraffe'); + }); + }); +}); diff --git a/packages/cli/src/ui/components/SessionBrowser/utils.ts b/packages/cli/src/ui/components/SessionBrowser/utils.ts new file mode 100644 index 0000000000..40902656ad --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser/utils.ts @@ -0,0 +1,130 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + cleanMessage, + type SessionInfo, + type TextMatch, +} from '../../../utils/sessionUtils.js'; + +/** + * Sorts an array of sessions by the specified criteria. + * @param sessions - Array of sessions to sort + * @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName) + * @param reverse - Whether to reverse the sort order (ascending instead of descending) + * @returns New sorted array of sessions + */ +export const sortSessions = ( + sessions: SessionInfo[], + sortBy: 'date' | 'messages' | 'name', + reverse: boolean, +): SessionInfo[] => { + const sorted = [...sessions].sort((a, b) => { + switch (sortBy) { + case 'date': + return ( + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime() + ); + case 'messages': + return b.messageCount - a.messageCount; + case 'name': + return a.displayName.localeCompare(b.displayName); + default: + return 0; + } + }); + + return reverse ? sorted.reverse() : sorted; +}; + +/** + * Finds all text matches for a search query within conversation messages. + * Creates TextMatch objects with context (10 chars before/after) and role information. + * @param messages - Array of messages to search through + * @param query - Search query string (case-insensitive) + * @returns Array of TextMatch objects containing match context and metadata + */ +export const findTextMatches = ( + messages: Array<{ role: 'user' | 'assistant'; content: string }>, + query: string, +): TextMatch[] => { + if (!query.trim()) return []; + + const lowerQuery = query.toLowerCase(); + const matches: TextMatch[] = []; + + for (const message of messages) { + const m = cleanMessage(message.content); + const lowerContent = m.toLowerCase(); + let startIndex = 0; + + while (true) { + const matchIndex = lowerContent.indexOf(lowerQuery, startIndex); + if (matchIndex === -1) break; + + const contextStart = Math.max(0, matchIndex - 10); + const contextEnd = Math.min(m.length, matchIndex + query.length + 10); + + const snippet = m.slice(contextStart, contextEnd); + const relativeMatchStart = matchIndex - contextStart; + const relativeMatchEnd = relativeMatchStart + query.length; + + let before = snippet.slice(0, relativeMatchStart); + const match = snippet.slice(relativeMatchStart, relativeMatchEnd); + let after = snippet.slice(relativeMatchEnd); + + if (contextStart > 0) before = '…' + before; + if (contextEnd < m.length) after = after + '…'; + + matches.push({ before, match, after, role: message.role }); + startIndex = matchIndex + 1; + } + } + + return matches; +}; + +/** + * Filters sessions based on a search query, checking titles, IDs, and full content. + * Also populates matchSnippets and matchCount for sessions with content matches. + * @param sessions - Array of sessions to filter + * @param query - Search query string (case-insensitive) + * @returns Filtered array of sessions that match the query + */ +export const filterSessions = ( + sessions: SessionInfo[], + query: string, +): SessionInfo[] => { + if (!query.trim()) { + return sessions.map((session) => ({ + ...session, + matchSnippets: undefined, + matchCount: undefined, + })); + } + + const lowerQuery = query.toLowerCase(); + return sessions.filter((session) => { + const titleMatch = + session.displayName.toLowerCase().includes(lowerQuery) || + session.id.toLowerCase().includes(lowerQuery) || + session.firstUserMessage.toLowerCase().includes(lowerQuery); + + const contentMatch = session.fullContent + ?.toLowerCase() + .includes(lowerQuery); + + if (titleMatch || contentMatch) { + if (session.messages) { + session.matchSnippets = findTextMatches(session.messages, query); + session.matchCount = session.matchSnippets.length; + } + return true; + } + + return false; + }); +};