feat(terminal): insert escaped paths from status bar drags
This commit is contained in:
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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user