add drag and drop upload.

This commit is contained in:
KingKDot
2026-08-03 22:25:29 -05:00
parent efc096cf4d
commit 5693409c93
4 changed files with 124 additions and 17 deletions
@@ -103,6 +103,13 @@ export function getParentPath(path) {
return parentPath || ".";
}
export function joinRemotePath(directory, fileName) {
if (!directory || directory === ".") return fileName;
if (/[\\/]$/.test(directory)) return `${directory}${fileName}`;
const separator = directory.includes("\\") && !directory.includes("/") ? "\\" : "/";
return `${directory}${separator}${fileName}`;
}
export function formatBytes(bytes) {
if (bytes === 0 || bytes === 0n) return "0 B";
const sizes = ["B", "KB", "MB", "GB", "TB"];
+87 -16
View File
@@ -18,6 +18,7 @@ import {
getHighlightLanguage,
getParentPath,
isPreviewable,
joinRemotePath,
shouldShowParentDirectory,
} from "./filebrowser-utils.js";
@@ -54,9 +55,11 @@ const breadcrumbEl = document.getElementById("breadcrumb");
const fileListEl = document.getElementById("file-list");
const refreshBtn = document.getElementById("refresh-btn");
const uploadBtn = document.getElementById("upload-btn");
const uploadExecuteBtn = document.getElementById("upload-execute-btn");
const mkdirBtn = document.getElementById("mkdir-btn");
const searchBtn = document.getElementById("search-btn");
const fileInput = document.getElementById("file-input");
const executeFileInput = document.getElementById("execute-file-input");
const contextMenu = document.getElementById("context-menu");
const clientIdHeader = document.getElementById("client-id-header");
const backBtn = document.getElementById("back-btn");
@@ -161,6 +164,7 @@ function updateStatus(state, text) {
function enableControls(enabled) {
refreshBtn.disabled = !enabled;
uploadBtn.disabled = !enabled;
uploadExecuteBtn.disabled = !enabled;
mkdirBtn.disabled = !enabled;
}
@@ -2403,22 +2407,27 @@ function hideContextMenu() {
refreshBtn.onclick = () => listFiles(currentPath);
uploadBtn.onclick = () => fileInput.click();
uploadExecuteBtn.onclick = () => executeFileInput.click();
fileInput.onchange = async (e) => {
const files = Array.from(e.target.files);
if (files.length === 0) return;
for (const file of files) {
await uploadFile(file);
}
await uploadMultipleFiles(files);
fileInput.value = "";
listFiles(currentPath);
};
async function uploadMultipleFiles(files) {
executeFileInput.onchange = async (e) => {
const files = Array.from(e.target.files);
if (files.length === 0) return;
await uploadMultipleFiles(files, { executeAfterUpload: true });
executeFileInput.value = "";
};
async function uploadMultipleFiles(files, options = {}) {
for (const file of files) {
await uploadFile(file);
await uploadFile(file, options);
}
listFiles(currentPath);
}
@@ -2429,6 +2438,7 @@ function hasFileDrag(event) {
}
let dropOverlayEl = null;
let activeDropFolderRow = null;
function ensureDropOverlay() {
if (dropOverlayEl || !fileListPanel) return dropOverlayEl;
@@ -2451,7 +2461,7 @@ function ensureDropOverlay() {
return overlay;
}
function setDropTargetActive(active) {
function setDropTargetActive(active, destinationPath = currentPath, folderRow = null) {
if (!fileListPanel) return;
const overlay = ensureDropOverlay();
if (overlay) {
@@ -2459,16 +2469,33 @@ function setDropTargetActive(active) {
if (active) {
const pathEl = overlay.querySelector(".drop-target-path");
if (pathEl) {
pathEl.textContent = currentPath ? `into ${currentPath}` : "";
pathEl.textContent = destinationPath ? `into ${destinationPath}` : "";
}
}
}
if (activeDropFolderRow && activeDropFolderRow !== folderRow) {
activeDropFolderRow.classList.remove("ring-2", "ring-emerald-400", "bg-emerald-500/10");
}
activeDropFolderRow = active && folderRow ? folderRow : null;
if (activeDropFolderRow) {
activeDropFolderRow.classList.add("ring-2", "ring-emerald-400", "bg-emerald-500/10");
}
fileListPanel.classList.toggle("ring-2", active);
fileListPanel.classList.toggle("ring-blue-500", active);
fileListPanel.classList.toggle("ring-offset-2", active);
fileListPanel.classList.toggle("ring-offset-slate-950", active);
}
function getDropDestination(event) {
const folderRow = event.target instanceof Element
? event.target.closest('.file-item[data-is-dir="true"]')
: null;
if (folderRow && fileListPanel.contains(folderRow)) {
return { path: folderRow.dataset.path || currentPath, folderRow };
}
return { path: currentPath, folderRow: null };
}
function setupDragAndDropUpload() {
if (!fileListPanel) return;
ensureDropOverlay();
@@ -2477,14 +2504,16 @@ function setupDragAndDropUpload() {
if (!hasFileDrag(e)) return;
e.preventDefault();
dragDepth += 1;
setDropTargetActive(true);
const destination = getDropDestination(e);
setDropTargetActive(true, destination.path, destination.folderRow);
});
fileListPanel.addEventListener("dragover", (e) => {
if (!hasFileDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setDropTargetActive(true);
const destination = getDropDestination(e);
setDropTargetActive(true, destination.path, destination.folderRow);
});
fileListPanel.addEventListener("dragleave", (e) => {
@@ -2499,14 +2528,50 @@ function setupDragAndDropUpload() {
fileListPanel.addEventListener("drop", async (e) => {
if (!hasFileDrag(e)) return;
e.preventDefault();
const destination = getDropDestination(e);
dragDepth = 0;
setDropTargetActive(false);
const files = Array.from(e.dataTransfer?.files || []);
if (files.length === 0) return;
notifyToast(`Uploading ${files.length} file(s)...`, "info", 3000);
await uploadMultipleFiles(files);
notifyToast(`Uploading ${files.length} file(s) into ${destination.path || "."}...`, "info", 3000);
await uploadMultipleFiles(files, { destinationPath: destination.path });
});
}
function setupUploadAndExecuteDropTarget() {
if (!uploadExecuteBtn) return;
const setActive = (active) => {
uploadExecuteBtn.classList.toggle("ring-4", active);
uploadExecuteBtn.classList.toggle("ring-amber-300/50", active);
uploadExecuteBtn.classList.toggle("scale-105", active);
};
uploadExecuteBtn.addEventListener("dragenter", (event) => {
if (!hasFileDrag(event)) return;
event.preventDefault();
setActive(true);
});
uploadExecuteBtn.addEventListener("dragover", (event) => {
if (!hasFileDrag(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setActive(true);
});
uploadExecuteBtn.addEventListener("dragleave", (event) => {
if (!hasFileDrag(event)) return;
setActive(false);
});
uploadExecuteBtn.addEventListener("drop", async (event) => {
if (!hasFileDrag(event)) return;
event.preventDefault();
setActive(false);
const files = Array.from(event.dataTransfer?.files || []);
if (files.length === 0) return;
notifyToast(`Uploading and running ${files.length} file(s)...`, "info", 3000);
await uploadMultipleFiles(files, { executeAfterUpload: true });
});
}
@@ -2775,8 +2840,9 @@ async function uploadFileViaWsChunks(file, path, transfer) {
}
}
async function uploadFile(file) {
const path = currentPath ? `${currentPath}/${file.name}` : file.name;
async function uploadFile(file, options = {}) {
const destinationPath = options.destinationPath ?? currentPath;
const path = joinRemotePath(destinationPath, file.name);
const transferId = `upload-${Date.now()}-${Math.random()}`;
console.log("Uploading:", path);
@@ -2815,6 +2881,8 @@ async function uploadFile(file) {
await uploadFileViaHttpPull(file, path, transfer);
}
finishUpload(transfer);
if (options.executeAfterUpload) executeFile(path, false);
return path;
} catch (err) {
const canFallbackToWs = PREFER_HTTP_UPLOAD_PULL && file.size <= WS_UPLOAD_MAX_TOTAL && !transfer.cancelled;
if (canFallbackToWs) {
@@ -2836,7 +2904,8 @@ async function uploadFile(file) {
try {
await uploadFileViaWsChunks(file, path, transfer);
finishUpload(transfer);
return;
if (options.executeAfterUpload) executeFile(path, false);
return path;
} catch (fallbackErr) {
err = fallbackErr;
}
@@ -2846,6 +2915,7 @@ async function uploadFile(file) {
fileUploads.delete(path);
fileUploadsById.delete(transferId);
alert(`Upload failed: ${err.message}`);
return null;
}
}
@@ -2890,6 +2960,7 @@ document.addEventListener("click", (e) => {
});
setupDragAndDropUpload();
setupUploadAndExecuteDropTarget();
updateStatus("connecting", "Connecting...");
updateBackButton();
+10 -1
View File
@@ -542,6 +542,14 @@
>
<i class="fa-solid fa-upload"></i> Upload
</button>
<button
id="upload-execute-btn"
class="flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-amber-600 hover:bg-amber-500 border border-amber-500 text-white text-sm font-medium disabled:opacity-40"
title="Choose a local file, or drop files here, to upload and run"
disabled
>
<i class="fa-solid fa-play"></i> Upload &amp; run
</button>
<button
id="mkdir-btn"
class="flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-violet-600 hover:bg-violet-500 border border-violet-500 text-white text-sm font-medium disabled:opacity-40"
@@ -1001,8 +1009,9 @@
<!-- Upload Modal -->
<input type="file" id="file-input" style="display: none" multiple />
<input type="file" id="execute-file-input" style="display: none" multiple />
<script type="module" src="/assets/nav.js"></script>
<script type="module" src="/assets/filebrowser.js?v=mac-permission-locks-20260609-1"></script>
<script type="module" src="/assets/filebrowser.js?v=drag-drop-execute-20260803-1"></script>
</body>
</html>
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
// @ts-expect-error Browser assets are intentionally shipped as plain JavaScript.
import { joinRemotePath } from "../public/assets/filebrowser-utils.js";
describe("file browser remote upload paths", () => {
test("joins files to Windows destinations without mixing separators", () => {
expect(joinRemotePath("C:\\Temp", "tool.exe")).toBe("C:\\Temp\\tool.exe");
expect(joinRemotePath("C:\\", "tool.exe")).toBe("C:\\tool.exe");
});
test("joins files to Unix and normalized destinations", () => {
expect(joinRemotePath("/tmp", "tool.sh")).toBe("/tmp/tool.sh");
expect(joinRemotePath("C:/Temp", "tool.exe")).toBe("C:/Temp/tool.exe");
});
test("keeps relative root uploads relative", () => {
expect(joinRemotePath(".", "tool.bin")).toBe("tool.bin");
expect(joinRemotePath("", "tool.bin")).toBe("tool.bin");
});
});