From abaa6a2f8aadcd526f45c5e59cf7b419f525128d Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 12 Sep 2026 16:38:36 +0800 Subject: [PATCH] Improve SFTP file workflows --- FrameworkTests/DataSizeFormatTests.swift | 18 + Frameworks/Resources/Localizable.xcstrings | 163 +++++++- .../Agent/ContextAttachmentImporter.swift | 12 +- .../TermindPresentation/DataSizeFormat.swift | 29 ++ .../TermindPresentation/MetricsFormat.swift | 11 +- .../TermindPresentation/RemoteFileSort.swift | 77 ++++ .../SFTP/RemoteFile+Presentation.swift | 4 +- .../UnixPermissionFields.swift | 26 -- .../Transfers/SFTPRemoteTransferPlanner.swift | 386 ++++++++++++++++++ TFI/App/MobileRootView.swift | 2 +- .../Transfers/MobileSFTPSessionModel.swift | 25 +- .../SFTP/MobileRemoteFileEditor.swift | 17 +- TFI/Features/SFTP/MobileSFTPBrowser.swift | 127 +++++- TFI/Features/SFTP/MobileSFTPItemType.swift | 5 + .../Settings/MobileSkillSettings.swift | 4 +- .../Transfers/MobileTransfersView.swift | 159 +++++++- TFI/Features/Workspaces/MobileWorkspace.swift | 166 +++++++- .../Workspaces/MobileWorkspaceView.swift | 132 +++++- TFI/Info.plist | 13 + TFI/UI/Components/MobileNativeList.swift | 9 +- TFITests/MobileNavigationParityTests.swift | 9 +- TFITests/MobileSFTPSessionTests.swift | 47 +++ TFM/DesignSystem/MenuSymbol.swift | 9 +- TFM/Features/SFTP/SFTPBrowserView.swift | 89 +++- TFM/Features/SFTP/SFTPSessionModel.swift | 74 +--- TFM/Features/Settings/SkillEditorSheet.swift | 3 +- TFM/Features/StatusBar/StatusBarView.swift | 7 +- TFMTests/SFTPFeatureTests.swift | 22 +- Termind.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- 30 files changed, 1448 insertions(+), 203 deletions(-) create mode 100644 FrameworkTests/DataSizeFormatTests.swift create mode 100644 Frameworks/TermindPresentation/DataSizeFormat.swift create mode 100644 Frameworks/TermindPresentation/RemoteFileSort.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/Transfers/SFTPRemoteTransferPlanner.swift create mode 100644 TFI/Features/SFTP/MobileSFTPItemType.swift diff --git a/FrameworkTests/DataSizeFormatTests.swift b/FrameworkTests/DataSizeFormatTests.swift new file mode 100644 index 00000000..4162e5f1 --- /dev/null +++ b/FrameworkTests/DataSizeFormatTests.swift @@ -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) == "—") + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 15360487..c1580d72 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : "已复制" } } } diff --git a/Frameworks/TermindInfrastructure/Agent/ContextAttachmentImporter.swift b/Frameworks/TermindInfrastructure/Agent/ContextAttachmentImporter.swift index c12b7a92..e377f2ba 100644 --- a/Frameworks/TermindInfrastructure/Agent/ContextAttachmentImporter.swift +++ b/Frameworks/TermindInfrastructure/Agent/ContextAttachmentImporter.swift @@ -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.") diff --git a/Frameworks/TermindPresentation/DataSizeFormat.swift b/Frameworks/TermindPresentation/DataSizeFormat.swift new file mode 100644 index 00000000..c0288ce0 --- /dev/null +++ b/Frameworks/TermindPresentation/DataSizeFormat.swift @@ -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" + } +} diff --git a/Frameworks/TermindPresentation/MetricsFormat.swift b/Frameworks/TermindPresentation/MetricsFormat.swift index 68beefb2..7e191307 100644 --- a/Frameworks/TermindPresentation/MetricsFormat.swift +++ b/Frameworks/TermindPresentation/MetricsFormat.swift @@ -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) } } diff --git a/Frameworks/TermindPresentation/RemoteFileSort.swift b/Frameworks/TermindPresentation/RemoteFileSort.swift new file mode 100644 index 00000000..af6395d3 --- /dev/null +++ b/Frameworks/TermindPresentation/RemoteFileSort.swift @@ -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) + } + } +} diff --git a/Frameworks/TermindPresentation/SFTP/RemoteFile+Presentation.swift b/Frameworks/TermindPresentation/SFTP/RemoteFile+Presentation.swift index 372044a6..ca271b2d 100644 --- a/Frameworks/TermindPresentation/SFTP/RemoteFile+Presentation.swift +++ b/Frameworks/TermindPresentation/SFTP/RemoteFile+Presentation.swift @@ -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") diff --git a/Frameworks/TermindPresentation/UnixPermissionFields.swift b/Frameworks/TermindPresentation/UnixPermissionFields.swift index 71a6c44b..76529d2d 100644 --- a/Frameworks/TermindPresentation/UnixPermissionFields.swift +++ b/Frameworks/TermindPresentation/UnixPermissionFields.swift @@ -1,32 +1,6 @@ import SwiftUI import TermindDomain -public struct UnixPermissionFields: View { - @Binding private var draft: SFTPPermissionDraft - public init(draft: Binding) { _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 { - Binding(get: { draft.contains(mask) }, set: { draft.set(mask, enabled: $0) }) - } -} - public struct UnixPermissionGrid: View { @Binding private var draft: SFTPPermissionDraft private let horizontalSpacing: CGFloat diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPRemoteTransferPlanner.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPRemoteTransferPlanner.swift new file mode 100644 index 00000000..b29152f1 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPRemoteTransferPlanner.swift @@ -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 + 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 = [] + 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 + 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 + 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 = [] + 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, + 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 + "/") + } +} diff --git a/TFI/App/MobileRootView.swift b/TFI/App/MobileRootView.swift index 14e7b581..807880c9 100644 --- a/TFI/App/MobileRootView.swift +++ b/TFI/App/MobileRootView.swift @@ -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") } diff --git a/TFI/Application/Transfers/MobileSFTPSessionModel.swift b/TFI/Application/Transfers/MobileSFTPSessionModel.swift index 5bea8a3d..60a7914a 100644 --- a/TFI/Application/Transfers/MobileSFTPSessionModel.swift +++ b/TFI/Application/Transfers/MobileSFTPSessionModel.swift @@ -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 diff --git a/TFI/Features/SFTP/MobileRemoteFileEditor.swift b/TFI/Features/SFTP/MobileRemoteFileEditor.swift index 6fc80f58..d15b9e5a 100644 --- a/TFI/Features/SFTP/MobileRemoteFileEditor.swift +++ b/TFI/Features/SFTP/MobileRemoteFileEditor.swift @@ -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()) + } } }) } diff --git a/TFI/Features/SFTP/MobileSFTPBrowser.swift b/TFI/Features/SFTP/MobileSFTPBrowser.swift index 3aaed947..4b052a0d 100644 --- a/TFI/Features/SFTP/MobileSFTPBrowser.swift +++ b/TFI/Features/SFTP/MobileSFTPBrowser.swift @@ -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")) } } diff --git a/TFI/Features/SFTP/MobileSFTPItemType.swift b/TFI/Features/SFTP/MobileSFTPItemType.swift new file mode 100644 index 00000000..f2a07627 --- /dev/null +++ b/TFI/Features/SFTP/MobileSFTPItemType.swift @@ -0,0 +1,5 @@ +import UniformTypeIdentifiers + +nonisolated extension UTType { + static let termindSFTPItems = UTType(exportedAs: "com.akinokaede.termind.sftp-items") +} diff --git a/TFI/Features/Settings/MobileSkillSettings.swift b/TFI/Features/Settings/MobileSkillSettings.swift index a4b40542..e7a0d509 100644 --- a/TFI/Features/Settings/MobileSkillSettings.swift +++ b/TFI/Features/Settings/MobileSkillSettings.swift @@ -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) } } diff --git a/TFI/Features/Transfers/MobileTransfersView.swift b/TFI/Features/Transfers/MobileTransfersView.swift index 5a8bb7c3..b584517b 100644 --- a/TFI/Features/Transfers/MobileTransfersView.swift +++ b/TFI/Features/Transfers/MobileTransfersView.swift @@ -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 } } diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 4deb0d5b..40eeb0dd 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -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? private var uploadEnqueueTask: Task? private var uploadNoticeTask: Task? + private var remoteTransferPreparationTask: Task? + 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 diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index 5cff33f1..a043c2d3 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -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 } diff --git a/TFI/Info.plist b/TFI/Info.plist index c85f7b9f..9db24e6e 100644 --- a/TFI/Info.plist +++ b/TFI/Info.plist @@ -31,5 +31,18 @@ Termind uses Apple Speech Recognition to turn your recording into text. Depending on system settings and language support, Apple may process the audio online. NSCameraUsageDescription Termind uses the camera to attach photos to your chat. + UTExportedTypeDeclarations + + + UTTypeConformsTo + + public.data + + UTTypeDescription + Termind SFTP Items + UTTypeIdentifier + com.akinokaede.termind.sftp-items + + diff --git a/TFI/UI/Components/MobileNativeList.swift b/TFI/UI/Components/MobileNativeList.swift index bd83d7ff..ea8d49d3 100644 --- a/TFI/UI/Components/MobileNativeList.swift +++ b/TFI/UI/Components/MobileNativeList.swift @@ -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? private weak var refreshControl: UIRefreshControl? @@ -100,6 +105,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { func update( listView: ListView, 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 ) } diff --git a/TFITests/MobileNavigationParityTests.swift b/TFITests/MobileNavigationParityTests.swift index 4ba48326..53b61a52 100644 --- a/TFITests/MobileNavigationParityTests.swift +++ b/TFITests/MobileNavigationParityTests.swift @@ -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\"")) diff --git a/TFITests/MobileSFTPSessionTests.swift b/TFITests/MobileSFTPSessionTests.swift index 20fa101c..d4d08cf1 100644 --- a/TFITests/MobileSFTPSessionTests.swift +++ b/TFITests/MobileSFTPSessionTests.swift @@ -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() diff --git a/TFM/DesignSystem/MenuSymbol.swift b/TFM/DesignSystem/MenuSymbol.swift index 0911fe06..d7192f9d 100644 --- a/TFM/DesignSystem/MenuSymbol.swift +++ b/TFM/DesignSystem/MenuSymbol.swift @@ -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, diff --git a/TFM/Features/SFTP/SFTPBrowserView.swift b/TFM/Features/SFTP/SFTPBrowserView.swift index 06cbf176..c55a549d 100644 --- a/TFM/Features/SFTP/SFTPBrowserView.swift +++ b/TFM/Features/SFTP/SFTPBrowserView.swift @@ -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: " · ") } } diff --git a/TFM/Features/SFTP/SFTPSessionModel.swift b/TFM/Features/SFTP/SFTPSessionModel.swift index bab052c3..d3f607e0 100644 --- a/TFM/Features/SFTP/SFTPSessionModel.swift +++ b/TFM/Features/SFTP/SFTPSessionModel.swift @@ -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 = [] 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() diff --git a/TFM/Features/Settings/SkillEditorSheet.swift b/TFM/Features/Settings/SkillEditorSheet.swift index 46239539..73cfea4d 100644 --- a/TFM/Features/Settings/SkillEditorSheet.swift +++ b/TFM/Features/Settings/SkillEditorSheet.swift @@ -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) diff --git a/TFM/Features/StatusBar/StatusBarView.swift b/TFM/Features/StatusBar/StatusBarView.swift index c8c489f2..bc4ccde9 100644 --- a/TFM/Features/StatusBar/StatusBarView.swift +++ b/TFM/Features/StatusBar/StatusBarView.swift @@ -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 diff --git a/TFMTests/SFTPFeatureTests.swift b/TFMTests/SFTPFeatureTests.swift index 46179c3d..21c48416 100644 --- a/TFMTests/SFTPFeatureTests.swift +++ b/TFMTests/SFTPFeatureTests.swift @@ -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() diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 050b3c28..f4b93bb7 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -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 */ diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d4878bdc..ded2e8c5 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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" } }, {