Pass a temp copy of dragged attachment files on Windows
Some checks failed
CI / Detect changes (push) Has been cancelled
CI / Utilities Tests (push) Has been cancelled
CI / Build, Upload (push) Has been cancelled
CI / Test () (push) Has been cancelled
CI / Test (macOS NFS) (push) Has been cancelled

Allowing only 'copy' for file attachment drags kept File Explorer from
moving files out of 'storage' but locked the cursor at '+' even for
moves within Zotero. Instead, provide the file via a flavor data
provider that copies it to the temp directory when a target asks for it,
so the drag can allow 'copyMove' again and Explorer's move only touches
the copy. A copy's directory is removed once Explorer has moved the file
out of it, at the next drag, or with the temp directory at shutdown.

(cherry picked from commit acc4fd5ad2)
This commit is contained in:
Dan Stillman 2026-09-16 15:01:47 -04:00
parent c59734e974
commit 80bc5565e0
3 changed files with 172 additions and 4 deletions

View file

@ -242,3 +242,103 @@ Zotero.FileDragDataProvider.prototype = {
}
}
/**
* Implements nsIFlavorDataProvider for a single dragged attachment file, handing drop targets a
* copy in the temp directory instead of the file in storage. The copy is made only when a target
* asks for the file, so drags within Zotero don't copy anything, and a target that moves the
* dropped file (e.g., File Explorer on Windows for an unmodified drag) moves the copy.
*
* A target can still be working on the copy after the drag ends -- e.g., while a replace/skip
* dialog is open -- and gives no signal when it's done, so a copy's directory is removed only
* once it's empty (the target moved the file), at the start of the next drag, or with the temp
* directory at shutdown.
*
* @param {String} path
*/
Zotero.TempFileDragDataProvider = function (path) {
this._path = path;
this._file = null;
};
Zotero.TempFileDragDataProvider._copiedSinceDragEnd = false;
/**
* Remove directories created for copies
*
* @param {Object} [options]
* @param {Boolean} [options.onlyEmpty=false] - Remove only directories whose file the target has
* moved away, which is safe while a target may still be using another copy
*/
Zotero.TempFileDragDataProvider.removeCopies = async function ({ onlyEmpty = false } = {}) {
let tmpDir = Zotero.getTempDirectory().path;
let children;
try {
children = await IOUtils.getChildren(tmpDir);
}
catch (e) {
Zotero.debug(e, 2);
return;
}
for (let dir of children) {
if (!/^drag(-\d+)?$/.test(PathUtils.filename(dir))) {
continue;
}
try {
if (onlyEmpty) {
if ((await IOUtils.getChildren(dir)).length) {
continue;
}
// Non-recursive, so a copy made in the meantime is never removed
await IOUtils.remove(dir);
}
else {
await IOUtils.remove(dir, { recursive: true });
}
Zotero.debug(`Removed drag copy directory ${dir}`);
}
// A target may still have a copy open
catch (e) {
Zotero.debug(e, 2);
}
}
};
/**
* Call when a drag that offered files through these providers ends
*/
Zotero.TempFileDragDataProvider.onDragEnd = function () {
if (!this._copiedSinceDragEnd) {
return;
}
this._copiedSinceDragEnd = false;
// An unmodified drop in File Explorer moves the copy right away, so remove the emptied
// directory shortly after, with a later pass for a target that took longer
for (let delay of [2000, 30000]) {
setTimeout(() => this.removeCopies({ onlyEmpty: true }), delay);
}
};
Zotero.TempFileDragDataProvider.prototype = {
QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]),
getFlavorData(_transferable, flavor, data) {
if (flavor != 'application/x-moz-file') {
return;
}
// Targets ask for the file repeatedly during a drag, so make the copy once
if (!this._file) {
let file = Zotero.File.pathToFile(this._path);
// Copy into a directory of its own so that the copy keeps its filename
let dir = Zotero.getTempDirectory();
dir.append('drag');
dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
Zotero.debug(`Copying ${this._path} to ${dir.path} for drag`);
file.copyTo(dir, null);
Zotero.TempFileDragDataProvider._copiedSinceDragEnd = true;
dir.append(file.leafName);
this._file = dir;
}
data.value = this._file;
}
};

View file

@ -3285,6 +3285,11 @@ Zotero.Utilities.Internal.onDragItems = function (event, itemIDs, dragImage = ev
.filter(path => path);
if (files.length) {
if (Zotero.isWin) {
// Copies from earlier drags are no longer in use
Zotero.TempFileDragDataProvider.removeCopies();
}
// Advanced multi-file drag (with unique filenames, which otherwise happen automatically on
// Windows but not Linux) and auxiliary snapshot file copying on macOS
let dataProvider;
@ -3307,15 +3312,30 @@ Zotero.Utilities.Internal.onDragItems = function (event, itemIDs, dragImage = ev
event.dataTransfer.mozSetDataAt("text/x-moz-url", uri + '\n' + file.leafName, i);
}
// Allow dragging to web targets (e.g., Gmail)
// Allow dragging to the filesystem and web targets (e.g., Gmail). On Windows, hand
// targets a copy in the temp directory, since File Explorer moves a dropped file for
// an unmodified drag and would otherwise move it out of storage.
Zotero.debug("Adding application/x-moz-file " + i);
event.dataTransfer.mozSetDataAt("application/x-moz-file", file, i);
event.dataTransfer.mozSetDataAt(
"application/x-moz-file",
Zotero.isWin ? new Zotero.TempFileDragDataProvider(files[i]) : file,
i
);
if (Zotero.isWin || Zotero.isLinux) {
// Copy rather than move (Windows) or symlink (Linux) for an unmodified drag
if (Zotero.isLinux) {
// Copy rather than symlink for an unmodified drag. Drops within Zotero can still
// move -- see LibraryTreeView::setDropEffect().
event.dataTransfer.effectAllowed = 'copy';
}
}
if (Zotero.isWin) {
event.currentTarget.addEventListener(
'dragend',
() => Zotero.TempFileDragDataProvider.onDragEnd(),
{ once: true }
);
}
}
// Get Quick Copy format for current URL (set via /ping from connector)

View file

@ -0,0 +1,48 @@
"use strict";
describe("Zotero.TempFileDragDataProvider", function () {
it("should hand out a copy of the file with the same name and remove its directory once emptied", async function () {
var attachment = await importFileAttachment('test.pdf');
var path = attachment.getFilePath();
var provider = new Zotero.TempFileDragDataProvider(path);
var data = {};
provider.getFlavorData(null, 'application/x-moz-file', data);
var file = data.value.QueryInterface(Ci.nsIFile);
var copyPath = file.path;
var copyDir = PathUtils.parent(copyPath);
assert.notEqual(copyPath, path);
assert.equal(file.leafName, PathUtils.filename(path));
assert.isTrue(copyPath.startsWith(Zotero.getTempDirectory().path));
assert.equal(
await Zotero.File.getBinaryContentsAsync(copyPath),
await Zotero.File.getBinaryContentsAsync(path)
);
// Repeated requests get the same copy
var data2 = {};
provider.getFlavorData(null, 'application/x-moz-file', data2);
assert.equal(data2.value.QueryInterface(Ci.nsIFile).path, copyPath);
// A copy the target may still be using stays
await Zotero.TempFileDragDataProvider.removeCopies({ onlyEmpty: true });
assert.isTrue(await IOUtils.exists(copyPath));
// Once the target has moved the file away, the directory goes
await IOUtils.remove(copyPath);
await Zotero.TempFileDragDataProvider.removeCopies({ onlyEmpty: true });
assert.isFalse(await IOUtils.exists(copyDir));
});
it("should remove leftover copies from earlier drags", async function () {
var attachment = await importFileAttachment('test.pdf');
var provider = new Zotero.TempFileDragDataProvider(attachment.getFilePath());
var data = {};
provider.getFlavorData(null, 'application/x-moz-file', data);
var copyDir = PathUtils.parent(data.value.QueryInterface(Ci.nsIFile).path);
await Zotero.TempFileDragDataProvider.removeCopies();
assert.isFalse(await IOUtils.exists(copyDir));
});
});