diff --git a/src/extension/chatSessions/claude/node/sessionParser/test/claudeCodeSessionService.spec.ts b/src/extension/chatSessions/claude/node/sessionParser/test/claudeCodeSessionService.spec.ts index bf9da7e4a0..53a3794c34 100644 --- a/src/extension/chatSessions/claude/node/sessionParser/test/claudeCodeSessionService.spec.ts +++ b/src/extension/chatSessions/claude/node/sessionParser/test/claudeCodeSessionService.spec.ts @@ -78,7 +78,6 @@ class MockFolderRepositoryManager implements IFolderRepositoryManager { async initializeFolderRepository(): Promise<{ folder: undefined; repository: undefined; worktree: undefined; worktreeProperties: undefined; trusted: undefined }> { return { folder: undefined, repository: undefined, worktree: undefined, worktreeProperties: undefined, trusted: undefined }; } async getRepositoryInfo(): Promise<{ repository: undefined; headBranchName: undefined }> { return { repository: undefined, headBranchName: undefined }; } async getFolderMRU(): Promise { return this._mruEntries; } - async deleteMRUEntry(): Promise { } } // #endregion @@ -476,7 +475,7 @@ describe('ClaudeCodeSessionService', () => { noWorkspaceTestingServiceCollection.define(IFolderRepositoryManager, noWorkspaceFolderManager); noWorkspaceFolderManager.setMRUEntries([ - { folder: mruFolder, repository: undefined, lastAccessed: Date.now(), isUntitledSessionSelection: false }, + { folder: mruFolder, repository: undefined, lastAccessed: Date.now() }, ]); const accessor = noWorkspaceTestingServiceCollection.createTestingAccessor(); @@ -515,8 +514,8 @@ describe('ClaudeCodeSessionService', () => { const mruFolder2 = URI.file('/another/project'); noWorkspaceFolderManager.setMRUEntries([ - { folder: mruFolder, repository: undefined, lastAccessed: Date.now(), isUntitledSessionSelection: false }, - { folder: mruFolder2, repository: undefined, lastAccessed: Date.now() - 1000, isUntitledSessionSelection: false }, + { folder: mruFolder, repository: undefined, lastAccessed: Date.now() }, + { folder: mruFolder2, repository: undefined, lastAccessed: Date.now() - 1000 }, ]); const multiMruServiceCollection = store.add(createExtensionUnitTestingServices(store)); diff --git a/src/extension/chatSessions/claude/node/test/claudeProjectFolders.spec.ts b/src/extension/chatSessions/claude/node/test/claudeProjectFolders.spec.ts index 7f84882d6f..68d627e009 100644 --- a/src/extension/chatSessions/claude/node/test/claudeProjectFolders.spec.ts +++ b/src/extension/chatSessions/claude/node/test/claudeProjectFolders.spec.ts @@ -25,7 +25,6 @@ class MockFolderRepositoryManager implements IFolderRepositoryManager { async initializeFolderRepository(): Promise<{ folder: undefined; repository: undefined; worktree: undefined; worktreeProperties: undefined; trusted: undefined }> { return { folder: undefined, repository: undefined, worktree: undefined, worktreeProperties: undefined, trusted: undefined }; } async getRepositoryInfo(): Promise { return undefined; } async getFolderMRU(): Promise { return this._mruEntries; } - async deleteMRUEntry(): Promise { } } // #endregion @@ -90,7 +89,7 @@ describe('getProjectFolders', () => { const mruFolder = URI.file('/Users/test/recent-project'); const workspace = new TestWorkspaceService([]); const folderManager = new MockFolderRepositoryManager(); - folderManager.setMRUEntries([{ folder: mruFolder, repository: undefined, lastAccessed: Date.now(), isUntitledSessionSelection: false }]); + folderManager.setMRUEntries([{ folder: mruFolder, repository: undefined, lastAccessed: Date.now() }]); const result = await getProjectFolders(workspace, folderManager); @@ -113,7 +112,7 @@ describe('getProjectFolders', () => { const mruFolder = URI.file('/Users/test/mru-folder'); const workspace = new TestWorkspaceService([workspaceFolder]); const folderManager = new MockFolderRepositoryManager(); - folderManager.setMRUEntries([{ folder: mruFolder, repository: undefined, lastAccessed: Date.now(), isUntitledSessionSelection: false }]); + folderManager.setMRUEntries([{ folder: mruFolder, repository: undefined, lastAccessed: Date.now() }]); const result = await getProjectFolders(workspace, folderManager); diff --git a/src/extension/chatSessions/common/chatSessionMetadataStore.ts b/src/extension/chatSessions/common/chatSessionMetadataStore.ts index 06556046cb..49dec3992e 100644 --- a/src/extension/chatSessions/common/chatSessionMetadataStore.ts +++ b/src/extension/chatSessions/common/chatSessionMetadataStore.ts @@ -80,7 +80,6 @@ export interface IChatSessionMetadataStore { getWorktreeProperties(folder: Uri): Promise; getSessionWorkspaceFolder(sessionId: string): Promise; getSessionWorkspaceFolderEntry(sessionId: string): Promise; - getUsedWorkspaceFolders(): Promise; getAdditionalWorkspaces(sessionId: string): Promise; setAdditionalWorkspaces(sessionId: string, workspaces: IWorkspaceInfo[]): Promise; getSessionFirstUserMessage(sessionId: string): Promise; diff --git a/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts b/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts index e8f986ffb3..734307015f 100644 --- a/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts +++ b/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts @@ -17,11 +17,6 @@ export const IChatSessionWorkspaceFolderService = createServiceIdentifier; - /** - * Delete a recent folder from all tracked sessions. - */ - deleteRecentFolder(folder: vscode.Uri): Promise; deleteTrackedWorkspaceFolder(sessionId: string): Promise; /** * Track workspace folder selection for a session (for folders without git repos in multi-root workspaces) diff --git a/src/extension/chatSessions/common/folderRepositoryManager.ts b/src/extension/chatSessions/common/folderRepositoryManager.ts index 754a876c9a..fee87397b6 100644 --- a/src/extension/chatSessions/common/folderRepositoryManager.ts +++ b/src/extension/chatSessions/common/folderRepositoryManager.ts @@ -78,11 +78,6 @@ export interface FolderRepositoryMRUEntry { * Timestamp of last access (milliseconds since epoch). */ readonly lastAccessed: number; - - /** - * Whether this entry was used in an untitled session. - */ - readonly isUntitledSessionSelection: boolean; } export const IFolderRepositoryManager = createServiceIdentifier('IFolderRepositoryManager'); @@ -152,6 +147,7 @@ export interface IFolderRepositoryManager { ): Promise<{ repository: vscode.Uri | undefined; headBranchName: string | undefined }>; /** + * @deprecated * Get list of most recently used folders and repositories. * * This is used for empty workspaces to show a list of previously used @@ -161,9 +157,4 @@ export interface IFolderRepositoryManager { * limited to 10 items, with non-existent paths filtered out */ getFolderMRU(): Promise; - - /** - * Delete an entry from the MRU list. - */ - deleteMRUEntry(folder: vscode.Uri): Promise; } diff --git a/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts b/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts index 0b0b9625b9..dcf1c9053d 100644 --- a/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts +++ b/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts @@ -61,10 +61,6 @@ export class MockChatSessionMetadataStore implements IChatSessionMetadataStore { return undefined; } - async getUsedWorkspaceFolders(): Promise { - return Array.from(this._workspaceFolders.values()); - } - async getAdditionalWorkspaces(sessionId: string): Promise { return this._additionalWorkspaces.get(sessionId) ?? []; } diff --git a/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 74f97d474f..d77225a057 100644 --- a/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -62,7 +62,6 @@ export interface ICopilotCLISessionItem { readonly status?: ChatSessionStatus; readonly workingDirectory?: Uri; } - export type ExtendedChatRequest = ChatRequest & { prompt: string }; export type ISessionOptions = { model?: string; diff --git a/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts index 63786c9146..23a61a07bb 100644 --- a/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts +++ b/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -62,8 +62,6 @@ class NullAgentSessionsWorkspace implements IAgentSessionsWorkspace { } class NullChatSessionWorkspaceFolderService extends mock() { - override getRecentFolders = vi.fn(async () => []); - override deleteRecentFolder = vi.fn(async () => { }); override deleteTrackedWorkspaceFolder = vi.fn(async () => { }); override trackSessionWorkspaceFolder = vi.fn(async () => { }); override getSessionWorkspaceFolder = vi.fn(async () => undefined); diff --git a/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts b/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts index 48b36d547c..ddb7d44273 100644 --- a/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts +++ b/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts @@ -12,7 +12,6 @@ import { findLast } from '../../../util/vs/base/common/arraysFind'; import { SequencerByKey, ThrottledDelayer } from '../../../util/vs/base/common/async'; import { Lazy } from '../../../util/vs/base/common/lazy'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; -import { ResourceMap } from '../../../util/vs/base/common/map'; import { dirname, isEqual } from '../../../util/vs/base/common/resources'; import { ChatSessionMetadataFile, IChatSessionMetadataStore, RequestDetails, WorkspaceFolderEntry } from '../common/chatSessionMetadataStore'; import { ChatSessionWorktreeData, ChatSessionWorktreeProperties } from '../common/chatSessionWorktreeService'; @@ -245,18 +244,6 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession return metadata.workspaceFolder; } - async getUsedWorkspaceFolders(): Promise { - await this._intialize.value; - const entries = new ResourceMap(); - for (const metadata of Object.values(this._cache)) { - if (metadata.workspaceFolder?.folderPath) { - const folderUri = Uri.file(metadata.workspaceFolder.folderPath); - entries.set(folderUri, Math.max(entries.get(folderUri) ?? 0, metadata.workspaceFolder.timestamp)); - } - } - return Array.from(entries.entries()).map(([folderUri, timestamp]) => ({ folderPath: folderUri.fsPath, timestamp })); - } - async getAdditionalWorkspaces(sessionId: string): Promise { const metadata = await this.getSessionMetadata(sessionId); if (!metadata?.additionalWorkspaces?.length) { diff --git a/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts b/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts index 65db960138..08e2f1df09 100644 --- a/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts +++ b/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts @@ -10,12 +10,10 @@ import { IGitService } from '../../../platform/git/common/gitService'; import { parseGitChangesRaw } from '../../../platform/git/vscode-node/utils'; import { DiffChange } from '../../../platform/git/vscode/git'; import { ILogService } from '../../../platform/log/common/logService'; -import { coalesce } from '../../../util/vs/base/common/arrays'; import { SequencerByKey } from '../../../util/vs/base/common/async'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; -import { ResourceMap, ResourceSet } from '../../../util/vs/base/common/map'; +import { ResourceMap } from '../../../util/vs/base/common/map'; import * as path from '../../../util/vs/base/common/path'; -import { isEqual } from '../../../util/vs/base/common/resources'; import { generateUuid } from '../../../util/vs/base/common/uuid'; import { IChatSessionMetadataStore, WorkspaceFolderEntry } from '../common/chatSessionMetadataStore'; import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService'; @@ -32,8 +30,6 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh private readonly workspaceFolderChanges = new ResourceMap(); private readonly workspaceState = new Map(); - private recentFolders: { folder: vscode.Uri; lastAccessTime: number }[] = []; - private readonly deletedFolders = new ResourceSet(); private readonly workspaceChangesSequencer = new SequencerByKey(); constructor( @@ -45,28 +41,6 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh super(); } - public async deleteRecentFolder(folder: vscode.Uri): Promise { - this.recentFolders = this.recentFolders.filter(entry => !isEqual(entry.folder, folder)); - this.deletedFolders.add(folder); - } - - public async getRecentFolders(): Promise<{ folder: vscode.Uri; lastAccessTime: number }[]> { - const items = await this.metadataStore.getUsedWorkspaceFolders(); - this.recentFolders = coalesce(items.map(item => { - if (!item.folderPath) { - return; - } - const folder = vscode.Uri.file(item.folderPath); - if (this.deletedFolders.has(folder)) { - return; - } - return { - folder, - lastAccessTime: item.timestamp - }; - })).sort((a, b) => b.lastAccessTime - a.lastAccessTime); - return this.recentFolders; - } async deleteTrackedWorkspaceFolder(sessionId: string): Promise { this.workspaceState.delete(sessionId); await this.metadataStore.deleteSessionMetadata(sessionId); diff --git a/src/extension/chatSessions/vscode-node/chatSessions.ts b/src/extension/chatSessions/vscode-node/chatSessions.ts index 2c32c08802..e9c51964bb 100644 --- a/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -62,6 +62,7 @@ import { ChatSessionWorktreeService } from './chatSessionWorktreeServiceImpl'; import { ClaudeChatSessionContentProvider } from './claudeChatSessionContentProvider'; import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionParticipant, registerCLIChatCommands } from './copilotCLIChatSessions'; import { CopilotCLIChatSessionContentProvider as CopilotCLIChatSessionContentProviderV1, CopilotCLIChatSessionItemProvider as CopilotCLIChatSessionItemProviderV1, CopilotCLIChatSessionParticipant as CopilotCLIChatSessionParticipantV1, registerCLIChatCommands as registerCLIChatCommandsV1 } from './copilotCLIChatSessionsContribution'; +import { CopilotCLIFolderMruService, ICopilotCLIFolderMruService } from './copilotCLIFolderMru'; import { CopilotCLITerminalIntegration, ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration'; import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider'; import { ClaudeFolderRepositoryManager, CopilotCLIFolderRepositoryManager } from './folderRepositoryManagerImpl'; @@ -166,6 +167,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib [ICustomSessionTitleService, new SyncDescriptor(CustomSessionTitleService)], [ICopilotCLISkills, new SyncDescriptor(CopilotCLISkills)], [IChatSessionMetadataStore, new SyncDescriptor(ChatSessionMetadataStore)], + [ICopilotCLIFolderMruService, new SyncDescriptor(CopilotCLIFolderMruService)], ...getServices() )); @@ -189,6 +191,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const nativeEnvService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(INativeEnvService)); const fileSystemService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IFileSystemService)); const copilotModels = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLIModels)); + const copilotCLIFolderMruService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLIFolderMruService)); this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionTracker))); this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatPromptFileService))); @@ -198,7 +201,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const copilotcliParticipant = vscode.chat.createChatParticipant(this.copilotcliSessionType, copilotcliChatSessionParticipant.createHandler()); this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant)); - this._register(registerCLIChatCommands(copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, nativeEnvService, fileSystemService, sessionTracker, terminalIntegration, logService)); + this._register(registerCLIChatCommands(copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotCLIFolderMruService, nativeEnvService, fileSystemService, sessionTracker, terminalIntegration, logService)); // #endregion const sessionMetadata = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionMetadataStore)); @@ -228,6 +231,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib [ICustomSessionTitleService, new SyncDescriptor(CustomSessionTitleService)], [ICopilotCLISkills, new SyncDescriptor(CopilotCLISkills)], [IChatSessionMetadataStore, new SyncDescriptor(ChatSessionMetadataStore)], + [ICopilotCLIFolderMruService, new SyncDescriptor(CopilotCLIFolderMruService)], ...getServices() )); @@ -254,6 +258,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const nativeEnvService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(INativeEnvService)); const fileSystemService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IFileSystemService)); const copilotModels = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLIModels)); + const copilotFolderMruService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLIFolderMruService)); this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionTracker))); this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatPromptFileService))); @@ -263,7 +268,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const copilotcliParticipant = vscode.chat.createChatParticipant(this.copilotcliSessionType, copilotcliChatSessionParticipant.createHandler()); this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant)); - this._register(registerCLIChatCommandsV1(copilotcliSessionItemProvider, copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, gitExtensionService, toolsService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, nativeEnvService, fileSystemService, logService)); + this._register(registerCLIChatCommandsV1(copilotcliSessionItemProvider, copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, gitExtensionService, toolsService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotFolderMruService, nativeEnvService, fileSystemService, logService)); // #endregion const sessionMetadata = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionMetadataStore)); diff --git a/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index dca3c1c586..98232ce49e 100644 --- a/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -48,6 +48,7 @@ import { builtinSlashSCommands, CopilotCLICommand, copilotCLICommands, ICopilotC import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService'; import { buildMcpServerMappings } from '../copilotcli/node/mcpHandler'; import { ICopilotCLISessionTracker } from '../copilotcli/vscode-node/copilotCLISessionTracker'; +import { ICopilotCLIFolderMruService } from './copilotCLIFolderMru'; import { convertReferenceToVariable } from './copilotCLIPromptReferences'; import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration'; import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider'; @@ -62,6 +63,7 @@ const COPILOT_WORKTREE_PATTERN = 'copilot-worktree-'; * 4. Remove this._currentSessionId * 5. Remove isWorktreeIsolationSelected and update to account for dropdown. * 6. Is chatSessionContext?.initialSessionOptions still valid with new API + * 7. Validated selected MRU item * * Cases to cover: * 1. Hook up the dropdowns for empty workspace folders as well @@ -236,6 +238,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements @IOctoKitService private readonly octoKitService: IOctoKitService, @ILogService private readonly logService: ILogService, @IAgentSessionsWorkspace private readonly _agentSessionsWorkspace: IAgentSessionsWorkspace, + @ICopilotCLIFolderMruService private readonly copilotCLIFolderMruService: ICopilotCLIFolderMruService, ) { super(); @@ -610,7 +613,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements let items: vscode.ChatSessionProviderOptionItem[] = []; // For untitled workspaces, show last used repositories and "Open Repository..." command - const repositories = await this.folderRepositoryManager.getFolderMRU(); + const repositories = await this.copilotCLIFolderMruService.getRecentlyUsedFolders(CancellationToken.None); items = folderMRUToChatProviderOptions(repositories); items.splice(MAX_MRU_ENTRIES); // Limit to max entries if (this._lastUsedFolderIdInUntitledWorkspace) { @@ -622,7 +625,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements const lastUsedEntry = folderMRUToChatProviderOptions([{ folder, repository: isRepo ? folder : undefined, - isUntitledSessionSelection: true, lastAccessed }])[0]; items.unshift(lastUsedEntry); @@ -686,7 +688,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements const copilotcliSessionId = SessionIdForCLI.parse(resource); const optionGroups: vscode.ChatSessionProviderOptionGroup[] = []; const folderInfo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token); - const repositories = isWelcomeView(this.workspaceService) ? folderMRUToChatProviderOptions(await this.folderRepositoryManager.getFolderMRU()) : this.getRepositoryOptionItems(); + const repositories = isWelcomeView(this.workspaceService) ? folderMRUToChatProviderOptions(await this.copilotCLIFolderMruService.getRecentlyUsedFolders(token)) : this.getRepositoryOptionItems(); const folderOrRepoId = folderInfo.repository?.fsPath ?? folderInfo.folder?.fsPath; const existingItem = folderOrRepoId ? repositories.find(repo => repo.id === folderOrRepoId) : undefined; const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(copilotcliSessionId); @@ -1595,6 +1597,7 @@ export function registerCLIChatCommands( copilotCliWorkspaceSession: IChatSessionWorkspaceFolderService, contentProvider: CopilotCLIChatSessionContentProvider, folderRepositoryManager: IFolderRepositoryManager, + copilotCLIFolderMruService: ICopilotCLIFolderMruService, envService: INativeEnvService, fileSystemService: IFileSystemService, sessionTracker: ICopilotCLISessionTracker, @@ -1806,14 +1809,13 @@ export function registerCLIChatCommands( } let selectedFolderUri: Uri | undefined = undefined; - const mruItems = await folderRepositoryManager.getFolderMRU(); + const mruItems = await copilotCLIFolderMruService.getRecentlyUsedFolders(CancellationToken.None); if (mruItems.length === 0) { selectedFolderUri = await selectFolder(); } else { type RecentFolderQuickPickItem = vscode.QuickPickItem & ({ folderUri: vscode.Uri; openFolder: false } | { folderUri: undefined; openFolder: true }); const items: RecentFolderQuickPickItem[] = mruItems - .filter(item => !item.isUntitledSessionSelection) .map(item => { const optionItem = item.repository ? toRepositoryOptionItem(item.folder) @@ -1891,6 +1893,7 @@ export function registerCLIChatCommands( return; } if (!(await checkPathExists(selectedFolderUri, fileSystemService))) { + await copilotCLIFolderMruService.deleteRecentlyUsedFolder(selectedFolderUri); const message = l10n.t('The path \'{0}\' does not exist on this computer.', selectedFolderUri.fsPath); vscode.window.showErrorMessage(l10n.t('Path does not exist'), { modal: true, detail: message }); return; diff --git a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 1450aa1ef9..2f93d87223 100644 --- a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -51,6 +51,7 @@ import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../copilotcli import { buildMcpServerMappings } from '../copilotcli/node/mcpHandler'; import { ICopilotCLISessionTracker } from '../copilotcli/vscode-node/copilotCLISessionTracker'; import { ICopilotCLIChatSessionItemProvider } from './copilotCLIChatSessions'; +import { ICopilotCLIFolderMruService } from './copilotCLIFolderMru'; import { convertReferenceToVariable } from './copilotCLIPromptReferences'; import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration'; import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider'; @@ -471,6 +472,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements @ICustomSessionTitleService private readonly customSessionTitleService: ICustomSessionTitleService, @IVSCodeExtensionContext private readonly context: IVSCodeExtensionContext, @ILogService private readonly logService: ILogService, + @ICopilotCLIFolderMruService private readonly folderMruService: ICopilotCLIFolderMruService, ) { super(); const originalRepos = this.getRepositoryOptionItems().length; @@ -501,7 +503,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements } private async getDefaultUntitledSessionRepositoryOption(copilotcliSessionId: string | undefined, token: vscode.CancellationToken) { - const repositories = this.isUntitledWorkspace() ? folderMRUToChatProviderOptions(await this.folderRepositoryManager.getFolderMRU()) : this.getRepositoryOptionItems(); + const repositories = this.isUntitledWorkspace() ? folderMRUToChatProviderOptions(await this.folderMruService.getRecentlyUsedFolders(token)) : this.getRepositoryOptionItems(); // Use FolderRepositoryManager to get folder/repository info (no trust check needed for UI population) const folderInfo = copilotcliSessionId ? await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token) : undefined; const uri = folderInfo?.repository ?? folderInfo?.folder; @@ -750,9 +752,15 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements // Handle repository options based on workspace type if (this.isUntitledWorkspace()) { // For untitled workspaces, show last used repositories and "Open Repository..." command - const repositories = await this.folderRepositoryManager.getFolderMRU(); + const repositories = await this.folderMruService.getRecentlyUsedFolders(CancellationToken.None); const items = folderMRUToChatProviderOptions(repositories); items.splice(MAX_MRU_ENTRIES); // Limit to max entries + + if (this._lastUsedFolderIdInUntitledWorkspace && !items.some(repo => repo.id === this._lastUsedFolderIdInUntitledWorkspace)) { + const uri = Uri.file(this._lastUsedFolderIdInUntitledWorkspace); + items.unshift(toWorkspaceFolderOptionItem(uri, basename(uri))); + } + const commands: vscode.Command[] = []; commands.push({ command: OPEN_REPOSITORY_COMMAND_ID, @@ -992,7 +1000,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements triggerProviderOptionsChange = true; } } else { - await this.folderRepositoryManager.deleteMRUEntry(folder); + await this.folderMruService.deleteRecentlyUsedFolder(folder); const message = l10n.t('The path \'{0}\' does not exist on this computer.', folder.fsPath); vscode.window.showErrorMessage(l10n.t('Path does not exist'), { modal: true, detail: message }); const defaultRepo = await this.getDefaultUntitledSessionRepositoryOption(sessionId, token); @@ -1912,6 +1920,7 @@ export function registerCLIChatCommands( copilotCliWorkspaceSession: IChatSessionWorkspaceFolderService, contentProvider: CopilotCLIChatSessionContentProvider, folderRepositoryManager: IFolderRepositoryManager, + cliFolderMruService: ICopilotCLIFolderMruService, envService: INativeEnvService, fileSystemService: IFileSystemService, logService: ILogService @@ -2100,14 +2109,13 @@ export function registerCLIChatCommands( } let selectedFolderUri: Uri | undefined = undefined; - const mruItems = await folderRepositoryManager.getFolderMRU(); + const mruItems = await cliFolderMruService.getRecentlyUsedFolders(CancellationToken.None); if (mruItems.length === 0) { selectedFolderUri = await selectFolder(); } else { type RecentFolderQuickPickItem = vscode.QuickPickItem & ({ folderUri: vscode.Uri; openFolder: false } | { folderUri: undefined; openFolder: true }); const items: RecentFolderQuickPickItem[] = mruItems - .filter(item => !item.isUntitledSessionSelection) .map(item => { const optionItem = item.repository ? toRepositoryOptionItem(item.folder) diff --git a/src/extension/chatSessions/vscode-node/copilotCLIFolderMru.ts b/src/extension/chatSessions/vscode-node/copilotCLIFolderMru.ts new file mode 100644 index 0000000000..334a51c463 --- /dev/null +++ b/src/extension/chatSessions/vscode-node/copilotCLIFolderMru.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken, Uri } from 'vscode'; +import { IGitService } from '../../../platform/git/common/gitService'; +import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; +import { createServiceIdentifier } from '../../../util/common/services'; +import { raceTimeout } from '../../../util/vs/base/common/async'; +import { ResourceMap, ResourceSet } from '../../../util/vs/base/common/map'; +import { ChatSessionStatus } from '../../../vscodeTypes'; +import { FolderRepositoryMRUEntry } from '../common/folderRepositoryManager'; +import { ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService'; + + +type Mutable = { + -readonly [K in keyof T]: T[K]; +}; + +export interface ICopilotCLIFolderMruService { + readonly _serviceBrand: undefined; + getRecentlyUsedFolders(token: CancellationToken): Promise; + deleteRecentlyUsedFolder(folder: Uri): Promise; +} +export const ICopilotCLIFolderMruService = createServiceIdentifier('ICopilotCLIFolderMruService'); + +export class CopilotCLIFolderMruService implements ICopilotCLIFolderMruService { + declare _serviceBrand: undefined; + private readonly removedFolders = new ResourceSet(); + private cachedEntries: FolderRepositoryMRUEntry[] | undefined = undefined; + constructor( + @ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService, + @IGitService private readonly gitService: IGitService, + @IWorkspaceService private readonly workspaceService: IWorkspaceService, + ) { } + + async getRecentlyUsedFolders(token: CancellationToken): Promise { + const cachedEntries = this.cachedEntries; + const entries = this.getRecentlyUsedFoldersImpl(token).then(entries => { + this.cachedEntries = entries; + return entries; + }); + + return (cachedEntries ? cachedEntries : await entries).filter(e =>{ + if (this.removedFolders.has(e.folder)) { + return false; + } + return true; + }); + } + + async getRecentlyUsedFoldersImpl(token: CancellationToken): Promise { + const mruEntries = new ResourceMap>(); + + // We're getting MRU, don't delay session retrieve by more than 5s + const sessions = await raceTimeout(this.sessionService.getAllSessions(token), 5_000); + + for (const session of (sessions ?? [])) { + if (!session.workingDirectory) { + continue; + } + if (session.workingDirectory.path.includes('.worktrees/copilot-')) { + continue; + } + const isActive = session.status === ChatSessionStatus.InProgress; + const lastAccessed = session.timing?.lastRequestEnded ?? session.timing?.endTime ?? session.timing?.startTime ?? session.timing?.startTime ?? (isActive ? Date.now() : 0); + mruEntries.set(session.workingDirectory, { + folder: session.workingDirectory, + repository: undefined, + lastAccessed, + }); + } + + // Add recent git repositories + for (const repo of this.gitService.getRecentRepositories()) { + if (repo.rootUri.path.includes('.worktrees/copilot-')) { + continue; + } + const existingEntry = mruEntries.get(repo.rootUri); + if (existingEntry) { + existingEntry.lastAccessed = Math.max(existingEntry.lastAccessed, repo.lastAccessTime); + existingEntry.repository = repo.rootUri; + continue; + } + mruEntries.set(repo.rootUri, { + folder: repo.rootUri, + repository: repo.rootUri, + lastAccessed: repo.lastAccessTime, + }); + } + + // If in mult-root folder add the folders as well, but on top. + for (const folder of this.workspaceService.getWorkspaceFolders()) { + const existingEntry = mruEntries.get(folder); + if (existingEntry) { + continue; + } + mruEntries.set(folder, { + folder: folder, + repository: undefined, + lastAccessed: Date.now(), + }); + } + + return Array.from(mruEntries.values()) + .sort((a, b) => b.lastAccessed - a.lastAccessed); + } + + async deleteRecentlyUsedFolder(folder: Uri): Promise { + this.removedFolders.add(folder); + } +} diff --git a/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts b/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts index 7a72e93e0f..381171ea48 100644 --- a/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts +++ b/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts @@ -315,7 +315,6 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol folder: uri, repository: undefined, lastAccessed: lastAccessTime, - isUntitledSessionSelection: true }); } @@ -329,22 +328,6 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol folder: repo.rootUri, repository: repo.rootUri, lastAccessed: repo.lastAccessTime, - isUntitledSessionSelection: false - }); - } - - // Add recent workspace folders - const folders = await this.workspaceFolderService.getRecentFolders(); - for (const folder of folders) { - if (seenUris.has(folder.folder)) { - continue; - } - seenUris.add(folder.folder); - latestReposAndFolders.push({ - folder: folder.folder, - repository: undefined, - lastAccessed: folder.lastAccessTime, - isUntitledSessionSelection: false }); } @@ -354,18 +337,6 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol return latestReposAndFolders; } - async deleteMRUEntry(folder: vscode.Uri): Promise { - // Remove from untitled session folders if present - for (const [sessionId, entry] of this._newSessionFolders.entries()) { - if (isEqual(entry.uri, folder)) { - this._newSessionFolders.delete(sessionId); - } - } - - await this.workspaceFolderService.deleteRecentFolder(folder); - } - - /** * Check for uncommitted changes and prompt user for action. * diff --git a/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts b/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts index c3c76fb7a2..2983d94a1a 100644 --- a/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts +++ b/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts @@ -755,9 +755,6 @@ describe('ChatSessionMetadataStore', () => { ); expect(bulkWrites).toHaveLength(0); - // Store should still function with empty data - const folders = await store.getUsedWorkspaceFolders(); - expect(folders).toEqual([]); store.dispose(); }); }); @@ -1028,62 +1025,6 @@ describe('ChatSessionMetadataStore', () => { }); }); - // ────────────────────────────────────────────────────────────────────────── - // getUsedWorkspaceFolders - // ────────────────────────────────────────────────────────────────────────── - describe('getUsedWorkspaceFolders', () => { - it('should return empty array when no workspace folders exist', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - - const store = await createStore(); - const folders = await store.getUsedWorkspaceFolders(); - expect(folders).toEqual([]); - store.dispose(); - }); - - it('should return deduplicated workspace folders', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-1': { workspaceFolder: { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 } }, - 'session-2': { workspaceFolder: { folderPath: Uri.file('/workspace/b').fsPath, timestamp: 200 } }, - })); - - const store = await createStore(); - const folders = await store.getUsedWorkspaceFolders(); - expect(folders).toHaveLength(2); - const paths = folders.map(f => f.folderPath); - expect(paths).toContain(Uri.file('/workspace/a').fsPath); - expect(paths).toContain(Uri.file('/workspace/b').fsPath); - store.dispose(); - }); - - it('should use latest timestamp when multiple sessions point to same folder', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-1': { workspaceFolder: { folderPath: Uri.file('/workspace/shared').fsPath, timestamp: 100 } }, - 'session-2': { workspaceFolder: { folderPath: Uri.file('/workspace/shared').fsPath, timestamp: 300 } }, - 'session-3': { workspaceFolder: { folderPath: Uri.file('/workspace/shared').fsPath, timestamp: 200 } }, - })); - - const store = await createStore(); - const folders = await store.getUsedWorkspaceFolders(); - expect(folders).toHaveLength(1); - expect(folders[0].timestamp).toBe(300); - store.dispose(); - }); - - it('should ignore sessions with only worktree properties', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-wt': { worktreeProperties: makeWorktreeV1Props() }, - 'session-folder': { workspaceFolder: { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 } }, - })); - - const store = await createStore(); - const folders = await store.getUsedWorkspaceFolders(); - expect(folders).toHaveLength(1); - expect(folders[0].folderPath).toBe(Uri.file('/workspace/a').fsPath); - store.dispose(); - }); - }); - // ────────────────────────────────────────────────────────────────────────── // deleteSessionMetadata // ────────────────────────────────────────────────────────────────────────── @@ -1858,35 +1799,6 @@ describe('ChatSessionMetadataStore', () => { ); store.dispose(); }); - - it('should handle bulk file with invalid JSON gracefully', async () => { - mockFs.mockFile(BULK_METADATA_FILE, 'not-valid-json{{{'); - - // This will fail to parse, fall through to global state migration path - const store = await createStore(); - - // Should still function — store returns empty data - const folders = await store.getUsedWorkspaceFolders(); - expect(folders).toEqual([]); - store.dispose(); - }); - - it('should dispose ThrottledDelayer on dispose', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - const store = await createStore(); - - // Should not throw - store.dispose(); - }); - - it('should work correctly when globalState returns empty objects for both keys', async () => { - // globalState.get returns default {} for both keys - const store = await createStore(); - - const folders = await store.getUsedWorkspaceFolders(); - expect(folders).toEqual([]); - store.dispose(); - }); }); }); diff --git a/src/extension/chatSessions/vscode-node/test/chatSessionWorkspaceFolderService.spec.ts b/src/extension/chatSessions/vscode-node/test/chatSessionWorkspaceFolderService.spec.ts index e9993fb300..37f1947aeb 100644 --- a/src/extension/chatSessions/vscode-node/test/chatSessionWorkspaceFolderService.spec.ts +++ b/src/extension/chatSessions/vscode-node/test/chatSessionWorkspaceFolderService.spec.ts @@ -83,7 +83,6 @@ class MockMetadataStore extends mock() { } return undefined; }); - override getUsedWorkspaceFolders = vi.fn(async (): Promise => Array.from(this._data.values())); override deleteSessionMetadata = vi.fn(async (_sessionId: string) => { this._data.delete(_sessionId); }); @@ -301,127 +300,7 @@ describe('ChatSessionWorkspaceFolderService', () => { }); }); - describe('getRecentFolders', () => { - it('should return empty array when no folders tracked', async () => { - const result = await service.getRecentFolders(); - expect(result).toEqual([]); - }); - - it('should return tracked folders sorted by access time (newest first)', async () => { - // Add folders with controlled timestamps - await service.trackSessionWorkspaceFolder('session-1', vscode.Uri.file('/path/1').fsPath); - // Small delay to ensure different timestamps - await new Promise(resolve => setTimeout(resolve, 10)); - await service.trackSessionWorkspaceFolder('session-2', vscode.Uri.file('/path/2').fsPath); - await new Promise(resolve => setTimeout(resolve, 10)); - await service.trackSessionWorkspaceFolder('session-3', vscode.Uri.file('/path/3').fsPath); - - const result = await service.getRecentFolders(); - - expect(result.length).toBe(3); - // Most recent first - expect(result[0].folder.fsPath).toBe(vscode.Uri.file('/path/3').fsPath); - expect(result[1].folder.fsPath).toBe(vscode.Uri.file('/path/2').fsPath); - expect(result[2].folder.fsPath).toBe(vscode.Uri.file('/path/1').fsPath); - }); - - it('should include lastAccessTime for each folder', async () => { - await service.trackSessionWorkspaceFolder('session-1', vscode.Uri.file('/path/1').fsPath); - const result = await service.getRecentFolders(); - - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty('lastAccessTime'); - expect(typeof result[0].lastAccessTime).toBe('number'); - }); - - it('should filter out entries with missing folderPath', async () => { - // Override mock to return entries with and without folderPath - metadataStore.getUsedWorkspaceFolders.mockResolvedValueOnce([ - { folderPath: vscode.Uri.file('/path/1').fsPath, timestamp: Date.now() }, - { folderPath: '', timestamp: Date.now() }, - ]); - - const result = await service.getRecentFolders(); - - // Should only include the valid entry - expect(result.length).toBe(1); - expect(result[0].folder.fsPath).toBe(vscode.Uri.file('/path/1').fsPath); - }); - - it('should return entries from metadata store with valid folderPath', async () => { - metadataStore.getUsedWorkspaceFolders.mockResolvedValueOnce([ - { folderPath: vscode.Uri.file('/some/path').fsPath, timestamp: Date.now() } - ]); - - const result = await service.getRecentFolders(); - - expect(result.length).toBe(1); - expect(result[0].folder.fsPath).toBe(vscode.Uri.file('/some/path').fsPath); - }); - - it('should handle entries with missing fields gracefully', async () => { - // Override mock to return entries with missing fields - metadataStore.getUsedWorkspaceFolders.mockResolvedValueOnce([ - { folderPath: '', timestamp: 0 } as WorkspaceFolderEntry - ]); - - // Should not throw - const result = await service.getRecentFolders(); - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(0); - }); - }); - - describe('deleteRecentFolder', () => { - it('should handle UUID entries (empty folderPath)', async () => { - // Manually inject entry with no folderPath - const data = { - 'session-1': { timestamp: Date.now() } - }; - await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', data); - - // Should not throw - await expect(service.deleteRecentFolder(vscode.Uri.file('/some/path'))).resolves.toBeUndefined(); - }); - - it('should exclude deleted folder from subsequent getRecentFolders calls', async () => { - await service.trackSessionWorkspaceFolder('session-1', vscode.Uri.file('/path/1').fsPath); - await service.trackSessionWorkspaceFolder('session-2', vscode.Uri.file('/path/2').fsPath); - - await service.deleteRecentFolder(vscode.Uri.file('/path/1')); - - const recent = await service.getRecentFolders(); - const paths = recent.map(r => r.folder.fsPath); - expect(paths).not.toContain(vscode.Uri.file('/path/1').fsPath); - expect(paths).toContain(vscode.Uri.file('/path/2').fsPath); - }); - - it('should not affect session workspace folder tracking after delete', async () => { - await service.trackSessionWorkspaceFolder('session-1', vscode.Uri.file('/path/1').fsPath); - - await service.deleteRecentFolder(vscode.Uri.file('/path/1')); - - // The session folder itself should still be retrievable (deleteRecentFolder only hides from MRU) - const folder = await service.getSessionWorkspaceFolder('session-1'); - expect(folder?.fsPath).toBe(vscode.Uri.file('/path/1').fsPath); - }); - }); - describe('cleanupOldEntries', () => { - it('should handle large number of entries from metadata store', async () => { - const entries: WorkspaceFolderEntry[] = []; - for (let i = 0; i < 100; i++) { - entries.push({ - folderPath: vscode.Uri.file(`/old/path/${i}`).fsPath, - timestamp: Date.now() - 10000 + i - }); - } - metadataStore.getUsedWorkspaceFolders.mockResolvedValueOnce(entries); - - const result = await service.getRecentFolders(); - expect(result.length).toBe(100); - }); - it('should keep newer entries and remove older ones', async () => { const MAX_ENTRIES = 1500; @@ -455,54 +334,6 @@ describe('ChatSessionWorkspaceFolderService', () => { }); describe('integration scenarios', () => { - it('should maintain data across multiple operations', async () => { - await service.trackSessionWorkspaceFolder('session-1', vscode.Uri.file('/path/1').fsPath); - await service.trackSessionWorkspaceFolder('session-2', vscode.Uri.file('/path/2').fsPath); - await service.trackSessionWorkspaceFolder('session-3', vscode.Uri.file('/path/3').fsPath); - - let recent = await service.getRecentFolders(); - expect(recent.length).toBe(3); - - await service.deleteRecentFolder(vscode.Uri.file('/path/2')); - - recent = await service.getRecentFolders(); - expect(recent.length).toBe(2); - - const folder1 = await service.getSessionWorkspaceFolder('session-1'); - const folder3 = await service.getSessionWorkspaceFolder('session-3'); - expect(folder1?.fsPath).toBe(vscode.Uri.file('/path/1').fsPath); - expect(folder3?.fsPath).toBe(vscode.Uri.file('/path/3').fsPath); - }); - - it('should handle rapid concurrent operations', async () => { - const operations = []; - for (let i = 0; i < 50; i++) { - operations.push( - service.trackSessionWorkspaceFolder(`session-${i}`, vscode.Uri.file(`/path/${i}`).fsPath) - ); - } - - await Promise.all(operations); - - const recent = await service.getRecentFolders(); - expect(recent.length).toBe(50); - }); - - it('should maintain consistency after delete and re-track', async () => { - const sessionId = 'session-1'; - const folderPath = vscode.Uri.file('/path/1').fsPath; - - await service.trackSessionWorkspaceFolder(sessionId, folderPath); - await service.deleteTrackedWorkspaceFolder(sessionId); - await service.trackSessionWorkspaceFolder(sessionId, folderPath); - - const result = await service.getSessionWorkspaceFolder(sessionId); - expect(result?.fsPath).toBe(folderPath); - - const recent = await service.getRecentFolders(); - expect(recent.length).toBe(1); - }); - describe('getWorkspaceChanges - cache invalidation', () => { let headCommitHash: ReturnType>; diff --git a/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts b/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts index 60013f79b3..7520607790 100644 --- a/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts +++ b/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts @@ -122,8 +122,6 @@ class MockFolderRepositoryManager implements IFolderRepositoryManager { async getFolderMRU(): Promise { return this._mruEntries; } - - async deleteMRUEntry(): Promise { } } function createDefaultMocks() { @@ -451,8 +449,8 @@ describe('ChatSessionContentProvider', () => { const mruFolder = URI.file('/recent/project'); const mruRepo = URI.file('/recent/repo'); mockFolderRepositoryManager.setMRUEntries([ - { folder: mruFolder, repository: undefined, lastAccessed: Date.now(), isUntitledSessionSelection: true }, - { folder: mruRepo, repository: mruRepo, lastAccessed: Date.now() - 1000, isUntitledSessionSelection: false }, + { folder: mruFolder, repository: undefined, lastAccessed: Date.now() }, + { folder: mruRepo, repository: mruRepo, lastAccessed: Date.now() - 1000 }, ]); const options = await emptyWorkspaceProvider.provideChatSessionProviderOptions(); @@ -475,7 +473,7 @@ describe('ChatSessionContentProvider', () => { it('getFolderInfoForSession uses MRU fallback when no selection', async () => { const mruFolder = URI.file('/recent/project'); mockFolderRepositoryManager.setMRUEntries([ - { folder: mruFolder, repository: undefined, lastAccessed: Date.now(), isUntitledSessionSelection: true }, + { folder: mruFolder, repository: undefined, lastAccessed: Date.now() }, ]); const folderInfo = await emptyWorkspaceProvider.getFolderInfoForSession('test-session'); @@ -493,7 +491,7 @@ describe('ChatSessionContentProvider', () => { const mruFolder = URI.file('/recent/project'); const selectedFolder = URI.file('/selected/project'); mockFolderRepositoryManager.setMRUEntries([ - { folder: mruFolder, repository: undefined, lastAccessed: Date.now(), isUntitledSessionSelection: true }, + { folder: mruFolder, repository: undefined, lastAccessed: Date.now() }, ]); seedSessionItem('test-session'); diff --git a/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index 1803b93b25..c1b0768e06 100644 --- a/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -126,7 +126,6 @@ class FakeToolsService extends mock() { class FakeChatSessionWorkspaceFolderService extends mock() { private _sessionWorkspaceFolders = new Map(); private _sessionWorkspaceFolderRepositories = new Map(); - private _recentFolders: { folder: vscode.Uri; lastAccessTime: number }[] = []; private _workspaceChanges = new Map(); override trackSessionWorkspaceFolder = vi.fn(async (sessionId: string, workspaceFolderUri: string, repositoryFolderUri?: string) => { this._sessionWorkspaceFolders.set(sessionId, vscode.Uri.file(workspaceFolderUri)); @@ -152,26 +151,9 @@ class FakeChatSessionWorkspaceFolderService extends mock => { - return Promise.resolve(this._recentFolders); - }); override getWorkspaceChanges = vi.fn(async (workspaceFolderUri: vscode.Uri): Promise => { return this._workspaceChanges.get(workspaceFolderUri.toString()); }); - setTestRecentFolders(folders: { folder: vscode.Uri; lastAccessTime: number }[]): void { - this._recentFolders = folders; - } - setTestSessionWorkspaceFolder(sessionId: string, folder: vscode.Uri): void { - this._sessionWorkspaceFolders.set(sessionId, folder); - } - - setTestSessionWorkspaceFolderEntry(sessionId: string, folder: vscode.Uri, repository?: vscode.Uri): void { - this._sessionWorkspaceFolders.set(sessionId, folder); - this._sessionWorkspaceFolderRepositories.set(sessionId, repository); - } - setTestWorkspaceChanges(folder: vscode.Uri, changes: readonly ChatSessionWorktreeFile[] | undefined): void { - this._workspaceChanges.set(folder.toString(), changes); - } override clearWorkspaceChanges(workspaceFolderUri: vscode.Uri): void { this._workspaceChanges.delete(workspaceFolderUri.toString()); } @@ -764,7 +746,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { configurationService, customSessionTitleService, new MockExtensionContext() as unknown as IVSCodeExtensionContext, - logService + logService, ); const invalidParticipant = new CopilotCLIChatSessionParticipant( invalidContentProvider, diff --git a/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts b/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts index b0767eedd3..88ac6a6ede 100644 --- a/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts +++ b/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts @@ -115,7 +115,6 @@ class TestFolderRepositoryManager extends mock() { })); override getRepositoryInfo = vi.fn(async () => ({ repository: undefined, headBranchName: undefined })); override getFolderMRU = vi.fn(async () => []); - override deleteMRUEntry = vi.fn(async () => { }); } class TestGitService extends mock() { diff --git a/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts b/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts index a69dd7cf43..3a5c2a96d2 100644 --- a/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts +++ b/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts @@ -72,10 +72,6 @@ class FakeChatSessionWorkspaceFolderService extends mock => { - this._recentFolders = this._recentFolders.filter(entry => entry.folder.fsPath !== folder.fsPath); - }); - override getSessionWorkspaceFolder = vi.fn(async (sessionId: string): Promise => { return this._sessionWorkspaceFolders.get(sessionId); }); @@ -94,10 +90,6 @@ class FakeChatSessionWorkspaceFolderService extends mock => { - return this._recentFolders; - }); - override getWorkspaceChanges = vi.fn(async (workspaceFolderUri: vscode.Uri): Promise => { return this._workspaceChanges.get(workspaceFolderUri.toString()); }); @@ -110,14 +102,6 @@ class FakeChatSessionWorkspaceFolderService extends mock { }); }); - describe('getFolderMRU', () => { - it('combines data from all sources', async () => { - gitService.setTestRecentRepositories([ - { rootUri: vscode.Uri.file('/repo1'), lastAccessTime: 1000 }, - { rootUri: vscode.Uri.file('/repo2'), lastAccessTime: 2000 } - ]); - workspaceFolderService.setTestRecentFolders([ - { folder: vscode.Uri.file('/folder1'), lastAccessTime: 1500 } - ]); - - const result = await manager.getFolderMRU(); - - // Should have items from both sources - expect(result.length).toBeGreaterThan(0); - }); - - it('deduplicates entries', async () => { - const duplicateUri = vscode.Uri.file('/same/path'); - gitService.setTestRecentRepositories([ - { rootUri: duplicateUri, lastAccessTime: 1000 } - ]); - workspaceFolderService.setTestRecentFolders([ - { folder: duplicateUri, lastAccessTime: 2000 } - ]); - - const result = await manager.getFolderMRU(); - - // Should only have one entry for the duplicate path - const paths = result.map(r => r.folder.fsPath); - const uniquePaths = [...new Set(paths)]; - expect(paths.length).toBe(uniquePaths.length); - }); - - it('sorts by last access time descending', async () => { - gitService.setTestRecentRepositories([ - { rootUri: vscode.Uri.file('/old'), lastAccessTime: 1000 }, - { rootUri: vscode.Uri.file('/new'), lastAccessTime: 3000 }, - { rootUri: vscode.Uri.file('/middle'), lastAccessTime: 2000 } - ]); - - const result = await manager.getFolderMRU(); - - expect(result[0].folder.fsPath).toBe(vscode.Uri.file('/new').fsPath); - expect(result[1].folder.fsPath).toBe(vscode.Uri.file('/middle').fsPath); - expect(result[2].folder.fsPath).toBe(vscode.Uri.file('/old').fsPath); - }); - }); - - describe('deleteMRUEntry', () => { - - it('removes entry from workspace folder service', async () => { - const folderUri = vscode.Uri.file('/workspace/folder'); - - workspaceFolderService.setTestRecentFolders([ - { folder: folderUri, lastAccessTime: Date.now() } - ]); - - // Verify it's there before deletion - const result = await manager.getFolderMRU(); - expect(result.length).toBeGreaterThan(0); - - await manager.deleteMRUEntry(folderUri); - - // Verify deleteRecentFolder was called on workspace folder service - expect((workspaceFolderService.deleteRecentFolder).mock.calls.length).toBe(1); - }); - - it('handles non-existent folder deletion gracefully', async () => { - const nonExistentUri = vscode.Uri.file('/non/existent/path'); - - // Should not throw - await expect(manager.deleteMRUEntry(nonExistentUri)).resolves.toBeUndefined(); - }); - - it('deduplicates after deletion from untitled session folders', async () => { - const folderUri = vscode.Uri.file('/my/folder'); - - manager.setNewSessionFolder('untitled:1', folderUri); - manager.setNewSessionFolder('untitled:2', folderUri); - - let mru = await manager.getFolderMRU(); - const beforeCount = mru.filter(entry => entry.folder.fsPath === folderUri.fsPath).length; - - await manager.deleteMRUEntry(folderUri); - - mru = await manager.getFolderMRU(); - const afterCount = mru.filter(entry => entry.folder.fsPath === folderUri.fsPath).length; - - expect(afterCount).toBeLessThan(beforeCount); - }); - }); - describe('uncommitted changes prompting in initializeFolderRepository', () => { const mockToolInvocationToken = {} as vscode.ChatParticipantToolToken;