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
14 changes: 12 additions & 2 deletions extensions/git/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1040,19 +1040,29 @@ export class CommandCenter {
}

@command('_git.cloneRepository')
async cloneRepository(url: string, parentPath: string): Promise<void> {
async cloneRepository(url: string, localPath: string, ref?: string): Promise<void> {
const opts = {
location: ProgressLocation.Notification,
title: l10n.t('Cloning git repository "{0}"...', url),
cancellable: true
};

const parentPath = path.dirname(localPath);
const targetName = path.basename(localPath);

await window.withProgress(
opts,
(progress, token) => this.model.git.clone(url, { parentPath, progress }, token)
(progress, token) => this.model.git.clone(url, { parentPath, targetName, progress, ref }, token)
);
}

@command('_git.checkout')
async checkoutRepository(repositoryPath: string, treeish: string, detached?: boolean): Promise<void> {
const dotGit = await this.git.getRepositoryDotGit(repositoryPath);
const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger);
await repo.checkout(treeish, [], detached ? { detached: true } : {});
}

@command('_git.pull')
async pullRepository(repositoryPath: string): Promise<void> {
const dotGit = await this.git.getRepositoryDotGit(repositoryPath);
Expand Down
11 changes: 7 additions & 4 deletions extensions/git/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ const STASH_FORMAT = '%H%n%P%n%gd%n%gs%n%at%n%ct';

export interface ICloneOptions {
readonly parentPath: string;
readonly targetName?: string;
readonly progress: Progress<{ increment: number }>;
readonly recursive?: boolean;
readonly ref?: string;
Expand Down Expand Up @@ -433,14 +434,16 @@ export class Git {
}

async clone(url: string, options: ICloneOptions, cancellationToken?: CancellationToken): Promise<string> {
const baseFolderName = decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository';
const baseFolderName = options.targetName || decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository';
let folderName = baseFolderName;
let folderPath = path.join(options.parentPath, folderName);
let count = 1;

while (count < 20 && await new Promise(c => exists(folderPath, c))) {
folderName = `${baseFolderName}-${count++}`;
folderPath = path.join(options.parentPath, folderName);
if (!options.targetName) {
while (count < 20 && await new Promise(c => exists(folderPath, c))) {
folderName = `${baseFolderName}-${count++}`;
folderPath = path.join(options.parentPath, folderName);
}
}

await mkdirp(options.parentPath);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../base/
import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';
import { Schemas, matchesScheme } from '../../../../../base/common/network.js';
import { autorun } from '../../../../../base/common/observable.js';
import { basename, dirname, joinPath } from '../../../../../base/common/resources.js';
import { dirname, joinPath } from '../../../../../base/common/resources.js';
import { URI } from '../../../../../base/common/uri.js';
import { generateUuid } from '../../../../../base/common/uuid.js';
import { TokenizationRegistry } from '../../../../../editor/common/languages.js';
Expand Down Expand Up @@ -202,6 +202,7 @@ export class AgentPluginEditor extends EditorPane {
description: item.description,
version: '',
source: item.source,
sourceDescriptor: item.sourceDescriptor,
marketplace: item.marketplace,
marketplaceReference: item.marketplaceReference,
marketplaceType: item.marketplaceType,
Expand All @@ -222,6 +223,7 @@ export class AgentPluginEditor extends EditorPane {
name: item.name,
description: mp.description,
source: mp.source,
sourceDescriptor: mp.sourceDescriptor,
marketplace: mp.marketplace,
marketplaceReference: mp.marketplaceReference,
marketplaceType: mp.marketplaceType,
Expand Down Expand Up @@ -267,7 +269,7 @@ export class AgentPluginEditor extends EditorPane {
}

private installedPluginToItem(plugin: IAgentPlugin): IInstalledPluginItem {
const name = basename(plugin.uri);
const name = plugin.label;
const description = plugin.fromMarketplace?.description ?? this.labelService.getUriLabel(dirname(plugin.uri), { relative: true });
const marketplace = plugin.fromMarketplace?.marketplace;
return { kind: AgentPluginItemKind.Installed, name, description, marketplace, plugin };
Expand Down Expand Up @@ -517,6 +519,7 @@ class InstallPluginEditorAction extends Action {
description: this.item.description,
version: '',
source: this.item.source,
sourceDescriptor: this.item.sourceDescriptor,
marketplace: this.item.marketplace,
marketplaceReference: this.item.marketplaceReference,
marketplaceType: this.item.marketplaceType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { URI } from '../../../../../base/common/uri.js';
import type { IAgentPlugin } from '../../common/plugins/agentPluginService.js';
import type { IMarketplaceReference, MarketplaceType } from '../../common/plugins/pluginMarketplaceService.js';
import type { IMarketplaceReference, IPluginSourceDescriptor, MarketplaceType } from '../../common/plugins/pluginMarketplaceService.js';

export const enum AgentPluginItemKind {
Installed = 'installed',
Expand All @@ -25,6 +25,7 @@ export interface IMarketplacePluginItem {
readonly name: string;
readonly description: string;
readonly source: string;
readonly sourceDescriptor: IPluginSourceDescriptor;
readonly marketplace: string;
readonly marketplaceReference: IMarketplaceReference;
readonly marketplaceType: MarketplaceType;
Expand Down
180 changes: 177 additions & 3 deletions src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { IProgressService, ProgressLocation } from '../../../../platform/progres
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import type { Dto } from '../../../services/extensions/common/proxyIdentifier.js';
import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js';
import { IMarketplacePlugin, IMarketplaceReference, MarketplaceReferenceKind, MarketplaceType } from '../common/plugins/pluginMarketplaceService.js';
import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js';

const MARKETPLACE_INDEX_STORAGE_KEY = 'chat.plugins.marketplaces.index.v1';

Expand Down Expand Up @@ -176,7 +176,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi
this._storageService.store(MARKETPLACE_INDEX_STORAGE_KEY, JSON.stringify(serialized), StorageScope.APPLICATION, StorageTarget.MACHINE);
}

private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string): Promise<void> {
private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string, ref?: string): Promise<void> {
try {
await this._progressService.withProgress(
{
Expand All @@ -186,7 +186,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi
},
async () => {
await this._fileService.createFolder(dirname(repoDir));
await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, dirname(repoDir).fsPath);
await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, repoDir.fsPath, ref);
}
);
} catch (err) {
Expand All @@ -212,4 +212,178 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi
}
return pluginDir;
}

getPluginSourceInstallUri(sourceDescriptor: IPluginSourceDescriptor): URI {
switch (sourceDescriptor.kind) {
case PluginSourceKind.RelativePath:
throw new Error('Use getPluginInstallUri() for relative-path sources');
case PluginSourceKind.GitHub: {
const [owner, repo] = sourceDescriptor.repo.split('/');
return joinPath(this._cacheRoot, 'github.com', owner, repo, ...this._getSourceRevisionCacheSuffix(sourceDescriptor));
}
case PluginSourceKind.GitUrl: {
const segments = this._gitUrlCacheSegments(sourceDescriptor.url, sourceDescriptor.ref, sourceDescriptor.sha);
return joinPath(this._cacheRoot, ...segments);
}
case PluginSourceKind.Npm:
return joinPath(this._cacheRoot, 'npm', sanitizePackageName(sourceDescriptor.package), 'node_modules', sourceDescriptor.package);
case PluginSourceKind.Pip:
return joinPath(this._cacheRoot, 'pip', sanitizePackageName(sourceDescriptor.package));
}
}

async ensurePluginSource(plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise<URI> {
const descriptor = plugin.sourceDescriptor;
switch (descriptor.kind) {
case PluginSourceKind.RelativePath:
return this.ensureRepository(plugin.marketplaceReference, options);
case PluginSourceKind.GitHub: {
const cloneUrl = `https://github.com/${descriptor.repo}.git`;
const repoDir = this.getPluginSourceInstallUri(descriptor);
const repoExists = await this._fileService.exists(repoDir);
if (repoExists) {
await this._checkoutPluginSourceRevision(repoDir, descriptor, options?.failureLabel ?? descriptor.repo);
return repoDir;
}
const progressTitle = options?.progressTitle ?? localize('cloningPluginSource', "Cloning plugin source '{0}'...", descriptor.repo);
const failureLabel = options?.failureLabel ?? descriptor.repo;
await this._cloneRepository(repoDir, cloneUrl, progressTitle, failureLabel, descriptor.ref);
await this._checkoutPluginSourceRevision(repoDir, descriptor, failureLabel);
return repoDir;
}
case PluginSourceKind.GitUrl: {
const repoDir = this.getPluginSourceInstallUri(descriptor);
const repoExists = await this._fileService.exists(repoDir);
if (repoExists) {
await this._checkoutPluginSourceRevision(repoDir, descriptor, options?.failureLabel ?? descriptor.url);
return repoDir;
}
const progressTitle = options?.progressTitle ?? localize('cloningPluginSourceUrl', "Cloning plugin source '{0}'...", descriptor.url);
const failureLabel = options?.failureLabel ?? descriptor.url;
await this._cloneRepository(repoDir, descriptor.url, progressTitle, failureLabel, descriptor.ref);
await this._checkoutPluginSourceRevision(repoDir, descriptor, failureLabel);
return repoDir;
}
case PluginSourceKind.Npm: {
// npm/pip install directories are managed by the install service.
// Return the expected install URI without performing installation.
return joinPath(this._cacheRoot, 'npm', sanitizePackageName(descriptor.package));
}
case PluginSourceKind.Pip: {
return joinPath(this._cacheRoot, 'pip', sanitizePackageName(descriptor.package));
}
}
}

async updatePluginSource(plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise<void> {
const descriptor = plugin.sourceDescriptor;
if (descriptor.kind !== PluginSourceKind.GitHub && descriptor.kind !== PluginSourceKind.GitUrl) {
return;
}

const repoDir = this.getPluginSourceInstallUri(descriptor);
const repoExists = await this._fileService.exists(repoDir);
if (!repoExists) {
this._logService.warn(`[AgentPluginRepositoryService] Cannot update plugin '${options?.pluginName ?? plugin.name}': source repository not cloned`);
return;
}

const updateLabel = options?.pluginName ?? plugin.name;
const failureLabel = options?.failureLabel ?? updateLabel;

try {
await this._progressService.withProgress(
{
location: ProgressLocation.Notification,
title: localize('updatingPluginSource', "Updating plugin '{0}'...", updateLabel),
cancellable: false,
},
async () => {
await this._commandService.executeCommand('git.openRepository', repoDir.fsPath);
if (descriptor.sha) {
await this._commandService.executeCommand('git.fetch', repoDir.fsPath);
} else {
await this._commandService.executeCommand('_git.pull', repoDir.fsPath);
}
await this._checkoutPluginSourceRevision(repoDir, descriptor, failureLabel);
}
);
} catch (err) {
this._logService.error(`[AgentPluginRepositoryService] Failed to update plugin source ${updateLabel}:`, err);
this._notificationService.notify({
severity: Severity.Error,
message: localize('pullPluginSourceFailed', "Failed to update plugin '{0}': {1}", failureLabel, err?.message ?? String(err)),
actions: {
primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => {
this._commandService.executeCommand('git.showOutput');
})],
},
});
}
}

private _gitUrlCacheSegments(url: string, ref?: string, sha?: string): string[] {
try {
const parsed = URI.parse(url);
const authority = (parsed.authority || 'unknown').replace(/[\\/:*?"<>|]/g, '_').toLowerCase();
const pathPart = parsed.path.replace(/^\/+/, '').replace(/\.git$/i, '').replace(/\/+$/g, '');
const segments = pathPart.split('/').map(s => s.replace(/[\\/:*?"<>|]/g, '_'));
return [authority, ...segments, ...this._getSourceRevisionCacheSuffix(ref, sha)];
} catch {
return ['git', url.replace(/[\\/:*?"<>|]/g, '_'), ...this._getSourceRevisionCacheSuffix(ref, sha)];
}
}

private _getSourceRevisionCacheSuffix(descriptorOrRef: IPluginSourceDescriptor | string | undefined, sha?: string): string[] {
if (typeof descriptorOrRef === 'object' && descriptorOrRef) {
if (descriptorOrRef.kind === PluginSourceKind.GitHub || descriptorOrRef.kind === PluginSourceKind.GitUrl) {
return this._getSourceRevisionCacheSuffix(descriptorOrRef.ref, descriptorOrRef.sha);
}
return [];
}

const ref = descriptorOrRef;
if (sha) {
return [`sha_${sanitizePackageName(sha)}`];
}
if (ref) {
return [`ref_${sanitizePackageName(ref)}`];
}
return [];
}

private async _checkoutPluginSourceRevision(repoDir: URI, descriptor: IPluginSourceDescriptor, failureLabel: string): Promise<void> {
if (descriptor.kind !== PluginSourceKind.GitHub && descriptor.kind !== PluginSourceKind.GitUrl) {
return;
}

if (!descriptor.sha && !descriptor.ref) {
return;
}

try {
if (descriptor.sha) {
await this._commandService.executeCommand('_git.checkout', repoDir.fsPath, descriptor.sha, true);
return;
}

await this._commandService.executeCommand('_git.checkout', repoDir.fsPath, descriptor.ref);
} catch (err) {
this._logService.error(`[AgentPluginRepositoryService] Failed to checkout plugin source revision for ${failureLabel}:`, err);
this._notificationService.notify({
severity: Severity.Error,
message: localize('checkoutPluginSourceFailed', "Failed to checkout plugin '{0}' to requested revision: {1}", failureLabel, err?.message ?? String(err)),
actions: {
primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => {
this._commandService.executeCommand('git.showOutput');
})],
},
});
throw err;
}
}
}

function sanitizePackageName(name: string): string {
return name.replace(/[\\/:*?"<>|]/g, '_');
}
7 changes: 5 additions & 2 deletions src/vs/workbench/contrib/chat/browser/agentPluginsView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { Event } from '../../../../base/common/event.js';
import { Disposable, DisposableStore, IDisposable, isDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { autorun } from '../../../../base/common/observable.js';
import { IPagedModel, PagedModel } from '../../../../base/common/paging.js';
import { basename, dirname, joinPath } from '../../../../base/common/resources.js';
import { dirname, joinPath } from '../../../../base/common/resources.js';
import { URI } from '../../../../base/common/uri.js';
import { localize, localize2 } from '../../../../nls.js';
import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';
Expand Down Expand Up @@ -53,7 +53,7 @@ export const InstalledAgentPluginsViewId = 'workbench.views.agentPlugins.install
//#region Item model

function installedPluginToItem(plugin: IAgentPlugin, labelService: ILabelService): IInstalledPluginItem {
const name = basename(plugin.uri);
const name = plugin.label;
const description = plugin.fromMarketplace?.description ?? labelService.getUriLabel(dirname(plugin.uri), { relative: true });
const marketplace = plugin.fromMarketplace?.marketplace;
return { kind: AgentPluginItemKind.Installed, name, description, marketplace, plugin };
Expand All @@ -65,6 +65,7 @@ function marketplacePluginToItem(plugin: IMarketplacePlugin): IMarketplacePlugin
name: plugin.name,
description: plugin.description,
source: plugin.source,
sourceDescriptor: plugin.sourceDescriptor,
marketplace: plugin.marketplace,
marketplaceReference: plugin.marketplaceReference,
marketplaceType: plugin.marketplaceType,
Expand Down Expand Up @@ -92,6 +93,7 @@ class InstallPluginAction extends Action {
description: this.item.description,
version: '',
source: this.item.source,
sourceDescriptor: this.item.sourceDescriptor,
marketplace: this.item.marketplace,
marketplaceReference: this.item.marketplaceReference,
marketplaceType: this.item.marketplaceType,
Expand Down Expand Up @@ -443,6 +445,7 @@ export class AgentPluginsListView extends AbstractExtensionsListView<IAgentPlugi
description: m.description,
version: '',
source: m.source,
sourceDescriptor: m.sourceDescriptor,
marketplace: m.marketplace,
marketplaceReference: m.marketplaceReference,
marketplaceType: m.marketplaceType,
Expand Down
Loading
Loading