Skip to content

Commit 43fbaed

Browse files
author
Alexander Phillips
committed
Add Docker runtime privacy toggle
1 parent daf3c63 commit 43fbaed

7 files changed

Lines changed: 258 additions & 3 deletions

File tree

archive/folderview.plus-2026.04.12.05.txz.sha256

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ad55e176682f07eaaf44b33b2e1d71561ca399fe3db3002aed508dff72786c18 folderview.plus-2026.04.15.07.txz

folderview.plus.plg

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@
66
<!ENTITY launch "Settings/FolderViewPlus">
77
<!ENTITY plugdir "/usr/local/emhttp/plugins/&name;">
88
<!ENTITY pluginURL "https://raw.githubusercontent.com/&github;/dev/folderview.plus.plg">
9-
<!ENTITY version "2026.04.15.06">
10-
<!ENTITY md5 "e132c9cd5f7364a247ccf100e3f79f5f">
9+
<!ENTITY version "2026.04.15.07">
10+
<!ENTITY md5 "9e0a0e51ea0e0c8ecbba8d5ef8471aef">
1111
]>
1212

1313
<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">
1414
<CHANGES>
1515

16+
###2026.04.15.07
17+
- Feature: Add a Docker runtime privacy toggle beside the Basic view switch.
18+
- Privacy: Persist Docker blur mode directly from the Docker tab and keep the runtime toggle in sync after host re-renders.
19+
20+
1621
###2026.04.15.06
1722
- Fix: Docker support-bundle snapshots, trace storage caps, and rendered-state diagnostics.
1823
- Fix: Docker runtime rows, folder state, and container interactions.

src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2956,6 +2956,213 @@ const syncDockerVisibleFoldersFromRuntimeCache = () => {
29562956
};
29572957

29582958
const readDockerListViewMode = () => ($.cookie('docker_listview_mode') == 'advanced' ? 'advanced' : 'basic');
2959+
const DOCKER_RUNTIME_PRIVACY_TOGGLE_ID = 'fvplus-docker-runtime-privacy-toggle';
2960+
const DOCKER_RUNTIME_PRIVACY_TOGGLE_SHELL_ID = 'fvplus-docker-runtime-privacy-shell';
2961+
const DOCKER_RUNTIME_PRIVACY_TOGGLE_FALLBACK_HOST_ID = 'fvplus-docker-runtime-toolbar-controls';
2962+
let dockerRuntimePrivacyToggleMountQueued = false;
2963+
2964+
const readDockerRuntimePrivacyMode = () => utils.normalizePrefs(folderTypePrefs || {}).dashboard?.privacyMode === true;
2965+
2966+
const buildDockerRuntimePrivacyPrefsPayload = (enabled) => {
2967+
const current = utils.normalizePrefs(folderTypePrefs || {});
2968+
return utils.normalizePrefs({
2969+
...current,
2970+
dashboard: {
2971+
...(current.dashboard || {}),
2972+
privacyMode: enabled === true
2973+
}
2974+
});
2975+
};
2976+
2977+
const persistDockerRuntimePrivacyMode = async (enabled) => {
2978+
const nextPrefs = buildDockerRuntimePrivacyPrefsPayload(enabled);
2979+
const payload = {
2980+
type: 'docker',
2981+
prefs: JSON.stringify({
2982+
dashboard: nextPrefs.dashboard
2983+
})
2984+
};
2985+
const request = window.FolderViewPlusRequest;
2986+
if (request && typeof request.postJson === 'function') {
2987+
try {
2988+
return await request.postJson('/plugins/folderview.plus/server/prefs.php', payload, {
2989+
retries: 1,
2990+
retryDelayMs: 260
2991+
});
2992+
} catch (_error) {
2993+
// Fall through to the legacy POST path so the runtime toggle still works
2994+
// when the request wrapper is late or degraded.
2995+
}
2996+
}
2997+
const response = await $.post('/plugins/folderview.plus/server/prefs.php', payload).promise();
2998+
return parseJsonPayloadSafe(response);
2999+
};
3000+
3001+
const findDockerRuntimeListViewToggleAnchor = () => {
3002+
const table = document.querySelector('table#docker_containers');
3003+
if (!table) {
3004+
return null;
3005+
}
3006+
const scopes = [
3007+
table.parentElement,
3008+
table.parentElement?.parentElement,
3009+
document.body
3010+
].filter(Boolean);
3011+
const switchSelector = 'input[type="checkbox"], .switch-button, .switch-button-background';
3012+
for (const scope of scopes) {
3013+
const candidates = Array.from(scope.querySelectorAll('label, div, span, td, th'));
3014+
for (const candidate of candidates) {
3015+
const text = String(candidate.textContent || '').replace(/\s+/g, ' ').trim().toLowerCase();
3016+
if (!text.includes('basic view')) {
3017+
continue;
3018+
}
3019+
if (candidate.querySelector(switchSelector)) {
3020+
return candidate;
3021+
}
3022+
if (candidate.parentElement && candidate.parentElement.querySelector(switchSelector)) {
3023+
return candidate.parentElement;
3024+
}
3025+
}
3026+
}
3027+
return null;
3028+
};
3029+
3030+
const ensureDockerRuntimePrivacyFallbackHost = () => {
3031+
const table = document.querySelector('table#docker_containers');
3032+
const mountRoot = table?.parentElement;
3033+
if (!mountRoot) {
3034+
return null;
3035+
}
3036+
let host = document.getElementById(DOCKER_RUNTIME_PRIVACY_TOGGLE_FALLBACK_HOST_ID);
3037+
if (!host) {
3038+
host = document.createElement('div');
3039+
host.id = DOCKER_RUNTIME_PRIVACY_TOGGLE_FALLBACK_HOST_ID;
3040+
host.className = 'fvplus-docker-runtime-toolbar-controls';
3041+
}
3042+
if (host.parentElement !== mountRoot) {
3043+
mountRoot.insertBefore(host, table);
3044+
} else if (host.nextElementSibling !== table) {
3045+
mountRoot.insertBefore(host, table);
3046+
}
3047+
return host;
3048+
};
3049+
3050+
const resolveDockerRuntimePrivacyToggleMount = () => {
3051+
const anchor = findDockerRuntimeListViewToggleAnchor();
3052+
if (anchor && anchor.parentElement) {
3053+
return {
3054+
anchor,
3055+
host: anchor.parentElement,
3056+
fallback: false
3057+
};
3058+
}
3059+
const fallbackHost = ensureDockerRuntimePrivacyFallbackHost();
3060+
if (!fallbackHost) {
3061+
return null;
3062+
}
3063+
return {
3064+
anchor: null,
3065+
host: fallbackHost,
3066+
fallback: true
3067+
};
3068+
};
3069+
3070+
const renderDockerRuntimePrivacyToggle = () => {
3071+
const mount = resolveDockerRuntimePrivacyToggleMount();
3072+
if (!mount?.host) {
3073+
return;
3074+
}
3075+
let shell = document.getElementById(DOCKER_RUNTIME_PRIVACY_TOGGLE_SHELL_ID);
3076+
if (shell && shell.parentElement !== mount.host) {
3077+
shell.remove();
3078+
shell = null;
3079+
}
3080+
if (!shell) {
3081+
shell = document.createElement('div');
3082+
shell.id = DOCKER_RUNTIME_PRIVACY_TOGGLE_SHELL_ID;
3083+
}
3084+
shell.className = `fvplus-docker-runtime-toggle-shell${mount.fallback ? ' is-fallback' : ''}`;
3085+
if (mount.anchor) {
3086+
if (mount.anchor.nextElementSibling !== shell) {
3087+
mount.anchor.insertAdjacentElement('afterend', shell);
3088+
}
3089+
} else if (mount.host.firstElementChild !== shell) {
3090+
mount.host.insertBefore(shell, mount.host.firstChild);
3091+
}
3092+
const enabled = readDockerRuntimePrivacyMode();
3093+
shell.innerHTML = `
3094+
<span class="fvplus-docker-runtime-toggle-label">Privacy</span>
3095+
<input id="${DOCKER_RUNTIME_PRIVACY_TOGGLE_ID}" class="basic-switch fvplus-docker-runtime-privacy-switch" type="checkbox" ${enabled ? 'checked' : ''}>
3096+
`;
3097+
const $input = $(`#${DOCKER_RUNTIME_PRIVACY_TOGGLE_ID}`);
3098+
if (!$input.length) {
3099+
return;
3100+
}
3101+
if (typeof $input.switchButton === 'function') {
3102+
$input.switchButton({
3103+
labels_placement: 'right',
3104+
off_label: $.i18n('off'),
3105+
on_label: $.i18n('on'),
3106+
checked: enabled
3107+
});
3108+
}
3109+
$input.off('change.fvDockerRuntimePrivacy').on('change.fvDockerRuntimePrivacy', function onDockerRuntimePrivacyChange() {
3110+
void setDockerRuntimePrivacyMode($(this).is(':checked'));
3111+
});
3112+
};
3113+
3114+
const queueDockerRuntimePrivacyToggleMount = () => {
3115+
if (dockerRuntimePrivacyToggleMountQueued) {
3116+
return;
3117+
}
3118+
dockerRuntimePrivacyToggleMountQueued = true;
3119+
const flush = () => {
3120+
dockerRuntimePrivacyToggleMountQueued = false;
3121+
renderDockerRuntimePrivacyToggle();
3122+
};
3123+
if (typeof window?.requestAnimationFrame === 'function') {
3124+
window.requestAnimationFrame(flush);
3125+
return;
3126+
}
3127+
setTimeout(flush, 0);
3128+
};
3129+
3130+
const setDockerRuntimePrivacyMode = async (enabled, options = {}) => {
3131+
const nextEnabled = enabled === true;
3132+
const previousPrefs = utils.normalizePrefs(folderTypePrefs || {});
3133+
const previousEnabled = previousPrefs.dashboard?.privacyMode === true;
3134+
if (previousEnabled === nextEnabled) {
3135+
queueDockerRuntimePrivacyToggleMount();
3136+
return;
3137+
}
3138+
folderTypePrefs = buildDockerRuntimePrivacyPrefsPayload(nextEnabled);
3139+
applyRuntimePrefs(folderTypePrefs);
3140+
queueDockerRuntimePrivacyToggleMount();
3141+
if (options.persist === false) {
3142+
return;
3143+
}
3144+
const result = await dockerSafeUiActionRunner.run('docker-runtime-privacy-toggle', async () => {
3145+
const response = await persistDockerRuntimePrivacyMode(nextEnabled);
3146+
folderTypePrefs = utils.normalizePrefs(response?.prefs || folderTypePrefs);
3147+
applyRuntimePrefs(folderTypePrefs);
3148+
queueDockerRuntimePrivacyToggleMount();
3149+
return response;
3150+
}, {
3151+
userVisible: false
3152+
});
3153+
if (!result.ok) {
3154+
folderTypePrefs = previousPrefs;
3155+
applyRuntimePrefs(folderTypePrefs);
3156+
queueDockerRuntimePrivacyToggleMount();
3157+
swal({
3158+
title: 'Privacy toggle save failed',
3159+
text: `${escapeHtml(String(result.error?.message || 'FolderView Plus could not save the Docker privacy toggle.'))}<br>Reverted to the previous state.`,
3160+
type: 'error',
3161+
html: true,
3162+
confirmButtonText: 'OK'
3163+
});
3164+
}
3165+
};
29593166

29603167
const emitDockerListViewModeChange = (mode, source = 'cookie-write') => {
29613168
if (typeof window?.dispatchEvent !== 'function' || typeof window?.CustomEvent !== 'function') {
@@ -3011,10 +3218,12 @@ const syncDockerListViewModeFromCookie = (source = 'passive') => {
30113218
source: String(source || 'passive').trim() || 'passive'
30123219
});
30133220
if (!loadedFolder || !globalFolders || Object.keys(globalFolders).length <= 0) {
3221+
queueDockerRuntimePrivacyToggleMount();
30143222
return;
30153223
}
30163224
syncDockerVisibleFoldersFromRuntimeCache();
30173225
scheduleDockerRuntimeWidthReflow('listview-mode-change', 12);
3226+
queueDockerRuntimePrivacyToggleMount();
30183227
};
30193228

30203229
const startDockerListViewModeObserver = () => {
@@ -5304,6 +5513,7 @@ window.listview = () => {
53045513
} else {
53055514
if (FOLDER_VIEW_DEBUG_MODE) console.log('[FV3_DEBUG] Patched listview: loadedFolder is true. Skipped createFolders.');
53065515
}
5516+
queueDockerRuntimePrivacyToggleMount();
53075517
queueDockerRuntimeResizerBind();
53085518
if (FOLDER_VIEW_DEBUG_MODE) console.log('[FV3_DEBUG] Patched listview: Exit.');
53095519
};
@@ -5805,6 +6015,7 @@ const applyRuntimePrefs = (prefs) => {
58056015
$('body').toggleClass('fvplus-performance-mode', normalized.performanceMode === true);
58066016
$('body').toggleClass('fvplus-performance-mode-strict', dockerRuntimePerformanceProfile?.strict === true);
58076017
$('body').toggleClass('fvplus-privacy-docker-runtime', normalized?.dashboard?.privacyMode === true);
6018+
queueDockerRuntimePrivacyToggleMount();
58086019
scheduleLiveRefresh(normalized);
58096020
};
58106021

@@ -5880,6 +6091,7 @@ bindDockerHostOpenDockerPatch();
58806091
bindDockerUpdateActionClickCapture();
58816092
bindDockerPostUpdateRenderReconcile();
58826093
startDockerListViewModeObserver();
6094+
queueDockerRuntimePrivacyToggleMount();
58836095

58846096
if (FOLDER_VIEW_DEBUG_MODE) {
58856097
console.log('[FV3_DEBUG] Global variables initialized:', {

src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/docker.css

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,36 @@ th.fvplus-runtime-resizable .fvplus-runtime-col-resizer:focus-visible::before {
146146
white-space: nowrap;
147147
}
148148

149+
.fvplus-docker-runtime-toolbar-controls {
150+
display: flex;
151+
justify-content: flex-end;
152+
align-items: center;
153+
gap: 0.7rem;
154+
margin: 0 0 0.45rem 0;
155+
}
156+
157+
.fvplus-docker-runtime-toggle-shell {
158+
display: inline-flex;
159+
align-items: center;
160+
gap: 0.55rem;
161+
margin-left: 0.7rem;
162+
white-space: nowrap;
163+
vertical-align: middle;
164+
}
165+
166+
.fvplus-docker-runtime-toggle-shell.is-fallback {
167+
margin-left: 0;
168+
}
169+
170+
.fvplus-docker-runtime-toggle-label {
171+
font-size: 0.92rem;
172+
font-weight: 700;
173+
letter-spacing: 0.08em;
174+
text-transform: uppercase;
175+
color: var(--fvplus-theme-foreground);
176+
opacity: 0.88;
177+
}
178+
149179
/* --- General Folder Styles --- */
150180
.hover:hover div.folder-preview div:not(.folder-preview-row):not(.folder-preview-divider) {
151181
visibility: visible;

tests/privacy-mode-contract.test.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ test('settings page exposes docker and vm privacy mode dashboard toggles', () =>
4040

4141
test('privacy mode toggles runtime body classes and ships masking selectors across settings runtime and dashboard surfaces', () => {
4242
assert.match(dockerJs, /toggleClass\('fvplus-privacy-docker-runtime', normalized\?\.dashboard\?\.privacyMode === true\)/);
43+
assert.match(dockerJs, /const DOCKER_RUNTIME_PRIVACY_TOGGLE_ID = 'fvplus-docker-runtime-privacy-toggle';/);
44+
assert.match(dockerJs, /const renderDockerRuntimePrivacyToggle = \(\) =>/);
45+
assert.match(dockerJs, /const findDockerRuntimeListViewToggleAnchor = \(\) =>/);
46+
assert.match(dockerJs, /const setDockerRuntimePrivacyMode = async \(enabled, options = \{\}\) =>/);
47+
assert.match(dockerJs, /queueDockerRuntimePrivacyToggleMount\(\);/);
4348
assert.match(vmJs, /toggleClass\('fvplus-privacy-vm-runtime', normalized\?\.dashboard\?\.privacyMode === true\)/);
4449
assert.match(dashboardJs, /toggleClass\('fvplus-privacy-docker-dashboard', dockerPrefs\?\.dashboard\?\.privacyMode === true\)/);
4550
assert.match(dashboardJs, /toggleClass\('fvplus-privacy-vm-dashboard', vmPrefs\?\.dashboard\?\.privacyMode === true\)/);
@@ -49,6 +54,9 @@ test('privacy mode toggles runtime body classes and ships masking selectors acro
4954
assert.match(settingsCss, /#vm-tree-path-hint/);
5055
assert.match(settingsCss, /\.bulk-item-name/);
5156
assert.match(dockerCss, /body\.fvplus-privacy-docker-runtime/);
57+
assert.match(dockerCss, /\.fvplus-docker-runtime-toggle-shell/);
58+
assert.match(dockerCss, /\.fvplus-docker-runtime-toggle-label/);
59+
assert.match(dockerCss, /\.fvplus-docker-runtime-toolbar-controls/);
5260
assert.match(dockerCss, /\.fv-docker-member-menu-name/);
5361
assert.match(vmCss, /body\.fvplus-privacy-vm-runtime/);
5462
assert.match(dashboardCss, /body\.fvplus-privacy-docker-dashboard/);

0 commit comments

Comments
 (0)