feat(terminal): insert escaped paths from status bar drags

This commit is contained in:
Kaede Akino committed 2026-10-02 00:33:41 +08:00
1 parent 58d8440ca5
commit dbf56691fe
14 files changed
+472 -7

No files matched your search

@@ -1,8 +1,51 @@
import Foundation
import TermindDomain
import Testing
@Suite
struct RemoteShellCommandTests {
@Test(arguments: [
("/", "/"),
("/srv/app-1.0_test", "/srv/app-1.0_test"),
("/srv/中文目录", "/srv/中文目录"),
("/srv/my files", #"/srv/my\ files"#),
("/srv/it's live", #"/srv/it\'s\ live"#),
(#"/srv/"quoted"\file"#, #"/srv/\"quoted\"\\file"#),
(#"/srv/$HOME/$(pwd)/`pwd`"#, #"/srv/\$HOME/\$\(pwd\)/\`pwd\`"#),
(#"/srv/*?[abc]{a,b}!~#;&|<>()^="#, #"/srv/\*\?\[abc\]\{a\,b\}\!\~\#\;\&\|\<\>\(\)\^\="#),
(" /srv/with trailing space ", #"\ /srv/with\ trailing\ space\ "#),
("/srv/a\tb", "/srv/a\\\tb"),
("", "''"),
("/srv/line\nbreak", "'/srv/line\nbreak'"),
("/srv/it's\r\nlive", "'/srv/it'\"'\"'s\r\nlive'"),
])
func argumentsPreferBackslashesAndPreserveEveryCharacter(_ value: String, _ expected: String) {
#expect(RemoteShellCommand.escapeArgument(value) == expected)
}
#if os(macOS)
@Test
func spaceSeparatedPathsRoundTripThroughPOSIXShellAsDistinctLiteralArguments() throws {
let paths = [
"/srv/中文目录", "/srv/my files", "/srv/it's live", #"/srv/"quoted"\file"#,
#"/srv/$HOME/$(printf substituted)/`printf substituted`"#,
"/srv/*?[abc]{a,b}!~#;&|<>()^=", "/srv/line\nbreak", "/srv/it's\r\nlive",
"/srv/a\tb", " /srv/trailing ", "",
]
let arguments = paths.map(RemoteShellCommand.escapeArgument).joined(separator: " ")
let process = Process()
let output = Pipe()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", "set -- \(arguments); printf '%s\\000' \"$@\""]
process.standardOutput = output
try process.run()
let data = output.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
#expect(process.terminationStatus == 0)
#expect(data == Data(paths.map { $0 + "\0" }.joined().utf8))
}
#endif
@Test
func directoryChangesQuoteRemotePaths() {
#expect(
@@ -2,6 +2,29 @@ import Foundation
/// Shell commands generated from remote session context rather than typed by the user.
public nonisolated enum RemoteShellCommand {
/// Encodes one literal shell argument without changing its contents.
/// Backslashes keep paths readable; newlines need quotes because a
/// backslash followed by a newline would remove both characters.
public static func escapeArgument(_ value: String) -> String {
guard !value.isEmpty else { return "''" }
if value.contains("\n") || value.contains("\r") {
return "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'"
}
var escaped = ""
for scalar in value.unicodeScalars {
switch scalar.value {
case 0x30...0x39, 0x41...0x5A, 0x61...0x7A, 0x2F, 0x2E, 0x5F, 0x2D:
break // ASCII letters, digits, / . _ -
case 0x80...0x10FFFF:
break // Shell syntax is ASCII; preserve Unicode paths verbatim.
default:
escaped.append("\\")
}
escaped.unicodeScalars.append(scalar)
}
return escaped
}
/// Builds a one-shot directory change for a newly opened shell.
///
/// Single-quoting keeps spaces and shell punctuation inert; embedded
+3 -1
View File
@@ -151,7 +151,9 @@ struct MobileSFTPBrowser: View {
Button(AppStrings.Common.copy.swiftUIKey, systemImage: "doc.on.doc") { copy(file) }
Menu(AppStrings.SFTP.copyAs.swiftUIKey, systemImage: "doc.on.doc") {
Button(AppStrings.Common.name.swiftUIKey) { UIPasteboard.general.string = file.name }
Button(AppStrings.SFTP.path.swiftUIKey) { UIPasteboard.general.string = file.path }
Button(AppStrings.SFTP.path.swiftUIKey) {
UIPasteboard.general.string = RemoteShellCommand.escapeArgument(file.path)
}
}
Button(AppStrings.Common.paste.swiftUIKey, systemImage: "doc.on.clipboard") { paste() }
Button(AppStrings.Common.rename.swiftUIKey, systemImage: "pencil") { rename(file) }
+2 -1
View File
@@ -999,7 +999,8 @@ struct SFTPBrowserView: View {
case .download: chooseDownloadDestination(for: targets)
case .copy: copyRemoteItems(targets)
case .copyNames: copyToPasteboard(targets.map(\.name).joined(separator: "\n"))
case .copyPaths: copyToPasteboard(targets.map(\.path).joined(separator: "\n"))
case .copyPaths:
copyToPasteboard(targets.map { RemoteShellCommand.escapeArgument($0.path) }.joined(separator: " "))
case .paste: pasteRemoteItems()
case .rename:
renameTarget = file
+4 -2
View File
@@ -275,6 +275,7 @@ struct StatusBarView: View {
.buttonStyle(.plain)
.help(Text(AppStrings.SFTP.openDirectoryWithSFTPHelp))
.accessibilityLabel(Text(AppStrings.SFTP.openWithSftp))
.onDrag { TerminalPathDrag.provider(for: path) }
.contextMenu {
// The children read on from the parent — "Open with SFTP ▸ in New
// Workspace" — and take the same pair of glyphs Duplicate's do.
@@ -290,7 +291,7 @@ struct StatusBarView: View {
}
Button(AppStrings.Workspace.copyPath.swiftUIKey, systemImage: MenuSymbol.copy) {
app.onboarding.recordUse(of: .statusBarPath)
copyToPasteboard(path)
copyToPasteboard(RemoteShellCommand.escapeArgument(path))
}
}
}
@@ -307,6 +308,7 @@ struct StatusBarView: View {
.buttonStyle(.plain)
.help(Text(AppStrings.Workspace.openDirectoryWithSSHHelp))
.accessibilityLabel(Text(AppStrings.Connections.openWithSsh))
.onDrag { TerminalPathDrag.provider(for: path) }
.contextMenu {
Menu(AppStrings.Connections.openWithSsh.swiftUIKey, systemImage: MenuSymbol.ssh) {
Button(AppStrings.Workspace.inCurrentWorkspace.swiftUIKey, systemImage: MenuSymbol.currentWorkspace) {
@@ -320,7 +322,7 @@ struct StatusBarView: View {
}
Button(AppStrings.Workspace.copyPath.swiftUIKey, systemImage: MenuSymbol.copy) {
app.onboarding.recordUse(of: .statusBarPath)
copyToPasteboard(path)
copyToPasteboard(RemoteShellCommand.escapeArgument(path))
}
}
}
@@ -96,6 +96,70 @@
var pasteOnRightClick = false
private var isSelectingWithMouse = false
private var rightClickPastes: Bool?
private var isPathDropVisible = false
override func setSurfaceVisible(_ visible: Bool) {
super.setSurfaceVisible(visible)
isPathDropVisible = visible
updatePathDropRegistration()
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
updatePathDropRegistration()
}
private func updatePathDropRegistration() {
if isPathDropVisible, window != nil {
registerForDraggedTypes([TerminalPathDrag.pasteboardType])
} else {
unregisterDraggedTypes()
}
}
/// Registration alone cannot cancel a drag AppKit already dispatched
/// before this workspace became hidden. Recheck at every entry point.
func pathDropOperation(
from pasteboard: NSPasteboard, sourceOperations: NSDragOperation
) -> NSDragOperation {
guard isPathDropVisible, window != nil, superview != nil,
!isHiddenOrHasHiddenAncestor, sourceOperations.contains(.copy),
TerminalPathDrag.text(from: pasteboard) != nil
else { return [] }
// AppKit supplies the green circle with a white plus cursor badge.
return .copy
}
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
pathDropOperation(from: sender.draggingPasteboard, sourceOperations: sender.draggingSourceOperationMask)
}
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
pathDropOperation(from: sender.draggingPasteboard, sourceOperations: sender.draggingSourceOperationMask)
}
override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool {
draggingUpdated(sender) == .copy
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
receivePathDrop(from: sender.draggingPasteboard, sourceOperations: sender.draggingSourceOperationMask)
}
func receivePathDrop(from pasteboard: NSPasteboard, sourceOperations: NSDragOperation) -> Bool {
guard pathDropOperation(from: pasteboard, sourceOperations: sourceOperations) == .copy,
let text = TerminalPathDrag.text(from: pasteboard)
else { return false }
return pasteDroppedPath(text)
}
func pasteDroppedPath(_ text: String) -> Bool {
guard isPathDropVisible, window != nil, superview != nil,
!isHiddenOrHasHiddenAncestor, !text.isEmpty
else { return false }
acquireProgrammaticFocus()
return captureInteraction { paste(text: text) }
}
override func mouseDown(with event: NSEvent) {
// Remote mouse reports must never copy an unrelated retained selection.
@@ -250,6 +314,12 @@
backend.readViewportText()
}
/// Pane overlays own native drop routing above the embedded surface.
@discardableResult
func pasteDroppedPath(_ text: String) -> Bool {
terminalView.pasteDroppedPath(text)
}
func appendNotice(_ message: String) {
receive(Data("\r\n\u{001B}[2m[Termind] \(message)\u{001B}[0m\r\n".utf8))
}
@@ -0,0 +1,27 @@
import AppKit
import TermindDomain
import UniformTypeIdentifiers
/// A path insertion carries shell text, never a file URL or an SFTP copy token.
enum TerminalPathDrag {
static let type = UTType(exportedAs: "com.akinokaede.termind.terminal-path", conformingTo: .utf8PlainText)
static let pasteboardType = NSPasteboard.PasteboardType(type.identifier)
static func provider(for path: String) -> NSItemProvider {
let text = RemoteShellCommand.escapeArgument(path)
let provider = NSItemProvider(object: text as NSString)
let data = Data(text.utf8)
provider.registerDataRepresentation(forTypeIdentifier: type.identifier, visibility: .ownProcess) { completion in
completion(data, nil)
return nil
}
return provider
}
static func text(from pasteboard: NSPasteboard) -> String? {
guard let data = pasteboard.data(forType: pasteboardType),
let text = String(data: data, encoding: .utf8), !text.isEmpty
else { return nil }
return text
}
}
@@ -459,7 +459,7 @@ private struct PaneView: View {
case .sshSession:
NativeDropTarget(
targetedKind: $nativeDropKind,
acceptedKinds: [.files, .sftpItems],
acceptedKinds: [.files, .sftpItems, .terminalPath],
isEnabled: isActive && window.dragging == nil,
receive: receivePaneDrop
)
@@ -470,7 +470,14 @@ private struct PaneView: View {
isEnabled: isActive && window.dragging == nil,
receive: receivePaneDrop
)
case .telnetSession, .serialSession, .empty:
case .telnetSession, .serialSession:
NativeDropTarget(
targetedKind: $nativeDropKind,
acceptedKinds: [.terminalPath],
isEnabled: isActive && window.dragging == nil,
receive: receivePaneDrop
)
case .empty:
EmptyView()
}
}
@@ -483,6 +490,8 @@ private struct PaneView: View {
}
private var paneDropPresentation: DropTargetPresentation? {
// Path insertion uses the native copy cursor, not the upload overlay.
guard nativeDropKind != .terminalPath else { return nil }
switch pane.content {
case .sshSession(let sshSession):
if let problem = TerminalUploadAvailability.problem(
@@ -535,6 +544,17 @@ private struct PaneView: View {
}
private func receivePaneDrop(_ payload: NativeDropPayload) -> Bool {
if let text = payload.terminalPath {
let owner: PersistentTerminalViewOwner
switch pane.content {
case .sshSession(let session): owner = session.terminalViewOwner
case .telnetSession(let session): owner = session.terminalViewOwner
case .serialSession(let session): owner = session.terminalViewOwner
case .sftpChannel, .empty: return false
}
workspace.focus(pane.id)
return owner.pasteDroppedPath(text)
}
if let data = payload.sftpItems {
switch pane.content {
case .sshSession, .sftpChannel:
+8
View File
@@ -70,6 +70,14 @@
<string>$(ICLOUD_CONTAINER_ENVIRONMENT)</string>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.utf8-plain-text</string>
</array>
<key>UTTypeIdentifier</key>
<string>com.akinokaede.termind.terminal-path</string>
</dict>
<dict>
<key>UTTypeConformsTo</key>
<array>
+9 -1
View File
@@ -19,15 +19,18 @@ nonisolated enum NativeDropKind: Hashable, Sendable {
case files
case images
case sftpItems
case terminalPath
}
nonisolated struct NativeDropPayload: Sendable {
var fileURLs: [URL] = []
var images: [Data] = []
var sftpItems: Data?
var terminalPath: String?
var preferredKind: NativeDropKind? {
if sftpItems != nil { return .sftpItems }
if terminalPath != nil { return .terminalPath }
if !fileURLs.isEmpty { return .files }
if !images.isEmpty { return .images }
return nil
@@ -37,7 +40,8 @@ nonisolated struct NativeDropPayload: Sendable {
NativeDropPayload(
fileURLs: acceptedKinds.contains(.files) ? fileURLs : [],
images: acceptedKinds.contains(.images) ? images : [],
sftpItems: acceptedKinds.contains(.sftpItems) ? sftpItems : nil
sftpItems: acceptedKinds.contains(.sftpItems) ? sftpItems : nil,
terminalPath: acceptedKinds.contains(.terminalPath) ? terminalPath : nil
)
}
}
@@ -244,6 +248,9 @@ nonisolated struct NativeDropPayload: Sendable {
// creates both a remote copy and a redundant download/upload.
return NativeDropPayload(sftpItems: sftpItems)
}
if let text = TerminalPathDrag.text(from: pasteboard) {
return NativeDropPayload(terminalPath: text)
}
var payload = NativeDropPayload(sftpItems: sftpItems)
for item in pasteboard.pasteboardItems ?? [] {
@@ -274,6 +281,7 @@ nonisolated struct NativeDropPayload: Sendable {
NSPasteboard.PasteboardType(UTType.tiff.identifier),
NSPasteboard.PasteboardType(UTType.heic.identifier),
NSPasteboard.PasteboardType(UTType.termindSFTPItems.identifier),
TerminalPathDrag.pasteboardType,
]
}
#endif
+32
View File
@@ -0,0 +1,32 @@
import AppKit
/// Exercises AppKit destination callbacks with a test-owned pasteboard.
@MainActor
final class DraggingInfoStub: NSObject, NSDraggingInfo {
let draggingPasteboard: NSPasteboard
var draggingSourceOperationMask: NSDragOperation = .copy
var draggingDestinationWindow: NSWindow?
var draggingLocation: NSPoint = .zero
var draggedImageLocation: NSPoint = .zero
nonisolated var draggedImage: NSImage? { nil }
var draggingSource: Any? { nil }
var draggingSequenceNumber: Int { 1 }
var draggingFormation: NSDraggingFormation = .none
var animatesToDestination = false
var numberOfValidItemsForDrop = 1
var springLoadingHighlight: NSSpringLoadingHighlight { .none }
init(pasteboard: NSPasteboard) {
draggingPasteboard = pasteboard
super.init()
}
func slideDraggedImage(to _: NSPoint) {}
nonisolated override func namesOfPromisedFilesDropped(atDestination _: URL) -> [String]? { nil }
func resetSpringLoading() {}
func enumerateDraggingItems(
options _: NSDraggingItemEnumerationOptions,
for _: NSView?, classes _: [AnyClass], searchOptions _: [NSPasteboard.ReadingOptionKey: Any],
using _: (NSDraggingItem, Int, UnsafeMutablePointer<ObjCBool>) -> Void
) {}
}
+65
View File
@@ -31,6 +31,71 @@ import TermindRemoteInfrastructure
/// through to the control underneath.
private static let dragEvent = NSEvent.EventType.leftMouseDragged
@Test
func terminalPaneOverlayClaimsPathDragsAndProposesCopyWithoutStartingATransfer() {
let pasteboard = stagedPasteboard()
defer { pasteboard.releaseGlobally() }
let text = RemoteShellCommand.escapeArgument("/srv/中文/my files")
pasteboard.setString(text, forType: TerminalPathDrag.pasteboardType)
var received: [NativeDropPayload] = []
let target = FileURLDropNSView(
acceptedKinds: [.files, .sftpItems, .terminalPath],
targetedKind: { _ in },
receivePayload: {
received.append($0)
return true
})
target.frame = CGRect(x: 0, y: 0, width: 200, height: 100)
let drag = DraggingInfoStub(pasteboard: pasteboard)
#expect(participation(target, pasteboard))
#expect(!participation(target, pasteboard, event: .leftMouseDown))
#expect(target.draggingEntered(drag) == .copy)
#expect(target.draggingUpdated(drag) == .copy)
#expect(target.performDragOperation(drag))
#expect(received.count == 1)
#expect(received.first?.terminalPath == text)
#expect(received.first?.sftpItems == nil)
#expect(received.first?.fileURLs.isEmpty == true)
target.isEnabled = false
#expect(!participation(target, pasteboard))
#expect(target.draggingUpdated(drag).isEmpty)
#expect(!target.performDragOperation(drag))
}
@Test
func sftpDragsProposeTheCopyCursorAndDeliverOnlyTheRemotePayload() throws {
let pasteboard = stagedPasteboard()
defer { pasteboard.releaseGlobally() }
let data = try JSONEncoder().encode(SFTPRemoteItemsPayload(token: UUID()))
pasteboard.setData(data, forType: NSPasteboard.PasteboardType(UTType.termindSFTPItems.identifier))
let drag = DraggingInfoStub(pasteboard: pasteboard)
var targeted: NativeDropKind?
var received: [NativeDropPayload] = []
let target = FileURLDropNSView(
acceptedKinds: [.files, .sftpItems],
targetedKind: { targeted = $0 },
receivePayload: {
received.append($0)
return true
})
#expect(target.draggingEntered(drag) == .copy)
#expect(target.draggingUpdated(drag) == .copy)
#expect(targeted == .sftpItems)
#expect(received.isEmpty)
#expect(target.performDragOperation(drag))
#expect(received.count == 1)
#expect(received.first?.sftpItems == data)
#expect(received.first?.fileURLs.isEmpty == true)
#expect(targeted == nil)
target.isEnabled = false
#expect(target.draggingEntered(drag).isEmpty)
#expect(target.draggingUpdated(drag).isEmpty)
#expect(!target.performDragOperation(drag))
#expect(received.count == 1)
}
/// Asks the participation rule about a point inside the target, so each
/// case below reads as one line instead of a four-line call.
private func participation(
@@ -3,8 +3,11 @@ import GhosttyTerminal
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
import struct TermindDomain.Host
@testable import Termind
private nonisolated let terminalMouseHasDisplay: Bool = {
@@ -16,6 +19,111 @@ private nonisolated let terminalMouseHasDisplay: Bool = {
.requiresVisibleWindow, .serialized, .disabled(if: !terminalMouseHasDisplay, "Ghostty requires an active display"))
@MainActor
struct TerminalMouseInteractionTests {
@Test func statusBarPathReachesTerminalThroughWorkspaceOverlays() async throws {
let fixture = try await TerminalMouseFixture.make()
defer { fixture.close() }
let path = "/home/user/中文/my files"
let hosts = fixture.workspace.panes.compactMap { pane -> Host? in
guard let session = pane.sshSession else { return nil }
session.status = .connected
session.workingDirectory = path
session.receiveShellIntegrationEvent(.available(shell: "zsh", systemInfo: nil, homeDirectory: "/home/user"))
return Host(id: session.hostID, name: "Drag test", host: "test.invalid")
}
let app = AppModel(hosts: HostLibraryModel(hosts: hosts))
defer { app.shutdownForTermination() }
let model = WindowModel(workspaces: [fixture.workspace])
let first = try #require(fixture.workspace.panes.first { $0.sshSession === fixture.session })
fixture.workspace.focus(first.id)
let domain = "TerminalPathDrag.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: domain))
defaults.set(true, forKey: DesktopSettingsKey.statusBarExpanded)
defer { defaults.removePersistentDomain(forName: domain) }
let hosting = NSHostingView(
rootView: WorkspaceContentView(workspace: fixture.workspace, isActive: true)
.overlay(alignment: .bottomTrailing) { StatusBarView().padding(12) }
.environment(app).environment(model).defaultAppStorage(defaults)
.frame(width: 800, height: 500))
fixture.window.title = "Path drag verification"
fixture.window.contentView = hosting
fixture.window.setContentSize(NSSize(width: 800, height: 500))
fixture.window.makeKeyAndOrderFront(nil)
hosting.layoutSubtreeIfNeeded()
try await Task.sleep(for: .milliseconds(300))
fixture.feed("\u{1B}[?2004h")
fixture.firstWrites.removeAll()
func targets(in view: NSView) -> [FileURLDropNSView] {
(view as? FileURLDropNSView).map { [$0] } ?? view.subviews.flatMap { targets(in: $0) }
}
let point = fixture.view.convert(NSPoint(x: 50, y: 50), to: hosting)
let target = try #require(
targets(in: hosting).first {
$0.acceptedKinds.contains(.terminalPath) && $0.bounds.contains($0.convert(point, from: hosting))
})
let text = RemoteShellCommand.escapeArgument(path)
let pasteboard = NSPasteboard.withUniqueName()
defer { pasteboard.releaseGlobally() }
pasteboard.setString(text, forType: TerminalPathDrag.pasteboardType)
let drag = DraggingInfoStub(pasteboard: pasteboard)
#expect(target.draggingEntered(drag) == .copy)
#expect(target.draggingUpdated(drag) == .copy)
#expect(target.performDragOperation(drag))
try await fixture.flushWrites()
#expect(fixture.firstWrites.reduce(Data(), +) == Data("\u{1B}[200~\(text)\u{1B}[201~".utf8))
fixture.window.contentView = nil
}
@Test func pathDropProposesCopyAndPastesExactlyOnceWithoutSubmittingOrEscapingAgain() async throws {
let fixture = try await TerminalMouseFixture.make()
defer { fixture.close() }
fixture.workspace.broadcastsInput = true
fixture.feed("\u{1B}[?2004h")
let pasteboard = NSPasteboard.withUniqueName()
defer { pasteboard.releaseGlobally() }
let text = RemoteShellCommand.escapeArgument("/home/user/中文/my files/it's live")
pasteboard.setString(text, forType: TerminalPathDrag.pasteboardType)
let drag = DraggingInfoStub(pasteboard: pasteboard)
fixture.setClipboard("unchanged")
#expect(fixture.view.registeredDraggedTypes.contains(TerminalPathDrag.pasteboardType))
#expect(fixture.view.draggingEntered(drag) == .copy)
#expect(fixture.view.draggingUpdated(drag) == .copy)
#expect(fixture.view.prepareForDragOperation(drag))
#expect(fixture.view.pathDropOperation(from: pasteboard, sourceOperations: [.copy, .move]) == .copy)
#expect(fixture.view.pathDropOperation(from: pasteboard, sourceOperations: .move).isEmpty)
#expect(fixture.firstWrites.isEmpty)
#expect(fixture.view.performDragOperation(drag))
try await fixture.flushWrites()
let expected = Data("\u{1B}[200~\(text)\u{1B}[201~".utf8)
#expect(fixture.firstWrites.reduce(Data(), +) == expected)
#expect(fixture.secondWrites.reduce(Data(), +) == expected)
#expect(NSPasteboard.general.string(forType: .string) == "unchanged")
}
@Test func hidingOrUnmountingTheTerminalRejectsAnAlreadyTrackedPathDrop() async throws {
let fixture = try await TerminalMouseFixture.make()
defer { fixture.close() }
let pasteboard = NSPasteboard.withUniqueName()
defer { pasteboard.releaseGlobally() }
pasteboard.setString("/srv/path", forType: TerminalPathDrag.pasteboardType)
#expect(fixture.view.pathDropOperation(from: pasteboard, sourceOperations: .copy) == .copy)
fixture.owner.mount(in: fixture.mount, state: fixture.state, isVisible: false, requestsFocus: false)
#expect(!fixture.view.registeredDraggedTypes.contains(TerminalPathDrag.pasteboardType))
#expect(fixture.view.pathDropOperation(from: pasteboard, sourceOperations: .copy).isEmpty)
#expect(!fixture.view.receivePathDrop(from: pasteboard, sourceOperations: .copy))
fixture.owner.mount(in: fixture.mount, state: fixture.state, isVisible: true, requestsFocus: false)
#expect(fixture.view.pathDropOperation(from: pasteboard, sourceOperations: .copy) == .copy)
fixture.owner.unmount(from: fixture.mount)
#expect(!fixture.view.registeredDraggedTypes.contains(TerminalPathDrag.pasteboardType))
#expect(!fixture.view.receivePathDrop(from: pasteboard, sourceOperations: .copy))
try await fixture.flushWrites()
#expect(fixture.firstWrites.isEmpty)
}
@Test func dragCopiesChineseAndMultipleLinesOnlyAfterRelease() async throws {
let fixture = try await TerminalMouseFixture.make()
defer { fixture.close() }
+56
View File
@@ -0,0 +1,56 @@
import AppKit
import TermindDomain
import Testing
import UniformTypeIdentifiers
@testable import Termind
@Suite @MainActor
struct TerminalPathDragTests {
@Test
func providerCarriesTheFullEscapedPathWithoutAdvertisingAFile() async throws {
let path = "/home/user/中文/my files/it's live"
let provider = TerminalPathDrag.provider(for: path)
let data: Data = try await withCheckedThrowingContinuation { continuation in
_ = provider.loadDataRepresentation(forTypeIdentifier: TerminalPathDrag.type.identifier) { data, error in
if let data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown))
}
}
}
let pasteboard = NSPasteboard.withUniqueName()
defer { pasteboard.releaseGlobally() }
pasteboard.setData(data, forType: TerminalPathDrag.pasteboardType)
#expect(TerminalPathDrag.text(from: pasteboard) == #"/home/user/中文/my\ files/it\'s\ live"#)
#expect(provider.hasItemConformingToTypeIdentifier(UTType.utf8PlainText.identifier))
#expect(!provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier))
#expect(FileURLDropNSView.payload(from: pasteboard).restricted(to: [.files, .sftpItems]).preferredKind == nil)
}
@Test
func plainTextFileURLsAndInvalidDataAreNotPathInsertions() {
let pasteboard = NSPasteboard.withUniqueName()
defer { pasteboard.releaseGlobally() }
pasteboard.setString("/srv/plain text", forType: .string)
pasteboard.setString("file:///srv/file", forType: .fileURL)
#expect(TerminalPathDrag.text(from: pasteboard) == nil)
pasteboard.setData(Data([0xFF]), forType: TerminalPathDrag.pasteboardType)
#expect(TerminalPathDrag.text(from: pasteboard) == nil)
pasteboard.setData(Data(), forType: TerminalPathDrag.pasteboardType)
#expect(TerminalPathDrag.text(from: pasteboard) == nil)
}
@Test
func unmountedTerminalRefusesPathDrops() {
let pasteboard = NSPasteboard.withUniqueName()
defer { pasteboard.releaseGlobally() }
pasteboard.setString("/srv/path", forType: TerminalPathDrag.pasteboardType)
let view = TermindTerminalView(frame: .zero)
view.setSurfaceVisible(true)
#expect(!view.registeredDraggedTypes.contains(TerminalPathDrag.pasteboardType))
#expect(view.pathDropOperation(from: pasteboard, sourceOperations: .copy).isEmpty)
#expect(!view.receivePathDrop(from: pasteboard, sourceOperations: .copy))
}
}