Files
Termind/Frameworks/TermindPresentation/SFTP/SFTPEditUploadOverlay.swift
T

230 lines
9.1 KiB
Swift

import SwiftUI
import TermindApplication
import TermindDomain
/// Hosts own placement, connections and file presentation. Only dirty records reach this view.
public struct SFTPEditUploadOverlay: View {
let files: [SFTPEditedFile]
let transfers: [SFTPTransfer]
let connected: Bool
let upload: (UUID, Bool) async -> Bool
let resume: (UUID) -> Void
let open: (URL) -> Void
let discard: (UUID) async -> Void
let keepLocalCopy: ((URL) -> Void)?
@State private var conflictID: UUID?
@State private var errorDetails: String?
@ScaledMetric private var rowHeight = 76.0
public init(
files: [SFTPEditedFile], transfers: [SFTPTransfer], connected: Bool,
upload: @escaping (UUID, Bool) async -> Bool,
resume: @escaping (UUID) -> Void, open: @escaping (URL) -> Void,
discard: @escaping (UUID) async -> Void,
keepLocalCopy: ((URL) -> Void)? = nil
) {
self.files = files
self.transfers = transfers
self.connected = connected
self.upload = upload
self.resume = resume
self.open = open
self.discard = discard
self.keepLocalCopy = keepLocalCopy
}
public var body: some View {
if !files.isEmpty {
rows
.frame(height: rowHeight * Double(min(files.count, 3)) + Double(min(files.count, 3) - 1))
.frame(maxWidth: 540)
.modifier(SFTPEditGlassSurface())
.confirmationDialog(
"The remote file has changed",
isPresented: Binding(get: { conflictID != nil }, set: { if !$0 { conflictID = nil } }),
titleVisibility: .visible
) {
if let id = conflictID {
Button("Overwrite Remote File", role: .destructive) { send(id, replacing: true) }
}
Button("Cancel", role: .cancel) { conflictID = nil }
} message: {
Text("The remote file changed or could not be verified. Replace it with your saved local changes?")
}
.alert(
"SFTP Error",
isPresented: Binding(
get: { errorDetails != nil }, set: { if !$0 { errorDetails = nil } })
) {
Button("OK", role: .cancel) { errorDetails = nil }
} message: {
Text(errorDetails ?? "")
}
}
}
@ViewBuilder private var rows: some View {
#if os(iOS)
List(files) { file in
row(file).frame(height: rowHeight)
.overlay(alignment: .bottom) {
if file.id != files.last?.id { Divider().padding(.horizontal, 12) }
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if canDiscard(file) {
Button("Discard", role: .destructive) { Task { await discard(file.id) } }
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.clear)
.environment(\.defaultMinListRowHeight, rowHeight)
#else
ScrollView {
VStack(spacing: 0) {
ForEach(files) { file in
row(file).frame(height: rowHeight)
if file.id != files.last?.id { Divider() }
}
}
}
.scrollContentBackground(.hidden)
.background(Color.clear)
#endif
}
private func canDiscard(_ file: SFTPEditedFile) -> Bool {
guard !file.isPreparing else { return false }
guard let id = file.transferID else { return true }
return transfers.first { $0.id == id }?.state == .paused
}
private func row(_ file: SFTPEditedFile) -> some View {
let transfer = transfers.first { $0.id == file.transferID }
return HStack(spacing: 10) {
Image(systemName: file.displayedError == nil ? "doc" : "exclamationmark.circle")
.font(.system(size: 20, weight: .regular))
.foregroundStyle(file.displayedError == nil ? AnyShapeStyle(.secondary) : AnyShapeStyle(.orange))
.frame(width: 24)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text(file.name).font(.subheadline.weight(.medium)).lineLimit(1).truncationMode(.middle)
Text(file.remotePath).font(.caption).foregroundStyle(.secondary)
.lineLimit(1).truncationMode(.middle)
if let transfer, transfer.state == .running || transfer.state == .queued {
ProgressView(
value: transfer.byteCount > 0
? min(1, Double(transfer.transferredBytes) / Double(transfer.byteCount)) : nil
)
.accessibilityLabel("Uploading changes")
} else if let error = file.displayedError {
Text(error).font(.caption2).foregroundStyle(.red).lineLimit(1).help(error)
} else if !connected {
Text("Reconnect to upload changes").font(.caption2).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
action(file, transfer: transfer).controlSize(.small)
#if os(macOS)
Menu {
fileActions(file)
} label: {
Image(systemName: "ellipsis").frame(width: 16, height: 28)
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.accessibilityLabel("More")
#endif
}
.padding(.horizontal, 12)
.contentShape(Rectangle())
.contextMenu { fileActions(file) }
}
@ViewBuilder private func fileActions(_ file: SFTPEditedFile) -> some View {
if let error = file.displayedError {
Button("Error Details", systemImage: "exclamationmark.circle") { errorDetails = error }
Divider()
}
Button("Open Local Copy", systemImage: "arrow.up.right") { open(file.localURL) }
#if os(macOS)
// ShareLink installs a hosted sharing control in NSMenu. Mixing it with ordinary
// menu items can offset the native selection highlight on subsequent items.
Button("Keep Local Copy", systemImage: "square.and.arrow.up") { keepLocalCopy?(file.localURL) }
.disabled(keepLocalCopy == nil)
#else
ShareLink(item: file.localURL) { Label("Keep Local Copy", systemImage: "square.and.arrow.up") }
#endif
Divider()
Button("Discard", systemImage: "xmark", role: .destructive) { Task { await discard(file.id) } }
.disabled(!canDiscard(file))
}
@ViewBuilder
private func action(_ file: SFTPEditedFile, transfer: SFTPTransfer?) -> some View {
if file.isPreparing {
ProgressView().controlSize(.small)
} else if let transfer {
if transfer.state == .paused {
Button("Resume", systemImage: "play.fill") { resume(transfer.id) }
.disabled(!connected || file.isInvalidated)
} else {
ProgressView().controlSize(.small)
}
} else {
Button {
send(file.id, replacing: false)
} label: {
Text(file.displayedError == nil ? "Upload" : "Retry")
.font(.caption.weight(.semibold))
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
.accessibilityLabel(file.displayedError == nil ? Text("Upload") : Text("Retry"))
.buttonStyle(.borderedProminent)
.disabled(!connected || file.isInvalidated)
}
}
private func send(_ id: UUID, replacing: Bool) {
conflictID = nil
Task {
if await upload(id, replacing) { conflictID = id }
}
}
}
private struct SFTPEditGlassSurface: ViewModifier {
func body(content: Content) -> some View {
let shape = RoundedRectangle(cornerRadius: 20)
return
content
.background { SFTPEditGlassBackground(shape: shape) }
.clipShape(shape)
.overlay {
shape
.strokeBorder(.primary.opacity(0.10), lineWidth: 1)
.allowsHitTesting(false)
}
.shadow(color: .black.opacity(0.16), radius: 16, y: 6)
}
}
private struct SFTPEditGlassBackground: View {
let shape: RoundedRectangle
@ViewBuilder var body: some View {
if #available(macOS 26.0, iOS 26.0, *) {
Color.clear.glassEffect(.regular, in: shape)
} else {
shape.fill(.regularMaterial)
}
}
}