127 lines
4.4 KiB
Swift
127 lines
4.4 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import Observation
|
|
import TermindDomain
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
/// Metadata is a best-effort concurrency guard, not a server-side compare-and-swap.
|
|
public nonisolated struct SFTPFileVersion: Equatable, Sendable {
|
|
public let kind: RemoteFile.Kind
|
|
public let size: Int64
|
|
public let modifiedAt: Date?
|
|
public let permissions: UInt32
|
|
|
|
public init(_ file: RemoteFile) {
|
|
kind = file.kind
|
|
size = file.size
|
|
modifiedAt = file.modifiedAt
|
|
permissions = file.permissionMode
|
|
}
|
|
}
|
|
|
|
public nonisolated struct SFTPEditUploadPolicy: Sendable {
|
|
public let expected: SFTPFileVersion?
|
|
public let permissions: UInt32
|
|
|
|
public init(expected: SFTPFileVersion?, permissions: UInt32) {
|
|
self.expected = expected
|
|
self.permissions = permissions
|
|
}
|
|
|
|
public func validate(_ file: RemoteFile?) throws {
|
|
guard file.map(SFTPFileVersion.init) == expected,
|
|
file == nil || file?.kind == .file
|
|
else { throw SFTPEditFailure.remoteChanged }
|
|
}
|
|
}
|
|
|
|
public nonisolated enum SFTPEditFailure: LocalizedError, Sendable {
|
|
case remoteChanged, unavailable, localChanged, unsupported
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .remoteChanged: String(localized: "The remote file changed. Review it before uploading again.")
|
|
case .unavailable: String(localized: "The original connection is no longer available.")
|
|
case .localChanged: String(localized: "The local file changed while preparing the upload. Try again.")
|
|
case .unsupported: String(localized: "This file cannot be uploaded back to its original path.")
|
|
}
|
|
}
|
|
}
|
|
|
|
public nonisolated struct SFTPEditedFile: Identifiable, Sendable {
|
|
public let id: UUID
|
|
public let hostID: Host.ID
|
|
public let remotePath: String
|
|
public let localURL: URL
|
|
public var baseline: SFTPFileVersion
|
|
public var uploadedDigest: String
|
|
public var currentDigest: String
|
|
public var isPreparing = false
|
|
public var transferID: UUID?
|
|
public var error: String?
|
|
public var localError: String?
|
|
public var isInvalidated = false
|
|
public var name: String { localURL.lastPathComponent }
|
|
public var isModified: Bool { currentDigest != uploadedDigest }
|
|
public var displayedError: String? { error ?? localError }
|
|
public var isVisible: Bool { isModified || isPreparing || transferID != nil }
|
|
}
|
|
|
|
/// Process-local edit records outlive individual browsers and workspaces.
|
|
@MainActor
|
|
@Observable
|
|
public final class SFTPEditModel {
|
|
public private(set) var files: [SFTPEditedFile] = []
|
|
public init() {}
|
|
|
|
public var hasPendingChanges: Bool { files.contains(where: \.isVisible) }
|
|
|
|
public func modifiedFiles(for hostID: Host.ID) -> [SFTPEditedFile] {
|
|
files.filter { $0.hostID == hostID && $0.isVisible }
|
|
}
|
|
|
|
public func file(_ id: UUID) -> SFTPEditedFile? { files.first { $0.id == id } }
|
|
|
|
public func file(hostID: Host.ID, path: String) -> SFTPEditedFile? {
|
|
files.first { $0.hostID == hostID && $0.remotePath == path }
|
|
}
|
|
|
|
@discardableResult
|
|
public func register(hostID: Host.ID, file: RemoteFile, localURL: URL, digest: String) -> UUID {
|
|
if let existing = self.file(hostID: hostID, path: file.path) { return existing.id }
|
|
let id = UUID()
|
|
files.append(
|
|
SFTPEditedFile(
|
|
id: id, hostID: hostID, remotePath: file.path, localURL: localURL,
|
|
baseline: SFTPFileVersion(file), uploadedDigest: digest, currentDigest: digest))
|
|
return id
|
|
}
|
|
|
|
public func update(_ id: UUID, _ change: (inout SFTPEditedFile) -> Void) {
|
|
guard let index = files.firstIndex(where: { $0.id == id }) else { return }
|
|
change(&files[index])
|
|
}
|
|
|
|
public func remove(_ id: UUID) { files.removeAll { $0.id == id } }
|
|
|
|
public func didUpload(_ id: UUID, digest: String, baseline: SFTPFileVersion) {
|
|
update(id) {
|
|
$0.uploadedDigest = digest
|
|
$0.baseline = baseline
|
|
$0.transferID = nil
|
|
$0.isPreparing = false
|
|
$0.error = nil
|
|
}
|
|
}
|
|
|
|
public func invalidate(hostID: Host.ID) {
|
|
for index in files.indices where files[index].hostID == hostID {
|
|
files[index].isInvalidated = true
|
|
files[index].error = SFTPEditFailure.unavailable.localizedDescription
|
|
}
|
|
}
|
|
}
|