diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 3b152f2..5579446 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ 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 05919cb..72006b8 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ 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);