Add SFTP Open With and edited-copy import

This commit is contained in:
Kaede Akino committed 2026-09-17 14:15:03 +08:00
1 parent ed4d3616fd
commit 99d14e683a
15 files changed
+633 -2

No files matched your search

@@ -1,6 +1,13 @@
{
"sourceLanguage" : "en",
"strings" : {
"Import Edited Copy…" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"导入已编辑副本…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"輸入已編輯副本…"}}}},
"Choose an edited copy with the same file format as the original." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"请选择与原文件格式相同的已编辑副本。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"請選擇與原始檔案格式相同的已編輯副本。"}}}},
"Open With" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"打开方式"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"打開方式"}}}},
"Open With…" : {"extractionState":"extracted","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"打开方式…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"打開方式…"}}}},
"Other…" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"其他…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"其他…"}}}},
"%@ (default)" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%@(默认)"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"%@(預設)"}}}},
"No installed app can open this file." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"没有已安装的应用可以打开此文件。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"沒有已安裝的App可以打開此檔案。"}}}},
"Error Details" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"错误详情"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"錯誤詳細資訊"}}}},
"The remote file changed. Review it before uploading again." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"远端文件已更改。请检查后再上传。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"遠端檔案已變更。請檢查後再上傳。"}}}},
"The original connection is no longer available." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"原连接已不可用。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"原連線已無法使用。"}}}},
+10
View File
@@ -228,6 +228,16 @@ and takes ownership of temporary edited copies. Different-format copies are expo
separately instead of overwriting the original remote format. Shared Presentation owns
the dirty-file card; AppKit/UIKit and their lifecycle adapters stay in their apps.
Open With uses the same managed edit copy as ordinary Open. macOS discovers applications
by UTType and launches the one selected for that invocation without changing defaults.
iOS retains Quick Look as its primary action and uses a workspace-owned document
interaction controller for Open With; retired/background presentation requests cannot
surface a late download in another workspace. The controller survives scene deactivation
until an in-progress document handoff finishes. Receiving apps may edit an independent
copy, so mobile offers explicit same-format edited-copy import into an existing record.
Import preserves the original remote identity and baseline, rejects invalidated/busy
records, and only marks local changes; the existing explicit upload/conflict flow commits them.
`sftp_copy` is Termind-owned and acting-only in Assistant, with Ask/sequential
policy. It copies regular files and directory trees into an existing directory;
unsupported descendants fail before writes. Its awaited operation shares browser
+10
View File
@@ -27,6 +27,9 @@ struct MobileSFTPBrowser: View {
@Bindable var session: MobileSFTPSessionModel
let connected: Bool
let open: (RemoteFile) -> Void
let openWith: (RemoteFile) -> Void
let canImportEditedCopy: (RemoteFile) -> Bool
let importEditedCopy: (RemoteFile) -> Void
let download: (RemoteFile) -> Void
let copy: (RemoteFile) -> Void
let paste: () -> Void
@@ -130,6 +133,13 @@ struct MobileSFTPBrowser: View {
.contentShape(.rect)
.contextMenu {
Button("Open", systemImage: "arrow.up.right") { open(file) }
if !file.isDirectory {
Button("Open With…", systemImage: "arrow.up.forward.app") { openWith(file) }
.disabled(!connected)
if canImportEditedCopy(file) {
Button("Import Edited Copy…", systemImage: "square.and.arrow.down") { importEditedCopy(file) }
}
}
Button("Download", systemImage: "square.and.arrow.down") { download(file) }
Divider()
Button("Copy", systemImage: "doc.on.doc") { copy(file) }
@@ -1352,6 +1352,19 @@ final class MobileWorkspace: Identifiable {
}
}
func importEditedCopy(_ source: URL, for id: UUID) async {
guard let edits = sftpEdits, let file = edits.model.file(id), file.hostID == descriptor.hostID else { return }
do {
let copy = try await MobileSFTPEditedCopyImporter().stage(source, matching: file.localURL)
defer { try? FileManager.default.removeItem(at: copy.deletingLastPathComponent()) }
guard let current = edits.model.file(id), !current.isInvalidated,
!current.isPreparing, current.transferID == nil
else { throw SFTPEditFailure.unavailable }
_ = try FileManager.default.replaceItemAt(current.localURL, withItemAt: copy)
await edits.recheck()
} catch { operationError = error.localizedDescription }
}
func reopenHistoryFile(at path: String, completion: @escaping (URL) -> Void = { _ in }) async -> Bool {
guard let sftp, connected else {
operationError = String(localized: "The SFTP connection is not available.")
@@ -42,6 +42,9 @@ struct MobileWorkspaceView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var downloadTarget: RemoteFile?
@State private var previewURL: URL?
@State private var openWithPresenter = MobileSFTPOpenWithPresenter()
@State private var importingEditedCopy = false
@State private var editedCopyTarget: UUID?
@State private var exportedEditURL: URL?
@State private var showsExportedEdit = false
@ScaledMetric private var editRowHeight = 76.0
@@ -58,6 +61,12 @@ struct MobileWorkspaceView: View {
}
}
.background {
MobileSFTPOpenWithAnchor(
presenter: openWithPresenter,
isEnabled: workspaceIsVisible && scenePhase == .active,
failed: { [workspace] in workspace.operationError = $0 },
finished: { [edits = workspace.sftpEdits] in Task { await edits?.recheck() } }
)
MobileWindowDropTarget(
isTargeted: $uploadDropTargeted,
isEnabled: workspaceIsVisible && scenePhase == .active,
@@ -68,15 +77,20 @@ struct MobileWorkspaceView: View {
}
.onAppear { workspaceIsVisible = true }
.onDisappear {
openWithPresenter.isEnabled = false
workspaceIsVisible = false
uploadDropTargeted = false
cancelDropLoading()
}
.onChange(of: workspace.id) { _, _ in
openWithPresenter.invalidate()
uploadDropTargeted = false
cancelDropLoading()
}
.onChange(of: workspace.uploadDestination) { _, _ in cancelDropLoading() }
.onChange(of: scenePhase) { _, phase in
if phase != .active { openWithPresenter.isEnabled = false }
}
}
private var workspaceChrome: some View {
@@ -251,6 +265,19 @@ struct MobileWorkspaceView: View {
.mobileSheetPresentation(compact: true)
}
}
.fileImporter(
isPresented: $importingEditedCopy, allowedContentTypes: [.data], allowsMultipleSelection: false
) { result in
let target = editedCopyTarget
editedCopyTarget = nil
switch result {
case .success(let urls):
if let target, let source = urls.first {
Task { await workspace.importEditedCopy(source, for: target) }
}
case .failure(let error): workspace.operationError = error.localizedDescription
}
}
.fileImporter(
isPresented: $importing,
allowedContentTypes: [.data, .folder],
@@ -371,6 +398,19 @@ struct MobileWorkspaceView: View {
session: workspace.sftpSession,
connected: workspace.connected,
open: open,
openWith: openWith,
canImportEditedCopy: { file in
guard
let edit = workspace.sftpEdits?.model.file(hostID: workspace.descriptor.hostID, path: file.path)
else { return false }
return !edit.isInvalidated && !edit.isPreparing && edit.transferID == nil
},
importEditedCopy: { file in
editedCopyTarget =
workspace.sftpEdits?.model.file(
hostID: workspace.descriptor.hostID, path: file.path)?.id
importingEditedCopy = editedCopyTarget != nil
},
download: { downloadTarget = $0 },
copy: copyRemoteItem,
paste: pasteRemoteItems,
@@ -412,6 +452,19 @@ struct MobileWorkspaceView: View {
}
}
private func openWith(_ file: RemoteFile) {
guard !file.isDirectory, workspace.connected, let request = openWithPresenter.begin() else { return }
// preview prepares the same managed edit copy used by Quick Look and the upload overlay.
if !workspace.preview(
file,
completion: { [openWithPresenter] url in
openWithPresenter.downloaded(url, request: request)
})
{
openWithPresenter.invalidate()
}
}
private func reopenHistory(_ entry: SFTPHistoryEntry) {
switch entry.kind {
case .directory:
@@ -0,0 +1,49 @@
import Foundation
import UniformTypeIdentifiers
/// A receiving app may edit its own copy. Bring an explicitly selected copy back
/// into the existing edit record without changing its remote path or baseline.
actor MobileSFTPEditedCopyImporter {
enum Failure: LocalizedError {
case differentFormat
var errorDescription: String? {
String(localized: "Choose an edited copy with the same file format as the original.")
}
}
func stage(_ source: URL, matching original: URL) throws -> URL {
let originalType = UTType(filenameExtension: original.pathExtension)
let sourceType = UTType(filenameExtension: source.pathExtension)
let sameFormat =
if let originalType, let sourceType { originalType == sourceType } else {
original.pathExtension.lowercased() == source.pathExtension.lowercased()
}
guard sameFormat else { throw Failure.differentFormat }
let scoped = source.startAccessingSecurityScopedResource()
defer { if scoped { source.stopAccessingSecurityScopedResource() } }
let directory = FileManager.default.temporaryDirectory
.appending(path: "TermindEditedImports", directoryHint: .isDirectory)
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let copy = directory.appending(path: original.lastPathComponent)
do {
var coordinationError: NSError?
var copyError: (any Error)?
NSFileCoordinator().coordinate(readingItemAt: source, options: [], error: &coordinationError) { url in
do {
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
guard values.isRegularFile == true, values.isSymbolicLink != true else {
throw CocoaError(.fileReadUnsupportedScheme)
}
try FileManager.default.copyItem(at: url, to: copy)
} catch { copyError = error }
}
if let error = coordinationError ?? copyError { throw error }
return copy
} catch {
try? FileManager.default.removeItem(at: directory)
throw error
}
}
}
@@ -0,0 +1,102 @@
import SwiftUI
import UIKit
/// The workspace owns presentation intent; the edit service owns the local file.
@MainActor
final class MobileSFTPOpenWithPresenter: NSObject, UIDocumentInteractionControllerDelegate {
weak var sourceView: UIView?
var failed: (String) -> Void = { _ in }
var finished: () -> Void = {}
var isEnabled = false {
didSet { if !isEnabled { invalidate() } }
}
private var requestID: UUID?
private(set) var controller: UIDocumentInteractionController?
private var sending = false
private var handoffRetention: MobileSFTPOpenWithPresenter?
private let present: @MainActor (UIDocumentInteractionController, UIView) -> Bool
init(
present: @escaping @MainActor (UIDocumentInteractionController, UIView) -> Bool = { controller, view in
controller.presentOpenInMenu(
from: CGRect(x: view.bounds.midX, y: view.bounds.midY, width: 1, height: 1),
in: view, animated: true)
}
) {
self.present = present
}
func begin() -> UUID? {
guard isEnabled, !sending else { return nil }
invalidate()
let id = UUID()
requestID = id
return id
}
func downloaded(_ url: URL, request: UUID) {
guard requestID == request else { return }
requestID = nil
guard isEnabled, let sourceView, sourceView.window != nil else { return }
let controller = UIDocumentInteractionController(url: url)
controller.delegate = self
self.controller = controller
if !present(controller, sourceView) {
self.controller = nil
failed(String(localized: "No installed app can open this file."))
}
}
func invalidate() {
requestID = nil
// Switching to the receiving app can deactivate this scene before handoff finishes.
guard !sending else { return }
let previous = controller
controller = nil
previous?.dismissMenu(animated: false)
}
func documentInteractionController(
_ controller: UIDocumentInteractionController, willBeginSendingToApplication application: String?
) {
guard self.controller === controller else { return }
sending = true
handoffRetention = self
}
func documentInteractionControllerDidDismissOpenInMenu(_ controller: UIDocumentInteractionController) {
guard self.controller === controller, !sending else { return }
self.controller = nil
finished()
}
func documentInteractionController(
_ controller: UIDocumentInteractionController, didEndSendingToApplication application: String?
) {
guard self.controller === controller else { return }
sending = false
self.controller = nil
finished()
handoffRetention = nil
}
}
struct MobileSFTPOpenWithAnchor: UIViewRepresentable {
let presenter: MobileSFTPOpenWithPresenter
let isEnabled: Bool
let failed: (String) -> Void
let finished: () -> Void
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.isUserInteractionEnabled = false
return view
}
func updateUIView(_ view: UIView, context: Context) {
presenter.sourceView = view
presenter.failed = failed
presenter.finished = finished
presenter.isEnabled = isEnabled
}
}
@@ -43,7 +43,9 @@ struct MobileMarketingScreenshotTests {
try await capture("sftp", language: language) {
NavigationStack {
MobileSFTPBrowser(
session: sftp, connected: true, open: { _ in }, download: { _ in }, copy: { _ in },
session: sftp, connected: true, open: { _ in }, openWith: { _ in },
canImportEditedCopy: { _ in false }, importEditedCopy: { _ in },
download: { _ in }, copy: { _ in },
paste: {}, rename: { _ in }, editPermissions: { _ in }, delete: { _ in }
)
.navigationTitle(text(language, "Production files", "生产文件", "正式環境檔案"))
@@ -0,0 +1,88 @@
import CryptoKit
import Foundation
import TermindApplication
import TermindDomain
import TermindRemoteInfrastructure
import Testing
@testable import TermindIOS
@MainActor
struct MobileSFTPEditedCopyImporterTests {
@Test func importedCopyBecomesAPendingEditWithoutUploadingOrChangingRemoteIdentity() async throws {
let fixture = try Fixture()
defer { fixture.clean() }
let baseline = try #require(fixture.edits.model.file(fixture.id)?.baseline)
await fixture.workspace.importEditedCopy(fixture.source, for: fixture.id)
#expect(fixture.workspace.operationError == nil)
#expect(try Data(contentsOf: fixture.original) == Data([3, 2, 1]))
#expect(try Data(contentsOf: fixture.source) == Data([3, 2, 1]))
let entry = try #require(fixture.edits.model.file(fixture.id))
#expect(entry.localURL == fixture.original)
#expect(entry.remotePath == "/srv/document.txt")
#expect(entry.baseline == baseline)
#expect(entry.isModified)
#expect(fixture.transfers.sftpTransfers.isEmpty)
}
@Test func differentFormatsAndDiscardedRecordsNeverReplaceTheManagedFile() async throws {
let fixture = try Fixture()
defer { fixture.clean() }
let otherFormat = fixture.directory.appending(path: "edited.pdf")
try Data([4]).write(to: otherFormat)
await fixture.workspace.importEditedCopy(otherFormat, for: fixture.id)
#expect(fixture.workspace.operationError != nil)
#expect(try Data(contentsOf: fixture.original) == Data([1, 2, 3]))
await fixture.edits.discard(fixture.id)
await fixture.workspace.importEditedCopy(fixture.source, for: fixture.id)
#expect(try Data(contentsOf: fixture.original) == Data([1, 2, 3]))
}
@Test func invalidatedOrUploadingRecordsKeepTheirCurrentContents() async throws {
let fixture = try Fixture()
defer { fixture.clean() }
fixture.edits.model.update(fixture.id) { $0.transferID = UUID() }
await fixture.workspace.importEditedCopy(fixture.source, for: fixture.id)
#expect(try Data(contentsOf: fixture.original) == Data([1, 2, 3]))
fixture.edits.model.update(fixture.id) {
$0.transferID = nil
$0.isInvalidated = true
}
await fixture.workspace.importEditedCopy(fixture.source, for: fixture.id)
#expect(try Data(contentsOf: fixture.original) == Data([1, 2, 3]))
}
private struct Fixture {
let directory: URL
let original: URL
let source: URL
let edits: SFTPEditService
let transfers: SFTPTransferCoordinator
let workspace: MobileWorkspace
let id: UUID
init() throws {
directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
original = directory.appending(path: "document.txt")
source = directory.appending(path: "edited.txt")
try Data([1, 2, 3]).write(to: original)
try Data([3, 2, 1]).write(to: source)
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
let library = HostLibraryModel(hosts: [host])
transfers = SFTPTransferCoordinator(store: library, clientFactory: { SFTPClient() })
edits = SFTPEditService(transfers: transfers)
edits.setMonitoring(false)
let file = RemoteFile(
path: "/srv/document.txt", name: "document.txt", kind: .file, size: 3,
permissions: 0o644, ownerUID: 0, ownerGID: 0)
let digest = SHA256.hash(data: Data([1, 2, 3])).map { String(format: "%02x", $0) }.joined()
id = edits.model.register(hostID: host.id, file: file, localURL: original, digest: digest)
workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library, transfers: transfers, sftpEdits: edits)
}
func clean() { try? FileManager.default.removeItem(at: directory) }
}
}
+108
View File
@@ -0,0 +1,108 @@
import Testing
import UIKit
@testable import TermindIOS
@Suite(.serialized) @MainActor
struct MobileSFTPOpenWithTests {
@Test func completedDownloadUsesTheManagedCopyAndCurrentWindowAnchor() throws {
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 834, height: 1194))
let anchor = UIView(frame: window.bounds)
window.addSubview(anchor)
var presented: URL?
let presenter = MobileSFTPOpenWithPresenter { controller, view in
#expect(view === anchor)
#expect(view.window === window)
presented = controller.url
return true
}
presenter.sourceView = anchor
presenter.isEnabled = true
let request = try #require(presenter.begin())
#expect(presenter.controller == nil)
let managedCopy = URL(filePath: "/tmp/TermindEdits/managed/document.pdf")
presenter.downloaded(managedCopy, request: request)
#expect(presented == managedCopy)
let controller = try #require(presenter.controller)
presenter.documentInteractionControllerDidDismissOpenInMenu(controller)
#expect(presenter.controller == nil)
}
@Test func backgroundAndWorkspaceChangesInvalidateLateDownloads() throws {
let window = UIWindow()
let anchor = UIView()
window.addSubview(anchor)
let presenter = MobileSFTPOpenWithPresenter { _, _ in
Issue.record("A retired request must not present")
return true
}
presenter.sourceView = anchor
presenter.isEnabled = true
let request = try #require(presenter.begin())
presenter.isEnabled = false
presenter.isEnabled = true
presenter.downloaded(URL(filePath: "/tmp/old.pdf"), request: request)
#expect(presenter.controller == nil)
let next = try #require(presenter.begin())
presenter.invalidate()
presenter.downloaded(URL(filePath: "/tmp/next.pdf"), request: next)
#expect(presenter.controller == nil)
let superseded = try #require(presenter.begin())
_ = presenter.begin()
presenter.downloaded(URL(filePath: "/tmp/superseded.pdf"), request: superseded)
#expect(presenter.controller == nil)
}
@Test func noCompatibleApplicationReportsAnErrorAndReleasesController() throws {
let window = UIWindow()
let anchor = UIView()
window.addSubview(anchor)
let presenter = MobileSFTPOpenWithPresenter { _, _ in false }
presenter.sourceView = anchor
presenter.isEnabled = true
var error: String?
presenter.failed = { error = $0 }
presenter.downloaded(URL(filePath: "/tmp/unknown"), request: try #require(presenter.begin()))
#expect(error != nil)
#expect(presenter.controller == nil)
}
@Test func handoffRetainsControllerAcrossSceneDeactivationUntilSendingFinishes() throws {
let window = UIWindow()
let anchor = UIView()
window.addSubview(anchor)
let presenter = MobileSFTPOpenWithPresenter { _, _ in true }
presenter.sourceView = anchor
presenter.isEnabled = true
var finished = 0
presenter.finished = { finished += 1 }
presenter.downloaded(URL(filePath: "/tmp/file.pdf"), request: try #require(presenter.begin()))
let controller = try #require(presenter.controller)
presenter.documentInteractionController(controller, willBeginSendingToApplication: "test.editor")
presenter.isEnabled = false
presenter.documentInteractionControllerDidDismissOpenInMenu(controller)
#expect(presenter.controller === controller)
#expect(finished == 0)
presenter.documentInteractionController(controller, didEndSendingToApplication: "test.editor")
#expect(presenter.controller == nil)
#expect(finished == 1)
}
@Test func closingWorkspaceDuringHandoffKeepsTheDelegateUntilCompletion() throws {
let window = UIWindow()
let anchor = UIView()
window.addSubview(anchor)
var presenter: MobileSFTPOpenWithPresenter? = MobileSFTPOpenWithPresenter { _, _ in true }
presenter?.sourceView = anchor
presenter?.isEnabled = true
presenter?.downloaded(URL(filePath: "/tmp/file.pdf"), request: try #require(presenter?.begin()))
let controller = try #require(presenter?.controller)
presenter?.documentInteractionController(controller, willBeginSendingToApplication: "test.editor")
weak var retained = presenter
defer { retained = nil }
presenter = nil
#expect(retained != nil)
retained?.documentInteractionController(controller, didEndSendingToApplication: "test.editor")
#expect(retained == nil)
}
}
+17
View File
@@ -962,6 +962,12 @@ struct SFTPBrowserView: View {
let targets = menuTargets(for: file)
if targets.count == 1 {
Button("Open", systemImage: MenuSymbol.open) { primaryAction(file) }
if !file.isDirectory {
SFTPOpenWithMenu(filename: file.name) { application in
open(file, with: application)
}
.disabled(sftpSession.status != .connected)
}
}
Button(
targets.count == 1 ? "Download" : "Download \(targets.count) Items",
@@ -1025,6 +1031,17 @@ struct SFTPBrowserView: View {
}
}
private func open(_ file: RemoteFile, with application: URL) {
sftpSession.open(file, coordinator: app.sftpTransfers, edits: app.sftpEdits) { localURL in
Task {
do {
try await SFTPApplicationOpener.open(localURL, with: application)
sftpSession.recordOpenedFile(at: file.path)
} catch { sftpSession.operationError = error.localizedDescription }
}
}
}
private func menuTargets(for file: RemoteFile) -> [RemoteFile] {
guard sftpSession.selection.contains(file.id), sftpSession.selection.count > 1 else {
return [file]
+36
View File
@@ -0,0 +1,36 @@
import SwiftUI
struct SFTPOpenWithMenu: View {
let filename: String
let open: (URL) -> Void
@State private var applications: [SFTPFileApplication] = []
var body: some View {
Menu("Open With") {
ForEach(applications) { application in
Button {
open(application.url)
} label: {
HStack {
if let icon = application.icon {
Image(nsImage: icon).resizable().frame(width: 16, height: 16)
}
if application.isDefault {
Text("\(application.name) (default)")
} else {
Text(verbatim: application.name)
}
}
}
}
if !applications.isEmpty { Divider() }
Button("Other…") {
Task {
if let application = await SFTPApplicationOpener.chooseApplication() { open(application) }
}
}
}
// Context menu content appears only when requested, not while rendering file rows.
.onAppear { applications = SFTPApplicationOpener.applications(for: filename) }
}
}
+63
View File
@@ -0,0 +1,63 @@
import AppKit
import UniformTypeIdentifiers
struct SFTPFileApplication: Identifiable {
let url: URL
let name: String
let icon: NSImage?
let isDefault: Bool
var id: URL { url }
}
@MainActor
enum SFTPApplicationOpener {
static func applications(for filename: String) -> [SFTPFileApplication] {
let ext = (filename as NSString).pathExtension
guard !ext.isEmpty, let type = UTType(filenameExtension: ext) else { return [] }
let workspace = NSWorkspace.shared
return applications(
urls: workspace.urlsForApplications(toOpen: type),
defaultURL: workspace.urlForApplication(toOpen: type),
name: {
let name = FileManager.default.displayName(atPath: $0.path)
return name.hasSuffix(".app") ? String(name.dropLast(4)) : name
},
icon: { workspace.icon(forFile: $0.path) }
)
}
static func applications(
urls: [URL], defaultURL: URL?, name: (URL) -> String, icon: (URL) -> NSImage?
) -> [SFTPFileApplication] {
let preferred = defaultURL?.standardizedFileURL
var seen: Set<URL> = []
return (urls + (defaultURL.map { [$0] } ?? [])).compactMap { url in
let url = url.standardizedFileURL
guard seen.insert(url).inserted else { return nil }
return SFTPFileApplication(url: url, name: name(url), icon: icon(url), isDefault: url == preferred)
}.sorted { lhs, rhs in
if lhs.isDefault != rhs.isDefault { return lhs.isDefault }
let order = lhs.name.localizedStandardCompare(rhs.name)
return order == .orderedSame ? lhs.url.path < rhs.url.path : order == .orderedAscending
}
}
static func chooseApplication() async -> URL? {
let panel = NSOpenPanel()
panel.title = String(localized: "Open With")
panel.prompt = String(localized: "Open")
panel.directoryURL = URL(filePath: "/Applications", directoryHint: .isDirectory)
panel.allowedContentTypes = [.applicationBundle]
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.treatsFilePackagesAsDirectories = false
return await panel.begin() == .OK ? panel.url : nil
}
static func open(_ url: URL, with application: URL) async throws {
_ = try await NSWorkspace.shared.open(
[url], withApplicationAt: application, configuration: NSWorkspace.OpenConfiguration())
}
}
+45
View File
@@ -0,0 +1,45 @@
import AppKit
import Testing
@testable import Termind
@MainActor
struct SFTPApplicationOpenerTests {
@Test func defaultApplicationComesFirstAndAliasesAreDeduplicated() {
let preferred = URL(filePath: "/Applications/Zed.app")
let alpha = URL(filePath: "/Applications/Alpha.app")
let beta = URL(filePath: "/Applications/Beta.app")
let result = SFTPApplicationOpener.applications(
urls: [beta, alpha, preferred, URL(filePath: "/Applications/./Zed.app")],
defaultURL: preferred, name: { $0.deletingPathExtension().lastPathComponent }, icon: { _ in nil })
#expect(result.map(\.url) == [preferred, alpha, beta])
#expect(result.map(\.isDefault) == [true, false, false])
}
@Test func defaultApplicationIsIncludedEvenWhenMissingFromRecommendations() {
let preferred = URL(filePath: "/Applications/Editor.app")
let result = SFTPApplicationOpener.applications(
urls: [], defaultURL: preferred, name: { $0.lastPathComponent }, icon: { _ in nil })
#expect(result.count == 1)
#expect(result.first?.url == preferred)
}
@Test func missingApplicationPropagatesLaunchFailure() async throws {
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appending(path: "report.txt")
try Data("test".utf8).write(to: file)
await #expect(throws: (any Error).self) {
try await SFTPApplicationOpener.open(file, with: directory.appending(path: "Missing.app"))
}
}
@Test func unknownTypeAndExtensionlessFilesHaveNoFabricatedApplications() {
#expect(SFTPApplicationOpener.applications(for: "README").isEmpty)
#expect(SFTPApplicationOpener.applications(for: ".gitignore").isEmpty)
#expect(
SFTPApplicationOpener.applications(urls: [], defaultURL: nil, name: { $0.path }, icon: { _ in nil }).isEmpty
)
}
}
+29 -1
View File
@@ -4,10 +4,37 @@ import TermindApplication
import TermindDomain
import Testing
@testable import Termind
@testable import TermindRemoteInfrastructure
@Suite @MainActor
struct SFTPEditTests {
@Test func browserOpenReusesTheTrackedCopyAndRetainsEditUploadSupport() async throws {
let fixture = Fixture()
let configuration = SSHConnectionConfiguration(
hostID: fixture.hostID, host: "example.invalid", port: 22, username: "test", credentials: [],
expectedHostKeys: [])
let session = SFTPSessionModel(hostID: fixture.hostID, client: fixture.client, configuration: configuration)
let file = await fixture.client.file
var opened: URL?
session.open(file, coordinator: fixture.transfers, edits: fixture.service) { opened = $0 }
try await wait { opened != nil }
let url = try #require(opened)
defer { fixture.clean(url) }
let id = try #require(fixture.service.model.files.first?.id)
try Data([7, 6, 5]).write(to: url, options: .atomic)
opened = nil
session.open(file, coordinator: fixture.transfers, edits: fixture.service) { opened = $0 }
try await wait { opened != nil }
#expect(opened == url)
#expect(await fixture.client.downloads == 1)
await fixture.service.recheck()
#expect(fixture.service.model.file(id)?.isModified == true)
#expect(await session.uploadEdit(id, using: fixture.service, replacing: false) == false)
try await wait { fixture.service.model.file(id)?.transferID == nil }
#expect(await fixture.client.data == Data([7, 6, 5]))
}
@Test(arguments: ["config", "file.txt", "image.png", "archive.zip", "data.sqlite", "document.pdf"])
func arbitraryBinaryContentsAndAtomicSavesAreDetected(_ name: String) async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
@@ -256,7 +283,8 @@ struct SFTPEditTests {
let service: SFTPEditService
var connection: SFTPTransferConnection { .borrowed(id: hostID, client: client) }
init() {
transfers = SFTPTransferCoordinator(store: HostLibraryModel(), clientFactory: { EditClient() })
let client = client
transfers = SFTPTransferCoordinator(store: HostLibraryModel(), clientFactory: { client })
service = SFTPEditService(transfers: transfers)
service.setMonitoring(false)
}