fix: navigatie
This commit is contained in:
Binary file not shown.
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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user