This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git


The following commit(s) were added to refs/heads/main by this push:
     new b7361af  Allow multiple file selection in move, and add linting
b7361af is described below

commit b7361af3485169f2f678a659510d7efd508e0074
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue May 27 19:04:15 2025 +0100

    Allow multiple file selection in move, and add linting
    
    - Retain the incompatibility display when selecting items
    - Use checkboxes and radio controls for source and destination
    - Add an ESLint configuration and a package.json file
    - Update linting configuration to exclude node_modules
---
 .gitignore                                |   2 +
 .pre-commit-config.yaml                   |  12 +-
 atr/routes/finish.py                      | 255 +++++++++----
 atr/static/js/finish-selected-move.js     | 471 ++++++++++++++---------
 atr/static/js/finish-selected-move.js.map |   2 +-
 atr/static/ts/finish-selected-move.ts     | 614 ++++++++++++++++++------------
 atr/templates/finish-selected.html        |   6 +-
 eslint.config.mjs                         |  37 ++
 package.json                              |  11 +
 pyproject.toml                            |   5 +-
 tsconfig.json                             |  13 +-
 11 files changed, 905 insertions(+), 523 deletions(-)

diff --git a/.gitignore b/.gitignore
index f9cff25..339aa9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,5 +10,7 @@
 __pycache__/
 apptoken.txt
 bootstrap/build/
+node_modules/
+package-lock.json
 state/
 atr/_version.py
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 5ded58b..107c14d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -3,10 +3,15 @@ repos:
   rev: v5.0.0
   hooks:
     - id: check-toml
+      exclude: ^node_modules/
     - id: check-yaml
+      exclude: ^node_modules/
     - id: end-of-file-fixer
+      exclude: ^node_modules/
     - id: mixed-line-ending
+      exclude: ^node_modules/
     - id: trailing-whitespace
+      exclude: ^node_modules/
 - repo: https://github.com/Lucas-C/pre-commit-hooks
   rev: v1.5.5
   hooks:
@@ -62,18 +67,21 @@ repos:
       entry: ruff check --fix
       language: system
       types: [python]
+      exclude: ^node_modules/
     - id: ruff-format
       name: Ruff Formatter
       entry: ruff format --force-exclude
       language: system
       types: [python]
+      exclude: ^node_modules/
     - id: mypy
       name: Mypy Type Check
       entry: mypy
       language: system
       require_serial: true
       types: [python]
-      exclude: "tests" # see 
https://github.com/pre-commit/pre-commit/issues/2967
+      # See https://github.com/pre-commit/pre-commit/issues/2967
+      exclude: ^(node_modules|tests)/
       args:
         - --config-file=pyproject.toml
         - --scripts-are-modules
@@ -83,7 +91,7 @@ repos:
       language: system
       require_serial: true
       types: [python]
-      exclude: "tests"
+      exclude: ^tests/
     - id: jinja-route-check
       name: Jinja Route Checker
       description: Check whether routes used in Jinja2 templates actually exist
diff --git a/atr/routes/finish.py b/atr/routes/finish.py
index ce3bd67..8dda9c5 100644
--- a/atr/routes/finish.py
+++ b/atr/routes/finish.py
@@ -22,8 +22,10 @@ from typing import Final
 import aiofiles.os
 import quart
 import quart.wrappers.response as quart_response
+import werkzeug.datastructures as datastructures
 import werkzeug.wrappers.response as response
 import wtforms
+import wtforms.fields as fields
 
 import atr.db as db
 import atr.db.models as models
@@ -56,21 +58,32 @@ class CreateDirectoryForm(util.QuartFormTyped):
 
 
 class MoveFileForm(util.QuartFormTyped):
-    """Form for moving a file within a preview revision."""
+    """Form for moving one or more files within a preview revision."""
 
-    source_file = wtforms.SelectField("File to move", choices=[], 
validators=[wtforms.validators.InputRequired()])
+    source_files = wtforms.SelectMultipleField(
+        "Files to move",
+        choices=[],
+        validators=[wtforms.validators.DataRequired(message="Please select at 
least one file to move.")],
+    )
     target_directory = wtforms.SelectField(
-        "Target directory", choices=[], 
validators=[wtforms.validators.InputRequired()]
+        "Target directory", choices=[], 
validators=[wtforms.validators.DataRequired()]
     )
     submit = wtforms.SubmitField("Move file")
 
+    def validate_source_files(self, field: fields.SelectMultipleField) -> None:
+        if not field.data or len(field.data) == 0:
+            raise wtforms.validators.ValidationError("Please select at least 
one file to move.")
+
     def validate_target_directory(self, field: wtforms.Field) -> None:
         # This validation runs only if both fields have data
-        if self.source_file.data and field.data:
-            source_path = pathlib.Path(self.source_file.data)
+        if self.source_files.data and field.data:
+            source_paths = [pathlib.Path(sf) for sf in self.source_files.data]
             target_dir = pathlib.Path(field.data)
-            if source_path.parent == target_dir:
-                raise wtforms.validators.ValidationError("Target directory 
cannot be the same as the source directory.")
+            for source_path in source_paths:
+                if source_path.parent == target_dir:
+                    raise wtforms.validators.ValidationError(
+                        f"Target directory cannot be the same as the source 
directory for {source_path.name}."
+                    )
 
 
 @routes.committer("/finish/<project_name>/<version_name>", methods=["GET", 
"POST"])
@@ -105,28 +118,16 @@ async def selected(
     )
 
     # Populate choices dynamically for both GET and POST
-    move_form.source_file.choices = sorted([(str(p), str(p)) for p in 
source_files_rel])
+    move_form.source_files.choices = sorted([(str(p), str(p)) for p in 
source_files_rel])
     move_form.target_directory.choices = sorted([(str(d), str(d)) for d in 
target_dirs])
     can_move = (len(target_dirs) > 1) and (len(source_files_rel) > 0)
 
     if formdata:
-        form_action = formdata.get("form_action")
-        if form_action == "create_dir":
-            if await create_dir_form.validate_on_submit():
-                new_dir_name = create_dir_form.new_directory_name.data
-                if new_dir_name:
-                    return await _create_directory_in_revision(
-                        pathlib.Path(new_dir_name),
-                        session,
-                        project_name,
-                        version_name,
-                        
quart.request.accept_mimetypes.best_match(["application/json", "text/html"])
-                        == "application/json",
-                    )
-        elif (form_action != "create_dir") and can_move:
-            move_result = await _move_file(move_form, session, project_name, 
version_name)
-            if move_result is not None:
-                return move_result
+        result = await _process_formdata(
+            formdata, session, project_name, version_name, create_dir_form, 
move_form, can_move
+        )
+        if result is not None:
+            return result
 
     # resp = await quart.current_app.make_response(template_rendered)
     # resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
@@ -147,22 +148,6 @@ async def selected(
     )
 
 
-async def _respond(
-    session: routes.CommitterSession,
-    project_name: str,
-    version_name: str,
-    wants_json: bool,
-    ok: bool,
-    msg: str,
-    http_status: int = 200,
-) -> tuple[quart_response.Response, int] | response.Response:
-    """Helper to respond with JSON or flash message and redirect."""
-    if wants_json:
-        return quart.jsonify(ok=ok, message=msg), http_status
-    await quart.flash(msg, "success" if ok else "error")
-    return await session.redirect(selected, project_name=project_name, 
version_name=version_name)
-
-
 async def _create_directory_in_revision(
     new_dir_rel: pathlib.Path,
     session: routes.CommitterSession,
@@ -219,38 +204,117 @@ async def _create_directory_in_revision(
         )
 
 
+async def _process_formdata(
+    formdata: datastructures.MultiDict,
+    session: routes.CommitterSession,
+    project_name: str,
+    version_name: str,
+    create_dir_form: CreateDirectoryForm,
+    move_form: MoveFileForm,
+    can_move: bool,
+) -> tuple[quart_response.Response, int] | response.Response | str | None:
+    form_action = formdata.get("form_action")
+
+    if (
+        (quart.request.method == "POST")
+        and ("source_files" in formdata)
+        and ("target_directory" in formdata)
+        and (not form_action)
+    ):
+        source_files_data = formdata.getlist("source_files")
+        target_dir_data = formdata.get("target_directory")
+        wants_json = 
quart.request.accept_mimetypes.best_match(["application/json", "text/html"]) == 
"application/json"
+
+        if not source_files_data or not target_dir_data:
+            return await _respond(
+                session,
+                project_name,
+                version_name,
+                wants_json,
+                False,
+                "Missing source file(s) or target directory.",
+                400,
+            )
+
+        source_files_rel = [pathlib.Path(sf) for sf in source_files_data]
+        target_dir_rel = pathlib.Path(target_dir_data)
+
+        if not source_files_rel:
+            return await _respond(
+                session, project_name, version_name, wants_json, False, "No 
source files selected.", 400
+            )
+        return await _move_file_to_revision(
+            source_files_rel, target_dir_rel, session, project_name, 
version_name, wants_json
+        )
+
+    elif form_action == "create_dir":
+        if await create_dir_form.validate_on_submit():
+            new_dir_name = create_dir_form.new_directory_name.data
+            if new_dir_name:
+                return await _create_directory_in_revision(
+                    pathlib.Path(new_dir_name),
+                    session,
+                    project_name,
+                    version_name,
+                    
quart.request.accept_mimetypes.best_match(["application/json", "text/html"]) == 
"application/json",
+                )
+
+    elif ((form_action != "create_dir") or (form_action is None)) and can_move:
+        return await _move_file(move_form, session, project_name, version_name)
+    return None
+
+
 async def _move_file(
-    form: MoveFileForm, session: routes.CommitterSession, project_name: str, 
version_name: str
+    form: MoveFileForm,
+    session: routes.CommitterSession,
+    project_name: str,
+    version_name: str,
 ) -> tuple[quart_response.Response, int] | response.Response | None:
     wants_json = "application/json" in quart.request.headers.get("Accept", "")
+
     if await form.validate_on_submit():
-        source_file_rel = pathlib.Path(form.source_file.data)
-        target_dir_rel = pathlib.Path(form.target_directory.data)
+        source_files_rel_str_list = form.source_files.data
+        target_dir_rel_str = form.target_directory.data
+        if not source_files_rel_str_list or not target_dir_rel_str:
+            return await _respond(
+                session,
+                project_name,
+                version_name,
+                wants_json,
+                False,
+                "Source file(s) or target directory missing.",
+                400,
+            )
+
+        source_files_rel = [pathlib.Path(sf_str) for sf_str in 
source_files_rel_str_list]
+        target_dir_rel = pathlib.Path(target_dir_rel_str)
         return await _move_file_to_revision(
-            source_file_rel, target_dir_rel, session, project_name, 
version_name, wants_json
+            source_files_rel, target_dir_rel, session, project_name, 
version_name, wants_json
         )
     else:
         if wants_json:
             error_messages = []
             for field_name, field_errors in form.errors.items():
                 field_object = getattr(form, field_name, None)
-                label_object = getattr(field_object, "label", None)
-                label_text = label_object.text if label_object else 
field_name.replace("_", " ").title()
+                label_text = field_name.replace("_", " ").title()
+                if field_object and hasattr(field_object, "label") and 
field_object.label:
+                    label_text = field_object.label.text
                 error_messages.append(f"{label_text}: {', 
'.join(field_errors)}")
             error_string = "; ".join(error_messages)
             return await _respond(session, project_name, version_name, True, 
False, error_string, 400)
         else:
             for field_name, field_errors in form.errors.items():
                 field_object = getattr(form, field_name, None)
-                label_object = getattr(field_object, "label", None)
-                label_text = label_object.text if label_object else 
field_name.replace("_", " ").title()
+                label_text = field_name.replace("_", " ").title()
+                if field_object and hasattr(field_object, "label") and 
field_object.label:
+                    label_text = field_object.label.text
                 for error_message_text in field_errors:
                     await quart.flash(f"{label_text}: {error_message_text}", 
"warning")
             return None
 
 
 async def _move_file_to_revision(
-    source_file_rel: pathlib.Path,
+    source_files_rel: list[pathlib.Path],
     target_dir_rel: pathlib.Path,
     session: routes.CommitterSession,
     project_name: str,
@@ -259,27 +323,41 @@ async def _move_file_to_revision(
 ) -> tuple[quart_response.Response, int] | response.Response:
     try:
         description = "File move through web interface"
+        moved_files_names: list[str] = []
+        skipped_files_names: list[str] = []
+
         async with revision.create_and_manage(
             project_name, version_name, session.uid, description=description
         ) as creating:
-            related_files = _related_files(source_file_rel)
-            bundle = [f for f in related_files if await 
aiofiles.os.path.exists(creating.interim_path / f)]
-            collisions = [
-                f.name for f in bundle if await 
aiofiles.os.path.exists(creating.interim_path / target_dir_rel / f.name)
-            ]
-            if collisions:
-                # Remove the temporary directory, and do not create or commit 
a new revision
-                # (But also do not raise an exception)
-                creating.failed = True
-                msg = f"Files already exist in '{target_dir_rel}': {', 
'.join(collisions)}"
-                return await _respond(session, project_name, version_name, 
wants_json, False, msg, 400)
-
-            for f in bundle:
-                await aiofiles.os.rename(creating.interim_path / f, 
creating.interim_path / target_dir_rel / f.name)
+            await _setup_revision(
+                source_files_rel,
+                target_dir_rel,
+                creating,
+                moved_files_names,
+                skipped_files_names,
+            )
+
+        if creating.failed:
+            return await _respond(
+                session, project_name, version_name, wants_json, False, "Move 
operation failed due to pre-check.", 409
+            )
+
+        response_messages = []
+        if moved_files_names:
+            response_messages.append(f"Moved {', '.join(moved_files_names)}")
+        if skipped_files_names:
+            response_messages.append(f"Skipped {', 
'.join(skipped_files_names)} (already in target directory)")
+
+        if not response_messages:
+            if not source_files_rel:
+                return await _respond(
+                    session, project_name, version_name, wants_json, False, 
"No source files specified for move.", 400
+                )
+            msg = f"No files were moved. {', '.join(skipped_files_names)} 
already in '{target_dir_rel}'."
+            return await _respond(session, project_name, version_name, 
wants_json, True, msg, 200)
 
-        return await _respond(
-            session, project_name, version_name, wants_json, True, f"Moved {', 
'.join(f.name for f in bundle)}", 200
-        )
+        final_msg = ". ".join(response_messages) + "."
+        return await _respond(session, project_name, version_name, wants_json, 
True, final_msg, 200)
 
     except FileNotFoundError:
         _LOGGER.exception("File not found during move operation in new 
revision")
@@ -314,6 +392,49 @@ def _related_files(path: pathlib.Path) -> 
list[pathlib.Path]:
     ]
 
 
+async def _respond(
+    session: routes.CommitterSession,
+    project_name: str,
+    version_name: str,
+    wants_json: bool,
+    ok: bool,
+    msg: str,
+    http_status: int = 200,
+) -> tuple[quart_response.Response, int] | response.Response:
+    """Helper to respond with JSON or flash message and redirect."""
+    if wants_json:
+        return quart.jsonify(ok=ok, message=msg), http_status
+    await quart.flash(msg, "success" if ok else "error")
+    return await session.redirect(selected, project_name=project_name, 
version_name=version_name)
+
+
+async def _setup_revision(
+    source_files_rel: list[pathlib.Path],
+    target_dir_rel: pathlib.Path,
+    creating: revision.Creating,
+    moved_files_names: list[str],
+    skipped_files_names: list[str],
+) -> None:
+    for source_file_rel in source_files_rel:
+        if source_file_rel.parent == target_dir_rel:
+            skipped_files_names.append(source_file_rel.name)
+            continue
+
+        related_files = _related_files(source_file_rel)
+        bundle = [f for f in related_files if await 
aiofiles.os.path.exists(creating.interim_path / f)]
+        collisions = [
+            f.name for f in bundle if await 
aiofiles.os.path.exists(creating.interim_path / target_dir_rel / f.name)
+        ]
+        if collisions:
+            creating.failed = True
+            return
+
+        for f in bundle:
+            await aiofiles.os.rename(creating.interim_path / f, 
creating.interim_path / target_dir_rel / f.name)
+            if f == source_file_rel:
+                moved_files_names.append(f.name)
+
+
 async def _sources_and_targets(latest_revision_dir: pathlib.Path) -> 
tuple[list[pathlib.Path], set[pathlib.Path]]:
     source_files_rel: list[pathlib.Path] = []
     target_dirs: set[pathlib.Path] = {pathlib.Path(".")}
diff --git a/atr/static/js/finish-selected-move.js 
b/atr/static/js/finish-selected-move.js
index bcd1a58..c35a6ad 100644
--- a/atr/static/js/finish-selected-move.js
+++ b/atr/static/js/finish-selected-move.js
@@ -13,24 +13,29 @@ var ItemType;
     ItemType["File"] = "file";
     ItemType["Dir"] = "dir";
 })(ItemType || (ItemType = {}));
-const CONFIRM_MOVE_BUTTON_ID = "confirm-move-button";
-const CURRENT_MOVE_SELECTION_INFO_ID = "current-move-selection-info";
-const DIR_DATA_ID = "dir-data";
-const DIR_FILTER_INPUT_ID = "dir-filter-input";
-const DIR_LIST_MORE_INFO_ID = "dir-list-more-info";
-const DIR_LIST_TABLE_BODY_ID = "dir-list-table-body";
-const ERROR_ALERT_ID = "move-error-alert";
-const FILE_DATA_ID = "file-data";
-const FILE_FILTER_ID = "file-filter";
-const FILE_LIST_MORE_INFO_ID = "file-list-more-info";
-const FILE_LIST_TABLE_BODY_ID = "file-list-table-body";
-const MAIN_SCRIPT_DATA_ID = "main-script-data";
-const MAX_FILES_INPUT_ID = "max-files-input";
-const SELECTED_FILE_NAME_TITLE_ID = "selected-file-name-title";
-const TXT_CHOOSE = "Choose";
-const TXT_CHOSEN = "Chosen";
-const TXT_SELECT = "Select";
-const TXT_SELECTED = "Selected";
+const ID = Object.freeze({
+    confirmMoveButton: "confirm-move-button",
+    currentMoveSelectionInfo: "current-move-selection-info",
+    dirData: "dir-data",
+    dirFilterInput: "dir-filter-input",
+    dirListMoreInfo: "dir-list-more-info",
+    dirListTableBody: "dir-list-table-body",
+    errorAlert: "move-error-alert",
+    fileData: "file-data",
+    fileFilter: "file-filter",
+    fileListMoreInfo: "file-list-more-info",
+    fileListTableBody: "file-list-table-body",
+    mainScriptData: "main-script-data",
+    maxFilesInput: "max-files-input",
+    selectedFileNameTitle: "selected-file-name-title",
+});
+const TXT = Object.freeze({
+    Choose: "Choose",
+    Chosen: "Chosen",
+    Select: "Select",
+    Selected: "Selected",
+    MoreItemsHint: "more available (filter to browse)..."
+});
 const MAX_FILES_FALLBACK = 5;
 let fileFilterInput;
 let fileListTableBody;
@@ -42,6 +47,14 @@ let confirmMoveButton;
 let currentMoveSelectionInfoElement;
 let errorAlert;
 let uiState;
+function toLower(s) {
+    return (s || "").toLocaleLowerCase();
+}
+function includesCaseInsensitive(haystack, needle) {
+    if (haystack === null || haystack === undefined || needle === null || 
needle === undefined)
+        return false;
+    return toLower(haystack).includes(toLower(needle));
+}
 function getParentPath(filePathString) {
     if (!filePathString || typeof filePathString !== "string")
         return ".";
@@ -52,84 +65,109 @@ function getParentPath(filePathString) {
         return "/";
     return filePathString.substring(0, lastSlash);
 }
-const toLower = (s) => (s || "").toLocaleLowerCase();
-const includesCaseInsensitive = (haystack, lowerNeedle) => 
toLower(haystack).includes(lowerNeedle);
 function assertElementPresent(element, selector) {
     if (!element) {
         throw new Error(`Required DOM element '${selector}' not found.`);
     }
     return element;
 }
+function $(id) {
+    return assertElementPresent(document.getElementById(id), id);
+}
 function updateMoveSelectionInfo() {
     if (selectedFileNameTitleElement) {
-        selectedFileNameTitleElement.textContent = 
uiState.currentlySelectedFilePath
-            ? `Select a destination for ${uiState.currentlySelectedFilePath}`
+        selectedFileNameTitleElement.textContent = 
uiState.currentlySelectedFilePaths.size > 0
+            ? `Select a destination for 
${uiState.currentlySelectedFilePaths.size} file(s)`
             : "Select a destination for the file";
     }
-    let infoHTML = "";
-    let disableConfirm = true;
-    if (!uiState.currentlySelectedFilePath && 
uiState.currentlyChosenDirectoryPath) {
-        infoHTML = `Selected destination: 
<strong>${uiState.currentlyChosenDirectoryPath}</strong>. Please select a file 
to move.`;
+    const numSelectedFiles = uiState.currentlySelectedFilePaths.size;
+    const destinationDir = uiState.currentlyChosenDirectoryPath;
+    let message = "Please select files and a destination.";
+    let disableConfirmButton = true;
+    currentMoveSelectionInfoElement.innerHTML = '';
+    if (!numSelectedFiles && destinationDir) {
+        
currentMoveSelectionInfoElement.appendChild(document.createTextNode("Selected 
destination: "));
+        const strongDest = document.createElement("strong");
+        strongDest.textContent = destinationDir;
+        currentMoveSelectionInfoElement.appendChild(strongDest);
+        currentMoveSelectionInfoElement.appendChild(document.createTextNode(". 
Please select file(s) to move."));
     }
-    else if (uiState.currentlySelectedFilePath && 
!uiState.currentlyChosenDirectoryPath) {
-        infoHTML = `Moving 
<strong>${uiState.currentlySelectedFilePath}</strong> to (select destination).`;
+    else if (numSelectedFiles && !destinationDir) {
+        
currentMoveSelectionInfoElement.appendChild(document.createTextNode("Moving "));
+        const strongN = document.createElement("strong");
+        strongN.textContent = `${numSelectedFiles} file(s)`;
+        currentMoveSelectionInfoElement.appendChild(strongN);
+        currentMoveSelectionInfoElement.appendChild(document.createTextNode(" 
to (select destination)."));
     }
-    else if (uiState.currentlySelectedFilePath && 
uiState.currentlyChosenDirectoryPath) {
-        infoHTML = `Move <strong>${uiState.currentlySelectedFilePath}</strong> 
to <strong>${uiState.currentlyChosenDirectoryPath}</strong>`;
-        disableConfirm = false;
+    else if (numSelectedFiles && destinationDir) {
+        const filesArray = Array.from(uiState.currentlySelectedFilePaths);
+        const displayFiles = filesArray.length > 1 ? `${filesArray[0]} and 
${filesArray.length - 1} other(s)` : filesArray[0];
+        
currentMoveSelectionInfoElement.appendChild(document.createTextNode("Move "));
+        const strongDisplayFiles = document.createElement("strong");
+        strongDisplayFiles.textContent = displayFiles;
+        currentMoveSelectionInfoElement.appendChild(strongDisplayFiles);
+        currentMoveSelectionInfoElement.appendChild(document.createTextNode(" 
to "));
+        const strongDest = document.createElement("strong");
+        strongDest.textContent = destinationDir;
+        currentMoveSelectionInfoElement.appendChild(strongDest);
+        message = "";
+        disableConfirmButton = false;
     }
-    else {
-        infoHTML = "Please select a file and a destination.";
+    if (message && currentMoveSelectionInfoElement.childNodes.length === 0) {
+        currentMoveSelectionInfoElement.textContent = message;
     }
-    currentMoveSelectionInfoElement.innerHTML = infoHTML;
-    confirmMoveButton.disabled = disableConfirm;
+    confirmMoveButton.disabled = disableConfirmButton;
 }
 function renderListItems(tbodyElement, items, config) {
     const fragment = new DocumentFragment();
     const itemsToShow = items.slice(0, uiState.maxFilesToShow);
     itemsToShow.forEach(item => {
-        const itemPathString = config.itemType === ItemType.Dir && !item ? "." 
: String(item || "");
+        const itemPathString = (config.itemType === ItemType.Dir && !item) ? 
"." : String(item || "");
         const row = document.createElement("tr");
         row.className = "page-table-row-interactive";
-        const buttonCell = row.insertCell();
-        buttonCell.className = "page-table-button-cell text-end";
+        const controlCell = row.insertCell();
+        controlCell.className = "page-table-button-cell text-end";
         const pathCell = row.insertCell();
         pathCell.className = "page-table-path-cell";
         const span = document.createElement("span");
         span.className = "page-file-select-text";
         span.textContent = itemPathString;
-        let isIncompatible = false;
-        if (config.itemType === ItemType.File) {
-            if (uiState.currentlyChosenDirectoryPath && 
getParentPath(itemPathString) === uiState.currentlyChosenDirectoryPath) {
-                isIncompatible = true;
+        switch (config.itemType) {
+            case ItemType.File: {
+                const checkbox = document.createElement("input");
+                checkbox.type = "checkbox";
+                checkbox.className = "form-check-input ms-2";
+                checkbox.dataset.filePath = itemPathString;
+                checkbox.checked = 
uiState.currentlySelectedFilePaths.has(itemPathString);
+                checkbox.setAttribute("aria-label", `Select file 
${itemPathString}`);
+                if (uiState.currentlyChosenDirectoryPath && 
getParentPath(itemPathString) === uiState.currentlyChosenDirectoryPath) {
+                    checkbox.disabled = true;
+                    span.classList.add("page-extra-muted");
+                }
+                controlCell.appendChild(checkbox);
+                break;
             }
-        }
-        else {
-            if (uiState.currentlySelectedFilePath && 
getParentPath(uiState.currentlySelectedFilePath) === itemPathString) {
-                isIncompatible = true;
+            case ItemType.Dir: {
+                const radio = document.createElement("input");
+                radio.type = "radio";
+                radio.name = "target-directory-radio";
+                radio.className = "form-check-input ms-2";
+                radio.value = itemPathString;
+                radio.dataset.dirPath = itemPathString;
+                radio.checked = itemPathString === config.selectedItem;
+                radio.setAttribute("aria-label", `Choose directory 
${itemPathString}`);
+                if (itemPathString === config.selectedItem) {
+                    row.classList.add("page-item-selected");
+                    row.setAttribute("aria-selected", "true");
+                    span.classList.add("fw-bold");
+                }
+                else {
+                    row.setAttribute("aria-selected", "false");
+                }
+                controlCell.appendChild(radio);
+                break;
             }
         }
-        if (isIncompatible) {
-            span.classList.add("page-extra-muted");
-        }
-        if (itemPathString === config.selectedItem) {
-            row.classList.add("page-item-selected");
-            row.setAttribute("aria-selected", "true");
-            span.classList.add("fw-bold");
-            const arrowSpan = document.createElement("span");
-            arrowSpan.className = "text-success fs-1";
-            arrowSpan.textContent = "→";
-            buttonCell.appendChild(arrowSpan);
-        }
-        else {
-            row.setAttribute("aria-selected", "false");
-            const button = document.createElement("button");
-            button.type = "button";
-            button.className = `btn btn-sm ${config.buttonClassBase} 
${config.buttonClassOutline}`;
-            button.dataset[config.itemType === ItemType.File ? "filePath" : 
"dirPath"] = itemPathString;
-            button.textContent = config.buttonTextDefault;
-            buttonCell.appendChild(button);
-        }
         pathCell.appendChild(span);
         fragment.appendChild(row);
     });
@@ -137,7 +175,7 @@ function renderListItems(tbodyElement, items, config) {
     const moreInfoElement = document.getElementById(config.moreInfoId);
     if (moreInfoElement) {
         if (items.length > uiState.maxFilesToShow) {
-            moreInfoElement.textContent = `${items.length - 
uiState.maxFilesToShow} more available (filter to browse)...`;
+            moreInfoElement.textContent = `${items.length - 
uiState.maxFilesToShow} ${TXT.MoreItemsHint}`;
             moreInfoElement.style.display = "block";
         }
         else {
@@ -147,184 +185,241 @@ function renderListItems(tbodyElement, items, config) {
     }
 }
 function renderAllLists() {
-    const lowerFileFilter = toLower(uiState.fileFilter);
-    const filteredFilePaths = uiState.originalFilePaths.filter(fp => 
includesCaseInsensitive(fp, lowerFileFilter));
+    const filteredFilePaths = uiState.originalFilePaths.filter(fp => 
includesCaseInsensitive(fp, uiState.filters.file));
     const filesConfig = {
         itemType: ItemType.File,
-        selectedItem: uiState.currentlySelectedFilePath,
-        buttonClassBase: "select-file-btn",
-        buttonClassOutline: "btn-outline-primary",
-        buttonClassActive: "btn-primary",
-        buttonTextSelected: TXT_SELECTED,
-        buttonTextDefault: TXT_SELECT,
-        moreInfoId: FILE_LIST_MORE_INFO_ID
+        selectedItem: null,
+        moreInfoId: ID.fileListMoreInfo
     };
     renderListItems(fileListTableBody, filteredFilePaths, filesConfig);
-    const lowerDirFilter = toLower(uiState.dirFilter);
-    const filteredDirs = uiState.allTargetDirs.filter(dirP => 
includesCaseInsensitive(dirP, lowerDirFilter));
+    const filteredDirs = uiState.allTargetDirs.filter(dirP => 
includesCaseInsensitive(dirP, uiState.filters.dir));
     const dirsConfig = {
         itemType: ItemType.Dir,
         selectedItem: uiState.currentlyChosenDirectoryPath,
-        buttonClassBase: "choose-dir-btn",
-        buttonClassOutline: "btn-outline-secondary",
-        buttonClassActive: "btn-secondary",
-        buttonTextSelected: TXT_CHOSEN,
-        buttonTextDefault: TXT_CHOOSE,
-        moreInfoId: DIR_LIST_MORE_INFO_ID
+        moreInfoId: ID.dirListMoreInfo
     };
     renderListItems(dirListTableBody, filteredDirs, dirsConfig);
     updateMoveSelectionInfo();
 }
-function handleFileSelection(filePath) {
-    if (uiState.currentlyChosenDirectoryPath) {
-        const parentOfNewFile = getParentPath(filePath);
-        if (parentOfNewFile === uiState.currentlyChosenDirectoryPath) {
-            uiState.currentlyChosenDirectoryPath = null;
-        }
-    }
-    uiState.currentlySelectedFilePath = filePath;
-    renderAllLists();
-}
 function handleDirSelection(dirPath) {
-    if (dirPath && uiState.currentlySelectedFilePath && 
getParentPath(uiState.currentlySelectedFilePath) === dirPath)
-        uiState.currentlySelectedFilePath = null;
     uiState.currentlyChosenDirectoryPath = dirPath;
     renderAllLists();
 }
-function onFileListClick(event) {
-    const targetElement = event.target;
-    const button = targetElement.closest("button.select-file-btn");
-    if (button && !button.disabled) {
-        const filePath = button.dataset.filePath || null;
-        handleFileSelection(filePath);
+function delegate(parent, selector, handler) {
+    parent.addEventListener("click", (e) => {
+        const targetElement = e.target;
+        if (targetElement) {
+            const el = targetElement.closest(selector);
+            if (el instanceof HTMLElement) {
+                handler(el, e);
+            }
+        }
+    });
+}
+function handleFileCheckbox(checkbox) {
+    const filePath = checkbox.dataset.filePath;
+    if (filePath) {
+        if (checkbox.checked) {
+            uiState.currentlySelectedFilePaths.add(filePath);
+        }
+        else {
+            uiState.currentlySelectedFilePaths.delete(filePath);
+        }
+        renderAllLists();
     }
 }
-function onDirListClick(event) {
-    const targetElement = event.target;
-    const button = targetElement.closest("button.choose-dir-btn");
-    if (button && !button.disabled) {
-        const dirPath = button.dataset.dirPath || null;
+function handleDirRadio(radio) {
+    if (radio.checked) {
+        const dirPath = radio.dataset.dirPath || null;
         handleDirSelection(dirPath);
     }
 }
-function onFileFilterInput(event) {
-    uiState.fileFilter = event.target.value;
+function setState(partial) {
+    uiState = Object.assign(Object.assign({}, uiState), partial);
     renderAllLists();
 }
+function onFileFilterInput(event) {
+    const target = event.target;
+    if (target instanceof HTMLInputElement) {
+        setState({ filters: Object.assign(Object.assign({}, uiState.filters), 
{ file: target.value }) });
+    }
+}
 function onDirFilterInput(event) {
-    uiState.dirFilter = event.target.value;
-    renderAllLists();
+    const target = event.target;
+    if (target instanceof HTMLInputElement) {
+        setState({ filters: Object.assign(Object.assign({}, uiState.filters), 
{ dir: target.value }) });
+    }
 }
 function onMaxFilesChange(event) {
-    const newValue = parseInt(event.target.value, 10);
+    const target = event.target;
+    const newValue = parseInt(target.value, 10);
     if (newValue >= 1) {
-        uiState.maxFilesToShow = newValue;
-        renderAllLists();
+        setState({ maxFilesToShow: newValue });
     }
     else {
-        event.target.value = String(uiState.maxFilesToShow);
+        target.value = String(uiState.maxFilesToShow);
     }
 }
-function onConfirmMoveClick() {
+function isErrorResponse(data) {
+    return typeof data === 'object' && data !== null &&
+        (('message' in data && typeof data.message === 'string') ||
+            ('error' in data && typeof data.error === 'string'));
+}
+function moveFiles(files, dest, csrfToken, signal) {
     return __awaiter(this, void 0, void 0, function* () {
-        errorAlert.classList.add("d-none");
-        if (uiState.currentlySelectedFilePath && 
uiState.currentlyChosenDirectoryPath && uiState.csrfToken) {
-            const formData = new FormData();
-            formData.append("csrf_token", uiState.csrfToken);
-            formData.append("source_file", uiState.currentlySelectedFilePath);
-            formData.append("target_directory", 
uiState.currentlyChosenDirectoryPath);
-            try {
-                const response = yield fetch(window.location.pathname, {
-                    method: "POST",
-                    body: formData,
-                    credentials: "same-origin",
-                    headers: {
-                        "Accept": "application/json",
-                    },
-                });
-                if (response.ok) {
-                    window.location.reload();
+        const formData = new FormData();
+        formData.append("csrf_token", csrfToken);
+        for (const file of files) {
+            formData.append("source_files", file);
+        }
+        formData.append("target_directory", dest);
+        try {
+            const response = yield fetch(window.location.pathname, {
+                method: "POST",
+                body: formData,
+                credentials: "same-origin",
+                headers: {
+                    "Accept": "application/json",
+                },
+                signal,
+            });
+            if (response.ok) {
+                return { ok: true };
+            }
+            else {
+                let errorMsg = `An error occurred while moving the file 
(Status: ${response.status})`;
+                if (response.status === 403)
+                    errorMsg = "Permission denied to move the file.";
+                if (response.status === 400)
+                    errorMsg = "Invalid request to move the file.";
+                if (signal === null || signal === void 0 ? void 0 : 
signal.aborted) {
+                    errorMsg = "Move operation aborted.";
                 }
-                else {
-                    let errorMsg = `An error occurred while moving the file 
(Status: ${response.status})`;
-                    if (response.status === 403)
-                        errorMsg = "Permission denied to move the file.";
-                    if (response.status === 400)
-                        errorMsg = "Invalid request to move the file.";
-                    try {
-                        const errorData = yield response.json();
-                        if (errorData && typeof errorData.error === "string")
+                try {
+                    const errorData = yield response.json();
+                    if (isErrorResponse(errorData)) {
+                        if (errorData.message) {
+                            errorMsg = errorData.message;
+                        }
+                        else if (errorData.error) {
                             errorMsg = errorData.error;
+                        }
                     }
-                    catch (_) { }
-                    errorAlert.textContent = errorMsg;
-                    errorAlert.classList.remove("d-none");
-                    return;
                 }
+                catch ( /* Do nothing */_a) { /* Do nothing */ }
+                return { ok: false, message: errorMsg };
+            }
+        }
+        catch (error) {
+            // console.error("Network or fetch error:", error);
+            if (error instanceof Error && error.name === 'AbortError') {
+                return { ok: false, message: "Move operation aborted." };
+            }
+            return { ok: false, message: "A network error occurred. Please 
check your connection and try again." };
+        }
+    });
+}
+function splitMoveCandidates(selected, dest) {
+    const toMoveMutable = [];
+    const alreadyThereMutable = [];
+    for (const filePath of selected) {
+        if (getParentPath(filePath) === dest) {
+            alreadyThereMutable.push(filePath);
+        }
+        else {
+            toMoveMutable.push(filePath);
+        }
+    }
+    return { toMove: toMoveMutable, alreadyThere: alreadyThereMutable };
+}
+function onConfirmMoveClick() {
+    return __awaiter(this, void 0, void 0, function* () {
+        errorAlert.classList.add("d-none");
+        errorAlert.textContent = "";
+        const controller = new AbortController();
+        window.addEventListener("beforeunload", () => controller.abort());
+        if (uiState.currentlySelectedFilePaths.size > 0 && 
uiState.currentlyChosenDirectoryPath && uiState.csrfToken) {
+            const { toMove, alreadyThere: filesAlreadyInDest } = 
splitMoveCandidates(uiState.currentlySelectedFilePaths, 
uiState.currentlyChosenDirectoryPath);
+            if (toMove.length === 0 && filesAlreadyInDest.length > 0 && 
uiState.currentlySelectedFilePaths.size > 0) {
+                errorAlert.classList.remove("d-none");
+                errorAlert.textContent = `All selected files 
(${filesAlreadyInDest.join(", ")}) are already in the target directory. No 
files were moved.`;
+                confirmMoveButton.disabled = false;
+                return;
+            }
+            if (filesAlreadyInDest.length > 0) {
+                const alreadyInDestMsg = `Note: ${filesAlreadyInDest.join(", 
")} ${filesAlreadyInDest.length === 1 ? "is" : "are"} already in the target 
directory and will not be moved.`;
+                const existingError = errorAlert.textContent;
+                errorAlert.textContent = existingError ? `${existingError} 
${alreadyInDestMsg}` : alreadyInDestMsg;
             }
-            catch (error) {
-                console.error("Network or fetch error:", error);
-                errorAlert.textContent = "A network error occurred. Please 
check your connection and try again.";
+            const result = yield moveFiles(toMove, 
uiState.currentlyChosenDirectoryPath, uiState.csrfToken, controller.signal);
+            if (result.ok) {
+                window.location.reload();
+            }
+            else {
                 errorAlert.classList.remove("d-none");
+                errorAlert.textContent = result.message;
             }
         }
         else {
-            errorAlert.textContent = "Please select both a file to move and a 
destination directory.";
             errorAlert.classList.remove("d-none");
+            errorAlert.textContent = "Please select file(s) and a destination 
directory.";
         }
     });
 }
-document.addEventListener("DOMContentLoaded", function () {
-    fileFilterInput = 
assertElementPresent(document.querySelector(`#${FILE_FILTER_ID}`), 
FILE_FILTER_ID);
-    fileListTableBody = 
assertElementPresent(document.querySelector(`#${FILE_LIST_TABLE_BODY_ID}`), 
FILE_LIST_TABLE_BODY_ID);
-    maxFilesInput = 
assertElementPresent(document.querySelector(`#${MAX_FILES_INPUT_ID}`), 
MAX_FILES_INPUT_ID);
-    selectedFileNameTitleElement = 
assertElementPresent(document.getElementById(SELECTED_FILE_NAME_TITLE_ID), 
SELECTED_FILE_NAME_TITLE_ID);
-    dirFilterInput = 
assertElementPresent(document.querySelector(`#${DIR_FILTER_INPUT_ID}`), 
DIR_FILTER_INPUT_ID);
-    dirListTableBody = 
assertElementPresent(document.querySelector(`#${DIR_LIST_TABLE_BODY_ID}`), 
DIR_LIST_TABLE_BODY_ID);
-    confirmMoveButton = 
assertElementPresent(document.querySelector(`#${CONFIRM_MOVE_BUTTON_ID}`), 
CONFIRM_MOVE_BUTTON_ID);
-    currentMoveSelectionInfoElement = 
assertElementPresent(document.getElementById(CURRENT_MOVE_SELECTION_INFO_ID), 
CURRENT_MOVE_SELECTION_INFO_ID);
+document.addEventListener("DOMContentLoaded", () => {
+    var _a, _b, _c, _d;
+    fileFilterInput = $(ID.fileFilter);
+    fileListTableBody = $(ID.fileListTableBody);
+    maxFilesInput = $(ID.maxFilesInput);
+    selectedFileNameTitleElement = $(ID.selectedFileNameTitle);
+    dirFilterInput = $(ID.dirFilterInput);
+    dirListTableBody = $(ID.dirListTableBody);
+    confirmMoveButton = $(ID.confirmMoveButton);
+    currentMoveSelectionInfoElement = $(ID.currentMoveSelectionInfo);
     currentMoveSelectionInfoElement.setAttribute("aria-live", "polite");
-    errorAlert = assertElementPresent(document.getElementById(ERROR_ALERT_ID), 
ERROR_ALERT_ID);
+    errorAlert = $(ID.errorAlert);
     let initialFilePaths = [];
     let initialTargetDirs = [];
     try {
-        const fileDataElement = document.getElementById(FILE_DATA_ID);
-        if (fileDataElement === null || fileDataElement === void 0 ? void 0 : 
fileDataElement.textContent) {
-            initialFilePaths = JSON.parse(fileDataElement.textContent);
-        }
-        const dirDataElement = document.getElementById(DIR_DATA_ID);
-        if (dirDataElement === null || dirDataElement === void 0 ? void 0 : 
dirDataElement.textContent) {
-            initialTargetDirs = JSON.parse(dirDataElement.textContent);
-        }
-    }
-    catch (e) {
-        console.error("Error parsing JSON data:", e);
+        const fileData = (_a = document.getElementById(ID.fileData)) === null 
|| _a === void 0 ? void 0 : _a.textContent;
+        if (fileData)
+            initialFilePaths = JSON.parse(fileData);
+        const dirData = (_b = document.getElementById(ID.dirData)) === null || 
_b === void 0 ? void 0 : _b.textContent;
+        if (dirData)
+            initialTargetDirs = JSON.parse(dirData);
     }
-    if (initialFilePaths.length === 0 && initialTargetDirs.length === 0) {
-        alert("Warning: File and/or directory lists could not be loaded or are 
empty.");
+    catch (_e) {
+        // console.error("Error parsing JSON data:");
     }
-    const mainScriptDataElement = 
document.querySelector(`#${MAIN_SCRIPT_DATA_ID}`);
-    const initialCsrfToken = (mainScriptDataElement === null || 
mainScriptDataElement === void 0 ? void 0 : 
mainScriptDataElement.dataset.csrfToken) || null;
+    const csrfToken = (_d = (_c = document
+        .querySelector(`#${ID.mainScriptData}`)) === null || _c === void 0 ? 
void 0 : _c.dataset.csrfToken) !== null && _d !== void 0 ? _d : null;
     uiState = {
-        fileFilter: fileFilterInput.value || "",
-        dirFilter: dirFilterInput.value || "",
-        maxFilesToShow: parseInt(maxFilesInput.value, 10) || 
MAX_FILES_FALLBACK,
-        currentlySelectedFilePath: null,
+        filters: {
+            file: fileFilterInput.value || "",
+            dir: dirFilterInput.value || "",
+        },
+        maxFilesToShow: Math.max(parseInt(maxFilesInput.value, 10) || 0, 1) || 
MAX_FILES_FALLBACK,
+        currentlySelectedFilePaths: new Set(),
         currentlyChosenDirectoryPath: null,
         originalFilePaths: initialFilePaths,
         allTargetDirs: initialTargetDirs,
-        csrfToken: initialCsrfToken,
+        csrfToken,
     };
-    if (isNaN(uiState.maxFilesToShow) || uiState.maxFilesToShow < 1) {
-        uiState.maxFilesToShow = MAX_FILES_FALLBACK;
-        maxFilesInput.value = String(uiState.maxFilesToShow);
-    }
+    maxFilesInput.value = String(uiState.maxFilesToShow);
     fileFilterInput.addEventListener("input", onFileFilterInput);
     dirFilterInput.addEventListener("input", onDirFilterInput);
     maxFilesInput.addEventListener("change", onMaxFilesChange);
-    fileListTableBody.addEventListener("click", onFileListClick);
-    dirListTableBody.addEventListener("click", onDirListClick);
-    confirmMoveButton.addEventListener("click", onConfirmMoveClick);
+    delegate(fileListTableBody, "input[type='checkbox'][data-file-path]", 
handleFileCheckbox);
+    delegate(dirListTableBody, 
"input[type='radio'][name='target-directory-radio']", handleDirRadio);
+    confirmMoveButton.addEventListener("click", () => {
+        onConfirmMoveClick().catch(_err => {
+            // console.error("Error in onConfirmMoveClick handler:", _err);
+            if (errorAlert) {
+                errorAlert.classList.remove("d-none");
+                errorAlert.textContent = "An unexpected error occurred. Please 
try again.";
+            }
+        });
+    });
     renderAllLists();
 });
 //# sourceMappingURL=finish-selected-move.js.map
diff --git a/atr/static/js/finish-selected-move.js.map 
b/atr/static/js/finish-selected-move.js.map
index 6016ed5..2887f6f 100644
--- a/atr/static/js/finish-selected-move.js.map
+++ b/atr/static/js/finish-selected-move.js.map
@@ -1 +1 @@
-{"version":3,"file":"finish-selected-move.js","sourceRoot":"","sources":["../ts/finish-selected-move.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;;;;;;;;;;AAEb,IAAK,QAGJ;AAHD,WAAK,QAAQ;IACT,yBAAa,CAAA;IACb,uBAAW,CAAA;AACf,CAAC,EAHI,QAAQ,KAAR,QAAQ,QAGZ;AAED,MAAM,sBAAsB,GAAG,qBAAqB,CAAC;AACrD,MAAM,8BAA8B,GAAG,6BAA6B,CAAC;AACrE,MAAM,WAAW,GAAG,UAAU,CAAC;AAC/B,MAAM,mBAAmB,GAAG,kBAAkB,CAAC;AAC/C,MAAM,qBAAqB,GAAG,oBAAoB,CAAC;AACnD,MAAM,sBAAsB,GAAG,qBAAqB,CAAC;AACrD,MAAM,cAAc,GAAG,kBAAkB,CAAC;AAC1
 [...]
+{"version":3,"file":"finish-selected-move.js","sourceRoot":"","sources":["../ts/finish-selected-move.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;;;;;;;;;;AAEb,IAAK,QAGJ;AAHD,WAAK,QAAQ;IACT,yBAAa,CAAA;IACb,uBAAW,CAAA;AACf,CAAC,EAHI,QAAQ,KAAR,QAAQ,QAGZ;AAED,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,CAAC;IACrB,iBAAiB,EAAE,qBAAqB;IACxC,wBAAwB,EAAE,6BAA6B;IACvD,OAAO,EAAE,UAAU;IACnB,cAAc,EAAE,kBAAkB;IAClC,eAAe,EAAE,oBAAoB;IACrC,gBAAgB,EAAE,qBAAqB;IACvC,UAAU,EAAE,kBAAkB;IAC9B,QAAQ,EAAE,WAAW;IACrB,UAAU,EAAE,
 [...]
diff --git a/atr/static/ts/finish-selected-move.ts 
b/atr/static/ts/finish-selected-move.ts
index f54d6fd..c5089d7 100644
--- a/atr/static/ts/finish-selected-move.ts
+++ b/atr/static/ts/finish-selected-move.ts
@@ -5,75 +5,71 @@ enum ItemType {
     Dir = "dir",
 }
 
-const CONFIRM_MOVE_BUTTON_ID = "confirm-move-button";
-const CURRENT_MOVE_SELECTION_INFO_ID = "current-move-selection-info";
-const DIR_DATA_ID = "dir-data";
-const DIR_FILTER_INPUT_ID = "dir-filter-input";
-const DIR_LIST_MORE_INFO_ID = "dir-list-more-info";
-const DIR_LIST_TABLE_BODY_ID = "dir-list-table-body";
-const ERROR_ALERT_ID = "move-error-alert";
-const FILE_DATA_ID = "file-data";
-const FILE_FILTER_ID = "file-filter";
-const FILE_LIST_MORE_INFO_ID = "file-list-more-info";
-const FILE_LIST_TABLE_BODY_ID = "file-list-table-body";
-const MAIN_SCRIPT_DATA_ID = "main-script-data";
-const MAX_FILES_INPUT_ID = "max-files-input";
-const SELECTED_FILE_NAME_TITLE_ID = "selected-file-name-title";
-
-const TXT_CHOOSE = "Choose";
-const TXT_CHOSEN = "Chosen";
-const TXT_SELECT = "Select";
-const TXT_SELECTED = "Selected";
+const ID = Object.freeze({
+    confirmMoveButton: "confirm-move-button",
+    currentMoveSelectionInfo: "current-move-selection-info",
+    dirData: "dir-data",
+    dirFilterInput: "dir-filter-input",
+    dirListMoreInfo: "dir-list-more-info",
+    dirListTableBody: "dir-list-table-body",
+    errorAlert: "move-error-alert",
+    fileData: "file-data",
+    fileFilter: "file-filter",
+    fileListMoreInfo: "file-list-more-info",
+    fileListTableBody: "file-list-table-body",
+    mainScriptData: "main-script-data",
+    maxFilesInput: "max-files-input",
+    selectedFileNameTitle: "selected-file-name-title",
+} as const);
+
+const TXT = Object.freeze({
+    Choose: "Choose",
+    Chosen: "Chosen",
+    Select: "Select",
+    Selected: "Selected",
+    MoreItemsHint: "more available (filter to browse)..."
+} as const);
 
 const MAX_FILES_FALLBACK = 5;
 
-interface ButtonDataset extends DOMStringMap {
-    filePath?: string;
-    dirPath?: string;
-}
-
-type ButtonClickEvent = MouseEvent & {
-    currentTarget: HTMLButtonElement & { dataset: ButtonDataset };
-};
-
-type FilterInputEvent = Event & {
-    target: HTMLInputElement;
-};
-
 interface UIState {
-    fileFilter: string;
-    dirFilter: string;
+    filters: {
+        file: string;
+        dir: string;
+    };
     maxFilesToShow: number;
-    currentlySelectedFilePath: string | null;
+    currentlySelectedFilePaths: Set<string>;
     currentlyChosenDirectoryPath: string | null;
-    originalFilePaths: string[];
-    allTargetDirs: string[];
+    readonly originalFilePaths: readonly string[];
+    readonly allTargetDirs: readonly string[];
     csrfToken: string | null;
 }
 
-interface RenderListDisplayConfig {
-    itemType: ItemType;
-    selectedItem: string | null;
-    buttonClassBase: string;
-    buttonClassOutline: string;
-    buttonClassActive: string;
-    buttonTextSelected: string;
-    buttonTextDefault: string;
-    moreInfoId: string;
-}
+type RenderListDisplayConfig =
+    | { itemType: ItemType.File; selectedItem: null; moreInfoId: string }
+    | { itemType: ItemType.Dir; selectedItem: string | null; moreInfoId: 
string };
 
-let fileFilterInput: HTMLInputElement;
-let fileListTableBody: HTMLTableSectionElement;
-let maxFilesInput: HTMLInputElement;
-let selectedFileNameTitleElement: HTMLElement;
-let dirFilterInput: HTMLInputElement;
-let dirListTableBody: HTMLTableSectionElement;
-let confirmMoveButton: HTMLButtonElement;
-let currentMoveSelectionInfoElement: HTMLElement;
-let errorAlert: HTMLElement;
+let fileFilterInput!: HTMLInputElement;
+let fileListTableBody!: HTMLTableSectionElement;
+let maxFilesInput!: HTMLInputElement;
+let selectedFileNameTitleElement!: HTMLElement;
+let dirFilterInput!: HTMLInputElement;
+let dirListTableBody!: HTMLTableSectionElement;
+let confirmMoveButton!: HTMLButtonElement;
+let currentMoveSelectionInfoElement!: HTMLElement;
+let errorAlert!: HTMLElement;
 
 let uiState: UIState;
 
+function toLower(s: string | null | undefined): string {
+    return (s || "").toLocaleLowerCase();
+}
+
+function includesCaseInsensitive(haystack: string | null | undefined, needle: 
string | null | undefined): boolean {
+    if (haystack === null || haystack === undefined || needle === null || 
needle === undefined) return false;
+    return toLower(haystack).includes(toLower(needle));
+}
+
 function getParentPath(filePathString: string | null | undefined): string {
     if (!filePathString || typeof filePathString !== "string") return ".";
     const lastSlash = filePathString.lastIndexOf("/");
@@ -82,11 +78,6 @@ function getParentPath(filePathString: string | null | 
undefined): string {
     return filePathString.substring(0, lastSlash);
 }
 
-const toLower = (s: string | null | undefined): string => (s || 
"").toLocaleLowerCase();
-
-const includesCaseInsensitive = (haystack: string | null | undefined, 
lowerNeedle: string): boolean =>
-    toLower(haystack).includes(lowerNeedle);
-
 function assertElementPresent<T extends HTMLElement>(element: T | null, 
selector: string): T {
     if (!element) {
         throw new Error(`Required DOM element '${selector}' not found.`);
@@ -94,29 +85,56 @@ function assertElementPresent<T extends 
HTMLElement>(element: T | null, selector
     return element;
 }
 
+function $<T extends HTMLElement = HTMLElement>(id: string): T {
+  return assertElementPresent(document.getElementById(id) as T | null, id);
+}
+
 function updateMoveSelectionInfo(): void {
     if (selectedFileNameTitleElement) {
-        selectedFileNameTitleElement.textContent = 
uiState.currentlySelectedFilePath
-            ? `Select a destination for ${uiState.currentlySelectedFilePath}`
+        selectedFileNameTitleElement.textContent = 
uiState.currentlySelectedFilePaths.size > 0
+            ? `Select a destination for 
${uiState.currentlySelectedFilePaths.size} file(s)`
             : "Select a destination for the file";
     }
 
-    let infoHTML = "";
-    let disableConfirm = true;
+    const numSelectedFiles = uiState.currentlySelectedFilePaths.size;
+    const destinationDir = uiState.currentlyChosenDirectoryPath;
+    let message = "Please select files and a destination.";
+    let disableConfirmButton = true;
+
+    currentMoveSelectionInfoElement.innerHTML = '';
+
+    if (!numSelectedFiles && destinationDir) {
+        
currentMoveSelectionInfoElement.appendChild(document.createTextNode("Selected 
destination: "));
+        const strongDest = document.createElement("strong");
+        strongDest.textContent = destinationDir;
+        currentMoveSelectionInfoElement.appendChild(strongDest);
+        currentMoveSelectionInfoElement.appendChild(document.createTextNode(". 
Please select file(s) to move."));
+    } else if (numSelectedFiles && !destinationDir) {
+        
currentMoveSelectionInfoElement.appendChild(document.createTextNode("Moving "));
+        const strongN = document.createElement("strong");
+        strongN.textContent = `${numSelectedFiles} file(s)`;
+        currentMoveSelectionInfoElement.appendChild(strongN);
+        currentMoveSelectionInfoElement.appendChild(document.createTextNode(" 
to (select destination)."));
+    } else if (numSelectedFiles && destinationDir) {
+        const filesArray = Array.from(uiState.currentlySelectedFilePaths);
+        const displayFiles = filesArray.length > 1 ? `${filesArray[0]} and 
${filesArray.length -1} other(s)` : filesArray[0];
+        
currentMoveSelectionInfoElement.appendChild(document.createTextNode("Move "));
+        const strongDisplayFiles = document.createElement("strong");
+        strongDisplayFiles.textContent = displayFiles;
+        currentMoveSelectionInfoElement.appendChild(strongDisplayFiles);
+        currentMoveSelectionInfoElement.appendChild(document.createTextNode(" 
to "));
+        const strongDest = document.createElement("strong");
+        strongDest.textContent = destinationDir;
+        currentMoveSelectionInfoElement.appendChild(strongDest);
+        message = "";
+        disableConfirmButton = false;
+    }
 
-    if (!uiState.currentlySelectedFilePath && 
uiState.currentlyChosenDirectoryPath) {
-        infoHTML = `Selected destination: 
<strong>${uiState.currentlyChosenDirectoryPath}</strong>. Please select a file 
to move.`;
-    } else if (uiState.currentlySelectedFilePath && 
!uiState.currentlyChosenDirectoryPath) {
-        infoHTML = `Moving 
<strong>${uiState.currentlySelectedFilePath}</strong> to (select destination).`;
-    } else if (uiState.currentlySelectedFilePath && 
uiState.currentlyChosenDirectoryPath) {
-        infoHTML = `Move <strong>${uiState.currentlySelectedFilePath}</strong> 
to <strong>${uiState.currentlyChosenDirectoryPath}</strong>`;
-        disableConfirm = false;
-    } else {
-        infoHTML = "Please select a file and a destination.";
+    if (message && currentMoveSelectionInfoElement.childNodes.length === 0) {
+        currentMoveSelectionInfoElement.textContent = message;
     }
 
-    currentMoveSelectionInfoElement.innerHTML = infoHTML;
-    confirmMoveButton.disabled = disableConfirm;
+    confirmMoveButton.disabled = disableConfirmButton;
 }
 
 function renderListItems(
@@ -128,12 +146,12 @@ function renderListItems(
     const itemsToShow = items.slice(0, uiState.maxFilesToShow);
 
     itemsToShow.forEach(item => {
-        const itemPathString = config.itemType === ItemType.Dir && !item ? "." 
: String(item || "");
+        const itemPathString = (config.itemType === ItemType.Dir && !item) ? 
"." : String(item || "");
         const row = document.createElement("tr");
         row.className = "page-table-row-interactive";
 
-        const buttonCell = row.insertCell();
-        buttonCell.className = "page-table-button-cell text-end";
+        const controlCell = row.insertCell();
+        controlCell.className = "page-table-button-cell text-end";
         const pathCell = row.insertCell();
         pathCell.className = "page-table-path-cell";
 
@@ -141,40 +159,42 @@ function renderListItems(
         span.className = "page-file-select-text";
         span.textContent = itemPathString;
 
-        let isIncompatible = false;
-        if (config.itemType === ItemType.File) {
-            if (uiState.currentlyChosenDirectoryPath && 
getParentPath(itemPathString) === uiState.currentlyChosenDirectoryPath) {
-                isIncompatible = true;
+        switch (config.itemType) {
+            case ItemType.File: {
+                const checkbox = document.createElement("input");
+                checkbox.type = "checkbox";
+                checkbox.className = "form-check-input ms-2";
+                checkbox.dataset.filePath = itemPathString;
+                checkbox.checked = 
uiState.currentlySelectedFilePaths.has(itemPathString);
+                checkbox.setAttribute("aria-label", `Select file 
${itemPathString}`);
+                if (uiState.currentlyChosenDirectoryPath && 
getParentPath(itemPathString) === uiState.currentlyChosenDirectoryPath) {
+                    checkbox.disabled = true;
+                    span.classList.add("page-extra-muted");
+                }
+                controlCell.appendChild(checkbox);
+                break;
             }
-        } else {
-            if (uiState.currentlySelectedFilePath && 
getParentPath(uiState.currentlySelectedFilePath) === itemPathString) {
-                isIncompatible = true;
+            case ItemType.Dir: {
+                const radio = document.createElement("input");
+                radio.type = "radio";
+                radio.name = "target-directory-radio";
+                radio.className = "form-check-input ms-2";
+                radio.value = itemPathString;
+                radio.dataset.dirPath = itemPathString;
+                radio.checked = itemPathString === config.selectedItem;
+                radio.setAttribute("aria-label", `Choose directory 
${itemPathString}`);
+
+                if (itemPathString === config.selectedItem) {
+                    row.classList.add("page-item-selected");
+                    row.setAttribute("aria-selected", "true");
+                    span.classList.add("fw-bold");
+                } else {
+                    row.setAttribute("aria-selected", "false");
+                }
+                controlCell.appendChild(radio);
+                break;
             }
         }
-        if (isIncompatible) {
-            span.classList.add("page-extra-muted");
-        }
-
-        if (itemPathString === config.selectedItem) {
-            row.classList.add("page-item-selected");
-            row.setAttribute("aria-selected", "true");
-            span.classList.add("fw-bold");
-
-            const arrowSpan = document.createElement("span");
-            arrowSpan.className = "text-success fs-1";
-            arrowSpan.textContent = "→";
-            buttonCell.appendChild(arrowSpan);
-        } else {
-            row.setAttribute("aria-selected", "false");
-
-            const button = document.createElement("button") as 
HTMLButtonElement & { dataset: ButtonDataset };
-            button.type = "button";
-            button.className = `btn btn-sm ${config.buttonClassBase} 
${config.buttonClassOutline}`;
-            button.dataset[config.itemType === ItemType.File ? "filePath" : 
"dirPath"] = itemPathString;
-            button.textContent = config.buttonTextDefault;
-
-            buttonCell.appendChild(button);
-        }
 
         pathCell.appendChild(span);
         fragment.appendChild(row);
@@ -182,10 +202,10 @@ function renderListItems(
 
     tbodyElement.replaceChildren(fragment);
 
-    const moreInfoElement = document.getElementById(config.moreInfoId) as 
HTMLElement | null;
+    const moreInfoElement = document.getElementById(config.moreInfoId);
     if (moreInfoElement) {
         if (items.length > uiState.maxFilesToShow) {
-            moreInfoElement.textContent = `${items.length - 
uiState.maxFilesToShow} more available (filter to browse)...`;
+            moreInfoElement.textContent = `${items.length - 
uiState.maxFilesToShow} ${TXT.MoreItemsHint}`;
             moreInfoElement.style.display = "block";
         } else {
             moreInfoElement.textContent = "";
@@ -195,196 +215,286 @@ function renderListItems(
 }
 
 function renderAllLists(): void {
-    const lowerFileFilter = toLower(uiState.fileFilter);
     const filteredFilePaths = uiState.originalFilePaths.filter(fp =>
-        includesCaseInsensitive(fp, lowerFileFilter)
+        includesCaseInsensitive(fp, uiState.filters.file)
     );
     const filesConfig: RenderListDisplayConfig = {
         itemType: ItemType.File,
-        selectedItem: uiState.currentlySelectedFilePath,
-        buttonClassBase: "select-file-btn",
-        buttonClassOutline: "btn-outline-primary",
-        buttonClassActive: "btn-primary",
-        buttonTextSelected: TXT_SELECTED,
-        buttonTextDefault: TXT_SELECT,
-        moreInfoId: FILE_LIST_MORE_INFO_ID
+        selectedItem: null,
+        moreInfoId: ID.fileListMoreInfo
     };
     renderListItems(fileListTableBody, filteredFilePaths, filesConfig);
 
-    const lowerDirFilter = toLower(uiState.dirFilter);
     const filteredDirs = uiState.allTargetDirs.filter(dirP =>
-        includesCaseInsensitive(dirP, lowerDirFilter)
+        includesCaseInsensitive(dirP, uiState.filters.dir)
     );
     const dirsConfig: RenderListDisplayConfig = {
         itemType: ItemType.Dir,
         selectedItem: uiState.currentlyChosenDirectoryPath,
-        buttonClassBase: "choose-dir-btn",
-        buttonClassOutline: "btn-outline-secondary",
-        buttonClassActive: "btn-secondary",
-        buttonTextSelected: TXT_CHOSEN,
-        buttonTextDefault: TXT_CHOOSE,
-        moreInfoId: DIR_LIST_MORE_INFO_ID
+        moreInfoId: ID.dirListMoreInfo
     };
     renderListItems(dirListTableBody, filteredDirs, dirsConfig);
 
     updateMoveSelectionInfo();
 }
 
-function handleFileSelection(filePath: string | null): void {
-    if (uiState.currentlyChosenDirectoryPath) {
-        const parentOfNewFile = getParentPath(filePath);
-        if (parentOfNewFile === uiState.currentlyChosenDirectoryPath) {
-            uiState.currentlyChosenDirectoryPath = null;
-        }
-    }
-    uiState.currentlySelectedFilePath = filePath;
-    renderAllLists();
-}
-
 function handleDirSelection(dirPath: string | null): void {
-    if (dirPath && uiState.currentlySelectedFilePath && 
getParentPath(uiState.currentlySelectedFilePath) === dirPath) 
uiState.currentlySelectedFilePath = null;
     uiState.currentlyChosenDirectoryPath = dirPath;
     renderAllLists();
 }
 
-function onFileListClick(event: Event): void {
-    const targetElement = event.target as HTMLElement;
-    const button = 
targetElement.closest<HTMLButtonElement>("button.select-file-btn");
-    if (button && !button.disabled) {
-        const filePath = button.dataset.filePath || null;
-        handleFileSelection(filePath);
+function delegate<T extends HTMLElement>(
+  parent: HTMLElement,
+  selector: string,
+  handler: (el: T, event: Event) => void,
+): void {
+  parent.addEventListener("click", (e: Event) => {
+    const targetElement = e.target as Element | null;
+    if (targetElement) {
+        const el = targetElement.closest(selector);
+        if (el instanceof HTMLElement) {
+            handler(el as T, e);
+        }
     }
+  });
 }
 
-function onDirListClick(event: Event): void {
-    const targetElement = event.target as HTMLElement;
-    const button = 
targetElement.closest<HTMLButtonElement>("button.choose-dir-btn");
-    if (button && !button.disabled) {
-        const dirPath = button.dataset.dirPath || null;
+function handleFileCheckbox(checkbox: HTMLInputElement): void {
+    const filePath = checkbox.dataset.filePath;
+    if (filePath) {
+        if (checkbox.checked) {
+            uiState.currentlySelectedFilePaths.add(filePath);
+        } else {
+            uiState.currentlySelectedFilePaths.delete(filePath);
+        }
+        renderAllLists();
+    }
+}
+
+function handleDirRadio(radio: HTMLInputElement): void {
+    if (radio.checked) {
+        const dirPath = radio.dataset.dirPath || null;
         handleDirSelection(dirPath);
     }
 }
 
-function onFileFilterInput(event: FilterInputEvent): void {
-    uiState.fileFilter = event.target.value;
-    renderAllLists();
+function setState(partial: Partial<UIState>): void {
+  uiState = {...uiState, ...partial};
+  renderAllLists();
 }
 
-function onDirFilterInput(event: FilterInputEvent): void {
-    uiState.dirFilter = event.target.value;
-    renderAllLists();
+function onFileFilterInput(event: Event): void {
+    const target = event.target;
+    if (target instanceof HTMLInputElement) {
+        setState({filters: { ...uiState.filters, file: target.value }});
+    }
 }
 
-function onMaxFilesChange(event: FilterInputEvent): void {
-    const newValue = parseInt(event.target.value, 10);
-    if (newValue >= 1) {
-        uiState.maxFilesToShow = newValue;
-        renderAllLists();
-    } else {
-        event.target.value = String(uiState.maxFilesToShow);
+function onDirFilterInput(event: Event): void {
+    const target = event.target;
+    if (target instanceof HTMLInputElement) {
+        setState({filters: { ...uiState.filters, dir: target.value }});
     }
 }
 
-async function onConfirmMoveClick(): Promise<void> {
-    errorAlert.classList.add("d-none");
-    if (uiState.currentlySelectedFilePath && 
uiState.currentlyChosenDirectoryPath && uiState.csrfToken) {
-        const formData = new FormData();
-        formData.append("csrf_token", uiState.csrfToken);
-        formData.append("source_file", uiState.currentlySelectedFilePath);
-        formData.append("target_directory", 
uiState.currentlyChosenDirectoryPath);
-
-        try {
-            const response = await fetch(window.location.pathname, {
-                method: "POST",
-                body: formData,
-                credentials: "same-origin",
-                headers: {
-                    "Accept": "application/json",
-                },
-            });
-
-            if (response.ok) {
-                window.location.reload();
-            } else {
-                let errorMsg = `An error occurred while moving the file 
(Status: ${response.status})`;
-                if (response.status === 403) errorMsg = "Permission denied to 
move the file.";
-                if (response.status === 400) errorMsg = "Invalid request to 
move the file.";
-                try {
-                    const errorData = await response.json();
-                    if (errorData && typeof errorData.error === "string") 
errorMsg = errorData.error;
-                } catch (_) { }
-                errorAlert.textContent = errorMsg;
-                errorAlert.classList.remove("d-none");
-                return;
-            }
-        } catch (error) {
-            console.error("Network or fetch error:", error);
-            errorAlert.textContent = "A network error occurred. Please check 
your connection and try again.";
-            errorAlert.classList.remove("d-none");
-        }
+function onMaxFilesChange(event: Event): void {
+    const target = event.target as HTMLInputElement;
+    const newValue = parseInt(target.value, 10);
+    if (newValue >= 1) {
+        setState({maxFilesToShow: newValue});
     } else {
-        errorAlert.textContent = "Please select both a file to move and a 
destination directory.";
-        errorAlert.classList.remove("d-none");
+        target.value = String(uiState.maxFilesToShow);
     }
 }
 
-document.addEventListener("DOMContentLoaded", function () {
-    fileFilterInput = 
assertElementPresent(document.querySelector<HTMLInputElement>(`#${FILE_FILTER_ID}`),
 FILE_FILTER_ID);
-    fileListTableBody = 
assertElementPresent(document.querySelector<HTMLTableSectionElement>(`#${FILE_LIST_TABLE_BODY_ID}`),
 FILE_LIST_TABLE_BODY_ID);
-    maxFilesInput = 
assertElementPresent(document.querySelector<HTMLInputElement>(`#${MAX_FILES_INPUT_ID}`),
 MAX_FILES_INPUT_ID);
-    selectedFileNameTitleElement = 
assertElementPresent(document.getElementById(SELECTED_FILE_NAME_TITLE_ID) as 
HTMLElement, SELECTED_FILE_NAME_TITLE_ID);
-    dirFilterInput = 
assertElementPresent(document.querySelector<HTMLInputElement>(`#${DIR_FILTER_INPUT_ID}`),
 DIR_FILTER_INPUT_ID);
-    dirListTableBody = 
assertElementPresent(document.querySelector<HTMLTableSectionElement>(`#${DIR_LIST_TABLE_BODY_ID}`),
 DIR_LIST_TABLE_BODY_ID);
-    confirmMoveButton = 
assertElementPresent(document.querySelector<HTMLButtonElement>(`#${CONFIRM_MOVE_BUTTON_ID}`),
 CONFIRM_MOVE_BUTTON_ID);
-    currentMoveSelectionInfoElement = 
assertElementPresent(document.getElementById(CURRENT_MOVE_SELECTION_INFO_ID) as 
HTMLElement, CURRENT_MOVE_SELECTION_INFO_ID);
-    currentMoveSelectionInfoElement.setAttribute("aria-live", "polite");
-    errorAlert = assertElementPresent(document.getElementById(ERROR_ALERT_ID) 
as HTMLElement, ERROR_ALERT_ID);
-
-    let initialFilePaths: string[] = [];
-    let initialTargetDirs: string[] = [];
+type Ok = { ok: true };
+type Err = { ok: false; message: string };
+
+type MoveResult = Ok | Err;
+
+interface ErrorResponse {
+    message?: string;
+    error?: string;
+}
+
+function isErrorResponse(data: unknown): data is ErrorResponse {
+    return typeof data === 'object' && data !== null &&
+           (('message' in data && typeof (data as {message: unknown}).message 
=== 'string') ||
+            ('error' in data && typeof (data as {error: unknown}).error === 
'string'));
+}
+
+async function moveFiles(files: readonly string[], dest: string, csrfToken: 
string, signal?: AbortSignal): Promise<MoveResult> {
+    const formData = new FormData();
+    formData.append("csrf_token", csrfToken);
+    for (const file of files) {
+        formData.append("source_files", file);
+    }
+    formData.append("target_directory", dest);
+
     try {
-        const fileDataElement = document.getElementById(FILE_DATA_ID);
-        if (fileDataElement?.textContent) {
-            initialFilePaths = JSON.parse(fileDataElement.textContent);
+        const response = await fetch(window.location.pathname, {
+            method: "POST",
+            body: formData,
+            credentials: "same-origin",
+            headers: {
+                "Accept": "application/json",
+            },
+            signal,
+        });
+
+        if (response.ok) {
+            return { ok: true };
+        } else {
+            let errorMsg = `An error occurred while moving the file (Status: 
${response.status})`;
+            if (response.status === 403) errorMsg = "Permission denied to move 
the file.";
+            if (response.status === 400) errorMsg = "Invalid request to move 
the file.";
+            if (signal?.aborted) {
+                errorMsg = "Move operation aborted.";
+            }
+            try {
+                const errorData: unknown = await response.json();
+                if (isErrorResponse(errorData)) {
+                    if (errorData.message) {
+                        errorMsg = errorData.message;
+                    } else if (errorData.error) {
+                        errorMsg = errorData.error;
+                    }
+                }
+            } catch { /* Do nothing */ }
+            return { ok: false, message: errorMsg };
         }
-        const dirDataElement = document.getElementById(DIR_DATA_ID);
-        if (dirDataElement?.textContent) {
-            initialTargetDirs = JSON.parse(dirDataElement.textContent);
+    } catch (error: unknown) {
+        // console.error("Network or fetch error:", error);
+        if (error instanceof Error && error.name === 'AbortError') {
+            return { ok: false, message: "Move operation aborted." };
         }
-    } catch (e) {
-        console.error("Error parsing JSON data:", e);
+        return { ok: false, message: "A network error occurred. Please check 
your connection and try again." };
     }
+}
 
-    if (initialFilePaths.length === 0 && initialTargetDirs.length === 0) {
-        alert("Warning: File and/or directory lists could not be loaded or are 
empty.");
+function splitMoveCandidates(
+  selected: Iterable<string>,
+  dest: string,
+): { readonly toMove: readonly string[]; readonly alreadyThere: readonly 
string[] } {
+    const toMoveMutable: string[] = [];
+    const alreadyThereMutable: string[] = [];
+    for (const filePath of selected) {
+        if (getParentPath(filePath) === dest) {
+            alreadyThereMutable.push(filePath);
+        } else {
+            toMoveMutable.push(filePath);
+        }
     }
+    return { toMove: toMoveMutable, alreadyThere: alreadyThereMutable };
+}
 
-    const mainScriptDataElement = document.querySelector<HTMLElement & { 
dataset: { csrfToken?: string } }>(`#${MAIN_SCRIPT_DATA_ID}`);
-    const initialCsrfToken = mainScriptDataElement?.dataset.csrfToken || null;
-
-    uiState = {
-        fileFilter: fileFilterInput.value || "",
-        dirFilter: dirFilterInput.value || "",
-        maxFilesToShow: parseInt(maxFilesInput.value, 10) || 
MAX_FILES_FALLBACK,
-        currentlySelectedFilePath: null,
-        currentlyChosenDirectoryPath: null,
-        originalFilePaths: initialFilePaths,
-        allTargetDirs: initialTargetDirs,
-        csrfToken: initialCsrfToken,
-    };
-    if (isNaN(uiState.maxFilesToShow) || uiState.maxFilesToShow < 1) {
-        uiState.maxFilesToShow = MAX_FILES_FALLBACK;
-        maxFilesInput.value = String(uiState.maxFilesToShow);
-    }
+async function onConfirmMoveClick(): Promise<void> {
+    errorAlert.classList.add("d-none");
+    errorAlert.textContent = "";
 
-    fileFilterInput.addEventListener("input", onFileFilterInput as 
EventListener);
-    dirFilterInput.addEventListener("input", onDirFilterInput as 
EventListener);
-    maxFilesInput.addEventListener("change", onMaxFilesChange as 
EventListener);
+    const controller = new AbortController();
+    window.addEventListener("beforeunload", () => controller.abort());
 
-    fileListTableBody.addEventListener("click", onFileListClick);
-    dirListTableBody.addEventListener("click", onDirListClick);
+    if (uiState.currentlySelectedFilePaths.size > 0 && 
uiState.currentlyChosenDirectoryPath && uiState.csrfToken) {
+        const { toMove, alreadyThere: filesAlreadyInDest } = 
splitMoveCandidates(
+            uiState.currentlySelectedFilePaths,
+            uiState.currentlyChosenDirectoryPath
+        );
 
-    confirmMoveButton.addEventListener("click", onConfirmMoveClick);
+        if (toMove.length === 0 && filesAlreadyInDest.length > 0 && 
uiState.currentlySelectedFilePaths.size > 0) {
+            errorAlert.classList.remove("d-none");
+            errorAlert.textContent = `All selected files 
(${filesAlreadyInDest.join(", ")}) are already in the target directory. No 
files were moved.`;
+            confirmMoveButton.disabled = false;
+            return;
+        }
 
-    renderAllLists();
+        if (filesAlreadyInDest.length > 0) {
+            const alreadyInDestMsg = `Note: ${filesAlreadyInDest.join(", ")} 
${filesAlreadyInDest.length === 1 ? "is" : "are"} already in the target 
directory and will not be moved.`;
+            const existingError = errorAlert.textContent;
+            errorAlert.textContent = existingError ? `${existingError} 
${alreadyInDestMsg}` : alreadyInDestMsg;
+        }
+
+        const result = await moveFiles(toMove, 
uiState.currentlyChosenDirectoryPath, uiState.csrfToken, controller.signal);
+
+        if (result.ok) {
+            window.location.reload();
+        } else {
+            errorAlert.classList.remove("d-none");
+            errorAlert.textContent = result.message;
+        }
+    } else {
+        errorAlert.classList.remove("d-none");
+        errorAlert.textContent = "Please select file(s) and a destination 
directory.";
+    }
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+  fileFilterInput = $<HTMLInputElement>(ID.fileFilter);
+  fileListTableBody = $<HTMLTableSectionElement>(ID.fileListTableBody);
+  maxFilesInput = $<HTMLInputElement>(ID.maxFilesInput);
+  selectedFileNameTitleElement = $(ID.selectedFileNameTitle);
+  dirFilterInput = $<HTMLInputElement>(ID.dirFilterInput);
+  dirListTableBody = $<HTMLTableSectionElement>(ID.dirListTableBody);
+  confirmMoveButton = $<HTMLButtonElement>(ID.confirmMoveButton);
+  currentMoveSelectionInfoElement = $(ID.currentMoveSelectionInfo);
+  currentMoveSelectionInfoElement.setAttribute("aria-live", "polite");
+  errorAlert = $(ID.errorAlert);
+
+  let initialFilePaths: string[] = [];
+  let initialTargetDirs: string[] = [];
+  try {
+    const fileData = document.getElementById(ID.fileData)?.textContent;
+    if (fileData) initialFilePaths = JSON.parse(fileData) as string[];
+    const dirData  = document.getElementById(ID.dirData)?.textContent;
+    if (dirData)  initialTargetDirs = JSON.parse(dirData) as string[];
+  } catch {
+    // console.error("Error parsing JSON data:");
+  }
+
+  const csrfToken =
+    document
+      .querySelector<HTMLElement & { dataset: { csrfToken?: string } }>(
+        `#${ID.mainScriptData}`,
+      )?.dataset.csrfToken ?? null;
+
+  uiState = {
+    filters: {
+      file: fileFilterInput.value || "",
+      dir:  dirFilterInput.value  || "",
+    },
+    maxFilesToShow:
+      Math.max(parseInt(maxFilesInput.value, 10) || 0, 1) || 
MAX_FILES_FALLBACK,
+    currentlySelectedFilePaths: new Set(),
+    currentlyChosenDirectoryPath: null,
+    originalFilePaths: initialFilePaths,
+    allTargetDirs: initialTargetDirs,
+    csrfToken,
+  };
+  maxFilesInput.value = String(uiState.maxFilesToShow);
+
+  fileFilterInput.addEventListener("input", onFileFilterInput as 
EventListener);
+  dirFilterInput.addEventListener("input", onDirFilterInput as EventListener);
+  maxFilesInput.addEventListener("change", onMaxFilesChange as EventListener);
+
+  delegate<HTMLInputElement>(
+    fileListTableBody,
+    "input[type='checkbox'][data-file-path]",
+    handleFileCheckbox,
+  );
+  delegate<HTMLInputElement>(
+    dirListTableBody,
+    "input[type='radio'][name='target-directory-radio']",
+    handleDirRadio,
+  );
+  confirmMoveButton.addEventListener("click", () => {
+    onConfirmMoveClick().catch(_err => {
+        // console.error("Error in onConfirmMoveClick handler:", _err);
+        if (errorAlert) {
+            errorAlert.classList.remove("d-none");
+            errorAlert.textContent = "An unexpected error occurred. Please try 
again.";
+        }
+    });
+  });
+
+  renderAllLists();
 });
diff --git a/atr/templates/finish-selected.html 
b/atr/templates/finish-selected.html
index e1c4e58..c330b29 100644
--- a/atr/templates/finish-selected.html
+++ b/atr/templates/finish-selected.html
@@ -108,7 +108,7 @@
   </div>
 
   {% if can_move %}
-    <h2>Move a file to a different directory</h2>
+    <h2>Move files to a different directory</h2>
     <p>
       Note that files with associated metadata (e.g. <code>.asc</code> or 
<code>.sha512</code> files) are treated as a single unit and will be moved 
together if any one of them is selected for movement.
     </p>
@@ -121,7 +121,7 @@
         <div class="col-lg-6">
           <div class="card mb-4">
             <div class="card-header bg-light">
-              <h3 class="mb-0">Select a file to move</h3>
+              <h3 class="mb-0">Select files to move</h3>
             </div>
             <div class="card-body">
               <input type="text"
@@ -168,7 +168,7 @@
                  min="1" />
         </div>
         <div id="current-move-selection-info" class="text-muted">Please select 
a file and a destination.</div>
-        <button type="button" id="confirm-move-button" class="btn btn-success 
mt-2">Move file to selected directory</button>
+        <button type="button" id="confirm-move-button" class="btn btn-success 
mt-2">Move to selected directory</button>
       </div>
     </form>
   {% else %}
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000..a72c229
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,37 @@
+// eslint.config.mjs
+import eslint from "@eslint/js";
+import tseslint from "typescript-eslint";
+import globals from "globals";
+
+export default tseslint.config(
+  {
+    ignores: [
+      "atr/static/js/**/*.js",
+      "node_modules/",
+      "dist/",
+      "build/",
+      "coverage/",
+      ".venv/",
+      "eslint.config.mjs"
+    ]
+  },
+  eslint.configs.recommended,
+  ...tseslint.configs.recommended,
+  ...tseslint.configs.recommendedTypeChecked,
+  {
+    languageOptions: {
+      parserOptions: { project: true, tsconfigRootDir: import.meta.dirname },
+      globals: { ...globals.browser }
+    },
+    rules: {
+      "no-console": "warn",
+      "no-debugger": "warn",
+      "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" 
}],
+      "@typescript-eslint/no-explicit-any": "warn",
+      "@typescript-eslint/explicit-function-return-type": "error",
+      "@typescript-eslint/explicit-module-boundary-types": "error",
+      "@typescript-eslint/no-floating-promises": "error",
+      "@typescript-eslint/consistent-type-imports": "error"
+    }
+  }
+);
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..8db2c6d
--- /dev/null
+++ b/package.json
@@ -0,0 +1,11 @@
+{
+  "scripts": {
+    "lint": "eslint . --ext .js,.jsx,.ts,.tsx"
+  },
+  "devDependencies": {
+    "eslint": "^9.0.0",
+    "globals": "^15.0.0",
+    "typescript": "^5.0.0",
+    "typescript-eslint": "latest"
+  }
+}
diff --git a/pyproject.toml b/pyproject.toml
index bfec183..cf1a6bf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -98,6 +98,9 @@ executionEnvironments = [
 
 [tool.ruff]
 line-length = 120
+extend-exclude = [
+    "node_modules",
+]
 
 [tool.ruff.lint]
 ignore = []
@@ -123,7 +126,7 @@ select = [
 
 [tool.mypy]
 python_version = "3.13"
-exclude = ["tests"]
+exclude = ["node_modules", "tests"]
 plugins = ["pydantic.mypy"]
 mypy_path = "typestubs"
 check_untyped_defs = false
diff --git a/tsconfig.json b/tsconfig.json
index 29c000c..a1c45e2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,20 +1,15 @@
 {
   "compilerOptions": {
-    "target": "es6",
+    "target": "ES6",
     "module": "commonjs",
-    "outDir": "./atr/static/js",
     "rootDir": "./atr/static/ts",
+    "outDir": "./atr/static/js",
     "strict": true,
     "esModuleInterop": true,
     "skipLibCheck": true,
     "forceConsistentCasingInFileNames": true,
-    "resolveJsonModule": true,
     "sourceMap": true
   },
-  "include": [
-    "./atr/static/ts/**/*.ts"
-  ],
-  "exclude": [
-    "./atr/static/js"
-  ]
+  "include": ["./atr/static/ts/**/*.ts"],
+  "exclude": ["./atr/static/js", "node_modules"]
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to