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)) + } +}