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.04.txz
Binary file not shown.
1 change: 0 additions & 1 deletion archive/folderview.plus-2026.07.24.04.txz.sha256

This file was deleted.

Binary file added archive/folderview.plus-2026.07.28.09.txz
Binary file not shown.
1 change: 1 addition & 0 deletions archive/folderview.plus-2026.07.28.09.txz.sha256
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
5a0332d780639aa201001b38888b97969b4d67d17b0cb2916146e4fae16b644d folderview.plus-2026.07.28.09.txz
12 changes: 12 additions & 0 deletions docs/releases/2026.07.28.09.md
Original file line number Diff line number Diff line change
@@ -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.
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.08",
"version": "2026.07.28.09",
"properties": [
{
"name": "folderview-plus:runtime-dependencies",
Expand Down
17 changes: 14 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.08">
<!ENTITY md5 "3d15bf60aa67884829989e9e8a817b63">
<!ENTITY sha256 "9f2d20b30e6f48538592c87c13b217755ec6ddf89cb0a64074938c99575f0c34">
<!ENTITY version "2026.07.28.09">
<!ENTITY md5 "190e76ea7382561edac6641600504eb1">
<!ENTITY sha256 "5a0332d780639aa201001b38888b97969b4d67d17b0cb2916146e4fae16b644d">
<!ENTITY iconPackVersion "1.0.0">
<!ENTITY iconPackMd5 "a149c36b41339949bb5c1eecad550704">
<!ENTITY iconPackSha256 "992f6c3544a8a3c1db80b861472fdd8b3d499f20f81796ed71405a10beb750bd">
Expand All @@ -18,6 +18,17 @@
<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.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
Expand Down
3 changes: 2 additions & 1 deletion scripts/fixture_browser_tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +3366 to +3367

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Roll back to the last confirmed pin state

When multiple Pin/Unpin intents are submitted while a save is in flight, folderTypePrefs already contains the preceding optimistic intent, so previousPinned may represent a state that was never persisted. For example, if Pin is followed by Unpin and both requests fail during a network outage, the latest failure restores the transient pinned state even though the server and the user's latest selection are unpinned. Keep a server-confirmed snapshot for rollback rather than capturing the current optimistic preferences; the VM implementation has the same pattern.

Useful? React with 👍 / 👎.

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();
Expand All @@ -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 : {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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;
Expand All @@ -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);
}
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
28 changes: 20 additions & 8 deletions tests/browser/fixtures/docker-layout-stability.html
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
8 changes: 4 additions & 4 deletions tests/docker-folder-row-quick-actions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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\)\);/);
Expand Down
3 changes: 2 additions & 1 deletion tests/folder-action-regression.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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\.'\) => \{/);
Expand Down
Loading