Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1769,6 +1769,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 Expand Up @@ -3477,6 +3489,7 @@
"when": "view == workbench.panel.chat.view.copilot",
"group": "3_show"
},

{
"command": "github.copilot.cloud.sessions.refresh",
"when": "view == workbench.view.chat.sessions.copilot-cloud-agent",
Expand Down
2 changes: 2 additions & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
"github.copilot.command.generateTests": "Generate Tests",
"github.copilot.command.openUserPreferences": "Open User Preferences",
"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
122 changes: 122 additions & 0 deletions src/extension/context/node/externalContextService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*---------------------------------------------------------------------------------------------
* 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) {
this._paths.set(path.toString(), path);
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 replaceExternalPaths method does not enforce the MAX_EXTERNAL_PATHS limit, allowing more paths to be set than the maximum. This is inconsistent with addExternalPaths which respects the limit. Add a check to only add paths up to MAX_EXTERNAL_PATHS.

Suggested change
this._paths.set(path.toString(), path);
if (this._paths.size >= MAX_EXTERNAL_PATHS) {
break;
}
const key = path.toString();
if (!this._paths.has(key)) {
this._paths.set(key, path);
}

Copilot uses AI. Check for mistakes.
}
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 {
if (potentialParent === child) {
return true;
}

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 equality check at lines 115-117 is redundant since the same comparison is already performed by the caller at line 98 (candidateComparable === storedComparable). Remove this check to avoid duplicated logic.

Suggested change
if (potentialParent === child) {
return true;
}

Copilot uses AI. Check for mistakes.
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
Loading