diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 7a8cc1d..32c94d5 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index fdf1700..84688b6 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 1e0243f..fc9923e 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -178,7 +178,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function effectiveThemeKey(theme, colorMode)', app_js) self.assertIn("document.documentElement.dataset.theme", app_js) self.assertIn('document.getElementById("theme-toggle").onclick = toggleTheme;', app_js) - self.assertIn('document.getElementById("upload-btn").onclick = openUploadPicker;', app_js) + self.assertIn('document.getElementById("upload-btn").onclick = (event) => {', app_js) + self.assertIn('if (event.altKey) {', app_js) + self.assertIn("openFolderPicker();", app_js) + self.assertIn("openUploadPicker();", app_js) self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js) self.assertIn('async function loadSettings()', app_js) self.assertIn('await loadSettings();', app_js) @@ -200,6 +203,13 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('"/api/settings"', app_js) self.assertIn('function uploadElements()', app_js) self.assertIn('function openUploadPicker()', app_js) + self.assertIn('function ensureFolderUploadPicker()', app_js) + self.assertIn('function openFolderPicker()', app_js) + self.assertIn('function buildFolderUploadPlan(files, targetPath)', app_js) + self.assertIn('function handleFolderSelection(event)', app_js) + self.assertIn('input.setAttribute("webkitdirectory", "")', app_js) + self.assertIn('Folder: ${plan.rootFolderName} (plan only)', app_js) + self.assertIn('Folder upload plan ready:', app_js) self.assertIn('async function handleUploadSelection(event)', app_js) self.assertIn('uploadElements().input.onchange = handleUploadSelection;', app_js) self.assertIn('"/api/files/upload"', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 8357e08..8a438db 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -69,6 +69,14 @@ let uploadState = { cancelled: false, conflictResolver: null, }; +let folderUploadPlanState = { + targetPath: "", + rootFolderName: "", + entries: [], + fileCount: 0, + subfolderCount: 0, +}; +let folderUploadPickerInput = null; let settingsState = { activeTab: "general", logsLoaded: false, @@ -325,6 +333,22 @@ function uploadElements() { }; } +function ensureFolderUploadPicker() { + if (folderUploadPickerInput) { + return folderUploadPickerInput; + } + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.hidden = true; + input.setAttribute("webkitdirectory", ""); + input.setAttribute("directory", ""); + input.onchange = handleFolderSelection; + document.body.append(input); + folderUploadPickerInput = input; + return folderUploadPickerInput; +} + function uploadConflictElements() { return { overlay: document.getElementById("upload-conflict-modal"), @@ -471,6 +495,16 @@ function resetUploadProgress() { setUploadProgressVisible(false); } +function showFolderUploadPlan(plan) { + const elements = uploadElements(); + folderUploadPlanState = plan; + elements.button.disabled = false; + elements.target.textContent = `Upload to: ${plan.targetPath}`; + elements.currentFile.textContent = `Folder: ${plan.rootFolderName} (plan only)`; + elements.count.textContent = `${plan.fileCount} file${plan.fileCount === 1 ? "" : "s"}${plan.subfolderCount > 0 ? ` • ${plan.subfolderCount} subfolder${plan.subfolderCount === 1 ? "" : "s"}` : ""}`; + setUploadProgressVisible(true); +} + function updateUploadProgress() { const elements = uploadElements(); const total = uploadState.files.length; @@ -494,6 +528,22 @@ function openUploadPicker() { elements.input.click(); } +function openFolderPicker() { + if (uploadState.active) { + return; + } + folderUploadPlanState = { + targetPath: activePaneState().currentPath, + rootFolderName: "", + entries: [], + fileCount: 0, + subfolderCount: 0, + }; + const input = ensureFolderUploadPicker(); + input.value = ""; + input.click(); +} + function isUploadConflictOpen() { const overlay = document.getElementById("upload-conflict-modal"); return Boolean(overlay) && !overlay.classList.contains("hidden"); @@ -522,6 +572,77 @@ function promptUploadConflict(fileName, targetPath, message) { }); } +function countPlannedSubfolders(relativePaths) { + const folderPaths = new Set(); + relativePaths.forEach((relativePath) => { + const parts = relativePath.split("/").filter(Boolean); + if (parts.length <= 1) { + return; + } + let current = ""; + for (let index = 0; index < parts.length - 1; index += 1) { + current = current ? `${current}/${parts[index]}` : parts[index]; + folderPaths.add(current); + } + }); + return folderPaths.size; +} + +function buildFolderUploadPlan(files, targetPath) { + if (!files.length) { + return null; + } + const plannedEntries = files.map((file) => { + const webkitRelativePath = String(file.webkitRelativePath || "").replace(/\\/g, "/"); + const parts = webkitRelativePath.split("/").filter(Boolean); + return { + file, + webkitRelativePath, + rootFolderName: parts[0] || "", + relativePath: parts.length > 1 ? parts.slice(1).join("/") : file.name, + }; + }); + const rootFolderName = plannedEntries[0].rootFolderName; + if (!rootFolderName) { + throw new Error("Folder picker did not return a usable folder structure"); + } + if (plannedEntries.some((entry) => entry.rootFolderName !== rootFolderName || !entry.relativePath)) { + throw new Error("Folder picker returned multiple roots or invalid relative paths"); + } + return { + targetPath, + rootFolderName, + entries: plannedEntries.map((entry) => ({ + name: entry.file.name, + size: entry.file.size, + relativePath: entry.relativePath, + })), + fileCount: plannedEntries.length, + subfolderCount: countPlannedSubfolders(plannedEntries.map((entry) => entry.relativePath)), + }; +} + +function handleFolderSelection(event) { + const files = Array.from(event.target.files || []); + event.target.value = ""; + if (files.length === 0) { + return; + } + try { + const targetPath = folderUploadPlanState.targetPath || activePaneState().currentPath; + const plan = buildFolderUploadPlan(files, targetPath); + if (!plan) { + return; + } + showFolderUploadPlan(plan); + setError("actions-error", ""); + setStatus(`Folder upload plan ready: ${plan.rootFolderName} (${plan.fileCount} file${plan.fileCount === 1 ? "" : "s"})`); + } catch (err) { + setUploadProgressVisible(false); + setActionError("Folder upload", err); + } +} + async function handleUploadSelection(event) { const files = Array.from(event.target.files || []); event.target.value = ""; @@ -3006,7 +3127,13 @@ function setupEvents() { setupPaneEvents("right"); document.addEventListener("keydown", handleKeyboardShortcuts); document.getElementById("theme-toggle").onclick = toggleTheme; - document.getElementById("upload-btn").onclick = openUploadPicker; + document.getElementById("upload-btn").onclick = (event) => { + if (event.altKey) { + openFolderPicker(); + return; + } + openUploadPicker(); + }; document.getElementById("settings-btn").onclick = () => openSettings("general"); document.getElementById("view-btn").onclick = openViewer; document.getElementById("edit-btn").onclick = openEditor;