feat: upload - deel 03.02 - Skipp all toegevoegd

This commit is contained in:
kodi
2026-03-13 18:30:10 +01:00
parent 8fe9d0f436
commit 360815498e
13 changed files with 463 additions and 19 deletions
+220
View File
@@ -0,0 +1,220 @@
# Folder Upload v1 Design
## 1. Doel
Folder upload voegt waarde toe omdat de huidige uploadflow al bruikbaar is voor losse bestanden en batches, maar niet voor veelvoorkomende workflows waarbij een gebruiker een complete lokale mapstructuur naar de storage wil kopieren. Dat past logisch binnen de bestaande dual-pane workflow: het actieve paneel bepaalt al de doelmap, en upload is al een expliciete actie in de functiebalk.
De kern van v1 is niet "een nieuwe uploadarchitectuur", maar een gecontroleerde uitbreiding van de bestaande uploadflow zodat een lokale map recursief kan worden ingestuurd naar `currentPath` van het actieve paneel.
## 2. Scope
Folder Upload v1 ondersteunt expliciet:
- selectie van precies een lokale map via de browser
- recursieve upload van de inhoud van die map
- behoud van directorystructuur onder het gekozen doelpad
- target = `currentPath` van het actieve paneel
- hergebruik van de bestaande sequentiele uploadflow en bestaande conflictopties
Niet in scope voor v1:
- meerdere lokale mappen tegelijk
- drag & drop
- resumable upload
- chunked upload
- taskmodel-integratie
- rollback
- backendherontwerp buiten wat strikt nodig is om directorystructuur veilig te ondersteunen
Aanbevolen v1-scope met laag regressierisico:
- precies 1 geselecteerde lokale map
- recursieve upload van alle files daaronder
- directorystructuur behouden
- conflictbehandeling alleen op bestandsniveau via bestaande keuzes
## 3. Browserselectie
Browsermatig is folderselectie geen aparte native "map upload API" zoals bij desktop-apps, maar een file input met directory-selectie-attributen zoals `webkitdirectory`. In de praktijk levert dit een lijst bestanden op met relatieve paden binnen de gekozen map.
Dit past redelijk goed bij de bestaande native file picker flow:
- huidige uploadknop opent al een browser file picker
- voor folder upload kan een aparte, kleine flow dezelfde picker gebruiken, maar dan in directory-selectiemodus
- drag & drop is niet nodig voor v1
Aanbeveling:
- v1 gebruikt browser-native directory picker via input-attributen
- geen drag & drop
- geen extra dependency
## 4. Doelstructuur
De veiligste en meest voorspelbare semantiek voor v1 is:
- de geselecteerde mapnaam zelf wordt meegenomen in de doelstructuur
- dus upload van lokale map `Photos/` naar target `/Volumes/8TB/Uploads` resulteert in:
- `/Volumes/8TB/Uploads/Photos/...`
Dit voorkomt ambiguiteit en sluit aan op gebruikersverwachting uit file managers.
Relatieve paden:
- browser levert per bestand een relatief pad onder de gekozen rootmap
- frontend mag dat relatieve pad gebruiken als beschrijving van directorystructuur
- backend mag die structuur nooit blind vertrouwen zonder per segment validatie
Aanbevolen semantiek:
- geselecteerde mapnaam opnemen
- directorystructuur daaronder behouden
- alle relatieve padsegmenten strikt normaliseren en valideren
## 5. Conflictgedrag
Conflictgedrag moet in v1 voortbouwen op de bestaande uploadconflictflow.
### Bestandsconflicten
Bij een bestaand doelbestand:
- `Overwrite`: huidig bestand overschrijven
- `Overwrite all`: huidige en volgende bestandsconflicten overschrijven
- `Skip`: huidig bestand overslaan
- `Skip all`: huidige en volgende bestandsconflicten overslaan
- `Cancel`: resterende upload stoppen
### Directoryconflicten
Directoryconflict is subtieler. Als de doelmap al bestaat en ook een directory is, hoeft dat in v1 geen fout te zijn. Dat is juist het normale mechanisme om inhoud in een bestaande mapstructuur te laten landen.
Aanbevolen v1-regel:
- bestaande doel-directory: toegestaan, geen conflictmodal
- bestaande doel-directory fungeert als containermap voor verdere recursie
### Typeconflicten
Als een padsegment een typeconflict veroorzaakt, bijvoorbeeld:
- lokale structuur verwacht een directory
- maar op bestemming bestaat daar een file
Dan moet dit als conflict/failure behandeld worden. De bestaande conflictknoppen kunnen dan alleen zinnig worden toegepast als overschrijven echt veilig definieerbaar is. Voor v1 is dat te riskant op directoryniveau.
Aanbevolen v1-regel:
- typeconflict directory-versus-file niet proberen slim op te lossen
- behandel als blokkade/failure voor het huidige bestand
- laat bestaande flow stoppen of conflictueel handelen op bestandsniveau, maar niet op "directory vervangen"
## 6. Backend-impact
De bestaande backend uploadbasis is grotendeels herbruikbaar voor de feitelijke bestandsoverdracht, maar folder upload heeft waarschijnlijk extra backendondersteuning nodig voor directorystructuur.
Het bestaande endpoint ondersteunt nu:
- 1 file per request
- `target_path`
- basename-validatie
Voor folder upload is minimaal een van deze routes nodig:
### Route A: frontend maakt directories expliciet aan
- frontend leest relatieve paden
- frontend zorgt eerst dat directories bestaan via bestaand `mkdir` endpoint
- daarna uploadt frontend elk bestand naar het juiste `target_path`
Voordelen:
- weinig nieuw backendcontract
- hergebruik van bestaande `mkdir` en `upload`
Nadelen:
- meer frontendcoordinatie
- meer requests
### Route B: upload-endpoint accepteert veilige relatieve subpath
- per bestand meegeven:
- `target_path`
- `relative_path`
- `file`
- backend maakt ontbrekende directories aan na validatie
Voordelen:
- schonere folder-uploadflow
- minder frontendcomplexiteit
Nadelen:
- nieuw backendcontract
- iets meer validatielogica
Aanbeveling voor laag regressierisico:
- v1 folder upload liever via Route A ontwerpen:
- frontend maakt directories expliciet aan via bestaande of lichte `mkdir`-flow
- frontend uploadt bestanden daarna via bestaand endpoint
- alleen als dat in praktijk te onhandig blijkt, Route B overwegen
Beide varianten moeten blijven leunen op:
- `path_guard`
- bestaande whitelist/root-containment
- bestaande naamvalidatie per segment
## 7. Frontend-impact
De bestaande sequentiele uploadflow kan worden uitgebreid zonder herontwerp:
- browser levert lijst bestanden uit de gekozen map
- frontend groepeert impliciet op relatieve directorystructuur
- frontend zorgt dat doel-directories bestaan
- frontend uploadt daarna de files sequentieel
Voortgang bij veel bestanden:
- huidige compacte progress UI kan blijven
- tonen:
- aantal totaal
- huidig bestand
- doelpad of huidige relatieve submap indien nuttig
- geen zware task-UI nodig in v1
Aanbevolen v1-richting:
- zelfde uploadmodal/progresscomponent als nu
- alleen uitbreiden met "uploading folder X to path Y"
- geen tweede aparte uploadarchitectuur
## 8. Regressierisico
Belangrijkste risico's:
- security: relatieve paden uit browser niet blind vertrouwen
- diepe mapstructuren: veel requests, langzame voortgang
- gedeeltelijke successen/failures: batch kan halverwege stoppen
- conflictcomplexiteit: directoryconflicten versus bestandsconflicten
- UI-complexiteit: folder upload mag bestaande file upload niet verwarren
Specifiek risico:
- een ogenschijnlijk simpele folder-upload kan ongemerkt uitgroeien tot een mini-sync-engine
- dat moet expliciet vermeden worden
## 9. Teststrategie
### Backend golden tests
Als folder upload later gebouwd wordt, minimaal testen:
- create-mkdir-then-upload flow voor nested directorystructuur
- traversal blokkade op relatieve padsegmenten
- invalid filename segment blokkade
- typeconflict file-versus-directory
- conflict op bestaand bestand
- upload naar bestaande directorystructuur
### UI smoke/regressietests
- folder-upload startpunt aanwezig
- progress UI blijft werken
- conflictopties blijven intact
- actieve-paneel target blijft leidend
### Handmatige validatie
- map met alleen files
- map met nested subdirs
- map met enkele conflicten
- map met typeconflict
- lange/brede directorystructuur
## 10. Aanbeveling
De aanbevolen v1-richting met laag regressierisico is:
- ondersteun precies 1 lokale map
- behoud de geselecteerde mapnaam in de doelstructuur
- gebruik browser-native directory picker
- breid de bestaande sequentiele uploadflow uit in plaats van een nieuwe architectuur te bouwen
- houd conflictbehandeling primair op bestandsniveau
- behandel bestaande directories als toegestaan
- vermijd drag & drop, taskintegratie, chunking en resumable uploads
Concreet aanbevolen technische richting:
- eerst proberen met bestaande architectuur en expliciete directorycreatie vanuit frontend
- alleen als dat te fragiel blijkt een kleine backenduitbreiding voor veilige relatieve paden ontwerpen
Dit houdt folder upload klein, bruikbaar en beheersbaar zonder de bestaande uploadflow opnieuw uit te vinden.
+2 -1
View File
@@ -37,10 +37,11 @@ async def delete(
@router.post("/upload", response_model=UploadResponse) @router.post("/upload", response_model=UploadResponse)
async def upload( async def upload(
target_path: str = Form(...), target_path: str = Form(...),
overwrite: bool = Form(False),
file: UploadFile = File(...), file: UploadFile = File(...),
service: FileOpsService = Depends(get_file_ops_service), service: FileOpsService = Depends(get_file_ops_service),
) -> UploadResponse: ) -> UploadResponse:
return service.upload(target_path=target_path, upload_file=file) return service.upload(target_path=target_path, upload_file=file, overwrite=overwrite)
@router.get("/view", response_model=ViewResponse) @router.get("/view", response_model=ViewResponse)
+3 -2
View File
@@ -140,8 +140,9 @@ class FilesystemAdapter:
"modified": self.modified_iso(path), "modified": self.modified_iso(path),
} }
def write_uploaded_file(self, path: Path, file_stream, chunk_size: int = 1024 * 1024) -> dict: def write_uploaded_file(self, path: Path, file_stream, chunk_size: int = 1024 * 1024, overwrite: bool = False) -> dict:
with path.open("xb") as handle: mode = "wb" if overwrite else "xb"
with path.open(mode) as handle:
while True: while True:
chunk = file_stream.read(chunk_size) chunk = file_stream.read(chunk_size)
if not chunk: if not chunk:
+14 -2
View File
@@ -204,7 +204,7 @@ class FileOpsService:
self._record_history_error(operation="delete", path=path, error=error) self._record_history_error(operation="delete", path=path, error=error)
raise error raise error
def upload(self, target_path: str, upload_file) -> UploadResponse: def upload(self, target_path: str, upload_file, overwrite: bool = False) -> UploadResponse:
destination_relative = None destination_relative = None
history_path = target_path history_path = target_path
try: try:
@@ -216,14 +216,26 @@ class FileOpsService:
resolved_destination = self._path_guard.resolve_path(destination_relative) resolved_destination = self._path_guard.resolve_path(destination_relative)
if resolved_destination.absolute.exists(): if resolved_destination.absolute.exists():
if not overwrite:
raise AppError( raise AppError(
code="already_exists", code="already_exists",
message="Target path already exists", message="Target path already exists",
status_code=409, status_code=409,
details={"path": resolved_destination.relative}, details={"path": resolved_destination.relative},
) )
if resolved_destination.absolute.is_dir():
raise AppError(
code="type_conflict",
message="Cannot overwrite an existing directory",
status_code=409,
details={"path": resolved_destination.relative},
)
saved = self._filesystem.write_uploaded_file(resolved_destination.absolute, upload_file.file) saved = self._filesystem.write_uploaded_file(
resolved_destination.absolute,
upload_file.file,
overwrite=overwrite,
)
self._record_history( self._record_history(
operation="upload", operation="upload",
status="completed", status="completed",
Binary file not shown.
@@ -49,13 +49,13 @@ class UploadApiGoldenTest(unittest.TestCase):
app.dependency_overrides.clear() app.dependency_overrides.clear()
self.temp_dir.cleanup() self.temp_dir.cleanup()
def _upload(self, *, target_path: str, filename: str, content: bytes) -> httpx.Response: def _upload(self, *, target_path: str, filename: str, content: bytes, overwrite: bool = False) -> httpx.Response:
async def _run() -> httpx.Response: async def _run() -> httpx.Response:
transport = httpx.ASGITransport(app=app) transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
return await client.post( return await client.post(
"/api/files/upload", "/api/files/upload",
data={"target_path": target_path}, data={"target_path": target_path, "overwrite": "true" if overwrite else "false"},
files={"file": (filename, content, "application/octet-stream")}, files={"file": (filename, content, "application/octet-stream")},
) )
@@ -184,3 +184,21 @@ class UploadApiGoldenTest(unittest.TestCase):
self.assertEqual(history[0]["operation"], "upload") self.assertEqual(history[0]["operation"], "upload")
self.assertEqual(history[0]["status"], "failed") self.assertEqual(history[0]["status"], "failed")
self.assertEqual(history[0]["error_code"], "already_exists") self.assertEqual(history[0]["error_code"], "already_exists")
def test_upload_overwrite_existing_file_success(self) -> None:
existing = self.uploads_dir / "hello.txt"
existing.write_text("existing", encoding="utf-8")
response = self._upload(
target_path="storage1/uploads",
filename="hello.txt",
content=b"replacement",
overwrite=True,
)
self.assertEqual(response.status_code, 200)
self.assertEqual((self.uploads_dir / "hello.txt").read_bytes(), b"replacement")
history = self._get_history()
self.assertEqual(history[0]["operation"], "upload")
self.assertEqual(history[0]["status"], "completed")
@@ -203,6 +203,19 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('async function handleUploadSelection(event)', app_js) self.assertIn('async function handleUploadSelection(event)', app_js)
self.assertIn('uploadElements().input.onchange = handleUploadSelection;', app_js) self.assertIn('uploadElements().input.onchange = handleUploadSelection;', app_js)
self.assertIn('"/api/files/upload"', app_js) self.assertIn('"/api/files/upload"', app_js)
self.assertIn('function ensureUploadConflictModal()', app_js)
self.assertIn('function promptUploadConflict(', app_js)
self.assertIn('formData.append("overwrite", overwrite ? "true" : "false")', app_js)
self.assertIn('createButton("Overwrite"', app_js)
self.assertIn('createButton("Overwrite all"', app_js)
self.assertIn('createButton("Skip"', app_js)
self.assertIn('createButton("Skip all"', app_js)
self.assertIn('createButton("Cancel"', app_js)
self.assertIn('if (err.code !== "already_exists") {', app_js)
self.assertIn('if (choice === "overwrite_all") {', app_js)
self.assertIn('if (uploadState.skipAll) {', app_js)
self.assertIn('if (choice === "skip_all") {', app_js)
self.assertIn('uploadState.skipAll = true;', app_js)
self.assertIn('Upload to: ${uploadState.targetPath}', app_js) self.assertIn('Upload to: ${uploadState.targetPath}', app_js)
self.assertIn('Uploading ${total} file', app_js) self.assertIn('Uploading ${total} file', app_js)
self.assertIn('`/api/files/thumbnail?', app_js) self.assertIn('`/api/files/thumbnail?', app_js)
+186 -7
View File
@@ -62,6 +62,12 @@ let uploadState = {
targetPath: "", targetPath: "",
files: [], files: [],
index: 0, index: 0,
overwriteAll: false,
skipAll: false,
successfulCount: 0,
skippedCount: 0,
cancelled: false,
conflictResolver: null,
}; };
let settingsState = { let settingsState = {
activeTab: "general", activeTab: "general",
@@ -319,6 +325,72 @@ function uploadElements() {
}; };
} }
function uploadConflictElements() {
return {
overlay: document.getElementById("upload-conflict-modal"),
title: document.getElementById("upload-conflict-title"),
target: document.getElementById("upload-conflict-target"),
fileName: document.getElementById("upload-conflict-file-name"),
message: document.getElementById("upload-conflict-message"),
overwriteButton: document.getElementById("upload-conflict-overwrite-btn"),
overwriteAllButton: document.getElementById("upload-conflict-overwrite-all-btn"),
skipButton: document.getElementById("upload-conflict-skip-btn"),
skipAllButton: document.getElementById("upload-conflict-skip-all-btn"),
cancelButton: document.getElementById("upload-conflict-cancel-btn"),
};
}
function ensureUploadConflictModal() {
if (document.getElementById("upload-conflict-modal")) {
return uploadConflictElements();
}
const overlay = document.createElement("div");
overlay.id = "upload-conflict-modal";
overlay.className = "popup-overlay hidden";
const card = document.createElement("div");
card.className = "popup-card";
card.setAttribute("role", "dialog");
card.setAttribute("aria-modal", "true");
card.setAttribute("aria-labelledby", "upload-conflict-title");
const title = document.createElement("h3");
title.id = "upload-conflict-title";
title.textContent = "Upload conflict";
const target = document.createElement("div");
target.id = "upload-conflict-target";
target.className = "popup-meta";
const fileName = document.createElement("div");
fileName.id = "upload-conflict-file-name";
fileName.className = "popup-meta";
const message = document.createElement("div");
message.id = "upload-conflict-message";
message.className = "popup-meta";
const actions = document.createElement("div");
actions.className = "popup-actions";
const overwriteButton = createButton("Overwrite", () => resolveUploadConflict("overwrite"));
overwriteButton.id = "upload-conflict-overwrite-btn";
const overwriteAllButton = createButton("Overwrite all", () => resolveUploadConflict("overwrite_all"));
overwriteAllButton.id = "upload-conflict-overwrite-all-btn";
const skipButton = createButton("Skip", () => resolveUploadConflict("skip"));
skipButton.id = "upload-conflict-skip-btn";
const skipAllButton = createButton("Skip all", () => resolveUploadConflict("skip_all"));
skipAllButton.id = "upload-conflict-skip-all-btn";
const cancelButton = createButton("Cancel", () => resolveUploadConflict("cancel"));
cancelButton.id = "upload-conflict-cancel-btn";
actions.append(overwriteButton, overwriteAllButton, skipButton, skipAllButton, cancelButton);
card.append(title, target, fileName, message, actions);
overlay.append(card);
document.body.append(overlay);
return uploadConflictElements();
}
async function apiRequest(method, url, body) { async function apiRequest(method, url, body) {
const options = { method, headers: {} }; const options = { method, headers: {} };
if (body !== undefined) { if (body !== undefined) {
@@ -334,9 +406,19 @@ async function apiRequest(method, url, body) {
return data; return data;
} }
async function uploadFileRequest(targetPath, file) { function createApiError(response, data) {
const error = data.error || {};
const err = new Error(error.message || `HTTP ${response.status}`);
err.code = error.code || null;
err.status = response.status;
err.details = error.details || {};
return err;
}
async function uploadFileRequest(targetPath, file, overwrite = false) {
const formData = new FormData(); const formData = new FormData();
formData.append("target_path", targetPath); formData.append("target_path", targetPath);
formData.append("overwrite", overwrite ? "true" : "false");
formData.append("file", file, file.name); formData.append("file", file, file.name);
const response = await fetch("/api/files/upload", { const response = await fetch("/api/files/upload", {
@@ -345,8 +427,7 @@ async function uploadFileRequest(targetPath, file) {
}); });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
const error = data.error || {}; throw createApiError(response, data);
throw new Error(error.message || `HTTP ${response.status}`);
} }
return data; return data;
} }
@@ -377,6 +458,12 @@ function resetUploadProgress() {
uploadState.targetPath = ""; uploadState.targetPath = "";
uploadState.files = []; uploadState.files = [];
uploadState.index = 0; uploadState.index = 0;
uploadState.overwriteAll = false;
uploadState.skipAll = false;
uploadState.successfulCount = 0;
uploadState.skippedCount = 0;
uploadState.cancelled = false;
uploadState.conflictResolver = null;
elements.button.disabled = false; elements.button.disabled = false;
elements.target.textContent = ""; elements.target.textContent = "";
elements.currentFile.textContent = ""; elements.currentFile.textContent = "";
@@ -407,6 +494,34 @@ function openUploadPicker() {
elements.input.click(); elements.input.click();
} }
function isUploadConflictOpen() {
const overlay = document.getElementById("upload-conflict-modal");
return Boolean(overlay) && !overlay.classList.contains("hidden");
}
function resolveUploadConflict(choice) {
const resolver = uploadState.conflictResolver;
if (!resolver) {
return;
}
uploadState.conflictResolver = null;
const elements = uploadConflictElements();
elements.overlay.classList.add("hidden");
resolver(choice);
}
function promptUploadConflict(fileName, targetPath, message) {
const elements = ensureUploadConflictModal();
elements.title.textContent = "Upload conflict";
elements.target.textContent = `Upload to: ${targetPath}`;
elements.fileName.textContent = `File: ${fileName}`;
elements.message.textContent = message || "Target path already exists.";
elements.overlay.classList.remove("hidden");
return new Promise((resolve) => {
uploadState.conflictResolver = resolve;
});
}
async function handleUploadSelection(event) { async function handleUploadSelection(event) {
const files = Array.from(event.target.files || []); const files = Array.from(event.target.files || []);
event.target.value = ""; event.target.value = "";
@@ -419,18 +534,71 @@ async function handleUploadSelection(event) {
uploadState.targetPath = targetPath; uploadState.targetPath = targetPath;
uploadState.files = files; uploadState.files = files;
uploadState.index = 0; uploadState.index = 0;
uploadState.overwriteAll = false;
uploadState.skipAll = false;
uploadState.successfulCount = 0;
uploadState.skippedCount = 0;
uploadState.cancelled = false;
setError("actions-error", ""); setError("actions-error", "");
updateUploadProgress(); updateUploadProgress();
try { try {
outer:
for (let index = 0; index < files.length; index += 1) { for (let index = 0; index < files.length; index += 1) {
uploadState.index = index; uploadState.index = index;
updateUploadProgress(); updateUploadProgress();
await uploadFileRequest(targetPath, files[index]); let overwrite = uploadState.overwriteAll;
} while (true) {
await loadBrowsePane(state.activePane); try {
setStatus(`Upload: ${files.length} file${files.length === 1 ? "" : "s"} uploaded`); await uploadFileRequest(targetPath, files[index], overwrite);
uploadState.successfulCount += 1;
break;
} catch (err) { } catch (err) {
if (err.code !== "already_exists") {
throw err;
}
if (uploadState.skipAll) {
uploadState.skippedCount += 1;
break;
}
const choice = await promptUploadConflict(files[index].name, targetPath, err.message);
if (choice === "overwrite") {
overwrite = true;
continue;
}
if (choice === "overwrite_all") {
uploadState.overwriteAll = true;
overwrite = true;
continue;
}
if (choice === "skip") {
uploadState.skippedCount += 1;
break;
}
if (choice === "skip_all") {
uploadState.skipAll = true;
uploadState.skippedCount += 1;
break;
}
uploadState.cancelled = true;
break outer;
}
}
}
if (uploadState.successfulCount > 0) {
await loadBrowsePane(state.activePane);
}
if (uploadState.cancelled) {
setStatus(`Upload: ${uploadState.successfulCount}/${files.length} file${files.length === 1 ? "" : "s"} uploaded before cancel`);
} else if (uploadState.skippedCount > 0) {
setStatus(`Upload: ${uploadState.successfulCount} uploaded, ${uploadState.skippedCount} skipped`);
} else {
setStatus(`Upload: ${uploadState.successfulCount} file${uploadState.successfulCount === 1 ? "" : "s"} uploaded`);
}
} catch (err) {
if (uploadState.successfulCount > 0) {
await loadBrowsePane(state.activePane);
}
setActionError("Upload", err); setActionError("Upload", err);
} finally { } finally {
resetUploadProgress(); resetUploadProgress();
@@ -1558,6 +1726,10 @@ function isBatchMovePopupOpen() {
return !batchMoveElements().overlay.classList.contains("hidden"); return !batchMoveElements().overlay.classList.contains("hidden");
} }
function isUploadConflictModalOpen() {
return isUploadConflictOpen();
}
function isViewerOpen() { function isViewerOpen() {
return !viewerElements().overlay.classList.contains("hidden"); return !viewerElements().overlay.classList.contains("hidden");
} }
@@ -2664,6 +2836,13 @@ function handleKeyboardShortcuts(event) {
} }
return; return;
} }
if (isUploadConflictModalOpen()) {
if (event.key === "Escape") {
event.preventDefault();
resolveUploadConflict("cancel");
}
return;
}
if (isMovePopupOpen()) { if (isMovePopupOpen()) {
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();