fix: navigatie

This commit is contained in:
kodi
2026-03-15 07:39:57 +01:00
parent cc5a978e79
commit c0bd6b647c
4 changed files with 260 additions and 1 deletions
Binary file not shown.
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import subprocess
import sys import sys
import textwrap
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -19,6 +21,216 @@ class UiSmokeGoldenTest(unittest.TestCase):
return route return route
self.fail("Expected /ui mount to be registered") 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: def test_ui_mount_and_index_contains_expected_panels(self) -> None:
mount = self._ui_mount() mount = self._ui_mount()
self.assertIsInstance(mount.app, StaticFiles) self.assertIsInstance(mount.app, StaticFiles)
@@ -228,6 +440,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn("width: min(1180px, calc(100vw - 20px));", base_css) self.assertIn("width: min(1180px, calc(100vw - 20px));", base_css)
app_js = (static_root / "app.js").read_text(encoding="utf-8") app_js = (static_root / "app.js").read_text(encoding="utf-8")
self.assertIn('currentPath: "/Volumes"', app_js) self.assertIn('currentPath: "/Volumes"', app_js)
self.assertIn('returnFocusName: null,', app_js)
self.assertIn('selectedTheme: "default"', app_js) self.assertIn('selectedTheme: "default"', app_js)
self.assertIn('selectedColorMode: "dark"', app_js) self.assertIn('selectedColorMode: "dark"', app_js)
self.assertIn('const VALID_THEME_FAMILIES = [', 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.assertIn('function toggleUploadMenu()', app_js)
self.assertNotIn('if (event.altKey) {', app_js) self.assertNotIn('if (event.altKey) {', app_js)
self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', 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('function collectDeleteRecursivePaths(selectedItems)', app_js)
self.assertIn('const confirmed = await openConfirmModal({', app_js) self.assertIn('const confirmed = await openConfirmModal({', app_js)
self.assertIn('recursivePaths.has(item.path)', 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("left").currentPath = settingsState.preferredStartupPathLeft || "/Volumes";', app_js)
self.assertIn('paneState("right").currentPath = settingsState.preferredStartupPathRight || "/Volumes";', app_js) self.assertIn('paneState("right").currentPath = settingsState.preferredStartupPathRight || "/Volumes";', app_js)
self.assertIn('applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);', 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('settings.interfaceTab.onclick = () => {', app_js)
self.assertIn('setSettingsTab("interface");', app_js) self.assertIn('setSettingsTab("interface");', app_js)
self.assertIn('settings.downloadsTab.onclick = () => {', 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 ensureFolderUploadPicker()', app_js)
self.assertIn('function openFolderPicker()', app_js) self.assertIn('function openFolderPicker()', app_js)
self.assertIn('function uploadModalElements()', app_js) self.assertIn('function uploadModalElements()', app_js)
self.assertIn('function setUploadModalVisible(', app_js) self.assertIn('function setUploadModalVisible(', app_js)
self.assertIn('function updateUploadModalDisplay(', app_js) self.assertIn('function updateUploadModalDisplay(', app_js)
self.assertIn('function buildFolderUploadPlan(files, targetPath)', 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(base_css_url, "/ui/base.css")
self.assertEqual(theme_default_url, "/ui/theme-default.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__": if __name__ == "__main__":
unittest.main() unittest.main()
+25 -1
View File
@@ -9,6 +9,7 @@ let state = {
currentRowIndex: -1, currentRowIndex: -1,
selectionAnchorIndex: null, selectionAnchorIndex: null,
pendingSelectionPath: null, pendingSelectionPath: null,
returnFocusName: null,
}, },
right: { right: {
currentPath: "/Volumes", currentPath: "/Volumes",
@@ -19,6 +20,7 @@ let state = {
currentRowIndex: -1, currentRowIndex: -1,
selectionAnchorIndex: null, selectionAnchorIndex: null,
pendingSelectionPath: null, pendingSelectionPath: null,
returnFocusName: null,
}, },
}, },
activePane: "left", activePane: "left",
@@ -2210,10 +2212,15 @@ function navigateToParent(pane) {
if (!parentPath) { if (!parentPath) {
return; return;
} }
model.pendingSelectionPath = childPath; prepareParentReturnRestore(pane, childPath);
navigateTo(pane, parentPath); navigateTo(pane, parentPath);
} }
function prepareParentReturnRestore(pane, childPath) {
const model = paneState(pane);
model.returnFocusName = baseName(childPath);
}
function baseName(path) { function baseName(path) {
const index = path.lastIndexOf("/"); const index = path.lastIndexOf("/");
return index >= 0 ? path.slice(index + 1) : path; return index >= 0 ? path.slice(index + 1) : path;
@@ -2618,6 +2625,7 @@ async function loadBrowsePane(pane) {
} }
renderPaneItems(pane); renderPaneItems(pane);
restoreParentReturnFocus(pane, visibleItems);
scrollCurrentRowIntoView(pane); scrollCurrentRowIntoView(pane);
setStatus(`Loaded ${pane}: ${data.path}`); setStatus(`Loaded ${pane}: ${data.path}`);
} catch (err) { } 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) { function navigateTo(pane, path) {
closeContextMenu(); closeContextMenu();
const model = paneState(pane); const model = paneState(pane);