Skip to content

Commit 16543cc

Browse files
author
FolderView Plus Test
committed
Fix VM detail row adoption in folders
1 parent 09250ec commit 16543cc

7 files changed

Lines changed: 307 additions & 4 deletions

File tree

archive/folderview.plus-2026.04.15.20.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+
6f68f2ca1b0f9e75bda1bb7b26a1af794db3d09f4b363246a0f820ad89c67140 folderview.plus-2026.04.17.03.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.17.02">
10-
<!ENTITY md5 "77edaa4af1469bc0165406b021449844">
9+
<!ENTITY version "2026.04.17.03">
10+
<!ENTITY md5 "64ebf4e8677193a1f32176c4b860b78d">
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.17.03
17+
- Fix: VM detail rows now stay attached to the VM you clicked inside folders instead of appearing under the previously expanded VM.
18+
- Fix: Existing expanded VM detail rows move with their VM during folder renders, and later host-inserted detail rows are adopted back under the correct VM owner.
19+
20+
1621
###2026.04.17.02
1722
- Fix: Folder editor Rules tab now opens Settings directly into Advanced > Rules > Auto-assignment instead of restoring the last basic tab.
1823
- UX: Settings bootstrap now honors explicit launch overrides for advanced tab, target section, and Docker or VM rules workspace source.

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

Lines changed: 286 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1109,6 +1109,274 @@ const hideVmRuntimeLoadingRow = () => {
11091109
$('tbody#kvm_list tr.fv-runtime-loading-row').remove();
11101110
};
11111111

1112+
const VM_NATIVE_DETAIL_ROW_SELECTOR = 'tr[id^="name-"]:not([child-id])';
1113+
const VM_NATIVE_TOGGLE_SELECTOR = 'a[onclick*="toggle_id("]';
1114+
const VM_NATIVE_DETAIL_REQUEST_WINDOW_MS = 1600;
1115+
let vmNativeDetailRowObserver = null;
1116+
let vmNativeDetailRowObserverHost = null;
1117+
let vmNativeToggleClickHost = null;
1118+
let vmNativeDetailAdoptionSuspendDepth = 0;
1119+
let vmZebraRefreshTimer = null;
1120+
let vmLastNativeDetailRequest = {
1121+
detailId: '',
1122+
row: null,
1123+
requestedAt: 0
1124+
};
1125+
1126+
const applyVmZebra = () => {
1127+
let visibleIndex = 0;
1128+
$('#kvm_table tbody tr').each(function applyVmZebraRow() {
1129+
const $row = $(this);
1130+
if (!$row.is(':visible')) {
1131+
return;
1132+
}
1133+
if ($row.hasClass('fv-runtime-loading-row')) {
1134+
this.style.backgroundColor = '';
1135+
return;
1136+
}
1137+
this.style.backgroundColor = (visibleIndex % 2 === 1)
1138+
? 'var(--fvplus-vm-row-alt-bg, var(--dynamix-tablesorter-tbody-row-alt-bg-color, transparent))'
1139+
: 'var(--fvplus-vm-row-bg, transparent)';
1140+
visibleIndex += 1;
1141+
});
1142+
};
1143+
1144+
const scheduleVmZebraRefresh = (delayMs = 32) => {
1145+
if (vmZebraRefreshTimer !== null) {
1146+
window.clearTimeout(vmZebraRefreshTimer);
1147+
}
1148+
vmZebraRefreshTimer = window.setTimeout(() => {
1149+
vmZebraRefreshTimer = null;
1150+
applyVmZebra();
1151+
}, Math.max(0, Number(delayMs) || 0));
1152+
};
1153+
1154+
const isVmNativeDetailRow = (row) => (
1155+
row instanceof HTMLTableRowElement
1156+
&& String(row.id || '').startsWith('name-')
1157+
&& !row.hasAttribute('child-id')
1158+
);
1159+
1160+
const getVmNativeDetailRowId = (row) => (
1161+
isVmNativeDetailRow(row)
1162+
? String(row.id || '').trim()
1163+
: ''
1164+
);
1165+
1166+
const extractVmNativeToggleDetailId = (value) => {
1167+
const match = String(value || '').match(/toggle_id\((['"])(name-[^'")]+)\1\)/);
1168+
return match ? String(match[2] || '').trim() : '';
1169+
};
1170+
1171+
const clearVmFolderElementOwnership = (row) => {
1172+
if (!(row instanceof Element)) {
1173+
return;
1174+
}
1175+
Array.from(row.classList)
1176+
.filter((token) => /^folder-.+-element$/.test(token))
1177+
.forEach((token) => row.classList.remove(token));
1178+
row.classList.remove('folder-element');
1179+
};
1180+
1181+
const applyVmFolderElementOwnership = (row, folderId) => {
1182+
if (!(row instanceof Element)) {
1183+
return;
1184+
}
1185+
clearVmFolderElementOwnership(row);
1186+
const safeFolderId = String(folderId || '').trim();
1187+
if (!safeFolderId) {
1188+
return;
1189+
}
1190+
row.classList.add(`folder-${safeFolderId}-element`, 'folder-element');
1191+
};
1192+
1193+
const findVmFolderOwnerIdForRow = (row) => {
1194+
if (!(row instanceof Element)) {
1195+
return '';
1196+
}
1197+
const token = Array.from(row.classList).find((value) => /^folder-.+-element$/.test(value));
1198+
if (!token) {
1199+
return '';
1200+
}
1201+
const match = token.match(/^folder-(.+)-element$/);
1202+
return match ? String(match[1] || '').trim() : '';
1203+
};
1204+
1205+
const isVmFolderExpanded = (folderId) => (
1206+
$(`.dropDown-${String(folderId || '').trim()}`).attr('active') === 'true'
1207+
);
1208+
1209+
const findVmNativeToggleAnchorForDetailId = (detailId) => {
1210+
const targetDetailId = String(detailId || '').trim();
1211+
if (!targetDetailId) {
1212+
return null;
1213+
}
1214+
const anchors = Array.from(document.querySelectorAll(VM_NATIVE_TOGGLE_SELECTOR));
1215+
return anchors.find((anchor) => extractVmNativeToggleDetailId(anchor.getAttribute('onclick')) === targetDetailId) || null;
1216+
};
1217+
1218+
const findVmRuntimeRowForDetailId = (detailId) => {
1219+
const targetDetailId = String(detailId || '').trim();
1220+
if (!targetDetailId) {
1221+
return null;
1222+
}
1223+
const directAnchor = findVmNativeToggleAnchorForDetailId(targetDetailId);
1224+
const directRow = directAnchor instanceof Element ? directAnchor.closest('tr') : null;
1225+
if (directRow instanceof HTMLTableRowElement) {
1226+
return directRow;
1227+
}
1228+
const requestedAt = Number(vmLastNativeDetailRequest.requestedAt || 0);
1229+
const requestedRecently = requestedAt > 0 && (Date.now() - requestedAt) <= VM_NATIVE_DETAIL_REQUEST_WINDOW_MS;
1230+
if (
1231+
requestedRecently
1232+
&& vmLastNativeDetailRequest.detailId === targetDetailId
1233+
&& vmLastNativeDetailRequest.row instanceof HTMLTableRowElement
1234+
&& vmLastNativeDetailRequest.row.isConnected
1235+
) {
1236+
return vmLastNativeDetailRequest.row;
1237+
}
1238+
return null;
1239+
};
1240+
1241+
const collectExistingVmDetailRowsForVmRow = (vmRow) => {
1242+
if (!(vmRow instanceof HTMLTableRowElement)) {
1243+
return [];
1244+
}
1245+
const detailId = extractVmNativeToggleDetailId(vmRow.querySelector(VM_NATIVE_TOGGLE_SELECTOR)?.getAttribute('onclick'));
1246+
if (!detailId) {
1247+
return [];
1248+
}
1249+
const detailRows = [];
1250+
let sibling = vmRow.nextElementSibling;
1251+
while (sibling instanceof HTMLTableRowElement && isVmNativeDetailRow(sibling)) {
1252+
const siblingDetailId = getVmNativeDetailRowId(sibling);
1253+
if (siblingDetailId !== detailId) {
1254+
break;
1255+
}
1256+
detailRows.push(sibling);
1257+
sibling = sibling.nextElementSibling;
1258+
}
1259+
return detailRows;
1260+
};
1261+
1262+
const withVmNativeDetailAdoptionSuspended = (callback) => {
1263+
vmNativeDetailAdoptionSuspendDepth += 1;
1264+
try {
1265+
return callback();
1266+
} finally {
1267+
vmNativeDetailAdoptionSuspendDepth = Math.max(0, vmNativeDetailAdoptionSuspendDepth - 1);
1268+
}
1269+
};
1270+
1271+
const placeVmNativeDetailRowForOwner = (detailRow, vmRow) => {
1272+
if (!isVmNativeDetailRow(detailRow) || !(vmRow instanceof HTMLTableRowElement)) {
1273+
return false;
1274+
}
1275+
const folderId = findVmFolderOwnerIdForRow(vmRow);
1276+
return withVmNativeDetailAdoptionSuspended(() => {
1277+
if (folderId) {
1278+
applyVmFolderElementOwnership(detailRow, folderId);
1279+
detailRow.dataset.fvplusVmDetailAdopted = '1';
1280+
if (isVmFolderExpanded(folderId)) {
1281+
vmRow.after(detailRow);
1282+
} else {
1283+
const storage = document.querySelector(`tr.folder-id-${folderId} .folder-storage`);
1284+
if (storage instanceof Element) {
1285+
storage.appendChild(detailRow);
1286+
} else {
1287+
vmRow.after(detailRow);
1288+
}
1289+
}
1290+
return true;
1291+
}
1292+
clearVmFolderElementOwnership(detailRow);
1293+
detailRow.dataset.fvplusVmDetailAdopted = '1';
1294+
vmRow.after(detailRow);
1295+
return true;
1296+
});
1297+
};
1298+
1299+
const adoptVmNativeDetailRows = (rows = []) => {
1300+
let adoptedCount = 0;
1301+
rows.forEach((row) => {
1302+
if (!isVmNativeDetailRow(row)) {
1303+
return;
1304+
}
1305+
const detailId = getVmNativeDetailRowId(row);
1306+
const vmRow = findVmRuntimeRowForDetailId(detailId);
1307+
if (!(vmRow instanceof HTMLTableRowElement)) {
1308+
return;
1309+
}
1310+
if (placeVmNativeDetailRowForOwner(row, vmRow)) {
1311+
adoptedCount += 1;
1312+
}
1313+
});
1314+
if (adoptedCount > 0) {
1315+
scheduleVmZebraRefresh();
1316+
}
1317+
};
1318+
1319+
const ensureVmNativeDetailRowObserver = () => {
1320+
const tbody = document.querySelector('tbody#kvm_list');
1321+
if (!(tbody instanceof HTMLTableSectionElement)) {
1322+
return;
1323+
}
1324+
if (vmNativeDetailRowObserver && vmNativeDetailRowObserverHost === tbody) {
1325+
return;
1326+
}
1327+
if (vmNativeDetailRowObserver) {
1328+
vmNativeDetailRowObserver.disconnect();
1329+
vmNativeDetailRowObserver = null;
1330+
vmNativeDetailRowObserverHost = null;
1331+
}
1332+
vmNativeDetailRowObserverHost = tbody;
1333+
vmNativeDetailRowObserver = new MutationObserver((mutations) => {
1334+
if (vmNativeDetailAdoptionSuspendDepth > 0) {
1335+
return;
1336+
}
1337+
const detailRows = [];
1338+
mutations.forEach((mutation) => {
1339+
mutation.addedNodes.forEach((node) => {
1340+
if (isVmNativeDetailRow(node)) {
1341+
detailRows.push(node);
1342+
}
1343+
});
1344+
});
1345+
if (detailRows.length > 0) {
1346+
adoptVmNativeDetailRows(detailRows);
1347+
}
1348+
});
1349+
vmNativeDetailRowObserver.observe(tbody, { childList: true });
1350+
};
1351+
1352+
const ensureVmNativeDetailInteractionHooks = () => {
1353+
const table = document.getElementById('kvm_table');
1354+
if (!(table instanceof HTMLTableElement) || vmNativeToggleClickHost === table) {
1355+
return;
1356+
}
1357+
if (vmNativeToggleClickHost instanceof HTMLTableElement) {
1358+
vmNativeToggleClickHost.removeEventListener('click', handleVmNativeToggleClick, true);
1359+
}
1360+
vmNativeToggleClickHost = table;
1361+
table.addEventListener('click', handleVmNativeToggleClick, true);
1362+
};
1363+
1364+
function handleVmNativeToggleClick(event) {
1365+
const target = event.target instanceof Element ? event.target : null;
1366+
const anchor = target ? target.closest(VM_NATIVE_TOGGLE_SELECTOR) : null;
1367+
if (!(anchor instanceof Element)) {
1368+
return;
1369+
}
1370+
const detailId = extractVmNativeToggleDetailId(anchor.getAttribute('onclick'));
1371+
const vmRow = anchor.closest('tr');
1372+
vmLastNativeDetailRequest = {
1373+
detailId,
1374+
row: vmRow instanceof HTMLTableRowElement ? vmRow : null,
1375+
requestedAt: Date.now()
1376+
};
1377+
scheduleVmZebraRefresh(420);
1378+
}
1379+
11121380
let createFoldersInFlight = false;
11131381
let createFoldersQueued = false;
11141382

@@ -1283,12 +1551,16 @@ const createFolders = async () => {
12831551

12841552
// Assing the folder done to the global object
12851553
globalFolders = foldersDone;
1554+
ensureVmNativeDetailInteractionHooks();
1555+
ensureVmNativeDetailRowObserver();
1556+
adoptVmNativeDetailRows(Array.from(document.querySelectorAll(`tbody#kvm_list > ${VM_NATIVE_DETAIL_ROW_SELECTOR}`)));
12861557
refreshVmFolderQuickActionStates();
12871558
applyVmFocusedFolderState();
12881559
syncVmRuntimeExpandedStore();
12891560
persistVmExpandedStateFromGlobal();
12901561
renderRuntimeHealthBadge(globalFolders, folderTypePrefs);
12911562
scheduleVmRuntimeWidthReflow('create-folders', 0);
1563+
applyVmZebra();
12921564

12931565
folderDebugMode = false;
12941566
markVmFatalBannerStep('VM folders rendered');
@@ -1565,7 +1837,19 @@ const createFolder = (folder, id, position, order, vmInfo, foldersDone, matchCac
15651837
let $vmTR = $('#kvm_list > tr.sortable').filter(function() {
15661838
return $(this).find('td.vm-name span.outer span.inner a').first().text().trim() === container;
15671839
}).first();
1568-
$(`tr.folder-id-${id} div.folder-storage`).append($vmTR.addClass(`folder-${id}-element`).addClass(`folder-element`).removeClass('sortable'));
1840+
const vmRowNode = $vmTR.get(0);
1841+
const detailRows = collectExistingVmDetailRowsForVmRow(vmRowNode);
1842+
const storage = $(`tr.folder-id-${id} div.folder-storage`).get(0);
1843+
if (vmRowNode && storage instanceof Element) {
1844+
applyVmFolderElementOwnership(vmRowNode, id);
1845+
vmRowNode.classList.remove('sortable');
1846+
storage.appendChild(vmRowNode);
1847+
detailRows.forEach((detailRow) => {
1848+
applyVmFolderElementOwnership(detailRow, id);
1849+
detailRow.dataset.fvplusVmDetailAdopted = '1';
1850+
storage.appendChild(detailRow);
1851+
});
1852+
}
15691853

15701854
if(folderDebugMode) {
15711855
vmDebugLog(`${newFolder[container].id}(${offsetIndex}, ${index}) => ${id}`);
@@ -1770,6 +2054,7 @@ const dropDownButton = (id, persistState = true) => {
17702054
persistVmExpandedStateFromGlobal();
17712055
}
17722056
scheduleVmRuntimeWidthReflow('folder-expand-toggle', 32);
2057+
scheduleVmZebraRefresh();
17732058
folderEvents.dispatchEvent(new CustomEvent('vm-post-folder-expansion', {detail: { id }}));
17742059
};
17752060

tests/vm-runtime-docker-parity-guard.test.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ test('vm runtime context menu keeps focus/pin/lock and clone actions in vm folde
3030
assert.match(vmJs, /Branch actions/);
3131
});
3232

33+
test('vm runtime adopts native detail rows for folder members and later host detail inserts', () => {
34+
assert.match(vmJs, /const VM_NATIVE_DETAIL_ROW_SELECTOR = 'tr\[id\^="name-"\]:not\(\[child-id\]\)';/);
35+
assert.match(vmJs, /const collectExistingVmDetailRowsForVmRow = \(vmRow\) => \{/);
36+
assert.match(vmJs, /const ensureVmNativeDetailRowObserver = \(\) => \{[\s\S]*vmNativeDetailRowObserver = new MutationObserver/);
37+
assert.match(vmJs, /const ensureVmNativeDetailInteractionHooks = \(\) => \{/);
38+
assert.match(vmJs, /const placeVmNativeDetailRowForOwner = \(detailRow,\s*vmRow\) => \{/);
39+
assert.match(vmJs, /adoptVmNativeDetailRows\(Array\.from\(document\.querySelectorAll\(`tbody#kvm_list > \$\{VM_NATIVE_DETAIL_ROW_SELECTOR\}`\)\)\);/);
40+
assert.match(vmJs, /const detailRows = collectExistingVmDetailRowsForVmRow\(vmRowNode\);/);
41+
});
42+
3343
test('vm css includes parity selectors for quick action row and folder quick state styles', () => {
3444
assert.match(vmCss, /tr\.fv-folder-focused td\.vm-name\.folder-name/);
3545
assert.match(vmCss, /tr\.fv-folder-pinned td\.vm-name\.folder-name/);

tests/vm-runtime-shared-architecture.test.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ test('vm runtime consumes shared state/perf/action modules and exposes telemetry
6060
assert.match(vmJs, /const vmSafeUiActionRunner = createVmSafeUiActionRunner\(\);/);
6161
assert.match(vmJs, /const vmExpandedStateController = runtimeStateObserverModule/);
6262
assert.match(vmJs, /const vmRuntimeThemeReflowController = runtimeStateObserverModule/);
63+
assert.match(vmJs, /const applyVmZebra = \(\) => \{/);
64+
assert.match(vmJs, /const scheduleVmZebraRefresh = \(delayMs = 32\) => \{/);
65+
assert.match(vmJs, /const adoptVmNativeDetailRows = \(rows = \[\]\) => \{/);
6366
assert.match(vmJs, /const runVmGuardedAction = async \(actionName, action, context = \{\}\) =>/);
6467
assert.doesNotMatch(vmJs, /createVmRuntimeCommandCenterController/);
6568
assert.doesNotMatch(vmJs, /syncVmCommandCenterView/);

0 commit comments

Comments
 (0)