I have a table which represents files. I want to be able to drag and drop 1+ rows into iTerm or TextEdit as file path strings. I'm using the modern NSFilePromiseProvider
for this. I have followed the Support Image Export by Providing File Promises sample code and I now have a FilePromiseProvider
:
class FilePromiseProvider: NSFilePromiseProvider {
struct UserInfoKeys {
static let url = "url"
}
override func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] {
var types = super.writableTypes(for: pasteboard)
types.append(contentsOf: [.fileURL, .string]) // <-- Added .string
return types
}
override func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? {
guard let userInfoDict = userInfo as? [String: Any] else { return nil }
switch type {
case .fileURL:
// Incoming type is "public.file-url", return (from our userInfo) the URL.
if let url = userInfoDict[FilePromiseProvider.UserInfoKeys.url] as? NSURL {
return url.pasteboardPropertyList(forType: type)
}
case .string:
if let url = userInfoDict[FilePromiseProvider.UserInfoKeys.url] as? NSURL {
return url.path
}
default: break
}
return super.pasteboardPropertyList(forType: type)
}
public override func writingOptions(forType type: NSPasteboard.PasteboardType, pasteboard: NSPasteboard)
-> NSPasteboard.WritingOptions {
return super.writingOptions(forType: type, pasteboard: pasteboard)
}
}
In addition, I have wired this into the table view like so:
class ResultsViewController: NSTableViewDelegate {
// ... etc
// Queue used for reading and writing file promises.
var filePromiseQueue: OperationQueue = {
let queue = OperationQueue()
return queue
}()
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
var provider: FilePromiseProvider
if let assets = filesArrayController.arrangedObjects as? [MyFile] {
let asset = assets[row]
let ext = asset.url.pathExtension
if #available(macOS 11.0, *) {
let typeIdentifier = UTType(filenameExtension: ext)
provider = FilePromiseProvider(fileType: typeIdentifier!.identifier, delegate: self)
provider.userInfo = [FilePromiseProvider.UserInfoKeys.url: asset.url as Any]
} else {
let typeIdentifier =
UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, ext as CFString, nil)
provider = FilePromiseProvider(fileType: typeIdentifier!.takeRetainedValue() as String, delegate: self)
}
return provider
}
return nil
}
}
extension ResultsViewController: NSFilePromiseProviderDelegate {
func operationQueue(for filePromiseProvider: NSFilePromiseProvider) -> OperationQueue {
return filePromiseQueue
}
// Never called. Why not?
func filePromiseProvider(_ provider: NSFilePromiseProvider, fileNameForType fileType: String) -> String {
let asset = assetFromFilePromiseProvider(provider: provider)
let fileName = (asset?.url.lastPathComponent)!
return (asset?.url.lastPathComponent)!
}
func filePromiseProvider(_ provider: NSFilePromiseProvider,
writePromiseTo url: URL,
completionHandler: @escaping (Error?) -> Void) {
do {
if let asset = assetFromFilePromiseProvider(provider: provider) {
/** Copy the file to the location provided to us. We always do a copy, not a move.
It's important you call the completion handler.
*/
try FileManager.default.copyItem(at: asset.url, to: url)
}
completionHandler(nil)
} catch let error {
OperationQueue.main.addOperation {
self.presentError(error, modalFor: self.view.window!,
delegate: nil, didPresent: nil, contextInfo: nil)
}
completionHandler(error)
}
}
func assetFromFilePromiseProvider(provider: NSFilePromiseProvider) -> MyFile? {
var returnAsset: MyFile?
if let userInfo = provider.userInfo as? [String: Any],
let row = userInfo[FilePromiseProvider.UserInfoKeys.rowNumber] as? Int {
if let assets = filesArrayController.arrangedObjects as? [MyFile] {
returnAsset = assets[row]
}
}
return returnAsset
}
}
And this works! I can drag a row from the table into Finder, for example, and a copy will be made in the dropped folder. I can drop into the Open/Save dialog to switch the folder/filename. I can also drop into Terminal.app and the file's path will be inserted.
However, dropping from my app does NOT work for TextEdit or iTerm 2. The drag simply ends and nothing changes in the dropped document/terminal window. Note that dropping files from Finder or Panic's Transmit in these apps DOES insert the path, so it seems to be some issue with my implementation.
I've modeled my pasteboard on Panic's. Here are the type identifiers for the first dragged item from my app, as reported by Pasteboard Viewer:
Type | Value |
---|---|
com.apple.NSFilePromiseItemMetaData |
binary plist showing public.jpeg |
com.apple.pasteboard.promised-file-name |
empty |
com.apple.pasteboard.promised-suggested-file-name |
empty |
com.apple.pasteboard.promised-file-content-type |
public.jpeg |
com.apple.pasteboard.NSFilePromiseID |
unique for each drag |
public.utf8-plain-text |
E.g. /path/to/file name.jpg |
public.file-url |
E.g. file:///path/to/file%20name.jpg |
dyn.xxxxx |
Two of these, 56 bytes and 0 bytes. Not sure what they are? |
com.apple.pasteboard.promised-file-url |
empty |
Any ideas what I'm doing wrong? One odd thing I've noticed is that filePromiseProvider(_:fileNameForType:)
is never called. And indeed, the promised-file-name
type is empty. But it's empty in Panic's implementation too, so maybe that's expected?
UPDATE: One quirk I've noticed: when I drag a file from Transmit over a Finder window, I get the green Copy Pointer, indicating that dropping will make a copy of the file. I get the same pointer when I drag from Transmit over iTerm 2.
I don't get this pointer when dragging from my app. However, I don't see it for drags from Finder either; Finder drops work correctly, though. All things being equal, I think showing the correct drag pointer would be preferred. I'm actually returning .copy
from tableView(:validateDrop:proposedRow:proposedDropOperation:)
, but unsurprisingly this delegate method isn't called when the drop target is outside of the table view itself.
The problem was that I was declaring a dragging operation of .move
. When I changed that to .copy
, the drop into iTerm and TextEdit started working.
filesTable.setDraggingSourceOperationMask(.copy, forLocal: false)
Thanks to @Willeke for the pointer that helped me resolve this!