diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 94a1d2f5..baf6fdd9 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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":"原連線已無法使用。"}}}}, diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index d463b954..01012050 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -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 diff --git a/TFI/Features/SFTP/MobileSFTPBrowser.swift b/TFI/Features/SFTP/MobileSFTPBrowser.swift index e9318904..43cab3c9 100644 --- a/TFI/Features/SFTP/MobileSFTPBrowser.swift +++ b/TFI/Features/SFTP/MobileSFTPBrowser.swift @@ -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) } diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 81100489..143cdaa5 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -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.") diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index ede09f56..70ab72b1 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -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: diff --git a/TFI/Platform/MobileSFTPEditedCopyImporter.swift b/TFI/Platform/MobileSFTPEditedCopyImporter.swift new file mode 100644 index 00000000..1736f0a3 --- /dev/null +++ b/TFI/Platform/MobileSFTPEditedCopyImporter.swift @@ -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 + } + } +} diff --git a/TFI/Platform/MobileSFTPOpenWithPresenter.swift b/TFI/Platform/MobileSFTPOpenWithPresenter.swift new file mode 100644 index 00000000..3e75f79a --- /dev/null +++ b/TFI/Platform/MobileSFTPOpenWithPresenter.swift @@ -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 + } +} diff --git a/TFITests/MobileMarketingScreenshotTests.swift b/TFITests/MobileMarketingScreenshotTests.swift index 975bcda4..b771ace1 100644 --- a/TFITests/MobileMarketingScreenshotTests.swift +++ b/TFITests/MobileMarketingScreenshotTests.swift @@ -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", "生产文件", "正式環境檔案")) diff --git a/TFITests/MobileSFTPEditedCopyImporterTests.swift b/TFITests/MobileSFTPEditedCopyImporterTests.swift new file mode 100644 index 00000000..b75893d4 --- /dev/null +++ b/TFITests/MobileSFTPEditedCopyImporterTests.swift @@ -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) } + } +} diff --git a/TFITests/MobileSFTPOpenWithTests.swift b/TFITests/MobileSFTPOpenWithTests.swift new file mode 100644 index 00000000..0b661aea --- /dev/null +++ b/TFITests/MobileSFTPOpenWithTests.swift @@ -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) + } +} diff --git a/TFM/Features/SFTP/SFTPBrowserView.swift b/TFM/Features/SFTP/SFTPBrowserView.swift index b5433756..2461417e 100644 --- a/TFM/Features/SFTP/SFTPBrowserView.swift +++ b/TFM/Features/SFTP/SFTPBrowserView.swift @@ -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] diff --git a/TFM/Features/SFTP/SFTPOpenWithMenu.swift b/TFM/Features/SFTP/SFTPOpenWithMenu.swift new file mode 100644 index 00000000..3fbdc171 --- /dev/null +++ b/TFM/Features/SFTP/SFTPOpenWithMenu.swift @@ -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) } + } +} diff --git a/TFM/Platform/SFTPApplicationOpener.swift b/TFM/Platform/SFTPApplicationOpener.swift new file mode 100644 index 00000000..3fe884c4 --- /dev/null +++ b/TFM/Platform/SFTPApplicationOpener.swift @@ -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 = [] + 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()) + } +} diff --git a/TFMTests/SFTPApplicationOpenerTests.swift b/TFMTests/SFTPApplicationOpenerTests.swift new file mode 100644 index 00000000..8b21c989 --- /dev/null +++ b/TFMTests/SFTPApplicationOpenerTests.swift @@ -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 + ) + } +} diff --git a/TFMTests/SFTPEditTests.swift b/TFMTests/SFTPEditTests.swift index 641d6e06..e4a80a9e 100644 --- a/TFMTests/SFTPEditTests.swift +++ b/TFMTests/SFTPEditTests.swift @@ -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) }