From 44a0028b8bbe54a7b1b8ee8b72e030148f35ca63 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Wed, 9 Sep 2026 15:44:33 +0100
Subject: [PATCH 1/2] fix(import): accept a file dropped anywhere, and say why
one is refused
Dropping an audio file anywhere except the URL zone was left to the browser,
which navigates to it. In a tab that is merely surprising. In the desktop
shell there is no address bar and no back button, so the window is replaced by
the webview's bare media player and the only way out is to quit the app, which
is what the reporter hit with a .ogg.
A file dropped anywhere in the window is now treated as a drop on the URL
zone. Preventing the default is what stops the navigation; routing it to
applyFiles is what makes the gesture do the obvious thing rather than nothing.
The URL zone keeps its hover highlight, so it still shows where the file is
going.
Both handlers are guarded on the drag carrying "Files", so the library's own
drags -- tracks between folders, into the lanes, onto the trash -- are
untouched. Their handlers already bail before preventDefault when the drag is
not theirs, so the two never see each other's gestures.
A refused file now answers in the box it was aimed at, in red, rather than in
the error panel. That panel is for a job that failed: it takes over a region
and carries a retry button, and a file declined before anything started has
nothing to retry. Both hard refusals use it, unsupported format and too large,
because two different mechanisms for two refusals in the same gesture is how
the next report gets written. The partial-skip case still uses the panel:
files were accepted there, so it is a note rather than a refusal.
The message clears on the next drop, on typing, and after six seconds, so it
can never sit beside an armed file describing something else.
Found while doing this, and worth knowing: static/css/widgets.css is not
loaded by index.html and is referenced by nothing. The styling for this went
in there first and was invisible, which the test caught only because it
asserts the computed colour rather than the presence of a class. Left alone
here; it wants its own look.
tests/e2e/file-drop.spec.mjs covers a drop on the body arming the import, the
same with a track open, the refusal being visible and red and hiding the
input, a refusal clearing when something valid arrives or when the user types,
and a library drag not being mistaken for a file.
The navigation itself cannot be asserted: a synthetic drop never triggers the
browser's real navigation, so a page.url() check passes with the fix removed
and proves nothing. The tests assert the drop's default was cancelled instead,
which is the mechanism that prevents it. Verified against the previous
behaviour: four of the seven fail.
Closes #584
---
static/css/daw.css | 19 ++++
static/index.html | 5 +
static/js/main.js | 65 +++++++++++--
tests/e2e/file-drop.spec.mjs | 183 +++++++++++++++++++++++++++++++++++
4 files changed, 266 insertions(+), 6 deletions(-)
create mode 100644 tests/e2e/file-drop.spec.mjs
diff --git a/static/css/daw.css b/static/css/daw.css
index 0a034dda..30da6ad9 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -183,6 +183,25 @@ input, textarea { font-family: inherit; }
}
.daw-url-zone.has-file #url { display: none; }
+/* Why a dropped file was refused, said in the box it was aimed at.
+ The full error panel is for a job that failed: it takes a region and carries
+ a retry button, and a file declined before anything started has nothing to
+ retry. Sits where the input text does, and hides it, so the box says one
+ thing at a time (#584). */
+.url-drop-error {
+ flex: 1;
+ min-width: 0;
+ color: var(--danger);
+ font-size: 13px;
+ line-height: 1.25;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+/* :has(), not a sibling combinator: the message follows the input in the
+ markup, so `~` points the wrong way. */
+.daw-url-zone:has(.url-drop-error:not(.hidden)) #url { display: none; }
+
/* Composer vertical divider */
.daw-composer-sep {
width: 1px;
diff --git a/static/index.html b/static/index.html
index 805db14a..d7862198 100644
--- a/static/index.html
+++ b/static/index.html
@@ -50,6 +50,11 @@
spellcheck="false"
autocomplete="off"
/>
+
+
diff --git a/static/js/main.js b/static/js/main.js
index 91d70a16..bf0d8e50 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -540,8 +540,33 @@ function wireFileDrop() {
const fileName = document.getElementById("fileName");
const fileSize = document.getElementById("fileSize");
const fileClear = document.getElementById("fileClear");
+ const dropError = document.getElementById("urlDropError");
if (!urlWrap || !urlInput || !fileInput || !filePill) return;
+ // Why a dropped file was refused, said in the box it was aimed at.
+ //
+ // showError() is the panel for a job that failed: it takes over a region and
+ // carries a retry button. A file the importer declined before anything
+ // started has nothing to retry and does not deserve that much room, and the
+ // reason belongs next to the gesture rather than somewhere else on screen.
+ //
+ // Cleared on the next drop, on typing, and on a timer, so it can never be
+ // mistaken for the state of a file that is currently armed.
+ let dropErrorTimer = null;
+ function showDropError(message) {
+ if (!dropError) return;
+ dropError.textContent = message;
+ dropError.classList.remove("hidden");
+ clearTimeout(dropErrorTimer);
+ dropErrorTimer = setTimeout(clearDropError, 6000);
+ }
+ function clearDropError() {
+ clearTimeout(dropErrorTimer);
+ dropError?.classList.add("hidden");
+ if (dropError) dropError.textContent = "";
+ }
+ urlInput.addEventListener("input", clearDropError);
+
function formatBytes(n) {
return n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
}
@@ -560,13 +585,13 @@ function wireFileDrop() {
// stray file. Only complain if nothing usable came through.
const audio = all.filter(isAudioFile);
if (!audio.length) {
- showError(t("upload.unsupportedFormat"));
+ showDropError(t("upload.unsupportedFormat"));
return;
}
const files = audio.filter((f) => f.size <= MAX_UPLOAD_BYTES);
const oversized = audio.length - files.length;
if (!files.length) {
- showError(t("upload.fileTooLarge", { size: formatBytes(audio[0].size), max: formatBytes(MAX_UPLOAD_BYTES) }));
+ showDropError(t("upload.fileTooLarge", { size: formatBytes(audio[0].size), max: formatBytes(MAX_UPLOAD_BYTES) }));
return;
}
@@ -579,6 +604,7 @@ function wireFileDrop() {
const bytes = files.reduce((sum, f) => sum + f.size, 0);
fileSize.textContent = formatBytes(bytes);
}
+ clearDropError();
filePill.classList.remove("hidden");
urlWrap.classList.add("has-file");
// Cache the File objects directly on the element so job.js can always
@@ -617,16 +643,43 @@ function wireFileDrop() {
// happily import the same file twice.
fileInput._clear = clearFile;
+ const draggingFiles = (e) => !!e.dataTransfer?.types?.includes("Files");
+
+ // The URL zone keeps its own hover affordance. It is no longer the only place
+ // a file can land, so this is now about showing where it will go rather than
+ // about being the only target that works.
urlWrap.addEventListener("dragover", (e) => {
- if (!e.dataTransfer.types.includes("Files")) return;
- e.preventDefault();
- e.dataTransfer.dropEffect = "copy";
+ if (!draggingFiles(e)) return;
urlWrap.classList.add("drag-over");
});
urlWrap.addEventListener("dragleave", (e) => {
if (!urlWrap.contains(e.relatedTarget)) urlWrap.classList.remove("drag-over");
});
- urlWrap.addEventListener("drop", (e) => {
+
+ // A file dropped anywhere in the window is treated as a drop on the URL zone.
+ //
+ // Not a convenience. Everywhere else was previously left to the browser,
+ // which navigates to the file: in a tab that is merely surprising, but the
+ // desktop shell has no address bar and no back button, so the window is
+ // replaced by the webview's bare media player and the only way out is to
+ // quit the app (#584). Preventing the default is what fixes that; routing it
+ // to applyFiles is what makes the gesture do the obvious thing instead of
+ // nothing.
+ //
+ // Both handlers are guarded on "Files" so the library's own drags -- tracks
+ // between folders, into the lanes, onto the trash -- are untouched. Those
+ // carry no file list, and their handlers bail before preventDefault for the
+ // same reason, so the two never see each other's gestures.
+ //
+ // dragover must preventDefault too: without it the browser refuses the drop
+ // and no drop event is ever delivered to cancel.
+ document.addEventListener("dragover", (e) => {
+ if (!draggingFiles(e)) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "copy";
+ });
+ document.addEventListener("drop", (e) => {
+ if (!draggingFiles(e)) return;
e.preventDefault();
urlWrap.classList.remove("drag-over");
applyFiles(e.dataTransfer.files);
diff --git a/tests/e2e/file-drop.spec.mjs b/tests/e2e/file-drop.spec.mjs
new file mode 100644
index 00000000..fed2f314
--- /dev/null
+++ b/tests/e2e/file-drop.spec.mjs
@@ -0,0 +1,183 @@
+// A dropped audio file must be treated as an import, wherever it lands.
+//
+// Everywhere outside the URL zone used to be left to the browser, which
+// navigates to the file. In a tab that is surprising; in the desktop shell
+// there is no address bar and no back button, so the window is replaced by the
+// webview's bare media player and the only way out is to quit the app (#584).
+import { test, expect } from "@playwright/test";
+import { openStudio, seedLibrary, stubTauri, stubUpdateCheck } from "./helpers.mjs";
+
+// A real WAV, small enough to be uninteresting. The extension is what the
+// filter actually reads.
+const WAV = Buffer.concat([
+ Buffer.from("RIFF"), Buffer.from([36, 0, 0, 0]), Buffer.from("WAVEfmt "),
+ Buffer.from([16, 0, 0, 0, 1, 0, 1, 0, 68, 172, 0, 0, 136, 88, 1, 0, 2, 0, 16, 0]),
+ Buffer.from("data"), Buffer.from([0, 0, 0, 0]),
+]);
+
+async function dropOn(page, selector, name = "dropped.wav", bytes = WAV) {
+ const handle = await page.evaluateHandle(
+ ([n, data]) => {
+ const dt = new DataTransfer();
+ dt.items.add(new File([new Uint8Array(data)], n, { type: "audio/wav" }));
+ return dt;
+ },
+ [name, [...bytes]],
+ );
+ await page.dispatchEvent(selector, "dragover", { dataTransfer: handle });
+ await page.dispatchEvent(selector, "drop", { dataTransfer: handle });
+}
+
+// Whether the drop's default action was cancelled, recorded at the end of the
+// bubble chain. This, not page.url(), is what has to be asserted: a synthetic
+// drop never triggers the browser's real navigation, so a URL check passes with
+// the fix ripped out and proves nothing. Cancelling the default is the thing
+// that stops the webview replacing the app with its media player.
+async function watchDropDefault(page) {
+ await page.evaluate(() => {
+ window.__dropDefaultPrevented = null;
+ window.addEventListener(
+ "drop",
+ (e) => { window.__dropDefaultPrevented = e.defaultPrevented; },
+ { capture: false },
+ );
+ });
+}
+
+const dropWasCancelled = (page) => page.evaluate(() => window.__dropDefaultPrevented);
+
+const pill = (page) =>
+ page.evaluate(() => {
+ const p = document.getElementById("filePill");
+ return {
+ visible: p ? !p.classList.contains("hidden") : null,
+ name: document.getElementById("fileName")?.textContent || "",
+ armed: (document.getElementById("fileInput")?._files || []).length,
+ };
+ });
+
+test.describe("file drop", () => {
+ test("a file dropped on the page body arms the import", async ({ page }) => {
+ await seedLibrary(page);
+ await stubTauri(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.waitForSelector(".url-wrap");
+
+ expect((await pill(page)).visible).toBe(false);
+
+ // Deliberately not the URL zone. This is the gesture that used to navigate.
+ await dropOn(page, "body");
+
+ const p = await pill(page);
+ expect(p.visible, "the import pill is armed").toBe(true);
+ expect(p.name).toBe("dropped.wav");
+ expect(p.armed).toBe(1);
+ });
+
+ test("the drop's default is cancelled, so the webview cannot navigate", async ({ page }) => {
+ await seedLibrary(page);
+ await stubTauri(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.waitForSelector(".url-wrap");
+ await watchDropDefault(page);
+
+ await dropOn(page, "body");
+ await page.waitForTimeout(200);
+
+ expect(await dropWasCancelled(page), "an uncancelled drop is the bug").toBe(true);
+ await expect(page.locator(".url-wrap")).toBeVisible();
+ });
+
+ test("dropping still works with a track open", async ({ page }) => {
+ await openStudio(page, { tauri: true });
+ await dropOn(page, ".daw-content", "second.wav");
+
+ const p = await pill(page);
+ expect(p.visible).toBe(true);
+ expect(p.name).toBe("second.wav");
+ });
+
+ test("a non-audio file is refused rather than navigated to", async ({ page }) => {
+ await seedLibrary(page);
+ await stubTauri(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.waitForSelector(".url-wrap");
+ await watchDropDefault(page);
+
+ await dropOn(page, "body", "notes.txt", Buffer.from("hello"));
+ await page.waitForTimeout(200);
+
+ // Refusing it is not enough: an unsupported file the app declines must
+ // still not be handed back to the browser to open.
+ expect(await dropWasCancelled(page), "a refused file must be cancelled too").toBe(true);
+ expect((await pill(page)).visible, "nothing armed").toBe(false);
+
+ // And the refusal has to be visible, in the box the file was aimed at.
+ const err = page.locator("#urlDropError");
+ await expect(err).toBeVisible();
+ await expect(err).toContainText(/supported/i);
+
+ const shown = await err.evaluate((el) => {
+ const rgb = getComputedStyle(el).color.match(/\d+/g).map(Number);
+ return { rgb, inputHidden: getComputedStyle(document.getElementById("url")).display === "none" };
+ });
+ // Reddish: red dominant over both other channels rather than a hard-coded
+ // hex, so retheming the danger colour does not fail this.
+ expect(shown.rgb[0], `red channel in rgb(${shown.rgb})`).toBeGreaterThan(shown.rgb[1] + 40);
+ expect(shown.rgb[0]).toBeGreaterThan(shown.rgb[2] + 40);
+ expect(shown.inputHidden, "the box says one thing at a time").toBe(true);
+ });
+
+ test("a refusal clears itself once something valid arrives", async ({ page }) => {
+ await seedLibrary(page);
+ await stubTauri(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.waitForSelector(".url-wrap");
+
+ await dropOn(page, "body", "notes.txt", Buffer.from("hello"));
+ await expect(page.locator("#urlDropError")).toBeVisible();
+
+ // A stale refusal sitting next to an armed file would describe the wrong
+ // thing entirely.
+ await dropOn(page, "body", "good.wav");
+ await expect(page.locator("#urlDropError")).toBeHidden();
+ expect((await pill(page)).name).toBe("good.wav");
+ });
+
+ test("typing dismisses a refusal", async ({ page }) => {
+ await seedLibrary(page);
+ await stubTauri(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.waitForSelector(".url-wrap");
+
+ await dropOn(page, "body", "notes.txt", Buffer.from("hello"));
+ await expect(page.locator("#urlDropError")).toBeVisible();
+
+ // The input is hidden while the message shows, so the message must not be
+ // able to lock the user out of the box it is sitting in.
+ await page.locator("#urlDropError").click();
+ await page.evaluate(() => {
+ const i = document.getElementById("url");
+ i.value = "a";
+ i.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ await expect(page.locator("#urlDropError")).toBeHidden();
+ await expect(page.locator("#url")).toBeVisible();
+ });
+
+ test("a library drag is not mistaken for a file drop", async ({ page }) => {
+ // The library's own drags carry no file list. If the document handler
+ // stopped guarding on that, dragging a track would arm the importer.
+ await openStudio(page, { tauri: true });
+ const handle = await page.evaluateHandle(() => new DataTransfer());
+ await page.dispatchEvent("body", "dragover", { dataTransfer: handle });
+ await page.dispatchEvent("body", "drop", { dataTransfer: handle });
+
+ expect((await pill(page)).visible).toBe(false);
+ });
+});
From c21f1c368ac7058c7eceb7433c405bdfdf45de6a Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Wed, 9 Sep 2026 16:13:46 +0100
Subject: [PATCH 2/2] fix(import): drop the :has() dependency from the refusal
message
The rule that hides the input while a refusal is showing used :has(). It reads
better than the alternative, and a sibling combinator cannot do the job at all
because the message follows the input in the markup.
But this ships in three webviews and the oldest is the one that matters:
WebKitGTK only grew :has() in 2.38, and Linux is exactly where #584 was
reported from. On an older WebKitGTK the message and the input would both
render and compete for the same flex space. Degraded rather than broken, and
avoidable for one line.
A class toggled alongside the message works in every engine. The codebase had
no other :has() before this, so this keeps it at none.
The rest of the change was already portable: HTML5 drag-and-drop with
preventDefault is the same in WebView2, WKWebView and WebKitGTK, and
dragDropEnabled is false in the single tauri.conf.json with no per-platform
override, so the webview receives the events on all three.
---
static/css/daw.css | 10 +++++++---
static/js/main.js | 2 ++
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/static/css/daw.css b/static/css/daw.css
index 30da6ad9..affda047 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -198,9 +198,13 @@ input, textarea { font-family: inherit; }
text-overflow: ellipsis;
white-space: nowrap;
}
-/* :has(), not a sibling combinator: the message follows the input in the
- markup, so `~` points the wrong way. */
-.daw-url-zone:has(.url-drop-error:not(.hidden)) #url { display: none; }
+/* A plain class, toggled from JS alongside the message.
+ `:has()` reads better here, and a sibling combinator cannot work at all
+ because the message follows the input in the markup. But this ships in three
+ webviews, and the oldest of them is the one that matters: WebKitGTK only
+ grew :has() in 2.38, and Linux is where this bug was reported from. A class
+ costs one line in the handler and works in every engine. */
+.daw-url-zone.has-drop-error #url { display: none; }
/* Composer vertical divider */
.daw-composer-sep {
diff --git a/static/js/main.js b/static/js/main.js
index bf0d8e50..f4987726 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -557,12 +557,14 @@ function wireFileDrop() {
if (!dropError) return;
dropError.textContent = message;
dropError.classList.remove("hidden");
+ urlWrap.classList.add("has-drop-error");
clearTimeout(dropErrorTimer);
dropErrorTimer = setTimeout(clearDropError, 6000);
}
function clearDropError() {
clearTimeout(dropErrorTimer);
dropError?.classList.add("hidden");
+ urlWrap.classList.remove("has-drop-error");
if (dropError) dropError.textContent = "";
}
urlInput.addEventListener("input", clearDropError);