mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
refactor(ui): extract pure session browser utilities (#22256)
This commit is contained in:
@@ -13,9 +13,8 @@ import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
|||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { Config } from '@google/gemini-cli-core';
|
import type { Config } from '@google/gemini-cli-core';
|
||||||
import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js';
|
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||||
import {
|
import {
|
||||||
cleanMessage,
|
|
||||||
formatRelativeTime,
|
formatRelativeTime,
|
||||||
getSessionFiles,
|
getSessionFiles,
|
||||||
} from '../../utils/sessionUtils.js';
|
} from '../../utils/sessionUtils.js';
|
||||||
@@ -150,124 +149,7 @@ const SessionBrowserEmpty = (): React.JSX.Element => (
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
import { sortSessions, filterSessions } from './SessionBrowser/utils.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
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search input display component.
|
* Search input display component.
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user