diff --git a/packages/core/src/ide/constants.ts b/packages/core/src/ide/constants.ts index f1f066c43c..573b9aec03 100644 --- a/packages/core/src/ide/constants.ts +++ b/packages/core/src/ide/constants.ts @@ -5,3 +5,5 @@ */ export const GEMINI_CLI_COMPANION_EXTENSION_NAME = 'Gemini CLI Companion'; +export const IDE_MAX_OPEN_FILES = 10; +export const IDE_MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index c47753e420..f125b68374 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -12,6 +12,7 @@ import { beforeEach, afterEach, type Mocked, + type Mock, } from 'vitest'; import { IdeClient, IDEConnectionStatus } from './ide-client.js'; import * as fs from 'node:fs'; @@ -29,11 +30,13 @@ import * as os from 'node:os'; import * as path from 'node:path'; vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...(actual as object), promises: { + ...actual.promises, readFile: vi.fn(), + readdir: vi.fn(), }, realpathSync: (p: string) => p, existsSync: () => false, @@ -103,12 +106,17 @@ describe('IdeClient', () => { it('should connect using HTTP when port is provided in config file', async () => { const config = { port: '8080' }; vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp', 'gemini-ide-server-12345.json'), + path.join('/tmp/', 'gemini-ide-server-12345.json'), 'utf8', ); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( @@ -124,6 +132,11 @@ describe('IdeClient', () => { it('should connect using stdio when stdio config is provided in file', async () => { const config = { stdio: { command: 'test-cmd', args: ['--foo'] } }; vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -144,6 +157,11 @@ describe('IdeClient', () => { stdio: { command: 'test-cmd', args: ['--foo'] }, }; vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -159,6 +177,11 @@ describe('IdeClient', () => { vi.mocked(fs.promises.readFile).mockRejectedValue( new Error('File not found'), ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([]); process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090'; const ideClient = await IdeClient.getInstance(); @@ -178,6 +201,11 @@ describe('IdeClient', () => { vi.mocked(fs.promises.readFile).mockRejectedValue( new Error('File not found'), ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([]); process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'] = 'env-cmd'; process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'] = '["--bar"]'; @@ -197,6 +225,11 @@ describe('IdeClient', () => { it('should prioritize file config over environment variables', async () => { const config = { port: '8080' }; vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([]); process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090'; const ideClient = await IdeClient.getInstance(); @@ -215,6 +248,11 @@ describe('IdeClient', () => { vi.mocked(fs.promises.readFile).mockRejectedValue( new Error('File not found'), ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -229,4 +267,271 @@ describe('IdeClient', () => { ); }); }); + + describe('getConnectionConfigFromFile', () => { + it('should return config from the specific pid file if it exists', async () => { + const config = { port: '1234', workspacePath: '/test/workspace' }; + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + + const ideClient = await IdeClient.getInstance(); + // In tests, the private method can be accessed like this. + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(result).toEqual(config); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join('/tmp', 'gemini-ide-server-12345.json'), + 'utf8', + ); + }); + + it('should return undefined if no config files are found', async () => { + vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found')); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([]); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(result).toBeUndefined(); + }); + + it('should find and parse a single config file with the new naming scheme', async () => { + const config = { port: '5678', workspacePath: '/test/workspace' }; + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); // For old path + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue(['gemini-ide-server-12345-123.json']); + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ + isValid: true, + }); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(result).toEqual(config); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join('/tmp/.gemini/ide', 'gemini-ide-server-12345-123.json'), + 'utf8', + ); + }); + + it('should filter out configs with invalid workspace paths', async () => { + const validConfig = { + port: '5678', + workspacePath: '/test/workspace', + }; + const invalidConfig = { + port: '1111', + workspacePath: '/invalid/workspace', + }; + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([ + 'gemini-ide-server-12345-111.json', + 'gemini-ide-server-12345-222.json', + ]); + vi.mocked(fs.promises.readFile) + .mockResolvedValueOnce(JSON.stringify(invalidConfig)) + .mockResolvedValueOnce(JSON.stringify(validConfig)); + + const validateSpy = vi + .spyOn(IdeClient, 'validateWorkspacePath') + .mockReturnValueOnce({ isValid: false }) + .mockReturnValueOnce({ isValid: true }); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(result).toEqual(validConfig); + expect(validateSpy).toHaveBeenCalledWith( + '/invalid/workspace', + 'VS Code', + '/test/workspace/sub-dir', + ); + expect(validateSpy).toHaveBeenCalledWith( + '/test/workspace', + 'VS Code', + '/test/workspace/sub-dir', + ); + }); + + it('should return the first valid config when multiple workspaces are valid', async () => { + const config1 = { port: '1111', workspacePath: '/test/workspace' }; + const config2 = { port: '2222', workspacePath: '/test/workspace2' }; + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([ + 'gemini-ide-server-12345-111.json', + 'gemini-ide-server-12345-222.json', + ]); + vi.mocked(fs.promises.readFile) + .mockResolvedValueOnce(JSON.stringify(config1)) + .mockResolvedValueOnce(JSON.stringify(config2)); + vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ + isValid: true, + }); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(result).toEqual(config1); + }); + + it('should prioritize the config matching the port from the environment variable', async () => { + process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '2222'; + const config1 = { port: '1111', workspacePath: '/test/workspace' }; + const config2 = { port: '2222', workspacePath: '/test/workspace2' }; + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([ + 'gemini-ide-server-12345-111.json', + 'gemini-ide-server-12345-222.json', + ]); + vi.mocked(fs.promises.readFile) + .mockResolvedValueOnce(JSON.stringify(config1)) + .mockResolvedValueOnce(JSON.stringify(config2)); + vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ + isValid: true, + }); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(result).toEqual(config2); + }); + + it('should handle invalid JSON in one of the config files', async () => { + const validConfig = { port: '2222', workspacePath: '/test/workspace' }; + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([ + 'gemini-ide-server-12345-111.json', + 'gemini-ide-server-12345-222.json', + ]); + vi.mocked(fs.promises.readFile) + .mockResolvedValueOnce('invalid json') + .mockResolvedValueOnce(JSON.stringify(validConfig)); + vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ + isValid: true, + }); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(result).toEqual(validConfig); + }); + + it('should return undefined if readdir throws an error', async () => { + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + vi.mocked(fs.promises.readdir).mockRejectedValue( + new Error('readdir failed'), + ); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(result).toBeUndefined(); + }); + + it('should ignore files with invalid names', async () => { + const validConfig = { port: '3333', workspacePath: '/test/workspace' }; + vi.mocked(fs.promises.readFile).mockRejectedValueOnce( + new Error('not found'), + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue([ + 'gemini-ide-server-12345-111.json', // valid + 'not-a-config-file.txt', // invalid + 'gemini-ide-server-asdf.json', // invalid + ]); + vi.mocked(fs.promises.readFile).mockResolvedValueOnce( + JSON.stringify(validConfig), + ); + vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ + isValid: true, + }); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(result).toEqual(validConfig); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join('/tmp/.gemini/ide', 'gemini-ide-server-12345-111.json'), + 'utf8', + ); + expect(fs.promises.readFile).not.toHaveBeenCalledWith( + path.join('/tmp/.gemini/ide', 'not-a-config-file.txt'), + 'utf8', + ); + }); + }); }); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 3d973b2095..7da863c564 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -396,8 +396,10 @@ export class IdeClient { (ConnectionConfig & { workspacePath?: string }) | undefined > { if (!this.ideProcessInfo) { - return {}; + return undefined; } + + // For backwards compatability try { const portFile = path.join( os.tmpdir(), @@ -406,8 +408,82 @@ export class IdeClient { const portFileContents = await fs.promises.readFile(portFile, 'utf8'); return JSON.parse(portFileContents); } catch (_) { + // For newer extension versions, the file name matches the pattern + // /^gemini-ide-server-${pid}-\d+\.json$/. If multiple IDE + // windows are open, multiple files matching the pattern are expected to + // exist. + } + + const portFileDir = path.join(os.tmpdir(), '.gemini', 'ide'); + let portFiles; + try { + portFiles = await fs.promises.readdir(portFileDir); + } catch (e) { + logger.debug('Failed to read IDE connection directory:', e); return undefined; } + + const fileRegex = new RegExp( + `^gemini-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`, + ); + const matchingFiles = portFiles + .filter((file) => fileRegex.test(file)) + .sort(); + if (matchingFiles.length === 0) { + return undefined; + } + + let fileContents: string[]; + try { + fileContents = await Promise.all( + matchingFiles.map((file) => + fs.promises.readFile(path.join(portFileDir, file), 'utf8'), + ), + ); + } catch (e) { + logger.debug('Failed to read IDE connection config file(s):', e); + return undefined; + } + const parsedContents = fileContents.map((content) => { + try { + return JSON.parse(content); + } catch (e) { + logger.debug('Failed to parse JSON from config file: ', e); + return undefined; + } + }); + + const validWorkspaces = parsedContents.filter((content) => { + if (!content) { + return false; + } + const { isValid } = IdeClient.validateWorkspacePath( + content.workspacePath, + this.currentIdeDisplayName, + process.cwd(), + ); + return isValid; + }); + + if (validWorkspaces.length === 0) { + return undefined; + } + + if (validWorkspaces.length === 1) { + return validWorkspaces[0]; + } + + const portFromEnv = this.getPortFromEnv(); + if (portFromEnv) { + const matchingPort = validWorkspaces.find( + (content) => content.port === portFromEnv, + ); + if (matchingPort) { + return matchingPort; + } + } + + return validWorkspaces[0]; } private createProxyAwareFetch() { diff --git a/packages/core/src/ide/ideContext.test.ts b/packages/core/src/ide/ideContext.test.ts index 72ef10d0f2..7970d06aec 100644 --- a/packages/core/src/ide/ideContext.test.ts +++ b/packages/core/src/ide/ideContext.test.ts @@ -4,21 +4,34 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + IDE_MAX_OPEN_FILES, + IDE_MAX_SELECTED_TEXT_LENGTH, +} from './constants.js'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { createIdeContextStore } from './ideContext.js'; -import { FileSchema, IdeContextSchema } from './types.js'; +import { + type IdeContext, + FileSchema, + IdeContextSchema, + type File, +} from './types.js'; describe('ideContext', () => { describe('createIdeContextStore', () => { - let ideContext: ReturnType; + let ideContextStore: ReturnType; beforeEach(() => { // Create a fresh, isolated instance for each test - ideContext = createIdeContextStore(); + ideContextStore = createIdeContextStore(); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it('should return undefined initially for ide context', () => { - expect(ideContext.getIdeContext()).toBeUndefined(); + expect(ideContextStore.getIdeContext()).toBeUndefined(); }); it('should set and retrieve the ide context', () => { @@ -35,9 +48,9 @@ describe('ideContext', () => { }, }; - ideContext.setIdeContext(testFile); + ideContextStore.setIdeContext(testFile); - const activeFile = ideContext.getIdeContext(); + const activeFile = ideContextStore.getIdeContext(); expect(activeFile).toEqual(testFile); }); @@ -54,7 +67,7 @@ describe('ideContext', () => { ], }, }; - ideContext.setIdeContext(firstFile); + ideContextStore.setIdeContext(firstFile); const secondFile = { workspaceState: { @@ -68,9 +81,9 @@ describe('ideContext', () => { ], }, }; - ideContext.setIdeContext(secondFile); + ideContextStore.setIdeContext(secondFile); - const activeFile = ideContext.getIdeContext(); + const activeFile = ideContextStore.getIdeContext(); expect(activeFile).toEqual(secondFile); }); @@ -87,16 +100,16 @@ describe('ideContext', () => { ], }, }; - ideContext.setIdeContext(testFile); - expect(ideContext.getIdeContext()).toEqual(testFile); + ideContextStore.setIdeContext(testFile); + expect(ideContextStore.getIdeContext()).toEqual(testFile); }); it('should notify subscribers when ide context changes', () => { const subscriber1 = vi.fn(); const subscriber2 = vi.fn(); - ideContext.subscribeToIdeContext(subscriber1); - ideContext.subscribeToIdeContext(subscriber2); + ideContextStore.subscribeToIdeContext(subscriber1); + ideContextStore.subscribeToIdeContext(subscriber2); const testFile = { workspaceState: { @@ -110,7 +123,7 @@ describe('ideContext', () => { ], }, }; - ideContext.setIdeContext(testFile); + ideContextStore.setIdeContext(testFile); expect(subscriber1).toHaveBeenCalledTimes(1); expect(subscriber1).toHaveBeenCalledWith(testFile); @@ -130,7 +143,7 @@ describe('ideContext', () => { ], }, }; - ideContext.setIdeContext(newFile); + ideContextStore.setIdeContext(newFile); expect(subscriber1).toHaveBeenCalledTimes(2); expect(subscriber1).toHaveBeenCalledWith(newFile); @@ -142,10 +155,10 @@ describe('ideContext', () => { const subscriber1 = vi.fn(); const subscriber2 = vi.fn(); - const unsubscribe1 = ideContext.subscribeToIdeContext(subscriber1); - ideContext.subscribeToIdeContext(subscriber2); + const unsubscribe1 = ideContextStore.subscribeToIdeContext(subscriber1); + ideContextStore.subscribeToIdeContext(subscriber2); - ideContext.setIdeContext({ + ideContextStore.setIdeContext({ workspaceState: { openFiles: [ { @@ -162,7 +175,7 @@ describe('ideContext', () => { unsubscribe1(); - ideContext.setIdeContext({ + ideContextStore.setIdeContext({ workspaceState: { openFiles: [ { @@ -192,13 +205,159 @@ describe('ideContext', () => { }, }; - ideContext.setIdeContext(testFile); + ideContextStore.setIdeContext(testFile); - expect(ideContext.getIdeContext()).toEqual(testFile); + expect(ideContextStore.getIdeContext()).toEqual(testFile); - ideContext.clearIdeContext(); + ideContextStore.clearIdeContext(); - expect(ideContext.getIdeContext()).toBeUndefined(); + expect(ideContextStore.getIdeContext()).toBeUndefined(); + }); + + it('should set the context and notify subscribers when no workspaceState is present', () => { + const subscriber = vi.fn(); + ideContextStore.subscribeToIdeContext(subscriber); + const context: IdeContext = {}; + ideContextStore.setIdeContext(context); + expect(ideContextStore.getIdeContext()).toBe(context); + expect(subscriber).toHaveBeenCalledWith(context); + }); + + it('should handle an empty openFiles array', () => { + const context: IdeContext = { + workspaceState: { + openFiles: [], + }, + }; + ideContextStore.setIdeContext(context); + expect( + ideContextStore.getIdeContext()?.workspaceState?.openFiles, + ).toEqual([]); + }); + + it('should sort openFiles by timestamp in descending order', () => { + const context: IdeContext = { + workspaceState: { + openFiles: [ + { path: 'file1.ts', timestamp: 100, isActive: false }, + { path: 'file2.ts', timestamp: 300, isActive: true }, + { path: 'file3.ts', timestamp: 200, isActive: false }, + ], + }, + }; + ideContextStore.setIdeContext(context); + const openFiles = + ideContextStore.getIdeContext()?.workspaceState?.openFiles; + expect(openFiles?.[0]?.path).toBe('file2.ts'); + expect(openFiles?.[1]?.path).toBe('file3.ts'); + expect(openFiles?.[2]?.path).toBe('file1.ts'); + }); + + it('should mark only the most recent file as active and clear other active files', () => { + const context: IdeContext = { + workspaceState: { + openFiles: [ + { + path: 'file1.ts', + timestamp: 100, + isActive: true, + selectedText: 'hello', + }, + { + path: 'file2.ts', + timestamp: 300, + isActive: true, + cursor: { line: 1, character: 1 }, + selectedText: 'hello', + }, + { + path: 'file3.ts', + timestamp: 200, + isActive: false, + selectedText: 'hello', + }, + ], + }, + }; + ideContextStore.setIdeContext(context); + const openFiles = + ideContextStore.getIdeContext()?.workspaceState?.openFiles; + expect(openFiles?.[0]?.isActive).toBe(true); + expect(openFiles?.[0]?.cursor).toBeDefined(); + expect(openFiles?.[0]?.selectedText).toBeDefined(); + + expect(openFiles?.[1]?.isActive).toBe(false); + expect(openFiles?.[1]?.cursor).toBeUndefined(); + expect(openFiles?.[1]?.selectedText).toBeUndefined(); + + expect(openFiles?.[2]?.isActive).toBe(false); + expect(openFiles?.[2]?.cursor).toBeUndefined(); + expect(openFiles?.[2]?.selectedText).toBeUndefined(); + }); + + it('should truncate selectedText if it exceeds the max length', () => { + const longText = 'a'.repeat(IDE_MAX_SELECTED_TEXT_LENGTH + 10); + const context: IdeContext = { + workspaceState: { + openFiles: [ + { + path: 'file1.ts', + timestamp: 100, + isActive: true, + selectedText: longText, + }, + ], + }, + }; + ideContextStore.setIdeContext(context); + const selectedText = + ideContextStore.getIdeContext()?.workspaceState?.openFiles?.[0] + ?.selectedText; + expect(selectedText).toHaveLength( + IDE_MAX_SELECTED_TEXT_LENGTH + '... [TRUNCATED]'.length, + ); + expect(selectedText?.endsWith('... [TRUNCATED]')).toBe(true); + }); + + it('should not truncate selectedText if it is within the max length', () => { + const shortText = 'a'.repeat(IDE_MAX_SELECTED_TEXT_LENGTH); + const context: IdeContext = { + workspaceState: { + openFiles: [ + { + path: 'file1.ts', + timestamp: 100, + isActive: true, + selectedText: shortText, + }, + ], + }, + }; + ideContextStore.setIdeContext(context); + const selectedText = + ideContextStore.getIdeContext()?.workspaceState?.openFiles?.[0] + ?.selectedText; + expect(selectedText).toBe(shortText); + }); + + it('should truncate the openFiles list if it exceeds the max length', () => { + const files: File[] = Array.from( + { length: IDE_MAX_OPEN_FILES + 5 }, + (_, i) => ({ + path: `file${i}.ts`, + timestamp: i, + isActive: false, + }), + ); + const context: IdeContext = { + workspaceState: { + openFiles: files, + }, + }; + ideContextStore.setIdeContext(context); + const openFiles = + ideContextStore.getIdeContext()?.workspaceState?.openFiles; + expect(openFiles).toHaveLength(IDE_MAX_OPEN_FILES); }); }); diff --git a/packages/core/src/ide/ideContext.ts b/packages/core/src/ide/ideContext.ts index eee949990e..57d021f66a 100644 --- a/packages/core/src/ide/ideContext.ts +++ b/packages/core/src/ide/ideContext.ts @@ -5,6 +5,10 @@ */ import { z } from 'zod'; +import { + IDE_MAX_OPEN_FILES, + IDE_MAX_SELECTED_TEXT_LENGTH, +} from './constants.js'; import type { IdeContext } from './types.js'; export const IdeDiffAcceptedNotificationSchema = z.object({ @@ -92,6 +96,57 @@ export function createIdeContextStore() { * @param newIdeContext The new IDE context from the IDE. */ function setIdeContext(newIdeContext: IdeContext): void { + const { workspaceState } = newIdeContext; + if (!workspaceState) { + ideContextState = newIdeContext; + notifySubscribers(); + return; + } + + const { openFiles } = workspaceState; + + if (openFiles && openFiles.length > 0) { + // Sort by timestamp descending (newest first) + openFiles.sort((a, b) => b.timestamp - a.timestamp); + + // The most recent file is now at index 0. + const mostRecentFile = openFiles[0]; + + // If the most recent file is not active, then no file is active. + if (!mostRecentFile.isActive) { + openFiles.forEach((file) => { + file.isActive = false; + file.cursor = undefined; + file.selectedText = undefined; + }); + } else { + // The most recent file is active. Ensure it's the only one. + openFiles.forEach((file, index: number) => { + if (index !== 0) { + file.isActive = false; + file.cursor = undefined; + file.selectedText = undefined; + } + }); + + // Truncate selected text in the active file + if ( + mostRecentFile.selectedText && + mostRecentFile.selectedText.length > IDE_MAX_SELECTED_TEXT_LENGTH + ) { + mostRecentFile.selectedText = + mostRecentFile.selectedText.substring( + 0, + IDE_MAX_SELECTED_TEXT_LENGTH, + ) + '... [TRUNCATED]'; + } + } + + // Truncate files list + if (openFiles.length > IDE_MAX_OPEN_FILES) { + workspaceState.openFiles = openFiles.slice(0, IDE_MAX_OPEN_FILES); + } + } ideContextState = newIdeContext; notifySubscribers(); }