refactor(ui): extract pure session browser utilities (#22256)

This commit is contained in:
Abhi
2026-03-13 02:22:52 -04:00
committed by GitHub
parent 7b4a822b0e
commit 2d05396dd2
3 changed files with 264 additions and 120 deletions

View File

@@ -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 => (
</Box>
);
/**
* 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.

View File

@@ -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>): 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');
});
});
});

View File

@@ -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;
});
};