diff --git a/archive/folderview.plus-2026.07.24.04.txz b/archive/folderview.plus-2026.07.24.04.txz deleted file mode 100644 index e0be054f..00000000 Binary files a/archive/folderview.plus-2026.07.24.04.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.07.24.04.txz.sha256 b/archive/folderview.plus-2026.07.24.04.txz.sha256 deleted file mode 100644 index 1cdfaa88..00000000 --- a/archive/folderview.plus-2026.07.24.04.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -3b5102e48771961c57a7ed3f8ac3d232f1dea8c9093e909ffc80ad55b386e635 folderview.plus-2026.07.24.04.txz diff --git a/archive/folderview.plus-2026.07.28.09.txz b/archive/folderview.plus-2026.07.28.09.txz new file mode 100644 index 00000000..c65959d2 Binary files /dev/null and b/archive/folderview.plus-2026.07.28.09.txz differ diff --git a/archive/folderview.plus-2026.07.28.09.txz.sha256 b/archive/folderview.plus-2026.07.28.09.txz.sha256 new file mode 100644 index 00000000..0fb8d04a --- /dev/null +++ b/archive/folderview.plus-2026.07.28.09.txz.sha256 @@ -0,0 +1 @@ +5a0332d780639aa201001b38888b97969b4d67d17b0cb2916146e4fae16b644d folderview.plus-2026.07.28.09.txz diff --git a/docs/releases/2026.07.28.09.md b/docs/releases/2026.07.28.09.md new file mode 100644 index 00000000..38a05ddb --- /dev/null +++ b/docs/releases/2026.07.28.09.md @@ -0,0 +1,12 @@ +## FolderView Plus 2026.07.28.09 + +### Immediate folder pin feedback + +- Fix: Docker folder Pin and Unpin actions now update the visible folder order, pin badge, and menu state immediately instead of waiting for a server preference read. +- Fix: VM folder Pin and Unpin actions use the same immediate interaction behavior for runtime parity. + +### Reliable asynchronous persistence + +- Fix: Older Pin or Unpin save completions can no longer repaint over a newer user selection while preference requests are still settling. +- Reliability: Server-authoritative preference merging, post-save read-back verification, stale-render protection, and rollback on the latest save failure remain enabled. +- Quality: Added shared intent-generation coverage and a browser regression that verifies Unpin is visible before an outstanding Pin save completes. diff --git a/docs/sbom.cdx.json b/docs/sbom.cdx.json index 61691aae..581635ee 100644 --- a/docs/sbom.cdx.json +++ b/docs/sbom.cdx.json @@ -6,7 +6,7 @@ "component": { "type": "application", "name": "FolderView Plus", - "version": "2026.07.28.08", + "version": "2026.07.28.09", "properties": [ { "name": "folderview-plus:runtime-dependencies", diff --git a/folderview.plus.plg b/folderview.plus.plg index 96ca3c36..90a64183 100644 --- a/folderview.plus.plg +++ b/folderview.plus.plg @@ -6,9 +6,9 @@ - - - + + + @@ -18,6 +18,17 @@ +###2026.07.28.09 +## FolderView Plus 2026.07.28.09 +### Immediate folder pin feedback +- Fix: Docker folder Pin and Unpin actions now update the visible folder order, pin badge, and menu state immediately instead of waiting for a server preference read. +- Fix: VM folder Pin and Unpin actions use the same immediate interaction behavior for runtime parity. +### Reliable asynchronous persistence +- Fix: Older Pin or Unpin save completions can no longer repaint over a newer user selection while preference requests are still settling. +- Reliability: Server-authoritative preference merging, post-save read-back verification, stale-render protection, and rollback on the latest save failure remain enabled. +- Quality: Added shared intent-generation coverage and a browser regression that verifies Unpin is visible before an outstanding Pin save completes. + + ###2026.07.28.08 # FolderView Plus 2026.07.28.08 This interaction reliability update ensures a folder's Unpin request is not diff --git a/scripts/fixture_browser_tests.mjs b/scripts/fixture_browser_tests.mjs index 01eb9285..0ffd69f1 100644 --- a/scripts/fixture_browser_tests.mjs +++ b/scripts/fixture_browser_tests.mjs @@ -288,8 +288,9 @@ test('Docker folder Unpin intent is retained while the preceding Pin save settle await page.goto(`${baseUrl}/docker-layout-stability`, { waitUntil: 'load' }); const result = await page.evaluate(() => window.fixtureQueuedFolderPinIntent.run()); assert.equal(result.queuedBeforeSave, true, 'Unpin must queue while Pin is saving'); + assert.equal(result.pinnedBeforeSave, false, 'Unpin must update the visible state before Pin finishes saving'); assert.equal(result.pinned, false, 'one queued Unpin action must produce the requested final state'); - assert.deepEqual(result.transitions, ['pinned', 'pin-saved', 'unpinned']); + assert.deepEqual(result.transitions, ['pinned', 'unpinned', 'pin-saved', 'unpin-saved']); assert.equal(result.runningAfterSave, false); assert.equal(result.queuedAfterSave, false); }); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js index 3abfcde5..6f44265d 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js @@ -3362,21 +3362,16 @@ const broadcastDockerPinnedFolderChange = (payload = {}) => { }; const toggleDockerFolderPin = async (folderId, requestedPinned = !isDockerFolderPinned(folderId)) => { const id = String(folderId || '').trim(); - if (!id || !globalFolders[id]) { - return; - } - return dockerSafeUiActionRunner.run(`docker-pin:${id}`, async () => { - const previousPinned = normalizeDockerPinnedFolderIdList(folderTypePrefs?.pinnedFolderIds); + if (!id || !globalFolders[id]) { return; } + const previousPinned = normalizeDockerPinnedFolderIdList(folderTypePrefs?.pinnedFolderIds); + const optimisticPinned = requestedPinned === true ? (previousPinned.includes(id) ? previousPinned : [...previousPinned, id]) : previousPinned.filter((entry) => entry !== id); + return dockerSafeUiActionRunner.run(`docker-pin:${id}`, async (intent) => { const result = await runDockerGuardedAction('toggle-folder-pin', async () => { const currentPrefs = await fetchDockerPinnedFolderPrefs(); const current = normalizeDockerPinnedFolderIdList(currentPrefs.pinnedFolderIds); - const nextPinned = requestedPinned === true - ? (current.includes(id) ? current : [...current, id]) - : current.filter((entry) => entry !== id); - rememberDockerPinnedFolderIdsOverride(nextPinned); - applyDockerPinnedFolderIds(nextPinned); - syncDockerPinnedFolderUi(); + const nextPinned = requestedPinned === true ? (current.includes(id) ? current : [...current, id]) : current.filter((entry) => entry !== id); const response = await persistDockerPinnedFolderIds(nextPinned); + if (!intent.isLatest()) { return; } const confirmedPinned = normalizeDockerPinnedFolderIdList(response?.prefs?.pinnedFolderIds || nextPinned); applyDockerPinnedFolderIds(confirmedPinned); syncDockerPinnedFolderUi(); @@ -3389,12 +3384,16 @@ const toggleDockerFolderPin = async (folderId, requestedPinned = !isDockerFolder userMessage: getDockerMenuLabel('folder-pin-failed', 'Failed to update pinned folders.'), userVisible: true }); - if (!result.ok) { + if (!result.ok && intent.isLatest()) { clearDockerPinnedFolderIdsOverride(); applyDockerPinnedFolderIds(previousPinned); syncDockerPinnedFolderUi(); } - }, { queueIfBusy: true }); + }, { queueIfBusy: true, onIntent: () => { + rememberDockerPinnedFolderIdsOverride(optimisticPinned); + applyDockerPinnedFolderIds(optimisticPinned); + syncDockerPinnedFolderUi(); + } }); }; const buildDockerFolderRuntimeOrderState = () => { const folders = globalFolders && typeof globalFolders === 'object' ? globalFolders : {}; diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js index 63039d9e..fb566be3 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js @@ -797,21 +797,23 @@ /** * Deduplicates UI-triggered async actions by key to avoid racey double-click behavior. - * Reversible controls can retain one latest intent while the current action settles. + * Reversible controls can apply the latest intent immediately and retain its action + * while the current request settles. * @param {{onError?: (error: Error, actionKey: string) => void, onBusy?: (actionKey: string) => void}} options */ const createSafeUiActionRunner = (options = {}) => { const inFlight = new Set(); const queued = new Map(); + const intentGenerations = new Map(); const onError = typeof options.onError === 'function' ? options.onError : (error, actionKey) => console.error(`folderview.plus: safe ui action failed (${actionKey})`, error); const onBusy = typeof options.onBusy === 'function' ? options.onBusy : null; - const execute = async (key, action) => { + const execute = async (key, action, intent) => { inFlight.add(key); let result; try { - const value = await action(); + const value = await action(intent); result = { ok: true, value }; } catch (rawError) { const error = rawError instanceof Error ? rawError : new Error(String(rawError || 'Unknown error')); @@ -822,7 +824,7 @@ const pending = queued.get(key); if (pending) { queued.delete(key); - void execute(key, pending.action).then(pending.resolve); + void execute(key, pending.action, pending.intent).then(pending.resolve); } } return result; @@ -835,30 +837,41 @@ if (typeof action !== 'function') { return { ok: false, skipped: true, reason: 'invalid-action' }; } - if (inFlight.has(key)) { - if (settings.queueIfBusy === true) { - const pending = queued.get(key); - if (pending) { - pending.action = action; - return pending.promise; - } - let resolveQueued; - const promise = new Promise((resolve) => { - resolveQueued = resolve; - }); - queued.set(key, { - action, - promise, - resolve: resolveQueued - }); - return promise; - } + if (inFlight.has(key) && settings.queueIfBusy !== true) { if (onBusy) { onBusy(key); } return { ok: false, skipped: true, reason: 'in-flight' }; } - return execute(key, action); + const generation = Number(intentGenerations.get(key) || 0) + 1; + intentGenerations.set(key, generation); + const intent = Object.freeze({ + generation, + isLatest: () => Number(intentGenerations.get(key) || 0) === generation + }); + if (typeof settings.onIntent === 'function') { + settings.onIntent(intent); + } + if (inFlight.has(key)) { + const pending = queued.get(key); + if (pending) { + pending.action = action; + pending.intent = intent; + return pending.promise; + } + let resolveQueued; + const promise = new Promise((resolve) => { + resolveQueued = resolve; + }); + queued.set(key, { + action, + intent, + promise, + resolve: resolveQueued + }); + return promise; + } + return execute(key, action, intent); } }; }; diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.js index ad5cbdd2..4a97986d 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.js @@ -1010,28 +1010,32 @@ const toggleVmFolderPin = async (folderId, requestedPinned = !isVmFolderPinned(f if (!id || !globalFolders[id]) { return; } - return vmSafeUiActionRunner.run(`vm-pin:${id}`, async () => { - const current = Array.isArray(folderTypePrefs?.pinnedFolderIds) ? [...folderTypePrefs.pinnedFolderIds] : []; - const nextPinned = requestedPinned === true - ? (current.includes(id) ? current : [...current, id]) - : current.filter((entry) => entry !== id); - rememberVmPinnedFolderIdsOverride(nextPinned); - applyVmPinnedFolderIds(nextPinned); - refreshVmFolderQuickActionStates(); + const current = Array.isArray(folderTypePrefs?.pinnedFolderIds) ? [...folderTypePrefs.pinnedFolderIds] : []; + const nextPinned = requestedPinned === true + ? (current.includes(id) ? current : [...current, id]) + : current.filter((entry) => entry !== id); + return vmSafeUiActionRunner.run(`vm-pin:${id}`, async (intent) => { const result = await runVmGuardedAction('toggle-folder-pin', async () => { const response = await persistVmPinnedFolderIds(nextPinned); + if (!intent.isLatest()) { + return; + } applyVmPinnedFolderIds(Array.isArray(response?.prefs?.pinnedFolderIds) ? response.prefs.pinnedFolderIds : nextPinned); refreshVmFolderQuickActionStates(); }, { userMessage: 'Failed to update pinned folders.', userVisible: false }); - if (!result.ok) { + if (!result.ok && intent.isLatest()) { clearVmPinnedFolderIdsOverride(); applyVmPinnedFolderIds(current); refreshVmFolderQuickActionStates(); } - }, { queueIfBusy: true }); + }, { queueIfBusy: true, onIntent: () => { + rememberVmPinnedFolderIdsOverride(nextPinned); + applyVmPinnedFolderIds(nextPinned); + refreshVmFolderQuickActionStates(); + } }); }; const ensureVmFolderUnlocked = (id, actionLabel = 'This action') => { if (!isVmFolderLocked(id)) { diff --git a/tests/browser/fixtures/docker-layout-stability.html b/tests/browser/fixtures/docker-layout-stability.html index 9d8f48ca..b93b5ac3 100644 --- a/tests/browser/fixtures/docker-layout-stability.html +++ b/tests/browser/fixtures/docker-layout-stability.html @@ -189,21 +189,33 @@ }); const transitions = []; let pinned = false; - const pin = runner.run('docker-pin:fixture-folder', async () => { - pinned = true; - transitions.push('pinned'); + const applyIntent = (nextPinned) => { + if (pinned === nextPinned) { + return; + } + pinned = nextPinned; + transitions.push(nextPinned ? 'pinned' : 'unpinned'); + }; + const pin = runner.run('docker-pin:fixture-folder', async (intent) => { await pinSave; transitions.push('pin-saved'); - }); - const unpin = runner.run('docker-pin:fixture-folder', async () => { - pinned = false; - transitions.push('unpinned'); - }, { queueIfBusy: true }); + if (intent.isLatest()) { + applyIntent(true); + } + }, { onIntent: () => applyIntent(true) }); + const unpin = runner.run('docker-pin:fixture-folder', async (intent) => { + transitions.push('unpin-saved'); + if (intent.isLatest()) { + applyIntent(false); + } + }, { queueIfBusy: true, onIntent: () => applyIntent(false) }); const queuedBeforeSave = runner.isQueued('docker-pin:fixture-folder'); + const pinnedBeforeSave = pinned; releasePin(); await Promise.all([pin, unpin]); return { queuedBeforeSave, + pinnedBeforeSave, pinned, transitions, runningAfterSave: runner.isRunning('docker-pin:fixture-folder'), diff --git a/tests/docker-folder-row-quick-actions.test.mjs b/tests/docker-folder-row-quick-actions.test.mjs index 656c7b9e..d2f87722 100644 --- a/tests/docker-folder-row-quick-actions.test.mjs +++ b/tests/docker-folder-row-quick-actions.test.mjs @@ -62,10 +62,10 @@ test('docker pin quick action updates visible folder order immediately', () => { assert.match(dockerScript, /const reorderVisibleDockerRootFolderBlocks = \(\) =>/); assert.match(dockerScript, /const syncDockerPinnedFolderUi = \(\) =>/); assert.match(dockerScript, /const toggleDockerFolderPin = async \(folderId,\s*requestedPinned = !isDockerFolderPinned\(folderId\)\) =>/); - assert.match(dockerScript, /const currentPrefs = await fetchDockerPinnedFolderPrefs\(\);[\s\S]*const current = normalizeDockerPinnedFolderIdList\(currentPrefs\.pinnedFolderIds\);[\s\S]*const nextPinned = requestedPinned === true/); + assert.match(dockerScript, /const optimisticPinned = requestedPinned === true[\s\S]*return dockerSafeUiActionRunner\.run\(`docker-pin:\$\{id\}`,\s*async \(intent\) =>/); assert.match(dockerScript, /toggleDockerFolderPin\(id,\s*!pinned\);/); - assert.match(dockerScript, /\},\s*\{\s*queueIfBusy:\s*true\s*\}\);/); - assert.match(dockerScript, /applyDockerPinnedFolderIds\(nextPinned\);\s*syncDockerPinnedFolderUi\(\);/s); + assert.match(dockerScript, /queueIfBusy:\s*true,\s*onIntent:\s*\(\) => \{[\s\S]*applyDockerPinnedFolderIds\(optimisticPinned\);\s*syncDockerPinnedFolderUi\(\);/); + assert.match(dockerScript, /if \(!intent\.isLatest\(\)\) \{\s*return;\s*\}/); assert.match(dockerScript, /const confirmedPinned = normalizeDockerPinnedFolderIdList\(response\?\.prefs\?\.pinnedFolderIds \|\| nextPinned\);[\s\S]*applyDockerPinnedFolderIds\(confirmedPinned\);\s*syncDockerPinnedFolderUi\(\);/s); assert.doesNotMatch(dockerScript, /applyDockerPinnedFolderIds\(confirmedPinned\);\s*syncDockerPinnedFolderUi\(\);\s*queueLoadlistRefresh\(/s); assert.match(dockerScript, /applyDockerPinnedFolderIds\(previousPinned\);\s*syncDockerPinnedFolderUi\(\);/s); @@ -82,7 +82,7 @@ test('docker pin quick action verifies server persistence before keeping optimis assert.match(dockerScript, /const confirmedPrefs = await fetchDockerPinnedFolderPrefs\(\);/); assert.match(dockerScript, /if \(!dockerPinnedFolderIdListsMatch\(confirmedPrefs\.pinnedFolderIds, nextPinnedIds\)\) \{/); assert.match(dockerScript, /throw new Error\('Docker pinned folders did not persist\.'\);/); - assert.match(dockerScript, /rememberDockerPinnedFolderIdsOverride\(nextPinned\);/); + assert.match(dockerScript, /rememberDockerPinnedFolderIdsOverride\(optimisticPinned\);/); assert.match(dockerScript, /const currentPrefs = await fetchDockerPinnedFolderPrefs\(\);/); assert.match(dockerScript, /broadcastDockerPinnedFolderChange\(\{[\s\S]*pinnedFolderIds:\s*confirmedPinned,[\s\S]*changedFolderId:\s*id,[\s\S]*pinned:\s*confirmedPinned\.includes\(id\)[\s\S]*\}\);/); assert.match(dockerScript, /folderTypePrefs = applyDockerPinnedFolderPrefsOverride\(normalizeDockerPrefsResponse\(prefsResponse\)\);/); diff --git a/tests/folder-action-regression.test.mjs b/tests/folder-action-regression.test.mjs index 06180b7b..e1f95fd6 100644 --- a/tests/folder-action-regression.test.mjs +++ b/tests/folder-action-regression.test.mjs @@ -30,7 +30,8 @@ test('dashboard folder action errors do not trigger an immediate second reload', test('vm pin persistence and folder action error handling avoid stale reloads', () => { assert.match(vmJs, /const toggleVmFolderPin = async \(folderId,\s*requestedPinned = !isVmFolderPinned\(folderId\)\) =>/); assert.match(vmJs, /toggleVmFolderPin\(id,\s*!pinned\);/); - assert.match(vmJs, /\},\s*\{\s*queueIfBusy:\s*true\s*\}\);/); + assert.match(vmJs, /queueIfBusy:\s*true,\s*onIntent:\s*\(\) => \{[\s\S]*applyVmPinnedFolderIds\(nextPinned\);[\s\S]*refreshVmFolderQuickActionStates\(\);/); + assert.match(vmJs, /async \(intent\) => \{[\s\S]*if \(!intent\.isLatest\(\)\) \{\s*return;\s*\}/); assert.match(vmJs, /applyVmPinnedFolderIds\(Array\.isArray\(response\?\.prefs\?\.pinnedFolderIds\) \? response\.prefs\.pinnedFolderIds : nextPinned\);\s*refreshVmFolderQuickActionStates\(\);/s); assert.doesNotMatch(vmJs, /applyVmPinnedFolderIds\(Array\.isArray\(response\?\.prefs\?\.pinnedFolderIds\) \? response\.prefs\.pinnedFolderIds : nextPinned\);\s*refreshVmFolderQuickActionStates\(\);\s*queueLoadlistRefresh\(/s); assert.match(vmJs, /const assertVmPrefsSaveResponse = \(response, fallbackMessage = 'Failed to save VM preferences\.'\) => \{/); diff --git a/tests/safe-ui-action-runner.test.mjs b/tests/safe-ui-action-runner.test.mjs index 5e069cc8..aa9839ed 100644 --- a/tests/safe-ui-action-runner.test.mjs +++ b/tests/safe-ui-action-runner.test.mjs @@ -73,6 +73,33 @@ test('safe UI action runner queues one latest reversible intent while an action assert.equal(runner.isQueued('docker-pin:folder-1'), false); }); +test('safe UI action runner applies every intent immediately and marks stale work', async () => { + const runner = loadRuntimeShared().createSafeUiActionRunner(); + const visibleStates = []; + const completions = []; + let releaseFirst; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const first = runner.run('docker-pin:folder-1', async (intent) => { + await firstGate; + completions.push(intent.isLatest() ? 'pin-current' : 'pin-stale'); + }, { + onIntent: () => visibleStates.push('pinned') + }); + const second = runner.run('docker-pin:folder-1', async (intent) => { + completions.push(intent.isLatest() ? 'unpin-current' : 'unpin-stale'); + }, { + queueIfBusy: true, + onIntent: () => visibleStates.push('unpinned') + }); + + assert.deepEqual(visibleStates, ['pinned', 'unpinned']); + releaseFirst(); + await Promise.all([first, second]); + assert.deepEqual(completions, ['pin-stale', 'unpin-current']); +}); + test('safe UI action runner keeps duplicate suppression as its default', async () => { const runner = loadRuntimeShared().createSafeUiActionRunner(); let releaseFirst;