Support background session folder picker with validation of existence of folder (#3426)

* Support background session folder picker with validation of existence of folder

* Fix windows tests

* Fix windows tests

* Fix paths
This commit is contained in:
Don Jayamanne
2026-02-04 15:04:54 +11:00
committed by GitHub
parent bf7bcce698
commit 014e348a40
9 changed files with 859 additions and 226 deletions
@@ -16,6 +16,10 @@ export const IChatSessionWorkspaceFolderService = createServiceIdentifier<IChatS
export interface IChatSessionWorkspaceFolderService {
readonly _serviceBrand: undefined;
getRecentFolders(): { folder: vscode.Uri; lastAccessTime: number }[];
/**
* Delete a recent folder from all tracked sessions.
*/
deleteRecentFolder(folder: vscode.Uri): Promise<void>;
deleteTrackedWorkspaceFolder(sessionId: string): Promise<void>;
/**
* Track workspace folder selection for a session (for folders without git repos in multi-root workspaces)
@@ -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>('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<FolderRepositoryMRUEntry[]>;
getFolderMRU(): FolderRepositoryMRUEntry[];
/**
* Delete an entry from the MRU list.
*/
deleteMRUEntry(folder: vscode.Uri): Promise<void>;
/**
* Get the last used folder ID in untitled workspace.
@@ -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<string, WorkspaceFolderEntry>();
constructor(
@ILogService private readonly logService: ILogService,
@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext
) {
super();
this.loadWorkspaceFolders();
}
private loadWorkspaceFolders(): void {
const data = this.extensionContext.globalState.get<Record<string, WorkspaceFolderEntry>>(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<void> {
const data = this.extensionContext.globalState.get<Record<string, WorkspaceFolderEntry>>(CHAT_SESSION_WORKSPACE_FOLDER_MEMENTO_KEY, {});
const newData: Record<string, WorkspaceFolderEntry> = {};
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<void> {
const data = this.extensionContext.globalState.get<Record<string, WorkspaceFolderEntry>>(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<Record<string, WorkspaceFolderEntry>>(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<void> {
this._sessionWorkspaceFolders.delete(sessionId);
const data = this.extensionContext.globalState.get<Record<string, WorkspaceFolderEntry>>(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<void> {
const data = this.extensionContext.globalState.get<Record<string, WorkspaceFolderEntry>>(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;
}
}
@@ -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() {
@@ -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<vscode.ChatSession> {
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<ChatSessionProviderOptionItem[]> | undefined;
/**
* Get repository option items for untitled workspaces using last used repositories.
*/
private async getRepositoryOptionItemsForUntitledWorkspace(): Promise<ChatSessionProviderOptionItem[]> {
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<ChatSessionProviderOptionItem[]> {
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<vscode.ChatSessionOptionUpdate>, token: vscode.CancellationToken): Promise<void> {
@@ -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<Uri | undefined>();
const disposables = new DisposableStore();
const quickPick = disposables.add(vscode.window.createQuickPick<RecentFolderQuickPickItem>());
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<boolean> {
try {
const stat = await fileSystemService.stat(filePath);
return stat.type === vscode.FileType.Directory;
} catch {
return false;
}
}
@@ -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<FolderRepositoryMRUEntry[]> {
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<void> {
// 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<boolean> {
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.
*/
@@ -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<string, unknown>();
get<T>(key: string, defaultValue?: T): T {
const value = this.data.get(key);
return (value ?? defaultValue) as T;
}
async update(key: string, value: unknown): Promise<void> {
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<IVSCodeExtensionContext>() {
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<ILogService>() {
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<Record<string, unknown>>('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<Record<string, unknown>>('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<string, unknown> = {};
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<Record<string, unknown>>('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<Record<string, unknown>>('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<Record<string, unknown>>('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<string, any> = {
'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<Record<string, unknown>>('github.copilot.cli.sessionWorkspaceFolders', {});
expect(Object.keys(beforeDelete)).toContain(sessionId);
await service.deleteRecentFolder(vscode.Uri.file(folderPath));
const afterDelete = extensionContext.globalState.get<Record<string, unknown>>('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<string, unknown> = {};
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<Record<string, unknown>>('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<string, unknown> = {};
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<Record<string, unknown>>('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<Record<string, unknown>>('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);
});
});
});
@@ -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<IToolsService>() { }();
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(
@@ -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<IFileSystemService>() {
private _existingPaths = new Set<string>();
override async stat(resource: vscode.Uri): Promise<vscode.FileStat> {
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<IChatSessionWorkspaceFo
this._sessionWorkspaceFolders.delete(sessionId);
});
override deleteRecentFolder = vi.fn(async (folder: vscode.Uri): Promise<void> => {
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<IFolderRepositoryManager>(
};
});
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<ILogService>() {
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
);