Merge pull request #10359 from manaflow-ai/issue-10102-drop-ghosted-regression

Fix terminal file drops being ghosted after pane teardown
This commit is contained in:
Austin Wang
2026-08-18 18:12:07 -07:00
committed by GitHub
6 changed files with 500 additions and 9 deletions
@@ -1,6 +1,5 @@
public import AppKit
public import CmuxTerminalCore
internal import UniformTypeIdentifiers
#if DEBUG
internal import CMUXDebugLog
#endif
@@ -0,0 +1,183 @@
public import Foundation
internal import Darwin
internal import UniformTypeIdentifiers
extension TerminalPasteboardService {
private enum TransientImageCopyError: Error {
case emptySource
case exceedsSizeLimit
}
private static let transientImageCopyChunkSize = 64 * 1024
/// Rehomes temporary image URLs before a terminal receives their path.
///
/// Returns `nil` when a qualifying transient image cannot be copied. A
/// failed copy rejects the complete transfer so a multi-file drop cannot
/// silently omit one of the user's selected files.
///
/// - Parameters:
/// - fileURLs: URLs read from a pasteboard or drag provider.
/// - sourceIsTransient: Whether the provider promised temporary files.
/// - Returns: The original URLs plus owned copies, or `nil` on failure.
public func durableDroppedFileURLs(
_ fileURLs: [URL],
sourceIsTransient: Bool = false
) -> [URL]? {
var durableURLs: [URL] = []
durableURLs.reserveCapacity(fileURLs.count)
var newlyOwnedURLs: [URL] = []
for fileURL in fileURLs {
guard isTransientImageFileURL(
fileURL,
sourceIsTransient: sourceIsTransient
) else {
durableURLs.append(fileURL)
continue
}
let wasAlreadyOwned = isOwnedTemporaryImageFile(fileURL)
guard let durableURL = copyTemporaryImageFile(fileURL) else {
cleanupTransferredTemporaryImageFiles(newlyOwnedURLs)
return nil
}
durableURLs.append(durableURL)
if !wasAlreadyOwned {
newlyOwnedURLs.append(durableURL)
}
}
return durableURLs
}
/// Copies a source-owned temporary image into this service's owned storage.
///
/// The source is opened once with symlink-following disabled, validated via
/// its opened descriptor, and copied through a bounded read loop. This
/// keeps a drag provider from replacing or growing the path after a
/// path-based metadata check.
///
/// - Parameter sourceURL: A local regular image file to retain.
/// - Returns: An owned copy, or `nil` when the source is unavailable,
/// invalid, or exceeds the clipboard image-size limit.
public func copyTemporaryImageFile(_ sourceURL: URL) -> URL? {
let source = sourceURL.standardizedFileURL
guard source.isFileURL,
let type = UTType(filenameExtension: source.pathExtension),
type.conforms(to: .image),
isValidTemporaryDirectory else {
return nil
}
if isOwnedTemporaryImageFile(source) {
return fileManager.fileExists(atPath: source.path) ? source : nil
}
let sourceDescriptor = Darwin.open(
source.path,
O_RDONLY | O_NOFOLLOW | O_CLOEXEC
)
guard sourceDescriptor >= 0 else { return nil }
let sourceHandle = FileHandle(
fileDescriptor: sourceDescriptor,
closeOnDealloc: true
)
defer { try? sourceHandle.close() }
var metadata = Darwin.stat()
guard Darwin.fstat(sourceDescriptor, &metadata) == 0,
metadata.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG),
metadata.st_size > 0,
metadata.st_size <= off_t(Self.maxClipboardImageSize) else {
return nil
}
let destination = temporaryImageFileURL(
fileExtension: sanitizedImageFileExtension(
type.preferredFilenameExtension ?? source.pathExtension
)
).standardizedFileURL
let destinationDescriptor = Darwin.open(
destination.path,
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC,
mode_t(0o600)
)
guard destinationDescriptor >= 0 else { return nil }
let destinationHandle = FileHandle(
fileDescriptor: destinationDescriptor,
closeOnDealloc: true
)
defer { try? destinationHandle.close() }
do {
var copiedByteCount = 0
while let chunk = try sourceHandle.read(
upToCount: Self.transientImageCopyChunkSize
), !chunk.isEmpty {
copiedByteCount += chunk.count
guard copiedByteCount <= Self.maxClipboardImageSize else {
throw TransientImageCopyError.exceedsSizeLimit
}
try destinationHandle.write(contentsOf: chunk)
}
guard copiedByteCount > 0 else {
throw TransientImageCopyError.emptySource
}
try destinationHandle.close()
} catch {
try? destinationHandle.close()
try? fileManager.removeItem(at: destination)
return nil
}
registerOwnedTemporaryImageFile(destination)
return destination
}
private var isValidTemporaryDirectory: Bool {
guard let values = try? temporaryDirectory.resourceValues(
forKeys: [.isDirectoryKey, .isSymbolicLinkKey]
) else {
return false
}
return values.isDirectory == true && values.isSymbolicLink != true
}
private func isTransientImageFileURL(
_ fileURL: URL,
sourceIsTransient: Bool
) -> Bool {
let normalizedURL = fileURL.standardizedFileURL
guard normalizedURL.isFileURL,
let type = UTType(filenameExtension: normalizedURL.pathExtension),
type.conforms(to: .image) else {
return false
}
let path = Self.normalizedTemporaryAlias(normalizedURL.path)
let temporaryRoots = [
temporaryDirectory.standardizedFileURL.path,
fileManager.temporaryDirectory.standardizedFileURL.path,
"/tmp",
"/private/tmp",
].map(Self.normalizedTemporaryAlias)
guard temporaryRoots.contains(where: { root in
path == root || path.hasPrefix(root + "/")
}) else {
return false
}
let filename = normalizedURL.lastPathComponent.lowercased()
return sourceIsTransient || filename.hasPrefix("cmux-drop-")
}
private static func normalizedTemporaryAlias(_ path: String) -> String {
if path == "/tmp" || path.hasPrefix("/tmp/") {
return "/private" + path
}
if path == "/var" || path.hasPrefix("/var/") {
return "/private" + path
}
return path
}
}
+12 -2
View File
@@ -7856,8 +7856,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
func handleDroppedFileURLs(_ urls: [URL]) -> Bool {
executePreparedImageTransfer(
.fileURLs(urls),
let dragTypes = NSPasteboard(name: .drag).types ?? []
guard let durableURLs = GhosttyApp.terminalPasteboard.durableDroppedFileURLs(
urls,
sourceIsTransient: PasteboardFileURLReader.hasPromisedFileURLType(
dragTypes
)
) else {
return false
}
return executePreparedImageTransfer(
.fileURLs(durableURLs),
onCancel: {}
)
}
@@ -9936,6 +9945,7 @@ final class GhosttySurfaceScrollView: NSView {
}
func paneDropTargetForDrop(at localPoint: NSPoint) -> TerminalPaneDropTargetView? {
guard paneDropTargetView.dropContext != nil else { return nil }
guard bounds.contains(localPoint) else { return nil }
let pointInTarget = paneDropTargetView.convert(localPoint, from: self)
guard paneDropTargetView.bounds.contains(pointInTarget) else { return nil }
+57 -6
View File
@@ -34,17 +34,28 @@ enum TerminalImageTransferPreparedContent: Codable, Equatable, Sendable {
enum PasteboardFileURLReader {
static let legacyFilenamesPboardType = NSPasteboard.PasteboardType(rawValue: "NSFilenamesPboardType")
static let promisedFileURLPasteboardType = NSPasteboard.PasteboardType(
rawValue: "com.apple.pasteboard.promised-file-url"
)
static let fileURLPasteboardTypes: Set<NSPasteboard.PasteboardType> = [
.fileURL,
legacyFilenamesPboardType
legacyFilenamesPboardType,
promisedFileURLPasteboardType,
]
static func hasFileURLType(_ pasteboardTypes: [NSPasteboard.PasteboardType]) -> Bool {
return pasteboardTypes.contains { fileURLPasteboardTypes.contains($0) }
}
static func hasPromisedFileURLType(
_ pasteboardTypes: [NSPasteboard.PasteboardType]
) -> Bool {
pasteboardTypes.contains(promisedFileURLPasteboardType)
}
static func fileURLs(from pasteboard: NSPasteboard) -> [URL] {
var fileURLs: [URL] = []
var didReadPromisedFileURL = false
let objects = pasteboard.readObjects(
forClasses: [NSURL.self],
@@ -70,6 +81,30 @@ enum PasteboardFileURLReader {
fileURLs.append(url.standardizedFileURL)
}
for item in pasteboard.pasteboardItems ?? [] {
guard let rawPromisedFileURL = item.string(
forType: promisedFileURLPasteboardType
),
let url = URL(string: rawPromisedFileURL),
url.isFileURL else {
continue
}
fileURLs.append(url.standardizedFileURL)
didReadPromisedFileURL = true
}
// A few providers expose the promised value on the pasteboard rather
// than on an individual item. Preserve that legacy representation as
// a fallback after item-level extraction.
if !didReadPromisedFileURL,
let rawPromisedFileURL = pasteboard.string(
forType: promisedFileURLPasteboardType
),
let url = URL(string: rawPromisedFileURL),
url.isFileURL {
fileURLs.append(url.standardizedFileURL)
}
var seen: Set<String> = []
return fileURLs.filter { url in
seen.insert(url.path).inserted
@@ -413,7 +448,14 @@ enum TerminalImageTransferPlanner {
pasteboard: NSPasteboard,
pasteboardService: TerminalPasteboardService
) -> TerminalImageTransferPreparedContent {
let fileURLs = fileURLs(from: pasteboard)
guard let fileURLs = pasteboardService.durableDroppedFileURLs(
fileURLs(from: pasteboard),
sourceIsTransient: PasteboardFileURLReader.hasPromisedFileURLType(
pasteboard.types ?? []
)
) else {
return .reject
}
if !fileURLs.isEmpty {
return .fileURLs(fileURLs)
}
@@ -452,10 +494,12 @@ enum TerminalImageTransferPlanner {
pasteboard: NSPasteboard,
pasteboardService: TerminalPasteboardService
) -> TerminalImageTransferPreparedContent {
let fileURLs = materializedFileURLs(
guard let fileURLs = materializedFileURLs(
from: pasteboard,
pasteboardService: pasteboardService
)
) else {
return .reject
}
if !fileURLs.isEmpty {
return .fileURLs(fileURLs)
}
@@ -474,8 +518,15 @@ enum TerminalImageTransferPlanner {
private static func materializedFileURLs(
from pasteboard: NSPasteboard,
pasteboardService: TerminalPasteboardService
) -> [URL] {
let urls = fileURLs(from: pasteboard)
) -> [URL]? {
guard let urls = pasteboardService.durableDroppedFileURLs(
fileURLs(from: pasteboard),
sourceIsTransient: PasteboardFileURLReader.hasPromisedFileURLType(
pasteboard.types ?? []
)
) else {
return nil
}
if !urls.isEmpty {
return urls
}
@@ -244,6 +244,229 @@ final class FinderFileDropRegressionTests: XCTestCase {
XCTAssertFalse(text.contains("/clipboard-"))
}
func testTransientImageFileURLDropGetsAnOwnedCopyBeforeInsertion() throws {
let sourceURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-drop-\(UUID().uuidString).png")
try make1x1PNG(color: .systemPurple).write(to: sourceURL)
defer { try? FileManager.default.removeItem(at: sourceURL) }
let ownedDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-owned-drop-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(
at: ownedDirectory,
withIntermediateDirectories: false
)
defer { try? FileManager.default.removeItem(at: ownedDirectory) }
let pasteboard = NSPasteboard(
name: .init("cmux-test-transient-image-drop-\(UUID().uuidString)")
)
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([sourceURL as NSURL]))
let service = TerminalPasteboardService(
temporaryDirectory: ownedDirectory
)
let prepared = TerminalImageTransferPlanner.prepareSynchronously(
pasteboard: pasteboard,
mode: .drop,
pasteboardService: service
)
guard case .fileURLs(let fileURLs) = prepared,
let ownedURL = fileURLs.first else {
return XCTFail("expected a durable image file URL, got \(prepared)")
}
XCTAssertNotEqual(ownedURL.standardizedFileURL, sourceURL.standardizedFileURL)
XCTAssertTrue(FileManager.default.fileExists(atPath: ownedURL.path))
try FileManager.default.removeItem(at: sourceURL)
XCTAssertTrue(
FileManager.default.fileExists(atPath: ownedURL.path),
"The terminal path must survive the source drag provider's cleanup"
)
service.cleanupTransferredTemporaryImageFiles([ownedURL])
XCTAssertFalse(FileManager.default.fileExists(atPath: ownedURL.path))
}
func testPromisedTransientImageURLGetsCopiedEvenWithoutCmuxDropName() throws {
let sourceURL = FileManager.default.temporaryDirectory
.appendingPathComponent("provider-image-\(UUID().uuidString).png")
try make1x1PNG(color: .systemOrange).write(to: sourceURL)
defer { try? FileManager.default.removeItem(at: sourceURL) }
let ownedDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-owned-promised-drop-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(
at: ownedDirectory,
withIntermediateDirectories: false
)
defer { try? FileManager.default.removeItem(at: ownedDirectory) }
let pasteboard = NSPasteboard(
name: .init("cmux-test-promised-image-drop-\(UUID().uuidString)")
)
pasteboard.clearContents()
pasteboard.setString(
sourceURL.absoluteString,
forType: PasteboardFileURLReader.promisedFileURLPasteboardType
)
let service = TerminalPasteboardService(
temporaryDirectory: ownedDirectory
)
let prepared = TerminalImageTransferPlanner.prepareSynchronously(
pasteboard: pasteboard,
mode: .drop,
pasteboardService: service
)
guard case .fileURLs(let fileURLs) = prepared,
let ownedURL = fileURLs.first else {
return XCTFail("expected a durable promised image URL, got \(prepared)")
}
XCTAssertNotEqual(ownedURL.standardizedFileURL, sourceURL.standardizedFileURL)
try FileManager.default.removeItem(at: sourceURL)
XCTAssertTrue(FileManager.default.fileExists(atPath: ownedURL.path))
service.cleanupTransferredTemporaryImageFiles([ownedURL])
}
func testMultiplePromisedFileURLItemsAreReadIndividually() throws {
let firstURL = FileManager.default.temporaryDirectory
.appendingPathComponent("promised-first-" + UUID().uuidString + ".png")
let secondURL = FileManager.default.temporaryDirectory
.appendingPathComponent("promised-second-" + UUID().uuidString + ".png")
try make1x1PNG(color: .systemPink).write(to: firstURL)
try make1x1PNG(color: .systemYellow).write(to: secondURL)
defer {
try? FileManager.default.removeItem(at: firstURL)
try? FileManager.default.removeItem(at: secondURL)
}
let pasteboard = NSPasteboard(
name: .init("cmux-test-multiple-promised-file-urls-" + UUID().uuidString)
)
pasteboard.clearContents()
let firstItem = NSPasteboardItem()
firstItem.setString(
firstURL.absoluteString,
forType: PasteboardFileURLReader.promisedFileURLPasteboardType
)
let secondItem = NSPasteboardItem()
secondItem.setString(
secondURL.absoluteString,
forType: PasteboardFileURLReader.promisedFileURLPasteboardType
)
XCTAssertTrue(pasteboard.writeObjects([firstItem, secondItem]))
XCTAssertEqual(
PasteboardFileURLReader.fileURLs(from: pasteboard),
[firstURL.standardizedFileURL, secondURL.standardizedFileURL]
)
let ownedDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-owned-multiple-promised-" + UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(
at: ownedDirectory,
withIntermediateDirectories: false
)
defer { try? FileManager.default.removeItem(at: ownedDirectory) }
let service = TerminalPasteboardService(temporaryDirectory: ownedDirectory)
let prepared = TerminalImageTransferPlanner.prepareSynchronously(
pasteboard: pasteboard,
mode: .drop,
pasteboardService: service
)
guard case .fileURLs(let durableURLs) = prepared else {
return XCTFail("expected both promised image items to be materialized")
}
XCTAssertEqual(durableURLs.count, 2)
XCTAssertTrue(
durableURLs.allSatisfy { FileManager.default.fileExists(atPath: $0.path) }
)
service.cleanupTransferredTemporaryImageFiles(durableURLs)
}
func testTransientImageURLsUnderTmpAliasesGetOwnedCopies() throws {
let ownedDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-owned-alias-drop-" + UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(
at: ownedDirectory,
withIntermediateDirectories: false
)
defer { try? FileManager.default.removeItem(at: ownedDirectory) }
let service = TerminalPasteboardService(
temporaryDirectory: ownedDirectory
)
for root in ["/tmp", "/private/tmp"] {
let sourceURL = URL(fileURLWithPath: root)
.appendingPathComponent("cmux-drop-" + UUID().uuidString + ".png")
try make1x1PNG(color: .systemTeal).write(to: sourceURL)
defer { try? FileManager.default.removeItem(at: sourceURL) }
guard let durableURL = service.durableDroppedFileURLs([sourceURL])?.first else {
return XCTFail("expected a durable copy for " + sourceURL.path)
}
XCTAssertNotEqual(durableURL.standardizedFileURL, sourceURL.standardizedFileURL)
try FileManager.default.removeItem(at: sourceURL)
XCTAssertTrue(FileManager.default.fileExists(atPath: durableURL.path))
service.cleanupTransferredTemporaryImageFiles([durableURL])
XCTAssertFalse(FileManager.default.fileExists(atPath: durableURL.path))
}
}
func testTransientCopyFailureRejectsMixedFileDrop() throws {
let regularURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-mixed-drop-" + UUID().uuidString + ".txt")
try "plain text".write(to: regularURL, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: regularURL) }
let validTransientURL = URL(fileURLWithPath: "/tmp")
.appendingPathComponent("cmux-drop-" + UUID().uuidString + ".png")
try make1x1PNG(color: .systemBlue).write(to: validTransientURL)
defer { try? FileManager.default.removeItem(at: validTransientURL) }
let missingTransientURL = URL(fileURLWithPath: "/tmp")
.appendingPathComponent("cmux-drop-" + UUID().uuidString + ".png")
try? FileManager.default.removeItem(at: missingTransientURL)
let pasteboard = NSPasteboard(
name: .init("cmux-test-mixed-transient-failure-" + UUID().uuidString)
)
pasteboard.clearContents()
pasteboard.setPropertyList(
[regularURL.path, missingTransientURL.path],
forType: PasteboardFileURLReader.legacyFilenamesPboardType
)
let ownedDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-owned-mixed-drop-" + UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(
at: ownedDirectory,
withIntermediateDirectories: false
)
defer { try? FileManager.default.removeItem(at: ownedDirectory) }
let service = TerminalPasteboardService(
temporaryDirectory: ownedDirectory
)
let prepared = TerminalImageTransferPlanner.prepareSynchronously(
pasteboard: pasteboard,
mode: .drop,
pasteboardService: service
)
guard case .reject = prepared else {
return XCTFail("a mixed drop must be rejected when a transient image cannot be retained")
}
XCTAssertTrue(
try FileManager.default
.contentsOfDirectory(at: ownedDirectory, includingPropertiesForKeys: nil)
.isEmpty,
"partially copied transient files must be rolled back when the drop is rejected"
)
}
func testImageFileURLDropUploadsOriginalFilesForRemoteTerminal() throws {
let imageDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux remote image file drop \(UUID().uuidString)")
+25
View File
@@ -3764,6 +3764,31 @@ final class WindowTerminalHostViewTests: XCTestCase {
return hostedView
}
func testTerminalPaneDropTargetLookupRequiresActiveDropContext() {
let frame = NSRect(x: 0, y: 0, width: 240, height: 160)
let hostedView = makeHostedTerminalView(frame: frame)
hostedView.layoutSubtreeIfNeeded()
hostedView.layout()
let dropPoint = NSPoint(x: frame.midX, y: frame.midY)
hostedView.setPaneDropContext(TerminalPaneDropContext(
workspaceId: UUID(),
panelId: UUID(),
paneId: PaneID(id: UUID())
))
XCTAssertNotNil(
hostedView.paneDropTargetForDrop(at: dropPoint),
"Active terminal pane drop targets should remain discoverable for pane drop routing"
)
hostedView.setPaneDropContext(nil)
XCTAssertNil(
hostedView.paneDropTargetForDrop(at: dropPoint),
"Inactive terminal pane drop targets must not shadow terminal file-path drop insertion"
)
}
private func assertHitFallsInsideHostedTerminal(
_ hitView: NSView?,
hostedView: GhosttySurfaceScrollView,