diff --git a/app/static/app.js b/app/static/app.js index 270d1ae..ff8704f 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -17,6 +17,7 @@ modalKnownFiles: {}, modalFileFilter: "", modalVisibleFiles: [], + modalSelectionAnchorPath: null, syncScrolling: false, settings: { set_file_date_to_first_aired_date: false, @@ -755,6 +756,7 @@ }; }); state.modalFileFilter = ""; + state.modalSelectionAnchorPath = null; el.fileModalTitle.textContent = "File Discovery"; el.modalAddSelectedFilesBtn.style.display = ""; if (el.modalFileFilterInput) { @@ -819,22 +821,62 @@ const isSelected = state.modalSelectedFilePaths.has(file.path); if (isSelected) li.classList.add("selected"); + if (state.modalSelectionAnchorPath && state.modalSelectionAnchorPath === file.path) { + li.classList.add("modal-anchor"); + } - li.addEventListener("click", () => { - if (state.modalSelectedFilePaths.has(file.path)) { - state.modalSelectedFilePaths.delete(file.path); - li.classList.remove("selected"); - } else { - state.modalSelectedFilePaths.add(file.path); - li.classList.add("selected"); - } - updateModalSelectionCount(); + li.addEventListener("click", (event) => { + handleModalFileRowClick(file.path, event); }); el.modalFilesList.appendChild(li); }); updateModalSelectionCount(); } + function handleModalFileRowClick(path, event) { + const clickedPath = (path || "").toString().trim(); + if (!clickedPath) return; + + const visiblePaths = (state.modalVisibleFiles || []) + .map((f) => (f && f.path ? f.path : "")) + .filter((p) => p); + const isShift = !!(event && event.shiftKey); + const isToggle = !!(event && (event.ctrlKey || event.metaKey)); + + if (isShift) { + const anchor = state.modalSelectionAnchorPath || ""; + const from = visiblePaths.indexOf(anchor); + const to = visiblePaths.indexOf(clickedPath); + if (from >= 0 && to >= 0) { + const start = Math.min(from, to); + const end = Math.max(from, to); + const rangePaths = visiblePaths.slice(start, end + 1); + state.modalSelectedFilePaths = new Set(rangePaths); + } else { + // Fallback: no valid anchor in current visible list. + state.modalSelectedFilePaths = new Set([clickedPath]); + } + state.modalSelectionAnchorPath = clickedPath; + renderModalFiles(); + return; + } + + if (isToggle) { + if (state.modalSelectedFilePaths.has(clickedPath)) { + state.modalSelectedFilePaths.delete(clickedPath); + } else { + state.modalSelectedFilePaths.add(clickedPath); + } + state.modalSelectionAnchorPath = clickedPath; + renderModalFiles(); + return; + } + + state.modalSelectedFilePaths = new Set([clickedPath]); + state.modalSelectionAnchorPath = clickedPath; + renderModalFiles(); + } + async function loadModalFiles(subpath) { const rootId = el.modalRootSelect.value; const chosenSubpath = (subpath || "").trim(); @@ -845,6 +887,7 @@ const data = await api( `/api/files/discover?root_id=${encodeURIComponent(rootId)}&subpath=${encodeURIComponent(chosenSubpath)}&recursive=${recursive}&limit=200` ); + state.modalSelectionAnchorPath = null; state.modalFiles = data.items || []; state.modalFiles.forEach((file) => { const path = file.path || ""; @@ -891,6 +934,7 @@ function clearModalSelection() { state.modalSelectedFilePaths.clear(); + state.modalSelectionAnchorPath = null; renderModalFiles(); } diff --git a/app/static/styles.css b/app/static/styles.css index 0741c15..4198750 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -506,6 +506,10 @@ button.secondary { cursor: pointer; } +#modalFilesList li.modal-anchor { + box-shadow: inset 0 0 0 1px var(--button-primary-border); +} + .modal-files-tools { margin-bottom: 8px; } diff --git a/data/session_state.sqlite3 b/data/session_state.sqlite3 index b869528..bfdbd51 100644 Binary files a/data/session_state.sqlite3 and b/data/session_state.sqlite3 differ diff --git a/feature_tests_file_modal_selection.sh b/feature_tests_file_modal_selection.sh new file mode 100755 index 0000000..2f50a46 --- /dev/null +++ b/feature_tests_file_modal_selection.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://127.0.0.1:8085}" +ALT_BASE_URL="http://host.containers.internal:8085" + +detect_base_url() { + if curl -fsS --max-time 2 "$BASE_URL/api/health" >/dev/null 2>&1; then + echo "$BASE_URL" + return + fi + if curl -fsS --max-time 2 "$ALT_BASE_URL/api/health" >/dev/null 2>&1; then + echo "$ALT_BASE_URL" + return + fi + echo "$BASE_URL" +} + +BASE_URL="$(detect_base_url)" +echo "Using BASE_URL=$BASE_URL" + +echo "== Feature test 1: modal selection modifiers are implemented ==" +grep -q "modalSelectionAnchorPath" app/static/app.js || { echo "anchor state missing"; exit 1; } +grep -q "event.shiftKey" app/static/app.js || { echo "shift handling missing"; exit 1; } +grep -q "event.ctrlKey || event.metaKey" app/static/app.js || { echo "ctrl/meta handling missing"; exit 1; } +echo "modifier selection validation passed" + +echo +echo "== Feature test 2: anchor reset on clear and folder reload ==" +grep -q "state.modalSelectionAnchorPath = null;" app/static/app.js || { echo "anchor reset missing"; exit 1; } +grep -q "function clearModalSelection()" app/static/app.js || { echo "clearModalSelection missing"; exit 1; } +grep -q "async function loadModalFiles(subpath)" app/static/app.js || { echo "loadModalFiles missing"; exit 1; } +echo "anchor reset validation passed" + +echo +echo "== Feature test 3: modal file list UI still present with batch actions ==" +page="$(curl -fsS "$BASE_URL/")" +echo "$page" | grep -q 'id="modalFilesList"' || { echo "modalFilesList missing"; exit 1; } +echo "$page" | grep -q 'id="modalSelectAllFilesBtn"' || { echo "Select All button missing"; exit 1; } +echo "$page" | grep -q 'id="modalClearSelectionBtn"' || { echo "Clear Selection button missing"; exit 1; } +echo "modal UI validation passed" + +echo +echo "All file modal selection feature tests passed."