Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { basename } from '../../../../../../base/common/path.js';
import { ResourceSet } from '../../../../../../base/common/map.js';
import { PromptFilesLocator } from '../utils/promptFilesLocator.js';
import { Disposable } from '../../../../../../base/common/lifecycle.js';
import { Emitter, Event } from '../../../../../../base/common/event.js';
import { Event } from '../../../../../../base/common/event.js';
import { type ITextModel } from '../../../../../../editor/common/model.js';
import { ObjectCache } from '../../../../../../base/common/objectCache.js';
import { ILogService } from '../../../../../../platform/log/common/log.js';
Expand Down Expand Up @@ -52,6 +52,11 @@ export class PromptsService extends Disposable implements IPromptsService {
*/
public logTime: TLogFunction;

/**
* Lazily created event that is fired when the custom chat modes change.
*/
private onDidChangeCustomChatModesEvent: Event<void> | undefined;

constructor(
@ILogService public readonly logger: ILogService,
@ILabelService private readonly labelService: ILabelService,
Expand All @@ -61,7 +66,8 @@ export class PromptsService extends Disposable implements IPromptsService {
) {
super();

this.fileLocator = this.instantiationService.createInstance(PromptFilesLocator);
this.fileLocator = this._register(this.instantiationService.createInstance(PromptFilesLocator));

this.logTime = this.logger.trace.bind(this.logger);

// the factory function below creates a new prompt parser object
Expand Down Expand Up @@ -95,6 +101,17 @@ export class PromptsService extends Disposable implements IPromptsService {
);
}

/**
* Emitter for the custom chat modes change event.
*/
public get onDidChangeCustomChatModes(): Event<void> {
if (!this.onDidChangeCustomChatModesEvent) {
this.onDidChangeCustomChatModesEvent = this.fileLocator.getFilesUpdatedEvent(PromptsType.mode);
}
return this.onDidChangeCustomChatModesEvent;
}


/**
* @throws {Error} if:
* - the provided model is disposed
Expand Down Expand Up @@ -184,10 +201,6 @@ export class PromptsService extends Disposable implements IPromptsService {
});
}

private readonly _onDidChangeCustomChatModesEmitter: Emitter<void> = new Emitter<void>();
// todo: firing events not yet implemented
public readonly onDidChangeCustomChatModes: Event<void> = this._onDidChangeCustomChatModesEmitter.event;

@logTime()
public async getCustomChatModes(): Promise<readonly ICustomChatMode[]> {
const modeFiles = (await this.listPromptFiles(PromptsType.mode, CancellationToken.None))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,21 @@ import { PromptsConfig } from '../../../../../../platform/prompts/common/config.
import { basename, dirname, joinPath } from '../../../../../../base/common/resources.js';
import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { getPromptFileExtension, getPromptFileType, PromptsType } from '../../../../../../platform/prompts/common/prompts.js';
import { getPromptFileExtension, getPromptFileLocationsConfigKey, getPromptFileType, PromptsType } from '../../../../../../platform/prompts/common/prompts.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 { isCancellationError } from '../../../../../../base/common/errors.js';
import { TPromptsStorage } from '../service/types.js';
import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js';
import { Emitter, Event } from '../../../../../../base/common/event.js';
import { Disposable } from '../../../../../../base/common/lifecycle.js';

/**
* Utility class to locate prompt files.
*/
export class PromptFilesLocator {
export class PromptFilesLocator extends Disposable {

constructor(
@IFileService private readonly fileService: IFileService,
Expand All @@ -33,7 +35,9 @@ export class PromptFilesLocator {
@IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService,
@ISearchService private readonly searchService: ISearchService,
@IUserDataProfileService private readonly userDataService: IUserDataProfileService,
) { }
) {
super();
}

/**
* List all prompt files from the filesystem.
Expand All @@ -42,9 +46,7 @@ export class PromptFilesLocator {
*/
public async listFiles(type: PromptsType, storage: TPromptsStorage, token: CancellationToken): Promise<readonly URI[]> {
if (storage === 'local') {
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type);
const absoluteLocations = this.toAbsoluteLocations(configuredLocations);
return await this.findFilesInLocations(absoluteLocations, type, token);
return await this.listFilesInLocal(type, token);
} else {
return await this.listFilesInUserData(type, token);
}
Expand All @@ -55,6 +57,29 @@ export class PromptFilesLocator {
return files.filter(file => getPromptFileType(file) === type);
}

public getFilesUpdatedEvent(type: PromptsType): Event<void> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aeschli I find this a bit of a weird pattern to return an event from a method: esp. since you register disposables to the PromptFilesLocator, the lifecycle seems bound by "the big guy" and not the one calling it. This means you pile up listeners for as often as the method is called, only disposing them when "the big guy" goes away (likely never).

I would rather do 1 event that is fixed for the PromptsType to follow what we do in other places.

const eventEmitter = this._register(new Emitter<void>());
const key = getPromptFileLocationsConfigKey(type);
let parentFolders = this.getLocalParentFolders(type).map(folder => folder.parent);
this._register(this.configService.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(key)) {
parentFolders = this.getLocalParentFolders(type).map(folder => folder.parent);
eventEmitter.fire();
}
}));
this._register(this.fileService.onDidFilesChange(e => {
if (e.affects(this.userDataService.currentProfile.promptsHome)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aeschli this is problematic I think: the onDidFilesChange event is only firing for folders or files where you called fileService.watch. I think we are doing that here:

this._register(this.fileService.watch(this.promptsFolder));

But I would consider doing an explicit watch here to ensure the watcher is setup (multiple same watchers are folded into 1).

eventEmitter.fire();
return;
}
if (parentFolders.some(folder => e.affects(folder))) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aeschli I wonder how this can happen since the watching does not seem to be recursive.

eventEmitter.fire();
return;
}
}));
return eventEmitter.event;
}

/**
* Get all possible unambiguous prompt file source folders based on
* the current workspace folder structure.
Expand Down Expand Up @@ -107,29 +132,19 @@ export class PromptFilesLocator {
}

/**
* Finds all existent prompt files in the provided source folders.
*
* @throws if any of the provided folder paths is not an `absolute path`.
* Finds all existent prompt files in the configured local source folders.
*
* @param absoluteLocations List of prompt file source folders to search for prompt files in. Must be absolute paths.
* @returns List of prompt files found in the provided source folders.
* @returns List of prompt files found in the local source folders.
*/
private async findFilesInLocations(
absoluteLocations: readonly URI[],
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 absoluteLocation of absoluteLocations) {
assert(
isAbsolute(absoluteLocation.path),
`Provided location must be an absolute path, got '${absoluteLocation.path}'.`,
);

const { parent, filePattern } = firstNonGlobParentAndPattern(absoluteLocation);

for (const { parent, filePattern } of this.getLocalParentFolders(type)) {
const files = (filePattern === undefined)
? await this.resolveFilesAtLocation(parent, token) // if the location does not contain a glob pattern, resolve the location directly
: await this.searchFilesInLocation(parent, filePattern, token);
Expand All @@ -146,6 +161,12 @@ export class PromptFilesLocator {
return [...paths];
}

private getLocalParentFolders(type: PromptsType): readonly { parent: URI; filePattern?: string }[] {
const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type);
const absoluteLocations = this.toAbsoluteLocations(configuredLocations);
return absoluteLocations.map(firstNonGlobParentAndPattern);
}

/**
* Converts locations defined in `settings` to absolute filesystem path URIs.
* This conversion is needed because locations in settings can be relative,
Expand Down Expand Up @@ -330,7 +351,7 @@ export const isValidGlob = (pattern: string): boolean => {
*/
const firstNonGlobParentAndPattern = (
location: URI
): { parent: URI; filePattern: string | undefined } => {
): { parent: URI; filePattern?: string } => {
const segments = location.path.split('/');
let i = 0;
while (i < segments.length && isValidGlob(segments[i]) === false) {
Expand All @@ -339,18 +360,16 @@ const firstNonGlobParentAndPattern = (
if (i === segments.length) {
// the path does not contain a glob pattern, so we can
// just find all prompt files in the provided location
return { parent: location, filePattern: undefined };
return { parent: location };
}
const parent = location.with({ path: segments.slice(0, i).join('/') });
if (i === segments.length - 1 && segments[i] === '*' || segments[i] === ``) {
return {
parent: location.with({ path: segments.slice(0, i).join('/') }),
filePattern: undefined
};
return { parent };
}

// the path contains a glob pattern, so we search in last folder that does not contain a glob pattern
return {
parent: location.with({ path: segments.slice(0, i).join('/') }),
parent,
filePattern: segments.slice(i).join('/')
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { InMemoryFileSystemProvider } from '../../../../../../../platform/files/
import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js';
import { INSTRUCTION_FILE_EXTENSION, PROMPT_FILE_EXTENSION, PromptsType } from '../../../../../../../platform/prompts/common/prompts.js';
import { IWorkspacesService } from '../../../../../../../platform/workspaces/common/workspaces.js';

/**
* Helper class to assert the properties of a link.
Expand Down Expand Up @@ -106,6 +107,7 @@ suite('PromptsService', () => {
setup(async () => {
instaService = disposables.add(new TestInstantiationService());
instaService.stub(ILogService, new NullLogService());
instaService.stub(IWorkspacesService, {});
instaService.stub(IConfigurationService, new TestConfigurationService());

const fileService = disposables.add(instaService.createInstance(FileService));
Expand Down
Loading
Loading