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]