Skip to content

Commit 23205eb

Browse files
@W-22255905: Add clone sandbox option on VS Code (#389)
* @W-22255905: Add clone sandbox option on VS Code * @W-22255905: Add clone sandbox option on VS Code
1 parent 6be308a commit 23205eb

9 files changed

Lines changed: 835 additions & 15 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@salesforce/b2c-dx-docs': patch
3+
---
4+
5+
Updated plugin install examples to default to user scope

packages/b2c-vs-extension/package.json

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,18 @@
310310
"icon": "$(clock)",
311311
"category": "B2C DX - Sandboxes"
312312
},
313+
{
314+
"command": "b2c-dx.sandbox.clone",
315+
"title": "Clone Sandbox",
316+
"icon": "$(copy)",
317+
"category": "B2C DX - Sandboxes"
318+
},
319+
{
320+
"command": "b2c-dx.sandbox.viewCloneDetails",
321+
"title": "View Clone Details",
322+
"icon": "$(git-branch)",
323+
"category": "B2C DX - Sandboxes"
324+
},
313325
{
314326
"command": "b2c-dx.instance.inspect",
315327
"title": "B2C Instance Config",
@@ -737,32 +749,42 @@
737749
},
738750
{
739751
"command": "b2c-dx.sandbox.openBM",
740-
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-/",
752+
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-(?!cloning|settingup)/",
741753
"group": "1_info@2"
742754
},
743755
{
744756
"command": "b2c-dx.sandbox.start",
745-
"when": "view == b2cSandboxExplorer && viewItem == sandbox-stopped",
757+
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-stopped(-cloned)?$/",
746758
"group": "2_lifecycle@1"
747759
},
748760
{
749761
"command": "b2c-dx.sandbox.stop",
750-
"when": "view == b2cSandboxExplorer && viewItem == sandbox-started",
762+
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-started(-cloned)?$/",
751763
"group": "2_lifecycle@2"
752764
},
753765
{
754766
"command": "b2c-dx.sandbox.restart",
755-
"when": "view == b2cSandboxExplorer && viewItem == sandbox-started",
767+
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-started(-cloned)?$/",
756768
"group": "2_lifecycle@3"
757769
},
758770
{
759771
"command": "b2c-dx.sandbox.extendExpiration",
760-
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-/",
772+
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-(?!cloning|settingup)/",
761773
"group": "2_lifecycle@4"
762774
},
775+
{
776+
"command": "b2c-dx.sandbox.clone",
777+
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-(started|stopped)(-cloned)?$/",
778+
"group": "2_lifecycle@5"
779+
},
780+
{
781+
"command": "b2c-dx.sandbox.viewCloneDetails",
782+
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-.*-cloned$/",
783+
"group": "1_info@3"
784+
},
763785
{
764786
"command": "b2c-dx.sandbox.delete",
765-
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-/",
787+
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-(?!cloning|settingup)/",
766788
"group": "3_destructive@1"
767789
},
768790
{
@@ -874,6 +896,14 @@
874896
"command": "b2c-dx.sandbox.extendExpiration",
875897
"when": "false"
876898
},
899+
{
900+
"command": "b2c-dx.sandbox.clone",
901+
"when": "false"
902+
},
903+
{
904+
"command": "b2c-dx.sandbox.viewCloneDetails",
905+
"when": "false"
906+
},
877907
{
878908
"command": "b2c-dx.webdav.removeCatalog",
879909
"when": "false"
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
/** Minimal structural shape of a sandbox record used by these helpers. */
8+
export interface SandboxLike {
9+
id: string;
10+
realm?: string;
11+
instance?: string;
12+
state?: string;
13+
clonedFrom?: string;
14+
}
15+
16+
/** States of a cloned sandbox that indicate the clone is still being set up from its source. */
17+
export const CLONE_IN_PROGRESS_STATES = new Set(['cloning', 'creating', 'failed']);
18+
19+
/** Sandbox states that drive the realm auto-poll (anything mid-transition). */
20+
export const TRANSITIONAL_STATES = new Set(['creating', 'starting', 'stopping', 'deleting', 'cloning']);
21+
22+
export function getRealmInstanceId(s: SandboxLike): string | undefined {
23+
return s.realm && s.instance ? `${s.realm}-${s.instance}` : undefined;
24+
}
25+
26+
/** Return the set of realm-instance identifiers that are currently a source of an in-progress clone. */
27+
export function getActiveCloneSourceIds(sandboxes: SandboxLike[]): Set<string> {
28+
const sources = new Set<string>();
29+
for (const s of sandboxes) {
30+
if (typeof s.clonedFrom === 'string' && s.clonedFrom.length > 0) {
31+
const state = (s.state ?? '').toLowerCase();
32+
if (CLONE_IN_PROGRESS_STATES.has(state)) {
33+
sources.add(s.clonedFrom);
34+
}
35+
}
36+
}
37+
return sources;
38+
}
39+
40+
export interface SandboxDisplay {
41+
/** Text shown in the tree row description. */
42+
displayState: string;
43+
/** Context-value suffix after `sandbox-`, without the `-cloned` suffix. */
44+
contextState: string;
45+
/** Full context value used by VS Code menu `when` clauses. */
46+
contextValue: string;
47+
/** True when this row represents a cloned sandbox (clonedFrom is set). */
48+
isClone: boolean;
49+
/** True when the sandbox is a cloned target still being set up (state=failed + clonedFrom). */
50+
isCloneInSetup: boolean;
51+
/** True when this row is the source of an in-progress clone. */
52+
showAsCloning: boolean;
53+
/** Text shown in the tooltip State line. */
54+
tooltipStateLine: string | undefined;
55+
}
56+
57+
/**
58+
* Compute display data for a sandbox tree row. Pure function — no VS Code dependencies.
59+
*
60+
* @param sandbox the sandbox record
61+
* @param isCloneSource true when the caller knows this sandbox is the source of an active clone
62+
*/
63+
export function computeSandboxDisplay(sandbox: SandboxLike, isCloneSource: boolean): SandboxDisplay {
64+
const rawState = (sandbox.state ?? 'unknown').toLowerCase();
65+
const isClone = typeof sandbox.clonedFrom === 'string' && sandbox.clonedFrom.length > 0;
66+
const isCloneInSetup = isClone && rawState === 'failed';
67+
const showAsCloning = isCloneSource && !isCloneInSetup;
68+
const displayState = isCloneInSetup ? 'setting up' : showAsCloning ? 'cloning' : rawState;
69+
const contextState = isCloneInSetup ? 'settingup' : showAsCloning ? 'cloning' : rawState;
70+
const contextValue = isClone ? `sandbox-${contextState}-cloned` : `sandbox-${contextState}`;
71+
let tooltipStateLine: string | undefined;
72+
if (sandbox.state) {
73+
tooltipStateLine = isCloneInSetup
74+
? 'setting up (clone in progress)'
75+
: showAsCloning
76+
? `${sandbox.state} (clone in progress)`
77+
: sandbox.state;
78+
}
79+
return {displayState, contextState, contextValue, isClone, isCloneInSetup, showAsCloning, tooltipStateLine};
80+
}

packages/b2c-vs-extension/src/sandbox-tree/sandbox-commands.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

packages/b2c-vs-extension/src/sandbox-tree/sandbox-config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export interface SandboxInfo {
1818
createdBy?: string;
1919
autoScheduled?: boolean;
2020
links?: Array<{href: string; rel: string}>;
21+
clonedFrom?: string;
22+
sourceInstanceIdentifier?: string;
2123
[key: string]: unknown;
2224
}
2325

0 commit comments

Comments
 (0)