33 * Licensed under the MIT License. See License.txt in the project root for license information.
44 *--------------------------------------------------------------------------------------------*/
55
6+ import { Limiter } from '../../../../../../base/common/async.js' ;
67import { VSBuffer } from '../../../../../../base/common/buffer.js' ;
78import { Disposable } from '../../../../../../base/common/lifecycle.js' ;
89import { equals } from '../../../../../../base/common/objects.js' ;
910import { ResourceMap } from '../../../../../../base/common/map.js' ;
10- import { basename , dirname } from '../../../../../../base/common/resources.js' ;
11+ import { basename , dirname , extUri } from '../../../../../../base/common/resources.js' ;
1112import { URI } from '../../../../../../base/common/uri.js' ;
1213import { hash } from '../../../../../../base/common/hash.js' ;
13- import { IFileService } from '../../../../../../platform/files/common/files.js' ;
14+ import { IFileService , IFileStatWithPartialMetadata } from '../../../../../../platform/files/common/files.js' ;
15+ import { ILogService } from '../../../../../../platform/log/common/log.js' ;
1416import { IMcpServerConfiguration } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js' ;
1517import { PromptsType } from '../../../common/promptSyntax/promptTypes.js' ;
1618import { AICustomizationSource } from '../../../common/aiCustomizationWorkspaceService.js' ;
@@ -19,11 +21,14 @@ import { withCustomizationEnablement } from '../../../../../../platform/agentHos
1921import { customizationId , type ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js' ;
2022import { CustomizationEnablementKind , CustomizationType , type CustomizationEnablement , type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js' ;
2123import { IAgentHostFileSystemService , SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js' ;
24+ import { IgnoreFile } from '../../../../../../workbench/services/search/common/ignoreFile.js' ;
2225
2326// Re-export so existing consumers don't need to change their import source.
2427export { SYNCED_CUSTOMIZATION_SCHEME } ;
2528
2629const DISPLAY_NAME = 'VS Code Synced Data' ;
30+ const FILE_OPERATION_CONCURRENCY = 10 ;
31+ const SKILL_DIRECTORY_IGNORE = new IgnoreFile ( '.git\nnode_modules\n' , '/' , undefined , true ) ;
2732
2833const MANIFEST_CONTENT = JSON . stringify ( {
2934 name : DISPLAY_NAME ,
@@ -48,6 +53,34 @@ function pluginDirForType(type: PromptsType): string | undefined {
4853 }
4954}
5055
56+ type QueueFileOperation = < T > ( operation : ( ) => Promise < T > ) => Promise < T > ;
57+
58+ async function collectDirectoryFiles ( fileService : IFileService , logService : ILogService , root : URI , directory : URI , queueFileOperation : QueueFileOperation ) : Promise < IFileStatWithPartialMetadata [ ] > {
59+ const stat = await queueFileOperation ( ( ) => fileService . resolve ( directory ) ) ;
60+ const children = ( await Promise . all ( ( stat . children ?? [ ] ) . map ( async child => {
61+ try {
62+ return await queueFileOperation ( ( ) => fileService . stat ( child . resource ) ) ;
63+ } catch ( error ) {
64+ logService . trace ( '[SyncedCustomizationBundler] Failed to stat skill resource' , child . resource . toString ( ) , error ) ;
65+ return undefined ;
66+ }
67+ } ) ) ) . filter ( ( child ) : child is IFileStatWithPartialMetadata => child !== undefined ) ;
68+ const files = await Promise . all ( children . map ( async child => {
69+ const relativePath = extUri . relativePath ( root , child . resource ) ;
70+ if ( relativePath === undefined ) {
71+ throw new Error ( `Unable to resolve skill resource path: ${ child . resource . toString ( ) } ` ) ;
72+ }
73+ if ( child . isSymbolicLink || ! SKILL_DIRECTORY_IGNORE . isPathIncludedInTraversal ( `/${ relativePath } ` , child . isDirectory ) ) {
74+ return [ ] ;
75+ }
76+ if ( child . isDirectory ) {
77+ return collectDirectoryFiles ( fileService , logService , root , child . resource , queueFileOperation ) ;
78+ }
79+ return child . isFile ? [ child ] : [ ] ;
80+ } ) ) ;
81+ return files . flat ( ) ;
82+ }
83+
5184export interface ISyncableFile {
5285 readonly uri : URI ;
5386 readonly type : PromptsType ;
@@ -113,14 +146,15 @@ interface IBundleResult {
113146 * rules/ ← instruction files
114147 * commands/ ← prompt files
115148 * agents/ ← agent files
116- * skills/ ← skill files
149+ * skills/ ← skill directories
117150 * ```
118151 *
119- * The bundler computes a content -based nonce so the agent host can
152+ * The bundler computes a metadata -based nonce so the agent host can
120153 * skip re-loading when nothing has changed.
121154 */
122155export class SyncedCustomizationBundler extends Disposable {
123156
157+ private readonly _fileOperationLimiter = this . _register ( new Limiter < unknown > ( FILE_OPERATION_CONCURRENCY ) ) ;
124158 private readonly _authority : string ;
125159 private _lastNonce : string | undefined ;
126160 private _lastRef : IBundleResult | undefined ;
@@ -131,6 +165,7 @@ export class SyncedCustomizationBundler extends Disposable {
131165 authority : string ,
132166 @IFileService private readonly _fileService : IFileService ,
133167 @IAgentHostFileSystemService agentHostFileSystemService : IAgentHostFileSystemService ,
168+ @ILogService private readonly _logService : ILogService ,
134169 ) {
135170 super ( ) ;
136171 this . _authority = authority ;
@@ -146,12 +181,16 @@ export class SyncedCustomizationBundler extends Disposable {
146181 return URI . from ( { scheme : SYNCED_CUSTOMIZATION_SCHEME , path : `/${ this . _authority } ` } ) ;
147182 }
148183
184+ private _queueFileOperation < T > ( operation : ( ) => Promise < T > ) : Promise < T > {
185+ return this . _fileOperationLimiter . queue ( operation ) as Promise < T > ;
186+ }
187+
149188 /**
150189 * Bundles the given files and MCP servers into the in-memory plugin
151190 * filesystem.
152191 *
153192 * Overwrites any previous bundle content. Returns a {@link ClientPluginCustomization}
154- * pointing at the virtual plugin directory with a content -based nonce.
193+ * pointing at the virtual plugin directory with a metadata -based nonce.
155194 *
156195 * @returns The bundle result, or `undefined` if there is nothing to sync.
157196 */
@@ -161,13 +200,19 @@ export class SyncedCustomizationBundler extends Disposable {
161200 return undefined ;
162201 }
163202
164- // Read every source file up front so the content nonce can be computed
165- // before touching the in-memory tree. This lets us skip the destructive
166- // delete + rewrite entirely when nothing has changed since the last
167- // bundle (a frequent case when a change event fires but content is
168- // identical).
169- const entries : { destUri : URI ; content : VSBuffer ; hashPart : string } [ ] = [ ] ;
203+ const entries : { sourceUri : URI ; destUri : URI ; hashPart : string } [ ] = [ ] ;
170204 const originByDest = new ResourceMap < ISyncedCustomizationOrigin > ( ) ;
205+ const addEntry = ( file : ISyncableFile , source : IFileStatWithPartialMetadata , destUri : URI , hashKey : string ) : void => {
206+ entries . push ( { sourceUri : source . resource , destUri, hashPart : `${ hashKey } :${ source . mtime } :${ source . size } ` } ) ;
207+ if ( file . source !== undefined ) {
208+ originByDest . set ( destUri , {
209+ uri : source . resource ,
210+ source : file . source ,
211+ extensionId : file . extensionId ,
212+ pluginUri : file . pluginUri ,
213+ } ) ;
214+ }
215+ } ;
171216 await Promise . all ( syncable . map ( async file => {
172217 const dir = pluginDirForType ( file . type ) ! ;
173218 const fileName = basename ( file . uri ) ;
@@ -176,38 +221,32 @@ export class SyncedCustomizationBundler extends Disposable {
176221 // The file locator returns the SKILL.md URI, so basename is
177222 // always "SKILL.md" — which would cause every skill to collide.
178223 // Preserve the directory structure: skills/{skillName}/SKILL.md.
179- let destUri : URI ;
180- let hashKey : string ;
181224 if ( file . type === PromptsType . skill && fileName . toLowerCase ( ) === 'skill.md' ) {
182- const skillDirName = basename ( dirname ( file . uri ) ) ;
183- destUri = URI . joinPath ( this . _rootUri , dir , skillDirName , fileName ) ;
184- hashKey = `${ dir } /${ skillDirName } /${ fileName } ` ;
225+ const skillRoot = dirname ( file . uri ) ;
226+ const skillDirName = basename ( skillRoot ) ;
227+ const entrypoint = await this . _queueFileOperation ( ( ) => this . _fileService . stat ( file . uri ) ) ;
228+ addEntry ( file , entrypoint , URI . joinPath ( this . _rootUri , dir , skillDirName , fileName ) , `${ dir } /${ skillDirName } /${ fileName } ` ) ;
229+ for ( const source of await collectDirectoryFiles ( this . _fileService , this . _logService , skillRoot , skillRoot , operation => this . _queueFileOperation ( operation ) ) ) {
230+ if ( extUri . isEqual ( source . resource , file . uri ) ) {
231+ continue ;
232+ }
233+ const relativePath = extUri . relativePath ( skillRoot , source . resource ) ;
234+ if ( relativePath === undefined ) {
235+ throw new Error ( `Unable to resolve skill resource path: ${ source . resource . toString ( ) } ` ) ;
236+ }
237+ addEntry (
238+ file ,
239+ source ,
240+ URI . joinPath ( this . _rootUri , dir , skillDirName , relativePath ) ,
241+ `${ dir } /${ skillDirName } /${ relativePath } ` ,
242+ ) ;
243+ }
185244 } else {
186- destUri = URI . joinPath ( this . _rootUri , dir , fileName ) ;
187- hashKey = `${ dir } /${ fileName } ` ;
188- }
189-
190- // Record the reverse mapping so the flattened file's original
191- // provenance (extension/plugin/built-in) can be recovered later.
192- // Only files that carry a source have recoverable provenance.
193- if ( file . source !== undefined ) {
194- originByDest . set ( destUri , {
195- uri : file . uri ,
196- source : file . source ,
197- extensionId : file . extensionId ,
198- pluginUri : file . pluginUri ,
199- } ) ;
245+ const source = await this . _queueFileOperation ( ( ) => this . _fileService . stat ( file . uri ) ) ;
246+ addEntry ( file , source , URI . joinPath ( this . _rootUri , dir , fileName ) , `${ dir } /${ fileName } ` ) ;
200247 }
201-
202- const content = await this . _fileService . readFile ( file . uri ) ;
203- entries . push ( { destUri, content : content . value , hashPart : `${ hashKey } :${ content . value . toString ( ) } ` } ) ;
204248 } ) ) ;
205249
206- // Publish the freshly computed provenance map. This is done before the
207- // nonce short-circuit below so the map always reflects the latest set of
208- // bundled files, even when the content nonce is unchanged.
209- this . _originByDest = originByDest ;
210-
211250 // Write MCP servers into `.mcp.json`. The agent host's Open Plugin
212251 // adapter reads this file relative to the plugin root. Servers are
213252 // sorted by name so the serialized content (and nonce) is stable.
@@ -241,8 +280,9 @@ export class SyncedCustomizationBundler extends Disposable {
241280 const nonce = String ( hash ( hashParts . join ( '\n' ) ) ) ;
242281
243282 // Nothing changed since the last successful bundle — reuse it and skip
244- // the delete + rewrite of the in-memory plugin tree.
283+ // reading file contents and rewriting the in-memory plugin tree.
245284 if ( nonce === this . _lastNonce && this . _lastRef ) {
285+ this . _originByDest = originByDest ;
246286 if ( mcpServers . length > 0 && ! equals ( childEnablement , this . _lastRef . ref . childEnablement ) ) {
247287 return {
248288 ref : {
@@ -254,6 +294,12 @@ export class SyncedCustomizationBundler extends Disposable {
254294 return this . _lastRef ;
255295 }
256296
297+ const fileContents = await Promise . all ( entries . map ( async entry => ( {
298+ destUri : entry . destUri ,
299+ content : ( await this . _queueFileOperation ( ( ) => this . _fileService . readFile ( entry . sourceUri ) ) ) . value ,
300+ } ) ) ) ;
301+ this . _originByDest = originByDest ;
302+
257303 // Delete the previous tree for this authority, preserving other authorities
258304 try {
259305 await this . _fileService . del ( this . _rootUri , { recursive : true } ) ;
@@ -266,7 +312,7 @@ export class SyncedCustomizationBundler extends Disposable {
266312 await this . _fileService . writeFile ( manifestUri , VSBuffer . fromString ( MANIFEST_CONTENT ) ) ;
267313
268314 // Write each source file into the correct plugin directory.
269- for ( const entry of entries ) {
315+ for ( const entry of fileContents ) {
270316 await this . _fileService . writeFile ( entry . destUri , entry . content ) ;
271317 }
272318
0 commit comments