From d4323176ed4886203ca9eb1bc31cfd421c057c4e Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 2 Oct 2026 08:27:20 +0800 Subject: [PATCH] feat(terminal): make uploads free and add native upload menus --- FrameworkTests/RemoteShellCommandTests.swift | 2 +- FrameworkTests/SFTPUploadReceiptTests.swift | 53 ++ Frameworks/Resources/Localizable.xcstrings | 453 +----------------- .../Commerce/ProAccessTypes.swift | 1 - .../Shell/RemoteShellCommand.swift | 2 +- .../TermindPresentation/Strings/Pro.swift | 100 ---- .../Strings/Terminal.swift | 2 + .../Transfers/SFTPLocalUploadPlanner.swift | 151 ++++-- TFI/Features/Workspaces/MobileWorkspace.swift | 24 +- .../Workspaces/MobileWorkspaceView.swift | 99 +++- TFI/Platform/MobileTerminalPhotoPicker.swift | 29 ++ TFI/Platform/MobileTerminalSurface.swift | 36 ++ TFITests/MobileTerminalUploadMenuTests.swift | 28 ++ TFM/Features/SFTP/SFTPChannelModel.swift | 8 +- .../Terminal/DesktopTerminalSurface.swift | 13 + .../PersistentTerminalSurfaceView.swift | 41 ++ TFM/Features/Terminal/SSHSessionModel.swift | 6 + .../Terminal/TerminalPlaceholderView.swift | 39 +- TFMTests/BroadcastInputTests.swift | 12 + TFMTests/SFTPFeatureTests.swift | 10 +- TFMTests/TerminalContextMenuTests.swift | 19 + TFMTests/TerminalMouseInteractionTests.swift | 23 + Termind.xcodeproj/project.pbxproj | 2 +- 23 files changed, 545 insertions(+), 608 deletions(-) create mode 100644 FrameworkTests/SFTPUploadReceiptTests.swift create mode 100644 TFI/Platform/MobileTerminalPhotoPicker.swift create mode 100644 TFITests/MobileTerminalUploadMenuTests.swift diff --git a/FrameworkTests/RemoteShellCommandTests.swift b/FrameworkTests/RemoteShellCommandTests.swift index a14f9926..f57fd274 100644 --- a/FrameworkTests/RemoteShellCommandTests.swift +++ b/FrameworkTests/RemoteShellCommandTests.swift @@ -12,7 +12,7 @@ struct RemoteShellCommandTests { ("/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/*?[abc]{a,b}!~#;&|<>()^=", "/srv/\\*\\?\\[abc\\]\\{a\\,b\\}\\!\\~\\#\\;\\&\\|\\<\\>\\(\\)\\^\\="), (" /srv/with trailing space ", #"\ /srv/with\ trailing\ space\ "#), ("/srv/a\tb", "/srv/a\\\tb"), ("", "''"), diff --git a/FrameworkTests/SFTPUploadReceiptTests.swift b/FrameworkTests/SFTPUploadReceiptTests.swift new file mode 100644 index 00000000..e5fc2b25 --- /dev/null +++ b/FrameworkTests/SFTPUploadReceiptTests.swift @@ -0,0 +1,53 @@ +import TermindDomain +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite @MainActor +struct SFTPUploadReceiptTests { + @Test func waitsForAdmissionAndEveryTransferAndPreservesSelectionOrder() { + var received: [[String]] = [] + let receipt = SFTPUploadReceipt(paths: ["/a", "/folder"]) { received.append($0) } + receipt.admit(0) + receipt.completed(0, state: .completed) + #expect(received.isEmpty) + receipt.admit(1) + receipt.admit(1) + receipt.resolve(1, to: "/folder.2") + receipt.finishAdmission() + receipt.completed(1, state: .completed) + #expect(received.isEmpty) + receipt.completed(1, state: .completed) + #expect(received == [["/a", "/folder.2"]]) + receipt.finishAdmission() + #expect(received.count == 1) + } + + @Test func excludesFailedCancelledAndSkippedRoots() { + var received: [[String]] = [] + let receipt = SFTPUploadReceipt(paths: ["/ok", "/failed", "/cancelled", "/skipped"]) { + received.append($0) + } + for index in 0..<3 { receipt.admit(index) } + receipt.resolve(3, to: nil) + receipt.finishAdmission() + receipt.completed(2, state: .cancelled) + receipt.completed(0, state: .completed) + receipt.completed(1, state: .failed("offline")) + #expect(received == [["/ok"]]) + } + + @Test func emptyDirectoriesCompleteAfterAdmissionAndCancelledAdmissionNeverDelivers() { + var received: [[String]] = [] + let empty = SFTPUploadReceipt(paths: ["/empty"]) { received.append($0) } + #expect(received.isEmpty) + empty.finishAdmission() + #expect(received == [["/empty"]]) + let cancelled = SFTPUploadReceipt(paths: ["/cancelled"]) { received.append($0) } + cancelled.admit(0) + cancelled.cancel() + cancelled.finishAdmission() + cancelled.completed(0, state: .completed) + #expect(received == [["/empty"]]) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 00064e1e..8fba8553 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -3342,40 +3342,6 @@ } } }, - "Annual" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "年度订阅" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "年度訂閱" - } - } - } - }, - "Annual Subscription" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "年度订阅" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "年度訂閱" - } - } - } - }, "Annual · Family Shared" : { "extractionState" : "manual", "localizations" : { @@ -3548,23 +3514,6 @@ } } }, - "Apple Family Sharing Supported" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "支持Apple家庭共享" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "支援Apple家庭共享" - } - } - } - }, "Apple Speech Recognition could not transcribe this recording." : { "extractionState" : "manual", "localizations" : { @@ -4466,40 +4415,6 @@ } } }, - "Billing Grace Period" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "账单宽限期" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "帳單寬限期" - } - } - } - }, - "Billing Issue" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "账单问题" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "帳單問題" - } - } - } - }, "Binary" : { "extractionState" : "manual", "localizations" : { @@ -5307,25 +5222,6 @@ } } }, - "Checking…" : { - "comment" : "A label that indicates that the app is checking for Pro access.", - "extractionState" : "manual", - "isCommentAutoGenerated" : true, - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在检查…" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在檢查…" - } - } - } - }, "Choose" : { "comment" : "A button that opens a file picker to choose a private key.", "extractionState" : "manual", @@ -6826,25 +6722,6 @@ } } }, - "Connect MCP tools and run the built-in MCP Server" : { - "comment" : "A feature of Termind Pro.", - "extractionState" : "manual", - "isCommentAutoGenerated" : true, - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "连接MCP工具并运行内置MCP服务器" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "連線MCP工具並執行內建MCP伺服器" - } - } - } - }, "Connect Once" : { "extractionState" : "manual", "localizations" : { @@ -6879,24 +6756,6 @@ } } }, - "Connect remote MCP tools" : { - "comment" : "A feature of Termind Pro on iOS.", - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "连接远程MCP工具" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "連線遠端MCP工具" - } - } - } - }, "Connect to remote MCP servers over HTTP." : { "comment" : "A description of MCP servers.", "extractionState" : "manual", @@ -10324,25 +10183,6 @@ } } }, - "Drag files to upload on terminal" : { - "comment" : "A feature of Termind Pro.", - "extractionState" : "manual", - "isCommentAutoGenerated" : true, - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "拖放文件到终端上传" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "拖放檔案到終端上傳" - } - } - } - }, "Drag to Import" : { "comment" : "Title of a drop target presentation for importing a private key or certificate.", "extractionState" : "manual", @@ -12599,25 +12439,6 @@ } } }, - "Free" : { - "comment" : "A label for a free subscription.", - "extractionState" : "manual", - "isCommentAutoGenerated" : true, - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "免费版" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "免費版" - } - } - } - }, "Free Memory" : { "extractionState" : "manual", "localizations" : { @@ -12641,23 +12462,6 @@ } } }, - "Free Trial" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "免费试用" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "免費試用" - } - } - } - }, "Free for %@, then %@ every %@. Auto-renews until canceled." : { "extractionState" : "manual", "localizations" : { @@ -15309,57 +15113,6 @@ } } }, - "Lifetime" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "永久版" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "永久版" - } - } - } - }, - "Lifetime Access" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "永久版" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "永久版" - } - } - } - }, - "Lifetime Access · Family Shared" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "永久版·家庭共享" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "永久版·家庭共享" - } - } - } - }, "Light" : { "extractionState" : "manual", "localizations" : { @@ -15870,23 +15623,6 @@ } } }, - "Make remote work easier with AI." : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI助力,让远程操作更轻松。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI助力,讓遠端操作更輕鬆。" - } - } - } - }, "Manage Models" : { "extractionState" : "manual", "localizations" : { @@ -15904,23 +15640,6 @@ } } }, - "Manage Subscription" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "管理订阅" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "管理訂閱" - } - } - } - }, "Mark as Read" : { "extractionState" : "manual", "localizations" : { @@ -16449,23 +16168,6 @@ } } }, - "Monitor more than 3 servers" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "监控超过3台服务器" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "監控超過3台伺服器" - } - } - } - }, "More" : { "extractionState" : "manual", "localizations" : { @@ -18780,23 +18482,6 @@ } } }, - "One-time purchase" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "一次性购买" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "一次性購買" - } - } - } - }, "Only SKILL.md was imported. These were not: %@." : { "comment" : "A warning that a skill was imported but some files were not. The list of files is a comma-separated list.", "extractionState" : "manual", @@ -22373,23 +22058,6 @@ } } }, - "Reload App Store Prices" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "重新加载App Store价格" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "重新載入App Store價格" - } - } - } - }, "Remote" : { "extractionState" : "manual", "localizations" : { @@ -27039,40 +26707,6 @@ } } }, - "Subscription Expired" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "订阅已过期" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "訂閱已過期" - } - } - } - }, - "Subscription Revoked" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "订阅已撤销" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "訂閱已撤銷" - } - } - } - }, "Succeeded · %@" : { "extractionState" : "manual", "localizations" : { @@ -27245,23 +26879,6 @@ } } }, - "Sync your data across devices with iCloud" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "通过iCloud跨设备同步数据" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "透過iCloud跨裝置同步資料" - } - } - } - }, "Syncing %lld changes…" : { "extractionState" : "manual", "localizations" : { @@ -27748,23 +27365,6 @@ } } }, - "Terms of Use" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用条款" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用條款" - } - } - } - }, "Test" : { "extractionState" : "manual", "localizations" : { @@ -32734,6 +32334,23 @@ } } }, + "Upload File" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传文件" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "上傳檔案" + } + } + } + }, "Upload Paused" : { "extractionState" : "manual", "localizations" : { @@ -32925,25 +32542,6 @@ } } }, - "Use the AI Assistant" : { - "comment" : "A feature of Termind Pro.", - "extractionState" : "manual", - "isCommentAutoGenerated" : true, - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用AI助手" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用AI助手" - } - } - } - }, "Use this choice for all conflicts in this batch." : { "extractionState" : "manual", "localizations" : { @@ -35271,23 +34869,6 @@ } } }, - "per %@" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "每%@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "每%@" - } - } - } - }, "persistent memory" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindDomain/Commerce/ProAccessTypes.swift b/Frameworks/TermindDomain/Commerce/ProAccessTypes.swift index 2e38f66e..d85e723c 100644 --- a/Frameworks/TermindDomain/Commerce/ProAccessTypes.swift +++ b/Frameworks/TermindDomain/Commerce/ProAccessTypes.swift @@ -2,7 +2,6 @@ import Foundation public nonisolated enum ProFeature: String, CaseIterable, Hashable, Sendable { case assistant - case terminalFileUpload case remoteMCP case mcpServer case vaultSync diff --git a/Frameworks/TermindDomain/Shell/RemoteShellCommand.swift b/Frameworks/TermindDomain/Shell/RemoteShellCommand.swift index 5b47e934..228f2845 100644 --- a/Frameworks/TermindDomain/Shell/RemoteShellCommand.swift +++ b/Frameworks/TermindDomain/Shell/RemoteShellCommand.swift @@ -7,7 +7,7 @@ public nonisolated enum RemoteShellCommand { /// 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") { + if value.unicodeScalars.contains(where: { $0.value == 0x0A || $0.value == 0x0D }) { return "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" } var escaped = "" diff --git a/Frameworks/TermindPresentation/Strings/Pro.swift b/Frameworks/TermindPresentation/Strings/Pro.swift index 2fc3be9a..5e92ee08 100644 --- a/Frameworks/TermindPresentation/Strings/Pro.swift +++ b/Frameworks/TermindPresentation/Strings/Pro.swift @@ -2,125 +2,25 @@ import Foundation extension AppStrings { public nonisolated enum Pro { - public static var annual: LocalizedStringResource { - "Annual" - } - - public static var assistantFeature: LocalizedStringResource { - "Use the AI Assistant" - } - - public static var connectMcpToolsAndRunTheBuiltInMcpServer: LocalizedStringResource { - "Connect MCP tools and run the built-in MCP Server" - } - - public static var connectRemoteMcpTools: LocalizedStringResource { - "Connect remote MCP tools" - } - - public static var metricsFeature: LocalizedStringResource { - "Monitor more than 3 servers" - } - - public static var terminalUploadFeature: LocalizedStringResource { - "Drag files to upload on terminal" - } - - public static var syncFeature: LocalizedStringResource { - "Sync your data across devices with iCloud" - } - public static var contactingTheAppStoreProgress: LocalizedStringResource { "Contacting the App Store…" } - public static var familySharingDisclosure: LocalizedStringResource { - "Apple Family Sharing Supported" - } - - public static var lifetimeAccess: LocalizedStringResource { - "Lifetime Access" - } - - public static var lifetimeAccessFamilyShared: LocalizedStringResource { - "Lifetime Access · Family Shared" - } - - public static var oneTimePurchase: LocalizedStringResource { - "One-time purchase" - } - - public static var workflowTagline: LocalizedStringResource { - "Make remote work easier with AI." - } - - public static var reloadAppStorePrices: LocalizedStringResource { - "Reload App Store Prices" - } - - public static var termsOfUse: LocalizedStringResource { - "Terms of Use" - } - public static var productUnavailable: LocalizedStringResource { "Termind Pro is not available from the App Store right now." } - public static var annualSubscription: LocalizedStringResource { - "Annual Subscription" - } - - public static var billingGracePeriod: LocalizedStringResource { - "Billing Grace Period" - } - - public static var billingIssue: LocalizedStringResource { - "Billing Issue" - } - - public static var checkingProgress: LocalizedStringResource { - "Checking…" - } - - public static var free: LocalizedStringResource { - "Free" - } - - public static var freeTrial: LocalizedStringResource { - "Free Trial" - } - public static var freeMetricsLimit: LocalizedStringResource { "Free includes up to 3 monitoring cards. Upgrade to Pro for more." } - public static var lifetime: LocalizedStringResource { - "Lifetime" - } - - public static var manageSubscription: LocalizedStringResource { - "Manage Subscription" - } - public static var privacyPolicy: LocalizedStringResource { "Privacy Policy" } - public static var subscriptionExpired: LocalizedStringResource { - "Subscription Expired" - } - - public static var subscriptionRevoked: LocalizedStringResource { - "Subscription Revoked" - } - public static var termindPro: LocalizedStringResource { "Termind Pro" } - public static func billingPeriod(_ period: String) -> LocalizedStringResource { - "per \(period)" - } - } } diff --git a/Frameworks/TermindPresentation/Strings/Terminal.swift b/Frameworks/TermindPresentation/Strings/Terminal.swift index 01864787..844273f0 100644 --- a/Frameworks/TermindPresentation/Strings/Terminal.swift +++ b/Frameworks/TermindPresentation/Strings/Terminal.swift @@ -2,6 +2,8 @@ import Foundation extension AppStrings { public nonisolated enum Terminal { + public static var uploadFile: LocalizedStringResource { "Upload File" } + public static var armed: LocalizedStringResource { "Armed" } diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPLocalUploadPlanner.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPLocalUploadPlanner.swift index 780eafba..8336a61d 100644 --- a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPLocalUploadPlanner.swift +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPLocalUploadPlanner.swift @@ -5,6 +5,7 @@ import struct TermindDomain.Host public nonisolated struct SFTPLocalUploadBatch: Sendable { fileprivate struct Job: Sendable { + var rootIndex: Int var localURL: URL var remotePath: String var byteCount: Int64 @@ -16,6 +17,7 @@ public nonisolated struct SFTPLocalUploadBatch: Sendable { public let id: UUID public let rootCount: Int public let conflictCount: Int + fileprivate var roots: [String] fileprivate var jobs: [Job] fileprivate var remoteDirectories: [String] fileprivate var remoteDirectoryConflicts: Set @@ -39,7 +41,8 @@ public enum SFTPLocalUploadPlanner { var jobs: [SFTPLocalUploadBatch.Job] = [] var directories: [String] = [] - for root in urls where root.isFileURL { + let urls = urls.filter(\.isFileURL) + for (rootIndex, root) in urls.enumerated() { let scoped = root.startAccessingSecurityScopedResource() defer { if scoped { root.stopAccessingSecurityScopedResource() } } let values = try root.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]) @@ -63,6 +66,7 @@ public enum SFTPLocalUploadPlanner { } else { jobs.append( SFTPLocalUploadBatch.Job( + rootIndex: rootIndex, localURL: item, remotePath: remotePath, byteCount: Int64(itemValues.fileSize ?? 0), @@ -75,6 +79,7 @@ public enum SFTPLocalUploadPlanner { } else { jobs.append( SFTPLocalUploadBatch.Job( + rootIndex: rootIndex, localURL: root, remotePath: rootDestination, byteCount: Int64(values.fileSize ?? 0), @@ -97,6 +102,7 @@ public enum SFTPLocalUploadPlanner { id: UUID(), rootCount: urls.filter(\.isFileURL).count, conflictCount: preflight.conflictCount, + roots: urls.map { RemotePath.join(destination, $0.lastPathComponent) }, jobs: jobs, remoteDirectories: preflight.directories, remoteDirectoryConflicts: preflight.directoryConflicts @@ -112,52 +118,111 @@ public enum SFTPLocalUploadPlanner { homeDirectory: String? = nil, client: any SFTPChannelProtocol, coordinator: SFTPTransferCoordinator, + uploadedPaths: (([String]) -> Void)? = nil, completion: ((SFTPTransfer.State) -> Void)? = nil ) async throws -> SFTPLocalUploadEnqueueResult { - let remoteResolution = try await SFTPRemoteConflictResolver.resolveDirectories( - batch.remoteDirectories, - conflicts: batch.remoteDirectoryConflicts, - policy: policy, - client: client - ) - var transferCount = 0 - for var job in batch.jobs { - try Task.checkCancellation() - guard let destination = remoteResolution.destination(for: job.remotePath) else { continue } - if destination != job.remotePath { job.hasConflict = false } - job.remotePath = destination - if job.hasConflict { - guard - let resolved = try await SFTPRemoteConflictResolver.resolveConflict( - path: job.remotePath, - conflictingTargetIsDirectory: job.conflictingTargetIsDirectory, - policy: policy, - client: client - ) - else { continue } - job.remotePath = resolved.path - job.hasConflict = resolved.overwrite - } - try Task.checkCancellation() - coordinator.enqueue( - direction: .upload, - batchID: batch.id, - localURL: job.localURL, - remotePath: job.remotePath, - byteCount: job.byteCount, - hostID: hostID, - connection: connection, - scopedRoot: job.scopedRoot, - overwrite: job.hasConflict && policy == .replace, - homeDirectory: homeDirectory, - completion: completion + let receipt = SFTPUploadReceipt(paths: batch.roots, completion: uploadedPaths) + defer { receipt.finishAdmission() } + do { + let remoteResolution = try await SFTPRemoteConflictResolver.resolveDirectories( + batch.remoteDirectories, + conflicts: batch.remoteDirectoryConflicts, + policy: policy, + client: client ) - transferCount += 1 + for (index, root) in batch.roots.enumerated() { + receipt.resolve(index, to: remoteResolution.destination(for: root)) + if remoteResolution.skipped.contains(where: { $0 == root || $0.hasPrefix(root + "/") }) { + receipt.reject(index) + } + } + var transferCount = 0 + for var job in batch.jobs { + try Task.checkCancellation() + guard let destination = remoteResolution.destination(for: job.remotePath) else { continue } + if destination != job.remotePath { job.hasConflict = false } + job.remotePath = destination + if job.hasConflict { + guard + let resolved = try await SFTPRemoteConflictResolver.resolveConflict( + path: job.remotePath, + conflictingTargetIsDirectory: job.conflictingTargetIsDirectory, + policy: policy, + client: client + ) + else { + receipt.reject(job.rootIndex) + continue + } + job.remotePath = resolved.path + job.hasConflict = resolved.overwrite + } + try Task.checkCancellation() + let rootIndex = job.rootIndex + if job.scopedRoot == job.localURL { receipt.resolve(rootIndex, to: job.remotePath) } + receipt.admit(rootIndex) + coordinator.enqueue( + direction: .upload, + batchID: batch.id, + localURL: job.localURL, + remotePath: job.remotePath, + byteCount: job.byteCount, + hostID: hostID, + connection: connection, + scopedRoot: job.scopedRoot, + overwrite: job.hasConflict && policy == .replace, + homeDirectory: homeDirectory, + completion: { state in + receipt.completed(rootIndex, state: state) + completion?(state) + } + ) + transferCount += 1 + } + return SFTPLocalUploadEnqueueResult( + transferCount: transferCount, + changedRemoteDirectories: remoteResolution.changed + ) + } catch { + receipt.cancel() + throw error } - return SFTPLocalUploadEnqueueResult( - transferCount: transferCount, - changedRemoteDirectories: remoteResolution.changed - ) } } + +/// Collects final root paths after all admitted transfers finish. Admission may +/// suspend while an earlier transfer finishes, so completion waits for both. +@MainActor +final class SFTPUploadReceipt { + private var paths: [String?] + private var rejected: Set = [] + private var pending = 0 + private var admissionFinished = false + private var completion: (([String]) -> Void)? + + init(paths: [String], completion: (([String]) -> Void)?) { + self.paths = paths.map { $0 } + self.completion = completion + } + + func resolve(_ index: Int, to path: String?) { paths[index] = path } + func reject(_ index: Int) { rejected.insert(index) } + func admit(_ index: Int) { pending += 1 } + func completed(_ index: Int, state: SFTPTransfer.State) { + if state != .completed { reject(index) } + pending -= 1 + deliverIfFinished() + } + func finishAdmission() { + admissionFinished = true + deliverIfFinished() + } + func cancel() { completion = nil } + private func deliverIfFinished() { + guard admissionFinished, pending == 0, let completion else { return } + self.completion = nil + let successful = paths.enumerated().compactMap { rejected.contains($0.offset) ? nil : $0.element } + if !successful.isEmpty { completion(successful) } + } +} diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 8a4f1960..b951b12a 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -28,6 +28,7 @@ private struct MobilePendingUpload { var ownsClient: Bool var context: SFTPTransferContext var connectionGeneration: UUID + var insertPaths: Bool } private struct MobilePendingDownload { @@ -145,6 +146,14 @@ final class MobileWorkspace: Identifiable { private let commandDraft = TerminalCommandDraftController() private var workingDirectoryObservation: AnyCancellable? private let surfaceOwner = MobileTerminalSurface() + var uploadConnectionGeneration: UUID { generation } + + func configureTerminalUpload(files: (() -> Void)?, photos: (() -> Void)? = nil) { + surfaceOwner.view.uploadFiles = descriptor.kind == .ssh ? files : nil + surfaceOwner.view.uploadPhotos = descriptor.kind == .ssh ? photos : nil + surfaceOwner.view.canUploadFiles = { [weak self] in self?.uploadTargetProblem == nil && self != nil } + } + var uploadTerminalView: UIView? { descriptor.kind == .ssh ? surfaceOwner.view : nil } let terminal: TerminalViewState private let backend: InMemoryTerminalSession @@ -1332,7 +1341,7 @@ final class MobileWorkspace: Identifiable { } @discardableResult - func prepareUpload(_ urls: [URL]) -> Bool { + func prepareUpload(_ urls: [URL], insertPaths: Bool = false) -> Bool { let urls = urls.filter(\.isFileURL) guard !urls.isEmpty else { return false } cancelPendingUpload() @@ -1417,7 +1426,8 @@ final class MobileWorkspace: Identifiable { client: client, ownsClient: ownsClient, context: context, - connectionGeneration: connectionGeneration + connectionGeneration: connectionGeneration, + insertPaths: insertPaths ) if batch.conflictCount > 0 { if uploadNotice?.isRejected != true { uploadNotice = nil } @@ -1606,7 +1616,15 @@ final class MobileWorkspace: Identifiable { connection: .context(pendingUpload.context), homeDirectory: pendingUpload.homeDirectory, client: pendingUpload.client, - coordinator: transfers + coordinator: transfers, + uploadedPaths: pendingUpload.insertPaths + ? { [weak self] paths in + guard let self, self.connected, + self.generation == pendingUpload.connectionGeneration + else { return } + let text = paths.map(RemoteShellCommand.escapeArgument).joined(separator: " ") + " " + _ = self.terminal.paste(text: text) + } : nil ) { [weak self] state in guard state == .completed else { return } Task { @MainActor in diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index d1952a64..d50627e5 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -32,6 +32,15 @@ struct MobileWorkspaceView: View { let closeOtherWorkspaces: (UUID) -> Void @State private var terminalSheet: TerminalSheet? @State private var importing = false + @State private var importingTerminalFiles = false + @State private var importingTerminalPhotos = false + @State private var terminalUploadTarget: TerminalUploadTarget? + + private struct TerminalUploadTarget { + let workspaceID: UUID + let generation: UUID + let destination: String + } @State private var directoryName = "" @State private var creatingDirectory = false @State private var deleting: RemoteFile? @@ -76,23 +85,31 @@ struct MobileWorkspaceView: View { isTargeted: $uploadDropTargeted, isEnabled: workspaceIsVisible && scenePhase == .active, terminalView: workspace.uploadTerminalView, - receive: receiveUploadProviders + receive: { receiveUploadProviders($0) } ) .id(workspace.id) } - .onAppear { workspaceIsVisible = true } + .onAppear { + workspaceIsVisible = true + bindTerminalUploadMenu() + } .onDisappear { openWithPresenter.isEnabled = false workspaceIsVisible = false + workspace.configureTerminalUpload(files: nil) + terminalUploadTarget = nil uploadDropTargeted = false cancelDropLoading() } .onChange(of: workspace.id) { _, _ in openWithPresenter.invalidate() + terminalUploadTarget = nil + bindTerminalUploadMenu() uploadDropTargeted = false cancelDropLoading() } .onChange(of: workspace.uploadDestination) { _, _ in cancelDropLoading() } + .onChange(of: workspace.uploadConnectionGeneration) { _, _ in cancelDropLoading() } .onChange(of: workspace.pendingExternalPreviewURL, initial: true) { _, url in guard url != nil, let preview = workspace.consumePendingExternalPreviewURL() else { return } presentPreview(preview) @@ -330,6 +347,26 @@ struct MobileWorkspaceView: View { .mobileSheetPresentation(compact: true) } } + .sheet(isPresented: $importingTerminalPhotos) { + MobileTerminalPhotoPicker { providers in + importingTerminalPhotos = false + receiveTerminalUpload(providers) + } + } + .fileImporter( + isPresented: $importingTerminalFiles, + allowedContentTypes: [.data, .folder], allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): + receiveTerminalUploadFiles(urls) + case .failure(let error): + terminalUploadTarget = nil + if (error as NSError).code != NSUserCancelledError { + workspace.reportUploadFailure(error.localizedDescription) + } + } + } .fileImporter( isPresented: $importingEditedCopy, allowedContentTypes: [.data], allowsMultipleSelection: false ) { result in @@ -572,19 +609,52 @@ struct MobileWorkspaceView: View { ) } - private func receiveUpload(_ urls: [URL]) -> Bool { - if workspace.descriptor.kind == .ssh, - !pro.require(.terminalFileUpload) - { - return false - } - return workspace.prepareUpload(urls) + private func bindTerminalUploadMenu() { + workspace.configureTerminalUpload( + files: { beginTerminalUpload(photos: false) }, + photos: { beginTerminalUpload(photos: true) }) } - private func receiveUploadProviders(_ providers: [NSItemProvider]) -> Bool { + private func beginTerminalUpload(photos: Bool) { + guard workspaceIsVisible, workspace.uploadTargetProblem == nil, + let destination = workspace.uploadDestination + else { return } + terminalUploadTarget = TerminalUploadTarget( + workspaceID: workspace.id, generation: workspace.uploadConnectionGeneration, + destination: destination) + if photos { importingTerminalPhotos = true } else { importingTerminalFiles = true } + } + + private var terminalUploadTargetIsCurrent: Bool { + guard let target = terminalUploadTarget, workspaceIsVisible else { return false } + return target.workspaceID == workspace.id + && target.generation == workspace.uploadConnectionGeneration + && target.destination == workspace.uploadDestination + } + + private func receiveTerminalUploadFiles(_ urls: [URL]) { + defer { terminalUploadTarget = nil } + guard terminalUploadTargetIsCurrent else { return } + // Keep the original security-scoped URLs, as in the SFTP file importer. + _ = workspace.prepareUpload(urls, insertPaths: true) + } + + private func receiveTerminalUpload(_ providers: [NSItemProvider]) { + defer { terminalUploadTarget = nil } + guard terminalUploadTargetIsCurrent else { return } + _ = receiveUploadProviders(providers, insertPaths: true) + } + + private func receiveUpload(_ urls: [URL]) -> Bool { + workspace.prepareUpload(urls) + } + + private func receiveUploadProviders(_ providers: [NSItemProvider], insertPaths: Bool = false) -> Bool { guard MobileDropPayloadLoader.acceptsUpload(providers) else { return false } uploadDropTask?.cancel() workspace.cancelPendingUpload() + let targetWorkspace = workspace + let generation = workspace.uploadConnectionGeneration let destination = workspace.uploadDestination uploadDropTask = Task { let payload = await MobileDropPayloadLoader.load( @@ -592,18 +662,21 @@ struct MobileWorkspaceView: View { kinds: [.files], fileMode: .staged ) - guard !Task.isCancelled, workspace.uploadDestination == destination else { + guard !Task.isCancelled, workspace.id == targetWorkspace.id, + targetWorkspace.uploadConnectionGeneration == generation, + targetWorkspace.uploadDestination == destination + else { if let stagedRoot = payload.stagedRoot { try? FileManager.default.removeItem(at: stagedRoot) } return } if !payload.fileURLs.isEmpty { - let accepted = receiveUpload(payload.fileURLs) + let accepted = targetWorkspace.prepareUpload(payload.fileURLs, insertPaths: insertPaths) if !accepted, let stagedRoot = payload.stagedRoot { try? FileManager.default.removeItem(at: stagedRoot) } } if let error = payload.failures.first { - workspace.reportUploadFailure(error) + targetWorkspace.reportUploadFailure(error) } } return true diff --git a/TFI/Platform/MobileTerminalPhotoPicker.swift b/TFI/Platform/MobileTerminalPhotoPicker.swift new file mode 100644 index 00000000..0443673c --- /dev/null +++ b/TFI/Platform/MobileTerminalPhotoPicker.swift @@ -0,0 +1,29 @@ +import PhotosUI +import SwiftUI + +/// PHPicker exposes file providers without requesting access to the entire library. +struct MobileTerminalPhotoPicker: UIViewControllerRepresentable { + let selected: ([NSItemProvider]) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(selected: selected) } + + func makeUIViewController(context: Context) -> PHPickerViewController { + var configuration = PHPickerConfiguration() + configuration.selectionLimit = 0 + configuration.filter = .any(of: [.images, .videos]) + configuration.preferredAssetRepresentationMode = .current + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_: PHPickerViewController, context _: Context) {} + + final class Coordinator: NSObject, PHPickerViewControllerDelegate { + let selected: ([NSItemProvider]) -> Void + init(selected: @escaping ([NSItemProvider]) -> Void) { self.selected = selected } + func picker(_: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + selected(results.map(\.itemProvider)) + } + } +} diff --git a/TFI/Platform/MobileTerminalSurface.swift b/TFI/Platform/MobileTerminalSurface.swift index 97ad874e..eeb27dea 100644 --- a/TFI/Platform/MobileTerminalSurface.swift +++ b/TFI/Platform/MobileTerminalSurface.swift @@ -28,6 +28,42 @@ final class MobileTerminalSurface { /// input away from Ghostty. Escape is replaced only while Find is presented. @MainActor final class MobileTerminalView: TerminalView { + var uploadFiles: (() -> Void)? + var uploadPhotos: (() -> Void)? + var canUploadFiles: (() -> Bool)? + + override func touchMenuItems(for context: TerminalTouchMenuContext) -> [UIMenuElement] { + super.touchMenuItems(for: context) + uploadMenuItems() + } + + override func touchSelectionMenuItems(for context: TerminalTouchSelectionMenuContext) -> [UIMenuElement] { + super.touchSelectionMenuItems(for: context) + uploadMenuItems() + } + + func uploadMenuItems() -> [UIMenuElement] { + guard uploadFiles != nil else { return [] } + let attributes: UIMenuElement.Attributes = canUploadFiles?() == true ? [] : .disabled + let photos = UIAction( + title: String(localized: AppStrings.Assistant.photos), + image: UIImage(systemName: "photo.on.rectangle"), attributes: attributes + ) { [weak self] _ in + guard self?.canUploadFiles?() == true else { return } + self?.uploadPhotos?() + } + let files = UIAction( + title: String(localized: AppStrings.Assistant.files), + image: UIImage(systemName: "folder"), attributes: attributes + ) { [weak self] _ in + guard self?.canUploadFiles?() == true else { return } + self?.uploadFiles?() + } + return [ + UIMenu( + title: String(localized: AppStrings.Terminal.uploadFile), + image: UIImage(systemName: "paperclip"), children: [photos, files]) + ] + } + weak var findController: TerminalFindController? override func inputAccessoryAccessibilityLabel(for item: TerminalInputAccessoryItem) -> String? { diff --git a/TFITests/MobileTerminalUploadMenuTests.swift b/TFITests/MobileTerminalUploadMenuTests.swift new file mode 100644 index 00000000..2dab71ed --- /dev/null +++ b/TFITests/MobileTerminalUploadMenuTests.swift @@ -0,0 +1,28 @@ +import TermindPresentation +import Testing +import UIKit + +@testable import TermindIOS + +@Suite @MainActor +struct MobileTerminalUploadMenuTests { + @Test func menuUsesNativePhotoAndFileActionsAndTracksAvailability() throws { + let view = MobileTerminalView(frame: .zero) + #expect(view.uploadMenuItems().isEmpty) + var available = false + view.uploadFiles = {} + view.uploadPhotos = {} + view.canUploadFiles = { available } + let disabled = try #require(view.uploadMenuItems().first as? UIMenu) + #expect(disabled.title == String(localized: AppStrings.Terminal.uploadFile)) + #expect(disabled.children.compactMap { $0 as? UIAction }.allSatisfy { $0.attributes.contains(.disabled) }) + available = true + let enabled = try #require(view.uploadMenuItems().first as? UIMenu) + let actions = enabled.children.compactMap { $0 as? UIAction } + #expect( + actions.map(\.title) == [ + String(localized: AppStrings.Assistant.photos), String(localized: AppStrings.Assistant.files), + ]) + #expect(actions.allSatisfy { !$0.attributes.contains(.disabled) }) + } +} diff --git a/TFM/Features/SFTP/SFTPChannelModel.swift b/TFM/Features/SFTP/SFTPChannelModel.swift index 7c6bf2db..f41341c7 100644 --- a/TFM/Features/SFTP/SFTPChannelModel.swift +++ b/TFM/Features/SFTP/SFTPChannelModel.swift @@ -1099,7 +1099,10 @@ final class SFTPChannelModel: Identifiable { } } - func enqueuePending(policy: TransferConflictPolicy, coordinator: SFTPTransferCoordinator) async { + func enqueuePending( + policy: TransferConflictPolicy, coordinator: SFTPTransferCoordinator, + uploadedPaths: (([String]) -> Void)? = nil + ) async { bindTransfers(to: coordinator) if let batch = pendingUploadBatch { guard let connection = transferConnection, let client else { return } @@ -1112,7 +1115,8 @@ final class SFTPChannelModel: Identifiable { connection: connection, homeDirectory: homeResolution.resolvedDirectory, client: client, - coordinator: coordinator + coordinator: coordinator, + uploadedPaths: uploadedPaths ) { [weak self] state in if state == .completed { self?.scheduleSilentRefresh() } } diff --git a/TFM/Features/Terminal/DesktopTerminalSurface.swift b/TFM/Features/Terminal/DesktopTerminalSurface.swift index 8fd5fec1..a79859b7 100644 --- a/TFM/Features/Terminal/DesktopTerminalSurface.swift +++ b/TFM/Features/Terminal/DesktopTerminalSurface.swift @@ -16,6 +16,7 @@ nonisolated final class TerminalInteractiveInputCapture: @unchecked Sendable { private static let deferredWriteWindowNanoseconds: UInt64 = 100_000_000 private let lock = NSLock() private var depth = 0 + private var localPasteDepth = 0 private var writeGeneration: UInt64 = 0 private var deferredWriteDeadline: UInt64? private var bracketedPaste: (tail: Data, deadline: UInt64)? @@ -46,6 +47,17 @@ nonisolated final class TerminalInteractiveInputCapture: @unchecked Sendable { return try operation() } + /// A completed upload inserts into its original session even during broadcast. + func withLocalPaste(_ operation: () throws -> T) rethrows -> T { + lock.withLock { + localPasteDepth += 1 + deferredWriteDeadline = nil + bracketedPaste = nil + } + defer { lock.withLock { localPasteDepth -= 1 } } + return try operation() + } + /// Classifies the backend callback while it still has the input event's /// provenance. Ordinarily Ghostty writes synchronously. The short-lived /// deferred ticket covers callbacks it schedules just after AppKit returns. @@ -55,6 +67,7 @@ nonisolated final class TerminalInteractiveInputCapture: @unchecked Sendable { func classifyWrite(_ data: Data = Data()) -> Bool { lock.withLock { writeGeneration &+= 1 + guard localPasteDepth == 0 else { return false } let now = DispatchTime.now().uptimeNanoseconds if let paste = bracketedPaste, now > paste.deadline { bracketedPaste = nil diff --git a/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift b/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift index 09188a5e..16978a66 100644 --- a/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift +++ b/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift @@ -92,6 +92,8 @@ /// seeing the event. Registered Termind shortcuts fall through; every other /// key still uses Ghostty's input path. final class TermindTerminalView: TerminalView { + var uploadFiles: (() -> Void)? + var canUploadFiles: (() -> Bool)? var inputCapture: TerminalInteractiveInputCapture? var copyOnSelect = false var pasteOnRightClick = false @@ -238,9 +240,23 @@ action: #selector(selectAll(_:)), keyEquivalent: "") selectAllItem.target = self menu.addItem(selectAllItem) + if uploadFiles != nil { + menu.addItem(.separator()) + let upload = NSMenuItem( + title: String(localized: AppStrings.Terminal.uploadFile), + action: #selector(uploadFromContextMenu(_:)), keyEquivalent: "") + upload.target = self + upload.isEnabled = canUploadFiles?() == true + menu.addItem(upload) + } return menu } + @objc private func uploadFromContextMenu(_: Any?) { + guard canUploadFiles?() == true else { return } + uploadFiles?() + } + private var canPasteFromClipboard: Bool { let pasteboard = NSPasteboard.general return pasteboard.string(forType: .string)?.isEmpty == false @@ -317,6 +333,31 @@ private var wantsFocus = false private var nextMountOrdinal: UInt64 = 0 + func configureUpload(action: (() -> Void)?, isAvailable: (() -> Bool)? = nil) { + terminalView.uploadFiles = action + terminalView.canUploadFiles = isAvailable + } + + func chooseUploadFiles(completion: @escaping ([URL]) -> Void) { + guard isUploadSurfaceVisible, let window = terminalView.window else { return } + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = true + panel.allowsMultipleSelection = true + panel.beginSheetModal(for: window) { [weak self] response in + guard response == .OK, self?.isUploadSurfaceVisible == true else { return } + completion(panel.urls) + } + } + + var isUploadSurfaceVisible: Bool { + appliedVisibility == true && terminalView.window != nil && !terminalView.isHiddenOrHasHiddenAncestor + } + + func pasteUploadedText(_ text: String) { + terminalView.inputCapture?.withLocalPaste { _ = state.paste(text: text) } + } + private var ownerIdentifier: ObjectIdentifier { ObjectIdentifier(self) } init( diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 999bb355..c9440672 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -331,6 +331,12 @@ final class SSHSessionModel: Identifiable { /// Clears the shell's edit buffer through the installed line-editor widget, /// then inserts one literal command as a bracketed paste. There is /// intentionally no carriage return in this payload. + func insertUploadedPaths(_ paths: [String]) { + let text = paths.map(RemoteShellCommand.escapeArgument).joined(separator: " ") + " " + // Write only to this session: broadcast input must not copy local upload paths. + terminalViewOwner.pasteUploadedText(text) + } + func replaceCurrentDraft(with command: String) { writeInputDirectly(commandDraft.replacementPayload(for: command)) } diff --git a/TFM/Features/Terminal/TerminalPlaceholderView.swift b/TFM/Features/Terminal/TerminalPlaceholderView.swift index f8fabf4e..c20d48d7 100644 --- a/TFM/Features/Terminal/TerminalPlaceholderView.swift +++ b/TFM/Features/Terminal/TerminalPlaceholderView.swift @@ -95,6 +95,7 @@ struct TerminalPlaceholderView: View { @FocusState private var terminalFocused: Bool @State private var pendingUploadChannel: SFTPChannelModel? @State private var pendingUploadItemCount = 0 + @State private var insertUploadedPaths = false @State private var uploadConflictPresented = false @State private var uploadNotice: WorkspaceUploadNotice? @State private var uploadNoticeTask: Task? @@ -127,9 +128,17 @@ struct TerminalPlaceholderView: View { sshSession.inputRouter = workspace terminalFocused = requestsFocus TerminalFontCatalog.shared.refresh() + sshSession.terminalViewOwner.configureUpload( + action: chooseUploadFiles, + isAvailable: { isVisible && uploadTargetProblem == nil }) sshSession.connectIfNeeded(using: app.hostLibrary) if requestsFocus { sshSession.requestTerminalFocus() } } + .onChange(of: isVisible) { + sshSession.terminalViewOwner.configureUpload( + action: chooseUploadFiles, + isAvailable: { isVisible && uploadTargetProblem == nil }) + } .onChange(of: requestsFocus) { _, focused in terminalFocused = focused if focused { sshSession.requestTerminalFocus() } @@ -145,6 +154,7 @@ struct TerminalPlaceholderView: View { .onChange(of: sshSession.connectionAttemptID) { cancelPendingUpload() } .onDisappear { terminalFocused = false + sshSession.terminalViewOwner.configureUpload(action: nil) cancelPendingUpload() } .onChange(of: scenePhase) { _, phase in @@ -228,17 +238,25 @@ struct TerminalPlaceholderView: View { return String(localized: AppStrings.SFTP.existingItemsMessage(count: count)) } + private func chooseUploadFiles() { + guard uploadTargetProblem == nil else { return } + let generation = sshSession.connectionAttemptID + let destination = sshSession.workingDirectory + sshSession.terminalViewOwner.chooseUploadFiles { urls in + guard sshSession.connectionAttemptID == generation, + sshSession.workingDirectory == destination, uploadTargetProblem == nil + else { return } + _ = prepareTerminalTransfer(.local(urls), insertPaths: true) + } + } + private func receiveDroppedURLs(_ urls: [URL]) -> Bool { - // Only this terminal convenience is Pro. SFTPBrowserView keeps its own - // upload flow and remains free. - guard app.proAccess.require(.terminalFileUpload) else { return false } let files = urls.filter(\.isFileURL) guard !files.isEmpty else { return false } return prepareTerminalTransfer(.local(files)) } private func receiveRemoteDrop(_ data: Data) -> Bool { - guard app.proAccess.require(.terminalFileUpload) else { return false } guard let request = TerminalTransferRequest.remote(data: data, coordinator: app.sftpTransfers) else { showUploadNotice(.rejected(String(localized: AppStrings.SFTP.theCopiedSftpItemsAreInvalid))) return false @@ -246,8 +264,9 @@ struct TerminalPlaceholderView: View { return prepareTerminalTransfer(request) } - private func prepareTerminalTransfer(_ request: TerminalTransferRequest) -> Bool { + private func prepareTerminalTransfer(_ request: TerminalTransferRequest, insertPaths: Bool = false) -> Bool { cancelPendingUpload() + insertUploadedPaths = insertPaths guard uploadTargetProblem == nil, let snapshot = sshSession.transferSnapshot, let workingDirectory = sshSession.workingDirectory @@ -342,6 +361,7 @@ struct TerminalPlaceholderView: View { let itemCount = pendingUploadItemCount let destination = uploadChannel.path let generation = sshSession.connectionAttemptID + let insertPaths = insertUploadedPaths uploadConflictPresented = false uploadEnqueueTask = Task { if uploadChannel.pendingRemoteCopyBatch != nil { @@ -352,7 +372,14 @@ struct TerminalPlaceholderView: View { } else { await uploadChannel.enqueuePending( policy: policy, - coordinator: app.sftpTransfers + coordinator: app.sftpTransfers, + uploadedPaths: insertPaths + ? { [weak session = sshSession] paths in + guard let session, session.status == .connected, + session.connectionAttemptID == generation + else { return } + session.insertUploadedPaths(paths) + } : nil ) } let error = uploadChannel.operationError diff --git a/TFMTests/BroadcastInputTests.swift b/TFMTests/BroadcastInputTests.swift index b4adbe2a..8e2e20c6 100644 --- a/TFMTests/BroadcastInputTests.swift +++ b/TFMTests/BroadcastInputTests.swift @@ -41,6 +41,18 @@ private final class WorkspaceCommandGeneratorStub: CommandGenerating { @MainActor struct BroadcastInputTests { + @Test func uploadPasteDoesNotConsumeAnInteractiveBroadcastTicket() { + let capture = TerminalInteractiveInputCapture() + capture.withInteraction(allowsDeferredWrite: true) {} + capture.withLocalPaste { + #expect(!capture.classifyWrite(Data("uploaded-path".utf8))) + } + #expect(!capture.classifyWrite(Data("deferred-upload".utf8))) + capture.withInteraction { + #expect(capture.classifyWrite(Data("typed".utf8))) + } + } + @Test func deferredBracketedPasteKeepsEveryChunkInteractiveUntilItsClosingMarker() { let capture = TerminalInteractiveInputCapture() diff --git a/TFMTests/SFTPFeatureTests.swift b/TFMTests/SFTPFeatureTests.swift index 393990a6..fa0b4bb7 100644 --- a/TFMTests/SFTPFeatureTests.swift +++ b/TFMTests/SFTPFeatureTests.swift @@ -1234,7 +1234,15 @@ struct SFTPFeatureTests { let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { client } await sftpChannel.prepareUpload([upload]) - await sftpChannel.enqueuePending(policy: .keepBoth, coordinator: coordinator) + var uploadedPaths: [[String]] = [] + await sftpChannel.enqueuePending( + policy: .keepBoth, coordinator: coordinator, + uploadedPaths: { uploadedPaths.append($0) }) + for _ in 0..<200 { + if !uploadedPaths.isEmpty { break } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(uploadedPaths == [["/incoming/bundle.2"]]) #expect(coordinator.sftpTransfers.map(\.remotePath) == ["/incoming/bundle.2/nested/payload.txt"]) #expect( diff --git a/TFMTests/TerminalContextMenuTests.swift b/TFMTests/TerminalContextMenuTests.swift index d253c2ae..f2ad4154 100644 --- a/TFMTests/TerminalContextMenuTests.swift +++ b/TFMTests/TerminalContextMenuTests.swift @@ -6,6 +6,25 @@ import Testing @Suite @MainActor struct TerminalContextMenuTests { + @Test func uploadActionAppearsOnlyWhenBoundAndUsesLiveAvailability() throws { + let view = TermindTerminalView(frame: .zero) + let title = String(localized: AppStrings.Terminal.uploadFile) + #expect(view.selectionContextMenu().item(withTitle: title) == nil) + var available = false + var requests = 0 + view.uploadFiles = { requests += 1 } + view.canUploadFiles = { available } + #expect(view.selectionContextMenu().item(withTitle: title)?.isEnabled == false) + available = true + let item = try #require(view.selectionContextMenu().item(withTitle: title)) + #expect(item.isEnabled) + _ = view.perform(item.action, with: item) + #expect(requests == 1) + available = false + _ = view.perform(item.action, with: item) + #expect(requests == 1) + } + @Test func defaultMenuValidatesPasteAndOptionBypassesAutomaticPaste() throws { let pasteboard = NSPasteboard.general let saved = (pasteboard.pasteboardItems ?? []).map { item in diff --git a/TFMTests/TerminalMouseInteractionTests.swift b/TFMTests/TerminalMouseInteractionTests.swift index 45a59dc6..7f35779e 100644 --- a/TFMTests/TerminalMouseInteractionTests.swift +++ b/TFMTests/TerminalMouseInteractionTests.swift @@ -19,6 +19,29 @@ private nonisolated let terminalMouseHasDisplay: Bool = { .requiresVisibleWindow, .serialized, .disabled(if: !terminalMouseHasDisplay, "Ghostty requires an active display")) @MainActor struct TerminalMouseInteractionTests { + @Test func uploadMenuPresentsFilePickerOnThePersistentTerminalWindow() async throws { + let fixture = try await TerminalMouseFixture.make() + defer { fixture.close() } + // The custom mount bypasses Ghostty's SwiftUI representable. + #expect(fixture.state.attachedPlatformView == nil) + fixture.owner.configureUpload( + action: { fixture.owner.chooseUploadFiles { _ in Issue.record("Cancelled picker must not upload") } }, + isAvailable: { true }) + defer { fixture.owner.configureUpload(action: nil) } + let title = String(localized: AppStrings.Terminal.uploadFile) + let item = try #require(fixture.view.selectionContextMenu().item(withTitle: title)) + #expect(!title.contains("…")) + _ = fixture.view.perform(item.action, with: item) + for _ in 0..<100 where fixture.window.attachedSheet == nil { + try await Task.sleep(for: .milliseconds(10)) + } + let panel = try #require(fixture.window.attachedSheet as? NSOpenPanel) + #expect(panel.canChooseFiles) + #expect(panel.canChooseDirectories) + #expect(panel.allowsMultipleSelection) + panel.cancel(nil) + } + @Test func statusBarPathReachesTerminalThroughWorkspaceOverlays() async throws { let fixture = try await TerminalMouseFixture.make() defer { fixture.close() } diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 71027b67..31a21235 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -2943,7 +2943,7 @@ repositoryURL = "https://github.com/AkinoKaede/libghostty-spm.git"; requirement = { kind = revision; - revision = "020eb7670797926e4513247faceff705a13f2493"; + revision = 020eb7670797926e4513247faceff705a13f2493; }; }; TRMD00000000000000100023 /* XCRemoteSwiftPackageReference "openssl-spm" */ = {