@@ -292,6 +292,186 @@ export function registerSandboxCommands(
292292 ) ;
293293 } ) ;
294294
295+ const CLONE_PROFILES = [ 'medium' , 'large' , 'xlarge' , 'xxlarge' ] as const ;
296+ type CloneProfile = ( typeof CLONE_PROFILES ) [ number ] ;
297+ const CLONE_POLL_INTERVAL_MS = 10_000 ;
298+ const CLONE_POLL_TIMEOUT_MS = 60 * 60_000 ;
299+
300+ const clone = vscode . commands . registerCommand ( 'b2c-dx.sandbox.clone' , async ( node : SandboxTreeItem ) => {
301+ if ( ! node ) return ;
302+
303+ const ttlStr = await vscode . window . showInputBox ( {
304+ title : `Clone Sandbox — ${ node . label ?? node . sandbox . id } ` ,
305+ prompt : 'TTL in hours for the clone (0 = infinite, otherwise must be >= 24)' ,
306+ value : '24' ,
307+ validateInput : ( v ) => {
308+ const n = Number ( v ) ;
309+ if ( Number . isNaN ( n ) ) return 'Enter a number' ;
310+ if ( n > 0 && n < 24 ) return 'TTL must be 0 (infinite) or at least 24 hours' ;
311+ return null ;
312+ } ,
313+ } ) ;
314+ if ( ttlStr === undefined ) return ;
315+ const ttl = Number ( ttlStr ) ;
316+
317+ const profilePick = await vscode . window . showQuickPick (
318+ [ { label : 'Same as source' , value : undefined } , ...CLONE_PROFILES . map ( ( p ) => ( { label : p , value : p } ) ) ] ,
319+ { title : 'Clone Sandbox — Resource Profile' , placeHolder : 'Select profile for the clone' } ,
320+ ) ;
321+ if ( ! profilePick ) return ;
322+ const targetProfile = profilePick . value as CloneProfile | undefined ;
323+
324+ const emailRegex = / ^ [ ^ \s @ ] + @ [ ^ \s @ ] + \. [ ^ \s @ ] + $ / ;
325+ const emailsStr = await vscode . window . showInputBox ( {
326+ title : `Clone Sandbox — Notification Emails` ,
327+ prompt : 'Comma-separated email addresses to notify (optional)' ,
328+ placeHolder : 'user1@example.com, user2@example.com' ,
329+ validateInput : ( v ) => {
330+ const trimmed = v . trim ( ) ;
331+ if ( ! trimmed ) return null ;
332+ const invalid = trimmed
333+ . split ( ',' )
334+ . map ( ( e ) => e . trim ( ) )
335+ . filter ( ( e ) => e . length > 0 )
336+ . filter ( ( e ) => ! emailRegex . test ( e ) ) ;
337+ return invalid . length ? `Invalid email(s): ${ invalid . join ( ', ' ) } ` : null ;
338+ } ,
339+ } ) ;
340+ if ( emailsStr === undefined ) return ;
341+ const emails = emailsStr
342+ . split ( ',' )
343+ . map ( ( e ) => e . trim ( ) )
344+ . filter ( ( e ) => e . length > 0 ) ;
345+
346+ const sandboxName = typeof node . label === 'string' ? node . label : node . sandbox . id ;
347+ await vscode . window . withProgress (
348+ {
349+ location : vscode . ProgressLocation . Notification ,
350+ title : `Cloning sandbox ${ sandboxName } ` ,
351+ cancellable : false ,
352+ } ,
353+ async ( progress ) => {
354+ progress . report ( { message : node . sandbox . id } ) ;
355+ let sourceMarked = false ;
356+ try {
357+ const odsClient = await getOdsClientFromConfig ( configProvider ) ;
358+ const result = await odsClient . POST ( '/sandboxes/{sandboxId}/clones' , {
359+ params : { path : { sandboxId : node . sandbox . id } } ,
360+ body : {
361+ ttl,
362+ ...( targetProfile ? { targetProfile} : { } ) ,
363+ ...( emails . length ? { emails} : { } ) ,
364+ } ,
365+ } ) ;
366+ if ( result . error ) {
367+ vscode . window . showErrorMessage (
368+ `Sandbox clone failed: ${ getApiErrorMessage ( result . error , result . response ) } ` ,
369+ ) ;
370+ return ;
371+ }
372+ treeProvider . markSourceCloning ( node . sandbox . id ) ;
373+ sourceMarked = true ;
374+ const cloneId = result . data ?. data ?. cloneId ;
375+ if ( ! cloneId ) {
376+ vscode . window . showInformationMessage ( 'Sandbox clone initiated.' ) ;
377+ treeProvider . refreshRealm ( node . realm ) ;
378+ treeProvider . startPollingRealm ( node . realm ) ;
379+ return ;
380+ }
381+
382+ vscode . window . showInformationMessage ( `Sandbox clone initiated (cloneId: ${ cloneId } ).` ) ;
383+ treeProvider . refreshRealm ( node . realm ) ;
384+ treeProvider . startPollingRealm ( node . realm ) ;
385+
386+ const startTime = Date . now ( ) ;
387+ let lastPct = 0 ;
388+ while ( Date . now ( ) - startTime < CLONE_POLL_TIMEOUT_MS ) {
389+ await new Promise ( ( r ) => setTimeout ( r , CLONE_POLL_INTERVAL_MS ) ) ;
390+ treeProvider . refreshRealm ( node . realm ) ;
391+ const statusResult = await odsClient . GET ( '/sandboxes/{sandboxId}/clones/{cloneId}' , {
392+ params : { path : { sandboxId : node . sandbox . id , cloneId} } ,
393+ } ) ;
394+ if ( statusResult . error || ! statusResult . data ?. data ) continue ;
395+ const clone = statusResult . data . data ;
396+ const status = clone . status ?? 'IN_PROGRESS' ;
397+ const pct = clone . progressPercentage ?? 0 ;
398+ const increment = Math . max ( 0 , pct - lastPct ) ;
399+ lastPct = pct ;
400+ progress . report ( {
401+ increment,
402+ message : `${ node . sandbox . id } — ${ status } ${ pct } %${ clone . lastKnownState ? ` (${ clone . lastKnownState } )` : '' } ` ,
403+ } ) ;
404+ if ( status === 'COMPLETED' || status === 'FAILED' ) {
405+ if ( status === 'COMPLETED' ) {
406+ vscode . window . showInformationMessage ( `Clone ${ cloneId } completed.` ) ;
407+ } else {
408+ vscode . window . showErrorMessage (
409+ `Clone ${ cloneId } failed${ clone . lastKnownState ? ` at ${ clone . lastKnownState } ` : '' } .` ,
410+ ) ;
411+ }
412+ // The /clones endpoint reports COMPLETED before the /sandboxes list updates the
413+ // source/target states. Keep the source marked and refresh a few more ticks so the
414+ // tree catches the final states before the "cloning" label clears.
415+ const sandboxId = node . sandbox . id ;
416+ const realm = node . realm ;
417+ for ( let i = 0 ; i < 3 ; i ++ ) {
418+ await new Promise ( ( r ) => setTimeout ( r , CLONE_POLL_INTERVAL_MS ) ) ;
419+ treeProvider . refreshRealm ( realm ) ;
420+ }
421+ treeProvider . unmarkSourceCloning ( sandboxId ) ;
422+ sourceMarked = false ;
423+ treeProvider . refreshRealm ( realm ) ;
424+ treeProvider . startPollingRealm ( realm ) ;
425+ return ;
426+ }
427+ }
428+ vscode . window . showWarningMessage (
429+ `Clone ${ cloneId } still in progress after timeout. Use "View Clone Details" to check status.` ,
430+ ) ;
431+ } catch ( err ) {
432+ const message = err instanceof Error ? err . message : String ( err ) ;
433+ vscode . window . showErrorMessage ( `Sandbox clone failed: ${ message } ` ) ;
434+ } finally {
435+ if ( sourceMarked ) {
436+ treeProvider . unmarkSourceCloning ( node . sandbox . id ) ;
437+ }
438+ }
439+ } ,
440+ ) ;
441+ } ) ;
442+
443+ const viewCloneDetails = vscode . commands . registerCommand (
444+ 'b2c-dx.sandbox.viewCloneDetails' ,
445+ async ( node : SandboxTreeItem ) => {
446+ if ( ! node ) return ;
447+ await vscode . window . withProgress (
448+ { location : vscode . ProgressLocation . Notification , title : 'Fetching clone details...' } ,
449+ async ( ) => {
450+ try {
451+ const details = await treeProvider . getSandboxWithCloneDetails ( node . sandbox . id ) ;
452+ if ( ! details ) {
453+ vscode . window . showErrorMessage ( 'Could not fetch clone details.' ) ;
454+ return ;
455+ }
456+ const cloneDetails = details . cloneDetails ?? {
457+ clonedFrom : details . clonedFrom ,
458+ sourceInstanceIdentifier : details . sourceInstanceIdentifier ,
459+ } ;
460+ const content = JSON . stringify ( cloneDetails , null , 2 ) ;
461+ const uri = vscode . Uri . parse ( `${ SANDBOX_DETAIL_SCHEME } :${ node . label ?? node . sandbox . id } -clone.json` ) ;
462+ detailProvider . setContent ( uri , content ) ;
463+ const doc = await vscode . workspace . openTextDocument ( uri ) ;
464+ await vscode . languages . setTextDocumentLanguage ( doc , 'json' ) ;
465+ await vscode . window . showTextDocument ( doc , { preview : true } ) ;
466+ } catch ( err ) {
467+ const message = err instanceof Error ? err . message : String ( err ) ;
468+ vscode . window . showErrorMessage ( `Failed to fetch clone details: ${ message } ` ) ;
469+ }
470+ } ,
471+ ) ;
472+ } ,
473+ ) ;
474+
295475 return [
296476 detailRegistration ,
297477 refresh ,
@@ -305,5 +485,7 @@ export function registerSandboxCommands(
305485 viewDetails ,
306486 openBM ,
307487 extendExpiration ,
488+ clone ,
489+ viewCloneDetails ,
308490 ] ;
309491}
0 commit comments