From c0bd6b647c66bd08f5d8e3870fed852a186a202c Mon Sep 17 00:00:00 2001 From: kodi Date: Sun, 15 Mar 2026 07:39:57 +0100 Subject: [PATCH] fix: navigatie --- webui/backend/data/tasks.db | Bin 233472 -> 237568 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 48843 -> 61761 bytes .../tests/golden/test_ui_smoke_golden.py | 235 ++++++++++++++++++ webui/html/app.js | 26 +- 4 files changed, 260 insertions(+), 1 deletion(-) diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 3b152f2236b0678fe288a8c1cf34dbe45e4cac98..55794466c0a4bbd89d6b5aaafe9bbe78b69eab67 100644 GIT binary patch delta 1690 zcmb7^TWB0r9LDE=X0NlCgdozGVwQFr8oLwcepyM~OQ?;Y7?X%BsmaXjjI1QP+s&pL zC1iJnq7N;V6%X{mMl2K*YC?oTq!?ecpkRxAEvCg6g<_t3@X52Alp3?O@%8^5_s|*s7vvoWL8lC0aJW~inmK&H*|dV`L;Py7kLk4E2v1m$_s^Rh?t%zDncffVwC z+nYun1fEa%Zon%eD6%Y1a7t3tl9HB#kzQIx0w*XeC$fB75!9q4=OFt~C9_xIq@<|k zAl*^niIRGtxSx%n+%wE8&_grF7!;hs;Tp=J;92a{(DUw>HFV97sCy)}-`c6Yq+6Ec zSOUi?7RQqs$Rt0}$g_r?H#Et}^9CnCwv~nHXsRX{8Y@VSNKW!5t4X@fT8=F^mLrmc zMM%4=@1uzU-xvTD;c;LUed2z;ioWv0jdi~JN6(QMo&8rNi|BL zrTg5|*HE)@fPo+tDTMtY#d|#XZD5GL>bs7{AxN%pFWop%kM>OV_b=7`6cr3Ek&0%s zS$-c~%P*8?3zmW5BI5C_Vd%kEhd~a+tWtS#sJz%5dvO5&dK~&rw%4|carZ`dBuV0A zF4sxYG)a=BoI9SO*M{jHDuMkq`jC2yO4Q?xba=A2pQ2j$vb>*!@y#ZZmkY)6LUr0% zC|cD*sW^Y1dXGKnO{ZCXx{(UskBu1rkHD(7Rhq-Tizx9RHSVGQq@8>G$;@@@^k_61 zA?u|{c|JXzk)t8Qg-41e<-Fv}IgvSUYyCqMYKY0p=U zV%{#!7T%bzPM;~{?b7^E^{wh|r0vJrX;$YN*&PtB0S?H&1ONa4 delta 427 zcmXw!-%C?r7{}l5^S)>2?A<&Y2I4PDVy!e4C$e=2wP{@#1kvV=ELdVlDY4yH6v1hN z&`lJJ9=uXP2)fA0f)@&5f>4+BAI!iTA($6+(N)Kbp3CR?KHtyx>o;6~(|z4zbqOK7 zH+_@n+AVHxIA>z-f`#y^(amH%6<>_i*LRj0xWQHl=QvryBWf#~w((;(T3I`!hkicFa9KRBLIs~8lf{TkT_uc>8qM_o~8O=p%Y!dlR^0-h*P%LN*@SHHFb-3DN%^F@4G7jX+5*gn8hy5L5}bc2 z)hDNk?@r4eQ{JO$r6Wu^(e@x}N7B|rc;6Y8A4A_L7ic{)xhc6jF5@t4`|^B!H2w<4 IypS{QKjFZ1X8-^I diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 05919cbf8d330f9c10fb2e748349775e1b9070fc..72006b83bc6ac58a20905566a06dda864fc752c0 100644 GIT binary patch delta 13061 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 QT?)UgJQq-J{kZM_0MK|!ZvX%Q delta 1344 zcmY+Ee@q-j6vt=w$KBrXWAFG4rSwWcJS{D)AfXa$(h~6aQDM23nroK@7JBsH=F+=#BdZ+#xFnVd|`!DW*rMO9*h6p)GRLmJYg21me2oZOci zlz@x4d?+NeX0UVrU#;1I&GbwZN-jhpneLPdyiw7*8Cr}yp|yY&O#Ysq=?Qp=*N1XY zA<~pQq`5+3YZ0wUhn-mQriU^#_nvlyj5LI3<|(MhC!2QDUehRq2-Xl0G)zRzsR?Pg&lXfkp3I{dDLHyGCXqsPs*3095#*0u=lS)qdb{IP8RkVFp7wD2-RXxsdHoDOWIsG}74yKAvqd1CtB0Z9BD?Y2 zKEbxn``vamoW{5g-k&bU^_(wo1~?ly8#$Xen>i0~zR20a*~%ztK@p-OKDa+6!w1tv zI0$ny#rPnjcqr(Amho+3d(g@4;h@6pOFmgta)Tb4>ZuUuccROOdeM`;bOM`)q2`_Y zjvN{zx%Re8er$KYTUlU#^W~Cgw_QDwc4`w#0q;T6-y^sF(Vf4(n&NLODLU&W$Im)m2en?gL{L4iQGYJ*$Esep%Z5Ld= ztHKwzRXDr62W~8Q;rm4u7MD|@ejyFMrSaW&3*hD*6)xQIux?K4=lIEaWdwd)$bn0X z9vG*+R%j1;101`T3Uv>>F!P%V;}28_uc)7`%sIAxjGFyI!a>YfoD2{Ld!9fH_A;qC zI$-)0kXN58e4Yl#Q5MbA^~WzZVlfi#jK@q%*GUJQSj`Tow8F}yIFHr^Nv>K?ep!$! z{~DAnS+2z-i*;dtqlBXC{YH2|*Zo-ux32fZ>Fp+MBeJAuy=uYQ>LHnWF#?Ti`>MNG zTw+H^2X!|(rL&+?-5>3aN6Z@X3YByekTpcHCSr^iQQ;DD!`#~5-9*8)cve}+@z3S> j*Xf6+a;?vcq~te-Z-}|Kq~!6Y*P32GuqGlYkGKC1gZZTD diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index a9a0f85..872a9db 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -1,6 +1,8 @@ from __future__ import annotations +import subprocess import sys +import textwrap import unittest from pathlib import Path @@ -19,6 +21,216 @@ class UiSmokeGoldenTest(unittest.TestCase): return route self.fail("Expected /ui mount to be registered") + def _extract_js_function(self, source: str, name: str) -> str: + marker = f"function {name}(" + start = source.find(marker) + if start < 0: + self.fail(f"Expected function {name} in app.js") + brace_start = source.find("{", start) + if brace_start < 0: + self.fail(f"Expected opening brace for function {name}") + depth = 0 + for index in range(brace_start, len(source)): + char = source[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[start : index + 1] + self.fail(f"Expected closing brace for function {name}") + + def _run_app_js_behavior_check(self, app_js: str) -> None: + functions = "\n\n".join( + [ + self._extract_js_function(app_js, "paneState"), + self._extract_js_function(app_js, "currentParentPath"), + self._extract_js_function(app_js, "baseName"), + self._extract_js_function(app_js, "prepareParentReturnRestore"), + self._extract_js_function(app_js, "navigateToParent"), + self._extract_js_function(app_js, "restoreParentReturnFocus"), + self._extract_js_function(app_js, "handleKeyboardShortcuts"), + ] + ) + script = textwrap.dedent( + f""" + const assert = (condition, message) => {{ + if (!condition) {{ + throw new Error(message); + }} + }}; + + let state = {{ + activePane: "left", + panes: {{ + left: {{ + currentPath: "storage1/parent/submap", + showHidden: false, + selectedItem: null, + selectedItems: [], + visibleItems: [], + currentRowIndex: -1, + selectionAnchorIndex: null, + pendingSelectionPath: null, + returnFocusName: null, + }}, + right: {{ + currentPath: "/Volumes", + showHidden: false, + selectedItem: null, + selectedItems: [], + visibleItems: [], + currentRowIndex: -1, + selectionAnchorIndex: null, + pendingSelectionPath: null, + returnFocusName: null, + }}, + }}, + }}; + + const navigationCalls = []; + const renderCalls = []; + + function navigateTo(pane, path) {{ + navigationCalls.push({{ pane, path }}); + }} + + function renderPaneItems(pane) {{ + renderCalls.push({{ pane, currentRowIndex: paneState(pane).currentRowIndex }}); + const model = paneState(pane); + if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) {{ + model.currentRowIndex = -1; + return; + }} + if (model.currentRowIndex < 0 || model.currentRowIndex >= model.visibleItems.length) {{ + model.currentRowIndex = 0; + }} + }} + + function isContextMenuOpen() {{ return false; }} + function uploadElements() {{ return {{ menuPopup: {{ classList: {{ contains() {{ return true; }} }} }} }}; }} + function isFeedbackModalOpen() {{ return false; }} + function closeFeedbackModal() {{}} + function isDownloadModalOpen() {{ return false; }} + function closeDownloadModal() {{}} + function isInfoOpen() {{ return false; }} + function closeInfo() {{}} + function isSearchOpen() {{ return false; }} + function closeSearch() {{}} + function submitSearch() {{}} + function isRenamePopupOpen() {{ return false; }} + function closeRenamePopup() {{}} + function submitRenamePopup() {{}} + function isSettingsOpen() {{ return false; }} + function closeSettings() {{}} + function isBatchMovePopupOpen() {{ return false; }} + function closeBatchMovePopup() {{}} + function submitBatchMovePopup() {{}} + function isDeleteConfirmModalOpen() {{ return false; }} + function closeDeleteConfirmModal() {{}} + function submitDeleteConfirmModal() {{}} + function isUploadConflictModalOpen() {{ return false; }} + function resolveUploadConflict() {{}} + function isMovePopupOpen() {{ return false; }} + function closeMovePopup() {{}} + function submitMovePopup() {{}} + function isEditorOpen() {{ return false; }} + function attemptCloseEditor() {{}} + function isImageOpen() {{ return false; }} + function closeImageViewer() {{}} + function isVideoOpen() {{ return false; }} + function closeVideoViewer() {{}} + function isPdfOpen() {{ return false; }} + function closePdfViewer() {{}} + function isViewerOpen() {{ return false; }} + function closeViewer() {{}} + function isWildcardPopupOpen() {{ return false; }} + function shouldHandleShortcut() {{ return true; }} + function openInfo() {{}} + function openSearch() {{}} + function actionShortcutHandled() {{ return false; }} + function openWildcardPopup() {{}} + function jumpCurrentRow() {{}} + function moveCurrentRow() {{}} + function extendSelectionByRow() {{}} + function otherPane(pane) {{ return pane === "left" ? "right" : "left"; }} + function setActivePane(pane) {{ state.activePane = pane; }} + function openCurrentDirectory() {{}} + function toggleCurrentSelection() {{}} + function clearSelectionForActivePane() {{}} + + {functions} + + let prevented = false; + handleKeyboardShortcuts({{ + key: "Backspace", + code: "Backspace", + shiftKey: false, + altKey: false, + metaKey: false, + ctrlKey: false, + target: null, + preventDefault() {{ prevented = true; }}, + }}); + assert(prevented, "Backspace should prevent default"); + assert(navigationCalls.length === 1, "Backspace should trigger parent navigation"); + assert(navigationCalls[0].path === "storage1/parent", "Backspace should navigate to parent path"); + assert(paneState("left").returnFocusName === "submap", "Backspace should prepare return focus by child name"); + assert(paneState("left").pendingSelectionPath === null, "Backspace parent return must not set pending selection path"); + assert(paneState("left").selectedItems.length === 0, "Backspace parent return must not select items"); + + state.panes.left.currentPath = "storage1/parent/submap"; + state.panes.left.returnFocusName = null; + state.panes.left.pendingSelectionPath = null; + navigationCalls.length = 0; + navigateToParent("left"); + assert(navigationCalls.length === 1, "Mouse parent-up should trigger parent navigation"); + assert(navigationCalls[0].path === "storage1/parent", "Mouse parent-up should navigate to parent path"); + assert(paneState("left").returnFocusName === "submap", "Mouse parent-up should use same restore flow"); + assert(paneState("left").pendingSelectionPath === null, "Mouse parent-up must not set pending selection path"); + + state.panes.left.visibleItems = [ + {{ path: "storage1/parent", name: "..", kind: "directory", isParent: true }}, + {{ path: "storage1/parent/submap", name: "submap", kind: "directory" }}, + {{ path: "storage1/parent/other", name: "other", kind: "directory" }}, + ]; + state.panes.left.currentRowIndex = 0; + state.panes.left.selectedItems = []; + state.panes.left.selectedItem = null; + state.panes.left.selectionAnchorIndex = null; + state.panes.left.returnFocusName = "submap"; + renderCalls.length = 0; + restoreParentReturnFocus("left", state.panes.left.visibleItems); + assert(state.panes.left.currentRowIndex === 1, "Restore should focus the child entry in parent"); + assert(state.panes.left.selectedItems.length === 0, "Restore must not add selection"); + assert(state.panes.left.selectedItem === null, "Restore must not set selectedItem"); + assert(state.panes.left.selectionAnchorIndex === null, "Restore must not set selection anchor"); + assert(state.panes.left.returnFocusName === null, "Restore state should be cleared after success"); + assert(renderCalls.length >= 1, "Restore should re-render focused row"); + + state.panes.left.currentRowIndex = 0; + state.panes.left.selectedItems = []; + state.panes.left.selectedItem = null; + state.panes.left.selectionAnchorIndex = null; + state.panes.left.returnFocusName = "missing"; + renderCalls.length = 0; + restoreParentReturnFocus("left", state.panes.left.visibleItems); + assert(state.panes.left.currentRowIndex === 0, "Fallback should keep existing row when match is missing"); + assert(state.panes.left.selectedItems.length === 0, "Fallback must not create selection"); + assert(state.panes.left.selectedItem === null, "Fallback must not set selectedItem"); + assert(state.panes.left.selectionAnchorIndex === null, "Fallback must not set selection anchor"); + assert(state.panes.left.returnFocusName === null, "Restore state should be cleared after failed match"); + """ + ) + result = subprocess.run( + ["node", "-e", script], + cwd="/workspace/webmanager-mvp", + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout) + def test_ui_mount_and_index_contains_expected_panels(self) -> None: mount = self._ui_mount() self.assertIsInstance(mount.app, StaticFiles) @@ -228,6 +440,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn("width: min(1180px, calc(100vw - 20px));", base_css) app_js = (static_root / "app.js").read_text(encoding="utf-8") self.assertIn('currentPath: "/Volumes"', app_js) + self.assertIn('returnFocusName: null,', app_js) self.assertIn('selectedTheme: "default"', app_js) self.assertIn('selectedColorMode: "dark"', app_js) self.assertIn('const VALID_THEME_FAMILIES = [', app_js) @@ -404,6 +617,13 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function toggleUploadMenu()', app_js) self.assertNotIn('if (event.altKey) {', app_js) self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js) + self.assertIn('if (event.key === "Backspace") {', app_js) + self.assertIn('navigateToParent(state.activePane);', app_js) + self.assertIn('function navigateToParent(pane) {', app_js) + self.assertIn('prepareParentReturnRestore(pane, childPath);', app_js) + self.assertIn('function prepareParentReturnRestore(pane, childPath) {', app_js) + self.assertIn('model.returnFocusName = baseName(childPath);', app_js) + self.assertNotIn('model.pendingSelectionPath = childPath;', app_js) self.assertIn('function collectDeleteRecursivePaths(selectedItems)', app_js) self.assertIn('const confirmed = await openConfirmModal({', app_js) self.assertIn('recursivePaths.has(item.path)', app_js) @@ -430,6 +650,15 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('paneState("left").currentPath = settingsState.preferredStartupPathLeft || "/Volumes";', app_js) self.assertIn('paneState("right").currentPath = settingsState.preferredStartupPathRight || "/Volumes";', app_js) self.assertIn('applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);', app_js) + self.assertIn('renderPaneItems(pane);', app_js) + self.assertIn('restoreParentReturnFocus(pane, visibleItems);', app_js) + self.assertIn('function restoreParentReturnFocus(pane, visibleItems) {', app_js) + self.assertIn('const returningFromChildName = model.returnFocusName;', app_js) + self.assertIn('model.returnFocusName = null;', app_js) + self.assertIn('const returnIndex = visibleItems.findIndex((item) => !item.isParent && item.name === returningFromChildName);', app_js) + self.assertIn('if (returnIndex < 0) {', app_js) + self.assertNotIn('setSingleSelectionAtIndex(pane, selectedEntryFromItem(returnItem), returnIndex);', app_js) + self.assertIn('renderPaneItems(pane);', app_js) self.assertIn('settings.interfaceTab.onclick = () => {', app_js) self.assertIn('setSettingsTab("interface");', app_js) self.assertIn('settings.downloadsTab.onclick = () => {', app_js) @@ -440,6 +669,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function ensureFolderUploadPicker()', app_js) self.assertIn('function openFolderPicker()', app_js) self.assertIn('function uploadModalElements()', app_js) + self.assertIn('function setUploadModalVisible(', app_js) self.assertIn('function updateUploadModalDisplay(', app_js) self.assertIn('function buildFolderUploadPlan(files, targetPath)', app_js) @@ -576,6 +806,11 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertEqual(base_css_url, "/ui/base.css") self.assertEqual(theme_default_url, "/ui/theme-default.css") + def test_parent_return_restore_focuses_without_selecting(self) -> None: + mount = self._ui_mount() + app_js = (Path(mount.app.directory) / "app.js").read_text(encoding="utf-8") + self._run_app_js_behavior_check(app_js) + if __name__ == "__main__": unittest.main() diff --git a/webui/html/app.js b/webui/html/app.js index e35f116..0055348 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -9,6 +9,7 @@ let state = { currentRowIndex: -1, selectionAnchorIndex: null, pendingSelectionPath: null, + returnFocusName: null, }, right: { currentPath: "/Volumes", @@ -19,6 +20,7 @@ let state = { currentRowIndex: -1, selectionAnchorIndex: null, pendingSelectionPath: null, + returnFocusName: null, }, }, activePane: "left", @@ -2210,10 +2212,15 @@ function navigateToParent(pane) { if (!parentPath) { return; } - model.pendingSelectionPath = childPath; + prepareParentReturnRestore(pane, childPath); navigateTo(pane, parentPath); } +function prepareParentReturnRestore(pane, childPath) { + const model = paneState(pane); + model.returnFocusName = baseName(childPath); +} + function baseName(path) { const index = path.lastIndexOf("/"); return index >= 0 ? path.slice(index + 1) : path; @@ -2618,6 +2625,7 @@ async function loadBrowsePane(pane) { } renderPaneItems(pane); + restoreParentReturnFocus(pane, visibleItems); scrollCurrentRowIntoView(pane); setStatus(`Loaded ${pane}: ${data.path}`); } catch (err) { @@ -2625,6 +2633,22 @@ async function loadBrowsePane(pane) { } } +function restoreParentReturnFocus(pane, visibleItems) { + const model = paneState(pane); + const returningFromChildName = model.returnFocusName; + model.returnFocusName = null; + if (!returningFromChildName) { + return; + } + const returnIndex = visibleItems.findIndex((item) => !item.isParent && item.name === returningFromChildName); + if (returnIndex < 0) { + return; + } + const returnItem = visibleItems[returnIndex]; + model.currentRowIndex = returnIndex; + renderPaneItems(pane); +} + function navigateTo(pane, path) { closeContextMenu(); const model = paneState(pane);