mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-24 17:31:37 +01:00
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:
+4
@@ -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.
|
||||
|
||||
+21
-65
@@ -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() {
|
||||
|
||||
+181
-59
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+18
-40
@@ -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.
|
||||
*/
|
||||
|
||||
+515
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
-3
@@ -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(
|
||||
|
||||
+104
-56
@@ -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
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user