mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-17 23:35:54 +01:00
support for chat customizations in parent repo folders (#300916)
* support for parent repo folders for all chat customizations * update
This commit is contained in:
committed by
GitHub
parent
3ce494989d
commit
bb5835fb94
@@ -23,6 +23,7 @@ import { IPathService } from '../../../../workbench/services/path/common/pathSer
|
||||
import { ISearchService } from '../../../../workbench/services/search/common/search.js';
|
||||
import { IUserDataProfileService } from '../../../../workbench/services/userDataProfile/common/userDataProfile.js';
|
||||
import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
|
||||
import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js';
|
||||
|
||||
/** URI root for built-in prompts bundled with the Sessions app. */
|
||||
export const BUILTIN_PROMPTS_URI = FileAccess.asFileUri('vs/sessions/prompts');
|
||||
@@ -144,6 +145,7 @@ class AgenticPromptFilesLocator extends PromptFilesLocator {
|
||||
@IUserDataProfileService userDataService: IUserDataProfileService,
|
||||
@ILogService logService: ILogService,
|
||||
@IPathService pathService: IPathService,
|
||||
@IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService,
|
||||
@IAICustomizationWorkspaceService private readonly customizationWorkspaceService: IAICustomizationWorkspaceService,
|
||||
) {
|
||||
super(
|
||||
@@ -154,7 +156,8 @@ class AgenticPromptFilesLocator extends PromptFilesLocator {
|
||||
searchService,
|
||||
userDataService,
|
||||
logService,
|
||||
pathService
|
||||
pathService,
|
||||
workspaceTrustManagementService
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -995,10 +995,10 @@ configurationRegistry.registerConfiguration({
|
||||
disallowConfigurationDefault: true,
|
||||
tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions']
|
||||
},
|
||||
[PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS]: {
|
||||
[PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS]: {
|
||||
type: 'boolean',
|
||||
title: nls.localize('chat.searchRootRepositoryCustomizations.title', "Search Root Repository Customizations",),
|
||||
markdownDescription: nls.localize('chat.searchRootRepositoryCustomizations.description', "Controls whether configuration files should be searched in parent folders of the workspace folder if the parent folder are repositories.",),
|
||||
title: nls.localize('chat.useCustomizationsInParentRepos.title', "Use Customizations in Parent Repositories",),
|
||||
markdownDescription: nls.localize('chat.useCustomizationsInParentRepos.description', "Controls whether to use chat customization files in parent repositories.",),
|
||||
default: false,
|
||||
restricted: true,
|
||||
disallowConfigurationDefault: true,
|
||||
|
||||
@@ -136,9 +136,9 @@ export namespace PromptsConfig {
|
||||
export const INCLUDE_REFERENCED_INSTRUCTIONS = 'chat.includeReferencedInstructions';
|
||||
|
||||
/**
|
||||
* Search for configuration files in parent folders of the workspace folder
|
||||
* Search for configuration files in parent repositories of the workspace folder
|
||||
*/
|
||||
export const SEARCH_ROOT_REPO_CUSTOMIZATIONS = 'chat.searchRootRepositoryCustomizations';
|
||||
export const USE_CUSTOMIZATIONS_IN_PARENT_REPOS = 'chat.useCustomizationsInParentRepositories';
|
||||
|
||||
/**
|
||||
* Get value of the `reusable prompt locations` configuration setting.
|
||||
|
||||
@@ -138,6 +138,8 @@ export interface IPromptSourceFolder {
|
||||
*/
|
||||
export interface IResolvedPromptSourceFolder {
|
||||
readonly uri: URI;
|
||||
readonly parent: URI; // matches the URI when no glob pattern is used
|
||||
readonly filePattern: string | undefined; // the part of the path with the glob pattern, or undefined if no glob pattern is used
|
||||
readonly source: PromptFileSource;
|
||||
readonly storage: PromptsStorage;
|
||||
/**
|
||||
|
||||
@@ -906,8 +906,8 @@ export class PromptsService extends Disposable implements IPromptsService {
|
||||
const resolvedAgentFiles: IResolvedAgentFile[] = [];
|
||||
const promises: Promise<IResolvedAgentFile[]>[] = [];
|
||||
|
||||
const includeParents = this.configurationService.getValue(PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS) === true;
|
||||
const rootFolders = await this.fileLocator.getWorkspaceFolderRoots(includeParents);
|
||||
const includeParents = this.configurationService.getValue(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS) === true;
|
||||
const rootFolders = await this.fileLocator.getWorkspaceFolderRoots(includeParents, logger);
|
||||
|
||||
const rootFiles: IWorkspaceInstructionFile[] = [];
|
||||
const useAgentMD = this.configurationService.getValue(PromptsConfig.USE_AGENT_MD);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ResourceSet } from '../../../../../../base/common/map.js';
|
||||
import * as nls from '../../../../../../nls.js';
|
||||
import { FileOperationError, FileOperationResult, IFileService } from '../../../../../../platform/files/common/files.js';
|
||||
import { getPromptFileLocationsConfigKey, isTildePath, PromptsConfig } from '../config/config.js';
|
||||
import { basename, dirname, isEqual, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js';
|
||||
import { basename, dirname, isEqual, isEqualOrParent, joinPath, extname } from '../../../../../../base/common/resources.js';
|
||||
import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js';
|
||||
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
|
||||
import { AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js';
|
||||
@@ -17,15 +17,16 @@ import { PromptsType } from '../promptTypes.js';
|
||||
import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js';
|
||||
import { Schemas } from '../../../../../../base/common/network.js';
|
||||
import { getExcludes, IFileQuery, ISearchConfiguration, ISearchService, QueryType } from '../../../../../services/search/common/search.js';
|
||||
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
|
||||
import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js';
|
||||
import { isCancellationError } from '../../../../../../base/common/errors.js';
|
||||
import { AgentFileType, IResolvedAgentFile, PromptsStorage } from '../service/promptsService.js';
|
||||
import { AgentFileType, IResolvedAgentFile, Logger, PromptsStorage } from '../service/promptsService.js';
|
||||
import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js';
|
||||
import { Emitter, Event } from '../../../../../../base/common/event.js';
|
||||
import { DisposableStore } from '../../../../../../base/common/lifecycle.js';
|
||||
import { ILogService } from '../../../../../../platform/log/common/log.js';
|
||||
import { IPathService } from '../../../../../services/path/common/pathService.js';
|
||||
import { equalsIgnoreCase } from '../../../../../../base/common/strings.js';
|
||||
import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js';
|
||||
|
||||
/**
|
||||
* Maximum recursion depth when traversing subdirectories for instruction files.
|
||||
@@ -42,6 +43,8 @@ export interface IWorkspaceInstructionFile {
|
||||
*/
|
||||
export class PromptFilesLocator {
|
||||
|
||||
private readonly userDataFolder: IResolvedPromptSourceFolder;
|
||||
|
||||
constructor(
|
||||
@IFileService private readonly fileService: IFileService,
|
||||
@IConfigurationService private readonly configService: IConfigurationService,
|
||||
@@ -51,7 +54,19 @@ export class PromptFilesLocator {
|
||||
@IUserDataProfileService private readonly userDataService: IUserDataProfileService,
|
||||
@ILogService private readonly logService: ILogService,
|
||||
@IPathService private readonly pathService: IPathService,
|
||||
@IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService,
|
||||
) {
|
||||
|
||||
const userDataPromptsHome = this.userDataService.currentProfile.promptsHome;
|
||||
this.userDataFolder = {
|
||||
uri: userDataPromptsHome,
|
||||
parent: userDataPromptsHome,
|
||||
filePattern: undefined,
|
||||
source: PromptFileSource.CopilotPersonal,
|
||||
storage: PromptsStorage.user,
|
||||
displayPath: nls.localize('promptsUserDataFolder', "User Data"),
|
||||
isDefault: true
|
||||
};
|
||||
}
|
||||
|
||||
protected getWorkspaceFolders(): readonly IWorkspaceFolder[] {
|
||||
@@ -66,26 +81,85 @@ export class PromptFilesLocator {
|
||||
return Event.map(this.workspaceService.onDidChangeWorkspaceFolders, () => undefined);
|
||||
}
|
||||
|
||||
public async getWorkspaceFolderRoots(includeParents: boolean, logger?: Logger): Promise<URI[]> {
|
||||
const workspaceFolders = this.getWorkspaceFolders();
|
||||
if (includeParents) {
|
||||
const roots = new ResourceSet();
|
||||
const userHome = await this.pathService.userHome();
|
||||
for (const workspaceFolder of workspaceFolders) {
|
||||
roots.add(workspaceFolder.uri);
|
||||
// Walk up from the workspace folder to find the repository root
|
||||
// (.git folder). Only include parent folders if a repo root is
|
||||
// actually found; otherwise keep only the workspace folder.
|
||||
const parents = await this.findParentRepoFolders(workspaceFolder.uri, userHome, roots, logger);
|
||||
for (const parent of parents) {
|
||||
roots.add(parent);
|
||||
}
|
||||
}
|
||||
return [...roots];
|
||||
}
|
||||
return workspaceFolders.map(f => f.uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks up from {@link folderUri} collecting parent folders until a
|
||||
* repository root (a folder containing `.git`) is found. Returns the
|
||||
* intermediate parent folders only when a repo root is found; returns
|
||||
* an empty array when the walk reaches the filesystem root, the user
|
||||
* home directory, or a folder already present in {@link seen}.
|
||||
*/
|
||||
private async findParentRepoFolders(folderUri: URI, userHome: URI, seen: ResourceSet, logger?: Logger): Promise<URI[]> {
|
||||
const candidates: URI[] = [];
|
||||
let current = folderUri;
|
||||
let parent = dirname(current);
|
||||
do {
|
||||
try {
|
||||
const isRepoRoot = await this.fileService.exists(joinPath(current, '.git'));
|
||||
if (isRepoRoot) {
|
||||
if ((await this.workspaceTrustManagementService.getUriTrustInfo(current)).trusted) {
|
||||
candidates.push(current);
|
||||
return candidates;
|
||||
}
|
||||
logger?.logInfo(`Repository root found at ${current.toString()}, but it is not trusted. Skipping parent folder inclusion for this workspace folder.`);
|
||||
return []; // if the repo root isn't trusted, don't include it or any parents
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger?.logInfo(`No repository root found for folder ${folderUri.toString()}. Error accessing ${joinPath(current, '.git')}: ${msg}.`);
|
||||
return []; // if we can't access the folder, return an empty list to avoid treating it as a non-repository when we might just have a permission issue
|
||||
}
|
||||
candidates.push(current);
|
||||
current = parent;
|
||||
parent = dirname(current);
|
||||
} while (!seen.has(current) && current.path !== '/' && !isEqual(userHome, current));
|
||||
// no repo found
|
||||
logger?.logInfo(`No repository root found for folder ${folderUri.toString()}.`);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* List all prompt files from the filesystem.
|
||||
*
|
||||
* @returns List of prompt files found in the workspace.
|
||||
*/
|
||||
public async listFiles(type: PromptsType, storage: PromptsStorage, token: CancellationToken): Promise<readonly URI[]> {
|
||||
if (storage === PromptsStorage.local) {
|
||||
return await this.listFilesInLocal(type, token);
|
||||
} else if (storage === PromptsStorage.user) {
|
||||
return await this.listFilesInUserData(type, token);
|
||||
if (storage !== PromptsStorage.user && storage !== PromptsStorage.local) {
|
||||
throw new Error(`Unsupported prompt file storage: ${storage}`);
|
||||
}
|
||||
|
||||
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type);
|
||||
const absoluteLocations = await this.toAbsoluteLocations(type, configuredLocations.filter(loc => loc.storage === storage));
|
||||
|
||||
if (storage === PromptsStorage.user && (type === PromptsType.agent || type === PromptsType.instructions || type === PromptsType.prompt)) {
|
||||
absoluteLocations.push(this.userDataFolder);
|
||||
}
|
||||
throw new Error(`Unsupported prompt file storage: ${storage}`);
|
||||
}
|
||||
|
||||
private async listFilesInUserData(type: PromptsType, token: CancellationToken): Promise<readonly URI[]> {
|
||||
const userStorageFolders = await this.getUserStorageFolders(type);
|
||||
const paths = new ResourceSet();
|
||||
|
||||
for (const { uri } of userStorageFolders) {
|
||||
const files = await this.resolveFilesAtLocation(uri, type, token);
|
||||
for (const { parent, filePattern } of absoluteLocations) {
|
||||
const files = (filePattern === undefined)
|
||||
? await this.resolveFilesAtLocation(parent, type, token) // if the location does not contain a glob pattern, resolve the location directly
|
||||
: await this.searchFilesInLocation(parent, filePattern, token);
|
||||
for (const file of files) {
|
||||
if (getPromptFileType(file) === type) {
|
||||
paths.add(file);
|
||||
@@ -95,123 +169,57 @@ export class PromptFilesLocator {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all user storage folders for the given prompt type.
|
||||
* This includes configured tilde paths and the VS Code user data prompts folder.
|
||||
*/
|
||||
private async getUserStorageFolders(type: PromptsType): Promise<readonly IResolvedPromptSourceFolder[]> {
|
||||
const userHome = await this.pathService.userHome();
|
||||
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type);
|
||||
const absoluteLocations = this.toAbsoluteLocations(type, configuredLocations, userHome);
|
||||
|
||||
// Filter to only user storage locations
|
||||
const result = absoluteLocations.filter(loc => loc.storage === PromptsStorage.user);
|
||||
|
||||
// Also include the VS Code user data prompts folder for certain types
|
||||
if (type === PromptsType.agent || type === PromptsType.instructions || type === PromptsType.prompt) {
|
||||
const userDataPromptsHome = this.userDataService.currentProfile.promptsHome;
|
||||
return [
|
||||
...result,
|
||||
{
|
||||
uri: userDataPromptsHome,
|
||||
source: PromptFileSource.CopilotPersonal,
|
||||
storage: PromptsStorage.user,
|
||||
displayPath: nls.localize('promptsUserDataFolder', "User Data"),
|
||||
isDefault: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all source folder URIs for a prompt type (both workspace and user home).
|
||||
* This is used for file watching to detect changes in all relevant locations.
|
||||
*/
|
||||
private getSourceFoldersSync(type: PromptsType, userHome: URI): readonly URI[] {
|
||||
const result: URI[] = [];
|
||||
const folders = this.getWorkspaceFolders();
|
||||
const defaultFileOrFolders = getPromptFileDefaultLocations(type);
|
||||
|
||||
const getFolderUri = (type: PromptsType, fileOrFolderPath: URI): URI => {
|
||||
// For hooks, the paths are sometimes file paths, so get the parent directory in that case
|
||||
if (type === PromptsType.hook && fileOrFolderPath.path.toLowerCase().endsWith('.json')) {
|
||||
return dirname(fileOrFolderPath);
|
||||
}
|
||||
return fileOrFolderPath;
|
||||
};
|
||||
|
||||
for (const sourceFileOrFolder of defaultFileOrFolders) {
|
||||
let fileOrFolderPath: URI;
|
||||
if (sourceFileOrFolder.storage === PromptsStorage.local) {
|
||||
for (const workspaceFolder of folders) {
|
||||
fileOrFolderPath = joinPath(workspaceFolder.uri, sourceFileOrFolder.path);
|
||||
result.push(getFolderUri(type, fileOrFolderPath));
|
||||
}
|
||||
} else if (sourceFileOrFolder.storage === PromptsStorage.user) {
|
||||
// For tilde paths, strip the ~/ prefix before joining with userHome
|
||||
const relativePath = isTildePath(sourceFileOrFolder.path) ? sourceFileOrFolder.path.substring(2) : sourceFileOrFolder.path;
|
||||
fileOrFolderPath = joinPath(userHome, relativePath);
|
||||
result.push(getFolderUri(type, fileOrFolderPath));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public createFilesUpdatedEvent(type: PromptsType): { readonly event: Event<void>; dispose: () => void } {
|
||||
const disposables = new DisposableStore();
|
||||
const eventEmitter = disposables.add(new Emitter<void>());
|
||||
|
||||
const userDataFolder = this.userDataService.currentProfile.promptsHome;
|
||||
|
||||
const key = getPromptFileLocationsConfigKey(type);
|
||||
let parentFolders = this.getLocalParentFolders(type);
|
||||
let allSourceFolders: URI[] = [];
|
||||
const token = disposables.add(new CancellationTokenSource()).token; // track the disposal of the event listeners so we can cancel any in-flight async operations when the event is disposed
|
||||
|
||||
const externalFolderWatchers = disposables.add(new DisposableStore());
|
||||
const key = getPromptFileLocationsConfigKey(type);
|
||||
const userDataFolder = this.userDataService.currentProfile.promptsHome;
|
||||
|
||||
let parentFolders: readonly IResolvedPromptSourceFolder[] = [];
|
||||
|
||||
const updateExternalFolderWatchers = () => {
|
||||
externalFolderWatchers.clear();
|
||||
for (const folder of parentFolders) {
|
||||
if (!this.getWorkspaceFolder(folder.parent)) {
|
||||
// if the folder is not part of the workspace, we need to watch it
|
||||
const recursive = folder.filePattern !== undefined;
|
||||
const recursive = folder.filePattern !== undefined || type === PromptsType.instructions; // instructions can be in subfolders, so watch recursively
|
||||
externalFolderWatchers.add(this.fileService.watch(folder.parent, { recursive, excludes: [] }));
|
||||
}
|
||||
}
|
||||
// Watch all source folders (including user home if applicable)
|
||||
for (const folder of allSourceFolders) {
|
||||
if (!this.getWorkspaceFolder(folder)) {
|
||||
externalFolderWatchers.add(this.fileService.watch(folder, { recursive: true, excludes: [] }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize source folders (async if type has userHome locations)
|
||||
this.pathService.userHome().then(userHome => {
|
||||
allSourceFolders = [...this.getSourceFoldersSync(type, userHome)];
|
||||
updateExternalFolderWatchers();
|
||||
});
|
||||
const update = async (emitEvent: boolean) => {
|
||||
try {
|
||||
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type);
|
||||
parentFolders = await this.toAbsoluteLocations(type, configuredLocations, undefined);
|
||||
|
||||
if (token.isCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
updateExternalFolderWatchers();
|
||||
if (emitEvent) {
|
||||
eventEmitter.fire();
|
||||
}
|
||||
} catch (err) {
|
||||
this.logService.error(`Error updating prompt file watchers after config change:`, err);
|
||||
}
|
||||
};
|
||||
disposables.add(this.configService.onDidChangeConfiguration(e => {
|
||||
if (e.affectsConfiguration(key)) {
|
||||
parentFolders = this.getLocalParentFolders(type);
|
||||
updateExternalFolderWatchers();
|
||||
eventEmitter.fire();
|
||||
void update(true);
|
||||
}
|
||||
}));
|
||||
disposables.add(this.onDidChangeWorkspaceFolders()(() => {
|
||||
parentFolders = this.getLocalParentFolders(type);
|
||||
this.pathService.userHome().then(userHome => {
|
||||
allSourceFolders = [...this.getSourceFoldersSync(type, userHome)];
|
||||
updateExternalFolderWatchers();
|
||||
});
|
||||
eventEmitter.fire();
|
||||
void update(true);
|
||||
}));
|
||||
disposables.add(this.workspaceTrustManagementService.onDidChangeTrustedFolders(() => {
|
||||
void update(true);
|
||||
}));
|
||||
disposables.add(this.fileService.onDidFilesChange(e => {
|
||||
if (e.affects(userDataFolder)) {
|
||||
@@ -222,13 +230,11 @@ export class PromptFilesLocator {
|
||||
eventEmitter.fire();
|
||||
return;
|
||||
}
|
||||
if (allSourceFolders.some(folder => e.affects(folder))) {
|
||||
eventEmitter.fire();
|
||||
return;
|
||||
}
|
||||
}));
|
||||
disposables.add(this.fileService.watch(userDataFolder));
|
||||
|
||||
void update(false);
|
||||
|
||||
return { event: eventEmitter.event, dispose: () => disposables.dispose() };
|
||||
}
|
||||
|
||||
@@ -237,7 +243,6 @@ export class PromptFilesLocator {
|
||||
* Returns folders from config, excluding user storage and Claude paths (which are read-only).
|
||||
*/
|
||||
public async getHookSourceFolders(): Promise<readonly URI[]> {
|
||||
const userHome = await this.pathService.userHome();
|
||||
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, PromptsType.hook);
|
||||
|
||||
// Ignore claude folders since they aren't first-class supported, so we don't want to create invalid formats
|
||||
@@ -248,19 +253,13 @@ export class PromptFilesLocator {
|
||||
|
||||
// Convert to absolute URIs
|
||||
const result = new ResourceSet();
|
||||
const absoluteLocations = this.toAbsoluteLocations(PromptsType.hook, allowedHookFolders, userHome);
|
||||
const absoluteLocations = await this.toAbsoluteLocations(PromptsType.hook, allowedHookFolders);
|
||||
|
||||
for (const location of absoluteLocations) {
|
||||
// For hook configs, entries are directories unless the path ends with .json (specific file)
|
||||
// Default entries have filePattern, user entries don't but are still directories
|
||||
const isSpecificFile = location.uri.path.endsWith('.json');
|
||||
if (isSpecificFile) {
|
||||
// It's a specific file path (like .github/hooks/hooks.json), use parent directory
|
||||
result.add(dirname(location.uri));
|
||||
} else {
|
||||
// It's a directory path (like .github/hooks or .github/books)
|
||||
result.add(location.uri);
|
||||
}
|
||||
// location.parent points to the directory in both cases, so we can just use that
|
||||
result.add(location.parent);
|
||||
}
|
||||
|
||||
return [...result];
|
||||
@@ -279,28 +278,28 @@ export class PromptFilesLocator {
|
||||
* @returns List of possible unambiguous prompt file folders.
|
||||
*/
|
||||
public async getConfigBasedSourceFolders(type: PromptsType): Promise<readonly URI[]> {
|
||||
const userHome = await this.pathService.userHome();
|
||||
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type);
|
||||
const absoluteLocations = this.toAbsoluteLocations(type, configuredLocations, userHome).map(l => l.uri);
|
||||
const absoluteLocations = await this.toAbsoluteLocations(type, configuredLocations);
|
||||
|
||||
// For anything that doesn't support glob patterns, we can return
|
||||
if (type !== PromptsType.prompt && type !== PromptsType.instructions) {
|
||||
return absoluteLocations;
|
||||
return absoluteLocations.map(l => l.uri);
|
||||
}
|
||||
|
||||
// locations in the settings can contain glob patterns so we need
|
||||
// to process them to get "clean" paths; the goal here is to have
|
||||
// a list of unambiguous folder paths where prompt files are stored
|
||||
const result = new ResourceSet();
|
||||
for (let absoluteLocation of absoluteLocations) {
|
||||
const baseName = basename(absoluteLocation);
|
||||
for (const absoluteLocation of absoluteLocations) {
|
||||
let location = absoluteLocation.uri;
|
||||
const baseName = basename(location);
|
||||
|
||||
// if a path ends with a well-known "any file" pattern, remove
|
||||
// it so we can get the dirname path of that setting value
|
||||
const filePatterns = ['*.md', `*${getPromptFileExtension(type)}`];
|
||||
for (const filePattern of filePatterns) {
|
||||
if (baseName === filePattern) {
|
||||
absoluteLocation = dirname(absoluteLocation);
|
||||
location = dirname(location);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -308,16 +307,16 @@ export class PromptFilesLocator {
|
||||
// likewise, if the pattern ends with single `*` (any file name)
|
||||
// remove it to get the dirname path of the setting value
|
||||
if (baseName === '*') {
|
||||
absoluteLocation = dirname(absoluteLocation);
|
||||
location = dirname(location);
|
||||
}
|
||||
|
||||
// if after replacing the "file name" glob pattern, the path
|
||||
// still contains a glob pattern, then ignore the path
|
||||
if (isValidGlob(absoluteLocation.path) === true) {
|
||||
if (isValidGlob(location.path) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.add(absoluteLocation);
|
||||
result.add(location);
|
||||
}
|
||||
|
||||
return [...result];
|
||||
@@ -335,8 +334,10 @@ export class PromptFilesLocator {
|
||||
* @returns List of resolved source folders with metadata.
|
||||
*/
|
||||
public async getResolvedSourceFolders(type: PromptsType): Promise<readonly IResolvedPromptSourceFolder[]> {
|
||||
const localFolders = await this.getLocalStorageFolders(type);
|
||||
const userFolders = await this.getUserStorageFolders(type);
|
||||
const absoluteLocations = await this.getLocalStorageFolders(type);
|
||||
|
||||
const localFolders = absoluteLocations.filter(loc => loc.storage === PromptsStorage.local);
|
||||
const userFolders = absoluteLocations.filter(loc => loc.storage === PromptsStorage.user);
|
||||
return this.dedupeSourceFolders([...localFolders, ...userFolders]);
|
||||
}
|
||||
|
||||
@@ -347,8 +348,9 @@ export class PromptFilesLocator {
|
||||
* for debug/diagnostic output so the displayed order is accurate.
|
||||
*/
|
||||
public async getSourceFoldersInDiscoveryOrder(type: PromptsType): Promise<readonly IResolvedPromptSourceFolder[]> {
|
||||
const userFolders = await this.getUserStorageFolders(type);
|
||||
const localFolders = await this.getLocalStorageFolders(type);
|
||||
const absoluteLocations = await this.getLocalStorageFolders(type);
|
||||
const userFolders = absoluteLocations.filter(loc => loc.storage === PromptsStorage.user);
|
||||
const localFolders = absoluteLocations.filter(loc => loc.storage === PromptsStorage.local);
|
||||
return this.dedupeSourceFolders([...userFolders, ...localFolders]);
|
||||
}
|
||||
|
||||
@@ -357,7 +359,6 @@ export class PromptFilesLocator {
|
||||
* This merges default folders with configured locations.
|
||||
*/
|
||||
private async getLocalStorageFolders(type: PromptsType): Promise<readonly IResolvedPromptSourceFolder[]> {
|
||||
const userHome = await this.pathService.userHome();
|
||||
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type);
|
||||
const defaultFolders = getPromptFileDefaultLocations(type);
|
||||
|
||||
@@ -367,7 +368,11 @@ export class PromptFilesLocator {
|
||||
...configuredLocations.filter(loc => !defaultFolders.some(df => df.path === loc.path))
|
||||
];
|
||||
|
||||
return this.toAbsoluteLocations(type, allFolders, userHome, defaultFolders);
|
||||
const absoluteLocations = await this.toAbsoluteLocations(type, allFolders, defaultFolders);
|
||||
if (type === PromptsType.agent || type === PromptsType.instructions || type === PromptsType.prompt) {
|
||||
absoluteLocations.push(this.userDataFolder);
|
||||
}
|
||||
return absoluteLocations;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -385,39 +390,6 @@ export class PromptFilesLocator {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all existent prompt files in the configured local source folders.
|
||||
*
|
||||
* @returns List of prompt files found in the local source folders.
|
||||
*/
|
||||
private async listFilesInLocal(type: PromptsType, token: CancellationToken): Promise<readonly URI[]> {
|
||||
// find all prompt files in the provided locations, then match
|
||||
// the found file paths against (possible) glob patterns
|
||||
const paths = new ResourceSet();
|
||||
|
||||
for (const { parent, filePattern } of this.getLocalParentFolders(type)) {
|
||||
const files = (filePattern === undefined)
|
||||
? await this.resolveFilesAtLocation(parent, type, token) // if the location does not contain a glob pattern, resolve the location directly
|
||||
: await this.searchFilesInLocation(parent, filePattern, token);
|
||||
for (const file of files) {
|
||||
if (getPromptFileType(file) === type) {
|
||||
paths.add(file);
|
||||
}
|
||||
}
|
||||
if (token.isCancellationRequested) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
private getLocalParentFolders(type: PromptsType): readonly { parent: URI; filePattern?: string }[] {
|
||||
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type);
|
||||
const absoluteLocations = this.toAbsoluteLocations(type, configuredLocations, undefined);
|
||||
return absoluteLocations.map((location) => firstNonGlobParentAndPattern(location.uri));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts locations defined in `settings` to absolute filesystem path URIs with metadata.
|
||||
* This conversion is needed because locations in settings can be relative,
|
||||
@@ -425,10 +397,12 @@ export class PromptFilesLocator {
|
||||
* If userHome is provided, paths starting with `~` will be expanded. Otherwise these paths are ignored.
|
||||
* Preserves the type and location properties from the source folder definitions.
|
||||
*/
|
||||
private toAbsoluteLocations(type: PromptsType, configuredLocations: readonly IPromptSourceFolder[], userHome: URI | undefined, defaultLocations?: readonly IPromptSourceFolder[]): readonly IResolvedPromptSourceFolder[] {
|
||||
private async toAbsoluteLocations(type: PromptsType, configuredLocations: readonly IPromptSourceFolder[], defaultLocations?: readonly IPromptSourceFolder[]): Promise<IResolvedPromptSourceFolder[]> {
|
||||
const result: IResolvedPromptSourceFolder[] = [];
|
||||
const seen = new ResourceSet();
|
||||
const folders = this.getWorkspaceFolders();
|
||||
|
||||
const userHome = await this.pathService.userHome();
|
||||
const rootFolders = await this.getWorkspaceFolderRoots(this.configService.getValue(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS) === true);
|
||||
|
||||
// Create a set of default paths for quick lookup
|
||||
const defaultPaths = new Set(defaultLocations?.map(loc => loc.path));
|
||||
@@ -461,13 +435,11 @@ export class PromptFilesLocator {
|
||||
try {
|
||||
// Handle tilde paths when userHome is provided
|
||||
if (isTildePath(configuredLocation)) {
|
||||
// If userHome is not provided, we cannot resolve tilde paths so we skip this entry
|
||||
if (userHome) {
|
||||
const uri = joinPath(userHome, configuredLocation.substring(2));
|
||||
if (!seen.has(uri)) {
|
||||
seen.add(uri);
|
||||
result.push({ uri, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault });
|
||||
}
|
||||
const uri = joinPath(userHome, configuredLocation.substring(2));
|
||||
if (!seen.has(uri)) {
|
||||
seen.add(uri);
|
||||
const { parent, filePattern } = getParentFolder(type, uri);
|
||||
result.push({ uri, parent, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -482,14 +454,16 @@ export class PromptFilesLocator {
|
||||
}
|
||||
if (!seen.has(uri)) {
|
||||
seen.add(uri);
|
||||
result.push({ uri, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault });
|
||||
const { parent, filePattern } = getParentFolder(type, uri);
|
||||
result.push({ uri, parent, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault });
|
||||
}
|
||||
} else {
|
||||
for (const workspaceFolder of folders) {
|
||||
const absolutePath = joinPath(workspaceFolder.uri, configuredLocation);
|
||||
for (const folder of rootFolders) {
|
||||
const absolutePath = joinPath(folder, configuredLocation);
|
||||
if (!seen.has(absolutePath)) {
|
||||
seen.add(absolutePath);
|
||||
result.push({ uri: absolutePath, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault });
|
||||
const { parent, filePattern } = getParentFolder(type, absolutePath);
|
||||
result.push({ uri: absolutePath, parent, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -671,23 +645,7 @@ export class PromptFilesLocator {
|
||||
return result;
|
||||
}
|
||||
|
||||
public async getWorkspaceFolderRoots(includeParents: boolean): Promise<URI[]> {
|
||||
const workspaceFolders = this.getWorkspaceFolders();
|
||||
if (includeParents) {
|
||||
const folders: URI[] = [];
|
||||
const userHome = await this.pathService.userHome();
|
||||
for (const workspaceFolder of workspaceFolders) {
|
||||
folders.push(workspaceFolder.uri);
|
||||
let parent = dirname(workspaceFolder.uri);
|
||||
while (parent.path !== '/' && !isEqual(userHome, parent) && !folders.some(f => isEqual(f, parent))) {
|
||||
folders.push(parent);
|
||||
parent = dirname(parent);
|
||||
}
|
||||
}
|
||||
return folders;
|
||||
}
|
||||
return workspaceFolders.map(f => f.uri);
|
||||
}
|
||||
|
||||
|
||||
public async findFilesInRoots(roots: URI[], folder: string | undefined, paths: IWorkspaceInstructionFile[], token: CancellationToken, result: IResolvedAgentFile[] = []): Promise<IResolvedAgentFile[]> {
|
||||
const toResolve = roots.map(root => ({ resource: folder !== undefined ? joinPath(root, folder) : root }));
|
||||
@@ -761,9 +719,8 @@ export class PromptFilesLocator {
|
||||
* Searches for skills in all configured locations.
|
||||
*/
|
||||
public async findAgentSkills(token: CancellationToken): Promise<IResolvedPromptFile[]> {
|
||||
const userHome = await this.pathService.userHome();
|
||||
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, PromptsType.skill);
|
||||
const absoluteLocations = this.toAbsoluteLocations(PromptsType.skill, configuredLocations, userHome);
|
||||
const absoluteLocations = await this.toAbsoluteLocations(PromptsType.skill, configuredLocations);
|
||||
const allResults: IResolvedPromptFile[] = [];
|
||||
|
||||
for (const { uri, source, storage } of absoluteLocations) {
|
||||
@@ -861,6 +818,11 @@ export function isValidGlob(pattern: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
interface IParentFolderResult {
|
||||
readonly parent: URI;
|
||||
readonly filePattern?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first parent of the provided location that does not contain a `glob pattern`.
|
||||
*
|
||||
@@ -870,13 +832,21 @@ export function isValidGlob(pattern: string): boolean {
|
||||
*
|
||||
* ```typescript
|
||||
* assert.strictDeepEqual(
|
||||
* firstNonGlobParentAndPattern(URI.file('/home/user/{folder1,folder2}/file.md')).path,
|
||||
* getParentFolder(PromptsType.prompt, URI.file('/home/user/{folder1,folder2}/file.md')),
|
||||
* { parent: URI.file('/home/user'), filePattern: '{folder1,folder2}/file.md' },
|
||||
* 'Must find correct non-glob parent dirname.',
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
function firstNonGlobParentAndPattern(location: URI): { parent: URI; filePattern?: string } {
|
||||
function getParentFolder(type: PromptsType, location: URI): IParentFolderResult {
|
||||
if (type === PromptsType.hook && extname(location) === '.json') {
|
||||
location = dirname(location);
|
||||
}
|
||||
if (type !== PromptsType.instructions && type !== PromptsType.prompt) {
|
||||
// only instructions and prompts support glob patterns, so we can return the location as is
|
||||
return { parent: location };
|
||||
}
|
||||
|
||||
const segments = location.path.split('/');
|
||||
let i = 0;
|
||||
while (i < segments.length && isValidGlob(segments[i]) === false) {
|
||||
|
||||
@@ -60,6 +60,7 @@ suite('ComputeAutomaticInstructions', () => {
|
||||
let fileService: IFileService;
|
||||
let toolsService: ILanguageModelToolsService;
|
||||
let fileSystemProvider: TestInMemoryFileSystemProviderWithRealPath;
|
||||
let workspaceTrustService: TestWorkspaceTrustManagementService;
|
||||
|
||||
setup(async () => {
|
||||
instaService = disposables.add(new TestInstantiationService());
|
||||
@@ -76,7 +77,7 @@ suite('ComputeAutomaticInstructions', () => {
|
||||
testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_REFERENCED_INSTRUCTIONS, true);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS, false);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, false);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.INSTRUCTIONS_LOCATION_KEY, { [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, [CLAUDE_RULES_SOURCE_FOLDER]: true });
|
||||
testConfigService.setUserConfiguration(PromptsConfig.PROMPT_LOCATIONS_KEY, { [PROMPT_DEFAULT_SOURCE_FOLDER]: true });
|
||||
testConfigService.setUserConfiguration(PromptsConfig.MODE_LOCATION_KEY, { [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true });
|
||||
@@ -92,6 +93,9 @@ suite('ComputeAutomaticInstructions', () => {
|
||||
activateByEvent: () => Promise.resolve()
|
||||
});
|
||||
|
||||
workspaceTrustService = disposables.add(new TestWorkspaceTrustManagementService());
|
||||
instaService.stub(IWorkspaceTrustManagementService, workspaceTrustService);
|
||||
|
||||
fileService = disposables.add(instaService.createInstance(FileService));
|
||||
instaService.stub(IFileService, fileService);
|
||||
|
||||
@@ -183,8 +187,6 @@ suite('ComputeAutomaticInstructions', () => {
|
||||
|
||||
instaService.stub(IContextKeyService, new MockContextKeyService());
|
||||
|
||||
instaService.stub(IWorkspaceTrustManagementService, disposables.add(new TestWorkspaceTrustManagementService()));
|
||||
|
||||
instaService.stub(IAgentPluginService, {
|
||||
plugins: observableValue('testPlugins', []),
|
||||
enablementModel: { readEnabled: () => 2 /* EnabledProfile */, setEnabled: () => { } },
|
||||
@@ -1638,6 +1640,10 @@ suite('ComputeAutomaticInstructions', () => {
|
||||
workspaceContextService.setWorkspace(testWorkspace(rootFolderUri));
|
||||
|
||||
await mockFiles(fileService, [
|
||||
{
|
||||
path: `${parentFolder}/.git/HEAD`,
|
||||
contents: ['ref: refs/heads/main'],
|
||||
},
|
||||
{
|
||||
path: `${parentFolder}/CLAUDE.md`,
|
||||
contents: ['Parent Claude guidelines'],
|
||||
@@ -1653,7 +1659,9 @@ suite('ComputeAutomaticInstructions', () => {
|
||||
]);
|
||||
|
||||
testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS, false);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, false);
|
||||
|
||||
await workspaceTrustService.setTrustedUris([URI.file(parentFolder)]);
|
||||
|
||||
const disabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined);
|
||||
const disabledParentVariables = new ChatRequestVariableSet();
|
||||
@@ -1668,7 +1676,7 @@ suite('ComputeAutomaticInstructions', () => {
|
||||
assert.ok(!paths.includes(`${parentFolder}/.claude/CLAUDE.md`), 'Should not include parent .claude/CLAUDE.md when parent search is disabled');
|
||||
|
||||
// Parent folder settings should allow finding both root and .claude CLAUDE files above the workspace folder.
|
||||
testConfigService.setUserConfiguration(PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS, true);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, true);
|
||||
|
||||
const enabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined);
|
||||
const enabledParentVariables = new ChatRequestVariableSet();
|
||||
@@ -1692,6 +1700,10 @@ suite('ComputeAutomaticInstructions', () => {
|
||||
workspaceContextService.setWorkspace(testWorkspace(rootFolderUri));
|
||||
|
||||
await mockFiles(fileService, [
|
||||
{
|
||||
path: `${parentFolder}/.git/HEAD`,
|
||||
contents: ['ref: refs/heads/main'],
|
||||
},
|
||||
{
|
||||
path: `${parentFolder}/.github/copilot-instructions.md`,
|
||||
contents: ['Parent copilot instructions'],
|
||||
@@ -1708,7 +1720,9 @@ suite('ComputeAutomaticInstructions', () => {
|
||||
|
||||
testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS, false);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, false);
|
||||
|
||||
await workspaceTrustService.setTrustedUris([URI.file(parentFolder)]);
|
||||
|
||||
const disabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined);
|
||||
const disabledParentVariables = new ChatRequestVariableSet();
|
||||
@@ -1722,7 +1736,7 @@ suite('ComputeAutomaticInstructions', () => {
|
||||
assert.ok(!paths.includes(`${parentFolder}/.github/copilot-instructions.md`), 'Should not include parent copilot-instructions.md when parent search is disabled');
|
||||
assert.ok(!paths.includes(`${parentFolder}/AGENTS.md`), 'Should not include parent AGENTS.md when parent search is disabled');
|
||||
|
||||
testConfigService.setUserConfiguration(PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS, true);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, true);
|
||||
|
||||
const enabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined);
|
||||
const enabledParentVariables = new ChatRequestVariableSet();
|
||||
|
||||
@@ -80,7 +80,7 @@ suite('PromptsService', () => {
|
||||
testConfigService.setUserConfiguration(PromptsConfig.USE_NESTED_AGENT_MD, false);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_REFERENCED_INSTRUCTIONS, true);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.SEARCH_ROOT_REPO_CUSTOMIZATIONS, false);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, false);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.INSTRUCTIONS_LOCATION_KEY, { [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true });
|
||||
testConfigService.setUserConfiguration(PromptsConfig.PROMPT_LOCATIONS_KEY, { [PROMPT_DEFAULT_SOURCE_FOLDER]: true });
|
||||
testConfigService.setUserConfiguration(PromptsConfig.MODE_LOCATION_KEY, { [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true });
|
||||
@@ -169,6 +169,7 @@ suite('PromptsService', () => {
|
||||
instaService.stub(IContextKeyService, new MockContextKeyService());
|
||||
|
||||
workspaceTrustService = disposables.add(new TestWorkspaceTrustManagementService());
|
||||
workspaceTrustService.getUriTrustInfo = (uri: URI) => Promise.resolve({ trusted: true, uri });
|
||||
instaService.stub(IWorkspaceTrustManagementService, workspaceTrustService);
|
||||
|
||||
testPluginsObservable = observableValue<readonly IAgentPlugin[]>('testPlugins', []);
|
||||
@@ -2049,6 +2050,156 @@ suite('PromptsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
suite('listPromptFiles - parent repo folder', () => {
|
||||
test('should find prompts, instructions, and agents in a parent repo folder', async () => {
|
||||
const parentFolder = '/repos/collect-prompt-parent-test';
|
||||
const rootFolder = `${parentFolder}/repo`;
|
||||
const rootFolderUri = URI.file(rootFolder);
|
||||
|
||||
workspaceContextService.setWorkspace(testWorkspace(rootFolderUri));
|
||||
|
||||
await mockFiles(fileService, [
|
||||
// .git in parent marks it as a repo root
|
||||
{
|
||||
path: `${parentFolder}/.git/HEAD`,
|
||||
contents: ['ref: refs/heads/main'],
|
||||
},
|
||||
// Applying instruction in parent
|
||||
{
|
||||
path: `${parentFolder}/.github/instructions/typescript.instructions.md`,
|
||||
contents: [
|
||||
'---',
|
||||
'description: \'Parent TypeScript instructions\'',
|
||||
'applyTo: "**/*.ts"',
|
||||
'---',
|
||||
'Parent TypeScript coding standards',
|
||||
]
|
||||
},
|
||||
// Prompt file in parent
|
||||
{
|
||||
path: `${parentFolder}/.github/prompts/help.prompt.md`,
|
||||
contents: [
|
||||
'---',
|
||||
'description: \'Parent help prompt\'',
|
||||
'---',
|
||||
'Help the user with their question',
|
||||
]
|
||||
},
|
||||
// Agent file in parent
|
||||
{
|
||||
path: `${parentFolder}/.github/agents/reviewer.agent.md`,
|
||||
contents: [
|
||||
'---',
|
||||
'description: \'Parent code reviewer agent\'',
|
||||
'---',
|
||||
'You are a code reviewer',
|
||||
]
|
||||
},
|
||||
{
|
||||
path: `${rootFolder}/src/file.ts`,
|
||||
contents: ['console.log("test");'],
|
||||
},
|
||||
]);
|
||||
|
||||
testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, false);
|
||||
|
||||
// With parent search disabled, should not find parent files
|
||||
let promptFiles = await service.listPromptFiles(PromptsType.prompt, CancellationToken.None);
|
||||
let agentFiles = await service.listPromptFiles(PromptsType.agent, CancellationToken.None);
|
||||
let instructionFiles = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None);
|
||||
|
||||
assert.ok(!promptFiles.some(f => f.uri.path.includes(parentFolder)), 'Should not find parent prompt files when parent search is disabled');
|
||||
assert.ok(!agentFiles.some(f => f.uri.path.includes(parentFolder)), 'Should not find parent agent files when parent search is disabled');
|
||||
assert.ok(!instructionFiles.some(f => f.uri.path.includes(parentFolder)), 'Should not find parent instruction files when parent search is disabled');
|
||||
|
||||
// With parent search enabled, should find parent files
|
||||
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, true);
|
||||
|
||||
promptFiles = await service.listPromptFiles(PromptsType.prompt, CancellationToken.None);
|
||||
agentFiles = await service.listPromptFiles(PromptsType.agent, CancellationToken.None);
|
||||
instructionFiles = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None);
|
||||
|
||||
const promptPaths = promptFiles.map(f => f.uri.path);
|
||||
const agentPaths = agentFiles.map(f => f.uri.path);
|
||||
const instructionPaths = instructionFiles.map(f => f.uri.path);
|
||||
|
||||
assert.ok(promptPaths.includes(`${parentFolder}/.github/prompts/help.prompt.md`), 'Should find parent prompt file when parent search is enabled');
|
||||
assert.ok(agentPaths.includes(`${parentFolder}/.github/agents/reviewer.agent.md`), 'Should find parent agent file when parent search is enabled');
|
||||
assert.ok(instructionPaths.includes(`${parentFolder}/.github/instructions/typescript.instructions.md`), 'Should find parent instruction file when parent search is enabled');
|
||||
});
|
||||
|
||||
test('should not find files in an untrusted parent repo folder', async () => {
|
||||
const parentFolder = '/repos/untrusted-parent-test';
|
||||
const rootFolder = `${parentFolder}/repo`;
|
||||
const rootFolderUri = URI.file(rootFolder);
|
||||
|
||||
workspaceContextService.setWorkspace(testWorkspace(rootFolderUri));
|
||||
|
||||
await mockFiles(fileService, [
|
||||
// .git in parent marks it as a repo root
|
||||
{
|
||||
path: `${parentFolder}/.git/HEAD`,
|
||||
contents: ['ref: refs/heads/main'],
|
||||
},
|
||||
// Applying instruction in parent
|
||||
{
|
||||
path: `${parentFolder}/.github/instructions/typescript.instructions.md`,
|
||||
contents: [
|
||||
'---',
|
||||
'description: \'Parent TypeScript instructions\'',
|
||||
'applyTo: "**/*.ts"',
|
||||
'---',
|
||||
'Parent TypeScript coding standards',
|
||||
]
|
||||
},
|
||||
// Prompt file in parent
|
||||
{
|
||||
path: `${parentFolder}/.github/prompts/help.prompt.md`,
|
||||
contents: [
|
||||
'---',
|
||||
'description: \'Parent help prompt\'',
|
||||
'---',
|
||||
'Help the user with their question',
|
||||
]
|
||||
},
|
||||
// Agent file in parent
|
||||
{
|
||||
path: `${parentFolder}/.github/agents/reviewer.agent.md`,
|
||||
contents: [
|
||||
'---',
|
||||
'description: \'Parent code reviewer agent\'',
|
||||
'---',
|
||||
'You are a code reviewer',
|
||||
]
|
||||
},
|
||||
{
|
||||
path: `${rootFolder}/src/file.ts`,
|
||||
contents: ['console.log("test");'],
|
||||
},
|
||||
]);
|
||||
|
||||
testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true);
|
||||
testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, true);
|
||||
|
||||
// Mark the parent repo root as untrusted
|
||||
workspaceTrustService.getUriTrustInfo = (uri: URI) => {
|
||||
if (uri.path === parentFolder) {
|
||||
return Promise.resolve({ trusted: false, uri });
|
||||
}
|
||||
return Promise.resolve({ trusted: true, uri });
|
||||
};
|
||||
|
||||
const promptFiles = await service.listPromptFiles(PromptsType.prompt, CancellationToken.None);
|
||||
const agentFiles = await service.listPromptFiles(PromptsType.agent, CancellationToken.None);
|
||||
const instructionFiles = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None);
|
||||
|
||||
assert.ok(!promptFiles.some(f => f.uri.path.includes(parentFolder)), 'Should not find parent prompt files when parent repo is untrusted');
|
||||
assert.ok(!agentFiles.some(f => f.uri.path.includes(parentFolder)), 'Should not find parent agent files when parent repo is untrusted');
|
||||
assert.ok(!instructionFiles.some(f => f.uri.path.includes(parentFolder)), 'Should not find parent instruction files when parent repo is untrusted');
|
||||
});
|
||||
});
|
||||
|
||||
test('Instructions provider', async () => {
|
||||
const instructionUri = URI.parse('file://extensions/my-extension/myInstruction.instructions.md');
|
||||
const extension = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js';
|
||||
import { Emitter, Event } from '../../../base/common/event.js';
|
||||
import { Iterable } from '../../../base/common/iterator.js';
|
||||
import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
|
||||
import { ResourceMap } from '../../../base/common/map.js';
|
||||
import { ResourceMap, ResourceSet } from '../../../base/common/map.js';
|
||||
import { Schemas } from '../../../base/common/network.js';
|
||||
import { observableValue } from '../../../base/common/observable.js';
|
||||
import { join } from '../../../base/common/path.js';
|
||||
@@ -380,7 +380,8 @@ export class TestWorkspaceTrustManagementService extends Disposable implements I
|
||||
|
||||
|
||||
constructor(
|
||||
private trusted: boolean = true
|
||||
private trusted: boolean = true,
|
||||
private trustedUris: ResourceSet = new ResourceSet()
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -406,11 +407,11 @@ export class TestWorkspaceTrustManagementService extends Disposable implements I
|
||||
}
|
||||
|
||||
getUriTrustInfo(uri: URI): Promise<IWorkspaceTrustUriInfo> {
|
||||
throw new Error('Method not implemented.');
|
||||
return Promise.resolve({ trusted: this.trustedUris.has(uri), uri });
|
||||
}
|
||||
|
||||
async setTrustedUris(folders: URI[]): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
this.trustedUris = new ResourceSet(folders);
|
||||
}
|
||||
|
||||
async setUrisTrust(uris: URI[], trusted: boolean): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user