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 daaa3eb  Redesign the file movement interface, and refactor path 
enumeration
daaa3eb is described below

commit daaa3ebcf2e9284deb8925f1117baa89d824da2f
Author: Sean B. Palmer <[email protected]>
AuthorDate: Sun May 18 10:57:26 2025 +0100

    Redesign the file movement interface, and refactor path enumeration
---
 atr/routes/finish.py                  |  35 +++-
 atr/templates/finish-selected.html    | 363 ++++++++++++++++++++++++++++++++--
 atr/templates/revisions-selected.html |   2 +-
 atr/util.py                           |  51 +++--
 4 files changed, 411 insertions(+), 40 deletions(-)

diff --git a/atr/routes/finish.py b/atr/routes/finish.py
index 1f27269..151863e 100644
--- a/atr/routes/finish.py
+++ b/atr/routes/finish.py
@@ -64,13 +64,24 @@ async def selected(session: routes.CommitterSession, 
project_name: str, version_
         user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
 
     latest_revision_dir = util.release_directory(release)
-    file_paths_rel: list[pathlib.Path] = []
-    unique_dirs: set[pathlib.Path] = {pathlib.Path(".")}
+    source_files_rel: list[pathlib.Path] = []
+    target_dirs: set[pathlib.Path] = {pathlib.Path(".")}
 
     try:
-        async for path in util.paths_recursive(latest_revision_dir):
-            file_paths_rel.append(path)
-            unique_dirs.add(path.parent)
+        async for item_rel_path in 
util.paths_recursive_all(latest_revision_dir):
+            current_parent = item_rel_path.parent
+            while True:
+                target_dirs.add(current_parent)
+                if current_parent == pathlib.Path("."):
+                    break
+                current_parent = current_parent.parent
+
+            item_abs_path = latest_revision_dir / item_rel_path
+            if await aiofiles.os.path.isfile(item_abs_path):
+                source_files_rel.append(item_rel_path)
+            elif await aiofiles.os.path.isdir(item_abs_path):
+                target_dirs.add(item_rel_path)
+
     except FileNotFoundError:
         await quart.flash("Preview revision directory not found.", "error")
         return await session.redirect(root.index)
@@ -78,9 +89,9 @@ async def selected(session: routes.CommitterSession, 
project_name: str, version_
     form = await MoveFileForm.create_form(data=await quart.request.form if 
(quart.request.method == "POST") else None)
 
     # Populate choices dynamically for both GET and POST
-    form.source_file.choices = sorted([(str(p), str(p)) for p in 
file_paths_rel])
-    form.target_directory.choices = sorted([(str(d), str(d)) for d in 
unique_dirs])
-    can_move = len(unique_dirs) > 1
+    form.source_file.choices = sorted([(str(p), str(p)) for p in 
source_files_rel])
+    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 (quart.request.method == "POST") and can_move:
         match r := await _move_file(form, session, project_name, version_name):
@@ -89,15 +100,21 @@ async def selected(session: routes.CommitterSession, 
project_name: str, version_
             case response.Response():
                 return r
 
+    # resp = await quart.current_app.make_response(template_rendered)
+    # resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+    # resp.headers["Pragma"] = "no-cache"
+    # resp.headers["Expires"] = "0"
+    # return resp
     return await quart.render_template(
         "finish-selected.html",
         asf_id=session.uid,
         server_domain=session.app_host,
         release=release,
-        file_paths=sorted(file_paths_rel),
+        source_files=sorted(source_files_rel),
         form=form,
         can_move=can_move,
         user_ssh_keys=user_ssh_keys,
+        target_dirs=sorted(list(target_dirs)),
     )
 
 
diff --git a/atr/templates/finish-selected.html 
b/atr/templates/finish-selected.html
index d6a7683..64c9809 100644
--- a/atr/templates/finish-selected.html
+++ b/atr/templates/finish-selected.html
@@ -8,6 +8,31 @@
   Finish {{ release.project.display_name }} {{ release.version }} as a release 
preview.
 {% endblock description %}
 
+{% block stylesheets %}
+  {{ super() }}
+  <style>
+      .page-file-select-text {
+          vertical-align: middle;
+          margin-left: 8px;
+      }
+
+      .page-table-button-cell {
+          width: 1%;
+          white-space: nowrap;
+          vertical-align: middle;
+      }
+
+      .page-table-path-cell {
+          vertical-align: middle;
+      }
+
+      .page-item-selected td {
+          background-color: #e9ecef;
+          font-weight: 500;
+      }
+  </style>
+{% endblock stylesheets %}
+
 {% block content %}
   <p class="d-flex justify-content-between align-items-center">
     <a href="{{ as_url(routes.root.index) }}" class="atr-back-link">← Back to 
Select a release</a>
@@ -75,28 +100,62 @@
   </div>
 
   {% if can_move %}
-    <div class="card mb-4">
-      <div class="card-header bg-light">
-        <h3 class="mb-0">Move file to different directory</h3>
-      </div>
-      <div class="card-body">
-        <form method="post" class="atr-canary">
-          {{ form.hidden_tag() }}
-
-          <div class="mb-3">
-            {{ forms.label(form.source_file) }}
-            {{ forms.widget(form.source_file, classes="form-select 
form-select-sm font-monospace") }}
-            {{ forms.errors(form.source_file, classes="invalid-feedback 
d-block") }}
+    <h2>Move a file to a different directory</h2>
+    <form class="atr-canary">
+      <div class="row">
+        <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>
+            </div>
+            <div class="card-body">
+              <input type="text"
+                     id="file-filter"
+                     class="form-control mb-2"
+                     placeholder="Search for a file to move..." />
+              <table class="table table-sm table-striped border mt-3">
+                <tbody id="file-list-table-body">
+                </tbody>
+              </table>
+              <div id="file-list-more-info" class="text-muted small 
mt-1"></div>
+            </div>
           </div>
-          <div class="mb-3">
-            {{ forms.label(form.target_directory) }}
-            {{ forms.widget(form.target_directory, classes="form-select 
form-select-sm font-monospace") }}
-            {{ forms.errors(form.target_directory, classes="invalid-feedback 
d-block") }}
+        </div>
+        <div class="col-lg-6">
+          <div class="card mb-4">
+            <div class="card-header bg-light">
+              <h3 class="mb-0">
+                <span id="selected-file-name-title">Select a destination for 
the file</span>
+              </h3>
+            </div>
+            <div class="card-body">
+              <input type="text"
+                     id="dir-filter-input"
+                     class="form-control mb-2"
+                     placeholder="Search for a directory to move to..." />
+              <table class="table table-sm table-striped border mt-3">
+                <tbody id="dir-list-table-body">
+                </tbody>
+              </table>
+              <div id="dir-list-more-info" class="text-muted small mt-1"></div>
+            </div>
           </div>
-          {{ form.submit(class="btn btn-primary btn-sm") }}
-        </form>
+        </div>
       </div>
-    </div>
+
+      <div>
+        <div class="mb-3">
+          <label for="maxFilesInput" class="form-label">Items to show per 
list:</label>
+          <input type="number"
+                 class="form-control form-control-sm w-25"
+                 id="max-files-input"
+                 value="5"
+                 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>
+      </div>
+    </form>
   {% else %}
     <div class="alert alert-info" role="alert">
       File moving is disabled as all files are currently in the same directory 
or the revision is empty.
@@ -106,4 +165,270 @@
 
 {% block javascripts %}
   {{ super() }}
+  {# If we don't turn the linter off, it breaks the Jinja2 variables #}
+  {# djlint:off #}
+  <script id="file-data" type="application/json">
+      {{ source_files | tojson | safe }}
+</script>
+  <script id="dir-data" type="application/json">
+      {{ target_dirs | tojson | safe }}
+</script>
+  {# djlint:on #}
+  <script id="main-script-data" data-csrf-token="{{ 
form.csrf_token.current_token }}">
+      document.addEventListener("DOMContentLoaded", function() {
+          const fileFilterInput = document.getElementById("file-filter");
+          const fileListTableBody = 
document.getElementById("file-list-table-body");
+
+          let originalFilePaths = [];
+          let allTargetDirs = [];
+
+          try {
+              const fileDataElement = document.getElementById("file-data");
+              if (fileDataElement) {
+                  originalFilePaths = JSON.parse(fileDataElement.textContent 
|| "[]");
+              }
+              const dirDataElement = document.getElementById("dir-data");
+              if (dirDataElement) {
+                  allTargetDirs = JSON.parse(dirDataElement.textContent || 
"[]");
+              }
+          } catch (e) {
+              console.error("Error parsing JSON data:", e);
+              originalFilePaths = [];
+              allTargetDirs = [];
+          }
+
+          let maxFilesToShow = 5;
+          const maxFilesInput = document.getElementById("max-files-input");
+          if (maxFilesInput) {
+              maxFilesToShow = parseInt(maxFilesInput.value, 10);
+              maxFilesInput.addEventListener("change", function(event) {
+                  const newValue = parseInt(event.target.value, 10);
+                  if (newValue >= 1) {
+                      maxFilesToShow = newValue;
+                      const currentFileFilter = 
fileFilterInput.value.toLowerCase();
+                      const currentDirFilter = 
dirFilterInput.value.toLowerCase();
+                      renderFilesTable(originalFilePaths.filter(fp => 
String(fp || "").toLowerCase().includes(currentFileFilter)));
+                      renderDirsTable(allTargetDirs.filter(dirP => String(dirP 
|| "").toLowerCase().includes(currentDirFilter)));
+                  } else {
+                      event.target.value = maxFilesToShow;
+                  }
+              });
+          }
+
+          let currentlySelectedFilePath = null;
+          let currentlyChosenDirectoryPath = null;
+
+          const selectedFileNameTitleElement = 
document.getElementById("selected-file-name-title");
+          const dirFilterInput = document.getElementById("dir-filter-input");
+          const dirListTableBody = 
document.getElementById("dir-list-table-body");
+          const confirmMoveButton = 
document.getElementById("confirm-move-button");
+          const currentMoveSelectionInfoElement = 
document.getElementById("current-move-selection-info");
+
+          function getParentPath(filePathString) {
+              if (!filePathString || typeof filePathString !== "string") 
return ".";
+              const lastSlash = filePathString.lastIndexOf("/");
+              if (lastSlash === -1) return ".";
+              if (lastSlash === 0) return "/";
+              return filePathString.substring(0, lastSlash);
+          }
+
+          function updateMoveSelectionInfo() {
+              if (!currentMoveSelectionInfoElement) return;
+
+              if (selectedFileNameTitleElement) {
+                  if (currentlySelectedFilePath) {
+                      selectedFileNameTitleElement.textContent = `Select a 
destination for ${currentlySelectedFilePath}`;
+                  } else {
+                      selectedFileNameTitleElement.textContent = "Select a 
destination for the file";
+                  }
+              }
+
+              if ((!currentlySelectedFilePath) && 
currentlyChosenDirectoryPath) {
+                  currentMoveSelectionInfoElement.textContent = `Selected 
destination: ${currentlyChosenDirectoryPath}. Please select a file to move.`;
+                  confirmMoveButton.disabled = true;
+              } else if (currentlySelectedFilePath && 
(!currentlyChosenDirectoryPath)) {
+                  currentMoveSelectionInfoElement.textContent = `Moving: 
${currentlySelectedFilePath} to (select destination).`;
+                  confirmMoveButton.disabled = true;
+              } else if (currentlySelectedFilePath && 
currentlyChosenDirectoryPath) {
+                  currentMoveSelectionInfoElement.textContent = `Move: 
${currentlySelectedFilePath} to ${currentlyChosenDirectoryPath}`;
+                  confirmMoveButton.disabled = false;
+              } else {
+                  currentMoveSelectionInfoElement.textContent = "Please select 
a file and a destination.";
+                  confirmMoveButton.disabled = true;
+              }
+          }
+
+          function renderList(tbodyElement, items, config) {
+              tbodyElement.innerHTML = "";
+
+              items.slice(0, maxFilesToShow).forEach(item => {
+                  const row = tbodyElement.insertRow();
+                  const itemPathString = config.itemType === "dir" ? 
String(item || ".") : String(item);
+
+                  const buttonCell = row.insertCell();
+                  buttonCell.classList.add("page-table-button-cell");
+                  const pathCell = row.insertCell();
+                  pathCell.classList.add("page-table-path-cell");
+
+                  const button = document.createElement("button");
+                  button.type = "button";
+                  button.className = `btn btn-sm m-1 ${config.buttonClassBase} 
${config.buttonClassOutline}`;
+                  button.dataset[config.itemType === "file" ? "filePath" : 
"dirPath"] = itemPathString;
+
+                  if (itemPathString === config.selectedItem) {
+                      row.classList.add("page-item-selected");
+                      button.textContent = config.buttonTextSelected;
+                      button.classList.remove(config.buttonClassOutline);
+                      button.classList.add(config.buttonClassActive);
+                  } else {
+                      button.textContent = config.buttonTextDefault;
+                  }
+
+                  if (config.disableCondition(itemPathString, 
currentlySelectedFilePath, currentlyChosenDirectoryPath, getParentPath)) {
+                      button.disabled = true;
+                  }
+
+                  button.addEventListener("click", config.eventHandler);
+
+                  const span = document.createElement("span");
+                  span.className = "page-file-select-text";
+                  span.textContent = itemPathString;
+
+                  buttonCell.appendChild(button);
+                  pathCell.appendChild(span);
+              });
+
+              const moreInfoElement = 
document.getElementById(config.moreInfoId);
+              if (moreInfoElement) {
+                  if (items.length > maxFilesToShow) {
+                      moreInfoElement.textContent = `${items.length - 
maxFilesToShow} more available (filter to browse)...`;
+                      moreInfoElement.style.display = "block";
+                  } else {
+                      moreInfoElement.textContent = "";
+                      moreInfoElement.style.display = "none";
+                  }
+              }
+          }
+
+          function renderDirsTable(dirsToShow) {
+              const dirsConfig = {
+                  itemType: "dir",
+                  selectedItem: currentlyChosenDirectoryPath,
+                  buttonClassBase: "choose-dir-btn",
+                  buttonClassOutline: "btn-outline-secondary",
+                  buttonClassActive: "btn-secondary",
+                  buttonTextSelected: "Chosen",
+                  buttonTextDefault: "Choose",
+                  eventHandler: handleDirChooseClick,
+                  moreInfoId: "dir-list-more-info",
+                  disableCondition: (itemPath, selectedFile, _chosenDir, 
getParent) => selectedFile && (getParent(selectedFile) === itemPath)
+              };
+              renderList(dirListTableBody, dirsToShow, dirsConfig);
+          }
+
+          function handleDirChooseClick(event) {
+              currentlyChosenDirectoryPath = event.target.dataset.dirPath;
+              const filterText = dirFilterInput.value.toLowerCase();
+              const filteredDirs = allTargetDirs.filter(dirP => String(dirP || 
"").toLowerCase().includes(filterText));
+              renderDirsTable(filteredDirs);
+
+              const fileFilterText = fileFilterInput.value.toLowerCase();
+              const filteredFilePaths = originalFilePaths.filter(fp => 
String(fp || "").toLowerCase().includes(fileFilterText));
+              renderFilesTable(filteredFilePaths);
+
+              updateMoveSelectionInfo();
+          }
+
+          function handleFileSelectButtonClick(event) {
+              const newlySelectedFilePath = event.target.dataset.filePath;
+
+              if (currentlyChosenDirectoryPath) {
+                  const parentOfNewFile = getParentPath(newlySelectedFilePath);
+                  if (parentOfNewFile === currentlyChosenDirectoryPath) {
+                      currentlyChosenDirectoryPath = null;
+                  }
+              }
+
+              currentlySelectedFilePath = newlySelectedFilePath;
+
+              const fileFilterText = fileFilterInput.value.toLowerCase();
+              const filteredFilePaths = originalFilePaths.filter(fp => 
String(fp || "").toLowerCase().includes(fileFilterText));
+              renderFilesTable(filteredFilePaths);
+
+              const dirFilterText = dirFilterInput.value.toLowerCase();
+              const filteredDirs = allTargetDirs.filter(dirP => String(dirP || 
"").toLowerCase().includes(dirFilterText));
+              renderDirsTable(filteredDirs);
+
+              updateMoveSelectionInfo();
+          }
+
+          function renderFilesTable(pathsToShow) {
+              const filesConfig = {
+                  itemType: "file",
+                  selectedItem: currentlySelectedFilePath,
+                  buttonClassBase: "select-file-btn",
+                  buttonClassOutline: "btn-outline-primary",
+                  buttonClassActive: "btn-primary",
+                  buttonTextSelected: "Selected",
+                  buttonTextDefault: "Select",
+                  eventHandler: handleFileSelectButtonClick,
+                  moreInfoId: "file-list-more-info",
+                  disableCondition: (itemPath, _selectedFile, chosenDir, 
getParent) => chosenDir && (getParent(itemPath) === chosenDir)
+              };
+              renderList(fileListTableBody, pathsToShow, filesConfig);
+          }
+
+          if (dirFilterInput) {
+              dirFilterInput.addEventListener("input", function() {
+                  const filterText = dirFilterInput.value.toLowerCase();
+                  const filteredDirs = allTargetDirs.filter(dirPath => {
+                      return String(dirPath || 
"").toLowerCase().includes(filterText);
+                  });
+                  renderDirsTable(filteredDirs);
+              });
+          }
+
+          fileFilterInput.addEventListener("input", function() {
+              const filterText = fileFilterInput.value.toLowerCase();
+              const filteredPaths = originalFilePaths.filter(filePath => {
+                  return String(filePath || 
"").toLowerCase().includes(filterText);
+              });
+              renderFilesTable(filteredPaths);
+          });
+
+          renderFilesTable(originalFilePaths);
+          renderDirsTable(allTargetDirs);
+
+          if (confirmMoveButton) {
+              confirmMoveButton.addEventListener("click", function() {
+                  if (currentlySelectedFilePath && 
currentlyChosenDirectoryPath) {
+                      const formData = new FormData();
+                      const mainScriptElement = 
document.getElementById("main-script-data");
+                      const csrfToken = mainScriptElement.dataset.csrfToken;
+                      formData.append("csrf_token", csrfToken);
+                      formData.append("source_file", 
currentlySelectedFilePath);
+                      formData.append("target_directory", 
currentlyChosenDirectoryPath);
+                      fetch(window.location.pathname, {
+                              method: "POST",
+                              body: formData,
+                          })
+                          .then(response => {
+                              if (response.ok) {
+                                  window.location.reload();
+                              } else {
+                                  alert("An error occurred while moving the 
file.");
+                              }
+                          })
+                          .catch(() => {
+                              alert("A network error occurred.");
+                          });
+                  } else {
+                      alert("Please select both a file to move and a 
destination directory.");
+                  }
+              });
+          }
+
+          updateMoveSelectionInfo();
+      });
+  </script>
 {% endblock javascripts %}
diff --git a/atr/templates/revisions-selected.html 
b/atr/templates/revisions-selected.html
index be448ac..292f9b2 100644
--- a/atr/templates/revisions-selected.html
+++ b/atr/templates/revisions-selected.html
@@ -123,7 +123,7 @@
                 {{ empty_form.hidden_tag() }}
 
                 <input type="hidden" name="revision_number" value="{{ 
revision.number }}" />
-                <button type="submit" class="btn btn-sm 
btn-outline-danger">Set this revision as current</button>
+                <button type="submit" class="btn btn-sm 
btn-outline-danger">Revert to this revision state</button>
               </form>
             </div>
           {% endif %}
diff --git a/atr/util.py b/atr/util.py
index 1136d17..27f8d20 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -334,6 +334,16 @@ def get_unfinished_dir() -> pathlib.Path:
     return pathlib.Path(config.get().UNFINISHED_STORAGE_DIR)
 
 
+async def is_dir_resolve(path: pathlib.Path) -> pathlib.Path | None:
+    try:
+        resolved_path = await asyncio.to_thread(path.resolve)
+        if not await aiofiles.os.path.isdir(resolved_path):
+            return None
+    except (FileNotFoundError, OSError):
+        return None
+    return resolved_path
+
+
 def is_user_viewing_as_admin(uid: str | None) -> bool:
     """Check whether a user is currently viewing the site with active admin 
privileges."""
     if not user.is_admin(uid):
@@ -398,18 +408,37 @@ def parse_key_blocks(keys_text: str) -> list[str]:
 
 async def paths_recursive(base_path: pathlib.Path) -> 
AsyncGenerator[pathlib.Path]:
     """Yield all file paths recursively within a base path, relative to the 
base path."""
-    try:
-        abs_base_path = await asyncio.to_thread(base_path.resolve)
-        for entry in await aiofiles.os.scandir(abs_base_path):
-            entry_path = pathlib.Path(entry.path)
-            relative_path = entry_path.relative_to(abs_base_path)
-            if entry.is_file():
-                yield relative_path
-            elif entry.is_dir():
-                async for sub_path in paths_recursive(entry_path):
-                    yield relative_path / sub_path
-    except FileNotFoundError:
+    if (resolved_base_path := await is_dir_resolve(base_path)) is None:
         return
+    async for rel_path in paths_recursive_all(base_path):
+        abs_path_to_check = resolved_base_path / rel_path
+        with contextlib.suppress(FileNotFoundError, OSError):
+            if await aiofiles.os.path.isfile(abs_path_to_check):
+                yield rel_path
+
+
+async def paths_recursive_all(base_path: pathlib.Path) -> 
AsyncGenerator[pathlib.Path]:
+    """Yield all file and directory paths recursively within a base path, 
relative to the base path."""
+    if (resolved_base_path := await is_dir_resolve(base_path)) is None:
+        return
+    queue: list[pathlib.Path] = [resolved_base_path]
+    visited_abs_paths: set[pathlib.Path] = set()
+    while queue:
+        current_abs_item = queue.pop(0)
+        try:
+            resolved_current_abs_item = await 
asyncio.to_thread(current_abs_item.resolve)
+        except (FileNotFoundError, OSError):
+            continue
+        if resolved_current_abs_item in visited_abs_paths:
+            continue
+        visited_abs_paths.add(resolved_current_abs_item)
+        with contextlib.suppress(FileNotFoundError, OSError):
+            for entry in await aiofiles.os.scandir(current_abs_item):
+                entry_abs_path = pathlib.Path(entry.path)
+                relative_path = entry_abs_path.relative_to(resolved_base_path)
+                yield relative_path
+                if entry.is_dir():
+                    queue.append(entry_abs_path)
 
 
 def permitted_recipients(asf_uid: str) -> list[str]:


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

Reply via email to