Skip to content

Commit b6744d1

Browse files
Fix Docker branch submenu hover and add branch cloning
1 parent 11b7591 commit b6744d1

8 files changed

Lines changed: 204 additions & 21 deletions

File tree

archive/folderview.plus-2026.03.27.13.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+
0efcac5ac82175505d63252adce2c3b607913b246e91f8cc1f637234f1dd74d3 folderview.plus-2026.03.29.10.txz

folderview.plus.plg

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@
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.03.29.09">
10-
<!ENTITY md5 "a16e5b9afe377656116605ea9a3c286d">
9+
<!ENTITY version "2026.03.29.10">
10+
<!ENTITY md5 "c444ec2ee928781929076fd070f76b70">
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.03.29.10
17+
- Fix: Restore Docker branch hover submenus by allowing runtime context menus to keep submenu overflow visible.
18+
- Feature: Add a Docker `Clone` submenu with `Clone folder` plus `Clone branch`, including nested branch cloning with preserved parent-child structure.
19+
1620
###2026.03.29.09
1721
- Fix: Restore modern folder editor module loading after reinstall by replacing short-tag asset includes in Folder.page with explicit PHP autov tags.
1822
- Quality: Add a regression guard to keep Folder.page shared editor assets on explicit PHP include tags instead of short tags.

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

Lines changed: 186 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4751,28 +4751,180 @@ const cloneDockerFolderFromMenu = async (id) => {
47514751
if (!nextName) {
47524752
return;
47534753
}
4754-
const clonePayload = {
4755-
name: nextName,
4756-
icon: String(source?.icon || ''),
4757-
parentId: normalizeFolderParentId(source?.parentId || source?.parent_id || ''),
4758-
settings: JSON.parse(JSON.stringify((source?.settings && typeof source.settings === 'object') ? source.settings : {})),
4759-
regex: String(source?.regex || ''),
4760-
containers: Array.isArray(source?.containers) ? [...source.containers] : [],
4761-
actions: Array.isArray(source?.actions) ? JSON.parse(JSON.stringify(source.actions)) : []
4762-
};
4754+
const clonePayload = buildDockerFolderClonePayload(source, { name: nextName });
47634755
$('div.spinner.fixed').show('slow');
47644756
try {
4765-
await $.post('/plugins/folderview.plus/server/create.php', {
4757+
await persistDockerFolderClonePayload(clonePayload);
4758+
await $.post('/plugins/folderview.plus/server/sync_order.php', { type: 'docker' }).promise();
4759+
loadlist();
4760+
} finally {
4761+
$('div.spinner.fixed').hide('slow');
4762+
}
4763+
}, {
4764+
userMessage: getDockerMenuLabel('clone-folder-failed', 'Failed to clone folder.'),
4765+
userVisible: true
4766+
});
4767+
};
4768+
4769+
const buildDockerFolderClonePayload = (source, overrides = {}) => {
4770+
const sourceName = String(source?.name || '').trim() || 'Folder';
4771+
const sourceParentId = normalizeFolderParentId(source?.parentId || source?.parent_id || '');
4772+
const overrideName = overrides && Object.prototype.hasOwnProperty.call(overrides, 'name')
4773+
? overrides.name
4774+
: undefined;
4775+
const overrideParentId = overrides && Object.prototype.hasOwnProperty.call(overrides, 'parentId')
4776+
? overrides.parentId
4777+
: undefined;
4778+
const resolvedName = String(overrideName ?? sourceName).trim() || 'Folder';
4779+
const resolvedParentId = normalizeFolderParentId(overrideParentId ?? sourceParentId);
4780+
return {
4781+
name: resolvedName,
4782+
icon: String(source?.icon || ''),
4783+
parentId: resolvedParentId,
4784+
settings: JSON.parse(JSON.stringify((source?.settings && typeof source.settings === 'object') ? source.settings : {})),
4785+
regex: String(source?.regex || ''),
4786+
containers: Array.isArray(source?.containers) ? [...source.containers] : [],
4787+
actions: Array.isArray(source?.actions) ? JSON.parse(JSON.stringify(source.actions)) : []
4788+
};
4789+
};
4790+
4791+
const generateDockerFolderCloneId = (reservedIds = new Set()) => {
4792+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
4793+
const reserved = reservedIds instanceof Set ? reservedIds : new Set();
4794+
const cryptoObject = window.crypto || window.msCrypto || null;
4795+
for (let attempt = 0; attempt < 16; attempt += 1) {
4796+
let nextId = '';
4797+
if (cryptoObject && typeof cryptoObject.getRandomValues === 'function') {
4798+
const bytes = new Uint8Array(20);
4799+
cryptoObject.getRandomValues(bytes);
4800+
nextId = Array.from(bytes, (value) => alphabet.charAt(value % alphabet.length)).join('');
4801+
} else {
4802+
nextId = Array.from({ length: 20 }, () => alphabet.charAt(Math.floor(Math.random() * alphabet.length))).join('');
4803+
}
4804+
if (!reserved.has(nextId) && !Object.prototype.hasOwnProperty.call(globalFolders, nextId)) {
4805+
return nextId;
4806+
}
4807+
}
4808+
return `fvclone${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`.slice(0, 20);
4809+
};
4810+
4811+
const getDockerFolderBranchCloneOrder = (rootId) => {
4812+
const orderedIds = [];
4813+
const seen = new Set();
4814+
const visit = (folderId) => {
4815+
const safeFolderId = String(folderId || '').trim();
4816+
if (!safeFolderId || seen.has(safeFolderId) || !globalFolders[safeFolderId]) {
4817+
return;
4818+
}
4819+
seen.add(safeFolderId);
4820+
orderedIds.push(safeFolderId);
4821+
getFolderChildren(safeFolderId).forEach(visit);
4822+
};
4823+
visit(rootId);
4824+
return orderedIds;
4825+
};
4826+
4827+
const persistDockerFolderClonePayload = async (payload, folderId = '') => {
4828+
const safeFolderId = String(folderId || '').trim();
4829+
const request = {
4830+
type: 'docker',
4831+
content: JSON.stringify(payload)
4832+
};
4833+
if (safeFolderId) {
4834+
request.id = safeFolderId;
4835+
}
4836+
await $.post(
4837+
safeFolderId
4838+
? '/plugins/folderview.plus/server/update.php'
4839+
: '/plugins/folderview.plus/server/create.php',
4840+
request
4841+
).promise();
4842+
};
4843+
4844+
const rollbackClonedDockerFolders = async (createdIds = []) => {
4845+
const ids = Array.isArray(createdIds) ? createdIds.filter((entry) => String(entry || '').trim() !== '') : [];
4846+
for (const createdId of ids.slice().reverse()) {
4847+
try {
4848+
await $.post('/plugins/folderview.plus/server/delete.php', {
47664849
type: 'docker',
4767-
content: JSON.stringify(clonePayload)
4850+
id: createdId
47684851
}).promise();
4852+
} catch (_error) {
4853+
// Best-effort rollback only.
4854+
}
4855+
}
4856+
if (ids.length > 0) {
4857+
try {
4858+
await $.post('/plugins/folderview.plus/server/sync_order.php', { type: 'docker' }).promise();
4859+
} catch (_error) {
4860+
// Best-effort rollback only.
4861+
}
4862+
}
4863+
};
4864+
4865+
const cloneDockerFolderBranchFromMenu = async (id) => {
4866+
await runDockerGuardedAction('clone-branch', async () => {
4867+
if (!ensureDockerFolderUnlocked(id, 'Clone branch')) {
4868+
return;
4869+
}
4870+
const source = globalFolders[id];
4871+
if (!source || typeof source !== 'object') {
4872+
return;
4873+
}
4874+
const branchIds = getDockerFolderBranchCloneOrder(id);
4875+
if (branchIds.length <= 1) {
4876+
await cloneDockerFolderFromMenu(id);
4877+
return;
4878+
}
4879+
const defaultName = `${String(source?.name || 'Folder').trim() || 'Folder'} (Copy)`;
4880+
const nextName = String(window.prompt('Clone branch root name', defaultName) || '').trim();
4881+
if (!nextName) {
4882+
return;
4883+
}
4884+
const sourceParentId = normalizeFolderParentId(source?.parentId || source?.parent_id || '');
4885+
const reservedIds = new Set(Object.keys(globalFolders));
4886+
const cloneIdMap = new Map();
4887+
branchIds.forEach((sourceId) => {
4888+
const cloneId = generateDockerFolderCloneId(reservedIds);
4889+
reservedIds.add(cloneId);
4890+
cloneIdMap.set(sourceId, cloneId);
4891+
});
4892+
const createdIds = [];
4893+
$('div.spinner.fixed').show('slow');
4894+
try {
4895+
for (const sourceId of branchIds) {
4896+
const sourceFolder = globalFolders[sourceId];
4897+
if (!sourceFolder || typeof sourceFolder !== 'object') {
4898+
continue;
4899+
}
4900+
const rawParentId = normalizeFolderParentId(sourceFolder?.parentId || sourceFolder?.parent_id || '');
4901+
const clonedParentId = sourceId === id
4902+
? sourceParentId
4903+
: String(cloneIdMap.get(rawParentId) || '').trim();
4904+
if (sourceId !== id && !clonedParentId) {
4905+
throw new Error(`Clone branch failed because parent mapping was missing for nested folder "${sourceFolder?.name || sourceId}".`);
4906+
}
4907+
const clonePayload = buildDockerFolderClonePayload(sourceFolder, {
4908+
name: sourceId === id ? nextName : String(sourceFolder?.name || '').trim() || 'Folder',
4909+
parentId: clonedParentId
4910+
});
4911+
const cloneId = String(cloneIdMap.get(sourceId) || '').trim();
4912+
if (!cloneId) {
4913+
throw new Error(`Clone branch failed because a clone id was not generated for folder "${sourceFolder?.name || sourceId}".`);
4914+
}
4915+
await persistDockerFolderClonePayload(clonePayload, cloneId);
4916+
createdIds.push(cloneId);
4917+
}
47694918
await $.post('/plugins/folderview.plus/server/sync_order.php', { type: 'docker' }).promise();
47704919
loadlist();
4920+
} catch (error) {
4921+
await rollbackClonedDockerFolders(createdIds);
4922+
throw error;
47714923
} finally {
47724924
$('div.spinner.fixed').hide('slow');
47734925
}
47744926
}, {
4775-
userMessage: getDockerMenuLabel('clone-folder-failed', 'Failed to clone folder.'),
4927+
userMessage: getDockerMenuLabel('clone-branch-failed', 'Failed to clone branch.'),
47764928
userVisible: true
47774929
});
47784930
};
@@ -5360,13 +5512,30 @@ const addDockerFolderContext = (id) => {
53605512
action: (evt) => { evt.preventDefault(); editFolder(id); }
53615513
});
53625514

5515+
const cloneSubMenu = [
5516+
{
5517+
text: getDockerMenuLabel('clone-folder', 'Clone folder'),
5518+
icon: 'fa-clone',
5519+
action: (evt) => {
5520+
evt.preventDefault();
5521+
cloneDockerFolderFromMenu(id);
5522+
}
5523+
}
5524+
];
5525+
if (hasChildren) {
5526+
cloneSubMenu.push({
5527+
text: getDockerMenuLabel('clone-branch', 'Clone branch'),
5528+
icon: 'fa-sitemap',
5529+
action: (evt) => {
5530+
evt.preventDefault();
5531+
cloneDockerFolderBranchFromMenu(id);
5532+
}
5533+
});
5534+
}
53635535
opts.push({
5364-
text: getDockerMenuLabel('clone-folder', 'Clone folder'),
5536+
text: getDockerMenuLabel('clone-menu', 'Clone'),
53655537
icon: 'fa-clone',
5366-
action: (evt) => {
5367-
evt.preventDefault();
5368-
cloneDockerFolderFromMenu(id);
5369-
}
5538+
subMenu: cloneSubMenu
53705539
});
53715540

53725541
opts.push({

src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/runtime.shared.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ body ul.dropdown-menu {
3232
border-radius: 8px !important;
3333
box-shadow: var(--fvplus-runtime-menu-shadow) !important;
3434
padding: 6px 0 !important;
35-
overflow: hidden !important;
35+
overflow: visible !important;
3636
backdrop-filter: blur(12px);
3737
}
3838

tests/docker-folder-context-feature-actions.test.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,16 @@ test('docker folder context supports open-all-webui actions with scoped options'
2727

2828
test('docker folder context supports clone-folder action flow', () => {
2929
assert.match(dockerScript, /const cloneDockerFolderFromMenu = async \(id\) =>/);
30+
assert.match(dockerScript, /const cloneDockerFolderBranchFromMenu = async \(id\) =>/);
31+
assert.match(dockerScript, /const getDockerFolderBranchCloneOrder = \(rootId\) =>/);
32+
assert.match(dockerScript, /const generateDockerFolderCloneId = \(reservedIds = new Set\(\)\) =>/);
33+
assert.match(dockerScript, /const rollbackClonedDockerFolders = async \(createdIds = \[\]\) =>/);
3034
assert.match(dockerScript, /window\.prompt\('Clone folder name'/);
35+
assert.match(dockerScript, /window\.prompt\('Clone branch root name'/);
3136
assert.match(dockerScript, /\/server\/create\.php/);
37+
assert.match(dockerScript, /\/server\/update\.php/);
3238
assert.match(dockerScript, /text:\s*getDockerMenuLabel\('clone-folder',\s*'Clone folder'\)/);
39+
assert.match(dockerScript, /text:\s*getDockerMenuLabel\('clone-menu',\s*'Clone'\)/);
40+
assert.match(dockerScript, /text:\s*getDockerMenuLabel\('clone-branch',\s*'Clone branch'\)/);
41+
assert.match(dockerScript, /subMenu:\s*cloneSubMenu/);
3342
});

tests/runtime-theme-token-guard.test.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ test('runtime context menus follow resolved dark and light theme tokens', () =>
6161
assert.match(runtimeSharedCss, /body ul\.context-menu-list,[\s\S]*body ul\.dropdown-menu \{/);
6262
assert.match(runtimeSharedCss, /background:\s*var\(--fvplus-runtime-menu-bg\) !important;/);
6363
assert.match(runtimeSharedCss, /border:\s*1px solid var\(--fvplus-runtime-menu-border\) !important;/);
64+
assert.match(runtimeSharedCss, /overflow:\s*visible !important;/);
6465
assert.match(runtimeSharedCss, /body ul\.context-menu-list > li\.divider,[\s\S]*context-menu-separator/);
6566
assert.match(runtimeSharedCss, /body ul\.context-menu-list \.dropdown-header,[\s\S]*var\(--fvplus-runtime-menu-header-bg\)/);
6667
assert.match(dashboardPage, /runtime\.shared\.css/);

0 commit comments

Comments
 (0)