feat: upload progressbar
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -54,10 +54,17 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('id="upload-menu-popup"', body)
|
self.assertIn('id="upload-menu-popup"', body)
|
||||||
self.assertIn('id="upload-folder-btn"', body)
|
self.assertIn('id="upload-folder-btn"', body)
|
||||||
self.assertIn('id="upload-input"', body)
|
self.assertIn('id="upload-input"', body)
|
||||||
self.assertIn('id="upload-progress"', body)
|
self.assertIn('id="upload-menu"', body)
|
||||||
self.assertIn('id="upload-target"', body)
|
self.assertIn('id="upload-menu-toggle"', body)
|
||||||
self.assertIn('id="upload-current-file"', body)
|
self.assertIn('id="upload-menu-popup"', body)
|
||||||
self.assertIn('id="upload-count"', body)
|
self.assertIn('id="upload-folder-btn"', body)
|
||||||
|
self.assertIn('id="upload-modal"', body)
|
||||||
|
self.assertIn('id="upload-modal-target"', body)
|
||||||
|
self.assertIn('id="upload-modal-current-file"', body)
|
||||||
|
self.assertIn('id="upload-modal-progress-bar"', body)
|
||||||
|
self.assertIn('id="upload-modal-count"', body)
|
||||||
|
self.assertIn('id="upload-modal-status"', body)
|
||||||
|
self.assertIn('id="upload-modal-cancel-btn"', body)
|
||||||
self.assertIn('id="settings-btn"', body)
|
self.assertIn('id="settings-btn"', body)
|
||||||
self.assertIn('id="rename-btn"', body)
|
self.assertIn('id="rename-btn"', body)
|
||||||
self.assertIn('id="view-btn"', body)
|
self.assertIn('id="view-btn"', body)
|
||||||
@@ -210,13 +217,15 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('function openUploadPicker()', app_js)
|
self.assertIn('function openUploadPicker()', app_js)
|
||||||
self.assertIn('function ensureFolderUploadPicker()', app_js)
|
self.assertIn('function ensureFolderUploadPicker()', app_js)
|
||||||
self.assertIn('function openFolderPicker()', app_js)
|
self.assertIn('function openFolderPicker()', app_js)
|
||||||
|
self.assertIn('function uploadModalElements()', app_js)
|
||||||
|
self.assertIn('function setUploadModalVisible(', app_js)
|
||||||
|
self.assertIn('function updateUploadModalDisplay(', app_js)
|
||||||
self.assertIn('function buildFolderUploadPlan(files, targetPath)', app_js)
|
self.assertIn('function buildFolderUploadPlan(files, targetPath)', app_js)
|
||||||
self.assertIn('function folderDirectoryPaths(plan)', app_js)
|
self.assertIn('function folderDirectoryPaths(plan)', app_js)
|
||||||
self.assertIn('async function ensureFolderDirectoryExists(path)', app_js)
|
self.assertIn('async function ensureFolderDirectoryExists(path)', app_js)
|
||||||
self.assertIn('async function executeFolderUploadPlan(plan)', app_js)
|
self.assertIn('async function executeFolderUploadPlan(plan)', app_js)
|
||||||
self.assertIn('async function handleFolderSelection(event)', app_js)
|
self.assertIn('async function handleFolderSelection(event)', app_js)
|
||||||
self.assertIn('input.setAttribute("webkitdirectory", "")', app_js)
|
self.assertIn('input.setAttribute("webkitdirectory", "")', app_js)
|
||||||
self.assertIn('Folder upload to: ${plan.targetPath}/${plan.rootFolderName}', app_js)
|
|
||||||
self.assertIn('await apiRequest("POST", "/api/files/mkdir", {', app_js)
|
self.assertIn('await apiRequest("POST", "/api/files/mkdir", {', app_js)
|
||||||
self.assertIn('await uploadFileRequest(targetPath, entry.file, overwrite);', app_js)
|
self.assertIn('await uploadFileRequest(targetPath, entry.file, overwrite);', app_js)
|
||||||
self.assertIn('Folder upload: preparing', app_js)
|
self.assertIn('Folder upload: preparing', app_js)
|
||||||
|
|||||||
+114
-30
@@ -68,6 +68,7 @@ let uploadState = {
|
|||||||
skippedCount: 0,
|
skippedCount: 0,
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
conflictResolver: null,
|
conflictResolver: null,
|
||||||
|
cancelRequested: false,
|
||||||
};
|
};
|
||||||
let folderUploadPlanState = {
|
let folderUploadPlanState = {
|
||||||
targetPane: "left",
|
targetPane: "left",
|
||||||
@@ -331,13 +332,69 @@ function uploadElements() {
|
|||||||
menuPopup: document.getElementById("upload-menu-popup"),
|
menuPopup: document.getElementById("upload-menu-popup"),
|
||||||
folderButton: document.getElementById("upload-folder-btn"),
|
folderButton: document.getElementById("upload-folder-btn"),
|
||||||
input: document.getElementById("upload-input"),
|
input: document.getElementById("upload-input"),
|
||||||
progress: document.getElementById("upload-progress"),
|
|
||||||
target: document.getElementById("upload-target"),
|
|
||||||
currentFile: document.getElementById("upload-current-file"),
|
|
||||||
count: document.getElementById("upload-count"),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uploadModalElements() {
|
||||||
|
return {
|
||||||
|
overlay: document.getElementById("upload-modal"),
|
||||||
|
target: document.getElementById("upload-modal-target"),
|
||||||
|
currentFile: document.getElementById("upload-modal-current-file"),
|
||||||
|
count: document.getElementById("upload-modal-count"),
|
||||||
|
progressBar: document.getElementById("upload-modal-progress-bar"),
|
||||||
|
status: document.getElementById("upload-modal-status"),
|
||||||
|
cancelButton: document.getElementById("upload-modal-cancel-btn"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUploadModalVisible(visible) {
|
||||||
|
const elements = uploadModalElements();
|
||||||
|
if (!elements.overlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
elements.overlay.classList.toggle("hidden", !visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUploadModalDisplay(info) {
|
||||||
|
const elements = uploadModalElements();
|
||||||
|
if (!elements.overlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const total = info.total || 0;
|
||||||
|
elements.target.textContent = `Uploading to: ${info.targetPath}`;
|
||||||
|
elements.currentFile.textContent = info.currentFileName
|
||||||
|
? `Current file: ${info.currentFileName}`
|
||||||
|
: info.rootFolderName
|
||||||
|
? `Folder: ${info.rootFolderName}`
|
||||||
|
: "Preparing files";
|
||||||
|
elements.count.textContent = total ? `${Math.min(info.index, total)}/${total} files` : "";
|
||||||
|
const percent = total ? Math.min(100, Math.round((info.index / total) * 100)) : 0;
|
||||||
|
elements.progressBar.style.width = `${percent}%`;
|
||||||
|
if (info.statusText) {
|
||||||
|
elements.status.textContent = info.statusText;
|
||||||
|
} else if (!elements.status.textContent) {
|
||||||
|
elements.status.textContent = "";
|
||||||
|
}
|
||||||
|
elements.cancelButton.disabled = !uploadState.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUploadModalStatus(msg) {
|
||||||
|
const elements = uploadModalElements();
|
||||||
|
if (!elements.overlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
elements.status.textContent = msg || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestUploadCancel() {
|
||||||
|
uploadState.cancelRequested = true;
|
||||||
|
const elements = uploadModalElements();
|
||||||
|
if (elements.cancelButton) {
|
||||||
|
elements.cancelButton.disabled = true;
|
||||||
|
}
|
||||||
|
setUploadModalStatus("Cancel requested; finishing current file...");
|
||||||
|
}
|
||||||
|
|
||||||
function ensureFolderUploadPicker() {
|
function ensureFolderUploadPicker() {
|
||||||
if (folderUploadPickerInput) {
|
if (folderUploadPickerInput) {
|
||||||
return folderUploadPickerInput;
|
return folderUploadPickerInput;
|
||||||
@@ -477,10 +534,6 @@ function createButton(text, onClick) {
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUploadProgressVisible(visible) {
|
|
||||||
uploadElements().progress.classList.toggle("hidden", !visible);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeUploadMenu() {
|
function closeUploadMenu() {
|
||||||
const elements = uploadElements();
|
const elements = uploadElements();
|
||||||
if (!elements.menuPopup || !elements.menuToggle) {
|
if (!elements.menuPopup || !elements.menuToggle) {
|
||||||
@@ -512,21 +565,22 @@ function resetUploadProgress() {
|
|||||||
uploadState.skippedCount = 0;
|
uploadState.skippedCount = 0;
|
||||||
uploadState.cancelled = false;
|
uploadState.cancelled = false;
|
||||||
uploadState.conflictResolver = null;
|
uploadState.conflictResolver = null;
|
||||||
|
uploadState.cancelRequested = false;
|
||||||
elements.button.disabled = false;
|
elements.button.disabled = false;
|
||||||
elements.target.textContent = "";
|
setUploadModalVisible(false);
|
||||||
elements.currentFile.textContent = "";
|
setUploadModalStatus("");
|
||||||
elements.count.textContent = "";
|
|
||||||
setUploadProgressVisible(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showFolderUploadPlan(plan) {
|
function showFolderUploadPlan(plan) {
|
||||||
const elements = uploadElements();
|
|
||||||
folderUploadPlanState = plan;
|
folderUploadPlanState = plan;
|
||||||
elements.button.disabled = false;
|
updateUploadModalDisplay({
|
||||||
elements.target.textContent = `Upload to: ${plan.targetPath}`;
|
targetPath: `${plan.targetPath}/${plan.rootFolderName}`,
|
||||||
elements.currentFile.textContent = `Folder: ${plan.rootFolderName}`;
|
rootFolderName: plan.rootFolderName,
|
||||||
elements.count.textContent = `${plan.fileCount} file${plan.fileCount === 1 ? "" : "s"}${plan.subfolderCount > 0 ? ` • ${plan.subfolderCount} subfolder${plan.subfolderCount === 1 ? "" : "s"}` : ""}`;
|
total: plan.fileCount,
|
||||||
setUploadProgressVisible(true);
|
index: 0,
|
||||||
|
});
|
||||||
|
setUploadModalVisible(true);
|
||||||
|
setUploadModalStatus("Preparing folder upload...");
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUploadProgress() {
|
function updateUploadProgress() {
|
||||||
@@ -685,15 +739,6 @@ async function ensureFolderDirectoryExists(path) {
|
|||||||
await apiRequest("GET", `/api/browse?${new URLSearchParams({ path }).toString()}`);
|
await apiRequest("GET", `/api/browse?${new URLSearchParams({ path }).toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFolderUploadProgress(plan, currentName, index) {
|
|
||||||
const elements = uploadElements();
|
|
||||||
elements.target.textContent = `Folder upload to: ${plan.targetPath}/${plan.rootFolderName}`;
|
|
||||||
elements.currentFile.textContent = currentName ? `Current file: ${currentName}` : `Folder: ${plan.rootFolderName}`;
|
|
||||||
elements.count.textContent = `${Math.min(index + 1, plan.fileCount)}/${plan.fileCount} files`;
|
|
||||||
elements.button.disabled = true;
|
|
||||||
setUploadProgressVisible(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeFolderUploadPlan(plan) {
|
async function executeFolderUploadPlan(plan) {
|
||||||
uploadState.active = true;
|
uploadState.active = true;
|
||||||
uploadState.targetPath = `${plan.targetPath}/${plan.rootFolderName}`;
|
uploadState.targetPath = `${plan.targetPath}/${plan.rootFolderName}`;
|
||||||
@@ -710,11 +755,20 @@ async function executeFolderUploadPlan(plan) {
|
|||||||
try {
|
try {
|
||||||
const directories = folderDirectoryPaths(plan);
|
const directories = folderDirectoryPaths(plan);
|
||||||
for (const directoryPath of directories) {
|
for (const directoryPath of directories) {
|
||||||
|
if (uploadState.cancelRequested) {
|
||||||
|
uploadState.cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
await ensureFolderDirectoryExists(directoryPath);
|
await ensureFolderDirectoryExists(directoryPath);
|
||||||
}
|
}
|
||||||
|
setUploadModalStatus("");
|
||||||
|
|
||||||
outer:
|
outer:
|
||||||
for (let index = 0; index < plan.entries.length; index += 1) {
|
for (let index = 0; index < plan.entries.length; index += 1) {
|
||||||
|
if (uploadState.cancelRequested) {
|
||||||
|
uploadState.cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
const entry = plan.entries[index];
|
const entry = plan.entries[index];
|
||||||
const relativeParts = entry.relativePath.split("/").filter(Boolean);
|
const relativeParts = entry.relativePath.split("/").filter(Boolean);
|
||||||
const fileName = relativeParts[relativeParts.length - 1];
|
const fileName = relativeParts[relativeParts.length - 1];
|
||||||
@@ -724,7 +778,13 @@ async function executeFolderUploadPlan(plan) {
|
|||||||
: `${plan.targetPath}/${plan.rootFolderName}`;
|
: `${plan.targetPath}/${plan.rootFolderName}`;
|
||||||
|
|
||||||
uploadState.index = index;
|
uploadState.index = index;
|
||||||
updateFolderUploadProgress(plan, entry.relativePath, index);
|
updateUploadModalDisplay({
|
||||||
|
targetPath: `${plan.targetPath}/${plan.rootFolderName}`,
|
||||||
|
rootFolderName: plan.rootFolderName,
|
||||||
|
total: plan.fileCount,
|
||||||
|
index: index + 1,
|
||||||
|
currentFileName: entry.relativePath,
|
||||||
|
});
|
||||||
let overwrite = uploadState.overwriteAll;
|
let overwrite = uploadState.overwriteAll;
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
@@ -758,6 +818,7 @@ async function executeFolderUploadPlan(plan) {
|
|||||||
uploadState.skippedCount += 1;
|
uploadState.skippedCount += 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
uploadState.cancelRequested = true;
|
||||||
uploadState.cancelled = true;
|
uploadState.cancelled = true;
|
||||||
break outer;
|
break outer;
|
||||||
}
|
}
|
||||||
@@ -822,14 +883,31 @@ async function handleUploadSelection(event) {
|
|||||||
uploadState.successfulCount = 0;
|
uploadState.successfulCount = 0;
|
||||||
uploadState.skippedCount = 0;
|
uploadState.skippedCount = 0;
|
||||||
uploadState.cancelled = false;
|
uploadState.cancelled = false;
|
||||||
|
uploadState.cancelRequested = false;
|
||||||
setError("actions-error", "");
|
setError("actions-error", "");
|
||||||
updateUploadProgress();
|
setUploadModalVisible(true);
|
||||||
|
updateUploadModalDisplay({
|
||||||
|
targetPath,
|
||||||
|
rootFolderName: "",
|
||||||
|
total: files.length,
|
||||||
|
index: 0,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
outer:
|
outer:
|
||||||
for (let index = 0; index < files.length; index += 1) {
|
for (let index = 0; index < files.length; index += 1) {
|
||||||
|
if (uploadState.cancelRequested) {
|
||||||
|
uploadState.cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
uploadState.index = index;
|
uploadState.index = index;
|
||||||
updateUploadProgress();
|
updateUploadModalDisplay({
|
||||||
|
targetPath,
|
||||||
|
rootFolderName: "",
|
||||||
|
total: files.length,
|
||||||
|
index: index + 1,
|
||||||
|
currentFileName: files[index].name,
|
||||||
|
});
|
||||||
let overwrite = uploadState.overwriteAll;
|
let overwrite = uploadState.overwriteAll;
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
@@ -863,6 +941,7 @@ async function handleUploadSelection(event) {
|
|||||||
uploadState.skippedCount += 1;
|
uploadState.skippedCount += 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
uploadState.cancelRequested = true;
|
||||||
uploadState.cancelled = true;
|
uploadState.cancelled = true;
|
||||||
break outer;
|
break outer;
|
||||||
}
|
}
|
||||||
@@ -883,6 +962,7 @@ async function handleUploadSelection(event) {
|
|||||||
await loadBrowsePane(state.activePane);
|
await loadBrowsePane(state.activePane);
|
||||||
}
|
}
|
||||||
setActionError("Upload", err);
|
setActionError("Upload", err);
|
||||||
|
setUploadModalStatus(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
resetUploadProgress();
|
resetUploadProgress();
|
||||||
}
|
}
|
||||||
@@ -3309,6 +3389,10 @@ function setupEvents() {
|
|||||||
document.getElementById("move-btn").onclick = openF6Flow;
|
document.getElementById("move-btn").onclick = openF6Flow;
|
||||||
document.getElementById("mkdir-btn").onclick = createFolderForActivePane;
|
document.getElementById("mkdir-btn").onclick = createFolderForActivePane;
|
||||||
uploadElements().input.onchange = handleUploadSelection;
|
uploadElements().input.onchange = handleUploadSelection;
|
||||||
|
const modalCancel = uploadModalElements().cancelButton;
|
||||||
|
if (modalCancel) {
|
||||||
|
modalCancel.onclick = requestUploadCancel;
|
||||||
|
}
|
||||||
document.addEventListener("click", (event) => {
|
document.addEventListener("click", (event) => {
|
||||||
const elements = uploadElements();
|
const elements = uploadElements();
|
||||||
if (!elements.menu || elements.menu.contains(event.target)) {
|
if (!elements.menu || elements.menu.contains(event.target)) {
|
||||||
|
|||||||
@@ -656,6 +656,33 @@ button:disabled {
|
|||||||
box-shadow: var(--shadow-elevated);
|
box-shadow: var(--shadow-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#upload-modal .popup-card {
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-modal-progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-border);
|
||||||
|
margin: 6px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-modal-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
background: var(--color-accent);
|
||||||
|
transition: width 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-modal-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.popup-meta {
|
.popup-meta {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
+15
-5
@@ -104,11 +104,6 @@
|
|||||||
<button id="delete-btn" type="button" disabled><span class="shortcut-hint">F8</span><span>Delete</span></button>
|
<button id="delete-btn" type="button" disabled><span class="shortcut-hint">F8</span><span>Delete</span></button>
|
||||||
</div>
|
</div>
|
||||||
<input id="upload-input" type="file" multiple hidden>
|
<input id="upload-input" type="file" multiple hidden>
|
||||||
<div id="upload-progress" class="upload-progress hidden" aria-live="polite">
|
|
||||||
<div id="upload-target" class="upload-progress-target"></div>
|
|
||||||
<div id="upload-current-file" class="upload-progress-file"></div>
|
|
||||||
<div id="upload-count" class="upload-progress-count"></div>
|
|
||||||
</div>
|
|
||||||
<div id="actions-error" class="error"></div>
|
<div id="actions-error" class="error"></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,6 +165,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="upload-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="upload-modal-title">
|
||||||
|
<div class="popup-card upload-modal-card">
|
||||||
|
<h3 id="upload-modal-title">Uploading</h3>
|
||||||
|
<div id="upload-modal-target" class="popup-meta"></div>
|
||||||
|
<div id="upload-modal-current-file" class="popup-meta"></div>
|
||||||
|
<div class="upload-modal-progress">
|
||||||
|
<div id="upload-modal-progress-bar" class="upload-modal-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div id="upload-modal-count" class="upload-modal-count"></div>
|
||||||
|
<div id="upload-modal-status" class="popup-meta"></div>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<button id="upload-modal-cancel-btn" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="search-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="search-title">
|
<div id="search-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="search-title">
|
||||||
<div class="popup-card search-card">
|
<div class="popup-card search-card">
|
||||||
<button id="search-close-btn" class="viewer-close" type="button" aria-label="Close search">X</button>
|
<button id="search-close-btn" class="viewer-close" type="button" aria-label="Close search">X</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user