Skip to content
Merged
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
98 changes: 79 additions & 19 deletions extensions/json-language-features/client/src/jsonClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Disposable>();
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.
Expand Down Expand Up @@ -767,7 +802,11 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP

async function computeSchemaAssociations(): Promise<ISchemaAssociation[]> {
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[] {
Expand Down Expand Up @@ -806,20 +845,41 @@ function getSchemaExtensionAssociations(): ISchemaAssociation[] {
return associations;
}

async function getDynamicSchemaAssociations(): Promise<ISchemaAssociation[]> {
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<ISchemaAssociation[]> {
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 = <Record<string, string[]>>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;
}
Expand Down
5 changes: 5 additions & 0 deletions extensions/json-language-features/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@
"url": "http://json-schema.org/draft-07/schema#"
}
],
"jsonValidationRegistry": [
{
"url": "vscode://schemas-associations/schemas-associations.json"
}
],
"commands": [
{
"command": "json.clearCache",
Expand Down
5 changes: 5 additions & 0 deletions src/vs/platform/extensions/common/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export interface IJSONValidation {
url: string;
}

export interface IJSONValidationRegistry {
url: string;
}

export interface IKeyBinding {
command: string;
key: string;
Expand Down Expand Up @@ -214,6 +218,7 @@ export interface IExtensionContributions {
debuggers?: IDebugger[];
grammars?: IGrammar[];
jsonValidation?: IJSONValidation[];
jsonValidationRegistry?: IJSONValidationRegistry[];
keybindings?: IKeyBinding[];
languages?: ILanguage[];
menus?: { [context: string]: IMenu[] };
Expand Down
2 changes: 1 addition & 1 deletion src/vs/sessions/AI_CUSTOMIZATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
56 changes: 56 additions & 0 deletions src/vs/workbench/api/common/jsonValidationExtensionPoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ interface IJSONValidationExtensionPoint {
url: string;
}

interface IJSONValidationRegistryExtensionPoint {
url: string;
}

const configurationExtPoint = ExtensionsRegistry.registerExtensionPoint<IJSONValidationExtensionPoint[]>({
extensionPoint: 'jsonValidation',
defaultExtensionKind: ['workspace', 'web'],
Expand Down Expand Up @@ -46,6 +50,26 @@ const configurationExtPoint = ExtensionsRegistry.registerExtensionPoint<IJSONVal
}
});

const registryExtPoint = ExtensionsRegistry.registerExtensionPoint<IJSONValidationRegistryExtensionPoint[]>({
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() {
Expand Down Expand Up @@ -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."));
}
}
}
});
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const SESSIONS_WINDOW_ALLOWED_CONTRIBUTION_POINTS: ReadonlySet<keyof IExtensionC
'colors',
'keybindings',
'jsonValidation',
'jsonValidationRegistry',
'localizations',
'grammars',
'languages',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"iconThemes",
"icons",
"jsonValidation",
"jsonValidationRegistry",
"keybindings",
"languageModelChatProviders",
"languageModelToolSets",
Expand Down
Loading