Improve SFTP file workflows

This commit is contained in:
Kaede Akino committed 2026-09-12 16:38:36 +08:00
1 parent e50ea0c75e
commit abaa6a2f8a
30 files changed
+1448 -203

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
import TermindPresentation
import Testing
@Suite
struct DataSizeFormatTests {
@Test func zeroHasNoUnitAndSizesUseDecimalFileUnits() {
#expect(DataSizeFormat.bytes(0) == "0")
#expect(DataSizeFormat.bytes(999).contains("999"))
#expect(DataSizeFormat.bytes(1_000).contains("KB"))
#expect(DataSizeFormat.bytes(1_000_000).contains("MB"))
}
@Test func unavailableMetricsStayUnavailable() {
#expect(DataSizeFormat.bytes(Double.nan) == "—")
#expect(DataSizeFormat.bytes(-1.0) == "—")
#expect(DataSizeFormat.rate(nil) == "—")
}
}
+162 -1
View File
@@ -1,6 +1,167 @@
{
"sourceLanguage" : "en",
"strings" : {
"Copy Cancelled" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "复制已取消" }
}
}
},
"Copy Failed" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "复制失败" }
}
}
},
"Copy Paused" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "复制已暂停" }
}
}
},
"Copy Queued" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "复制已排队" }
}
}
},
"Copying" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "正在复制" }
}
}
},
"Date" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "日期" }
}
}
},
"Download Cancelled" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "下载已取消" }
}
}
},
"Download Failed" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "下载失败" }
}
}
},
"Download Paused" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "下载已暂停" }
}
}
},
"Download Queued" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "下载已排队" }
}
}
},
"Downloaded" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "下载完成" }
}
}
},
"Downloading" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "正在下载" }
}
}
},
"Longer than %@." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "超过%@。" }
}
}
},
"Share" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "分享" }
}
}
},
"The copied SFTP items are invalid." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "已复制的SFTP项目无效。" }
}
}
},
"The file is larger than %@." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "文件大于%@。" }
}
}
},
"The normalized image is larger than %@." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "处理后的图像大于%@。" }
}
}
},
"Upload Cancelled" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "上传已取消" }
}
}
},
"Upload Failed" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "上传失败" }
}
}
},
"Upload Paused" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "上传已暂停" }
}
}
},
"Upload Queued" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "上传已排队" }
}
}
},
"Uploaded" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "上传完成" }
}
}
},
"Uploading" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : { "state" : "translated", "value" : "正在上传" }
}
}
},
"" : {
"localizations" : {
"zh-Hans" : {
@@ -16,7 +177,7 @@
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "复制"
"value" : "已复制"
}
}
}
@@ -299,8 +299,16 @@ nonisolated public enum ContextAttachmentImporter {
public var errorDescription: String? {
switch self {
case .notAFile: String(localized: "Folders cannot be attached.")
case .fileTooLarge: String(localized: "The file is larger than 25 MB.")
case .imageTooLarge: String(localized: "The normalized image is larger than 5 MB.")
case .fileTooLarge:
String(
localized:
"The file is larger than \(AgentByteCountFormatter.string(fromByteCount: Int64(maximumFileBytes)))."
)
case .imageTooLarge:
String(
localized:
"The normalized image is larger than \(AgentByteCountFormatter.string(fromByteCount: Int64(maximumImageBytes)))."
)
case .textTooLarge: String(localized: "The extracted text is longer than 200,000 characters.")
case .unsupportedFormat: String(localized: "This file format is not supported.")
case .legacyOffice: String(localized: "Convert legacy Office files to DOCX, XLSX, or PPTX first.")
@@ -0,0 +1,29 @@
import AgentKit
import Foundation
/// One byte-size vocabulary for every Termind surface.
public nonisolated enum DataSizeFormat {
public static func bytes(_ value: Int64) -> String {
AgentByteCountFormatter.string(fromByteCount: value)
}
public static func bytes(_ value: Int) -> String {
bytes(Int64(value))
}
public static func bytes(_ value: Double?) -> String {
guard let value, value.isFinite, value >= 0, value <= Double(Int64.max) else {
return "—"
}
return bytes(Int64(value.rounded()))
}
public static func rate(_ value: Int64) -> String {
value < 0 ? "—" : bytes(value) + "/s"
}
public static func rate(_ value: Double?) -> String {
guard let value, value.isFinite, value >= 0 else { return "—" }
return bytes(value) + "/s"
}
}
@@ -6,15 +6,8 @@ public nonisolated enum MetricsFormat {
return value.formatted(.number.precision(.fractionLength(1))) + "%"
}
public static func bytes(_ value: Double?) -> String {
guard var value, value.isFinite, value >= 0 else { return "—" }
let units = ["B", "KiB", "MiB", "GiB", "TiB"]
var index = 0
while value >= 1024, index < units.count - 1 {
value /= 1024
index += 1
}
return value.formatted(.number.precision(.fractionLength(index == 0 ? 0 : 1))) + " " + units[index]
DataSizeFormat.bytes(value)
}
public static func rate(_ value: Double?) -> String { value == nil ? "—" : bytes(value) + "/s" }
public static func rate(_ value: Double?) -> String { DataSizeFormat.rate(value) }
public static func capacity(_ used: Double?, _ total: Double?) -> String { bytes(used) + " / " + bytes(total) }
}
@@ -0,0 +1,77 @@
import Foundation
import TermindDomain
public nonisolated enum RemoteFileSortField: String, CaseIterable, Sendable {
case name
case modified
case size
case kind
public var defaultDirection: RemoteFileSortDirection {
switch self {
case .modified, .size: .descending
case .name, .kind: .ascending
}
}
}
public nonisolated enum RemoteFileSortDirection: String, Sendable {
case ascending
case descending
public mutating func toggle() {
self = self == .ascending ? .descending : .ascending
}
}
public nonisolated struct RemoteFileSortDescriptor: Sendable {
public var field: RemoteFileSortField
public var direction: RemoteFileSortDirection
public init(field: RemoteFileSortField = .name, direction: RemoteFileSortDirection = .ascending) {
self.field = field
self.direction = direction
}
public func precedes(
_ lhs: RemoteFile, _ rhs: RemoteFile,
lhsKind: String? = nil, rhsKind: String? = nil
) -> Bool {
if lhs.isDirectory != rhs.isDirectory { return lhs.isDirectory }
let primary = compare(lhs, rhs, lhsKind: lhsKind, rhsKind: rhsKind)
if primary != .orderedSame {
return direction == .ascending
? primary == .orderedAscending
: primary == .orderedDescending
}
let name = lhs.name.localizedStandardCompare(rhs.name)
if name != .orderedSame { return name == .orderedAscending }
return lhs.path.localizedStandardCompare(rhs.path) == .orderedAscending
}
private func compare(
_ lhs: RemoteFile, _ rhs: RemoteFile,
lhsKind: String?, rhsKind: String?
) -> ComparisonResult {
switch field {
case .name:
return lhs.name.localizedStandardCompare(rhs.name)
case .modified:
switch (lhs.modifiedAt, rhs.modifiedAt) {
case (.none, .none): return .orderedSame
case (.none, .some): return direction == .ascending ? .orderedDescending : .orderedAscending
case (.some, .none): return direction == .ascending ? .orderedAscending : .orderedDescending
case (.some(let left), .some(let right)):
return left == right
? .orderedSame : (left < right ? .orderedAscending : .orderedDescending)
}
case .size:
return lhs.size == rhs.size
? .orderedSame
: (lhs.size < rhs.size ? .orderedAscending : .orderedDescending)
case .kind:
return (lhsKind ?? lhs.sftpKindDescription)
.localizedStandardCompare(rhsKind ?? rhs.sftpKindDescription)
}
}
}
@@ -1,7 +1,7 @@
import Foundation
import TermindDomain
extension RemoteFile.Kind {
nonisolated extension RemoteFile.Kind {
public var symbol: String {
switch self {
case .directory: "folder.fill"
@@ -12,7 +12,7 @@ extension RemoteFile.Kind {
}
}
extension RemoteFile {
nonisolated extension RemoteFile {
public var sftpKindDescription: String {
switch kind {
case .directory: return String(localized: "Folder")
@@ -1,32 +1,6 @@
import SwiftUI
import TermindDomain
public struct UnixPermissionFields: View {
@Binding private var draft: SFTPPermissionDraft
public init(draft: Binding<SFTPPermissionDraft>) { _draft = draft }
public var body: some View {
Section("Permissions") {
TextField("Octal permissions", text: Binding(get: { draft.text }, set: { _ = draft.accept($0) }))
.font(.body.monospaced())
#if os(iOS)
.multilineTextAlignment(.leading)
.keyboardType(.numberPad)
#endif
}
Section("File Access") { UnixPermissionGrid(draft: $draft) }
Section("Special Permissions") {
Toggle("Set UID", isOn: permission(0o4000))
Toggle("Set GID", isOn: permission(0o2000))
Toggle("Sticky Bit", isOn: permission(0o1000))
}
}
private func permission(_ mask: UInt32) -> Binding<Bool> {
Binding(get: { draft.contains(mask) }, set: { draft.set(mask, enabled: $0) })
}
}
public struct UnixPermissionGrid: View {
@Binding private var draft: SFTPPermissionDraft
private let horizontalSpacing: CGFloat
@@ -0,0 +1,386 @@
import Foundation
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
public nonisolated struct SFTPRemoteDownloadBatch: Sendable {
public struct Job: Sendable {
var remotePath: String
var localURL: URL
var byteCount: Int64
var hasConflict: Bool
var conflictingTargetIsDirectory: Bool
}
public var id = UUID()
var jobs: [Job]
var directories: [URL]
var directoryConflicts: Set<URL>
public var destination: URL
public var conflictCount: Int
public var transferCount: Int { jobs.count }
public var directoryCount: Int { directories.count }
}
public nonisolated enum SFTPRemoteDownloadPlanner {
public static func prepare(
_ files: [RemoteFile], destination: URL,
client: any SFTPClientProtocol
) async throws -> SFTPRemoteDownloadBatch {
let scoped = destination.startAccessingSecurityScopedResource()
defer { if scoped { destination.stopAccessingSecurityScopedResource() } }
var jobs: [SFTPRemoteDownloadBatch.Job] = []
var directories: [URL] = []
var directoryConflicts: Set<URL> = []
var conflicts = 0
func collect(_ remote: RemoteFile, local: URL) async throws {
if remote.isDirectory {
directories.append(local)
for child in try await client.listDirectory(at: remote.path) {
try await collect(child, local: local.appending(path: child.name))
}
return
}
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: local.path, isDirectory: &isDirectory)
if exists { conflicts += 1 }
jobs.append(
.init(
remotePath: remote.path, localURL: local, byteCount: remote.size,
hasConflict: exists,
conflictingTargetIsDirectory: exists && isDirectory.boolValue
))
}
for file in files {
try await collect(file, local: destination.appending(path: file.name))
}
for directory in directories {
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory),
!isDirectory.boolValue
{
directoryConflicts.insert(directory)
conflicts += 1
}
}
return SFTPRemoteDownloadBatch(
jobs: jobs, directories: directories,
directoryConflicts: directoryConflicts,
destination: destination, conflictCount: conflicts
)
}
@MainActor public static func enqueue(
_ batch: SFTPRemoteDownloadBatch,
policy: TransferConflictPolicy,
hostID: Host.ID,
connection: SFTPTransferConnection,
homeDirectory: String? = nil,
coordinator: SFTPTransferCoordinator,
completion: ((String, SFTPTransfer.State) -> Void)? = nil
) throws {
let scoped = batch.destination.startAccessingSecurityScopedResource()
defer { if scoped { batch.destination.stopAccessingSecurityScopedResource() } }
var skippedPrefixes: [URL] = []
var mappings: [(URL, URL)] = []
for original in batch.directories {
if skippedPrefixes.contains(where: { contains(original, in: $0) }) { continue }
var directory = mapped(original, using: mappings)
if batch.directoryConflicts.contains(original) {
switch policy {
case .skip:
skippedPrefixes.append(original)
continue
case .replace:
try FileManager.default.removeItem(at: original)
case .keepBoth:
let candidate = keepBothURL(original)
mappings.append((original, candidate))
directory = candidate
}
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
for var job in batch.jobs {
if skippedPrefixes.contains(where: { contains(job.localURL, in: $0) }) { continue }
job.localURL = mapped(job.localURL, using: mappings)
if job.hasConflict {
switch policy {
case .skip: continue
case .replace:
if job.conflictingTargetIsDirectory {
try FileManager.default.removeItem(at: job.localURL)
}
case .keepBoth:
job.localURL = keepBothURL(job.localURL)
}
}
try FileManager.default.createDirectory(
at: job.localURL.deletingLastPathComponent(), withIntermediateDirectories: true
)
coordinator.enqueue(
direction: .download, batchID: batch.id,
localURL: job.localURL, remotePath: job.remotePath,
byteCount: job.byteCount, hostID: hostID,
connection: connection, scopedRoot: batch.destination,
overwrite: job.hasConflict && policy == .replace,
homeDirectory: homeDirectory
) { state in completion?(job.remotePath, state) }
}
}
private static func contains(_ url: URL, in prefix: URL) -> Bool {
url.path == prefix.path || url.path.hasPrefix(prefix.path + "/")
}
private static func mapped(_ url: URL, using mappings: [(URL, URL)]) -> URL {
for mapping in mappings where contains(url, in: mapping.0) {
return URL(fileURLWithPath: mapping.1.path + url.path.dropFirst(mapping.0.path.count))
}
return url
}
private static func keepBothURL(_ url: URL) -> URL {
let ext = url.pathExtension
let base = url.deletingPathExtension()
var index = 2
while true {
let name =
ext.isEmpty
? "\(base.lastPathComponent).\(index)"
: "\(base.lastPathComponent).\(index).\(ext)"
let candidate = base.deletingLastPathComponent().appending(path: name)
if !FileManager.default.fileExists(atPath: candidate.path) { return candidate }
index += 1
}
}
}
public nonisolated struct SFTPRemoteCopyBatch: Sendable {
public struct Job: Sendable {
var sourcePath: String
var destinationPath: String
var byteCount: Int64
var hasConflict: Bool
var conflictingTargetIsDirectory: Bool
}
public var id = UUID()
var source: SFTPRemoteCopySource
var jobs: [Job]
var directories: [String]
var directoryConflicts: Set<String>
public var conflictCount: Int
public var transferCount: Int { jobs.count }
public var directoryCount: Int { directories.count }
}
public nonisolated enum SFTPRemoteCopyPlanner {
public static func prepare(
_ payload: SFTPRemoteItemsPayload,
destination: String,
client: any SFTPClientProtocol,
coordinator: SFTPTransferCoordinator
) async throws -> SFTPRemoteCopyBatch {
let manifest = try await coordinator.remoteManifest(for: payload)
var jobs: [SFTPRemoteCopyBatch.Job] = []
var directories: [String] = []
for entry in manifest.entries {
let target = RemotePath.join(destination, entry.relativePath)
if entry.isDirectory {
directories.append(target)
} else {
jobs.append(
.init(
sourcePath: entry.sourcePath, destinationPath: target,
byteCount: entry.byteCount, hasConflict: false,
conflictingTargetIsDirectory: false
))
}
}
let preflight = try await preflight(
directories: directories, targets: jobs.map(\.destinationPath), client: client
)
for (index, kind) in zip(jobs.indices, preflight.targetKinds) {
guard let kind else { continue }
jobs[index].hasConflict = true
jobs[index].conflictingTargetIsDirectory = kind
}
return SFTPRemoteCopyBatch(
source: manifest.source, jobs: jobs,
directories: preflight.directories,
directoryConflicts: preflight.directoryConflicts,
conflictCount: preflight.conflictCount
)
}
@MainActor public static func enqueue(
_ batch: SFTPRemoteCopyBatch,
policy: TransferConflictPolicy,
destinationHostID: Host.ID,
destinationConnection: SFTPTransferConnection,
destinationHomeDirectory: String? = nil,
client: any SFTPClientProtocol,
coordinator: SFTPTransferCoordinator,
completion: ((SFTPTransfer.State) -> Void)? = nil
) async throws {
let resolution = try await resolveDirectories(
batch.directories, conflicts: batch.directoryConflicts,
policy: policy, client: client
)
for var job in batch.jobs {
if resolution.skipped.contains(where: { contains(job.destinationPath, in: $0) }) {
continue
}
for mapping in resolution.mappings where contains(job.destinationPath, in: mapping.0) {
job.destinationPath = mapping.1 + job.destinationPath.dropFirst(mapping.0.count)
job.hasConflict = false
}
if job.hasConflict {
guard
let resolved = try await resolveConflict(
path: job.destinationPath,
conflictingTargetIsDirectory: job.conflictingTargetIsDirectory,
policy: policy, client: client
)
else { continue }
job.destinationPath = resolved.path
job.hasConflict = resolved.overwrite
}
coordinator.enqueueRemoteCopy(
batchID: batch.id,
sourcePath: job.sourcePath,
sourceHostID: batch.source.hostID,
sourceConnection: batch.source.connection,
destinationPath: job.destinationPath,
destinationHostID: destinationHostID,
destinationConnection: destinationConnection,
byteCount: job.byteCount,
overwrite: job.hasConflict && policy == .replace,
sourceHomeDirectory: batch.source.homeDirectory,
destinationHomeDirectory: destinationHomeDirectory,
completion: completion
)
}
}
private struct Preflight {
var directories: [String]
var directoryConflicts: Set<String>
var targetKinds: [Bool?]
var conflictCount: Int
}
private struct DirectoryResolution {
var skipped: [String] = []
var mappings: [(String, String)] = []
}
private static func preflight(
directories: [String], targets: [String], client: any SFTPClientProtocol
) async throws -> Preflight {
let directories = directories.sorted { $0.count < $1.count }
var directoryConflicts: Set<String> = []
var targetKinds: [Bool?] = []
var conflictCount = 0
for directory in directories {
if directoryConflicts.contains(where: { contains(directory, in: $0) }) { continue }
if let existing = try await client.attributes(at: directory), !existing.isDirectory {
directoryConflicts.insert(directory)
conflictCount += 1
}
}
for target in targets {
guard !directoryConflicts.contains(where: { contains(target, in: $0) }) else {
targetKinds.append(nil)
continue
}
let kind = try await client.attributes(at: target)?.isDirectory
targetKinds.append(kind)
if kind != nil { conflictCount += 1 }
}
return Preflight(
directories: directories, directoryConflicts: directoryConflicts,
targetKinds: targetKinds, conflictCount: conflictCount
)
}
private static func resolveDirectories(
_ directories: [String], conflicts: Set<String>,
policy: TransferConflictPolicy, client: any SFTPClientProtocol
) async throws -> DirectoryResolution {
var result = DirectoryResolution()
for original in directories {
if result.skipped.contains(where: { contains(original, in: $0) }) { continue }
var destination = original
for mapping in result.mappings where contains(destination, in: mapping.0) {
destination = mapping.1 + destination.dropFirst(mapping.0.count)
}
if conflicts.contains(original) {
switch policy {
case .skip:
result.skipped.append(original)
continue
case .replace:
try await client.removeFile(at: original)
case .keepBoth:
destination = try await availablePath(for: original, client: client)
result.mappings.append((original, destination))
}
}
if try await client.attributes(at: destination) == nil {
try await client.createDirectory(at: destination)
}
}
return result
}
private static func resolveConflict(
path: String, conflictingTargetIsDirectory: Bool,
policy: TransferConflictPolicy, client: any SFTPClientProtocol
) async throws -> (path: String, overwrite: Bool)? {
switch policy {
case .skip: return nil
case .replace:
if conflictingTargetIsDirectory, let existing = try await client.attributes(at: path) {
try await deleteRecursively(existing, client: client)
}
return (path, true)
case .keepBoth:
return (try await availablePath(for: path, client: client), false)
}
}
private static func availablePath(
for path: String, client: any SFTPClientProtocol
) async throws -> String {
var index = 2
var candidate = RemotePath.keepBothCandidate(for: path, index: index)
while try await client.attributes(at: candidate) != nil {
index += 1
candidate = RemotePath.keepBothCandidate(for: path, index: index)
}
return candidate
}
private static func deleteRecursively(
_ file: RemoteFile, client: any SFTPClientProtocol
) async throws {
if file.isDirectory {
for child in try await client.listDirectory(at: file.path) {
try await deleteRecursively(child, client: client)
}
try await client.removeDirectory(at: file.path)
} else {
try await client.removeFile(at: file.path)
}
}
private static func contains(_ path: String, in prefix: String) -> Bool {
path == prefix || path.hasPrefix(prefix + "/")
}
}
+1 -1
View File
@@ -124,7 +124,7 @@ struct MobileRootView: View {
List {
Section {
NavigationLink {
MobileTransfersView(transfers: app.transfers)
MobileTransfersView(transfers: app.transfers, hosts: app.library)
} label: {
Label("Transfers", systemImage: "arrow.up.arrow.down")
}
@@ -2,6 +2,7 @@ import Foundation
import Observation
import TermindApplication
import TermindDomain
import TermindPresentation
import struct TermindDomain.Host
@@ -15,12 +16,28 @@ final class MobileSFTPSessionModel {
private(set) var backHistory: [String] = []
private(set) var forwardHistory: [String] = []
var operationError: String?
private(set) var isRefreshing = false
var sortField: RemoteFileSortField = .name
var sortDirection: RemoteFileSortDirection = .ascending
var canGoBack: Bool { !backHistory.isEmpty }
var canGoForward: Bool { !forwardHistory.isEmpty }
var visibleFiles: [RemoteFile] {
guard !query.isEmpty else { return files }
return files.filter { $0.name.localizedCaseInsensitiveContains(query) }
let matches =
query.isEmpty
? files
: files.filter { $0.name.localizedCaseInsensitiveContains(query) }
let descriptor = RemoteFileSortDescriptor(field: sortField, direction: sortDirection)
return matches.sorted { descriptor.precedes($0, $1) }
}
func sort(by field: RemoteFileSortField) {
if sortField == field {
sortDirection.toggle()
} else {
sortField = field
sortDirection = field.defaultDirection
}
}
@ObservationIgnored private var fileSystem: (any RemoteFileSystem)?
@@ -142,7 +159,9 @@ final class MobileSFTPSessionModel {
}
func reload() async {
guard let fileSystem, let connectionGeneration else { return }
guard let fileSystem, let connectionGeneration, !isRefreshing else { return }
isRefreshing = true
defer { isRefreshing = false }
let requestedPath = path
let request = UUID()
directoryRequest = request
+16 -1
View File
@@ -39,7 +39,22 @@ struct MobileRemoteFileEditor: View {
TextField("Name", text: $name).textInputAutocapitalization(.never).autocorrectionDisabled()
.multilineTextAlignment(.leading)
} else {
UnixPermissionFields(draft: $permissions)
Section("Permissions") {
TextField(
"Octal permissions",
text: Binding(
get: { permissions.text },
set: { _ = permissions.accept($0) }
)
)
.font(.body.monospaced())
.keyboardType(.numberPad)
}
Section("File Access") { UnixPermissionGrid(draft: $permissions) }
Section("Ownership") {
LabeledContent("UID", value: file.ownerUID.formatted())
LabeledContent("GID", value: file.ownerGID.formatted())
}
}
})
}
+106 -21
View File
@@ -3,36 +3,72 @@ import TermindDomain
import TermindPresentation
import UIKit
nonisolated enum MobileSFTPListItem: Identifiable, Hashable, Sendable {
case parent(String)
case file(RemoteFile)
var id: String {
switch self {
case .parent: "\u{0}parent"
case .file(let file): file.id
}
}
}
nonisolated func mobileSFTPListItems(path: String, files: [RemoteFile]) -> [MobileSFTPListItem] {
var items = files.map(MobileSFTPListItem.file)
if path != "/" {
items.insert(.parent(RemotePath.parent(of: path)), at: 0)
}
return items
}
struct MobileSFTPBrowser: View {
@Bindable var session: MobileSFTPSessionModel
let connected: Bool
let open: (RemoteFile) -> Void
let download: (RemoteFile) -> Void
let copy: (RemoteFile) -> Void
let paste: () -> Void
let rename: (RemoteFile) -> Void
let editPermissions: (RemoteFile) -> Void
let delete: (RemoteFile) -> Void
private var items: [MobileSFTPListItem] {
mobileSFTPListItems(path: session.path, files: session.visibleFiles)
}
var body: some View {
VStack(spacing: 0) {
MobileSFTPPathBar(session: session, connected: connected)
Divider()
ZStack {
MobileNativeList(
items: session.visibleFiles,
estimatedRowHeight: 52,
refreshID: connected,
onSelect: { file in
items: items,
estimatedRowHeight: 68,
refreshID: AnyHashable([connected, session.isRefreshing]),
onSelect: { item in
guard connected else { return }
open(file)
switch item {
case .parent(let path): Task { await session.openDirectory(path) }
case .file(let file): open(file)
}
},
onDelete: { file in
onDelete: { item in
guard connected else { return }
delete(file)
if case .file(let file) = item { delete(file) }
},
canDelete: { if case .file = $0 { true } else { false } },
onRefresh: { await session.reload() }
) { file in
fileRow(file)
) { item in
switch item {
case .parent:
parentRow
case .file(let file):
fileRow(file)
}
}
if session.visibleFiles.isEmpty {
if items.isEmpty {
Group {
if session.query.isEmpty {
ContentUnavailableView("Empty", systemImage: "folder")
@@ -47,19 +83,68 @@ struct MobileSFTPBrowser: View {
}
}
private var parentRow: some View {
HStack(spacing: 14) {
Image(systemName: "folder.fill")
.font(.system(size: 28))
.foregroundStyle(.tint)
.frame(width: 34)
Text("..").font(.body.weight(.semibold))
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.contentShape(.rect)
.overlay(alignment: .bottom) { Divider().padding(.leading, 64) }
.accessibilityElement(children: .combine)
.accessibilityLabel("Parent Directory")
}
private func fileRow(_ file: RemoteFile) -> some View {
Label(file.name, systemImage: file.kind.symbol)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 14)
.contentShape(.rect)
.contextMenu {
Button("Rename", systemImage: "pencil") { rename(file) }
Button("Edit Permissions", systemImage: "lock.shield") { editPermissions(file) }
Button("Delete", systemImage: "trash", role: .destructive) { delete(file) }
HStack(spacing: 14) {
Image(systemName: file.kind.symbol)
.font(.system(size: file.isDirectory ? 28 : 25))
.foregroundStyle(file.isDirectory ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
.frame(width: 34)
VStack(alignment: .leading, spacing: 3) {
Text(file.name).font(.body.weight(.semibold)).lineLimit(1)
Text(file.permissionDescription)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
}
.overlay(alignment: .bottom) { Divider().padding(.leading, 52) }
.accessibilityHint(file.isDirectory ? Text("Open") : Text("Download"))
Spacer(minLength: 12)
VStack(alignment: .trailing, spacing: 3) {
if !file.isDirectory {
Text(DataSizeFormat.bytes(file.size))
.font(.subheadline.monospacedDigit())
}
Text(file.modifiedAt?.formatted(date: .numeric, time: .shortened) ?? "—")
.font(.caption.monospacedDigit())
}
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 11)
.contentShape(.rect)
.contextMenu {
Button("Open", systemImage: "arrow.up.right") { open(file) }
Button("Download", systemImage: "square.and.arrow.down") { download(file) }
Divider()
Button("Copy", systemImage: "doc.on.doc") { copy(file) }
Menu("Copy As", systemImage: "doc.on.doc") {
Button("Name") { UIPasteboard.general.string = file.name }
Button("Path") { UIPasteboard.general.string = file.path }
}
Button("Paste", systemImage: "doc.on.clipboard") { paste() }
Button("Rename", systemImage: "pencil") { rename(file) }
Button("Edit Permissions", systemImage: "lock.shield") { editPermissions(file) }
Button("Delete", systemImage: "trash", role: .destructive) { delete(file) }
}
.overlay(alignment: .bottom) { Divider().padding(.leading, 52) }
.accessibilityHint(file.isDirectory ? Text("Open") : Text("Download"))
}
}
@@ -0,0 +1,5 @@
import UniformTypeIdentifiers
nonisolated extension UTType {
static let termindSFTPItems = UTType(exportedAs: "com.akinokaede.termind.sftp-items")
}
@@ -2,6 +2,7 @@ import AgentKit
import SwiftUI
import TermindApplication
import TermindInfrastructure
import TermindPresentation
import UniformTypeIdentifiers
struct MobileSkillSettings: View {
@@ -187,7 +188,8 @@ struct MobileSkillEditor: View {
Text("Markdown. Returned whole when the assistant loads this skill.").font(.caption)
.foregroundStyle(.secondary)
if model.draft.body.utf8.count > AgentSkill.maximumBodyBytes {
Text("Longer than \(AgentSkill.maximumBodyBytes / 1_024) KB.").foregroundStyle(.orange)
Text("Longer than \(DataSizeFormat.bytes(AgentSkill.maximumBodyBytes)).")
.foregroundStyle(.orange)
}
}
if let error { Section { Text(error).foregroundStyle(.red) } }
+138 -21
View File
@@ -1,39 +1,156 @@
import QuickLook
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
struct MobileTransfersView: View {
let transfers: SFTPTransferCoordinator
let hosts: HostLibraryModel
@State private var previewURL: URL?
private var items: [SFTPTransfer] {
transfers.sftpTransfers.sorted { $0.queuedAt > $1.queuedAt }
}
private var hasFinished: Bool {
items.contains {
switch $0.state {
case .completed, .cancelled, .failed: true
case .queued, .running, .paused: false
}
}
}
var body: some View {
MobileCardPage {
if transfers.sftpTransfers.isEmpty {
if items.isEmpty {
ContentUnavailableView("No Transfers", systemImage: "arrow.up.arrow.down")
}
MobileCardGrid {
ForEach(transfers.sftpTransfers) { transfer in
MobileCard {
VStack(alignment: .leading, spacing: 12) {
Label(transfer.fileName, systemImage: transfer.direction.symbol).font(.headline)
Text(transfer.remotePath).font(.caption.monospaced()).lineLimit(2)
.truncationMode(.middle).foregroundStyle(.secondary)
Text(transfer.state.label).font(.caption)
if transfer.state == .queued || transfer.state == .running {
ProgressView(value: transfer.byteCount > 0 ? transfer.progress : nil)
Button("Cancel", systemImage: "xmark") { transfers.cancel(transfer.id) }.frame(
minHeight: 44)
}
if transfer.direction == .download && transfer.state == .completed {
ShareLink(item: URL(fileURLWithPath: transfer.localPath)) {
Label("Save or Share", systemImage: "square.and.arrow.up")
}.frame(minHeight: 44)
}
}
}
ForEach(items) { transfer in
MobileCard { transferCard(transfer) }
}
}
}
.navigationTitle("Transfers")
.toolbar { Button("Clear Finished", systemImage: "trash") { transfers.clearFinished() }.labelStyle(.iconOnly) }
.quickLookPreview($previewURL)
.toolbar {
if hasFinished {
Button("Clear Finished", systemImage: "trash") { transfers.clearFinished() }
.labelStyle(.iconOnly)
}
}
}
private func transferCard(_ transfer: SFTPTransfer) -> some View {
let active = transfer.state == .queued || transfer.state == .running
return VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
MobileCardIcon(symbol: transfer.direction.symbol, tint: tint(for: transfer))
VStack(alignment: .leading, spacing: 4) {
Text(transfer.fileName).font(.headline).lineLimit(1)
Text(status(for: transfer)).font(.caption).foregroundStyle(tint(for: transfer))
}
Spacer(minLength: 8)
if active {
Button("Cancel transfer", systemImage: "xmark.circle.fill") {
transfers.cancel(transfer.id)
}
.labelStyle(.iconOnly)
.font(.title3)
}
}
Text(route(for: transfer))
.font(.caption.monospaced())
.lineLimit(2)
.truncationMode(.middle)
.foregroundStyle(.secondary)
if active {
ProgressView(value: transfer.byteCount > 0 ? transfer.progress : nil)
}
Text(detail(for: transfer))
.font(.caption.monospacedDigit())
.foregroundStyle(detailTint(for: transfer))
.lineLimit(2)
if transfer.direction == .download, transfer.state == .completed {
let url = URL(fileURLWithPath: transfer.localPath)
if FileManager.default.fileExists(atPath: url.path) {
HStack {
Button("Open", systemImage: "eye") { previewURL = url }
ShareLink(item: url) {
Label("Share", systemImage: "square.and.arrow.up")
}
}
.frame(minHeight: 44)
}
}
}
}
private func status(for transfer: SFTPTransfer) -> String {
switch (transfer.direction, transfer.state) {
case (.upload, .queued): String(localized: "Upload Queued")
case (.upload, .running): String(localized: "Uploading")
case (.upload, .paused): String(localized: "Upload Paused")
case (.upload, .completed): String(localized: "Uploaded")
case (.upload, .cancelled): String(localized: "Upload Cancelled")
case (.upload, .failed): String(localized: "Upload Failed")
case (.download, .queued): String(localized: "Download Queued")
case (.download, .running): String(localized: "Downloading")
case (.download, .paused): String(localized: "Download Paused")
case (.download, .completed): String(localized: "Downloaded")
case (.download, .cancelled): String(localized: "Download Cancelled")
case (.download, .failed): String(localized: "Download Failed")
case (.copy, .queued): String(localized: "Copy Queued")
case (.copy, .running): String(localized: "Copying")
case (.copy, .paused): String(localized: "Copy Paused")
case (.copy, .completed): String(localized: "Copied")
case (.copy, .cancelled): String(localized: "Copy Cancelled")
case (.copy, .failed): String(localized: "Copy Failed")
}
}
private func detail(for transfer: SFTPTransfer) -> String {
if case .failed(let reason) = transfer.state { return reason }
if transfer.bytesPerSecond > 0 { return DataSizeFormat.rate(transfer.bytesPerSecond) }
if transfer.byteCount > 0 {
return "\(DataSizeFormat.bytes(transfer.transferredBytes)) of \(DataSizeFormat.bytes(transfer.byteCount))"
}
return DataSizeFormat.bytes(transfer.byteCount)
}
private func route(for transfer: SFTPTransfer) -> String {
func remote(_ hostID: Host.ID?, _ path: String) -> String {
guard let hostID, let host = hosts.host(hostID) else { return path }
return "\(host.name) · \(path)"
}
switch transfer.direction {
case .upload:
return "\(transfer.localPath) → \(remote(transfer.destinationHostID, transfer.remotePath))"
case .download:
return "\(remote(transfer.sourceHostID, transfer.remotePath)) → \(transfer.localPath)"
case .copy:
return
"\(remote(transfer.sourceHostID, transfer.localPath)) → \(remote(transfer.destinationHostID, transfer.remotePath))"
}
}
private func tint(for transfer: SFTPTransfer) -> Color {
switch transfer.state {
case .failed: .red
case .cancelled: .secondary
case .completed: .green
case .queued, .running, .paused: .accentColor
}
}
private func detailTint(for transfer: SFTPTransfer) -> Color {
if case .failed = transfer.state { return .red }
return .secondary
}
}
+161 -5
View File
@@ -30,6 +30,18 @@ private struct MobilePendingUpload {
var connectionGeneration: UUID
}
private struct MobilePendingDownload {
var batch: SFTPRemoteDownloadBatch
var configuration: SSHConnectionConfiguration
var connectionGeneration: UUID
}
private struct MobilePendingRemoteCopy {
var batch: SFTPRemoteCopyBatch
var configuration: SSHConnectionConfiguration
var connectionGeneration: UUID
}
private struct MobilePendingInitialCommand {
var command: String
var recordsInHistory: Bool
@@ -76,6 +88,9 @@ final class MobileWorkspace: Identifiable {
private var uploadPreparationTask: Task<Void, Never>?
private var uploadEnqueueTask: Task<Void, Never>?
private var uploadNoticeTask: Task<Void, Never>?
private var remoteTransferPreparationTask: Task<Void, Never>?
private var pendingDownload: MobilePendingDownload?
private var pendingRemoteCopy: MobilePendingRemoteCopy?
let terminalSettings: TerminalSettingsModel
let commandHistory: CommandHistoryModel
let sftpHistory: SFTPHistoryModel
@@ -114,7 +129,13 @@ final class MobileWorkspace: Identifiable {
private var viewport = InMemoryTerminalViewport(columns: 80, rows: 24)
var uploadConflictCount: Int { pendingUpload?.batch.conflictCount ?? 0 }
var hasPendingUploadConflict: Bool { uploadConflictCount > 0 }
var transferConflictCount: Int {
pendingRemoteCopy?.batch.conflictCount
?? pendingDownload?.batch.conflictCount
?? pendingUpload?.batch.conflictCount
?? 0
}
var hasPendingTransferConflict: Bool { transferConflictCount > 0 }
var sameDirectoryLaunch: MobileWorkspaceDirectoryLaunch? {
guard connected else { return nil }
@@ -1014,6 +1035,138 @@ final class MobileWorkspace: Identifiable {
return true
}
func remoteCopySource(for file: RemoteFile) -> SFTPRemoteCopySource? {
guard descriptor.kind == .sftp, connected, let configuration = transferConfiguration else {
return nil
}
return SFTPRemoteCopySource(
hostID: descriptor.hostID,
connection: .saved(configuration),
files: [file]
)
}
func prepareRemoteCopy(_ payload: SFTPRemoteItemsPayload) {
guard let transfers, let configuration = transferConfiguration,
let sftp, connected
else {
operationError = String(localized: "The SFTP connection is not available.")
return
}
cancelPendingUpload()
let connectionGeneration = generation
remoteTransferPreparationTask = Task { [weak self] in
guard let self else { return }
do {
let batch = try await SFTPRemoteCopyPlanner.prepare(
payload, destination: sftpSession.path,
client: sftp, coordinator: transfers
)
try Task.checkCancellation()
guard generation == connectionGeneration else {
remoteTransferPreparationTask = nil
return
}
pendingRemoteCopy = MobilePendingRemoteCopy(
batch: batch, configuration: configuration,
connectionGeneration: connectionGeneration
)
remoteTransferPreparationTask = nil
if batch.conflictCount == 0 { enqueuePendingTransfer(.replace) }
} catch is CancellationError {
remoteTransferPreparationTask = nil
} catch {
operationError = error.localizedDescription
remoteTransferPreparationTask = nil
}
}
}
func prepareDownload(_ file: RemoteFile, to destination: URL) {
guard transfers != nil, let configuration = transferConfiguration,
let sftp, connected
else {
operationError = String(localized: "The SFTP connection is not available.")
return
}
cancelPendingUpload()
let connectionGeneration = generation
remoteTransferPreparationTask = Task { [weak self] in
guard let self else { return }
do {
let batch = try await SFTPRemoteDownloadPlanner.prepare(
[file], destination: destination, client: sftp
)
try Task.checkCancellation()
guard generation == connectionGeneration else {
remoteTransferPreparationTask = nil
return
}
pendingDownload = MobilePendingDownload(
batch: batch, configuration: configuration,
connectionGeneration: connectionGeneration
)
remoteTransferPreparationTask = nil
if batch.conflictCount == 0 { enqueuePendingTransfer(.replace) }
} catch is CancellationError {
remoteTransferPreparationTask = nil
} catch {
operationError = error.localizedDescription
remoteTransferPreparationTask = nil
}
}
}
func enqueuePendingTransfer(_ policy: TransferConflictPolicy) {
if let pendingRemoteCopy, let transfers, let sftp {
self.pendingRemoteCopy = nil
guard generation == pendingRemoteCopy.connectionGeneration else { return }
let generation = pendingRemoteCopy.connectionGeneration
Task { [weak self] in
guard let self else { return }
do {
try await SFTPRemoteCopyPlanner.enqueue(
pendingRemoteCopy.batch, policy: policy,
destinationHostID: descriptor.hostID,
destinationConnection: .saved(pendingRemoteCopy.configuration),
client: sftp, coordinator: transfers
) { [weak self] state in
guard state == .completed else { return }
Task { @MainActor in
guard let self, self.generation == generation else { return }
await self.sftpSession.reload()
}
}
} catch { operationError = error.localizedDescription }
}
return
}
if let pendingDownload, let transfers {
self.pendingDownload = nil
guard generation == pendingDownload.connectionGeneration else { return }
do {
try SFTPRemoteDownloadPlanner.enqueue(
pendingDownload.batch, policy: policy,
hostID: descriptor.hostID,
connection: .saved(pendingDownload.configuration),
coordinator: transfers
) { [sftpHistory, hostID = descriptor.hostID] path, state in
guard state == .completed else { return }
sftpHistory.record(kind: .file, path: path, for: hostID)
}
} catch { operationError = error.localizedDescription }
return
}
enqueuePendingUpload(policy)
}
func cancelPendingRemoteTransfer() {
remoteTransferPreparationTask?.cancel()
remoteTransferPreparationTask = nil
pendingDownload = nil
pendingRemoteCopy = nil
}
func enqueuePendingUpload(_ policy: TransferConflictPolicy) {
guard let pendingUpload, let transfers else { return }
self.pendingUpload = nil
@@ -1061,6 +1214,7 @@ final class MobileWorkspace: Identifiable {
}
func cancelPendingUpload() {
cancelPendingRemoteTransfer()
uploadPreparationTask?.cancel()
uploadPreparationTask = nil
uploadEnqueueTask?.cancel()
@@ -1087,7 +1241,7 @@ final class MobileWorkspace: Identifiable {
}
@discardableResult
func download(_ file: RemoteFile) -> Bool {
func preview(_ file: RemoteFile, completion: @escaping (URL) -> Void) -> Bool {
guard let transfers, let configuration = transferConfiguration, connected, !file.isDirectory else {
return false
}
@@ -1098,13 +1252,15 @@ final class MobileWorkspace: Identifiable {
)
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let localURL = directory.appending(path: file.name)
transfers.enqueue(
direction: .download, batchID: UUID(), localURL: directory.appending(path: file.name),
direction: .download, batchID: UUID(), localURL: localURL,
remotePath: file.path, byteCount: file.size, hostID: descriptor.hostID,
configuration: configuration, scopedRoot: directory, overwrite: false
) { [sftpHistory, hostID = descriptor.hostID, path = file.path] state in
guard state == .completed else { return }
sftpHistory.record(kind: .file, path: path, for: hostID)
completion(localURL)
}
return true
} catch {
@@ -1113,7 +1269,7 @@ final class MobileWorkspace: Identifiable {
}
}
func reopenHistoryFile(at path: String) async -> Bool {
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.")
return false
@@ -1126,7 +1282,7 @@ final class MobileWorkspace: Identifiable {
operationError = String(localized: "The remote file is no longer available.")
return false
}
return download(file)
return preview(file, completion: completion)
} catch {
if !handleSFTPConnectionError(error, connection: connectionGeneration) {
operationError = error.localizedDescription
+115 -17
View File
@@ -1,4 +1,5 @@
import GhosttyTerminal
import QuickLook
import SwiftUI
import TermindApplication
import TermindDomain
@@ -36,6 +37,8 @@ struct MobileWorkspaceView: View {
@State private var showingTransfers = false
@State private var uploadConflictPresented = false
@State private var uploadDropTargeted = false
@State private var downloadTarget: RemoteFile?
@State private var previewURL: URL?
var body: some View {
workspaceAlerts
@@ -138,17 +141,17 @@ struct MobileWorkspaceView: View {
Button("Transfers", systemImage: "arrow.up.arrow.down") { showingTransfers = true }
Button("History", systemImage: "clock") { terminalSheet = .sftpHistory }
Divider()
Button("Parent Directory", systemImage: "arrow.up") {
Task {
await workspace.sftpSession.openDirectory(
RemotePath.parent(of: workspace.sftpSession.path))
}
}
.disabled(!workspace.connected)
Button("Upload", systemImage: "square.and.arrow.up") { importing = true }
.disabled(!workspace.connected)
Button("New Folder", systemImage: "folder.badge.plus") { creatingDirectory = true }
.disabled(!workspace.connected)
Button("Paste", systemImage: "doc.on.clipboard", action: pasteRemoteItems)
.disabled(!workspace.connected)
sortMenu
Button("Refresh", systemImage: "arrow.clockwise") {
Task { await workspace.sftpSession.reload() }
}
.disabled(!workspace.connected || workspace.sftpSession.isRefreshing)
Divider()
sameDirectoryMenuAction
Button("Rename", systemImage: "pencil") { renameWorkspace(workspace) }
@@ -190,9 +193,12 @@ struct MobileWorkspaceView: View {
}
.sheet(isPresented: $showingTransfers) {
NavigationStack {
if let transfers = workspace.transfers { MobileTransfersView(transfers: transfers) }
if let transfers = workspace.transfers {
MobileTransfersView(transfers: transfers, hosts: library)
}
}.mobileSheetPresentation(compact: true)
}
.quickLookPreview($previewURL)
.fileImporter(
isPresented: $importing,
allowedContentTypes: [.data, .folder],
@@ -204,15 +210,34 @@ struct MobileWorkspaceView: View {
case .failure(let error): workspace.operationError = error.localizedDescription
}
}
.fileImporter(
isPresented: Binding(
get: { downloadTarget != nil },
set: { if !$0 { downloadTarget = nil } }
),
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
let target = downloadTarget
downloadTarget = nil
switch result {
case .success(let urls):
if let target, let destination = urls.first {
workspace.prepareDownload(target, to: destination)
}
case .failure(let error):
workspace.operationError = error.localizedDescription
}
}
}
private var workspaceDialogs: some View {
workspaceSheets
.onChange(of: workspace.uploadConflictCount) { _, count in
.onChange(of: workspace.transferConflictCount) { _, count in
uploadConflictPresented = count > 0
}
.onChange(of: uploadConflictPresented) { wasPresented, isPresented in
if wasPresented, !isPresented, workspace.hasPendingUploadConflict {
if wasPresented, !isPresented, workspace.hasPendingTransferConflict {
workspace.cancelPendingUpload()
}
}
@@ -294,6 +319,9 @@ struct MobileWorkspaceView: View {
session: workspace.sftpSession,
connected: workspace.connected,
open: open,
download: { downloadTarget = $0 },
copy: copyRemoteItem,
paste: pasteRemoteItems,
rename: { renaming = $0 },
editPermissions: { editingPermissions = $0 },
delete: { deleting = $0 }
@@ -305,8 +333,9 @@ struct MobileWorkspaceView: View {
if file.isDirectory {
Task { await workspace.sftpSession.openDirectory(file.path) }
} else {
workspace.download(file)
showingTransfers = true
_ = workspace.preview(file) { url in
Task { @MainActor in previewURL = url }
}
}
}
@@ -316,17 +345,17 @@ struct MobileWorkspaceView: View {
Task { await workspace.sftpSession.openDirectory(entry.path) }
case .file:
Task {
if await workspace.reopenHistoryFile(at: entry.path) {
showingTransfers = true
_ = await workspace.reopenHistoryFile(at: entry.path) { url in
Task { @MainActor in previewURL = url }
}
}
}
}
private var uploadConflictTitle: String {
workspace.uploadConflictCount == 1
workspace.transferConflictCount == 1
? String(localized: "1 item already exists")
: String(localized: "\(workspace.uploadConflictCount) items already exist")
: String(localized: "\(workspace.transferConflictCount) items already exist")
}
private var uploadDropTitle: String {
@@ -353,10 +382,79 @@ struct MobileWorkspaceView: View {
}
private func enqueuePendingUpload(_ policy: TransferConflictPolicy) {
workspace.enqueuePendingUpload(policy)
workspace.enqueuePendingTransfer(policy)
uploadConflictPresented = false
}
@ViewBuilder
private var sortMenu: some View {
Menu {
sortButton("Name", field: .name, symbol: "textformat")
sortButton("Date", field: .modified, symbol: "calendar")
sortButton("Size", field: .size, symbol: "externaldrive")
sortButton("Kind", field: .kind, symbol: "doc")
} label: {
Label {
VStack(alignment: .leading) {
Text("Sort By")
Text(sortFieldTitle).font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "arrow.up.arrow.down.square")
}
}
}
private func sortButton(
_ title: LocalizedStringKey, field: RemoteFileSortField, symbol: String
) -> some View {
Button {
workspace.sftpSession.sort(by: field)
} label: {
Label(
title,
systemImage: workspace.sftpSession.sortField == field
? (workspace.sftpSession.sortDirection == .ascending ? "chevron.up" : "chevron.down")
: symbol
)
}
}
private var sortFieldTitle: String {
switch workspace.sftpSession.sortField {
case .name: String(localized: "Name")
case .modified: String(localized: "Date")
case .size: String(localized: "Size")
case .kind: String(localized: "Kind")
}
}
private func copyRemoteItem(_ file: RemoteFile) {
guard let transfers = workspace.transfers,
let source = workspace.remoteCopySource(for: file),
let data = try? JSONEncoder().encode(transfers.registerRemoteCopySource(source))
else { return }
UIPasteboard.general.items = [
[
UTType.termindSFTPItems.identifier: data,
UTType.plainText.identifier: file.name,
]
]
}
private func pasteRemoteItems() {
guard
let data = UIPasteboard.general.data(
forPasteboardType: UTType.termindSFTPItems.identifier
), let payload = try? JSONDecoder().decode(SFTPRemoteItemsPayload.self, from: data),
workspace.transfers?.remoteCopySource(for: payload) != nil
else {
workspace.operationError = String(localized: "The copied SFTP items are invalid.")
return
}
workspace.prepareRemoteCopy(payload)
}
private var presentedOperationError: String? {
workspace.operationError ?? workspace.sftpSession.operationError
}
+13
View File
@@ -31,5 +31,18 @@
<string>Termind uses Apple Speech Recognition to turn your recording into text. Depending on system settings and language support, Apple may process the audio online.</string>
<key>NSCameraUsageDescription</key>
<string>Termind uses the camera to attach photos to your chat.</string>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>Termind SFTP Items</string>
<key>UTTypeIdentifier</key>
<string>com.akinokaede.termind.sftp-items</string>
</dict>
</array>
</dict>
</plist>
+8 -1
View File
@@ -11,6 +11,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View {
let refreshID: AnyHashable
let onSelect: ((Item) -> Void)?
let onDelete: ((Item) -> Void)?
let canDelete: (Item) -> Bool
let onRefresh: (() async -> Void)?
let followsBottom: Bool
@ViewBuilder let rowContent: (Item) -> RowContent
@@ -23,6 +24,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View {
items: [Item], estimatedRowHeight: CGFloat = 72, refreshID: AnyHashable = 0,
onSelect: ((Item) -> Void)? = nil,
onDelete: ((Item) -> Void)? = nil,
canDelete: @escaping (Item) -> Bool = { _ in true },
onRefresh: (() async -> Void)? = nil,
followsBottom: Bool = false,
@ViewBuilder rowContent: @escaping (Item) -> RowContent
@@ -32,6 +34,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View {
self.refreshID = refreshID
self.onSelect = onSelect
self.onDelete = onDelete
self.canDelete = canDelete
self.onRefresh = onRefresh
self.followsBottom = followsBottom
self.rowContent = rowContent
@@ -66,6 +69,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View {
renderer: { item in AnyView(rowContent(item)) },
onSelect: onSelect,
onDelete: onDelete,
canDelete: canDelete,
onRefresh: onRefresh,
followsBottom: followsBottom,
refreshID: refreshID,
@@ -87,6 +91,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View {
private var renderer: Renderer?
private var onSelect: ((Item) -> Void)?
private var onDelete: ((Item) -> Void)?
private var canDelete: (Item) -> Bool = { _ in true }
private var onRefresh: (() async -> Void)?
private var refreshTask: Task<Void, Never>?
private weak var refreshControl: UIRefreshControl?
@@ -100,6 +105,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View {
func update(
listView: ListView<Item>, items newItems: [Item], renderer: @escaping Renderer,
onSelect: ((Item) -> Void)?, onDelete: ((Item) -> Void)?,
canDelete: @escaping (Item) -> Bool,
onRefresh: (() async -> Void)?,
followsBottom: Bool,
refreshID newRefreshID: AnyHashable,
@@ -115,6 +121,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View {
self.renderer = renderer
self.onSelect = onSelect
self.onDelete = onDelete
self.canDelete = canDelete
self.onRefresh = onRefresh
self.locale = locale
self.colorScheme = colorScheme
@@ -174,7 +181,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View {
row.configure(
rootView: rootView(for: item),
onSelect: onSelect.map { action in { action(item) } },
onDelete: onDelete.map { action in { action(item) } }
onDelete: canDelete(item) ? onDelete.map { action in { action(item) } } : nil
)
}
+7 -2
View File
@@ -280,11 +280,16 @@ struct MobileNavigationParityTests {
#expect(sftpGroups.count == 3)
#expect(sftpGroups[0].contains("Menu {\n Button(\"Transfers\""))
let parentDirectory = try #require(sftpGroups[1].range(of: "Button(\"Parent Directory\""))
let upload = try #require(sftpGroups[1].range(of: "Button(\"Upload\""))
let newFolder = try #require(sftpGroups[1].range(of: "Button(\"New Folder\""))
#expect(parentDirectory.lowerBound < upload.lowerBound)
let paste = try #require(sftpGroups[1].range(of: "Button(\"Paste\""))
let sort = try #require(sftpGroups[1].range(of: "sortMenu"))
let refresh = try #require(sftpGroups[1].range(of: "Button(\"Refresh\""))
#expect(!sftpGroups[1].contains("Parent Directory"))
#expect(upload.lowerBound < newFolder.lowerBound)
#expect(newFolder.lowerBound < paste.lowerBound)
#expect(paste.lowerBound < sort.lowerBound)
#expect(sort.lowerBound < refresh.lowerBound)
let sftpSameDirectory = try #require(sftpGroups[2].range(of: "sameDirectoryMenuAction"))
let sftpRename = try #require(sftpGroups[2].range(of: "Button(\"Rename\""))
let sftpClose = try #require(sftpGroups[2].range(of: "Button(\"Close\""))
+47
View File
@@ -80,6 +80,53 @@ struct MobileSFTPSessionTests {
#expect(session.visibleFiles == [folder, notes, report])
}
@Test func sortMatchesDesktopDefaultsAndKeepsDirectoriesBeforeFiles() async throws {
let older = RemoteFile(
path: "/home/older.txt", name: "older.txt", kind: .file, size: 1_000,
modifiedAt: Date(timeIntervalSince1970: 10), permissions: 0o644,
ownerUID: 501, ownerGID: 20
)
let newer = RemoteFile(
path: "/home/newer.txt", name: "newer.txt", kind: .file, size: 10,
modifiedAt: Date(timeIntervalSince1970: 20), permissions: 0o644,
ownerUID: 501, ownerGID: 20
)
let folder = remoteFile("/home/folder", kind: .directory)
let session = MobileSFTPSessionModel()
try await attach(
session,
to: MobileBrowserFileSystem(listings: ["/home": [older, newer, folder]]),
path: "/home"
)
session.sort(by: .modified)
#expect(session.sortDirection == .descending)
#expect(session.visibleFiles.map(\.name) == ["folder", "newer.txt", "older.txt"])
session.sort(by: .modified)
#expect(session.sortDirection == .ascending)
#expect(session.visibleFiles.map(\.name) == ["folder", "older.txt", "newer.txt"])
session.sort(by: .size)
#expect(session.sortDirection == .descending)
#expect(session.visibleFiles.map(\.name) == ["folder", "older.txt", "newer.txt"])
}
@Test func parentNavigationItemIsPrependedAfterFileSorting() {
let files = [remoteFile("/home/z.txt"), remoteFile("/home/a.txt")]
let sorted = files.sorted { $0.name < $1.name }
let items = mobileSFTPListItems(path: "/home", files: sorted)
guard case .parent("/") = items.first else {
Issue.record("The parent navigation item must remain first")
return
}
let names = items.dropFirst().compactMap { item in
if case .file(let file) = item { file.name } else { nil }
}
#expect(names == ["a.txt", "z.txt"])
#expect(mobileSFTPListItems(path: "/", files: sorted).count == sorted.count)
}
@Test func successfulDirectoriesRefreshPersistentHistoryAndFailuresDoNot() async throws {
let hostID = UUID()
let history = SFTPHistoryModel()
+7 -2
View File
@@ -23,6 +23,7 @@ nonisolated enum MenuSymbol {
static let settings = "gearshape"
static let quickOpen = "magnifyingglass"
static let find = "magnifyingglass"
static let actions = "ellipsis.circle"
static let support = "questionmark.circle"
static let showAssistant = "sidebar.left"
@@ -71,6 +72,9 @@ nonisolated enum MenuSymbol {
static let start = "play"
static let stop = "stop.fill"
static let refresh = "arrow.clockwise"
static let showHiddenFiles = "eye"
static let hideHiddenFiles = "eye.slash"
static let editPath = "text.cursor"
static let editPermissions = "lock"
static let test = "checkmark.circle"
static let pin = "pin"
@@ -112,13 +116,14 @@ nonisolated enum MenuSymbol {
/// a real SF Symbol. A misspelled name draws nothing and says nothing, which
/// is exactly the kind of mistake no one notices in review.
static let all: [String] = [
settings, quickOpen, find, support, showAssistant,
settings, quickOpen, find, actions, support, showAssistant,
quickConnect, connect, ssh, sftp,
newChat, newHost, newGroup, newPackage, newFolder, newSnippet, saveAsSnippet,
newIdentity, generateKey, generateCommand,
importItem, download, export, upload,
details, pause, selectDevices, open, openFullScreen, edit, rename, resetName, duplicate, copy, paste,
execute, executeOnHosts, start, stop, refresh, editPermissions, test,
execute, executeOnHosts, start, stop, refresh, showHiddenFiles, hideHiddenFiles, editPath,
editPermissions, test,
pin, unpin, archive, unarchive, delete,
workspace, nextWorkspace, previousWorkspace, currentWorkspace, newWorkspace,
separatePanes, splitRight, splitDown, closeWorkspace, reopenWorkspace,
+74 -15
View File
@@ -10,13 +10,22 @@ import UniformTypeIdentifiers
#endif
nonisolated struct SFTPFileListItem: Identifiable, Hashable, Sendable {
let row: SFTPRow
nonisolated enum ID: Hashable, Sendable {
case parent
case file(RemoteFile.ID)
}
let row: SFTPRow?
let parentPath: String?
let isSelected: Bool
let isEmphasized: Bool
let isHovered: Bool
let layoutKey: Int
var id: RemoteFile.ID { row.id }
var id: ID {
if let row { return .file(row.id) }
return .parent
}
}
nonisolated struct SFTPFileScrollRequest: Equatable, Sendable {
@@ -122,7 +131,7 @@ private struct SFTPFileNativeList: NSViewRepresentable {
if let request = scrollRequest, request.token != lastScrollToken {
lastScrollToken = request.token
listView.scrollToRow(
with: request.targetID,
with: SFTPFileListItem.ID.file(request.targetID),
at: request.position == .top ? .top : .nearest,
animated: false
)
@@ -449,22 +458,27 @@ struct SFTPBrowserView: View {
private func actionsMenu(layout: Layout) -> some View {
Menu {
Button("New Folder") { newFolderPresented = true }
Button("New Folder", systemImage: MenuSymbol.newFolder) { newFolderPresented = true }
.disabled(sftpSession.status != .connected)
Button("Paste") { pasteRemoteItems() }
Button("Paste", systemImage: MenuSymbol.paste) { pasteRemoteItems() }
.disabled(sftpSession.status != .connected || !canPasteRemoteItems)
Divider()
if sftpSession.showsHiddenFiles {
Button("Hide Hidden Files") { sftpSession.showsHiddenFiles = false }
Button("Hide Hidden Files", systemImage: MenuSymbol.hideHiddenFiles) {
sftpSession.showsHiddenFiles = false
}
} else {
Button("Show Hidden Files") { sftpSession.showsHiddenFiles = true }
Button("Show Hidden Files", systemImage: MenuSymbol.showHiddenFiles) {
sftpSession.showsHiddenFiles = true
}
}
Button("Edit Path") { beginPathEditing() }
Button("Refresh") { sftpSession.reloadNow() }
Button("Edit Path", systemImage: MenuSymbol.editPath) { beginPathEditing() }
Button("Refresh", systemImage: MenuSymbol.refresh) { sftpSession.reloadNow() }
.disabled(sftpSession.status != .connected || sftpSession.isLoading)
} label: {
if layout == .full {
HStack(spacing: Metrics.Space.s) {
Image(systemName: MenuSymbol.actions)
Text("Actions")
Image(systemName: "chevron.down")
.font(.caption.weight(.semibold))
@@ -619,7 +633,7 @@ struct SFTPBrowserView: View {
scrollRequest: fileScrollRequest,
onBackgroundClick: clearFileSelection
) { item in
fileRow(item.row, layout: layout)
fileListRow(item, layout: layout)
}
.focusable(!window.isToolbarFindFocused)
.focusEffectDisabled()
@@ -659,15 +673,60 @@ struct SFTPBrowserView: View {
case .medium: layoutKey = 1
case .compact: layoutKey = 0
}
return sftpSession.visibleRows.map { row in
var items = sftpSession.visibleRows.map { row in
SFTPFileListItem(
row: row,
row: row, parentPath: nil,
isSelected: sftpSession.selection.contains(row.id),
isEmphasized: emphasized,
isHovered: hoveredFileID == row.id,
layoutKey: layoutKey
)
}
if sftpSession.path != "/" {
items.insert(
SFTPFileListItem(
row: nil, parentPath: RemotePath.parent(of: sftpSession.path),
isSelected: false, isEmphasized: false, isHovered: false,
layoutKey: layoutKey
),
at: 0
)
}
return items
}
@ViewBuilder
private func fileListRow(_ item: SFTPFileListItem, layout: Layout) -> some View {
if let row = item.row {
fileRow(row, layout: layout)
} else if let parentPath = item.parentPath {
parentDirectoryRow(path: parentPath)
}
}
private func parentDirectoryRow(path: String) -> some View {
HStack(spacing: Metrics.Space.m) {
Image(systemName: "folder.fill")
.font(.system(size: 23, weight: .regular))
.foregroundStyle(.tint)
.frame(width: 28)
Text("..")
.font(.body.weight(.medium))
Spacer()
}
.padding(.leading, Metrics.Space.xxl)
.padding(.trailing, Metrics.Space.xl)
.frame(maxWidth: .infinity, minHeight: 48)
.contentShape(.rect)
.onTapGesture {
focusPane()
focusListForUserGesture()
if NSApp.currentEvent?.clickCount == 2 { sftpSession.navigate(to: path) }
}
.accessibilityElement(children: .combine)
.accessibilityLabel("Parent Directory")
.accessibilityAddTraits(.isButton)
.accessibilityAction { sftpSession.navigate(to: path) }
}
private func clearFileSelection() {
@@ -713,7 +772,7 @@ struct SFTPBrowserView: View {
}
private func sortHeader(
_ title: LocalizedStringKey, field: SFTPFileSortField, separated: Bool = false
_ title: LocalizedStringKey, field: RemoteFileSortField, separated: Bool = false
) -> some View {
let isSorted = sftpSession.sortField == field
return Button {
@@ -861,12 +920,12 @@ struct SFTPBrowserView: View {
case .full:
return row.permissionText
case .medium:
return [row.permissionText, row.kindText, row.sizeText]
return [row.permissionText, row.kindText, row.sizeText].filter { !$0.isEmpty }
.joined(separator: " · ")
case .compact:
return [
row.kindText, row.sizeText, row.modifiedText, row.permissionText,
].joined(separator: " · ")
].filter { !$0.isEmpty }.joined(separator: " · ")
}
}
+9 -65
View File
@@ -10,29 +10,6 @@ protocol SFTPSessionNavigationRouting: AnyObject {
func sftpSession(_ sftpSession: SFTPSessionModel, didChangePath path: String)
}
nonisolated enum SFTPFileSortField: String, CaseIterable, Sendable {
case name
case modified
case size
case kind
var defaultDirection: SFTPFileSortDirection {
switch self {
case .modified, .size: .descending
case .name, .kind: .ascending
}
}
}
nonisolated enum SFTPFileSortDirection: String, Sendable {
case ascending
case descending
mutating func toggle() {
self = self == .ascending ? .descending : .ascending
}
}
/// One row of the browser, with its display strings already rendered.
///
/// The formatters are the reason this type exists. A hand-rolled `LazyVStack`
@@ -52,7 +29,7 @@ nonisolated struct SFTPRow: Identifiable, Hashable, Sendable {
init(file: RemoteFile, kindText: String? = nil) {
self.file = file
self.sizeText = file.isDirectory ? "--" : file.size.formatted(.byteCount(style: .file))
self.sizeText = file.isDirectory ? "" : DataSizeFormat.bytes(file.size)
self.modifiedText =
file.modifiedAt?.formatted(date: .abbreviated, time: .shortened) ?? "—"
self.kindText = kindText ?? file.sftpKindDescription
@@ -73,8 +50,8 @@ private struct SFTPDirectoryPresentation {
var selection: Set<RemoteFile.ID> = []
var query = ""
var showsHiddenFiles = false
var sortField: SFTPFileSortField = .name
var sortDirection: SFTPFileSortDirection = .ascending
var sortField: RemoteFileSortField = .name
var sortDirection: RemoteFileSortDirection = .ascending
private(set) var visibleRows: [SFTPRow] = []
/// Re-filters, re-sorts, and drops any selection that fell out of view.
@@ -101,42 +78,9 @@ private struct SFTPDirectoryPresentation {
}
private func rowPrecedes(_ lhs: SFTPRow, _ rhs: SFTPRow) -> Bool {
if lhs.file.isDirectory != rhs.file.isDirectory { return lhs.file.isDirectory }
let primary = compare(lhs, rhs)
if primary != .orderedSame {
return sortDirection == .ascending
? primary == .orderedAscending
: primary == .orderedDescending
}
let name = lhs.file.name.localizedStandardCompare(rhs.file.name)
if name != .orderedSame { return name == .orderedAscending }
return lhs.file.path.localizedStandardCompare(rhs.file.path) == .orderedAscending
}
private func compare(_ lhs: SFTPRow, _ rhs: SFTPRow) -> ComparisonResult {
switch sortField {
case .name:
return lhs.file.name.localizedStandardCompare(rhs.file.name)
case .modified:
switch (lhs.file.modifiedAt, rhs.file.modifiedAt) {
case (.none, .none): return .orderedSame
case (.none, .some):
return sortDirection == .ascending ? .orderedDescending : .orderedAscending
case (.some, .none):
return sortDirection == .ascending ? .orderedAscending : .orderedDescending
case (.some(let left), .some(let right)):
return left == right
? .orderedSame : (left < right ? .orderedAscending : .orderedDescending)
}
case .size:
return lhs.file.size == rhs.file.size
? .orderedSame
: (lhs.file.size < rhs.file.size ? .orderedAscending : .orderedDescending)
case .kind:
return lhs.kindText.localizedStandardCompare(rhs.kindText)
}
RemoteFileSortDescriptor(field: sortField, direction: sortDirection).precedes(
lhs.file, rhs.file, lhsKind: lhs.kindText, rhsKind: rhs.kindText
)
}
}
@@ -229,8 +173,8 @@ final class SFTPSessionModel: Identifiable {
directoryPresentation = presentation
}
}
var sortField: SFTPFileSortField { directoryPresentation.sortField }
var sortDirection: SFTPFileSortDirection { directoryPresentation.sortDirection }
var sortField: RemoteFileSortField { directoryPresentation.sortField }
var sortDirection: RemoteFileSortDirection { directoryPresentation.sortDirection }
var files: [RemoteFile] { directoryPresentation.files }
private(set) var status: ConnectionStatus = .disconnected {
didSet {
@@ -324,7 +268,7 @@ final class SFTPSessionModel: Identifiable {
directoryPresentation.visibleRows.filter { selection.contains($0.id) }.map(\.file)
}
func sort(by field: SFTPFileSortField) {
func sort(by field: RemoteFileSortField) {
var presentation = directoryPresentation
if presentation.sortField == field {
presentation.sortDirection.toggle()
+2 -1
View File
@@ -1,5 +1,6 @@
import AgentKit
import SwiftUI
import TermindPresentation
import UniformTypeIdentifiers
/// One skill: what it is called, when the assistant should reach for it, and
@@ -164,7 +165,7 @@ struct SkillEditorPage: View {
if skill.body.utf8.count > AgentSkill.maximumBodyBytes {
Label(
"Longer than \(AgentSkill.maximumBodyBytes / 1_024) KB.",
"Longer than \(DataSizeFormat.bytes(AgentSkill.maximumBodyBytes)).",
systemImage: "exclamationmark.triangle.fill"
)
.font(.caption)
+4 -3
View File
@@ -1,6 +1,7 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import struct TermindDomain.Host
@@ -571,11 +572,11 @@ private struct TransferCenterRow: View {
private var detail: String {
if case .failed(let reason) = transfer.state { return reason }
if transfer.bytesPerSecond > 0 {
return "\(transfer.bytesPerSecond.formatted(.byteCount(style: .file)))/s"
return DataSizeFormat.rate(transfer.bytesPerSecond)
}
if transfer.byteCount > 0 {
let transferred = transfer.transferredBytes.formatted(.byteCount(style: .file))
let total = transfer.byteCount.formatted(.byteCount(style: .file))
let transferred = DataSizeFormat.bytes(transfer.transferredBytes)
let total = DataSizeFormat.bytes(transfer.byteCount)
return "\(transferred) of \(total)"
}
return transfer.state.label
+21 -1
View File
@@ -160,7 +160,7 @@ struct SFTPFeatureTests {
await session.reload(silently: true)
#expect(session.visibleRows.map(\.file.name) == ["archive", "notes.txt", "report.txt"])
#expect(session.visibleRows.map(\.sizeText) == ["--", "10 bytes", "100 bytes"])
#expect(session.visibleRows.map(\.sizeText) == ["", "10 bytes", "100 bytes"])
session.selection = [notes.id, report.id]
#expect(session.visibleRows.map(\.file.name) == ["archive", "notes.txt", "report.txt"])
@@ -771,6 +771,26 @@ struct SFTPFeatureTests {
])
}
@Test
func remoteDownloadPlannerPreservesHierarchyAndEmptyDirectories() async throws {
let bundle = remoteFile(path: "/source/bundle", kind: .directory)
let empty = remoteFile(path: "/source/bundle/empty", kind: .directory)
let readme = remoteFile(path: "/source/bundle/README.md", kind: .file, size: 4)
let client = TreeSFTPClient(listings: [
bundle.path: [empty, readme],
empty.path: [],
])
let destination = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
let batch = try await SFTPRemoteDownloadPlanner.prepare(
[bundle], destination: destination, client: client
)
#expect(batch.transferCount == 1)
#expect(batch.directoryCount == 2)
#expect(batch.conflictCount == 0)
}
@Test
func remoteCopyPreflightFindsTargetConflicts() async {
let sourceHostID = UUID()
+1 -1
View File
@@ -2506,7 +2506,7 @@
repositoryURL = "https://github.com/AkinoKaede/AgentKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.6.1;
minimumVersion = 0.6.2;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/AkinoKaede/AgentKit.git",
"state" : {
"revision" : "41289294a7619d1d593c80d59f348f84f22a3126",
"version" : "0.6.1"
"revision" : "d92a8c580e5d8efc58976e5f3d168ea2b0d13ecb",
"version" : "0.6.2"
}
},
{