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:
-1
@@ -1,6 +1,5 @@
|
||||
public import AppKit
|
||||
public import CmuxTerminalCore
|
||||
internal import UniformTypeIdentifiers
|
||||
#if DEBUG
|
||||
internal import CMUXDebugLog
|
||||
#endif
|
||||
|
||||
+183
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user