diff --git a/archive/folderview.plus-2026.07.24.03.txz b/archive/folderview.plus-2026.07.24.03.txz deleted file mode 100644 index e0ccd5b6..00000000 Binary files a/archive/folderview.plus-2026.07.24.03.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.07.24.03.txz.sha256 b/archive/folderview.plus-2026.07.24.03.txz.sha256 deleted file mode 100644 index 8f96e47c..00000000 --- a/archive/folderview.plus-2026.07.24.03.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -a4f6636060f3f412f2db8fbb34e86309e958f196c28cdf7406bb4a3875d9ad52 folderview.plus-2026.07.24.03.txz diff --git a/archive/folderview.plus-2026.07.28.08.txz b/archive/folderview.plus-2026.07.28.08.txz new file mode 100644 index 00000000..bd1c432e Binary files /dev/null and b/archive/folderview.plus-2026.07.28.08.txz differ diff --git a/archive/folderview.plus-2026.07.28.08.txz.sha256 b/archive/folderview.plus-2026.07.28.08.txz.sha256 new file mode 100644 index 00000000..0ec60ba5 --- /dev/null +++ b/archive/folderview.plus-2026.07.28.08.txz.sha256 @@ -0,0 +1 @@ +9f2d20b30e6f48538592c87c13b217755ec6ddf89cb0a64074938c99575f0c34 folderview.plus-2026.07.28.08.txz diff --git a/docs/releases/2026.07.28.08.md b/docs/releases/2026.07.28.08.md new file mode 100644 index 00000000..8b064872 --- /dev/null +++ b/docs/releases/2026.07.28.08.md @@ -0,0 +1,22 @@ +# FolderView Plus 2026.07.28.08 + +This interaction reliability update ensures a folder's Unpin request is not +lost while its preceding Pin preference save is still settling. + +## Folder interactions + +- Fix: Retain one latest reversible folder Pin or Unpin intent instead of + silently discarding it when the same folder already has a save in flight. +- Fix: Capture the requested pinned state when the Docker or VM menu action is + selected so queued work cannot be reinterpreted after asynchronous state + changes. +- Reliability: Preserve duplicate suppression for non-queued actions and + continue to serialize preference writes through the existing revision, + rollback, and persistence guards. + +## Regression coverage + +- Test: Exercise queued, replaced, completed, and default duplicate-suppression + behavior in the shared safe UI action runner. +- Test: Add a Chromium fixture proving one Unpin selection is retained while + the preceding Pin save settles and produces an unpinned final state. diff --git a/docs/sbom.cdx.json b/docs/sbom.cdx.json index c195454f..61691aae 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.07", + "version": "2026.07.28.08", "properties": [ { "name": "folderview-plus:runtime-dependencies", diff --git a/folderview.plus.plg b/folderview.plus.plg index fbb34599..96ca3c36 100644 --- a/folderview.plus.plg +++ b/folderview.plus.plg @@ -6,9 +6,9 @@ - - - + + + @@ -18,6 +18,26 @@ +###2026.07.28.08 +# FolderView Plus 2026.07.28.08 +This interaction reliability update ensures a folder's Unpin request is not +lost while its preceding Pin preference save is still settling. +## Folder interactions +- Fix: Retain one latest reversible folder Pin or Unpin intent instead of + silently discarding it when the same folder already has a save in flight. +- Fix: Capture the requested pinned state when the Docker or VM menu action is + selected so queued work cannot be reinterpreted after asynchronous state + changes. +- Reliability: Preserve duplicate suppression for non-queued actions and + continue to serialize preference writes through the existing revision, + rollback, and persistence guards. +## Regression coverage +- Test: Exercise queued, replaced, completed, and default duplicate-suppression + behavior in the shared safe UI action runner. +- Test: Add a Chromium fixture proving one Unpin selection is retained while + the preceding Pin save settles and produces an unpinned final state. + + ###2026.07.28.07 # FolderView Plus 2026.07.28.07 This focused reliability update restores first-click folder menu behavior after diff --git a/scripts/fixture_browser_tests.mjs b/scripts/fixture_browser_tests.mjs index b614f4c2..01eb9285 100644 --- a/scripts/fixture_browser_tests.mjs +++ b/scripts/fixture_browser_tests.mjs @@ -284,6 +284,16 @@ test('Docker folder context menu opens from the first folder-icon click', async assert.equal(result.defaultPrevented, false, 'menu preparation must not cancel the opening click'); }); +test('Docker folder Unpin intent is retained while the preceding Pin save settles', async ({ page }) => { + 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.pinned, false, 'one queued Unpin action must produce the requested final state'); + assert.deepEqual(result.transitions, ['pinned', 'pin-saved', 'unpinned']); + assert.equal(result.runningAfterSave, false); + assert.equal(result.queuedAfterSave, false); +}); + test('Docker and VM host adapters share row, structure, and idempotent hook contracts', async ({ page }) => { await page.goto(`${baseUrl}/runtime`, { waitUntil: 'load' }); const result = await page.evaluate(() => window.fixtureRuntime.exerciseHostAdapters()); 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 c999a70b..3abfcde5 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 @@ -3360,7 +3360,7 @@ const broadcastDockerPinnedFolderChange = (payload = {}) => { // Same-window refresh hints are best-effort for older browsers. } }; -const toggleDockerFolderPin = async (folderId) => { +const toggleDockerFolderPin = async (folderId, requestedPinned = !isDockerFolderPinned(folderId)) => { const id = String(folderId || '').trim(); if (!id || !globalFolders[id]) { return; @@ -3370,9 +3370,9 @@ const toggleDockerFolderPin = async (folderId) => { const result = await runDockerGuardedAction('toggle-folder-pin', async () => { const currentPrefs = await fetchDockerPinnedFolderPrefs(); const current = normalizeDockerPinnedFolderIdList(currentPrefs.pinnedFolderIds); - const nextPinned = current.includes(id) - ? current.filter((entry) => entry !== id) - : [...current, id]; + const nextPinned = requestedPinned === true + ? (current.includes(id) ? current : [...current, id]) + : current.filter((entry) => entry !== id); rememberDockerPinnedFolderIdsOverride(nextPinned); applyDockerPinnedFolderIds(nextPinned); syncDockerPinnedFolderUi(); @@ -3394,7 +3394,7 @@ const toggleDockerFolderPin = async (folderId) => { applyDockerPinnedFolderIds(previousPinned); syncDockerPinnedFolderUi(); } - }); + }, { queueIfBusy: true }); }; const buildDockerFolderRuntimeOrderState = () => { const folders = globalFolders && typeof globalFolders === 'object' ? globalFolders : {}; @@ -7589,7 +7589,7 @@ const addDockerFolderContext = (id) => { icon: 'fa-thumb-tack', action: (evt) => { evt.preventDefault(); - toggleDockerFolderPin(id); + toggleDockerFolderPin(id, !pinned); } }); opts.push({ 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 1ef0aeac..63039d9e 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,38 +797,68 @@ /** * 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. * @param {{onError?: (error: Error, actionKey: string) => void, onBusy?: (actionKey: string) => void}} options */ const createSafeUiActionRunner = (options = {}) => { const inFlight = new Set(); + const queued = 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) => { + inFlight.add(key); + let result; + try { + const value = await action(); + result = { ok: true, value }; + } catch (rawError) { + const error = rawError instanceof Error ? rawError : new Error(String(rawError || 'Unknown error')); + onError(error, key); + result = { ok: false, error }; + } finally { + inFlight.delete(key); + const pending = queued.get(key); + if (pending) { + queued.delete(key); + void execute(key, pending.action).then(pending.resolve); + } + } + return result; + }; return { isRunning: (actionKey) => inFlight.has(String(actionKey || '')), - run: async (actionKey, action) => { + isQueued: (actionKey) => queued.has(String(actionKey || '')), + run: async (actionKey, action, settings = {}) => { const key = String(actionKey || '').trim() || 'action'; + 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 (onBusy) { onBusy(key); } return { ok: false, skipped: true, reason: 'in-flight' }; } - if (typeof action !== 'function') { - return { ok: false, skipped: true, reason: 'invalid-action' }; - } - inFlight.add(key); - try { - const value = await action(); - return { ok: true, value }; - } catch (rawError) { - const error = rawError instanceof Error ? rawError : new Error(String(rawError || 'Unknown error')); - onError(error, key); - return { ok: false, error }; - } finally { - inFlight.delete(key); - } + return execute(key, action); } }; }; 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 094ad7ab..ad5cbdd2 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 @@ -1005,16 +1005,16 @@ const persistVmPinnedFolderIds = async (nextPinnedIds) => { prefs: confirmedPrefs }; }; -const toggleVmFolderPin = async (folderId) => { +const toggleVmFolderPin = async (folderId, requestedPinned = !isVmFolderPinned(folderId)) => { const id = String(folderId || '').trim(); if (!id || !globalFolders[id]) { return; } return vmSafeUiActionRunner.run(`vm-pin:${id}`, async () => { const current = Array.isArray(folderTypePrefs?.pinnedFolderIds) ? [...folderTypePrefs.pinnedFolderIds] : []; - const nextPinned = current.includes(id) - ? current.filter((entry) => entry !== id) - : [...current, id]; + const nextPinned = requestedPinned === true + ? (current.includes(id) ? current : [...current, id]) + : current.filter((entry) => entry !== id); rememberVmPinnedFolderIdsOverride(nextPinned); applyVmPinnedFolderIds(nextPinned); refreshVmFolderQuickActionStates(); @@ -1031,7 +1031,7 @@ const toggleVmFolderPin = async (folderId) => { applyVmPinnedFolderIds(current); refreshVmFolderQuickActionStates(); } - }); + }, { queueIfBusy: true }); }; const ensureVmFolderUnlocked = (id, actionLabel = 'This action') => { if (!isVmFolderLocked(id)) { @@ -3020,7 +3020,7 @@ const addVMFolderContext = (id) => { icon: pinned ? 'fa-star' : 'fa-star-o', action: (evt) => { evt.preventDefault(); - toggleVmFolderPin(id); + toggleVmFolderPin(id, !pinned); } }); opts.push({ diff --git a/tests/browser/fixtures/docker-layout-stability.html b/tests/browser/fixtures/docker-layout-stability.html index 26d26e84..9d8f48ca 100644 --- a/tests/browser/fixtures/docker-layout-stability.html +++ b/tests/browser/fixtures/docker-layout-stability.html @@ -179,6 +179,38 @@ }; } }; + + window.fixtureQueuedFolderPinIntent = { + run: async () => { + const runner = window.FolderViewDockerRuntimeShared.createSafeUiActionRunner(); + let releasePin; + const pinSave = new Promise((resolve) => { + releasePin = resolve; + }); + const transitions = []; + let pinned = false; + const pin = runner.run('docker-pin:fixture-folder', async () => { + pinned = true; + transitions.push('pinned'); + await pinSave; + transitions.push('pin-saved'); + }); + const unpin = runner.run('docker-pin:fixture-folder', async () => { + pinned = false; + transitions.push('unpinned'); + }, { queueIfBusy: true }); + const queuedBeforeSave = runner.isQueued('docker-pin:fixture-folder'); + releasePin(); + await Promise.all([pin, unpin]); + return { + queuedBeforeSave, + pinned, + transitions, + runningAfterSave: runner.isRunning('docker-pin:fixture-folder'), + queuedAfterSave: runner.isQueued('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 930f4ea1..656c7b9e 100644 --- a/tests/docker-folder-row-quick-actions.test.mjs +++ b/tests/docker-folder-row-quick-actions.test.mjs @@ -61,7 +61,10 @@ test('docker context menu keeps focus/pin/lock quick actions at the top', () => test('docker pin quick action updates visible folder order immediately', () => { assert.match(dockerScript, /const reorderVisibleDockerRootFolderBlocks = \(\) =>/); assert.match(dockerScript, /const syncDockerPinnedFolderUi = \(\) =>/); - assert.match(dockerScript, /const currentPrefs = await fetchDockerPinnedFolderPrefs\(\);[\s\S]*const current = normalizeDockerPinnedFolderIdList\(currentPrefs\.pinnedFolderIds\);[\s\S]*const nextPinned = current\.includes\(id\)/); + 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, /toggleDockerFolderPin\(id,\s*!pinned\);/); + assert.match(dockerScript, /\},\s*\{\s*queueIfBusy:\s*true\s*\}\);/); assert.match(dockerScript, /applyDockerPinnedFolderIds\(nextPinned\);\s*syncDockerPinnedFolderUi\(\);/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); diff --git a/tests/folder-action-regression.test.mjs b/tests/folder-action-regression.test.mjs index d96d67b7..06180b7b 100644 --- a/tests/folder-action-regression.test.mjs +++ b/tests/folder-action-regression.test.mjs @@ -28,6 +28,9 @@ 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, /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 new file mode 100644 index 00000000..5e069cc8 --- /dev/null +++ b/tests/safe-ui-action-runner.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; + +const source = fs.readFileSync( + path.resolve( + process.cwd(), + 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js' + ), + 'utf8' +); + +const loadRuntimeShared = () => { + const window = { setTimeout, clearTimeout }; + vm.runInNewContext(source, { + window, + document: {}, + Element: class {}, + console, + Map, + Set, + WeakMap, + Object, + Array, + String, + Number, + Boolean, + Date, + Math, + JSON, + Promise, + performance, + setTimeout, + clearTimeout + }); + return window.FolderViewDockerRuntimeShared; +}; + +test('safe UI action runner queues one latest reversible intent while an action is busy', async () => { + const runner = loadRuntimeShared().createSafeUiActionRunner(); + let releaseFirst; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const calls = []; + + const first = runner.run('docker-pin:folder-1', async () => { + calls.push('pin-start'); + await firstGate; + calls.push('pin-end'); + return 'pinned'; + }); + const queued = runner.run('docker-pin:folder-1', async () => { + calls.push('stale-unpin'); + return 'stale'; + }, { queueIfBusy: true }); + const latestQueued = runner.run('docker-pin:folder-1', async () => { + calls.push('unpin'); + return 'unpinned'; + }, { queueIfBusy: true }); + + assert.equal(runner.isRunning('docker-pin:folder-1'), true); + assert.equal(runner.isQueued('docker-pin:folder-1'), true); + + releaseFirst(); + assert.equal((await first).value, 'pinned'); + assert.equal((await queued).value, 'unpinned'); + assert.equal((await latestQueued).value, 'unpinned'); + assert.deepEqual(calls, ['pin-start', 'pin-end', 'unpin']); + assert.equal(runner.isRunning('docker-pin:folder-1'), false); + assert.equal(runner.isQueued('docker-pin:folder-1'), false); +}); + +test('safe UI action runner keeps duplicate suppression as its default', async () => { + const runner = loadRuntimeShared().createSafeUiActionRunner(); + let releaseFirst; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = runner.run('folder-move', async () => firstGate); + const duplicate = await runner.run('folder-move', async () => 'unexpected'); + + assert.equal(duplicate.ok, false); + assert.equal(duplicate.skipped, true); + assert.equal(duplicate.reason, 'in-flight'); + releaseFirst('done'); + assert.equal((await first).value, 'done'); +});