feat(terminal): make uploads free and add native upload menus

This commit is contained in:
Kaede Akino committed 2026-10-02 08:27:20 +08:00
1 parent 8777169276
commit d4323176ed
23 files changed
+545 -608

No files matched your search

+1 -1
View File
@@ -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"),
("", "''"),
@@ -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"]])
}
}
+17 -436
View File
@@ -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" : {
@@ -2,7 +2,6 @@ import Foundation
public nonisolated enum ProFeature: String, CaseIterable, Hashable, Sendable {
case assistant
case terminalFileUpload
case remoteMCP
case mcpServer
case vaultSync
@@ -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 = ""
@@ -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)"
}
}
}
@@ -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"
}
@@ -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<String>
@@ -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<Int> = []
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) }
}
}
+21 -3
View File
@@ -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
@@ -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
@@ -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))
}
}
}
+36
View File
@@ -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? {
@@ -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) })
}
}
+6 -2
View File
@@ -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() }
}
@@ -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<T>(_ 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
@@ -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(
@@ -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))
}
@@ -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<Void, Never>?
@@ -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
+12
View File
@@ -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()
+9 -1
View File
@@ -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(
+19
View File
@@ -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
@@ -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() }
+1 -1
View File
@@ -2943,7 +2943,7 @@
repositoryURL = "https://github.com/AkinoKaede/libghostty-spm.git";
requirement = {
kind = revision;
revision = "020eb7670797926e4513247faceff705a13f2493";
revision = 020eb7670797926e4513247faceff705a13f2493;
};
};
TRMD00000000000000100023 /* XCRemoteSwiftPackageReference "openssl-spm" */ = {