Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/docs-default-user-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@salesforce/b2c-dx-docs': patch
---

Updated plugin install examples to default to user scope
42 changes: 36 additions & 6 deletions packages/b2c-vs-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,18 @@
"icon": "$(clock)",
"category": "B2C DX - Sandboxes"
},
{
"command": "b2c-dx.sandbox.clone",
"title": "Clone Sandbox",
"icon": "$(copy)",
"category": "B2C DX - Sandboxes"
},
{
"command": "b2c-dx.sandbox.viewCloneDetails",
"title": "View Clone Details",
"icon": "$(git-branch)",
"category": "B2C DX - Sandboxes"
},
{
"command": "b2c-dx.instance.inspect",
"title": "B2C Instance Config",
Expand Down Expand Up @@ -737,32 +749,42 @@
},
{
"command": "b2c-dx.sandbox.openBM",
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-/",
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-(?!cloning|settingup)/",
"group": "1_info@2"
},
{
"command": "b2c-dx.sandbox.start",
"when": "view == b2cSandboxExplorer && viewItem == sandbox-stopped",
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-stopped(-cloned)?$/",
"group": "2_lifecycle@1"
},
{
"command": "b2c-dx.sandbox.stop",
"when": "view == b2cSandboxExplorer && viewItem == sandbox-started",
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-started(-cloned)?$/",
"group": "2_lifecycle@2"
},
{
"command": "b2c-dx.sandbox.restart",
"when": "view == b2cSandboxExplorer && viewItem == sandbox-started",
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-started(-cloned)?$/",
"group": "2_lifecycle@3"
},
{
"command": "b2c-dx.sandbox.extendExpiration",
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-/",
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-(?!cloning|settingup)/",
"group": "2_lifecycle@4"
},
{
"command": "b2c-dx.sandbox.clone",
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-(started|stopped)(-cloned)?$/",
"group": "2_lifecycle@5"
},
{
"command": "b2c-dx.sandbox.viewCloneDetails",
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-.*-cloned$/",
"group": "1_info@3"
},
{
"command": "b2c-dx.sandbox.delete",
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-/",
"when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-(?!cloning|settingup)/",
"group": "3_destructive@1"
},
{
Expand Down Expand Up @@ -874,6 +896,14 @@
"command": "b2c-dx.sandbox.extendExpiration",
"when": "false"
},
{
"command": "b2c-dx.sandbox.clone",
"when": "false"
},
{
"command": "b2c-dx.sandbox.viewCloneDetails",
"when": "false"
},
{
"command": "b2c-dx.webdav.removeCatalog",
"when": "false"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* SPDX-License-Identifier: Apache-2
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
*/

/** Minimal structural shape of a sandbox record used by these helpers. */
export interface SandboxLike {
id: string;
realm?: string;
instance?: string;
state?: string;
clonedFrom?: string;
}

/** States of a cloned sandbox that indicate the clone is still being set up from its source. */
export const CLONE_IN_PROGRESS_STATES = new Set(['cloning', 'creating', 'failed']);

/** Sandbox states that drive the realm auto-poll (anything mid-transition). */
export const TRANSITIONAL_STATES = new Set(['creating', 'starting', 'stopping', 'deleting', 'cloning']);

export function getRealmInstanceId(s: SandboxLike): string | undefined {
return s.realm && s.instance ? `${s.realm}-${s.instance}` : undefined;
}

/** Return the set of realm-instance identifiers that are currently a source of an in-progress clone. */
export function getActiveCloneSourceIds(sandboxes: SandboxLike[]): Set<string> {
const sources = new Set<string>();
for (const s of sandboxes) {
if (typeof s.clonedFrom === 'string' && s.clonedFrom.length > 0) {
const state = (s.state ?? '').toLowerCase();
if (CLONE_IN_PROGRESS_STATES.has(state)) {
sources.add(s.clonedFrom);
}
}
}
return sources;
}

export interface SandboxDisplay {
/** Text shown in the tree row description. */
displayState: string;
/** Context-value suffix after `sandbox-`, without the `-cloned` suffix. */
contextState: string;
/** Full context value used by VS Code menu `when` clauses. */
contextValue: string;
/** True when this row represents a cloned sandbox (clonedFrom is set). */
isClone: boolean;
/** True when the sandbox is a cloned target still being set up (state=failed + clonedFrom). */
isCloneInSetup: boolean;
/** True when this row is the source of an in-progress clone. */
showAsCloning: boolean;
/** Text shown in the tooltip State line. */
tooltipStateLine: string | undefined;
}

/**
* Compute display data for a sandbox tree row. Pure function — no VS Code dependencies.
*
* @param sandbox the sandbox record
* @param isCloneSource true when the caller knows this sandbox is the source of an active clone
*/
export function computeSandboxDisplay(sandbox: SandboxLike, isCloneSource: boolean): SandboxDisplay {
const rawState = (sandbox.state ?? 'unknown').toLowerCase();
const isClone = typeof sandbox.clonedFrom === 'string' && sandbox.clonedFrom.length > 0;
const isCloneInSetup = isClone && rawState === 'failed';
const showAsCloning = isCloneSource && !isCloneInSetup;
const displayState = isCloneInSetup ? 'setting up' : showAsCloning ? 'cloning' : rawState;
const contextState = isCloneInSetup ? 'settingup' : showAsCloning ? 'cloning' : rawState;
const contextValue = isClone ? `sandbox-${contextState}-cloned` : `sandbox-${contextState}`;
let tooltipStateLine: string | undefined;
if (sandbox.state) {
tooltipStateLine = isCloneInSetup
? 'setting up (clone in progress)'
: showAsCloning
? `${sandbox.state} (clone in progress)`
: sandbox.state;
}
return {displayState, contextState, contextValue, isClone, isCloneInSetup, showAsCloning, tooltipStateLine};
}
182 changes: 182 additions & 0 deletions packages/b2c-vs-extension/src/sandbox-tree/sandbox-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,186 @@ export function registerSandboxCommands(
},
);

const CLONE_PROFILES = ['medium', 'large', 'xlarge', 'xxlarge'] as const;
type CloneProfile = (typeof CLONE_PROFILES)[number];
const CLONE_POLL_INTERVAL_MS = 10_000;
const CLONE_POLL_TIMEOUT_MS = 60 * 60_000;

const clone = vscode.commands.registerCommand('b2c-dx.sandbox.clone', async (node: SandboxTreeItem) => {
if (!node) return;

const ttlStr = await vscode.window.showInputBox({
title: `Clone Sandbox — ${node.label ?? node.sandbox.id}`,
prompt: 'TTL in hours for the clone (0 = infinite, otherwise must be >= 24)',
value: '24',
validateInput: (v) => {
const n = Number(v);
if (Number.isNaN(n)) return 'Enter a number';
if (n > 0 && n < 24) return 'TTL must be 0 (infinite) or at least 24 hours';
return null;
},
});
if (ttlStr === undefined) return;
const ttl = Number(ttlStr);

const profilePick = await vscode.window.showQuickPick(
[{label: 'Same as source', value: undefined}, ...CLONE_PROFILES.map((p) => ({label: p, value: p}))],
{title: 'Clone Sandbox — Resource Profile', placeHolder: 'Select profile for the clone'},
);
if (!profilePick) return;
const targetProfile = profilePick.value as CloneProfile | undefined;

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const emailsStr = await vscode.window.showInputBox({
title: `Clone Sandbox — Notification Emails`,
prompt: 'Comma-separated email addresses to notify (optional)',
placeHolder: 'user1@example.com, user2@example.com',
validateInput: (v) => {
const trimmed = v.trim();
if (!trimmed) return null;
const invalid = trimmed
.split(',')
.map((e) => e.trim())
.filter((e) => e.length > 0)
.filter((e) => !emailRegex.test(e));
return invalid.length ? `Invalid email(s): ${invalid.join(', ')}` : null;
},
});
if (emailsStr === undefined) return;
const emails = emailsStr
.split(',')
.map((e) => e.trim())
.filter((e) => e.length > 0);

const sandboxName = typeof node.label === 'string' ? node.label : node.sandbox.id;
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: `Cloning sandbox ${sandboxName}`,
cancellable: false,
},
async (progress) => {
progress.report({message: node.sandbox.id});
let sourceMarked = false;
try {
const odsClient = await getOdsClientFromConfig(configProvider);
const result = await odsClient.POST('/sandboxes/{sandboxId}/clones', {
params: {path: {sandboxId: node.sandbox.id}},
body: {
ttl,
...(targetProfile ? {targetProfile} : {}),
...(emails.length ? {emails} : {}),
},
});
if (result.error) {
vscode.window.showErrorMessage(
`Sandbox clone failed: ${getApiErrorMessage(result.error, result.response)}`,
);
return;
}
treeProvider.markSourceCloning(node.sandbox.id);
sourceMarked = true;
const cloneId = result.data?.data?.cloneId;
if (!cloneId) {
vscode.window.showInformationMessage('Sandbox clone initiated.');
treeProvider.refreshRealm(node.realm);
treeProvider.startPollingRealm(node.realm);
return;
}

vscode.window.showInformationMessage(`Sandbox clone initiated (cloneId: ${cloneId}).`);
treeProvider.refreshRealm(node.realm);
treeProvider.startPollingRealm(node.realm);

const startTime = Date.now();
let lastPct = 0;
while (Date.now() - startTime < CLONE_POLL_TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, CLONE_POLL_INTERVAL_MS));
treeProvider.refreshRealm(node.realm);
const statusResult = await odsClient.GET('/sandboxes/{sandboxId}/clones/{cloneId}', {
params: {path: {sandboxId: node.sandbox.id, cloneId}},
});
if (statusResult.error || !statusResult.data?.data) continue;
const clone = statusResult.data.data;
const status = clone.status ?? 'IN_PROGRESS';
const pct = clone.progressPercentage ?? 0;
const increment = Math.max(0, pct - lastPct);
lastPct = pct;
progress.report({
increment,
message: `${node.sandbox.id} — ${status} ${pct}%${clone.lastKnownState ? ` (${clone.lastKnownState})` : ''}`,
});
if (status === 'COMPLETED' || status === 'FAILED') {
if (status === 'COMPLETED') {
vscode.window.showInformationMessage(`Clone ${cloneId} completed.`);
} else {
vscode.window.showErrorMessage(
`Clone ${cloneId} failed${clone.lastKnownState ? ` at ${clone.lastKnownState}` : ''}.`,
);
}
// The /clones endpoint reports COMPLETED before the /sandboxes list updates the
// source/target states. Keep the source marked and refresh a few more ticks so the
// tree catches the final states before the "cloning" label clears.
const sandboxId = node.sandbox.id;
const realm = node.realm;
for (let i = 0; i < 3; i++) {
await new Promise((r) => setTimeout(r, CLONE_POLL_INTERVAL_MS));
treeProvider.refreshRealm(realm);
}
treeProvider.unmarkSourceCloning(sandboxId);
sourceMarked = false;
treeProvider.refreshRealm(realm);
treeProvider.startPollingRealm(realm);
return;
}
}
vscode.window.showWarningMessage(
`Clone ${cloneId} still in progress after timeout. Use "View Clone Details" to check status.`,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`Sandbox clone failed: ${message}`);
} finally {
if (sourceMarked) {
treeProvider.unmarkSourceCloning(node.sandbox.id);
}
}
},
);
});

const viewCloneDetails = vscode.commands.registerCommand(
'b2c-dx.sandbox.viewCloneDetails',
async (node: SandboxTreeItem) => {
if (!node) return;
await vscode.window.withProgress(
{location: vscode.ProgressLocation.Notification, title: 'Fetching clone details...'},
async () => {
try {
const details = await treeProvider.getSandboxWithCloneDetails(node.sandbox.id);
if (!details) {
vscode.window.showErrorMessage('Could not fetch clone details.');
return;
}
const cloneDetails = details.cloneDetails ?? {
clonedFrom: details.clonedFrom,
sourceInstanceIdentifier: details.sourceInstanceIdentifier,
};
const content = JSON.stringify(cloneDetails, null, 2);
const uri = vscode.Uri.parse(`${SANDBOX_DETAIL_SCHEME}:${node.label ?? node.sandbox.id}-clone.json`);
detailProvider.setContent(uri, content);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.languages.setTextDocumentLanguage(doc, 'json');
await vscode.window.showTextDocument(doc, {preview: true});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`Failed to fetch clone details: ${message}`);
}
},
);
},
);

return [
detailRegistration,
refresh,
Expand All @@ -302,5 +482,7 @@ export function registerSandboxCommands(
viewDetails,
openBM,
extendExpiration,
clone,
viewCloneDetails,
];
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface SandboxInfo {
createdBy?: string;
autoScheduled?: boolean;
links?: Array<{href: string; rel: string}>;
clonedFrom?: string;
sourceInstanceIdentifier?: string;
[key: string]: unknown;
}

Expand Down
Loading
Loading