diff --git a/FrameworkTests/RemoteShellCommandTests.swift b/FrameworkTests/RemoteShellCommandTests.swift
index 77ff902f..a14f9926 100644
--- a/FrameworkTests/RemoteShellCommandTests.swift
+++ b/FrameworkTests/RemoteShellCommandTests.swift
@@ -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(
diff --git a/Frameworks/TermindDomain/Shell/RemoteShellCommand.swift b/Frameworks/TermindDomain/Shell/RemoteShellCommand.swift
index d0204ef6..5b47e934 100644
--- a/Frameworks/TermindDomain/Shell/RemoteShellCommand.swift
+++ b/Frameworks/TermindDomain/Shell/RemoteShellCommand.swift
@@ -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
diff --git a/TFI/Features/SFTP/MobileSFTPBrowser.swift b/TFI/Features/SFTP/MobileSFTPBrowser.swift
index 34289e58..bf8a4f99 100644
--- a/TFI/Features/SFTP/MobileSFTPBrowser.swift
+++ b/TFI/Features/SFTP/MobileSFTPBrowser.swift
@@ -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) }
diff --git a/TFM/Features/SFTP/SFTPBrowserView.swift b/TFM/Features/SFTP/SFTPBrowserView.swift
index 5a774355..d5aea026 100644
--- a/TFM/Features/SFTP/SFTPBrowserView.swift
+++ b/TFM/Features/SFTP/SFTPBrowserView.swift
@@ -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
diff --git a/TFM/Features/StatusBar/StatusBarView.swift b/TFM/Features/StatusBar/StatusBarView.swift
index 38f0a10c..242798a2 100644
--- a/TFM/Features/StatusBar/StatusBarView.swift
+++ b/TFM/Features/StatusBar/StatusBarView.swift
@@ -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))
}
}
}
diff --git a/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift b/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift
index 2dec4082..6d5318ba 100644
--- a/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift
+++ b/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift
@@ -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))
}
diff --git a/TFM/Features/Terminal/TerminalPathDrag.swift b/TFM/Features/Terminal/TerminalPathDrag.swift
new file mode 100644
index 00000000..dd50fb54
--- /dev/null
+++ b/TFM/Features/Terminal/TerminalPathDrag.swift
@@ -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
+ }
+}
diff --git a/TFM/Features/Workspaces/WorkspaceContentView.swift b/TFM/Features/Workspaces/WorkspaceContentView.swift
index 27750ef2..ed699c64 100644
--- a/TFM/Features/Workspaces/WorkspaceContentView.swift
+++ b/TFM/Features/Workspaces/WorkspaceContentView.swift
@@ -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:
diff --git a/TFM/Info.plist b/TFM/Info.plist
index 7f797320..36d49bc3 100644
--- a/TFM/Info.plist
+++ b/TFM/Info.plist
@@ -70,6 +70,14 @@
$(ICLOUD_CONTAINER_ENVIRONMENT)
UTExportedTypeDeclarations
+
+ UTTypeConformsTo
+
+ public.utf8-plain-text
+
+ UTTypeIdentifier
+ com.akinokaede.termind.terminal-path
+
UTTypeConformsTo
diff --git a/TFM/UI/Components/FileURLDropTarget.swift b/TFM/UI/Components/FileURLDropTarget.swift
index d4e3071b..b697ae14 100644
--- a/TFM/UI/Components/FileURLDropTarget.swift
+++ b/TFM/UI/Components/FileURLDropTarget.swift
@@ -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
diff --git a/TFMTests/DraggingInfoStub.swift b/TFMTests/DraggingInfoStub.swift
new file mode 100644
index 00000000..319363bb
--- /dev/null
+++ b/TFMTests/DraggingInfoStub.swift
@@ -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) -> Void
+ ) {}
+}
diff --git a/TFMTests/FileURLDropTargetTests.swift b/TFMTests/FileURLDropTargetTests.swift
index a6cf8cf9..cdd53f29 100644
--- a/TFMTests/FileURLDropTargetTests.swift
+++ b/TFMTests/FileURLDropTargetTests.swift
@@ -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(
diff --git a/TFMTests/TerminalMouseInteractionTests.swift b/TFMTests/TerminalMouseInteractionTests.swift
index 20d2d7a4..bca42194 100644
--- a/TFMTests/TerminalMouseInteractionTests.swift
+++ b/TFMTests/TerminalMouseInteractionTests.swift
@@ -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() }
diff --git a/TFMTests/TerminalPathDragTests.swift b/TFMTests/TerminalPathDragTests.swift
new file mode 100644
index 00000000..a8a128df
--- /dev/null
+++ b/TFMTests/TerminalPathDragTests.swift
@@ -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))
+ }
+}