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 85b4696..0584c95 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 3380c41..2340646 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -68,6 +68,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="feedback-modal"', body) self.assertIn('id="feedback-message"', body) self.assertIn('id="feedback-close-btn"', body) + self.assertIn('id="context-menu"', body) + self.assertIn('id="context-menu-scope"', body) + self.assertIn('id="context-menu-target"', body) + self.assertIn('id="context-menu-open-placeholder"', body) self.assertIn('id="settings-btn"', body) self.assertIn('id="rename-btn"', body) self.assertIn('id="view-btn"', body) @@ -200,6 +204,15 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function feedbackElements()', app_js) self.assertIn('function openFeedbackModal(message)', app_js) self.assertIn('function closeFeedbackModal()', app_js) + self.assertIn('function contextMenuElements()', app_js) + self.assertIn('function openContextMenu(pane, entry, event)', app_js) + self.assertIn('function closeContextMenu()', app_js) + self.assertIn('selectedPathsSet.has(entry.path)', app_js) + self.assertIn('entry.isParent', app_js) + self.assertIn('row.oncontextmenu = (event) => {', app_js) + self.assertIn('event.target.closest("li[data-row-index]")', app_js) + self.assertIn('if (!row) {', app_js) + self.assertIn('closeContextMenu();', app_js) self.assertIn('document.getElementById("upload-menu-toggle").onclick = (event) => {', app_js) self.assertIn('document.getElementById("upload-folder-btn").onclick = openFolderPicker;', app_js) self.assertIn('throw createApiError(response, data);', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 17d701d..a8f5fd4 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -52,6 +52,12 @@ let deleteConfirmState = { items: [], recursivePaths: [], }; +let contextMenuState = { + open: false, + pane: "left", + items: [], + anchorPath: null, +}; let batchMoveState = { destinationBase: "", count: 0, @@ -315,6 +321,62 @@ function feedbackElements() { }; } +function contextMenuElements() { + return { + menu: document.getElementById("context-menu"), + scope: document.getElementById("context-menu-scope"), + target: document.getElementById("context-menu-target"), + placeholder: document.getElementById("context-menu-open-placeholder"), + }; +} + +function isContextMenuOpen() { + return contextMenuState.open && !contextMenuElements().menu.classList.contains("hidden"); +} + +function closeContextMenu() { + const elements = contextMenuElements(); + contextMenuState.open = false; + contextMenuState.pane = "left"; + contextMenuState.items = []; + contextMenuState.anchorPath = null; + if (!elements.menu) { + return; + } + elements.menu.classList.add("hidden"); + elements.scope.textContent = ""; + elements.target.textContent = ""; +} + +function openContextMenu(pane, entry, event) { + if (!entry || entry.isParent) { + return; + } + const elements = contextMenuElements(); + const selectedItems = paneState(pane).selectedItems || []; + const selectedPathsSet = new Set(selectedItems.map((item) => item.path)); + const items = selectedPathsSet.has(entry.path) + ? selectedItems.map((item) => ({ ...item })) + : [selectedEntryFromItem(entry)]; + + contextMenuState.open = true; + contextMenuState.pane = pane; + contextMenuState.items = items; + contextMenuState.anchorPath = entry.path; + + const isMulti = items.length > 1; + elements.scope.textContent = isMulti ? "Multi-selection" : "Single item"; + elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name; + + const menuWidth = 220; + const menuHeight = 120; + const x = Math.min(event.clientX, window.innerWidth - menuWidth - 12); + const y = Math.min(event.clientY, window.innerHeight - menuHeight - 12); + elements.menu.style.left = `${Math.max(8, x)}px`; + elements.menu.style.top = `${Math.max(8, y)}px`; + elements.menu.classList.remove("hidden"); +} + function settingsElements() { return { overlay: document.getElementById("settings-modal"), @@ -1689,6 +1751,7 @@ function updatePaneFocusLine(pane) { } function renderPaneItems(pane) { + closeContextMenu(); const model = paneState(pane); const items = document.getElementById(`${pane}-items`); items.innerHTML = ""; @@ -1718,6 +1781,9 @@ function renderPaneItems(pane) { clearSelectionAnchor(pane); renderPaneItems(pane); }; + up.oncontextmenu = (event) => { + event.preventDefault(); + }; const upNameCell = document.createElement("span"); upNameCell.className = "entry-name entry-dir"; upNameCell.append(createSelectionSlot(pane, { ...entry, isParent: true }, index)); @@ -1789,6 +1855,12 @@ function renderPaneItems(pane) { } renderPaneItems(pane); }; + row.oncontextmenu = (event) => { + event.preventDefault(); + event.stopPropagation(); + setActivePane(pane); + openContextMenu(pane, entry, event); + }; if (entry.kind === "file" && isImageSelection({ path: entry.path, name: entry.name, kind: entry.kind })) { row.ondblclick = (ev) => { ev.stopPropagation(); @@ -1873,6 +1945,7 @@ async function loadBrowsePane(pane) { } function navigateTo(pane, path) { + closeContextMenu(); const model = paneState(pane); model.currentPath = path; model.currentRowIndex = 0; @@ -3335,6 +3408,13 @@ function clearSelectionForActivePane() { } function handleKeyboardShortcuts(event) { + if (isContextMenuOpen()) { + if (event.key === "Escape") { + event.preventDefault(); + closeContextMenu(); + } + return; + } if (event.key === "Escape" && !uploadElements().menuPopup.classList.contains("hidden")) { event.preventDefault(); closeUploadMenu(); @@ -3617,9 +3697,24 @@ function setupEvents() { document.addEventListener("click", (event) => { const elements = uploadElements(); if (!elements.menu || elements.menu.contains(event.target)) { + } else { + closeUploadMenu(); + } + const contextMenu = contextMenuElements().menu; + if (contextMenu && !contextMenu.contains(event.target)) { + closeContextMenu(); + } + }); + document.addEventListener("contextmenu", (event) => { + const contextMenu = contextMenuElements().menu; + if (contextMenu && contextMenu.contains(event.target)) { + event.preventDefault(); return; } - closeUploadMenu(); + const row = event.target instanceof Element ? event.target.closest("li[data-row-index]") : null; + if (!row) { + closeContextMenu(); + } }); const rename = renameElements(); diff --git a/webui/html/base.css b/webui/html/base.css index 60dce9a..c26925a 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -727,6 +727,45 @@ button:disabled { width: min(440px, calc(100vw - 24px)); } +.context-menu { + position: fixed; + min-width: 220px; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-elevated); + box-shadow: var(--shadow-elevated); + z-index: 1100; +} + +.context-menu-scope { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.context-menu-target { + margin-top: 4px; + font-size: 12px; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.context-menu-separator { + height: 1px; + margin: 8px 0; + background: var(--color-border); +} + +.context-menu button { + width: 100%; + justify-content: flex-start; +} + #upload-modal .popup-card { max-width: 320px; padding: 12px 14px; diff --git a/webui/html/index.html b/webui/html/index.html index ee0cd17..9d36b75 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -118,6 +118,13 @@ +
+