diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts index 570d22c2777..64c59596cae 100644 --- a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts +++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts @@ -16,6 +16,10 @@ export const IChatSessionWorkspaceFolderService = createServiceIdentifier; deleteTrackedWorkspaceFolder(sessionId: string): Promise; /** * Track workspace folder selection for a session (for folders without git repos in multi-root workspaces) diff --git a/extensions/copilot/src/extension/chatSessions/common/folderRepositoryManager.ts b/extensions/copilot/src/extension/chatSessions/common/folderRepositoryManager.ts index 47e2dfea03e..88ebc43d2ab 100644 --- a/extensions/copilot/src/extension/chatSessions/common/folderRepositoryManager.ts +++ b/extensions/copilot/src/extension/chatSessions/common/folderRepositoryManager.ts @@ -73,6 +73,11 @@ 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'); @@ -139,7 +144,12 @@ export interface IFolderRepositoryManager { * @returns Array of MRU entries sorted by last accessed time (newest first), * limited to 10 items, with non-existent paths filtered out */ - getFolderMRU(): Promise; + getFolderMRU(): FolderRepositoryMRUEntry[]; + + /** + * Delete an entry from the MRU list. + */ + deleteMRUEntry(folder: vscode.Uri): Promise; /** * Get the last used folder ID in untitled workspace. diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts index 0caf2bda758..5823b37727b 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts @@ -7,20 +7,19 @@ import * as vscode from 'vscode'; import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { ILogService } from '../../../platform/log/common/logService'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { URI } from '../../../util/vs/base/common/uri'; import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService'; import { isUntitledSessionId } from '../common/utils'; +import { isEqual } from '../../../util/vs/base/common/resources'; const CHAT_SESSION_WORKSPACE_FOLDER_MEMENTO_KEY = 'github.copilot.cli.sessionWorkspaceFolders'; -// Maximum age of entries in milliseconds (30 days) -const MAX_ENTRY_AGE_MS = 30 * 24 * 60 * 60 * 1000; - // Maximum number of entries to keep const MAX_ENTRIES = 1_500; const ENTRIES_TO_PRUNE = 500; interface WorkspaceFolderEntry { - readonly folderPath: string; + readonly folderPath?: string; readonly timestamp: number; } @@ -31,58 +30,23 @@ interface WorkspaceFolderEntry { export class ChatSessionWorkspaceFolderService extends Disposable implements IChatSessionWorkspaceFolderService { declare _serviceBrand: undefined; - private _sessionWorkspaceFolders = new Map(); - constructor( @ILogService private readonly logService: ILogService, @IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext ) { super(); - this.loadWorkspaceFolders(); - } - - private loadWorkspaceFolders(): void { - const data = this.extensionContext.globalState.get>(CHAT_SESSION_WORKSPACE_FOLDER_MEMENTO_KEY, {}); - const now = Date.now(); - let needsCleanup = false; - - for (const [sessionId, entry] of Object.entries(data)) { - if (isUntitledSessionId(sessionId)) { - continue; // Skip untitled sessions that may have been saved. - } - // Check if entry is too old - if (now - entry.timestamp > MAX_ENTRY_AGE_MS) { - needsCleanup = true; - continue; // Skip old entries - } - this._sessionWorkspaceFolders.set(sessionId, entry); - } - - this.logService.trace(`[ChatSessionWorkspaceFolderService] Loaded ${this._sessionWorkspaceFolders.size} workspace folder mappings`); - - // Cleanup old entries - if (needsCleanup) { - void this.cleanupOldEntries(); - } } private async cleanupOldEntries(): Promise { + const data = this.extensionContext.globalState.get>(CHAT_SESSION_WORKSPACE_FOLDER_MEMENTO_KEY, {}); const newData: Record = {}; - const entries = Array.from(this._sessionWorkspaceFolders.entries()) + const entries = Object.entries(data) .map(([sessionId, entry]) => ({ sessionId, entry })); // Sort by timestamp (newest first) and keep only MAX_ENTRIES - ENTRIES_TO_PRUNE entries.sort((a, b) => b.entry.timestamp - a.entry.timestamp); const entriesToKeep = entries.slice(0, MAX_ENTRIES - ENTRIES_TO_PRUNE); - // Update in-memory map if we had to trim - if (entries.length > MAX_ENTRIES - ENTRIES_TO_PRUNE) { - this._sessionWorkspaceFolders.clear(); - for (const { sessionId, entry } of entriesToKeep) { - this._sessionWorkspaceFolders.set(sessionId, entry); - } - } - // Build new data object for (const { sessionId, entry } of entriesToKeep) { newData[sessionId] = entry; @@ -92,14 +56,24 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh this.logService.trace(`[ChatSessionWorkspaceFolderService] Cleaned up old entries, kept ${entriesToKeep.length}`); } + public async deleteRecentFolder(folder: vscode.Uri): Promise { + const data = this.extensionContext.globalState.get>(CHAT_SESSION_WORKSPACE_FOLDER_MEMENTO_KEY, {}); + for (const [sessionId, entry] of Object.entries(data)) { + if (entry.folderPath === folder.fsPath || !entry.folderPath || isEqual(URI.file(entry.folderPath), folder)) { + delete data[sessionId]; + } + } + return this.extensionContext.globalState.update(CHAT_SESSION_WORKSPACE_FOLDER_MEMENTO_KEY, data); + } + public getRecentFolders(): { folder: vscode.Uri; lastAccessTime: number }[] { const data = this.extensionContext.globalState.get>(CHAT_SESSION_WORKSPACE_FOLDER_MEMENTO_KEY, {}); const recentFolders: { folder: vscode.Uri; lastAccessTime: number }[] = []; - for (const entry of Object.values(data)) { - if (typeof entry === 'string') { + for (const [sessionId, entry] of Object.entries(data)) { + if (typeof entry === 'string' || !entry.folderPath) { continue; } - if (isUntitledSessionId(entry.folderPath)) { + if (isUntitledSessionId(sessionId)) { continue; // Skip untitled sessions that may have been saved. } recentFolders.push({ folder: vscode.Uri.file(entry.folderPath), lastAccessTime: entry.timestamp }); @@ -108,7 +82,6 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh return recentFolders; } async deleteTrackedWorkspaceFolder(sessionId: string): Promise { - this._sessionWorkspaceFolders.delete(sessionId); const data = this.extensionContext.globalState.get>(CHAT_SESSION_WORKSPACE_FOLDER_MEMENTO_KEY, {}); delete data[sessionId]; await this.extensionContext.globalState.update(CHAT_SESSION_WORKSPACE_FOLDER_MEMENTO_KEY, data); @@ -121,7 +94,6 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh folderPath: workspaceFolderUri, timestamp: Date.now() }; - this._sessionWorkspaceFolders.set(sessionId, entry); data[sessionId] = entry; await this.extensionContext.globalState.update(CHAT_SESSION_WORKSPACE_FOLDER_MEMENTO_KEY, data); @@ -135,25 +107,9 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh } getSessionWorkspaceFolder(sessionId: string): vscode.Uri | undefined { - const entry = this._sessionWorkspaceFolders.get(sessionId); - if (!entry) { - return undefined; - } - - // Update timestamp on access - const updatedEntry: WorkspaceFolderEntry = { - folderPath: entry.folderPath, - timestamp: Date.now() - }; - this._sessionWorkspaceFolders.set(sessionId, updatedEntry); - void this.updateEntryTimestamp(sessionId, updatedEntry); - - return vscode.Uri.file(entry.folderPath); - } - - private async updateEntryTimestamp(sessionId: string, entry: WorkspaceFolderEntry): Promise { const data = this.extensionContext.globalState.get>(CHAT_SESSION_WORKSPACE_FOLDER_MEMENTO_KEY, {}); - data[sessionId] = entry; - await this.extensionContext.globalState.update(CHAT_SESSION_WORKSPACE_FOLDER_MEMENTO_KEY, data); + + const entry = sessionId in data ? data[sessionId] : undefined; + return entry?.folderPath ? URI.file(entry.folderPath) : undefined; } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index e984f87361f..db6660ccb98 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { IEnvService } from '../../../platform/env/common/envService'; +import { IEnvService, INativeEnvService } from '../../../platform/env/common/envService'; +import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; import { IGitService } from '../../../platform/git/common/gitService'; import { IOctoKitService } from '../../../platform/github/common/githubService'; import { OctoKitService } from '../../../platform/github/common/octoKitServiceImpl'; @@ -146,9 +147,11 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const copilotCLIWorktreeManagerService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionWorktreeService)); const copilotCLIWorkspaceFolderSessions = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionWorkspaceFolderService)); const folderRepositoryManager = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IFolderRepositoryManager)); + const nativeEnvService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(INativeEnvService)); + const fileSystemService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IFileSystemService)); const copilotcliParticipant = vscode.chat.createChatParticipant(this.copilotcliSessionType, copilotcliChatSessionParticipant.createHandler()); this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant)); - this._register(registerCLIChatCommands(copilotcliSessionItemProvider, copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager)); + this._register(registerCLIChatCommands(copilotcliSessionItemProvider, copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, nativeEnvService, fileSystemService)); } private registerCopilotCloudAgent() { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index a7420416908..a5d5048fe84 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -8,6 +8,7 @@ import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; import { ChatExtendedRequestHandler, ChatSessionProviderOptionItem, Uri } from 'vscode'; import { IRunCommandExecutionService } from '../../../platform/commands/common/runCommandExecutionService'; +import { INativeEnvService } from '../../../platform/env/common/envService'; import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; import { IGitService, RepoContext } from '../../../platform/git/common/gitService'; import { toGitUri } from '../../../platform/git/common/utils'; @@ -16,11 +17,12 @@ import { IPromptsService, ParsedPromptFile } from '../../../platform/promptFiles import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { isUri } from '../../../util/common/types'; -import { disposableTimeout } from '../../../util/vs/base/common/async'; +import { DeferredPromise, disposableTimeout } from '../../../util/vs/base/common/async'; import { isCancellationError } from '../../../util/vs/base/common/errors'; import { Emitter, Event } from '../../../util/vs/base/common/event'; import { Disposable, DisposableStore, IDisposable, IReference, toDisposable } from '../../../util/vs/base/common/lifecycle'; -import { basename, extUri } from '../../../util/vs/base/common/resources'; +import { relative } from '../../../util/vs/base/common/path'; +import { basename, dirname, extUri, isEqual } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { ToolCall } from '../../agents/copilotcli/common/copilotCLITools'; @@ -34,7 +36,7 @@ import { ChatVariablesCollection, isPromptFile } from '../../prompt/common/chatV import { IToolsService } from '../../tools/common/toolsService'; import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService'; import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../common/chatSessionWorktreeService'; -import { IFolderRepositoryManager } from '../common/folderRepositoryManager'; +import { FolderRepositoryMRUEntry, IFolderRepositoryManager } from '../common/folderRepositoryManager'; import { isUntitledSessionId } from '../common/utils'; import { convertReferenceToVariable } from './copilotCLIPromptReferences'; import { ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration'; @@ -44,6 +46,7 @@ const AGENTS_OPTION_ID = 'agent'; const MODELS_OPTION_ID = 'model'; const REPOSITORY_OPTION_ID = 'repository'; const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.cli.sessions.openRepository'; +const MAX_MRU_ENTRIES = 10; const UncommittedChangesStep = 'uncommitted-changes'; type ConfirmationResult = { step: string; accepted: boolean; metadata?: CLIConfirmationMetadata }; @@ -278,23 +281,38 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements } public notifyProviderOptionsChange(): void { - this._repositoryOptionItemsForUntitledWorkspace = undefined; this._onDidChangeChatSessionProviderOptions.fire(); } + private async getDefaultUntitledSessionRepositoryOption(copilotcliSessionId: string, token: vscode.CancellationToken) { + const repositories = this.isUntitledWorkspace() ? folderMRUToChatProviderOptions(this.folderRepositoryManager.getFolderMRU()) : this.getRepositoryOptionItems(); + // Use FolderRepositoryManager to get folder/repository info (no trust check needed for UI population) + const folderInfo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token); + const uri = folderInfo.repository ?? folderInfo.folder; + if (uri) { + return uri; + } else if (repositories.length) { + // No folder selected yet for this untitled session - use MRU or first available + const lastUsedFolderId = this.folderRepositoryManager.getLastUsedFolderIdInUntitledWorkspace(); + const firstRepo = (lastUsedFolderId && repositories.find(repo => repo.id === lastUsedFolderId)?.id) ?? repositories[0].id; + return Uri.file(firstRepo); + } + return undefined; + } + async provideChatSessionContent(resource: Uri, token: vscode.CancellationToken): Promise { const copilotcliSessionId = SessionIdForCLI.parse(resource); const workingDirectoryValue = this.copilotCLIWorktreeManagerService.getWorktreePath(copilotcliSessionId); const workingDirectory = workingDirectoryValue ? workingDirectoryValue : undefined; const isolationEnabled = workingDirectoryValue ? true : false; // If theres' a worktree, that means isolation was enabled. - const [defaultModel, sessionAgent, defaultAgent, existingSession, repositories] = await Promise.all([ + const [defaultModel, sessionAgent, defaultAgent, existingSession] = await Promise.all([ this.copilotCLIModels.getDefaultModel(), this.copilotCLIAgents.getSessionAgent(copilotcliSessionId), this.copilotCLIAgents.getDefaultAgent(), isUntitledSessionId(copilotcliSessionId) ? Promise.resolve(undefined) : this.sessionService.getSession(copilotcliSessionId, { workingDirectory, isolationEnabled, readonly: true }, token), - this.isUntitledWorkspace() ? this.getRepositoryOptionItemsForUntitledWorkspace() : Promise.resolve(this.getRepositoryOptionItems()) ]); + const repositories = this.isUntitledWorkspace() ? folderMRUToChatProviderOptions(this.folderRepositoryManager.getFolderMRU()) : this.getRepositoryOptionItems(); // If we have session in _sessionModel, use that (faster as its in memory), else get from existing session. const model = (existingSession ? (_sessionModel.get(copilotcliSessionId) ?? await existingSession.object.getSelectedModelId()) : _sessionModel.get(copilotcliSessionId)) ?? await this.getCustomAgentModel(defaultAgent, token) ?? defaultModel; @@ -309,20 +327,15 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements } // Use FolderRepositoryManager to get folder/repository info (no trust check needed for UI population) - const folderInfo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token); if (isUntitledSessionId(copilotcliSessionId)) { - const id = folderInfo.repository?.fsPath ?? folderInfo.folder?.fsPath; - if (id) { - options[REPOSITORY_OPTION_ID] = id; - } else if (repositories.length) { - // No folder selected yet for this untitled session - use MRU or first available - const lastUsedFolderId = this.folderRepositoryManager.getLastUsedFolderIdInUntitledWorkspace(); - const firstRepo = (lastUsedFolderId && repositories.find(repo => repo.id === lastUsedFolderId)?.id) ?? repositories[0].id; - options[REPOSITORY_OPTION_ID] = firstRepo; + const defaultRepo = await this.getDefaultUntitledSessionRepositoryOption(copilotcliSessionId, token); + if (defaultRepo) { + options[REPOSITORY_OPTION_ID] = defaultRepo.fsPath; // Use the manager to track the selection for untitled sessions - this.folderRepositoryManager.setUntitledSessionFolder(copilotcliSessionId, vscode.Uri.file(firstRepo)); + this.folderRepositoryManager.setUntitledSessionFolder(copilotcliSessionId, defaultRepo); } } else { + const folderInfo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token); const folderOrRepoId = folderInfo.repository?.fsPath ?? folderInfo.folder?.fsPath; const existingItem = folderOrRepoId ? repositories.find(repo => repo.id === folderOrRepoId) : undefined; if (existingItem) { @@ -402,16 +415,21 @@ 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.getRepositoryOptionItemsForUntitledWorkspace(); + const repositories = this.folderRepositoryManager.getFolderMRU(); + const items = folderMRUToChatProviderOptions(repositories); + items.splice(MAX_MRU_ENTRIES); // Limit to max entries + const commands: vscode.Command[] = []; + commands.push({ + command: OPEN_REPOSITORY_COMMAND_ID, + title: l10n.t('Browse folders...') + }); + optionGroups.push({ id: REPOSITORY_OPTION_ID, name: l10n.t('Folder'), description: l10n.t('Pick Folder'), - items: repositories, - commands: [{ - command: OPEN_REPOSITORY_COMMAND_ID, - title: l10n.t('Open Folder...') - }] + items, + commands }); } else { const repositories = this.getRepositoryOptionItems(); @@ -469,30 +487,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements return repoItems.sort((a, b) => a.name.localeCompare(b.name)); } - private _repositoryOptionItemsForUntitledWorkspace: Promise | undefined; - - /** - * Get repository option items for untitled workspaces using last used repositories. - */ - private async getRepositoryOptionItemsForUntitledWorkspace(): Promise { - const currentValue = this._repositoryOptionItemsForUntitledWorkspace; - // Re-query in case some folders changed or new items have been added. - this._repositoryOptionItemsForUntitledWorkspace = this.getRepositoryOptionItemsForUntitledWorkspaceImpl(); - // Always return cached value for faster loading. - return currentValue ?? this._repositoryOptionItemsForUntitledWorkspace; - } - - private async getRepositoryOptionItemsForUntitledWorkspaceImpl(): Promise { - const mruItems = await this.folderRepositoryManager.getFolderMRU(); - - return mruItems.map((item) => { - if (item.repository) { - return toRepositoryOptionItem(item.folder); - } else { - return toWorkspaceFolderOptionItem(item.folder, basename(item.folder)); - } - }); - } // Handle option changes for a session (store current state in a map) async provideHandleOptionsChange(resource: Uri, updates: ReadonlyArray, token: vscode.CancellationToken): Promise { @@ -509,7 +503,22 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements await this.selectAgentModel(resource, agent, token); } } else if (update.optionId === REPOSITORY_OPTION_ID && typeof update.value === 'string' && isUntitledSessionId(sessionId)) { - this.folderRepositoryManager.setUntitledSessionFolder(sessionId, vscode.Uri.file(update.value)); + const folder = vscode.Uri.file(update.value); + if ((await checkPathExists(folder, this.fileSystem))) { + this.folderRepositoryManager.setUntitledSessionFolder(sessionId, folder); + } else { + await this.folderRepositoryManager.deleteMRUEntry(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); + if (defaultRepo && !isEqual(folder, defaultRepo)) { + this.folderRepositoryManager.setUntitledSessionFolder(sessionId, defaultRepo); + const changes: { optionId: string; value: string }[] = []; + changes.push({ optionId: REPOSITORY_OPTION_ID, value: defaultRepo.fsPath }); + this.notifySessionOptionsChange(resource, changes); + } + this.notifyProviderOptionsChange(); + } } } } @@ -860,9 +869,9 @@ export class CopilotCLIChatSessionParticipant extends Disposable { if (worktreeProperties) { void this.copilotCLIWorktreeManagerService.setWorktreeProperties(session.object.sessionId, worktreeProperties); } - if (session.object.options.workingDirectory && !session.object.options.isolationEnabled) { - void this.workspaceFolderService.trackSessionWorkspaceFolder(session.object.sessionId, session.object.options.workingDirectory.fsPath); - } + } + if (session.object.options.workingDirectory && !session.object.options.isolationEnabled) { + void this.workspaceFolderService.trackSessionWorkspaceFolder(session.object.sessionId, session.object.options.workingDirectory.fsPath); } disposables.add(session.object.attachStream(stream)); disposables.add(session.object.attachPermissionHandler(async (permissionRequest: PermissionRequest, toolCall: ToolCall | undefined, token: vscode.CancellationToken) => requestPermission(this.instantiationService, permissionRequest, toolCall, this.toolsService, request.toolInvocationToken, token))); @@ -1207,7 +1216,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } } -export function registerCLIChatCommands(copilotcliSessionItemProvider: CopilotCLIChatSessionItemProvider, copilotCLISessionService: ICopilotCLISessionService, copilotCLIWorktreeManagerService: IChatSessionWorktreeService, gitService: IGitService, copilotCliWorkspaceSession: IChatSessionWorkspaceFolderService, contentProvider: CopilotCLIChatSessionContentProvider, folderRepositoryManager: IFolderRepositoryManager): IDisposable { +export function registerCLIChatCommands(copilotcliSessionItemProvider: CopilotCLIChatSessionItemProvider, copilotCLISessionService: ICopilotCLISessionService, copilotCLIWorktreeManagerService: IChatSessionWorktreeService, gitService: IGitService, copilotCliWorkspaceSession: IChatSessionWorkspaceFolderService, contentProvider: CopilotCLIChatSessionContentProvider, folderRepositoryManager: IFolderRepositoryManager, envService: INativeEnvService, fileSystemService: IFileSystemService): IDisposable { const disposableStore = new DisposableStore(); disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.delete', async (sessionItem?: vscode.ChatSessionItem) => { if (sessionItem?.resource) { @@ -1273,11 +1282,7 @@ export function registerCLIChatCommands(copilotcliSessionItemProvider: CopilotCL vscode.window.createTerminal({ cwd: worktreePath }).show(); } })); - // Command to open a folder picker and select a repository for untitled workspaces - disposableStore.add(vscode.commands.registerCommand(OPEN_REPOSITORY_COMMAND_ID, async (sessionItemResource?: vscode.Uri) => { - if (!sessionItemResource) { - return; - } + async function selectFolder() { // Open folder picker dialog const folderUris = await vscode.window.showOpenDialog({ canSelectFiles: false, @@ -1286,13 +1291,105 @@ export function registerCLIChatCommands(copilotcliSessionItemProvider: CopilotCL openLabel: l10n.t('Open Folder...'), }); - if (!folderUris || folderUris.length === 0) { + return folderUris && folderUris.length > 0 ? folderUris[0] : undefined; + } + disposableStore.add(vscode.commands.registerCommand(OPEN_REPOSITORY_COMMAND_ID, async (sessionItemResource?: vscode.Uri) => { + if (!sessionItemResource) { return; } - const selectedFolderUri = folderUris[0]; - const sessionId = SessionIdForCLI.parse(sessionItemResource); + let selectedFolderUri: Uri | undefined = undefined; + const mruItems = folderRepositoryManager.getFolderMRU(); + 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) + : toWorkspaceFolderOptionItem(item.folder, basename(item.folder)); + + return { + label: optionItem.name, + description: `~/${relative(envService.userHome.fsPath, item.folder.fsPath)}`, + iconPath: optionItem.icon, + folderUri: item.folder, + openFolder: false + }; + }); + + items.unshift({ + label: l10n.t('Open Folder...'), + iconPath: new vscode.ThemeIcon('folder-opened'), + folderUri: undefined, + openFolder: true + }, { + kind: vscode.QuickPickItemKind.Separator, + label: '', + folderUri: undefined, + openFolder: true + }); + + const selectedFolder = new DeferredPromise(); + const disposables = new DisposableStore(); + const quickPick = disposables.add(vscode.window.createQuickPick()); + quickPick.items = items; + quickPick.placeholder = l10n.t('Select a recent folder'); + quickPick.matchOnDescription = true; + quickPick.ignoreFocusOut = true; + quickPick.matchOnDetail = true; + quickPick.show(); + disposables.add(quickPick.onDidHide(() => { + selectedFolder.complete(undefined); + })); + disposables.add(quickPick.onDidAccept(async () => { + if (quickPick.selectedItems.length === 0 && !quickPick.value) { + selectedFolder.complete(undefined); + quickPick.hide(); + } else if (quickPick.selectedItems.length && quickPick.selectedItems[0].folderUri) { + selectedFolder.complete(quickPick.selectedItems[0].folderUri); + quickPick.hide(); + } else if (quickPick.selectedItems.length && quickPick.selectedItems[0].openFolder) { + selectedFolder.complete(await selectFolder()); + quickPick.hide(); + } else if (quickPick.value) { + const fileOrFolder = vscode.Uri.file(quickPick.value); + try { + const stat = await vscode.workspace.fs.stat(fileOrFolder); + let directory: Uri | undefined = undefined; + if (stat.type & vscode.FileType.Directory) { + quickPick.hide(); + directory = fileOrFolder; + } else if (stat.type & vscode.FileType.File) { + directory = dirname(fileOrFolder); + } + if (directory) { + // Possible user selected a folder thats inside an existing workspace folder. + selectedFolder.complete(vscode.workspace.getWorkspaceFolder(directory)?.uri || directory); + quickPick.hide(); + } + } catch { + // ignore + } + } + })); + selectedFolderUri = await selectedFolder.p; + disposables.dispose(); + } + + if (!selectedFolderUri) { + return; + } + if (!(await checkPathExists(selectedFolderUri, fileSystemService))) { + 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; + } + + const sessionId = SessionIdForCLI.parse(sessionItemResource); folderRepositoryManager.setUntitledSessionFolder(sessionId, selectedFolderUri); // Notify VS Code that the option changed @@ -1303,6 +1400,7 @@ export function registerCLIChatCommands(copilotcliSessionItemProvider: CopilotCL // Notify that provider options have changed so the dropdown updates contentProvider.notifyProviderOptionsChange(); + })); const applyChanges = async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { @@ -1366,3 +1464,27 @@ async function getModelFromPromptFile(models: readonly string[], copilotCLIModel return undefined; } + +function folderMRUToChatProviderOptions(mruItems: FolderRepositoryMRUEntry[]): ChatSessionProviderOptionItem[] { + return mruItems.map((item) => { + if (item.repository) { + return toRepositoryOptionItem(item.folder); + } else { + return toWorkspaceFolderOptionItem(item.folder, basename(item.folder)); + } + }); + +} + + +/** + * Check if a path exists and is a directory. + */ +async function checkPathExists(filePath: vscode.Uri, fileSystemService: IFileSystemService): Promise { + try { + const stat = await fileSystemService.stat(filePath); + return stat.type === vscode.FileType.Directory; + } catch { + return false; + } +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts index e2a6c2a7e71..2eff3356230 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts @@ -5,7 +5,6 @@ import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; -import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; import { IGitService } from '../../../platform/git/common/gitService'; import { ILogService } from '../../../platform/log/common/logService'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; @@ -31,16 +30,6 @@ import { isUntitledSessionId } from '../common/utils'; */ const UNTRUSTED_FOLDER_MESSAGE = l10n.t('The selected folder is not trusted. Please trust the folder to continue with the {0}.', 'Background Agent'); -/** - * Maximum number of MRU items to return. - */ -const MAX_MRU_ITEMS = 10; - -/** - * Maximum number of MRU items to check for existence (performance optimization). - */ -const MAX_MRU_ITEMS_TO_CHECK = 20; - /** * Implementation of IFolderRepositoryManager. * @@ -71,7 +60,6 @@ export class FolderRepositoryManager extends Disposable implements IFolderReposi @ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService, @IGitService private readonly gitService: IGitService, @IWorkspaceService private readonly workspaceService: IWorkspaceService, - @IFileSystemService private readonly fileSystemService: IFileSystemService, @ILogService private readonly logService: ILogService ) { super(); @@ -331,7 +319,7 @@ export class FolderRepositoryManager extends Disposable implements IFolderReposi /** * @inheritdoc */ - async getFolderMRU(): Promise { + getFolderMRU(): FolderRepositoryMRUEntry[] { const latestReposAndFolders: FolderRepositoryMRUEntry[] = []; const seenUris = new ResourceSet(); @@ -343,7 +331,8 @@ export class FolderRepositoryManager extends Disposable implements IFolderReposi latestReposAndFolders.push({ folder: uri, repository: undefined, - lastAccessed: lastAccessTime + lastAccessed: lastAccessTime, + isUntitledSessionSelection: true }); } @@ -356,7 +345,8 @@ export class FolderRepositoryManager extends Disposable implements IFolderReposi latestReposAndFolders.push({ folder: repo.rootUri, repository: repo.rootUri, - lastAccessed: repo.lastAccessTime + lastAccessed: repo.lastAccessTime, + isUntitledSessionSelection: false }); } @@ -369,27 +359,27 @@ export class FolderRepositoryManager extends Disposable implements IFolderReposi latestReposAndFolders.push({ folder: folder.folder, repository: undefined, - lastAccessed: folder.lastAccessTime + lastAccessed: folder.lastAccessTime, + isUntitledSessionSelection: false }); } - // Filter out items that no longer exist (check first N for performance) - const existingItems: FolderRepositoryMRUEntry[] = []; - await Promise.all( - latestReposAndFolders.slice(0, MAX_MRU_ITEMS_TO_CHECK).map(async (item) => { - if (await this.checkPathExists(item.folder)) { - existingItems.push(item); - } - }) - ); - // Sort by last access time descending and limit - existingItems.sort((a, b) => b.lastAccessed - a.lastAccessed); + latestReposAndFolders.sort((a, b) => b.lastAccessed - a.lastAccessed); - return existingItems.slice(0, MAX_MRU_ITEMS); + return latestReposAndFolders; } + async deleteMRUEntry(folder: vscode.Uri): Promise { + // Remove from untitled session folders if present + for (const [sessionId, entry] of this._untitledSessionFolders.entries()) { + if (isEqual(entry.uri, folder)) { + this._untitledSessionFolders.delete(sessionId); + } + } + await this.workspaceFolderService.deleteRecentFolder(folder); + } /** * Get the last used folder ID in untitled workspace. * Used for defaulting the selection in the folder dropdown. @@ -416,18 +406,6 @@ export class FolderRepositoryManager extends Disposable implements IFolderReposi return true; } - /** - * Check if a path exists and is a directory. - */ - private async checkPathExists(filePath: vscode.Uri): Promise { - try { - const stat = await this.fileSystemService.stat(filePath); - return stat.type === vscode.FileType.Directory; - } catch { - return false; - } - } - /** * Move or copy uncommitted changes from the active repository to the worktree. */ diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionWorkspaceFolderService.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionWorkspaceFolderService.spec.ts new file mode 100644 index 00000000000..cf458a574fc --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionWorkspaceFolderService.spec.ts @@ -0,0 +1,515 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { mock } from '../../../../util/common/test/simpleMock'; +import { URI } from '../../../../util/vs/base/common/uri'; +import { ChatSessionWorkspaceFolderService } from '../chatSessionWorkspaceFolderServiceImpl'; + +/** + * Mock implementation of globalState for testing + */ +class MockGlobalState implements vscode.Memento { + private data = new Map(); + + get(key: string, defaultValue?: T): T { + const value = this.data.get(key); + return (value ?? defaultValue) as T; + } + + async update(key: string, value: unknown): Promise { + if (value === undefined) { + this.data.delete(key); + } else { + this.data.set(key, value); + } + } + + keys(): readonly string[] { + return Array.from(this.data.keys()); + } + + setKeysForSync(_keys: readonly string[]): void { + // No-op for testing + } +} + +/** + * Mock implementation of IVSCodeExtensionContext for testing + */ +class MockExtensionContext extends mock() { + public override globalState = new MockGlobalState(); + + override extensionPath = vscode.Uri.file('/mock/extension/path').fsPath; + override globalStorageUri = vscode.Uri.file('/mock/global/storage'); + override storagePath = vscode.Uri.file('/mock/storage/path').fsPath; + override globalStoragePath = vscode.Uri.file('/mock/global/storage/path').fsPath; + override logPath = vscode.Uri.file('/mock/log/path').fsPath; + override logUri = vscode.Uri.file('/mock/log/uri'); + override extensionUri = vscode.Uri.file('/mock/extension'); +} + +/** + * Mock implementation of ILogService for testing + */ +class MockLogService extends mock() { + override trace = vi.fn(); + override info = vi.fn(); + override warn = vi.fn(); + override error = vi.fn(); + override debug = vi.fn(); +} + +describe('ChatSessionWorkspaceFolderService', () => { + let service: ChatSessionWorkspaceFolderService; + let extensionContext: MockExtensionContext; + let logService: MockLogService; + + beforeEach(() => { + extensionContext = new MockExtensionContext(); + logService = new MockLogService(); + service = new ChatSessionWorkspaceFolderService(logService, extensionContext); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('trackSessionWorkspaceFolder', () => { + it('should track a workspace folder for a session', async () => { + const sessionId = 'session-1'; + const folderPath = vscode.Uri.file('/path/to/folder').fsPath; + + await service.trackSessionWorkspaceFolder(sessionId, folderPath); + + const tracked = service.getSessionWorkspaceFolder(sessionId); + expect(tracked?.fsPath).toBe(folderPath); + }); + + it('should update timestamp when tracking a folder', async () => { + const sessionId = 'session-1'; + const folderPath = vscode.Uri.file('/path/to/folder').fsPath; + + const beforeTime = Date.now(); + await service.trackSessionWorkspaceFolder(sessionId, folderPath); + const afterTime = Date.now(); + + // Verify by checking that globalState was updated + const data = extensionContext.globalState.get>('github.copilot.cli.sessionWorkspaceFolders', {}); + const entry = data[sessionId] as any; + expect(entry).toBeDefined(); + expect(entry.timestamp).toBeGreaterThanOrEqual(beforeTime); + expect(entry.timestamp).toBeLessThanOrEqual(afterTime); + }); + + it('should persist data to globalState', async () => { + const sessionId = 'session-1'; + const folderPath = vscode.Uri.file('/path/to/folder').fsPath; + + await service.trackSessionWorkspaceFolder(sessionId, folderPath); + + // Verify via globalState + const data = extensionContext.globalState.get>('github.copilot.cli.sessionWorkspaceFolders', {}); + expect(data[sessionId]).toBeDefined(); + }); + + it('should handle multiple concurrent tracking calls', async () => { + const sessionIds = ['session-1', 'session-2', 'session-3']; + const folderPaths = [vscode.Uri.file('/path/1').fsPath, vscode.Uri.file('/path/2').fsPath, vscode.Uri.file('/path/3').fsPath]; + + await Promise.all( + sessionIds.map((sessionId, idx) => service.trackSessionWorkspaceFolder(sessionId, folderPaths[idx])) + ); + + for (let i = 0; i < sessionIds.length; i++) { + const tracked = service.getSessionWorkspaceFolder(sessionIds[i]); + expect(tracked?.fsPath).toBe(folderPaths[i]); + } + }); + + it('should trigger cleanup when exceeding MAX_ENTRIES', async () => { + // Track MAX_ENTRIES + 1 entries to trigger cleanup + const MAX_ENTRIES = 1500; + + // Pre-fill globalState with old entries + const oldData: Record = {}; + for (let i = 0; i < MAX_ENTRIES; i++) { + oldData[`session-old-${i}`] = { + folderPath: vscode.Uri.file(`/old/path/${i}`).fsPath, + timestamp: Date.now() - 10000 + i // Incrementing timestamps + }; + } + await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', oldData); + + // Add one more entry to trigger cleanup + await service.trackSessionWorkspaceFolder('session-new', vscode.Uri.file('/new/path').fsPath); + + // Verify that cleanup occurred (some old entries should be gone) + const data = extensionContext.globalState.get>('github.copilot.cli.sessionWorkspaceFolders', {}); + const entryCount = Object.keys(data).length; + expect(entryCount).toBeLessThan(MAX_ENTRIES + 1); + }); + }); + + describe('getSessionWorkspaceFolder', () => { + it('should return undefined for non-existent session', () => { + const result = service.getSessionWorkspaceFolder('non-existent-session'); + expect(result).toBeUndefined(); + }); + + it('should return correct URI for tracked session', async () => { + const sessionId = 'session-1'; + const folderPath = vscode.Uri.file('/path/to/folder').fsPath; + + await service.trackSessionWorkspaceFolder(sessionId, folderPath); + const result = service.getSessionWorkspaceFolder(sessionId); + + expect(result).toBeDefined(); + expect(result?.fsPath).toBe(folderPath); + }); + + it('should return URI object with correct properties', async () => { + const sessionId = 'session-1'; + const folderPath = vscode.Uri.file('/path/to/folder').fsPath; + + await service.trackSessionWorkspaceFolder(sessionId, folderPath); + const result = service.getSessionWorkspaceFolder(sessionId); + + expect(result).toBeInstanceOf(vscode.Uri); + expect(result?.scheme).toBe('file'); + }); + + it('should handle malformed data gracefully', async () => { + // Manually inject malformed data + await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', { + 'session-bad': {} // Missing folderPath + }); + + const result = service.getSessionWorkspaceFolder('session-bad'); + expect(result).toBeUndefined(); + }); + + it('should return undefined if folderPath is empty string', async () => { + // Manually inject entry with empty folderPath + await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', { + 'session-empty': { folderPath: '', timestamp: Date.now() } + }); + + const result = service.getSessionWorkspaceFolder('session-empty'); + expect(result).toBeUndefined(); + }); + }); + + describe('deleteTrackedWorkspaceFolder', () => { + it('should delete tracked folder for session', async () => { + const sessionId = 'session-1'; + const folderPath = vscode.Uri.file('/path/to/folder').fsPath; + + await service.trackSessionWorkspaceFolder(sessionId, folderPath); + expect(service.getSessionWorkspaceFolder(sessionId)).toBeDefined(); + + await service.deleteTrackedWorkspaceFolder(sessionId); + expect(service.getSessionWorkspaceFolder(sessionId)).toBeUndefined(); + }); + + it('should update globalState when deleting', async () => { + const sessionId = 'session-1'; + await service.trackSessionWorkspaceFolder(sessionId, vscode.Uri.file('/path/to/folder').fsPath); + + await service.deleteTrackedWorkspaceFolder(sessionId); + + const data = extensionContext.globalState.get>('github.copilot.cli.sessionWorkspaceFolders', {}); + expect(data[sessionId]).toBeUndefined(); + }); + + it('should handle deletion of non-existent session', async () => { + // Should not throw + await expect(service.deleteTrackedWorkspaceFolder('non-existent')).resolves.toBeUndefined(); + }); + + it('should not affect other sessions when deleting one', async () => { + const session1 = 'session-1'; + const session2 = 'session-2'; + + await service.trackSessionWorkspaceFolder(session1, vscode.Uri.file('/path/1').fsPath); + await service.trackSessionWorkspaceFolder(session2, vscode.Uri.file('/path/2').fsPath); + + await service.deleteTrackedWorkspaceFolder(session1); + + expect(service.getSessionWorkspaceFolder(session1)).toBeUndefined(); + expect(service.getSessionWorkspaceFolder(session2)).toBeDefined(); + }); + }); + + describe('getRecentFolders', () => { + it('should return empty array when no folders tracked', () => { + const result = 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 = 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 = 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 () => { + // Add valid entry + await service.trackSessionWorkspaceFolder('session-1', vscode.Uri.file('/path/1').fsPath); + + // Manually inject malformed entry + const data = extensionContext.globalState.get>('github.copilot.cli.sessionWorkspaceFolders', {}); + (data as any)['session-malformed'] = { timestamp: Date.now() }; // No folderPath + await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', data); + + const result = 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 filter out untitled session IDs', async () => { + // Manually inject untitled session entry + const data: Record = { + 'untitled:12345': { + folderPath: vscode.Uri.file('/untitled/path').fsPath, + timestamp: Date.now() + } + }; + await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', data); + + const result = service.getRecentFolders(); + + // Untitled sessions should be filtered out + expect(result).toEqual([]); + }); + + it('should handle string entries (legacy data) gracefully', async () => { + // Manually inject legacy string data + const data = { + 'session-1': vscode.Uri.file('/path/as/string').fsPath // Legacy: entry was a string, not object + }; + await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', data); + + // Should not throw + const result = service.getRecentFolders(); + expect(Array.isArray(result)).toBe(true); + }); + }); + + describe('deleteRecentFolder', () => { + it('should delete folder by matching fsPath', async () => { + const sessionId = 'session-1'; + const folderPath = vscode.Uri.file('/path/to/folder').fsPath; + + await service.trackSessionWorkspaceFolder(sessionId, folderPath); + const deleteUri = vscode.Uri.file(folderPath); + + await service.deleteRecentFolder(deleteUri); + + expect(service.getSessionWorkspaceFolder(sessionId)).toBeUndefined(); + }); + + it('should delete folder by URI equality', async () => { + const sessionId = 'session-1'; + const folderPath = vscode.Uri.file('/path/to/folder').fsPath; + + await service.trackSessionWorkspaceFolder(sessionId, folderPath); + const deleteUri = URI.file(folderPath); + + await service.deleteRecentFolder(deleteUri); + + expect(service.getSessionWorkspaceFolder(sessionId)).toBeUndefined(); + }); + + it('should delete all entries matching the folder', async () => { + const folderPath = vscode.Uri.file('/path/to/folder').fsPath; + + await service.trackSessionWorkspaceFolder('session-1', folderPath); + await service.trackSessionWorkspaceFolder('session-2', folderPath); + await service.trackSessionWorkspaceFolder('session-3', vscode.Uri.file('/different/path').fsPath); + + await service.deleteRecentFolder(vscode.Uri.file(folderPath)); + + expect(service.getSessionWorkspaceFolder('session-1')).toBeUndefined(); + expect(service.getSessionWorkspaceFolder('session-2')).toBeUndefined(); + expect(service.getSessionWorkspaceFolder('session-3')).toBeDefined(); + }); + + 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 not affect other folders when deleting one', async () => { + const folder1 = vscode.Uri.file('/path/1').fsPath; + const folder2 = vscode.Uri.file('/path/2').fsPath; + + await service.trackSessionWorkspaceFolder('session-1', folder1); + await service.trackSessionWorkspaceFolder('session-2', folder2); + + await service.deleteRecentFolder(vscode.Uri.file(folder1)); + + expect(service.getSessionWorkspaceFolder('session-1')).toBeUndefined(); + expect(service.getSessionWorkspaceFolder('session-2')).toBeDefined(); + }); + + it('should handle non-existent folder deletion gracefully', async () => { + const result = await service.deleteRecentFolder(vscode.Uri.file('/non/existent/path')); + expect(result).toBeUndefined(); + }); + + it('should update globalState after deletion', async () => { + const sessionId = 'session-1'; + const folderPath = vscode.Uri.file('/path/to/folder').fsPath; + + await service.trackSessionWorkspaceFolder(sessionId, folderPath); + const beforeDelete = extensionContext.globalState.get>('github.copilot.cli.sessionWorkspaceFolders', {}); + expect(Object.keys(beforeDelete)).toContain(sessionId); + + await service.deleteRecentFolder(vscode.Uri.file(folderPath)); + + const afterDelete = extensionContext.globalState.get>('github.copilot.cli.sessionWorkspaceFolders', {}); + expect(Object.keys(afterDelete)).not.toContain(sessionId); + }); + }); + + describe('cleanupOldEntries', () => { + it('should be triggered when MAX_ENTRIES is exceeded', async () => { + const MAX_ENTRIES = 1500; + + // Pre-fill with old entries + const oldData: Record = {}; + for (let i = 0; i < MAX_ENTRIES; i++) { + oldData[`session-${i}`] = { + folderPath: vscode.Uri.file(`/old/path/${i}`).fsPath, + timestamp: Date.now() - 10000 + i + }; + } + await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', oldData); + + // Add new entry to trigger cleanup + await service.trackSessionWorkspaceFolder('session-trigger', vscode.Uri.file('/trigger/path').fsPath); + + const data = extensionContext.globalState.get>('github.copilot.cli.sessionWorkspaceFolders', {}); + const entryCount = Object.keys(data).length; + + // Should have pruned entries + expect(entryCount).toBeLessThan(MAX_ENTRIES); + }); + + it('should keep newer entries and remove older ones', async () => { + const MAX_ENTRIES = 1500; + + // Create old entries with predictable timestamps + const oldData: Record = {}; + for (let i = 0; i < MAX_ENTRIES; i++) { + oldData[`session-old-${i}`] = { + folderPath: vscode.Uri.file(`/old/path/${i}`).fsPath, + timestamp: 1000 + i // Older timestamps + }; + } + await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', oldData); + + // Add a new entry with current timestamp + const now = Date.now(); + const data = extensionContext.globalState.get>('github.copilot.cli.sessionWorkspaceFolders', {}); + (data as any)['session-new'] = { + folderPath: vscode.Uri.file('/new/path').fsPath, + timestamp: now + }; + await extensionContext.globalState.update('github.copilot.cli.sessionWorkspaceFolders', data); + + // Trigger cleanup by adding another entry + await service.trackSessionWorkspaceFolder('session-trigger', vscode.Uri.file('/trigger/path').fsPath); + + const finalData = extensionContext.globalState.get>('github.copilot.cli.sessionWorkspaceFolders', {}); + + // The newest entries should be preserved + expect(finalData['session-new']).toBeDefined(); + }); + }); + + 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 = service.getRecentFolders(); + expect(recent.length).toBe(3); + + await service.deleteRecentFolder(vscode.Uri.file('/path/2')); + + recent = service.getRecentFolders(); + expect(recent.length).toBe(2); + + const folder1 = service.getSessionWorkspaceFolder('session-1'); + const folder3 = 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 = 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 = service.getSessionWorkspaceFolder(sessionId); + expect(result?.fsPath).toBe(folderPath); + + const recent = service.getRecentFolders(); + expect(recent.length).toBe(1); + }); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index c40fdda0e66..9ea20540c1f 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -188,7 +188,6 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { let mcpHandler: ICopilotCLIMCPHandler; let folderRepositoryManager: FolderRepositoryManager; let cliSessionServiceForFolderManager: FakeCopilotCLISessionService; - let fileSystemService: MockFileSystemService; const cliSessions: TestCopilotCLISession[] = []; beforeEach(async () => { @@ -216,7 +215,6 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { git = new FakeGitService(); models = new FakeModels(); cliSessionServiceForFolderManager = new FakeCopilotCLISessionService(); - fileSystemService = new MockFileSystemService(); telemetry = new NullTelemetryService(); tools = new class FakeToolsService extends mock() { }(); workspaceService = new NullWorkspaceService([URI.file('/workspace')]); @@ -267,7 +265,6 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { cliSessionServiceForFolderManager as unknown as ICopilotCLISessionService, git, workspaceService, - fileSystemService, logService ); participant = new CopilotCLIChatSessionParticipant( diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts index 6b963901e5b..104ee3ce8f2 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts @@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as vscode from 'vscode'; -import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService'; import { IGitService, RepoContext } from '../../../../platform/git/common/gitService'; import { ILogService } from '../../../../platform/log/common/logService'; import { NullWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; @@ -20,28 +19,6 @@ import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../. import { IFolderRepositoryManager } from '../../common/folderRepositoryManager'; import { FolderRepositoryManager } from '../folderRepositoryManagerImpl'; -/** - * Mock file system service that tracks which paths exist. - */ -class MockTestFileSystemService extends mock() { - private _existingPaths = new Set(); - - override async stat(resource: vscode.Uri): Promise { - if (this._existingPaths.has(resource.fsPath)) { - return { type: vscode.FileType.Directory, ctime: 0, mtime: 0, size: 0 }; - } - throw new Error('File not found'); - } - - addExistingPath(path: string): void { - this._existingPaths.add(path); - } - - clearPaths(): void { - this._existingPaths.clear(); - } -} - /** * Fake implementation of IChatSessionWorktreeService for testing. */ @@ -88,6 +65,10 @@ class FakeChatSessionWorkspaceFolderService extends mock => { + this._recentFolders = this._recentFolders.filter(entry => entry.folder.fsPath !== folder.fsPath); + }); + override getSessionWorkspaceFolder = vi.fn((sessionId: string): vscode.Uri | undefined => { return this._sessionWorkspaceFolders.get(sessionId); }); @@ -221,7 +202,7 @@ export class FakeFolderRepositoryManager extends mock( }; }); - override getFolderMRU = vi.fn(async () => { + override getFolderMRU = vi.fn(() => { return []; }); @@ -252,7 +233,6 @@ describe('FolderRepositoryManager', () => { let sessionService: FakeCopilotCLISessionService; let gitService: FakeGitService; let workspaceService: MockWorkspaceService; - let fileSystemService: MockTestFileSystemService; let logService: ILogService; beforeEach(() => { @@ -261,7 +241,6 @@ describe('FolderRepositoryManager', () => { sessionService = new FakeCopilotCLISessionService(); gitService = new FakeGitService(); workspaceService = new MockWorkspaceService([URI.file('/workspace')]); - fileSystemService = new MockTestFileSystemService(); logService = new class extends mock() { override trace = vi.fn(); override info = vi.fn(); @@ -275,7 +254,6 @@ describe('FolderRepositoryManager', () => { sessionService, gitService, workspaceService, - fileSystemService, logService ); }); @@ -527,7 +505,6 @@ describe('FolderRepositoryManager', () => { sessionService, gitService, workspaceService, - fileSystemService, logService ); @@ -549,12 +526,7 @@ describe('FolderRepositoryManager', () => { { folder: vscode.Uri.file('/folder1'), lastAccessTime: 1500 } ]); - // Mock file existence check - fileSystemService.addExistingPath(vscode.Uri.file('/repo1').fsPath); - fileSystemService.addExistingPath(vscode.Uri.file('/repo2').fsPath); - fileSystemService.addExistingPath(vscode.Uri.file('/folder1').fsPath); - - const result = await manager.getFolderMRU(); + const result = manager.getFolderMRU(); // Should have items from both sources expect(result.length).toBeGreaterThan(0); @@ -569,9 +541,7 @@ describe('FolderRepositoryManager', () => { { folder: duplicateUri, lastAccessTime: 2000 } ]); - fileSystemService.addExistingPath(vscode.Uri.file('/same/path').fsPath); - - const result = await manager.getFolderMRU(); + const result = manager.getFolderMRU(); // Should only have one entry for the duplicate path const paths = result.map(r => r.folder.fsPath); @@ -586,29 +556,12 @@ describe('FolderRepositoryManager', () => { { rootUri: vscode.Uri.file('/middle'), lastAccessTime: 2000 } ]); - fileSystemService.addExistingPath(vscode.Uri.file('/old').fsPath); - fileSystemService.addExistingPath(vscode.Uri.file('/new').fsPath); - fileSystemService.addExistingPath(vscode.Uri.file('/middle').fsPath); - - const result = await manager.getFolderMRU(); + const result = 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); }); - - it('limits to 10 items', async () => { - const repos = []; - for (let i = 0; i < 15; i++) { - repos.push({ rootUri: vscode.Uri.file(`/repo${i}`), lastAccessTime: i * 100 }); - fileSystemService.addExistingPath(vscode.Uri.file(`/repo${i}`).fsPath); - } - gitService.setTestRecentRepositories(repos); - - const result = await manager.getFolderMRU(); - - expect(result.length).toBeLessThanOrEqual(10); - }); }); describe('deleteUntitledSessionFolder', () => { @@ -625,6 +578,102 @@ describe('FolderRepositoryManager', () => { }); }); + describe('deleteMRUEntry', () => { + it('removes entry from untitled session folders', async () => { + const sessionId = 'untitled:test-123'; + const folderUri = vscode.Uri.file('/my/folder'); + + manager.setUntitledSessionFolder(sessionId, folderUri); + expect(manager.getUntitledSessionFolder(sessionId)).toBeDefined(); + + await manager.deleteMRUEntry(folderUri); + + expect(manager.getUntitledSessionFolder(sessionId)).toBeUndefined(); + }); + + 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 = manager.getFolderMRU(); + expect(result.length).toBeGreaterThan(0); + + await manager.deleteMRUEntry(folderUri); + + // Verify deleteRecentFolder was called on workspace folder service + expect((workspaceFolderService.deleteRecentFolder as any).mock.calls.length).toBe(1); + }); + + it('handles URI equality comparison', async () => { + const folderPath = '/my/folder'; + const sessionId = 'untitled:test-456'; + + manager.setUntitledSessionFolder(sessionId, vscode.Uri.file(folderPath)); + + // Delete using a different URI instance with same path + await manager.deleteMRUEntry(vscode.Uri.file(folderPath)); + + expect(manager.getUntitledSessionFolder(sessionId)).toBeUndefined(); + }); + + it('removes all matching entries', async () => { + const folderUri = vscode.Uri.file('/duplicate/folder'); + const session1 = 'untitled:dup-1'; + const session2 = 'untitled:dup-2'; + + manager.setUntitledSessionFolder(session1, folderUri); + manager.setUntitledSessionFolder(session2, folderUri); + + await manager.deleteMRUEntry(folderUri); + + expect(manager.getUntitledSessionFolder(session1)).toBeUndefined(); + expect(manager.getUntitledSessionFolder(session2)).toBeUndefined(); + }); + + it('does not affect other folders when deleting one', async () => { + const folder1 = vscode.Uri.file('/folder/1'); + const folder2 = vscode.Uri.file('/folder/2'); + const session1 = 'untitled:test-1'; + const session2 = 'untitled:test-2'; + + manager.setUntitledSessionFolder(session1, folder1); + manager.setUntitledSessionFolder(session2, folder2); + + await manager.deleteMRUEntry(folder1); + + expect(manager.getUntitledSessionFolder(session1)).toBeUndefined(); + expect(manager.getUntitledSessionFolder(session2)).toBeDefined(); + }); + + 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.setUntitledSessionFolder('untitled:1', folderUri); + manager.setUntitledSessionFolder('untitled:2', folderUri); + + let mru = manager.getFolderMRU(); + const beforeCount = mru.filter(entry => entry.folder.fsPath === folderUri.fsPath).length; + + await manager.deleteMRUEntry(folderUri); + + mru = manager.getFolderMRU(); + const afterCount = mru.filter(entry => entry.folder.fsPath === folderUri.fsPath).length; + + expect(afterCount).toBeLessThan(beforeCount); + }); + }); + describe('edge cases', () => { it('handles empty workspace scenarios', async () => { // Create manager with no workspace folders @@ -635,7 +684,6 @@ describe('FolderRepositoryManager', () => { sessionService, gitService, workspaceService, - fileSystemService, logService );