Improve SFTP file workflows
This commit is contained in:
1 parent
e50ea0c75e
commit
abaa6a2f8a
30 files changed
+1448
-203
No files matched your search
@@ -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) == "—")
|
||||
}
|
||||
}
|
||||
@@ -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 + "/")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) } }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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\""))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user