From 2cacb64ea1948140c66e1f31764ed2c28b72e201 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Wed, 24 Jun 2026 13:34:18 -0400 Subject: [PATCH] fix: count folder contents for delete via backend-aware source.list (#1034) The delete confirmation crawled folder contents using nx's `crawl`, which lists against the DA origin only. On Helix 6 sites it found nothing, so the dialog reported "0 items" and the delete flow was confusing/broken. Replace the crawl with `crawlDeleteCount`, a recursive count built on the backend-aware `source.list` (the same API the listing uses), so it works for both the legacy DA and Helix 6 backends. Preserves cancellation semantics (cancelCrawl) and pagination handling. Co-Authored-By: Claude Opus 4.8 --- blocks/browse/da-list/da-list.js | 17 ++- blocks/browse/da-list/helpers/utils.js | 54 +++++++++ .../blocks/browse/helpers/helpers.test.js | 103 ++++++++++++++++++ 3 files changed, 168 insertions(+), 6 deletions(-) diff --git a/blocks/browse/da-list/da-list.js b/blocks/browse/da-list/da-list.js index 50e7e2489..e34bcec6c 100644 --- a/blocks/browse/da-list/da-list.js +++ b/blocks/browse/da-list/da-list.js @@ -443,17 +443,22 @@ export default class DaList extends LitElement { this._deleteCountLoading = true; try { - const { crawl } = await import(`${getNx()}/public/utils/tree.js`); - const crawlInstance = crawl({ - path: folders.map((folder) => folder.path), - files, + const { source } = await getNx2Api(); + const { crawlDeleteCount } = await import('./helpers/utils.js'); + // Use the backend-aware source.list (works for both DA and Helix 6) rather + // than nx's crawl, which only lists against the DA origin and returns 0 for + // Helix 6 sites. + const crawlInstance = crawlDeleteCount({ + folders, + fileCount: files.length, + source, concurrent: 5, }); this._deleteCrawl = crawlInstance; - const allFiles = await crawlInstance.results; + const count = await crawlInstance.results; // If the user cancelled/closed the dialog while we were crawling, bail out if (this._confirm !== 'delete' || this._deleteCrawl !== crawlInstance) return; - this._deleteCount = allFiles.length; + this._deleteCount = count; } finally { if (this._confirm === 'delete') { this._deleteCountLoading = false; diff --git a/blocks/browse/da-list/helpers/utils.js b/blocks/browse/da-list/helpers/utils.js index 740c7e5b5..992d5d3fa 100644 --- a/blocks/browse/da-list/helpers/utils.js +++ b/blocks/browse/da-list/helpers/utils.js @@ -81,6 +81,60 @@ export async function getFullEntryList(entries) { return files.filter((file) => file); } +/** + * Recursively count the files contained within the given folders. + * + * This intentionally uses the backend-aware `source.list` API (which routes to + * the correct origin for both the legacy DA backend and Helix 6) instead of + * nx's `crawl` helper, which lists against the DA origin only and therefore + * returns nothing for Helix 6 sites. + * + * @param {Object} opts + * @param {Array} opts.folders Folder items to crawl (each with a `path`). + * @param {number} [opts.fileCount] Count of already-selected standalone files. + * @param {Object} opts.source The nx2 `source` API (provides `list`). + * @param {number} [opts.concurrent] Max folders listed in parallel. + * @returns {{ results: Promise, cancelCrawl: () => void }} + */ +export function crawlDeleteCount({ folders, fileCount = 0, source, concurrent = 5 }) { + let cancelled = false; + + const listFolder = async (path, onCount) => { + const subfolders = []; + let continuationToken; + do { + if (cancelled) break; + const { ok, items, continuationToken: next } = await source.list( + path, + { continuationToken }, + ); + if (!ok || !items) break; + items.forEach((item) => { + if (item.ext) onCount(); + else subfolders.push(item.path); + }); + continuationToken = next; + } while (continuationToken); + return subfolders; + }; + + const results = (async () => { + let count = fileCount; + const onCount = () => { count += 1; }; + const pending = folders.map((folder) => folder.path); + + while (pending.length && !cancelled) { + const batch = pending.splice(0, concurrent); + const discovered = await Promise.all(batch.map((path) => listFolder(path, onCount))); + discovered.forEach((subs) => pending.push(...subs)); + } + + return count; + })(); + + return { results, cancelCrawl: () => { cancelled = true; } }; +} + export function getDropConflicts(list, files) { const existing = new Set( list.map((item) => (item.ext ? `${item.name}.${item.ext}` : item.name)), diff --git a/test/unit/blocks/browse/helpers/helpers.test.js b/test/unit/blocks/browse/helpers/helpers.test.js index 6a8715bd5..e695bffe7 100644 --- a/test/unit/blocks/browse/helpers/helpers.test.js +++ b/test/unit/blocks/browse/helpers/helpers.test.js @@ -9,6 +9,7 @@ const { handleUpload, getDropConflicts, items2Clipboard, + crawlDeleteCount, } = await import('../../../../../blocks/browse/da-list/helpers/utils.js'); // nx2 api.js pings `/ping/{org}/{site}` to detect hlx6 before every source @@ -154,6 +155,108 @@ describe('getDropConflicts', () => { }); }); +describe('crawlDeleteCount', () => { + // Build a fake `source` whose `list` returns the contents of `tree[path]`. + // A folder maps to an array of child items; files have an `ext`, folders don't. + function makeSource(tree) { + const list = async (path) => ({ ok: true, items: tree[path] ?? [], continuationToken: null }); + return { list }; + } + + it('Counts files recursively across nested folders', async () => { + const source = makeSource({ + '/org/site/folder': [ + { name: 'a', ext: 'html', path: '/org/site/folder/a.html' }, + { name: 'sub', path: '/org/site/folder/sub' }, + ], + '/org/site/folder/sub': [ + { name: 'b', ext: 'html', path: '/org/site/folder/sub/b.html' }, + { name: 'c', ext: 'json', path: '/org/site/folder/sub/c.json' }, + ], + }); + const { results } = crawlDeleteCount({ + folders: [{ path: '/org/site/folder' }], + source, + }); + expect(await results).to.equal(3); + }); + + it('Includes the already-selected standalone file count', async () => { + const folderItems = [{ name: 'a', ext: 'html', path: '/org/site/folder/a.html' }]; + const source = makeSource({ '/org/site/folder': folderItems }); + const { results } = crawlDeleteCount({ + folders: [{ path: '/org/site/folder' }], + fileCount: 2, + source, + }); + expect(await results).to.equal(3); + }); + + it('Follows pagination via the continuation token', async () => { + let page = 0; + const source = { + list: async (path, { continuationToken } = {}) => { + expect(path).to.equal('/org/site/folder'); + if (!continuationToken) { + page += 1; + return { + ok: true, + items: [{ name: 'a', ext: 'html', path: '/org/site/folder/a.html' }], + continuationToken: 'next', + }; + } + return { + ok: true, + items: [{ name: 'b', ext: 'html', path: '/org/site/folder/b.html' }], + continuationToken: null, + }; + }, + }; + const { results } = crawlDeleteCount({ + folders: [{ path: '/org/site/folder' }], + source, + }); + expect(await results).to.equal(2); + expect(page).to.equal(1); + }); + + it('Stops recursing into subfolders once cancelCrawl is called', async () => { + const listed = []; + const source = { + list: async (path) => { + listed.push(path); + if (path === '/org/site/folder') { + return { + ok: true, + items: [ + { name: 'a', ext: 'html', path: '/org/site/folder/a.html' }, + { name: 'sub', path: '/org/site/folder/sub' }, + ], + continuationToken: null, + }; + } + return { ok: true, items: [], continuationToken: null }; + }, + }; + const crawl = crawlDeleteCount({ folders: [{ path: '/org/site/folder' }], source }); + crawl.cancelCrawl(); + // The top folder's in-flight listing still resolves, but the discovered + // subfolder is never crawled. + expect(await crawl.results).to.equal(1); + expect(listed).to.deep.equal(['/org/site/folder']); + }); + + it('Stops recursing when a listing is not ok', async () => { + const source = { list: async () => ({ ok: false, items: [], continuationToken: null }) }; + const { results } = crawlDeleteCount({ + folders: [{ path: '/org/site/folder' }], + fileCount: 1, + source, + }); + expect(await results).to.equal(1); + }); +}); + describe('items2Clipboard', () => { let captured; let savedClipboard;