feat(terminal): make uploads free and add native upload menus
This commit is contained in:
1 parent
8777169276
commit
d4323176ed
23 files changed
+545
-608
No files matched your search
@@ -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"]])
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -2943,7 +2943,7 @@
|
||||
repositoryURL = "https://github.com/AkinoKaede/libghostty-spm.git";
|
||||
requirement = {
|
||||
kind = revision;
|
||||
revision = "020eb7670797926e4513247faceff705a13f2493";
|
||||
revision = 020eb7670797926e4513247faceff705a13f2493;
|
||||
};
|
||||
};
|
||||
TRMD00000000000000100023 /* XCRemoteSwiftPackageReference "openssl-spm" */ = {
|
||||
|
||||
Reference in new issue
Block a user