diff --git a/extensions/json-language-features/client/src/jsonClient.ts b/extensions/json-language-features/client/src/jsonClient.ts index 06dfa8efe48db9..fa1c6d10209921 100644 --- a/extensions/json-language-features/client/src/jsonClient.ts +++ b/extensions/json-language-features/client/src/jsonClient.ts @@ -544,14 +544,49 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(false)); - toDispose.push(extensions.onDidChange(async _ => { - client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(true)); + let schemaAssociationRefreshGeneration = 0; + let schemaAssociationRefreshTrigger: Disposable | undefined; + const refreshSchemaAssociations = () => { + const generation = ++schemaAssociationRefreshGeneration; + schemaAssociationRefreshTrigger?.dispose(); + schemaAssociationRefreshTrigger = runtime.timer.setTimeout(async () => { + schemaAssociationRefreshTrigger = undefined; + const associations = await getSchemaAssociations(true); + if (generation === schemaAssociationRefreshGeneration) { + client.sendNotification(SchemaAssociationNotification.type, associations); + } + }, 500); + }; + toDispose.push(new Disposable(() => { + schemaAssociationRefreshGeneration++; + schemaAssociationRefreshTrigger?.dispose(); })); - const associationWatcher = workspace.createFileSystemWatcher(new RelativePattern(Uri.parse(`vscode://schemas-associations/`), '**/schemas-associations.json')); - toDispose.push(associationWatcher); - toDispose.push(associationWatcher.onDidChange(async _e => { - client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(true)); + const registryWatchers = new Map(); + const updateRegistryWatchers = () => { + const registryUris = new Set(getSchemaRegistryUris().map(uri => uri.toString())); + for (const [uri, watcher] of registryWatchers) { + if (!registryUris.has(uri)) { + watcher.dispose(); + registryWatchers.delete(uri); + } + } + for (const uri of registryUris) { + if (!registryWatchers.has(uri)) { + const registryUri = Uri.parse(uri); + const fileName = registryUri.path.substring(registryUri.path.lastIndexOf('/') + 1); + const parentUri = registryUri.with({ path: registryUri.path.substring(0, registryUri.path.length - fileName.length), query: undefined, fragment: undefined }); + const watcher = workspace.createFileSystemWatcher(new RelativePattern(parentUri, fileName)); + registryWatchers.set(uri, Disposable.from(watcher, watcher.onDidCreate(refreshSchemaAssociations), watcher.onDidChange(refreshSchemaAssociations), watcher.onDidDelete(refreshSchemaAssociations))); + } + } + }; + updateRegistryWatchers(); + toDispose.push(new Disposable(() => registryWatchers.forEach(watcher => watcher.dispose()))); + + toDispose.push(extensions.onDidChange(() => { + updateRegistryWatchers(); + refreshSchemaAssociations(); })); // manually register / deregister format provider based on the `json.format.enable` setting avoiding issues with late registration. See #71652. @@ -767,7 +802,11 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP async function computeSchemaAssociations(): Promise { const extensionAssociations = getSchemaExtensionAssociations(); - return extensionAssociations.concat(await getDynamicSchemaAssociations()); + return extensionAssociations.concat(await getSchemaRegistryAssociations()); +} + +function resolveExtensionResource(extensionUri: Uri, resource: string): Uri { + return resource.startsWith('./') ? Uri.joinPath(extensionUri, resource) : Uri.parse(resource); } function getSchemaExtensionAssociations(): ISchemaAssociation[] { @@ -806,20 +845,41 @@ function getSchemaExtensionAssociations(): ISchemaAssociation[] { return associations; } -async function getDynamicSchemaAssociations(): Promise { +function getSchemaRegistryUris(): Uri[] { + const result: Uri[] = []; + for (const extension of extensions.allAcrossExtensionHosts) { + const registrys = extension.packageJSON?.contributes?.jsonValidationRegistry; + if (Array.isArray(registrys)) { + for (const registry of registrys) { + if (typeof registry?.url === 'string') { + result.push(resolveExtensionResource(extension.extensionUri, registry.url)); + } + } + } + } + return result; +} + +async function getSchemaRegistryAssociations(): Promise { const result: ISchemaAssociation[] = []; - try { - const data = await workspace.fs.readFile(Uri.parse(`vscode://schemas-associations/schemas-associations.json`)); - const rawStr = new TextDecoder().decode(data); - const obj = >JSON.parse(rawStr); - for (const item of Object.keys(obj)) { - result.push({ - fileMatch: obj[item], - uri: item - }); + for (const registryUri of getSchemaRegistryUris()) { + try { + const data = await workspace.fs.readFile(registryUri); + const rawStr = new TextDecoder().decode(data); + const registry = <{ schemas?: { url?: string; fileMatch?: string[] }[] }>JSON.parse(rawStr); + if (Array.isArray(registry.schemas)) { + for (const schema of registry.schemas) { + if (typeof schema.url === 'string' && Array.isArray(schema.fileMatch) && schema.fileMatch.every(fileMatch => typeof fileMatch === 'string')) { + result.push({ + fileMatch: schema.fileMatch, + uri: schema.url + }); + } + } + } + } catch { + // Ignore unavailable or invalid registry. } - } catch { - // ignore } return result; } diff --git a/extensions/json-language-features/package.json b/extensions/json-language-features/package.json index c616bf19b19ecf..cf3e0d5ecc691e 100644 --- a/extensions/json-language-features/package.json +++ b/extensions/json-language-features/package.json @@ -174,6 +174,11 @@ "url": "http://json-schema.org/draft-07/schema#" } ], + "jsonValidationRegistry": [ + { + "url": "vscode://schemas-associations/schemas-associations.json" + } + ], "commands": [ { "command": "json.clearCache", diff --git a/src/vs/platform/extensions/common/extensions.ts b/src/vs/platform/extensions/common/extensions.ts index 6a8cc98d148b24..af9aa6e24857fa 100644 --- a/src/vs/platform/extensions/common/extensions.ts +++ b/src/vs/platform/extensions/common/extensions.ts @@ -36,6 +36,10 @@ export interface IJSONValidation { url: string; } +export interface IJSONValidationRegistry { + url: string; +} + export interface IKeyBinding { command: string; key: string; @@ -214,6 +218,7 @@ export interface IExtensionContributions { debuggers?: IDebugger[]; grammars?: IGrammar[]; jsonValidation?: IJSONValidation[]; + jsonValidationRegistry?: IJSONValidationRegistry[]; keybindings?: IKeyBinding[]; languages?: ILanguage[]; menus?: { [context: string]: IMenu[] }; diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 6fbdd080e1a195..142d7c1dd00db6 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -73,7 +73,7 @@ The Tools section can browse the Marketplace in the core workbench, where extens Agent Host MCP **Show Output** actions prepare and register their target channel, close the modal management editor, then reveal the prepared channel. Closing before preparation can tear down the active harness context, while showing before close lets modal teardown reset the Output presentation. -When the active harness is an agent host (`agent-host-*` / `remote-*`), the overview can render a **Migrate** card. The card appears only when the core `IPromptsService` still discovers local/user `*.prompt.md` files, because those files are ignored by agent-host harnesses, and only when the experimental `chat.customizations.promptMigration.enabled` setting is enabled. The left sidebar also renders a bottom **Migrate Prompt Files** shortcut in that state so the flow is discoverable even when the overview is not visible. Choosing either entry opens a dedicated migration page where users can review all migratable prompt files, select the ones to migrate, and open individual files before running migration. The migrate action converts selected prompt files into skills under the harness-appropriate skill roots (for example `.github/skills` / `~/.copilot/skills` for Copilot, `.claude/skills` / `~/.claude/skills` for Claude), preserves manual invocation by setting `disable-model-invocation: true`, and removes the original prompt files. If multiple workspace skill roots are available, migration prompts once to choose the workspace target and reuses that target for all migrated workspace prompts. +When the active harness is an agent host (`agent-host-*` / `remote-*`), the overview can render a **Migrate** card. The card appears only when the core `IPromptsService` still discovers local/user `*.prompt.md` files, because those files are ignored by agent-host harnesses, and only when the experimental `chat.customizations.promptMigration.enabled` setting is enabled. The left sidebar also renders a bottom **Migrate Prompt Files** shortcut in that state so the flow is discoverable even when the overview is not visible. Choosing either entry opens a dedicated migration page where users can review all migratable prompt files, select the ones to migrate, and open individual files before running migration. Workspace and User prompt-file groups on that page are independently collapsible so large migrations stay scannable. The migrate action converts selected prompt files into skills under the harness-appropriate skill roots (for example `.github/skills` / `~/.copilot/skills` for Copilot, `.claude/skills` / `~/.claude/skills` for Claude), preserves manual invocation by setting `disable-model-invocation: true`, and removes the original prompt files. If multiple workspace skill roots are available, migration prompts once to choose the workspace target and reuses that target for all migrated workspace prompts. Automation run history stores the created session as a serialized URI. Its Open Session action uses the shared resource-first session opener, allowing the Agents window to route the URI through `ISessionsService` before the core workbench falls back to resolving an `IAgentSession`. diff --git a/src/vs/workbench/api/common/jsonValidationExtensionPoint.ts b/src/vs/workbench/api/common/jsonValidationExtensionPoint.ts index 24559bee3f800f..cd7f26af674066 100644 --- a/src/vs/workbench/api/common/jsonValidationExtensionPoint.ts +++ b/src/vs/workbench/api/common/jsonValidationExtensionPoint.ts @@ -19,6 +19,10 @@ interface IJSONValidationExtensionPoint { url: string; } +interface IJSONValidationRegistryExtensionPoint { + url: string; +} + const configurationExtPoint = ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'jsonValidation', defaultExtensionKind: ['workspace', 'web'], @@ -46,6 +50,26 @@ const configurationExtPoint = ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'jsonValidationRegistry', + defaultExtensionKind: ['workspace', 'web'], + jsonSchema: { + description: nls.localize('contributes.jsonValidationRegistry', 'Contributes a JSON validation registry. The registry can be a dynamic resource from a filesystem provider and allows associations to change at runtime.'), + type: 'array', + defaultSnippets: [{ body: [{ url: '${1:url}' }] }], + items: { + type: 'object', + defaultSnippets: [{ body: { url: '${1:url}' } }], + properties: { + url: { + description: nls.localize('contributes.jsonValidationRegistry.url', 'A registry URI or relative path to the extension folder (\'./\').'), + type: 'string' + } + } + } + } +}); + export class JSONValidationExtensionPoint { constructor() { @@ -85,6 +109,38 @@ export class JSONValidationExtensionPoint { }); } }); + + registryExtPoint.setHandler(extensions => { + for (const extension of extensions) { + const catalogs = extension.value; + const collector = extension.collector; + const extensionLocation = extension.description.extensionLocation; + + if (!Array.isArray(catalogs)) { + collector.error(nls.localize('invalid.jsonValidationRegistry', "'configuration.jsonValidationRegistry' must be an array")); + continue; + } + for (const catalog of catalogs) { + const uri = catalog?.url; + if (!isString(uri)) { + collector.error(nls.localize('invalid.jsonValidationRegistry.url', "'configuration.jsonValidationRegistry.url' must be a URI or relative path")); + continue; + } + if (uri.startsWith('./')) { + try { + const catalogLocation = resources.joinPath(extensionLocation, uri); + if (!resources.isEqualOrParent(catalogLocation, extensionLocation)) { + collector.warn(nls.localize('invalid.jsonValidationRegistry.path', "Expected `contributes.{0}.url` ({1}) to be included inside extension's folder ({2}). This might make the extension non-portable.", registryExtPoint.name, catalogLocation.toString(), extensionLocation.path)); + } + } catch (e) { + collector.error(nls.localize('invalid.jsonValidationRegistry.fileschema', "'configuration.jsonValidationRegistry.url' is an invalid relative URI: {0}", e.message)); + } + } else if (!/^[^:/?#]+:\/\//.test(uri)) { + collector.error(nls.localize('invalid.jsonValidationRegistry.schema', "'configuration.jsonValidationRegistry.url' must be an absolute URI or start with './' to reference a registry located in the extension.")); + } + } + } + }); } } diff --git a/src/vs/workbench/contrib/preferences/common/settingsFilesystemProvider.ts b/src/vs/workbench/contrib/preferences/common/settingsFilesystemProvider.ts index 171b2458587aba..00a73abea9e4bc 100644 --- a/src/vs/workbench/contrib/preferences/common/settingsFilesystemProvider.ts +++ b/src/vs/workbench/contrib/preferences/common/settingsFilesystemProvider.ts @@ -54,7 +54,8 @@ export class SettingsFileSystemProvider extends Disposable implements IFileSyste if (uri.authority === 'schemas') { content = this.getSchemaContent(uri); } else if (uri.authority === SettingsFileSystemProvider.SCHEMA_ASSOCIATIONS.authority) { - content = JSON.stringify(schemaRegistry.getSchemaAssociations()); + const schemas = Object.entries(schemaRegistry.getSchemaAssociations()).map(([url, fileMatch]) => ({ url, fileMatch })); + content = JSON.stringify({ schemas }); } else if (uri.authority === 'defaultsettings') { content = this.preferencesService.getDefaultSettingsContent(uri); } diff --git a/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts b/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts index 1226578ad61ca3..dad5d2898e0e0a 100644 --- a/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts +++ b/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts @@ -31,6 +31,7 @@ const SESSIONS_WINDOW_ALLOWED_CONTRIBUTION_POINTS: ReadonlySet