Add SFTP Open With and edited-copy import
This commit is contained in:
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":"原連線已無法使用。"}}}},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user