Skip to content
Merged
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
Binary file removed archive/folderview.plus-2026.07.24.03.txz
Binary file not shown.
1 change: 0 additions & 1 deletion archive/folderview.plus-2026.07.24.03.txz.sha256

This file was deleted.

Binary file added archive/folderview.plus-2026.07.28.08.txz
Binary file not shown.
1 change: 1 addition & 0 deletions archive/folderview.plus-2026.07.28.08.txz.sha256
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
9f2d20b30e6f48538592c87c13b217755ec6ddf89cb0a64074938c99575f0c34 folderview.plus-2026.07.28.08.txz
22 changes: 22 additions & 0 deletions docs/releases/2026.07.28.08.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/sbom.cdx.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 23 additions & 3 deletions folderview.plus.plg
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
<!ENTITY launch "Settings/FolderViewPlus">
<!ENTITY plugdir "/usr/local/emhttp/plugins/&name;">
<!ENTITY pluginURL "https://raw.githubusercontent.com/&github;/dev/folderview.plus.plg">
<!ENTITY version "2026.07.28.07">
<!ENTITY md5 "fb77af5943983dd6984d0c5f8eeb3b6a">
<!ENTITY sha256 "169b45ebdf1143c7c4f86d12703727d4c8db3dab1c637b1434c8ddb2dcf03c03">
<!ENTITY version "2026.07.28.08">
<!ENTITY md5 "3d15bf60aa67884829989e9e8a817b63">
<!ENTITY sha256 "9f2d20b30e6f48538592c87c13b217755ec6ddf89cb0a64074938c99575f0c34">
<!ENTITY iconPackVersion "1.0.0">
<!ENTITY iconPackMd5 "a149c36b41339949bb5c1eecad550704">
<!ENTITY iconPackSha256 "992f6c3544a8a3c1db80b861472fdd8b3d499f20f81796ed71405a10beb750bd">
Expand All @@ -18,6 +18,26 @@
<PLUGIN name="&name;" author="&author;" version="&version;" launch="&launch;" pluginURL="&pluginURL;" icon="folder-icon.png" support="https://forums.unraid.net/topic/197631-plugin-folderview-plus/" min="7.0.0">
<CHANGES>

###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
Expand Down
10 changes: 10 additions & 0 deletions scripts/fixture_browser_tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -3394,7 +3394,7 @@ const toggleDockerFolderPin = async (folderId) => {
applyDockerPinnedFolderIds(previousPinned);
syncDockerPinnedFolderUi();
}
});
}, { queueIfBusy: true });
};
const buildDockerFolderRuntimeOrderState = () => {
const folders = globalFolders && typeof globalFolders === 'object' ? globalFolders : {};
Expand Down Expand Up @@ -7589,7 +7589,7 @@ const addDockerFolderContext = (id) => {
icon: 'fa-thumb-tack',
action: (evt) => {
evt.preventDefault();
toggleDockerFolderPin(id);
toggleDockerFolderPin(id, !pinned);
}
});
opts.push({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -1031,7 +1031,7 @@ const toggleVmFolderPin = async (folderId) => {
applyVmPinnedFolderIds(current);
refreshVmFolderQuickActionStates();
}
});
}, { queueIfBusy: true });
};
const ensureVmFolderUnlocked = (id, actionLabel = 'This action') => {
if (!isVmFolderLocked(id)) {
Expand Down Expand Up @@ -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({
Expand Down
32 changes: 32 additions & 0 deletions tests/browser/fixtures/docker-layout-stability.html
Original file line number Diff line number Diff line change
Expand Up @@ -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')
};
}
};
</script>
</body>
</html>
5 changes: 4 additions & 1 deletion tests/docker-folder-row-quick-actions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions tests/folder-action-regression.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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\.'\) => \{/);
Expand Down
Loading