Skip to content

Commit dc7a25a

Browse files
authored
VS Code extension: native TreeView, content libraries, plugin support, CI (#186)
* Replace WebDAV Browser webview with native TreeView Replace the 561-line HTML webview (webdav.html) with a VS Code native TreeDataProvider sidebar. This gives the extension its first Activity Bar presence and provides collapsible tree browsing, context menus, keyboard navigation, theming, and accessibility for free. - Add Activity Bar container "B2C-DX WebDAV" with sidebar tree view - Lazy-load directory contents via PROPFIND with caching - Context menu commands: New Folder, Upload, Delete, Download, Open File - Open files via temp storage with native VS Code editors (syntax highlighting, image viewer, etc.) - Welcome view when no B2C instance is configured - Replace "*" activation event with targeted onView activation - Remove webdav.html and all inline webview message handling (~240 lines) * Add FileSystemProvider, New File command, and workspace folder mount - Add WebDavFileSystemProvider with caching, stat/readDirectory/readFile/ writeFile/createDirectory/delete operations against WebDAV - Root path handling returns synthetic directory listing of the 9 well-known B2C Commerce roots (avoids PROPFIND on "/") - Tree provider delegates to FS provider instead of calling WebDAV directly; root nodes use standard folder icons via resourceUri - New File command: prompts for filename, creates empty file, opens in editor - Mount/Unmount Workspace commands: add/remove b2c-webdav:/ as a VS Code workspace folder for native Explorer integration - Context key b2c-dx.webdav.mounted tracks mount state for menu visibility - Download command available in native Explorer context menu for b2c-webdav files * Mount individual folders instead of entire WebDAV root Change "Open as Workspace Folder" to operate on the right-clicked node (root or directory) rather than mounting b2c-webdav:/ globally. The workspace folder is named "WebDAV: <path>" for clarity. Move mount command from view title bar to context menu. Remove unmountWorkspace command and mounted context key tracking — VS Code's native "Remove Folder from Workspace" handles unmounting. * wip content * Add content library explorer to VS Code extension - Native tree view for browsing B2C Commerce content libraries (pages, content assets, components, static assets) in the sidebar - FileSystemProvider (b2c-content: scheme) for viewing/editing content XML with round-trip import via site archive jobs - Export commands: Export, Export without Assets, Export Assets Only - Import site archive from command palette or explorer context menu (B2C-DX submenu) - Filter/search within library tree with toggle in title bar - Click static assets to preview images via WebDAV filesystem - Show job log in editor on import failure - Sort content-link children by position element instead of XML document order * Add VS Code extension CI tests and VSIX release publishing - Create separate CI workflow for extension tests using xvfb-run (path-filtered to packages/b2c-vs-extension/**) - Add tsconfig.test.json and pretest script to compile test files - Fix self-referencing step condition bug in ci.yml - Add typecheck to main CI for the extension (catches SDK breakage) - Add VSIX build, git tagging, and release upload to publish workflow * wip * Centralize config resolution in VS Code extension Replace 7 independent resolveConfigWithPlugins() call sites with a single B2CExtensionConfig singleton that caches resolved config and exposes getInstance()/getConfig()/getConfigError()/reset(). - Add dw.json file watcher (RelativePattern + onDidSaveTextDocument) that auto-resets config and fires onDidReset event - WebDAV tree subscribes to onDidReset for automatic refresh on config changes - Silently handle FileNotFound in tree re-expansion after server switch - Delete WebDavConfigProvider (fully replaced by B2CExtensionConfig) * Add changeset for SDK plugin module * Replace WebDAV Browser webview with native TreeView Replace the 561-line HTML webview (webdav.html) with a VS Code native TreeDataProvider sidebar. This gives the extension its first Activity Bar presence and provides collapsible tree browsing, context menus, keyboard navigation, theming, and accessibility for free. - Add Activity Bar container "B2C-DX WebDAV" with sidebar tree view - Lazy-load directory contents via PROPFIND with caching - Context menu commands: New Folder, Upload, Delete, Download, Open File - Open files via temp storage with native VS Code editors (syntax highlighting, image viewer, etc.) - Welcome view when no B2C instance is configured - Replace "*" activation event with targeted onView activation - Remove webdav.html and all inline webview message handling (~240 lines) * Add FileSystemProvider, New File command, and workspace folder mount - Add WebDavFileSystemProvider with caching, stat/readDirectory/readFile/ writeFile/createDirectory/delete operations against WebDAV - Root path handling returns synthetic directory listing of the 9 well-known B2C Commerce roots (avoids PROPFIND on "/") - Tree provider delegates to FS provider instead of calling WebDAV directly; root nodes use standard folder icons via resourceUri - New File command: prompts for filename, creates empty file, opens in editor - Mount/Unmount Workspace commands: add/remove b2c-webdav:/ as a VS Code workspace folder for native Explorer integration - Context key b2c-dx.webdav.mounted tracks mount state for menu visibility - Download command available in native Explorer context menu for b2c-webdav files * Mount individual folders instead of entire WebDAV root Change "Open as Workspace Folder" to operate on the right-clicked node (root or directory) rather than mounting b2c-webdav:/ globally. The workspace folder is named "WebDAV: <path>" for clarity. Move mount command from view title bar to context menu. Remove unmountWorkspace command and mounted context key tracking — VS Code's native "Remove Folder from Workspace" handles unmounting. * fix config resolution for content tree * fix review findings: config reset, writeFile flags, archive stripping, plugin opts - Refresh content tree on config reset (matching webdav-tree pattern) - Honour create/overwrite flags in WebDAV writeFile per VS Code contract - Strip single existing top-level root in ensureArchiveStructure to avoid double-nesting when re-wrapping site archives - Add accountManagerHost to PluginHookOptions and resolveOptions - Use Uri.from instead of Uri.parse for correct special-char handling - Guard applyMiddleware against duplicate registration * bug fixing import zip conventions and adding multi-select * changeset: downgrade sdk-plugin-module to patch
1 parent a9db7da commit dc7a25a

36 files changed

Lines changed: 3484 additions & 921 deletions

.changeset/sdk-plugin-module.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@salesforce/b2c-tooling-sdk': patch
3+
---
4+
5+
Add `@salesforce/b2c-tooling-sdk/plugins` module for discovering and loading b2c-cli plugins outside of oclif. Enables the VS Code extension and other non-CLI consumers to use installed plugins (keychain managers, config sources, middleware) without depending on `@oclif/core`.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
name: VS Extension Tests
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
paths:
8+
- 'packages/b2c-vs-extension/**'
9+
pull_request:
10+
branches:
11+
- main
12+
paths:
13+
- 'packages/b2c-vs-extension/**'
14+
15+
permissions:
16+
contents: read
17+
18+
env:
19+
SFCC_DISABLE_TELEMETRY: ${{ vars.SFCC_DISABLE_TELEMETRY }}
20+
21+
jobs:
22+
test:
23+
runs-on: ubuntu-latest
24+
25+
strategy:
26+
matrix:
27+
node-version: [22.x]
28+
29+
steps:
30+
- name: Checkout code
31+
uses: actions/checkout@v4
32+
33+
- name: Setup Node.js ${{ matrix.node-version }}
34+
uses: actions/setup-node@v4
35+
with:
36+
node-version: ${{ matrix.node-version }}
37+
38+
- name: Setup pnpm
39+
uses: pnpm/action-setup@v4
40+
with:
41+
version: 10.17.1
42+
43+
- name: Get pnpm store directory
44+
shell: bash
45+
run: |
46+
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
47+
48+
- name: Setup pnpm cache
49+
uses: actions/cache@v4
50+
with:
51+
path: ${{ env.STORE_PATH }}
52+
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
53+
restore-keys: |
54+
${{ runner.os }}-pnpm-store-
55+
56+
- name: Install dependencies
57+
run: pnpm install --frozen-lockfile
58+
59+
- name: Build packages
60+
run: pnpm -r run build
61+
62+
- name: Run VS Extension tests
63+
working-directory: packages/b2c-vs-extension
64+
run: xvfb-run -a pnpm run test

.github/workflows/ci.yml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,11 @@ jobs:
8080
working-directory: packages/b2c-cli
8181
run: pnpm run pretest && pnpm run test:ci && pnpm run lint
8282

83-
- name: Run VS Extension lint
83+
- name: Run VS Extension checks
8484
id: vs-extension-test
85-
if: always() && steps.vs-extension-test.conclusion != 'cancelled'
85+
if: always() && steps.cli-test.conclusion != 'cancelled'
8686
working-directory: packages/b2c-vs-extension
87-
# Testing not currently supported on CI
88-
run: pnpm run lint
87+
run: pnpm run typecheck:agent && pnpm run lint
8988

9089
- name: Test Report
9190
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0

.github/workflows/publish.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,17 @@ jobs:
153153
check_package "@salesforce/b2c-cli" "packages/b2c-cli" "cli"
154154
check_package "@salesforce/b2c-dx-mcp" "packages/b2c-dx-mcp" "mcp"
155155
156+
# VS Code extension — compare against git tags (not npm)
157+
LOCAL_VSX_VERSION=$(node -p "require('./packages/b2c-vs-extension/package.json').version")
158+
LAST_VSX_TAG=$(git tag -l "b2c-vs-extension@*" --sort=-v:refname | head -1 | sed 's/b2c-vs-extension@//')
159+
echo "b2c-vs-extension: local=${LOCAL_VSX_VERSION} tag=${LAST_VSX_TAG:-none}"
160+
if [ "$LOCAL_VSX_VERSION" != "$LAST_VSX_TAG" ]; then
161+
echo "publish_vsx=true" >> $GITHUB_OUTPUT
162+
echo "version_vsx=${LOCAL_VSX_VERSION}" >> $GITHUB_OUTPUT
163+
else
164+
echo "publish_vsx=false" >> $GITHUB_OUTPUT
165+
fi
166+
156167
# Check if docs version changed (private package — not published to npm, uses git tag)
157168
DOCS_VERSION=$(node -p "require('./docs/package.json').version")
158169
if git rev-parse "docs@${DOCS_VERSION}" >/dev/null 2>&1; then
@@ -204,6 +215,11 @@ jobs:
204215
pnpm --filter @salesforce/b2c-dx-mcp publish --provenance --no-git-checks
205216
--tag ${{ steps.release-type.outputs.type == 'nightly' && 'nightly' || steps.packages.outputs.tag_mcp }}
206217
218+
- name: Package VS Code extension
219+
if: steps.release-type.outputs.type == 'stable' && steps.packages.outputs.publish_vsx == 'true'
220+
working-directory: packages/b2c-vs-extension
221+
run: pnpm run package
222+
207223
- name: Create git tags
208224
if: steps.release-type.outputs.type == 'stable' && steps.changesets.outputs.skip != 'true' && steps.quick-check.outputs.skip != 'true'
209225
run: |
@@ -230,6 +246,12 @@ jobs:
230246
TAGS_CREATED="$TAGS_CREATED $TAG"
231247
fi
232248
249+
if [[ "${{ steps.packages.outputs.publish_vsx }}" == "true" ]]; then
250+
TAG="b2c-vs-extension@${{ steps.packages.outputs.version_vsx }}"
251+
git tag "$TAG"
252+
TAGS_CREATED="$TAGS_CREATED $TAG"
253+
fi
254+
233255
if [ -n "$TAGS_CREATED" ]; then
234256
git push origin $TAGS_CREATED
235257
echo "Created tags:$TAGS_CREATED"
@@ -279,6 +301,13 @@ jobs:
279301
echo ""
280302
fi
281303
304+
if [[ "${{ steps.packages.outputs.publish_vsx }}" == "true" ]]; then
305+
echo "## b2c-vs-extension@${{ steps.packages.outputs.version_vsx }}"
306+
echo ""
307+
extract_latest packages/b2c-vs-extension/CHANGELOG.md
308+
echo ""
309+
fi
310+
282311
if [[ "${{ steps.packages.outputs.publish_docs }}" == "true" && -f docs/CHANGELOG.md ]]; then
283312
echo "## Documentation"
284313
echo ""
@@ -297,6 +326,8 @@ jobs:
297326
RELEASE_TAG="@salesforce/b2c-tooling-sdk@${{ steps.packages.outputs.version_sdk }}"
298327
elif [[ "${{ steps.packages.outputs.publish_mcp }}" == "true" ]]; then
299328
RELEASE_TAG="@salesforce/b2c-dx-mcp@${{ steps.packages.outputs.version_mcp }}"
329+
elif [[ "${{ steps.packages.outputs.publish_vsx }}" == "true" ]]; then
330+
RELEASE_TAG="b2c-vs-extension@${{ steps.packages.outputs.version_vsx }}"
300331
elif [[ "${{ steps.packages.outputs.publish_docs }}" == "true" ]]; then
301332
RELEASE_TAG="docs@${{ steps.packages.outputs.version_docs }}"
302333
else
@@ -330,6 +361,8 @@ jobs:
330361
RELEASE_TAG="@salesforce/b2c-tooling-sdk@${{ steps.packages.outputs.version_sdk }}"
331362
elif [[ "${{ steps.packages.outputs.publish_mcp }}" == "true" ]]; then
332363
RELEASE_TAG="@salesforce/b2c-dx-mcp@${{ steps.packages.outputs.version_mcp }}"
364+
elif [[ "${{ steps.packages.outputs.publish_vsx }}" == "true" ]]; then
365+
RELEASE_TAG="b2c-vs-extension@${{ steps.packages.outputs.version_vsx }}"
333366
else
334367
echo "No package release to upload to"
335368
exit 0
@@ -339,6 +372,27 @@ jobs:
339372
env:
340373
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
341374

375+
- name: Upload VS Code extension to release
376+
if: steps.release-type.outputs.type == 'stable' && steps.packages.outputs.publish_vsx == 'true'
377+
run: |
378+
# Determine the release tag (same logic as Create GitHub Release)
379+
if [[ "${{ steps.packages.outputs.publish_cli }}" == "true" ]]; then
380+
RELEASE_TAG="@salesforce/b2c-cli@${{ steps.packages.outputs.version_cli }}"
381+
elif [[ "${{ steps.packages.outputs.publish_sdk }}" == "true" ]]; then
382+
RELEASE_TAG="@salesforce/b2c-tooling-sdk@${{ steps.packages.outputs.version_sdk }}"
383+
elif [[ "${{ steps.packages.outputs.publish_mcp }}" == "true" ]]; then
384+
RELEASE_TAG="@salesforce/b2c-dx-mcp@${{ steps.packages.outputs.version_mcp }}"
385+
elif [[ "${{ steps.packages.outputs.publish_vsx }}" == "true" ]]; then
386+
RELEASE_TAG="b2c-vs-extension@${{ steps.packages.outputs.version_vsx }}"
387+
else
388+
echo "No release to upload to"
389+
exit 0
390+
fi
391+
392+
gh release upload "$RELEASE_TAG" packages/b2c-vs-extension/*.vsix
393+
env:
394+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
395+
342396
- name: Trigger documentation deployment
343397
if: >-
344398
steps.release-type.outputs.type == 'stable' && steps.changesets.outputs.skip != 'true' && steps.quick-check.outputs.skip != 'true'

packages/b2c-tooling-sdk/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,17 @@
266266
"default": "./dist/cjs/scaffold/index.js"
267267
}
268268
},
269+
"./plugins": {
270+
"development": "./src/plugins/index.ts",
271+
"import": {
272+
"types": "./dist/esm/plugins/index.d.ts",
273+
"default": "./dist/esm/plugins/index.js"
274+
},
275+
"require": {
276+
"types": "./dist/cjs/plugins/index.d.ts",
277+
"default": "./dist/cjs/plugins/index.js"
278+
}
279+
},
269280
"./test-utils": {
270281
"development": "./src/test-utils/index.ts",
271282
"import": {

packages/b2c-tooling-sdk/src/cli/config.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -255,19 +255,6 @@ export function loadConfig(
255255
sourcesAfter: pluginSources.after,
256256
});
257257

258-
// Log source summary
259-
for (const source of resolved.sources) {
260-
logger.trace(
261-
{
262-
source: source.name,
263-
location: source.location,
264-
fields: source.fields,
265-
fieldsIgnored: source.fieldsIgnored,
266-
},
267-
`[${source.name}] Contributed fields`,
268-
);
269-
}
270-
271258
// Log warnings (at warn level so users can see configuration issues)
272259
for (const warning of resolved.warnings) {
273260
logger.warn({warning}, `[Config] ${warning.message}`);

packages/b2c-tooling-sdk/src/config/resolver.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
*/
1414
import type {AuthCredentials} from '../auth/types.js';
1515
import type {B2CInstance} from '../instance/index.js';
16+
import {getLogger} from '../logging/logger.js';
1617
import {mergeConfigsWithProtection, getPopulatedFields, createInstanceFromConfig} from './mapping.js';
1718
import {DwJsonSource, MobifySource, PackageJsonSource} from './sources/index.js';
1819
import type {
@@ -221,6 +222,17 @@ export class ConfigResolver {
221222
fieldsIgnored: fieldsIgnored.length > 0 ? fieldsIgnored : undefined,
222223
});
223224

225+
const logger = getLogger();
226+
logger.trace(
227+
{
228+
source: source.name,
229+
location,
230+
fields,
231+
fieldsIgnored: fieldsIgnored.length > 0 ? fieldsIgnored : undefined,
232+
},
233+
`[${source.name}] Contributed fields`,
234+
);
235+
224236
// Enrich options with accumulated config values for subsequent sources.
225237
// Only set if not already provided via CLI options.
226238
if (!enrichedOptions.accountManagerHost && baseConfig.accountManagerHost) {

packages/b2c-tooling-sdk/src/operations/content/library.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,14 @@ function processContent(
118118
}
119119
}
120120

121-
// Recurse into content-links
121+
// Recurse into content-links (sorted by position)
122122
const contentLinks = content['content-links'] as Array<Record<string, unknown>> | undefined;
123123
if (contentLinks?.[0]?.['content-link']) {
124-
const links = contentLinks[0]['content-link'] as Array<Record<string, unknown>>;
124+
const links = (contentLinks[0]['content-link'] as Array<Record<string, unknown>>).slice().sort((a, b) => {
125+
const posA = parseFloat((a['position'] as string[] | undefined)?.[0] ?? 'Infinity');
126+
const posB = parseFloat((b['position'] as string[] | undefined)?.[0] ?? 'Infinity');
127+
return posA - posB;
128+
});
125129
for (const link of links) {
126130
const linkAttrs = link['$'] as Record<string, string>;
127131
const linkId = linkAttrs['content-id'];

packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ export interface SiteArchiveImportResult {
5050
* - A Buffer containing zip data
5151
* - A filename already on the instance (in Impex/src/instance/)
5252
*
53+
* **Buffer handling:** When passing a Buffer, the `archiveName` option controls
54+
* the contract:
55+
* - **Without `archiveName`:** The buffer should contain archive entries without
56+
* a root directory (e.g. `libraries/mylib/library.xml`). The SDK generates
57+
* an archive name and wraps the contents under it.
58+
* - **With `archiveName`:** The buffer must already be correctly structured with
59+
* `archiveName/` as the top-level directory. It is uploaded as-is.
60+
*
5361
* @param instance - B2C instance to import to
5462
* @param target - Source to import (directory path, zip file path, Buffer, or remote filename)
5563
* @param options - Import options
@@ -64,9 +72,17 @@ export interface SiteArchiveImportResult {
6472
* // Import from a zip file
6573
* const result = await siteArchiveImport(instance, './export.zip');
6674
*
67-
* // Import from a buffer
68-
* const zipBuffer = await fs.promises.readFile('./export.zip');
69-
* const result = await siteArchiveImport(instance, zipBuffer, {
75+
* // Import from a buffer (SDK wraps contents automatically)
76+
* const zip = new JSZip();
77+
* zip.file('libraries/mylib/library.xml', xmlContent);
78+
* const buffer = await zip.generateAsync({type: 'nodebuffer'});
79+
* const result = await siteArchiveImport(instance, buffer);
80+
*
81+
* // Import from a buffer with explicit archive name (caller owns structure)
82+
* const zip = new JSZip();
83+
* zip.file('my-import/libraries/mylib/library.xml', xmlContent);
84+
* const buffer = await zip.generateAsync({type: 'nodebuffer'});
85+
* const result = await siteArchiveImport(instance, buffer, {
7086
* archiveName: 'my-import'
7187
* });
7288
*
@@ -94,12 +110,20 @@ export async function siteArchiveImport(
94110
zipFilename = target.remoteFilename;
95111
needsUpload = false;
96112
} else if (Buffer.isBuffer(target)) {
97-
// Buffer - use provided archive name
98-
if (!archiveName) {
99-
throw new Error('archiveName is required when importing from a Buffer');
113+
if (archiveName) {
114+
// Caller provides name — buffer must already contain the correct
115+
// top-level directory structure (archiveName/...).
116+
const baseName = archiveName.endsWith('.zip') ? archiveName.slice(0, -4) : archiveName;
117+
zipFilename = `${baseName}.zip`;
118+
archiveContent = target;
119+
} else {
120+
// No name — SDK generates one and wraps the buffer contents under it.
121+
// The buffer should contain archive entries without a root directory
122+
// (e.g. libraries/mylib/library.xml, sites/RefArch/site.xml).
123+
const archiveDirName = `import-${Date.now()}`;
124+
zipFilename = `${archiveDirName}.zip`;
125+
archiveContent = await wrapArchiveContents(target, archiveDirName, logger);
100126
}
101-
zipFilename = archiveName.endsWith('.zip') ? archiveName : `${archiveName}.zip`;
102-
archiveContent = target;
103127
} else {
104128
// File path - check if directory or zip file
105129
const targetPath = target as string;
@@ -236,6 +260,39 @@ async function addDirectoryToZip(zipFolder: JSZip, dirPath: string): Promise<voi
236260
}
237261
}
238262

263+
/**
264+
* Wraps the contents of a zip buffer under a new top-level directory.
265+
*
266+
* The input buffer should contain archive entries without a root directory
267+
* (e.g. `libraries/mylib/library.xml`). The output will have all entries
268+
* nested under `archiveDirName/` (e.g. `archiveDirName/libraries/mylib/library.xml`).
269+
*/
270+
async function wrapArchiveContents(
271+
buffer: Buffer,
272+
archiveDirName: string,
273+
logger: ReturnType<typeof getLogger>,
274+
): Promise<Buffer> {
275+
const zip = await JSZip.loadAsync(buffer);
276+
277+
logger.debug({archiveDirName}, `Wrapping archive contents under ${archiveDirName}/`);
278+
279+
const newZip = new JSZip();
280+
const rootFolder = newZip.folder(archiveDirName)!;
281+
282+
for (const [filePath, entry] of Object.entries(zip.files)) {
283+
if (!entry.dir) {
284+
const content = await entry.async('nodebuffer');
285+
rootFolder.file(filePath, content);
286+
}
287+
}
288+
289+
return newZip.generateAsync({
290+
type: 'nodebuffer',
291+
compression: 'DEFLATE',
292+
compressionOptions: {level: 9},
293+
});
294+
}
295+
239296
/**
240297
* Configuration for sites in export.
241298
*/

0 commit comments

Comments
 (0)