diff --git a/.vscode/settings.json b/.vscode/settings.json index f65efbf06ee50..5cd09294f0168 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -100,10 +100,10 @@ // --- TypeScript --- "typescript.experimental.useTsgo": true, "typescript.tsdk": "node_modules/typescript/lib", - "typescript.preferences.importModuleSpecifier": "relative", - "typescript.preferences.quoteStyle": "single", + "js/ts.preferences.importModuleSpecifier": "relative", + "js/ts.preferences.quoteStyle": "single", "typescript.tsc.autoDetect": "off", - "typescript.preferences.autoImportFileExcludePatterns": [ + "js/ts.preferences.autoImportFileExcludePatterns": [ "@xterm/xterm", "@xterm/headless", "node-pty", @@ -144,6 +144,9 @@ "ts": "warning", "eslint": "warning" }, + "git.worktreeIncludeFiles": [ + "product.overrides.json" + ], // --- GitHub --- "githubPullRequests.experimental.createView": true, "githubPullRequests.assignCreated": "${user}", @@ -199,12 +202,11 @@ "sash" ], // --- Workbench --- - // "application.experimental.rendererProfiling": true, // https://github.com/microsoft/vscode/issues/265654 "editor.aiStats.enabled": true, // Team selfhosting on ai stats "azureMcp.enabledServices": [ "kusto" // Needed for kusto tool in data.prompt.md ], "azureMcp.serverMode": "all", "azureMcp.readOnly": true, - "debug.breakpointsView.presentation": "tree" + "debug.breakpointsView.presentation": "tree", } diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 31c31f58406ab..bc34262af5c1e 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -389,7 +389,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d const isInsiderOrExploration = quality === 'insider' || quality === 'exploration'; const embedded = isInsiderOrExploration - ? (product as typeof product & { embedded?: { nameShort: string; nameLong: string; applicationName: string; dataFolderName: string; darwinBundleIdentifier: string } }).embedded + ? (product as typeof product & { embedded?: { nameShort: string; nameLong: string; applicationName: string; dataFolderName: string; darwinBundleIdentifier: string; urlProtocol: string } }).embedded : undefined; const packageSubJsonStream = isInsiderOrExploration @@ -409,6 +409,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d json.applicationName = embedded.applicationName; json.dataFolderName = embedded.dataFolderName; json.darwinBundleIdentifier = embedded.darwinBundleIdentifier; + json.urlProtocol = embedded.urlProtocol; return json; })) .pipe(rename('product.sub.json')) diff --git a/package.json b/package.json index fb6ac1043c878..df095bf4486f3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "85914d5a600261a53306190177be48aa8f0cdfb4", + "distro": "bd187e4508a244500eb533c56e5cccb6801a699c", "author": { "name": "Microsoft Corporation" }, @@ -245,4 +245,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} +} \ No newline at end of file diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditor.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditor.ts index caaf09e7ca242..7d2e3de1face5 100644 --- a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditor.ts +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditor.ts @@ -247,7 +247,7 @@ export class AICustomizationManagementEditor extends EditorPane { layout: (width, _, height) => { this.sidebarContainer.style.width = `${width}px`; if (height !== undefined) { - const listHeight = height - 24; + const listHeight = height - 8; this.sectionsList.layout(listHeight, width); } }, diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css b/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css index 4954a2a3fd979..a307cfa98dfce 100644 --- a/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css @@ -9,7 +9,6 @@ flex-direction: column; height: 100%; overflow: hidden; - border-top: 1px solid var(--vscode-panel-border); } /* Sidebar */ @@ -17,12 +16,12 @@ background-color: var(--vscode-sideBar-background); height: 100%; overflow: hidden; - border-right: 1px solid var(--vscode-panel-border); } .ai-customization-management-editor .sidebar-content { height: 100%; - padding: 12px 0 12px 4px; + padding: 4px; + box-sizing: border-box; display: flex; flex-direction: column; } @@ -41,7 +40,6 @@ padding: 4px 8px; gap: 10px; cursor: pointer; - margin: 1px 6px; border-radius: 4px; transition: background-color 0.1s ease, opacity 0.1s ease; } diff --git a/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts index 30a68e7b3c5e8..99d5b415b922e 100644 --- a/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts +++ b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts @@ -3,14 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from '../../../base/common/cancellation.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; -import { IGitExtensionService, IGitService } from '../../contrib/git/common/gitService.js'; +import { IGitExtensionDelegate, IGitService, GitRef, GitRefQuery, GitRefType } from '../../contrib/git/common/gitService.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; -import { ExtHostContext, ExtHostGitExtensionShape, MainContext, MainThreadGitExtensionShape } from '../common/extHost.protocol.js'; +import { ExtHostContext, ExtHostGitExtensionShape, GitRefTypeDto, MainContext, MainThreadGitExtensionShape } from '../common/extHost.protocol.js'; + +function toGitRefType(type: GitRefTypeDto): GitRefType { + switch (type) { + case GitRefTypeDto.Head: return GitRefType.Head; + case GitRefTypeDto.RemoteHead: return GitRefType.RemoteHead; + case GitRefTypeDto.Tag: return GitRefType.Tag; + default: throw new Error(`Unknown GitRefType: ${type}`); + } +} @extHostNamedCustomer(MainContext.MainThreadGitExtension) -export class MainThreadGitExtensionService extends Disposable implements MainThreadGitExtensionShape, IGitExtensionService { +export class MainThreadGitExtensionService extends Disposable implements MainThreadGitExtensionShape, IGitExtensionDelegate { private readonly _proxy: ExtHostGitExtensionShape; constructor( @@ -20,7 +30,18 @@ export class MainThreadGitExtensionService extends Disposable implements MainThr super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostGitExtension); - gitService.setDelegate(this); + this._initializeDelegate(); + } + + private async _initializeDelegate(): Promise { + // Check whether the vscode.git extension is available in the extension host + // process before setting the delegate. The delegate should only be set once, + // for the extension host process that runs the vscode.git extension + const isExtensionAvailable = await this._proxy.$isGitExtensionAvailable(); + + if (isExtensionAvailable && !this._store.isDisposed) { + this._register(this.gitService.setDelegate(this)); + } } async openRepository(uri: URI): Promise { @@ -28,8 +49,16 @@ export class MainThreadGitExtensionService extends Disposable implements MainThr return result ? URI.revive(result) : undefined; } - override dispose(): void { - this.gitService.clearDelegate(); - super.dispose(); + async getRefs(root: URI, query: GitRefQuery, token?: CancellationToken): Promise { + const result = await this._proxy.$getRefs(root, query, token); + + if (token?.isCancellationRequested) { + return []; + } + + return result.map(ref => ({ + ...ref, + type: toGitRefType(ref.type) + } satisfies GitRef)); } } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index d10a973d8bc00..116d0b9f1fade 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3456,8 +3456,30 @@ export interface ExtHostChatSessionsShape { $provideHandleOptionsChange(providerHandle: number, sessionResource: UriComponents, updates: ReadonlyArray, token: CancellationToken): Promise; } +export interface GitRefQueryDto { + readonly contains?: string; + readonly count?: number; + readonly pattern?: string | string[]; + readonly sort?: 'alphabetically' | 'committerdate' | 'creatordate'; +} + +export enum GitRefTypeDto { + Head, + RemoteHead, + Tag +} + +export interface GitRefDto { + readonly id: string; + readonly name: string; + readonly type: GitRefTypeDto; + readonly revision: string; +} + export interface ExtHostGitExtensionShape { + $isGitExtensionAvailable(): Promise; $openRepository(root: UriComponents): Promise; + $getRefs(root: UriComponents, query: GitRefQueryDto, token?: CancellationToken): Promise; } // --- proxy identifiers diff --git a/src/vs/workbench/api/common/extHostGitExtensionService.ts b/src/vs/workbench/api/common/extHostGitExtensionService.ts index 47276d420b8d6..91e267f62405f 100644 --- a/src/vs/workbench/api/common/extHostGitExtensionService.ts +++ b/src/vs/workbench/api/common/extHostGitExtensionService.ts @@ -10,12 +10,46 @@ import { ExtensionIdentifier } from '../../../platform/extensions/common/extensi import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { IExtHostExtensionService } from './extHostExtensionService.js'; import { IExtHostRpcService } from './extHostRpcService.js'; -import { ExtHostGitExtensionShape } from './extHost.protocol.js'; +import { ExtHostGitExtensionShape, GitRefDto, GitRefQueryDto, GitRefTypeDto } from './extHost.protocol.js'; const GIT_EXTENSION_ID = 'vscode.git'; +function toGitRefTypeDto(type: GitRefType): GitRefTypeDto { + switch (type) { + case GitRefType.Head: return GitRefTypeDto.Head; + case GitRefType.RemoteHead: return GitRefTypeDto.RemoteHead; + case GitRefType.Tag: return GitRefTypeDto.Tag; + default: throw new Error(`Unknown GitRefType: ${type}`); + } +} + +interface Repository { + readonly rootUri: vscode.Uri; + getRefs(query: GitRefQuery, token?: vscode.CancellationToken): Promise; +} + +interface GitRef { + type: GitRefType; + name?: string; + commit?: string; + remote?: string; +} + +const enum GitRefType { + Head, + RemoteHead, + Tag +} + +interface GitRefQuery { + readonly contains?: string; + readonly count?: number; + readonly pattern?: string | string[]; + readonly sort?: 'alphabetically' | 'committerdate' | 'creatordate'; +} + interface GitExtensionAPI { - openRepository(root: vscode.Uri): Promise<{ readonly rootUri: vscode.Uri } | null>; + openRepository(root: vscode.Uri): Promise; } interface GitExtension { @@ -41,7 +75,10 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi super(); } - // --- Called by the main thread via RPC (ExtHostGitShape) --- + async $isGitExtensionAvailable(): Promise { + const registry = await this._extHostExtensionService.getExtensionRegistry(); + return !!registry.getExtensionDescription(GIT_EXTENSION_ID); + } async $openRepository(uri: UriComponents): Promise { const api = await this._ensureGitApi(); @@ -53,7 +90,43 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi return repository?.rootUri; } - // --- Private helpers --- + async $getRefs(uri: UriComponents, query: GitRefQueryDto, token?: vscode.CancellationToken): Promise { + const api = await this._ensureGitApi(); + if (!api) { + return []; + } + + const repository = await api.openRepository(URI.revive(uri)); + if (!repository) { + return []; + } + + try { + const refs = await repository.getRefs(query, token); + const result: (GitRefDto | undefined)[] = refs.map(ref => { + if (!ref.name || !ref.commit) { + return undefined; + } + + const id = ref.type === GitRefType.Head + ? `refs/heads/${ref.name}` + : ref.type === GitRefType.RemoteHead + ? `refs/remotes/${ref.remote}/${ref.name}` + : `refs/tags/${ref.name}`; + + return { + id, + name: ref.name, + type: toGitRefTypeDto(ref.type), + revision: ref.commit + } satisfies GitRefDto; + }); + + return result.filter(ref => !!ref); + } catch { + return []; + } + } private async _ensureGitApi(): Promise { if (this._gitApi) { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 1fac629812d06..a9e08c350c5db 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -181,7 +181,11 @@ abstract class SubmitAction extends Action2 { } } -const requestInProgressOrPendingToolCall = ContextKeyExpr.or(ChatContextKeys.requestInProgress, ChatContextKeys.Editing.hasToolConfirmation); +const requestInProgressOrPendingToolCall = ContextKeyExpr.or( + ChatContextKeys.requestInProgress, + ChatContextKeys.Editing.hasToolConfirmation, + ChatContextKeys.Editing.hasQuestionCarousel, +); const whenNotInProgress = ChatContextKeys.requestInProgress.negate(); export class ChatSubmitAction extends SubmitAction { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index d1284a8d1e601..20aa1bdcd56b8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -1054,20 +1054,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const freeformTextarea = this._freeformTextareas.get(question.id); const freeformValue = freeformTextarea?.value !== '' ? freeformTextarea?.value : undefined; - // Only include defaults if nothing selected AND no freeform input - let finalSelectedValues = selectedValues; - if (selectedValues.length === 0 && !freeformValue && question.defaultValue !== undefined) { - const defaultIds = Array.isArray(question.defaultValue) - ? question.defaultValue - : [question.defaultValue]; - const defaultValues = question.options - ?.filter(opt => defaultIds.includes(opt.id)) - .map(opt => opt.value); - finalSelectedValues = defaultValues?.filter(v => v !== undefined) || []; - } - - if (freeformValue || finalSelectedValues.length > 0) { - return { selectedValues: finalSelectedValues, freeformValue }; + // Return whatever was selected - defaults are applied at render time when + // checkboxes are initially checked, so empty selection means user unchecked all + if (freeformValue || selectedValues.length > 0) { + return { selectedValues, freeformValue }; } return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 929c68cecafa8..d0a42f3645d52 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -128,7 +128,7 @@ export function buildModelPickerItems( currentVSCodeVersion: string, updateStateType: StateType, onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, - upgradePlanUrl: string | undefined, + manageSettingsUrl: string | undefined, commandService: ICommandService, chatEntitlementService: IChatEntitlementService, ): IActionListItem[] { @@ -184,7 +184,7 @@ export function buildModelPickerItems( // --- 2. Promoted section (selected + recently used + featured) --- type PromotedItem = | { kind: 'available'; model: ILanguageModelChatMetadataAndIdentifier } - | { kind: 'unavailable'; entry: IModelControlEntry; reason: 'upgrade' | 'update' | 'admin' }; + | { kind: 'unavailable'; id: string; entry: IModelControlEntry; reason: 'upgrade' | 'update' | 'admin' }; const promotedItems: PromotedItem[] = []; @@ -198,7 +198,7 @@ export function buildModelPickerItems( markPlaced(model.identifier, model.metadata.id); const entry = controlModels[model.metadata.id]; if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - promotedItems.push({ kind: 'unavailable', entry, reason: 'update' }); + promotedItems.push({ kind: 'unavailable', id: model.metadata.id, entry, reason: 'update' }); } else { promotedItems.push({ kind: 'available', model }); } @@ -206,9 +206,9 @@ export function buildModelPickerItems( } if (!model) { const entry = controlModels[id]; - if (entry) { + if (entry && !entry.exists) { markPlaced(id); - promotedItems.push({ kind: 'unavailable', entry, reason: getUnavailableReason(entry) }); + promotedItems.push({ kind: 'unavailable', id, entry, reason: getUnavailableReason(entry) }); return true; } } @@ -226,21 +226,21 @@ export function buildModelPickerItems( } // Featured models from control manifest - for (const entry of Object.values(controlModels)) { - if (!entry.featured || placed.has(entry.id)) { + for (const [entryId, entry] of Object.entries(controlModels)) { + if (!entry.featured || placed.has(entryId)) { continue; } - const model = resolveModel(entry.id); + const model = resolveModel(entryId); if (model && !placed.has(model.identifier)) { markPlaced(model.identifier, model.metadata.id); if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - promotedItems.push({ kind: 'unavailable', entry, reason: 'update' }); + promotedItems.push({ kind: 'unavailable', id: entryId, entry, reason: 'update' }); } else { promotedItems.push({ kind: 'available', model }); } - } else if (!model) { - markPlaced(entry.id); - promotedItems.push({ kind: 'unavailable', entry, reason: getUnavailableReason(entry) }); + } else if (!model && !entry.exists) { + markPlaced(entryId); + promotedItems.push({ kind: 'unavailable', id: entryId, entry, reason: getUnavailableReason(entry) }); } } @@ -259,7 +259,7 @@ export function buildModelPickerItems( if (item.kind === 'available') { items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect), item.model)); } else { - items.push(createUnavailableModelItem(item.entry, item.reason, upgradePlanUrl, updateStateType)); + items.push(createUnavailableModelItem(item.id, item.entry, item.reason, manageSettingsUrl, updateStateType)); } } } @@ -301,7 +301,7 @@ export function buildModelPickerItems( for (const model of otherModels) { const entry = controlModels[model.metadata.id] ?? controlModels[model.identifier]; if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - items.push(createUnavailableModelItem(entry, 'update', upgradePlanUrl, updateStateType, ModelPickerSection.Other)); + items.push(createUnavailableModelItem(model.metadata.id, entry, 'update', manageSettingsUrl, updateStateType, ModelPickerSection.Other)); } else { items.push(createModelItem(createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other), model)); } @@ -373,30 +373,29 @@ export function buildModelPickerItems( } function createUnavailableModelItem( + id: string, entry: IModelControlEntry, reason: 'upgrade' | 'update' | 'admin', - upgradePlanUrl: string | undefined, + manageSettingsUrl: string | undefined, updateStateType: StateType, section?: string, ): IActionListItem { let description: string | MarkdownString | undefined; if (reason === 'upgrade') { - description = upgradePlanUrl - ? new MarkdownString(localize('chat.modelPicker.upgradeLink', "[Upgrade your plan]({0})", upgradePlanUrl), { isTrusted: true }) - : localize('chat.modelPicker.upgrade', "Upgrade"); + description = new MarkdownString(localize('chat.modelPicker.upgradeLink', "[Upgrade your plan](command:workbench.action.chat.upgradePlan \" \")"), { isTrusted: true }); } else if (reason === 'update') { description = localize('chat.modelPicker.updateDescription', "Update VS Code"); } else { - description = localize('chat.modelPicker.adminDescription', "Contact your admin"); + description = manageSettingsUrl + ? new MarkdownString(localize('chat.modelPicker.adminLink', "[Contact your admin]({0})", manageSettingsUrl), { isTrusted: true }) + : localize('chat.modelPicker.adminDescription', "Contact your admin"); } let hoverContent: MarkdownString; if (reason === 'upgrade') { hoverContent = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); - hoverContent.appendMarkdown(upgradePlanUrl - ? localize('chat.modelPicker.upgradeHover', "This model requires a paid plan. [Upgrade]({0}) to access it.", upgradePlanUrl) - : localize('chat.modelPicker.upgradeHoverNoLink', "This model requires a paid plan.")); + hoverContent.appendMarkdown(localize('chat.modelPicker.upgradeHover', "[Upgrade your plan](command:workbench.action.chat.upgradePlan \" \") to use this model.")); } else if (reason === 'update') { hoverContent = getUpdateHoverContent(updateStateType); } else { @@ -406,7 +405,7 @@ function createUnavailableModelItem( return { item: { - id: entry.id, + id, enabled: false, checked: false, class: undefined, @@ -419,7 +418,6 @@ function createUnavailableModelItem( label: entry.label, description, disabled: true, - group: { title: '' }, hideIcon: false, section, hover: { content: hoverContent }, @@ -546,7 +544,7 @@ export class ModelPickerWidget extends Disposable { this._productService.version, this._updateService.state.type, onSelect, - this._productService.defaultChatAgent?.upgradePlanUrl, + this._productService.defaultChatAgent?.manageSettingsUrl, this._commandService, this._entitlementService ); diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 0842bfccc8da8..803c331fb67ec 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -402,10 +402,10 @@ export interface ILanguageModelsService { } export interface IModelControlEntry { - readonly id: string; readonly label: string; readonly featured?: boolean; readonly minVSCodeVersion?: string; + readonly exists: boolean; } export interface IModelsControlManifest { @@ -507,8 +507,8 @@ interface IChatControlResponse { readonly version: number; readonly restrictedChatParticipants: { [name: string]: string[] }; readonly models?: { - readonly free?: Record; - readonly paid?: Record; + readonly free?: Record; + readonly paid?: Record; }; } @@ -542,6 +542,7 @@ export class LanguageModelsService implements ILanguageModelsService { readonly onDidChangeModelsControlManifest = this._onDidChangeModelsControlManifest.event; private _modelsControlManifest: IModelsControlManifest = { free: {}, paid: {} }; + private _modelsControlRawResponse: IChatControlResponse['models'] | undefined; private _chatControlUrl: string | undefined; private _chatControlDisposed = false; @@ -566,7 +567,10 @@ export class LanguageModelsService implements ILanguageModelsService { this._initChatControlData(); this._store.add(this._storageService.onDidChangeValue(StorageScope.PROFILE, CHAT_MODEL_PICKER_PREFERENCES_STORAGE_KEY, this._store)(() => this._onDidChangeModelPickerPreferences())); - this._store.add(this.onDidChangeLanguageModels(() => this._hasUserSelectableModels.set(this._modelCache.size > 0 && Array.from(this._modelCache.values()).some(model => model.isUserSelectable)))); + this._store.add(this.onDidChangeLanguageModels(() => { + this._hasUserSelectableModels.set(this._modelCache.size > 0 && Array.from(this._modelCache.values()).some(model => model.isUserSelectable)); + this._refreshModelsControlManifest(); + })); this._store.add(this._languageModelsConfigurationService.onDidChangeLanguageModelGroups(changedGroups => this._onDidChangeLanguageModelGroups(changedGroups))); this._store.add(languageModelChatProviderExtensionPoint.setHandler((extensions, { added, removed }) => { @@ -1420,26 +1424,32 @@ export class LanguageModelsService implements ILanguageModelsService { } private _setModelsControlManifest(response: IChatControlResponse['models']): void { + this._modelsControlRawResponse = response; + this._refreshModelsControlManifest(); + } + + private _refreshModelsControlManifest(): void { + const response = this._modelsControlRawResponse; const free: IStringDictionary = {}; const paid: IStringDictionary = {}; if (response?.free) { const freeEntries = Array.isArray(response.free) ? response.free : Object.values(response.free); for (const entry of freeEntries) { - if (!entry || !isObject(entry) || typeof entry.id !== 'string') { + if (!entry || !isObject(entry)) { continue; } - free[entry.id] = { id: entry.id, label: entry.label, featured: entry.featured }; + free[entry.id] = { label: entry.label, featured: entry.featured, exists: this._modelExistsInCache(entry.id) }; } } if (response?.paid) { const paidEntries = Array.isArray(response.paid) ? response.paid : Object.values(response.paid); for (const entry of paidEntries) { - if (!entry || !isObject(entry) || typeof entry.id !== 'string') { + if (!entry || !isObject(entry)) { continue; } - paid[entry.id] = { id: entry.id, label: entry.label, featured: entry.featured, minVSCodeVersion: entry.minVSCodeVersion }; + paid[entry.id] = { label: entry.label, featured: entry.featured, minVSCodeVersion: entry.minVSCodeVersion, exists: this._modelExistsInCache(entry.id) }; } } @@ -1447,6 +1457,15 @@ export class LanguageModelsService implements ILanguageModelsService { this._onDidChangeModelsControlManifest.fire(this._modelsControlManifest); } + private _modelExistsInCache(metadataId: string): boolean { + for (const model of this._modelCache.values()) { + if (model.id === metadataId) { + return true; + } + } + return false; + } + //#region Chat control data private _initChatControlData(): void { @@ -1489,16 +1508,34 @@ export class LanguageModelsService implements ILanguageModelsService { } private async _fetchChatControlData(): Promise { - const context = await this._requestService.request({ type: 'GET', url: this._chatControlUrl! }, CancellationToken.None); + this._logService.trace('[LM] Fetching chat control data from', this._chatControlUrl); + + let context; + try { + context = await this._requestService.request({ type: 'GET', url: this._chatControlUrl! }, CancellationToken.None); + } catch (err) { + this._logService.warn('[LM] Failed to request chat control data', getErrorMessage(err)); + return; + } if (context.res.statusCode !== 200) { - throw new Error('Could not get chat control data.'); + this._logService.warn(`[LM] Chat control data request failed with status ${context.res.statusCode}`); + return; } - const result = await asJson(context); + let result: IChatControlResponse | null; + try { + result = await asJson(context); + } catch (err) { + this._logService.warn('[LM] Failed to parse chat control response', getErrorMessage(err)); + return; + } + + this._logService.trace('[LM] Received chat control response', result ? Object.keys(result) : 'null'); if (!result || result.version !== 1) { - throw new Error('Unexpected chat control response.'); + this._logService.warn('[LM] Unexpected chat control response version', result?.version); + return; } // Update restricted chat participants @@ -1508,6 +1545,7 @@ export class LanguageModelsService implements ILanguageModelsService { // Update models control manifest if (result.models) { + this._logService.trace('[LM] Updating models control manifest', { freeCount: Object.keys(result.models.free ?? {}).length, paidCount: Object.keys(result.models.paid ?? {}).length }); this._setModelsControlManifest(result.models); this._storageService.store(CHAT_MODELS_CONTROL_STORAGE_KEY, JSON.stringify(result.models), StorageScope.APPLICATION, StorageTarget.MACHINE); } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts index 7e8c1835c3d3f..527259458ab1f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { IStringDictionary } from '../../../../../../../base/common/collections.js'; +import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { ActionListItemKind, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'; @@ -70,7 +71,7 @@ function callBuild( isProUser?: boolean; currentVSCodeVersion?: string; updateStateType?: StateType; - upgradePlanUrl?: string; + manageSettingsUrl?: string; } = {}, ): IActionListItem[] { const onSelect = () => { }; @@ -83,7 +84,7 @@ function callBuild( opts.currentVSCodeVersion ?? '1.100.0', opts.updateStateType ?? StateType.Idle, onSelect, - opts.upgradePlanUrl, + opts.manageSettingsUrl, stubCommandService, stubChatEntitlementService as IChatEntitlementService, ); @@ -138,7 +139,7 @@ suite('buildModelPickerItems', () => { const items = callBuild([auto, modelA], { selectedModelId: modelA.identifier, controlModels: { - 'gpt-4o': { id: 'gpt-4o', label: 'GPT-4o', minVSCodeVersion: '2.0.0' }, + 'gpt-4o': { label: 'GPT-4o', minVSCodeVersion: '2.0.0', exists: true }, }, currentVSCodeVersion: '1.90.0', }); @@ -169,7 +170,7 @@ suite('buildModelPickerItems', () => { const items = callBuild([auto], { recentModelIds: ['missing-model'], controlModels: { - 'missing-model': { id: 'missing-model', label: 'Missing Model' }, + 'missing-model': { label: 'Missing Model', exists: false }, }, isProUser: false, }); @@ -184,7 +185,7 @@ suite('buildModelPickerItems', () => { const items = callBuild([auto], { recentModelIds: ['missing-model'], controlModels: { - 'missing-model': { id: 'missing-model', label: 'Missing Model', minVSCodeVersion: '2.0.0' }, + 'missing-model': { label: 'Missing Model', minVSCodeVersion: '2.0.0', exists: false }, }, isProUser: true, currentVSCodeVersion: '1.90.0', @@ -200,7 +201,7 @@ suite('buildModelPickerItems', () => { const items = callBuild([auto], { recentModelIds: ['missing-model'], controlModels: { - 'missing-model': { id: 'missing-model', label: 'Missing Model' }, + 'missing-model': { label: 'Missing Model', exists: false }, }, isProUser: true, }); @@ -216,7 +217,7 @@ suite('buildModelPickerItems', () => { const modelB = createModel('claude', 'Claude'); const items = callBuild([auto, modelA, modelB], { controlModels: { - 'gpt-4o': { id: 'gpt-4o', label: 'GPT-4o', featured: true }, + 'gpt-4o': { label: 'GPT-4o', featured: true, exists: true }, }, }); const actions = getActionItems(items); @@ -229,7 +230,7 @@ suite('buildModelPickerItems', () => { const auto = createAutoModel(); const items = callBuild([auto], { controlModels: { - 'premium-model': { id: 'premium-model', label: 'Premium Model', featured: true }, + 'premium-model': { label: 'Premium Model', featured: true, exists: false }, }, isProUser: false, }); @@ -243,7 +244,7 @@ suite('buildModelPickerItems', () => { const auto = createAutoModel(); const items = callBuild([auto], { controlModels: { - 'premium-model': { id: 'premium-model', label: 'Premium Model', featured: true }, + 'premium-model': { label: 'Premium Model', featured: true, exists: false }, }, isProUser: true, }); @@ -258,7 +259,7 @@ suite('buildModelPickerItems', () => { const modelA = createModel('gpt-4o', 'GPT-4o'); const items = callBuild([auto, modelA], { controlModels: { - 'gpt-4o': { id: 'gpt-4o', label: 'GPT-4o', featured: true, minVSCodeVersion: '2.0.0' }, + 'gpt-4o': { label: 'GPT-4o', featured: true, minVSCodeVersion: '2.0.0', exists: true }, }, currentVSCodeVersion: '1.90.0', }); @@ -274,7 +275,7 @@ suite('buildModelPickerItems', () => { const modelB = createModel('claude', 'Claude'); const items = callBuild([auto, modelA, modelB], { controlModels: { - 'gpt-4o': { id: 'gpt-4o', label: 'GPT-4o', featured: false }, + 'gpt-4o': { label: 'GPT-4o', featured: false, exists: true }, }, }); // With no selected, no recent, and no featured, both models should be in Other @@ -308,7 +309,7 @@ suite('buildModelPickerItems', () => { const items = callBuild([auto, modelA], { recentModelIds: [modelA.identifier, 'missing-model'], controlModels: { - 'missing-model': { id: 'missing-model', label: 'Missing Model' }, + 'missing-model': { label: 'Missing Model', exists: false }, }, isProUser: false, }); @@ -356,7 +357,7 @@ suite('buildModelPickerItems', () => { const modelA = createModel('gpt-4o', 'GPT-4o'); const items = callBuild([auto, modelA], { controlModels: { - 'gpt-4o': { id: 'gpt-4o', label: 'GPT-4o', minVSCodeVersion: '2.0.0' }, + 'gpt-4o': { label: 'GPT-4o', minVSCodeVersion: '2.0.0', exists: true }, }, currentVSCodeVersion: '1.90.0', }); @@ -375,8 +376,8 @@ suite('buildModelPickerItems', () => { selectedModelId: modelA.identifier, recentModelIds: [modelA.identifier, modelB.identifier], controlModels: { - 'gpt-4o': { id: 'gpt-4o', label: 'GPT-4o', featured: true }, - 'claude': { id: 'claude', label: 'Claude', featured: true }, + 'gpt-4o': { label: 'GPT-4o', featured: true, exists: true }, + 'claude': { label: 'Claude', featured: true, exists: true }, }, }); const labels = getActionLabels(items).filter(l => l !== 'Other Models' && !l.includes('Manage Models')); @@ -391,7 +392,7 @@ suite('buildModelPickerItems', () => { selectedModelId: auto.identifier, recentModelIds: [auto.identifier], controlModels: { - 'auto': { id: 'auto', label: 'Auto', featured: true }, + 'auto': { label: 'Auto', featured: true, exists: true }, }, }); const autoItems = getActionItems(items).filter(a => a.label === 'Auto'); @@ -497,7 +498,7 @@ suite('buildModelPickerItems', () => { const items = callBuild([auto, modelA, modelB, modelC, modelD], { recentModelIds: [modelC.identifier], controlModels: { - 'alpha': { id: 'alpha', label: 'Alpha', featured: true }, + 'alpha': { label: 'Alpha', featured: true, exists: true }, }, }); const actions = getActionItems(items); @@ -508,4 +509,28 @@ suite('buildModelPickerItems', () => { // Then Other Models toggle assert.ok(actions[3].isSectionToggle); }); + + test('admin unavailable model shows manage settings link in description', () => { + const auto = createAutoModel(); + const items = buildModelPickerItems( + [auto], + undefined, + ['missing-model'], + { 'missing-model': { label: 'Missing Model' } }, + true, + '1.100.0', + StateType.Idle, + () => { }, + 'https://aka.ms/github-copilot-settings', + stubCommandService, + stubChatEntitlementService as IChatEntitlementService, + ); + + const adminItem = getActionItems(items).find(a => a.label === 'Missing Model'); + assert.ok(adminItem); + assert.strictEqual(adminItem.disabled, true); + const description = adminItem.description; + assert.ok(description instanceof MarkdownString); + assert.ok(description.value.includes('https://aka.ms/github-copilot-settings')); + }); }); diff --git a/src/vs/workbench/contrib/git/browser/gitService.ts b/src/vs/workbench/contrib/git/browser/gitService.ts index c298ec69e2a76..8b6cab3183580 100644 --- a/src/vs/workbench/contrib/git/browser/gitService.ts +++ b/src/vs/workbench/contrib/git/browser/gitService.ts @@ -3,29 +3,80 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Sequencer } from '../../../../base/common/async.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { BugIndicatingError } from '../../../../base/common/errors.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; import { URI } from '../../../../base/common/uri.js'; -import { IGitService, IGitExtensionService } from '../common/gitService.js'; +import { IGitService, IGitExtensionDelegate, GitRef, GitRefQuery, IGitRepository } from '../common/gitService.js'; export class GitService extends Disposable implements IGitService { declare readonly _serviceBrand: undefined; - private _delegate: IGitExtensionService | undefined; + private _delegate: IGitExtensionDelegate | undefined; + private readonly _openRepositorySequencer = new Sequencer(); + + private readonly _repositories = new ResourceMap(); + get repositories(): Iterable { + return this._repositories.values(); + } + + setDelegate(delegate: IGitExtensionDelegate): IDisposable { + // The delegate can only be set once, since the vscode.git + // extension can only run in one extension host process per + // window. + if (this._delegate) { + throw new BugIndicatingError('GitService delegate is already set.'); + } - setDelegate(delegate: IGitExtensionService): void { this._delegate = delegate; + + return toDisposable(() => { + this._repositories.clear(); + this._delegate = undefined; + }); } - clearDelegate(): void { - this._delegate = undefined; + async openRepository(uri: URI): Promise { + return this._openRepositorySequencer.queue(async () => { + if (!this._delegate) { + return undefined; + } + + // Check whether we have an opened repository for the uri + let repository = this._repositories.get(uri); + if (repository) { + return repository; + } + + // Open the repository to get the repository root + const root = await this._delegate.openRepository(uri); + if (!root) { + return undefined; + } + + const rootUri = URI.revive(root); + + // Check whether we have an opened repository for the root + repository = this._repositories.get(rootUri); + if (repository) { + return repository; + } + + // Create a new repository + repository = new GitRepository(this._delegate, rootUri); + this._repositories.set(rootUri, repository); + + return repository; + }); } +} - async openRepository(root: URI): Promise { - if (!this._delegate) { - return undefined; - } +export class GitRepository implements IGitRepository { + constructor(private readonly delegate: IGitExtensionDelegate, readonly rootUri: URI) { } - const result = await this._delegate.openRepository(root); - return result ? URI.revive(result) : undefined; + async getRefs(query: GitRefQuery, token?: CancellationToken): Promise { + return this.delegate.getRefs(this.rootUri, query, token); } } diff --git a/src/vs/workbench/contrib/git/common/gitService.ts b/src/vs/workbench/contrib/git/common/gitService.ts index 3092386866947..89f76d083cf00 100644 --- a/src/vs/workbench/contrib/git/common/gitService.ts +++ b/src/vs/workbench/contrib/git/common/gitService.ts @@ -3,15 +3,38 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -/** - * Delegate interface that bridges to the git extension running - * in the extension host. Set by MainThreadGit when an extension - * host connects. - */ -export interface IGitExtensionService { +export enum GitRefType { + Head, + RemoteHead, + Tag +} + +export interface GitRef { + readonly type: GitRefType; + readonly name?: string; + readonly commit?: string; + readonly remote?: string; +} + +export interface GitRefQuery { + readonly contains?: string; + readonly count?: number; + readonly pattern?: string | string[]; + readonly sort?: 'alphabetically' | 'committerdate' | 'creatordate'; +} + +export interface IGitRepository { + readonly rootUri: URI; + getRefs(query: GitRefQuery, token?: CancellationToken): Promise; +} + +export interface IGitExtensionDelegate { + getRefs(uri: UriComponents, query?: GitRefQuery, token?: CancellationToken): Promise; openRepository(uri: UriComponents): Promise; } @@ -20,12 +43,9 @@ export const IGitService = createDecorator('gitService'); export interface IGitService { readonly _serviceBrand: undefined; - setDelegate(delegate: IGitExtensionService): void; - clearDelegate(): void; + readonly repositories: Iterable; + + setDelegate(delegate: IGitExtensionDelegate): IDisposable; - /** - * Open a git repository at the given URI. - * @returns The repository root URI or `undefined` if the repository could not be opened. - */ - openRepository(uri: URI): Promise; + openRepository(uri: URI): Promise; }