Skip to content
Open
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
12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1824,6 +1824,18 @@
"icon": "$(terminal)",
"category": "Copilot CLI"
},
{
"command": "github.copilot.chat.addExternalContext",
"title": "%github.copilot.command.addExternalContext%",
"category": "Chat",
"icon": "$(folder-library)"
},
{
"command": "github.copilot.chat.manageExternalContexts",
"title": "%github.copilot.command.manageExternalContexts%",
"category": "Chat",
"icon": "$(list-selection)"
},
{
"command": "github.copilot.chat.replay",
"title": "Start Chat Replay",
Expand Down
2 changes: 2 additions & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
"github.copilot.command.openUserPreferences": "Open User Preferences",
"github.copilot.command.openMemoryFolder": "Open Memory Folder",
"github.copilot.command.sendChatFeedback": "Send Chat Feedback",
"github.copilot.command.addExternalContext": "Add External Folder",
"github.copilot.command.manageExternalContexts": "Manage External Folders",
"github.copilot.command.buildLocalWorkspaceIndex": "Build Local Workspace Index",
"github.copilot.command.buildRemoteWorkspaceIndex": "Build Remote Workspace Index",
"github.copilot.viewsWelcome.signIn": {
Expand Down
124 changes: 124 additions & 0 deletions src/extension/context/node/externalContextService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as npath from 'path';
import { createServiceIdentifier } from '../../../util/common/services';
import { isEqual } from '../../../util/vs/base/common/resources';
import { URI } from '../../../util/vs/base/common/uri';
import { Emitter, Event } from '../../../util/vs/base/common/event';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { isWindows } from '../../../util/vs/base/common/platform';

const MAX_EXTERNAL_PATHS = 3;

export const IExternalContextService = createServiceIdentifier<IExternalContextService>('IExternalContextService');

export interface IExternalContextService {
readonly _serviceBrand: undefined;
readonly onDidChangeExternalContext: Event<void>;
readonly maxExternalPaths: number;
getExternalPaths(): readonly URI[];
addExternalPaths(paths: readonly URI[]): readonly URI[];
replaceExternalPaths(paths: readonly URI[]): void;
removeExternalPath(path: URI): void;
clear(): void;
isExternalPath(uri: URI): boolean;
}

export class ExternalContextService extends Disposable implements IExternalContextService {
declare readonly _serviceBrand: undefined;

private readonly _onDidChangeExternalContext = this._register(new Emitter<void>());
readonly onDidChangeExternalContext: Event<void> = this._onDidChangeExternalContext.event;

readonly maxExternalPaths = MAX_EXTERNAL_PATHS;

private readonly _paths = new Map<string, URI>();

getExternalPaths(): readonly URI[] {
return [...this._paths.values()];
}

addExternalPaths(paths: readonly URI[]): readonly URI[] {
const added: URI[] = [];
if (!paths.length) {
return added;
}

for (const path of paths) {
if (this._paths.size >= MAX_EXTERNAL_PATHS) {
break;
}
const key = path.toString();
if (!this._paths.has(key)) {
this._paths.set(key, path);
added.push(path);
}
}
if (added.length) {
this._onDidChangeExternalContext.fire();
}

return added;
}

replaceExternalPaths(paths: readonly URI[]): void {
this._paths.clear();
for (const path of paths) {
if (this._paths.size >= MAX_EXTERNAL_PATHS) {
break;
}
const key = path.toString();
if (!this._paths.has(key)) {
this._paths.set(key, path);
}
}
this._onDidChangeExternalContext.fire();
}

removeExternalPath(path: URI): void {
for (const [key, storedPath] of this._paths) {
if (isEqual(storedPath, path)) {
this._paths.delete(key);
this._onDidChangeExternalContext.fire();
return;
}
}
}

clear(): void {
if (this._paths.size === 0) {
return;
}
this._paths.clear();
this._onDidChangeExternalContext.fire();
}

isExternalPath(uri: URI): boolean {
const candidateComparable = this.toComparablePath(uri);
for (const stored of this._paths.values()) {
const storedComparable = this.toComparablePath(stored);

if (candidateComparable === storedComparable) {
return true;
}

if (this.isSubPath(candidateComparable, storedComparable) || this.isSubPath(storedComparable, candidateComparable)) {
return true;
}
}
return false;
}
Comment on lines +99 to +113
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

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

The toComparablePath method is called repeatedly inside the loop, converting the same stored paths on every iteration. Consider caching the comparable paths in a Map alongside the URIs to avoid redundant path normalization operations.

Copilot uses AI. Check for mistakes.

private toComparablePath(uri: URI): string {
const normalized = npath.normalize(uri.fsPath);
return isWindows ? normalized.toLowerCase() : normalized;
}

private isSubPath(child: string, potentialParent: string): boolean {
const parentWithSep = potentialParent.endsWith(npath.sep) ? potentialParent : potentialParent + npath.sep;
return child.startsWith(parentWithSep);
}
}
53 changes: 53 additions & 0 deletions src/extension/context/node/test/externalContextService.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as path from 'path';
import { describe, expect, it } from 'vitest';
import { URI } from '../../../../util/vs/base/common/uri';
import { ExternalContextService } from '../externalContextService';

function createUri(name: string): URI {
return URI.file(path.join(process.cwd(), 'external-context-tests', name));
}

describe('ExternalContextService', () => {
it('caps at max external paths', () => {
const service = new ExternalContextService();

service.addExternalPaths([
createUri('one'),
createUri('two'),
createUri('three'),
createUri('four')
]);

expect(service.getExternalPaths()).toHaveLength(service.maxExternalPaths);
});

it('fires change event when paths are added', () => {
const service = new ExternalContextService();
let fired = 0;

service.onDidChangeExternalContext(() => fired++);

service.addExternalPaths([createUri('one')]);

expect(fired).toBe(1);
});

it('removes paths and fires event', () => {
const service = new ExternalContextService();
const [added] = service.addExternalPaths([createUri('one')]);
let fired = 0;

service.onDidChangeExternalContext(() => fired++);

service.removeExternalPath(added);

expect(service.getExternalPaths()).toHaveLength(0);
expect(fired).toBe(1);
});
});

Loading