Remove the transfer forwarding adapter, borrowed SFTP channel scheduling, legacy assistant send entry points, unreachable Telnet password state, unused Tailscale APIs, and unused host deletion impact projections. Migrate behavioral tests to current paths and cover SFTP passphrase saving. Validation: Swift formatting and shared-copy/localization checks passed. The macOS test run reported failures in ConnectionStatusIconLayoutTests and AgentRuntimeTests; investigation and iOS validation remain pending.
1495 lines
58 KiB
Swift
1495 lines
58 KiB
Swift
import ListViewKit
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
import TermindRemoteInfrastructure
|
|
import UniformTypeIdentifiers
|
|
|
|
#if canImport(AppKit)
|
|
import AppKit
|
|
#endif
|
|
|
|
nonisolated struct SFTPFileListItem: Identifiable, Hashable, Sendable {
|
|
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: ID {
|
|
if let row { return .file(row.id) }
|
|
return .parent
|
|
}
|
|
}
|
|
|
|
nonisolated struct SFTPFileScrollRequest: Equatable, Sendable {
|
|
nonisolated enum Position: Equatable, Sendable {
|
|
case nearest
|
|
case top
|
|
}
|
|
|
|
let token = UUID()
|
|
let targetID: RemoteFile.ID
|
|
let position: Position
|
|
}
|
|
|
|
struct SFTPFileNativeList: NSViewRepresentable {
|
|
typealias NSViewType = ListView<SFTPFileListItem>
|
|
typealias Renderer = @MainActor (SFTPFileListItem) -> AnyView
|
|
|
|
let items: [SFTPFileListItem]
|
|
let scrollRequest: SFTPFileScrollRequest?
|
|
let bottomContentInset: CGFloat
|
|
let onBackgroundClick: @MainActor () -> Void
|
|
let renderer: Renderer
|
|
|
|
@Environment(\.locale) private var locale
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
init(
|
|
items: [SFTPFileListItem],
|
|
scrollRequest: SFTPFileScrollRequest?,
|
|
bottomContentInset: CGFloat = 0,
|
|
onBackgroundClick: @escaping @MainActor () -> Void,
|
|
@ViewBuilder renderer: @escaping @MainActor (SFTPFileListItem) -> some View
|
|
) {
|
|
self.items = items
|
|
self.scrollRequest = scrollRequest
|
|
self.bottomContentInset = bottomContentInset
|
|
self.onBackgroundClick = onBackgroundClick
|
|
self.renderer = { AnyView(renderer($0)) }
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator { Coordinator() }
|
|
|
|
func makeNSView(context: Context) -> NSViewType {
|
|
context.coordinator.makeListView()
|
|
}
|
|
|
|
func updateNSView(_ listView: NSViewType, context: Context) {
|
|
if listView.contentInsets.bottom != bottomContentInset {
|
|
listView.contentInsets.bottom = bottomContentInset
|
|
}
|
|
context.coordinator.update(
|
|
listView: listView,
|
|
items: items,
|
|
scrollRequest: scrollRequest,
|
|
onBackgroundClick: onBackgroundClick,
|
|
renderer: renderer,
|
|
locale: locale,
|
|
colorScheme: colorScheme
|
|
)
|
|
}
|
|
|
|
static func dismantleNSView(_ nsView: NSViewType, coordinator: Coordinator) {
|
|
coordinator.stopObservingClicks()
|
|
}
|
|
|
|
@MainActor
|
|
final class Coordinator: NSObject {
|
|
private let content = HostingListContent<SFTPFileListItem>()
|
|
private var lastScrollToken: UUID?
|
|
private var onBackgroundClick: (@MainActor () -> Void)?
|
|
private weak var listView: NSViewType?
|
|
private var clickMonitor: Any?
|
|
|
|
func makeListView() -> NSViewType {
|
|
let listView = NSViewType()
|
|
self.listView = listView
|
|
listView.rowAnimator = ListBouncyAnimator(style: .regular)
|
|
listView.estimatedRowHeight = 48
|
|
listView.rows {
|
|
ListRow(HostingListRow.self)
|
|
.height { _, _ in 48 }
|
|
.configure { [weak self] row, item, _ in
|
|
self?.configure(row, item: item)
|
|
}
|
|
}
|
|
clickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) {
|
|
[weak self] event in
|
|
self?.observeClick(event)
|
|
return event
|
|
}
|
|
return listView
|
|
}
|
|
|
|
func update(
|
|
listView: NSViewType,
|
|
items: [SFTPFileListItem],
|
|
scrollRequest: SFTPFileScrollRequest?,
|
|
onBackgroundClick: @escaping @MainActor () -> Void,
|
|
renderer: @escaping Renderer,
|
|
locale: Locale,
|
|
colorScheme: ColorScheme
|
|
) {
|
|
self.onBackgroundClick = onBackgroundClick
|
|
content.update(
|
|
listView, items: items, renderer: renderer, locale: locale,
|
|
colorScheme: colorScheme, animated: true
|
|
)
|
|
|
|
if let request = scrollRequest, request.token != lastScrollToken {
|
|
lastScrollToken = request.token
|
|
listView.scrollToRow(
|
|
with: SFTPFileListItem.ID.file(request.targetID),
|
|
at: request.position == .top ? .top : .nearest,
|
|
animated: false
|
|
)
|
|
}
|
|
}
|
|
|
|
private func configure(_ row: HostingListRow, item: SFTPFileListItem) {
|
|
content.configure(row, item: item)
|
|
}
|
|
|
|
func stopObservingClicks() {
|
|
if let clickMonitor {
|
|
NSEvent.removeMonitor(clickMonitor)
|
|
self.clickMonitor = nil
|
|
}
|
|
}
|
|
|
|
private func observeClick(_ event: NSEvent) {
|
|
guard let listView, event.window === listView.window else { return }
|
|
let point = listView.convert(event.locationInWindow, from: nil)
|
|
guard listView.bounds.contains(point) else { return }
|
|
var hit = listView.hitTest(point)
|
|
while let current = hit {
|
|
if current is ListRowView { return }
|
|
if current is NSScroller { return }
|
|
if current === listView { break }
|
|
hit = current.superview
|
|
}
|
|
onBackgroundClick?()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SFTPBrowserView: View {
|
|
nonisolated private enum Layout: Equatable, Sendable {
|
|
case full
|
|
case medium
|
|
case compact
|
|
|
|
init(width: CGFloat) {
|
|
if width >= 760 { self = .full } else if width >= 520 { self = .medium } else { self = .compact }
|
|
}
|
|
}
|
|
|
|
let sftpChannel: SFTPChannelModel
|
|
let isPaneFocused: Bool
|
|
let focusPane: () -> Void
|
|
let externalFileDrop: LocalFileDropRequest?
|
|
let externalRemoteDrop: SFTPRemoteDropRequest?
|
|
let closePane: () -> Void
|
|
|
|
@Environment(AppModel.self) private var app
|
|
@Environment(\.locale) private var locale
|
|
@Environment(WindowModel.self) private var window
|
|
@Environment(\.controlActiveState) private var controlActiveState
|
|
@State private var newFolderPresented = false
|
|
@State private var newFolderName = ""
|
|
@State private var renameTarget: RemoteFile?
|
|
@State private var renameName = ""
|
|
@State private var permissionTarget: RemoteFile?
|
|
@State private var deleteTargets: [RemoteFile] = []
|
|
@State private var conflictPresented = false
|
|
@State private var selectionAnchor: RemoteFile.ID?
|
|
@State private var selectionCursor: RemoteFile.ID?
|
|
@State private var hoveredFileID: RemoteFile.ID?
|
|
@State private var editsPath = false
|
|
@State private var fileScrollRequest: SFTPFileScrollRequest?
|
|
@FocusState private var listFocused: Bool
|
|
@FocusState private var pathFocused: Bool
|
|
|
|
@ScaledMetric private var editRowHeight = 76.0
|
|
private var editedFiles: [SFTPEditedFile] { app.sftpEdits.model.modifiedFiles(for: sftpChannel.hostID) }
|
|
private var editOverlayHeight: CGFloat { (editRowHeight + 1) * Double(min(3, editedFiles.count)) }
|
|
|
|
private var editUploadOverlay: some View {
|
|
SFTPEditUploadOverlay(
|
|
files: editedFiles, transfers: app.sftpTransfers.sftpTransfers,
|
|
connected: sftpChannel.status == .connected,
|
|
upload: { id, replacing in
|
|
await sftpChannel.uploadEdit(id, using: app.sftpEdits, replacing: replacing)
|
|
},
|
|
resume: { app.sftpTransfers.resume($0) },
|
|
open: { NSWorkspace.shared.open($0) },
|
|
discard: { await app.sftpEdits.discard($0) },
|
|
keepLocalCopy: { source in
|
|
SFTPLocalCopyExporter.present(for: source) {
|
|
sftpChannel.operationError = $0.localizedDescription
|
|
}
|
|
}
|
|
)
|
|
.padding(.horizontal, 12)
|
|
.padding(.bottom, Metrics.StatusBar.overlayClearance)
|
|
}
|
|
|
|
private var deleteConfirmationMessage: String {
|
|
if deleteTargets.count == 1 {
|
|
return AppLocalization.resolve(AppStrings.SFTP.recursiveDeletionWarning, locale: locale)
|
|
}
|
|
let paths = deleteTargets.prefix(6).map { sftpChannel.displayPath($0.path) }
|
|
.joined(separator: "\n")
|
|
let remainder = max(0, deleteTargets.count - 6)
|
|
return AppStrings.SFTP.deletionMessage(paths: paths, remainingCount: remainder, locale: locale)
|
|
}
|
|
|
|
private var conflictTitle: String {
|
|
let count = sftpChannel.conflictCount
|
|
return String(localized: AppStrings.SFTP.existingItemsMessage(count: count))
|
|
}
|
|
|
|
/// The density is read from the *proposal* rather than from the browser's
|
|
/// own frame.
|
|
///
|
|
/// `onGeometryChange` reports the size a view settled at, and this one's
|
|
/// floor is its widest arrangement — so measuring it meant deciding the
|
|
/// arrangement from a number that arrangement had produced, and committing
|
|
/// the answer a layout pass late. A `GeometryReader` reports what the pane
|
|
/// offered no matter what the content does with it, which makes the
|
|
/// threshold a single clean snap instead of one frame drawn at the old
|
|
/// density and the new width.
|
|
///
|
|
/// The pane floor is 160pt and this chrome cannot compress much below
|
|
/// 260pt, so the content is clipped: a pane dragged past useful truncates
|
|
/// its own path bar rather than drawing over its neighbour.
|
|
var body: some View {
|
|
GeometryReader { proxy in
|
|
let layout = Layout(width: proxy.size.width)
|
|
browser(layout: layout)
|
|
.frame(width: proxy.size.width, height: proxy.size.height)
|
|
.clipped()
|
|
}
|
|
// The file list is held focused by `listFocused`, and SwiftUI restores
|
|
// that focus whenever the view rebuilds — which typing in the titlebar
|
|
// Find field does, because it narrows this very list. Standing the list
|
|
// down keeps the caret where the user is typing.
|
|
.onChange(of: window.isToolbarFindFocused) { _, isFindFocused in
|
|
if isFindFocused { listFocused = false }
|
|
}
|
|
// And again on every restoration, not only on the first. The line above
|
|
// fires once, when the field takes the keyboard; the list then takes it
|
|
// back on the *next* keystroke's rebuild, and on every one after that,
|
|
// with nothing left to notice. Watching the focus itself is what makes
|
|
// the stand-down hold for as long as the field is being typed into.
|
|
.onChange(of: listFocused) { _, isListFocused in
|
|
if isListFocused, window.isToolbarFindFocused { listFocused = false }
|
|
}
|
|
}
|
|
|
|
private func browser(layout: Layout) -> some View {
|
|
@Bindable var sftpChannel = sftpChannel
|
|
return ZStack {
|
|
VStack(spacing: 0) {
|
|
VStack(spacing: 0) {
|
|
pathBar(layout: layout)
|
|
columnHeader(layout: layout)
|
|
}
|
|
.background(TermindColor.windowSurface)
|
|
|
|
browserContent(layout: layout)
|
|
.overlay(alignment: .bottom) { editUploadOverlay }
|
|
}
|
|
|
|
if sftpChannel.connectionJourney.isPresented {
|
|
ConnectionJourneyView(
|
|
journey: sftpChannel.connectionJourney,
|
|
authenticationPrompt: sftpChannel.pendingAuthentication,
|
|
hostKey: sftpChannel.pendingHostKey,
|
|
submitAuthentication: {
|
|
identityID, username, password, passphrase, keyID, save in
|
|
sftpChannel.continueAuthentication(
|
|
identityID: identityID, username: username, password: password,
|
|
passphrase: passphrase, keyID: keyID, save: save, using: app.hostLibrary
|
|
)
|
|
},
|
|
continueHostKey: {
|
|
sftpChannel.continueWithTemporaryHostKey($0)
|
|
},
|
|
trustHostKey: { sftpChannel.trustAndConnect($0, using: app.hostLibrary) },
|
|
denyHostKey: {
|
|
cancelConnection()
|
|
},
|
|
retry: { sftpChannel.connect(using: app.hostLibrary) },
|
|
cancel: cancelConnection,
|
|
reconnectState: sftpChannel.reconnectState,
|
|
retryReconnectNow: sftpChannel.retryReconnectNow,
|
|
stopReconnecting: sftpChannel.stopReconnecting,
|
|
securityKeySigning: sftpChannel.connection.securityKeySigning,
|
|
keyboardAuthentication: sftpChannel.connection.keyboardAuthentication
|
|
)
|
|
.zIndex(10)
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
|
Task { await app.sftpEdits.recheck() }
|
|
}
|
|
.contextMenu { browserLocationMenu }
|
|
.background(TermindColor.windowSurface)
|
|
.onAppear {
|
|
sftpChannel.bindTransfers(to: app.sftpTransfers)
|
|
sftpChannel.connectIfNeeded(using: app.hostLibrary)
|
|
sftpChannel.startAutomaticRefresh()
|
|
}
|
|
.alert(AppStrings.SFTP.newFolder, isPresented: $newFolderPresented) {
|
|
TextField(AppStrings.SFTP.folderName.swiftUIKey, text: $newFolderName.singleLineText, axis: .horizontal)
|
|
.singleLinePaste()
|
|
.lineLimit(1)
|
|
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {}
|
|
Button(AppStrings.Common.create.swiftUIKey) {
|
|
let name = newFolderName
|
|
newFolderName = ""
|
|
Task { await sftpChannel.createFolder(named: name) }
|
|
}
|
|
}
|
|
.alert(
|
|
AppStrings.Common.rename,
|
|
isPresented: Binding(
|
|
get: { renameTarget != nil },
|
|
set: { if !$0 { renameTarget = nil } }
|
|
)
|
|
) {
|
|
TextField(AppStrings.Common.name.swiftUIKey, text: $renameName.singleLineText, axis: .horizontal)
|
|
.singleLinePaste().lineLimit(1)
|
|
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { renameTarget = nil }
|
|
Button(AppStrings.Common.rename.swiftUIKey) {
|
|
guard let target = renameTarget else { return }
|
|
let name = renameName
|
|
renameTarget = nil
|
|
Task { await sftpChannel.rename(target, to: name) }
|
|
}
|
|
}
|
|
.sheet(item: $permissionTarget) { file in
|
|
SFTPEditPermissionsSheet(
|
|
file: file, displayedPath: sftpChannel.displayPath(file.path)
|
|
) { permissions in
|
|
await sftpChannel.updatePermissions(for: file, to: permissions)
|
|
}
|
|
.allowsApplicationTerminationWhenModal()
|
|
}
|
|
.confirmationDialog(
|
|
deleteTargets.count == 1
|
|
? AppStrings.SFTP.deleteConfirmation(
|
|
deleteTargets.first.map { sftpChannel.displayPath($0.path) } ?? ""
|
|
)
|
|
: AppStrings.SFTP.deleteItemsConfirmation(deleteTargets.count),
|
|
isPresented: Binding(
|
|
get: { !deleteTargets.isEmpty },
|
|
set: { if !$0 { deleteTargets = [] } }
|
|
), titleVisibility: .visible
|
|
) {
|
|
Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) {
|
|
let targets = deleteTargets
|
|
deleteTargets = []
|
|
Task { await sftpChannel.delete(targets) }
|
|
}
|
|
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { deleteTargets = [] }
|
|
} message: {
|
|
Text(deleteConfirmationMessage)
|
|
}
|
|
.confirmationDialog(
|
|
conflictTitle,
|
|
isPresented: $conflictPresented, titleVisibility: .visible
|
|
) {
|
|
Button(AppStrings.SFTP.replace.swiftUIKey) { enqueue(.replace) }
|
|
Button(AppStrings.SFTP.skipExisting.swiftUIKey) { enqueue(.skip) }
|
|
Button(AppStrings.SFTP.keepBoth.swiftUIKey) { enqueue(.keepBoth) }
|
|
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {
|
|
sftpChannel.cancelPendingBatch()
|
|
}
|
|
} message: {
|
|
Text(AppStrings.SFTP.applyToAllConflicts)
|
|
}
|
|
.alert(
|
|
AppStrings.SFTP.sftpError,
|
|
isPresented: Binding(
|
|
get: { sftpChannel.operationError != nil },
|
|
set: { if !$0 { sftpChannel.operationError = nil } }
|
|
)
|
|
) {
|
|
Button(AppStrings.Common.ok.swiftUIKey) { sftpChannel.operationError = nil }
|
|
} message: {
|
|
Text(sftpChannel.operationError ?? "")
|
|
}
|
|
.background {
|
|
Button("") { beginPathEditing() }
|
|
.termindShortcut("l")
|
|
.disabled(sftpChannel.connectionJourney.isPresented)
|
|
.opacity(0)
|
|
.frame(width: 0, height: 0)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
private func cancelConnection() {
|
|
sftpChannel.disconnect()
|
|
closePane()
|
|
}
|
|
|
|
private func pathBar(layout: Layout) -> some View {
|
|
@Bindable var sftpChannel = sftpChannel
|
|
return VStack(spacing: 0) {
|
|
HStack(spacing: Metrics.Space.m) {
|
|
HStack(spacing: Metrics.Space.xs) {
|
|
navigationButton("chevron.left", help: AppStrings.Common.back, enabled: sftpChannel.canGoBack) {
|
|
sftpChannel.goBack()
|
|
}
|
|
navigationButton("chevron.right", help: AppStrings.SFTP.forward, enabled: sftpChannel.canGoForward)
|
|
{
|
|
sftpChannel.goForward()
|
|
}
|
|
}
|
|
|
|
if editsPath {
|
|
TextField(
|
|
AppStrings.SFTP.remotePath.swiftUIKey, text: $sftpChannel.pathText.singleLineText,
|
|
axis: .horizontal
|
|
)
|
|
.singleLinePaste().lineLimit(1)
|
|
.textFieldStyle(.plain)
|
|
.font(.body.monospaced())
|
|
.focused($pathFocused)
|
|
.onSubmit { commitPathEditing() }
|
|
.onKeyPress(.escape) {
|
|
cancelPathEditing()
|
|
return .handled
|
|
}
|
|
} else {
|
|
breadcrumbs(layout: layout)
|
|
}
|
|
|
|
Button(action: chooseUploadItems) {
|
|
if layout == .full {
|
|
Label(AppStrings.SFTP.upload.swiftUIKey, systemImage: "arrow.up.doc")
|
|
} else {
|
|
Image(systemName: "arrow.up.doc")
|
|
}
|
|
}
|
|
.disabled(sftpChannel.status != .connected)
|
|
.help(AppStrings.SFTP.upload.swiftUIKey)
|
|
|
|
actionsMenu(layout: layout)
|
|
}
|
|
.padding(.horizontal, Metrics.Space.l)
|
|
.frame(height: 48)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.onChange(of: pathFocused) { wasFocused, isFocused in
|
|
guard wasFocused, !isFocused, editsPath else { return }
|
|
cancelPathEditing()
|
|
}
|
|
}
|
|
|
|
private func navigationButton(
|
|
_ symbol: String, help: LocalizedStringResource, enabled: Bool, action: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: action) {
|
|
Image(systemName: symbol)
|
|
.font(.body.weight(.semibold))
|
|
.frame(width: 28, height: 28)
|
|
.contentShape(.rect)
|
|
}
|
|
.disabled(!enabled)
|
|
.help(help)
|
|
}
|
|
|
|
private func actionsMenu(layout: Layout) -> some View {
|
|
Menu {
|
|
Button(AppStrings.SFTP.newFolder.swiftUIKey, systemImage: MenuSymbol.newFolder) {
|
|
newFolderPresented = true
|
|
}
|
|
.disabled(sftpChannel.status != .connected)
|
|
Button(AppStrings.Common.paste.swiftUIKey, systemImage: MenuSymbol.paste) { pasteRemoteItems() }
|
|
.disabled(sftpChannel.status != .connected || !canPasteRemoteItems)
|
|
Divider()
|
|
if sftpChannel.showsHiddenFiles {
|
|
Button(AppStrings.SFTP.hideHiddenFiles.swiftUIKey, systemImage: MenuSymbol.hideHiddenFiles) {
|
|
sftpChannel.showsHiddenFiles = false
|
|
}
|
|
} else {
|
|
Button(AppStrings.SFTP.showHiddenFiles.swiftUIKey, systemImage: MenuSymbol.showHiddenFiles) {
|
|
sftpChannel.showsHiddenFiles = true
|
|
}
|
|
}
|
|
Button(AppStrings.SFTP.editPath.swiftUIKey, systemImage: MenuSymbol.editPath) { beginPathEditing() }
|
|
Button(AppStrings.Common.refresh.swiftUIKey, systemImage: MenuSymbol.refresh) { sftpChannel.reloadNow() }
|
|
.disabled(sftpChannel.status != .connected || sftpChannel.isLoading)
|
|
} label: {
|
|
if layout == .full {
|
|
HStack(spacing: Metrics.Space.s) {
|
|
Image(systemName: MenuSymbol.actions)
|
|
Text(AppStrings.Common.actions)
|
|
Image(systemName: "chevron.down")
|
|
.font(.caption.weight(.semibold))
|
|
}
|
|
} else {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
}
|
|
.help(AppStrings.Common.actions.swiftUIKey)
|
|
}
|
|
|
|
private func breadcrumbs(layout: Layout) -> some View {
|
|
let components = RemotePath.displayedComponents(
|
|
of: sftpChannel.path, home: sftpChannel.homeResolution
|
|
)
|
|
let tailCount =
|
|
switch layout {
|
|
case .full: 3
|
|
case .medium: 2
|
|
case .compact: 1
|
|
}
|
|
let collapses = components.count > tailCount + 1
|
|
let visible = collapses ? Array(components.suffix(tailCount)) : Array(components.dropFirst())
|
|
let hidden =
|
|
collapses
|
|
? Array(components.dropFirst().dropLast(tailCount))
|
|
: []
|
|
|
|
return Button(action: beginPathEditing) {
|
|
HStack(spacing: Metrics.Space.xs) {
|
|
breadcrumbLabel(components[0], isRoot: true)
|
|
|
|
if !hidden.isEmpty {
|
|
breadcrumbSeparator
|
|
Image(systemName: "ellipsis")
|
|
.frame(width: 24, height: 24)
|
|
}
|
|
|
|
ForEach(visible) { component in
|
|
breadcrumbSeparator
|
|
breadcrumbLabel(component, isRoot: false)
|
|
}
|
|
|
|
Spacer(minLength: Metrics.Space.s)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
.contentShape(.rect)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.help(sftpChannel.displayPath(sftpChannel.path))
|
|
}
|
|
|
|
private var breadcrumbSeparator: some View {
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
|
|
private func breadcrumbLabel(
|
|
_ component: RemotePath.Component, isRoot: Bool
|
|
) -> some View {
|
|
HStack(spacing: Metrics.Space.s) {
|
|
if isRoot { Image(systemName: "folder.fill").foregroundStyle(.tint) }
|
|
Text(component.name)
|
|
.font(.body.weight(component.path == sftpChannel.path ? .medium : .regular))
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
}
|
|
.padding(.horizontal, Metrics.Space.xs)
|
|
.frame(minHeight: 28)
|
|
}
|
|
|
|
private func beginPathEditing() {
|
|
sftpChannel.pathText = sftpChannel.path
|
|
editsPath = true
|
|
Task { @MainActor in pathFocused = true }
|
|
}
|
|
|
|
private func commitPathEditing() {
|
|
sftpChannel.commitPathText()
|
|
editsPath = false
|
|
pathFocused = false
|
|
}
|
|
|
|
private func cancelPathEditing() {
|
|
sftpChannel.pathText = sftpChannel.path
|
|
editsPath = false
|
|
pathFocused = false
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func browserContent(layout: Layout) -> some View {
|
|
ZStack {
|
|
fileList(layout: layout)
|
|
|
|
if sftpChannel.status == .disconnected, !sftpChannel.connectionJourney.isPresented {
|
|
VStack(spacing: Metrics.Space.l) {
|
|
Image(systemName: "folder.badge.questionmark")
|
|
.font(.largeTitle).foregroundStyle(.tertiary)
|
|
Text(AppStrings.Connections.notConnected).foregroundStyle(.secondary)
|
|
Button(AppStrings.Common.connect.swiftUIKey) { sftpChannel.connect(using: app.hostLibrary) }
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(TermindColor.windowSurface)
|
|
} else if sftpChannel.files.isEmpty, sftpChannel.isLoading {
|
|
centeredState(AppStrings.SFTP.loading, symbol: "arrow.triangle.2.circlepath", progress: true)
|
|
.background(TermindColor.windowSurface)
|
|
} else if sftpChannel.files.isEmpty, sftpChannel.status == .connected {
|
|
centeredState(AppStrings.SFTP.thisFolderIsEmpty, symbol: "folder", progress: false)
|
|
.background(TermindColor.windowSurface)
|
|
} else if sftpChannel.visibleRows.isEmpty, !sftpChannel.query.isEmpty {
|
|
ContentUnavailableView.search(text: sftpChannel.query)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(TermindColor.windowSurface)
|
|
}
|
|
}
|
|
.onCopyCommand { remoteCopyProviders(for: sftpChannel.selectedFiles) }
|
|
.onPasteCommand(of: [.termindSFTPItems]) { providers in
|
|
_ = receiveRemoteItemProviders(providers)
|
|
}
|
|
.onChange(of: externalFileDrop?.id) {
|
|
guard let externalFileDrop else { return }
|
|
_ = receiveDroppedURLs(externalFileDrop.urls)
|
|
}
|
|
.onChange(of: externalRemoteDrop?.id) {
|
|
guard let externalRemoteDrop else { return }
|
|
receiveRemoteDrop(externalRemoteDrop.data)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var browserLocationMenu: some View {
|
|
Button(AppStrings.Common.paste.swiftUIKey, systemImage: MenuSymbol.paste) { pasteRemoteItems() }
|
|
.disabled(sftpChannel.status != .connected || !canPasteRemoteItems)
|
|
Divider()
|
|
Button(AppStrings.SFTP.newFolder.swiftUIKey, systemImage: MenuSymbol.newFolder) { newFolderPresented = true }
|
|
.disabled(sftpChannel.status != .connected)
|
|
Button(AppStrings.SFTP.upload.swiftUIKey, systemImage: MenuSymbol.upload) { chooseUploadItems() }
|
|
.disabled(sftpChannel.status != .connected)
|
|
Button(AppStrings.Common.refresh.swiftUIKey, systemImage: MenuSymbol.refresh) { sftpChannel.reloadNow() }
|
|
.disabled(sftpChannel.status != .connected || sftpChannel.isLoading)
|
|
}
|
|
|
|
private func fileList(layout: Layout) -> some View {
|
|
SFTPFileNativeList(
|
|
items: fileListItems(layout: layout),
|
|
scrollRequest: fileScrollRequest,
|
|
bottomContentInset: editedFiles.isEmpty ? 0 : editOverlayHeight + Metrics.StatusBar.overlayClearance + 12,
|
|
onBackgroundClick: clearFileSelection
|
|
) { item in
|
|
fileListRow(item, layout: layout)
|
|
}
|
|
.focusable(!window.isToolbarFindFocused)
|
|
.focusEffectDisabled()
|
|
.focused($listFocused)
|
|
.onKeyPress(.upArrow) {
|
|
moveSelection(
|
|
by: -1, extending: NSEvent.modifierFlags.contains(.shift)
|
|
)
|
|
}
|
|
.onKeyPress(.downArrow) {
|
|
moveSelection(
|
|
by: 1, extending: NSEvent.modifierFlags.contains(.shift)
|
|
)
|
|
}
|
|
.onKeyPress(.return) {
|
|
guard let file = keyboardTarget else { return .ignored }
|
|
primaryAction(file)
|
|
return .handled
|
|
}
|
|
.onKeyPress("a", phases: .down) { press in
|
|
guard press.modifiers.contains(.command) else { return .ignored }
|
|
sftpChannel.selection = Set(sftpChannel.filteredFiles.map(\.id))
|
|
selectionAnchor = sftpChannel.filteredFiles.first?.id
|
|
selectionCursor = sftpChannel.filteredFiles.last?.id
|
|
return .handled
|
|
}
|
|
.background(TermindColor.windowSurface)
|
|
}
|
|
|
|
private func fileListItems(layout: Layout) -> [SFTPFileListItem] {
|
|
let emphasized =
|
|
isPaneFocused && !pathFocused
|
|
&& controlActiveState == .key
|
|
let layoutKey: Int
|
|
switch layout {
|
|
case .full: layoutKey = 2
|
|
case .medium: layoutKey = 1
|
|
case .compact: layoutKey = 0
|
|
}
|
|
var items = sftpChannel.visibleRows.map { row in
|
|
SFTPFileListItem(
|
|
row: row, parentPath: nil,
|
|
isSelected: sftpChannel.selection.contains(row.id),
|
|
isEmphasized: emphasized,
|
|
isHovered: hoveredFileID == row.id,
|
|
layoutKey: layoutKey
|
|
)
|
|
}
|
|
if sftpChannel.path != "/" {
|
|
items.insert(
|
|
SFTPFileListItem(
|
|
row: nil, parentPath: RemotePath.parent(of: sftpChannel.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(verbatim: "..")
|
|
.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 { sftpChannel.navigate(to: path) }
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityLabel(AppStrings.SFTP.parentDirectory.swiftUIKey)
|
|
.accessibilityAddTraits(.isButton)
|
|
.accessibilityAction { sftpChannel.navigate(to: path) }
|
|
}
|
|
|
|
private func clearFileSelection() {
|
|
focusPane()
|
|
sftpChannel.selection.removeAll()
|
|
selectionAnchor = nil
|
|
selectionCursor = nil
|
|
focusListForUserGesture()
|
|
}
|
|
|
|
/// Clicking in this list is the user taking the keyboard back off the
|
|
/// titlebar Find field, and is the only legitimate way the list gains focus
|
|
/// while that field is being typed into.
|
|
///
|
|
/// Saying so here is what lets the guard in `body` treat every *other*
|
|
/// restoration as what it is — a rebuild handing focus back to a view that
|
|
/// nobody asked to focus.
|
|
private func focusListForUserGesture() {
|
|
window.isToolbarFindFocused = false
|
|
listFocused = true
|
|
}
|
|
|
|
private func columnHeader(layout: Layout) -> some View {
|
|
HStack(spacing: 0) {
|
|
sortHeader(AppStrings.Common.name, field: .name)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
if layout != .compact {
|
|
sortHeader(AppStrings.SFTP.dateModified, field: .modified, separated: true)
|
|
.frame(width: 220, alignment: .leading)
|
|
}
|
|
if layout == .full {
|
|
sortHeader(AppStrings.Common.size, field: .size, separated: true)
|
|
.frame(width: 150, alignment: .leading)
|
|
sortHeader(AppStrings.Common.kind, field: .kind, separated: true)
|
|
.frame(width: 150, alignment: .leading)
|
|
}
|
|
}
|
|
.padding(.leading, Metrics.Space.xxl)
|
|
.padding(.trailing, Metrics.Space.xl)
|
|
.frame(height: 40)
|
|
.overlay(alignment: .bottom) { Divider().opacity(0.7) }
|
|
}
|
|
|
|
private func sortHeader(
|
|
_ title: LocalizedStringResource, field: RemoteFileSortField, separated: Bool = false
|
|
) -> some View {
|
|
let isSorted = sftpChannel.sortField == field
|
|
return Button {
|
|
sftpChannel.sort(by: field)
|
|
} label: {
|
|
HStack(spacing: Metrics.Space.s) {
|
|
Text(title).font(.body.weight(isSorted ? .semibold : .regular))
|
|
if isSorted {
|
|
Image(
|
|
systemName: sftpChannel.sortDirection == .ascending
|
|
? "chevron.up" : "chevron.down"
|
|
)
|
|
.font(.caption.weight(.semibold))
|
|
}
|
|
Spacer(minLength: Metrics.Space.s)
|
|
}
|
|
.padding(.horizontal, Metrics.Space.l)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.contentShape(.rect)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.overlay(alignment: .leading) {
|
|
if separated {
|
|
Rectangle().fill(.quaternary.opacity(0.55)).frame(width: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func fileRow(_ row: SFTPRow, layout: Layout) -> some View {
|
|
let file = row.file
|
|
let selected = sftpChannel.selection.contains(file.id)
|
|
// In a split workspace the pane owns durable focus. SwiftUI can clear
|
|
// the ScrollView's FocusState while the outer pane handles the same
|
|
// click even though this remains the active pane. Text editing and an
|
|
// inactive window still deliberately use the subdued selection style.
|
|
let emphasized =
|
|
selected && isPaneFocused && !pathFocused
|
|
&& controlActiveState == .key
|
|
return HStack(spacing: 0) {
|
|
nameColumn(row, layout: layout, emphasized: emphasized)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
if layout != .compact {
|
|
metadataText(row.modifiedText, emphasized: emphasized)
|
|
.frame(width: 220, alignment: .leading)
|
|
}
|
|
if layout == .full {
|
|
metadataText(row.sizeText, emphasized: emphasized, monospaced: true)
|
|
.frame(width: 150, alignment: .leading)
|
|
metadataText(row.kindText, emphasized: emphasized)
|
|
.frame(width: 150, alignment: .leading)
|
|
}
|
|
}
|
|
.padding(.leading, Metrics.Space.xxl)
|
|
.padding(.trailing, Metrics.Space.xl)
|
|
.frame(maxWidth: .infinity, minHeight: 48)
|
|
.background(rowBackground(selected: selected, emphasized: emphasized, file: file))
|
|
.contentShape(.rect)
|
|
.onTapGesture {
|
|
select(file)
|
|
if NSApp.currentEvent?.clickCount == 2 { primaryAction(file) }
|
|
}
|
|
.onHover { hovering in hoveredFileID = hovering ? file.id : nil }
|
|
.onDrag { remoteDragProvider(for: file) }
|
|
.overlay {
|
|
NativeRowContextMenu(
|
|
entries: {
|
|
selectForContextMenu(file)
|
|
return fileMenu(file)
|
|
},
|
|
isPresented: .constant(false)
|
|
)
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityAddTraits(selected ? [.isSelected, .isButton] : .isButton)
|
|
.opacity(file.isHidden && !selected ? 0.55 : 1)
|
|
}
|
|
|
|
private func nameColumn(
|
|
_ row: SFTPRow, layout: Layout, emphasized: Bool
|
|
) -> some View {
|
|
let file = row.file
|
|
return HStack(spacing: Metrics.Space.m) {
|
|
Image(systemName: file.kind.symbol)
|
|
.font(.system(size: file.isDirectory ? 23 : 20, weight: .regular))
|
|
.foregroundStyle(iconStyle(for: file, emphasized: emphasized))
|
|
.frame(width: 28)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(file.name)
|
|
.font(.body.weight(.medium))
|
|
.foregroundStyle(
|
|
emphasized
|
|
? AnyShapeStyle(TermindColor.selectedForeground)
|
|
: AnyShapeStyle(.primary)
|
|
)
|
|
.lineLimit(1)
|
|
Text(secondaryText(for: row, layout: layout))
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(
|
|
emphasized
|
|
? AnyShapeStyle(TermindColor.selectedForeground.opacity(0.78))
|
|
: AnyShapeStyle(.secondary)
|
|
)
|
|
.lineLimit(1)
|
|
}
|
|
.padding(.vertical, Metrics.Space.xs)
|
|
}
|
|
}
|
|
|
|
private func metadataText(
|
|
_ value: String, emphasized: Bool, monospaced: Bool = false
|
|
) -> some View {
|
|
Text(value)
|
|
.font(monospaced ? .body.monospacedDigit() : .body)
|
|
.foregroundStyle(
|
|
emphasized
|
|
? AnyShapeStyle(TermindColor.selectedForeground.opacity(0.88))
|
|
: AnyShapeStyle(.secondary)
|
|
)
|
|
.lineLimit(1)
|
|
.padding(.horizontal, Metrics.Space.l)
|
|
}
|
|
|
|
private func rowBackground(
|
|
selected: Bool, emphasized: Bool, file: RemoteFile
|
|
) -> AnyShapeStyle {
|
|
if emphasized { return AnyShapeStyle(Color.accentColor) }
|
|
if selected {
|
|
return AnyShapeStyle(Color(nsColor: .unemphasizedSelectedContentBackgroundColor))
|
|
}
|
|
if hoveredFileID == file.id { return AnyShapeStyle(Color.primary.opacity(0.045)) }
|
|
return AnyShapeStyle(Color.clear)
|
|
}
|
|
|
|
private func iconStyle(for file: RemoteFile, emphasized: Bool) -> AnyShapeStyle {
|
|
if emphasized {
|
|
return AnyShapeStyle(
|
|
TermindColor.selectedForeground.opacity(file.isDirectory ? 0.82 : 0.72)
|
|
)
|
|
}
|
|
return file.isDirectory ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)
|
|
}
|
|
|
|
/// Whatever the columns to the right of the name are not showing at this
|
|
/// density, so a narrow browser is still a complete listing. Assembled from
|
|
/// `SFTPRow`'s already-rendered strings — no formatter runs here.
|
|
private func secondaryText(for row: SFTPRow, layout: Layout) -> String {
|
|
switch layout {
|
|
case .full:
|
|
return row.permissionText
|
|
case .medium:
|
|
return [row.permissionText, row.kindText, row.sizeText].filter { !$0.isEmpty }
|
|
.joined(separator: " · ")
|
|
case .compact:
|
|
return [
|
|
row.kindText, row.sizeText, row.modifiedText, row.permissionText,
|
|
].filter { !$0.isEmpty }.joined(separator: " · ")
|
|
}
|
|
}
|
|
|
|
private func fileMenu(_ file: RemoteFile) -> [NativeMenuEntry] {
|
|
// Capture targets once, before AppKit starts tracking the menu. Later
|
|
// selection, hover and directory updates must not retarget its actions.
|
|
let targets = menuTargets(for: file)
|
|
return SFTPFileContextMenu(
|
|
file: file, targets: targets,
|
|
connected: sftpChannel.status == .connected,
|
|
allowsRemoteMutations: sftpChannel.allowsRemoteMutations,
|
|
canPaste: canPasteRemoteItems, isLoading: sftpChannel.isLoading,
|
|
locale: locale
|
|
).entries { action in
|
|
switch action {
|
|
case .open: primaryAction(file)
|
|
case .openWith(let application): open(file, with: application)
|
|
case .download: chooseDownloadDestination(for: targets)
|
|
case .copy: copyRemoteItems(targets)
|
|
case .copyNames: copyToPasteboard(targets.map(\.name).joined(separator: "\n"))
|
|
case .copyPaths:
|
|
copyToPasteboard(targets.map { RemoteShellCommand.escapeArgument($0.path) }.joined(separator: " "))
|
|
case .paste: pasteRemoteItems()
|
|
case .rename:
|
|
renameTarget = file
|
|
renameName = file.name
|
|
case .editPermissions: permissionTarget = file
|
|
case .delete: deleteTargets = targets
|
|
case .newFolder: newFolderPresented = true
|
|
case .upload: chooseUploadItems()
|
|
case .refresh: sftpChannel.reloadNow()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func primaryAction(_ file: RemoteFile) {
|
|
if file.isDirectory {
|
|
sftpChannel.navigate(to: file.path)
|
|
} else {
|
|
sftpChannel.open(file, coordinator: app.sftpTransfers, edits: app.sftpEdits) {
|
|
[history = sftpChannel.history] localURL in
|
|
if NSWorkspace.shared.open(localURL) {
|
|
history.record(kind: .file, path: file.path)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func open(_ file: RemoteFile, with application: URL) {
|
|
sftpChannel.open(file, coordinator: app.sftpTransfers, edits: app.sftpEdits) {
|
|
[history = sftpChannel.history] localURL in
|
|
Task {
|
|
do {
|
|
try await SFTPApplicationOpener.open(localURL, with: application)
|
|
history.record(kind: .file, path: file.path)
|
|
} catch { sftpChannel.operationError = error.localizedDescription }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func menuTargets(for file: RemoteFile) -> [RemoteFile] {
|
|
sftpChannel.menuTargets(for: file)
|
|
}
|
|
|
|
private func copyToPasteboard(_ value: String) {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(value, forType: .string)
|
|
}
|
|
|
|
private func select(_ file: RemoteFile) {
|
|
focusPane()
|
|
focusListForUserGesture()
|
|
let modifiers = NSEvent.modifierFlags
|
|
if modifiers.contains(.shift),
|
|
let anchor = selectionAnchor ?? sftpChannel.selection.first,
|
|
let anchorIndex = sftpChannel.filteredFiles.firstIndex(where: { $0.id == anchor }),
|
|
let fileIndex = sftpChannel.filteredFiles.firstIndex(where: { $0.id == file.id })
|
|
{
|
|
let bounds = min(anchorIndex, fileIndex)...max(anchorIndex, fileIndex)
|
|
let range = Set(sftpChannel.filteredFiles[bounds].map(\.id))
|
|
if modifiers.contains(.command) {
|
|
sftpChannel.selection.formUnion(range)
|
|
} else {
|
|
sftpChannel.selection = range
|
|
}
|
|
selectionCursor = file.id
|
|
return
|
|
}
|
|
|
|
selectionAnchor = file.id
|
|
selectionCursor = file.id
|
|
if modifiers.contains(.command) {
|
|
if sftpChannel.selection.contains(file.id) {
|
|
sftpChannel.selection.remove(file.id)
|
|
} else {
|
|
sftpChannel.selection.insert(file.id)
|
|
}
|
|
} else {
|
|
sftpChannel.selection = [file.id]
|
|
}
|
|
}
|
|
|
|
private func selectForContextMenu(_ file: RemoteFile) {
|
|
guard sftpChannel.selectForContextMenu(file) else { return }
|
|
selectionAnchor = file.id
|
|
selectionCursor = file.id
|
|
focusListForUserGesture()
|
|
}
|
|
|
|
private var keyboardTarget: RemoteFile? {
|
|
let target = selectionCursor ?? sftpChannel.selectedFiles.first?.id
|
|
return target.flatMap { id in sftpChannel.filteredFiles.first { $0.id == id } }
|
|
}
|
|
|
|
private func moveSelection(
|
|
by offset: Int, extending: Bool
|
|
) -> KeyPress.Result {
|
|
let files = sftpChannel.filteredFiles
|
|
guard !files.isEmpty else { return .ignored }
|
|
let currentID = selectionCursor ?? sftpChannel.selectedFiles.first?.id
|
|
let currentIndex =
|
|
currentID.flatMap { id in files.firstIndex { $0.id == id } }
|
|
?? (offset > 0 ? -1 : files.count)
|
|
let targetIndex = min(max(currentIndex + offset, 0), files.count - 1)
|
|
let target = files[targetIndex]
|
|
|
|
if extending {
|
|
let anchorID = selectionAnchor ?? currentID ?? target.id
|
|
let anchorIndex = files.firstIndex { $0.id == anchorID } ?? targetIndex
|
|
let bounds = min(anchorIndex, targetIndex)...max(anchorIndex, targetIndex)
|
|
sftpChannel.selection = Set(files[bounds].map(\.id))
|
|
selectionAnchor = anchorID
|
|
} else {
|
|
sftpChannel.selection = [target.id]
|
|
selectionAnchor = target.id
|
|
}
|
|
selectionCursor = target.id
|
|
fileScrollRequest = SFTPFileScrollRequest(
|
|
targetID: target.id,
|
|
position: targetIndex == 0 ? .top : .nearest
|
|
)
|
|
return .handled
|
|
}
|
|
|
|
private func remoteDragProvider(for file: RemoteFile) -> NSItemProvider {
|
|
let targets = menuTargets(for: file)
|
|
guard let source = sftpChannel.remoteCopySource(for: targets) else {
|
|
return NSItemProvider()
|
|
}
|
|
let payload = app.sftpTransfers.registerRemoteCopySource(source)
|
|
let provider =
|
|
file.isDirectory
|
|
? NSItemProvider()
|
|
: Self.promisedFileProvider(
|
|
for: file, sftpChannel: sftpChannel, coordinator: app.sftpTransfers
|
|
)
|
|
Self.register(payload, on: provider)
|
|
return provider
|
|
}
|
|
|
|
private func remoteCopyProviders(for files: [RemoteFile]) -> [NSItemProvider] {
|
|
guard let source = sftpChannel.remoteCopySource(for: files) else { return [] }
|
|
let payload = app.sftpTransfers.registerRemoteCopySource(source)
|
|
let provider = NSItemProvider()
|
|
Self.register(payload, on: provider)
|
|
return [provider]
|
|
}
|
|
|
|
private func copyRemoteItems(_ files: [RemoteFile]) {
|
|
guard let source = sftpChannel.remoteCopySource(for: files) else { return }
|
|
let payload = app.sftpTransfers.registerRemoteCopySource(source)
|
|
guard let data = try? JSONEncoder().encode(payload) else { return }
|
|
let item = NSPasteboardItem()
|
|
item.setData(data, forType: NSPasteboard.PasteboardType(UTType.termindSFTPItems.identifier))
|
|
item.setString(files.map(\.name).joined(separator: "\n"), forType: .string)
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.writeObjects([item])
|
|
}
|
|
|
|
private var canPasteRemoteItems: Bool {
|
|
guard let payload = remotePayloadOnPasteboard else { return false }
|
|
return app.sftpTransfers.remoteCopySource(for: payload) != nil
|
|
}
|
|
|
|
private func pasteRemoteItems() {
|
|
guard let payload = remotePayloadOnPasteboard,
|
|
app.sftpTransfers.remoteCopySource(for: payload) != nil
|
|
else { return }
|
|
prepareRemoteItems(payload)
|
|
}
|
|
|
|
private var remotePayloadOnPasteboard: SFTPRemoteItemsPayload? {
|
|
let type = NSPasteboard.PasteboardType(UTType.termindSFTPItems.identifier)
|
|
guard let data = NSPasteboard.general.data(forType: type) else { return nil }
|
|
return try? JSONDecoder().decode(SFTPRemoteItemsPayload.self, from: data)
|
|
}
|
|
|
|
private static func register(
|
|
_ payload: SFTPRemoteItemsPayload, on provider: NSItemProvider
|
|
) {
|
|
let data = try? JSONEncoder().encode(payload)
|
|
provider.registerDataRepresentation(
|
|
forTypeIdentifier: UTType.termindSFTPItems.identifier, visibility: .ownProcess
|
|
) { completion in
|
|
completion(
|
|
data, data == nil ? SFTPError.operation(String(localized: AppStrings.SFTP.unableToCopyItems)) : nil)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
static func promisedFileProvider(
|
|
for file: RemoteFile, sftpChannel: SFTPChannelModel,
|
|
coordinator: SFTPTransferCoordinator
|
|
) -> NSItemProvider {
|
|
let provider = NSItemProvider()
|
|
provider.suggestedName = file.name
|
|
provider.registerFileRepresentation(
|
|
forTypeIdentifier: UTType.data.identifier,
|
|
fileOptions: [], visibility: .all
|
|
) { completion in
|
|
let progress = Progress(totalUnitCount: 1)
|
|
Task { @MainActor in
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("Termind", isDirectory: true)
|
|
.appendingPathComponent("Drag", isDirectory: true)
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
do {
|
|
try FileManager.default.createDirectory(
|
|
at: directory, withIntermediateDirectories: true
|
|
)
|
|
} catch {
|
|
completion(nil, false, error)
|
|
return
|
|
}
|
|
let localURL = directory.appendingPathComponent(file.name)
|
|
sftpChannel.downloadPromisedFile(
|
|
file, to: directory, coordinator: coordinator
|
|
) { error in
|
|
progress.completedUnitCount = 1
|
|
completion(error == nil ? localURL : nil, false, error)
|
|
}
|
|
}
|
|
return progress
|
|
}
|
|
return provider
|
|
}
|
|
|
|
private func centeredState(_ title: LocalizedStringResource, symbol: String, progress: Bool) -> some View {
|
|
VStack(spacing: Metrics.Space.l) {
|
|
if progress {
|
|
ProgressView()
|
|
} else {
|
|
Image(systemName: symbol).font(.largeTitle).foregroundStyle(.tertiary)
|
|
}
|
|
Text(title).foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private func chooseUploadItems() {
|
|
let panel = NSOpenPanel()
|
|
panel.canChooseFiles = true
|
|
panel.canChooseDirectories = true
|
|
panel.allowsMultipleSelection = true
|
|
guard panel.runModal() == .OK else { return }
|
|
Task {
|
|
await sftpChannel.prepareUpload(panel.urls)
|
|
finishPreparingBatch()
|
|
}
|
|
}
|
|
|
|
private func receiveDroppedURLs(_ urls: [URL]) -> Bool {
|
|
let files = urls.filter(\.isFileURL)
|
|
guard sftpChannel.status == .connected, !files.isEmpty else { return false }
|
|
Task {
|
|
await sftpChannel.prepareUpload(files)
|
|
finishPreparingBatch()
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func receiveRemoteItemProviders(_ providers: [NSItemProvider]) -> Bool {
|
|
guard sftpChannel.status == .connected,
|
|
let provider = providers.first(where: {
|
|
$0.hasItemConformingToTypeIdentifier(UTType.termindSFTPItems.identifier)
|
|
})
|
|
else { return false }
|
|
provider.loadDataRepresentation(
|
|
forTypeIdentifier: UTType.termindSFTPItems.identifier
|
|
) { data, error in
|
|
Task { @MainActor in
|
|
if let error {
|
|
sftpChannel.operationError = error.localizedDescription
|
|
return
|
|
}
|
|
guard let data,
|
|
let payload = try? JSONDecoder().decode(
|
|
SFTPRemoteItemsPayload.self, from: data
|
|
)
|
|
else {
|
|
sftpChannel.operationError = String(localized: AppStrings.SFTP.theCopiedSftpItemsAreInvalid)
|
|
return
|
|
}
|
|
prepareRemoteItems(payload)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func receiveRemoteDrop(_ data: Data) {
|
|
guard sftpChannel.status == .connected else { return }
|
|
guard let payload = try? JSONDecoder().decode(SFTPRemoteItemsPayload.self, from: data)
|
|
else {
|
|
sftpChannel.operationError = String(localized: AppStrings.SFTP.theCopiedSftpItemsAreInvalid)
|
|
return
|
|
}
|
|
prepareRemoteItems(payload)
|
|
}
|
|
|
|
private func prepareRemoteItems(_ payload: SFTPRemoteItemsPayload) {
|
|
Task {
|
|
await sftpChannel.prepareRemoteCopy(payload, coordinator: app.sftpTransfers)
|
|
finishPreparingBatch()
|
|
}
|
|
}
|
|
|
|
private func chooseDownloadDestination(for files: [RemoteFile]) {
|
|
let panel = NSOpenPanel()
|
|
panel.canChooseFiles = false
|
|
panel.canChooseDirectories = true
|
|
panel.allowsMultipleSelection = false
|
|
guard panel.runModal() == .OK, let directory = panel.url else { return }
|
|
Task {
|
|
await sftpChannel.prepareDownload(files, to: directory)
|
|
finishPreparingBatch()
|
|
}
|
|
}
|
|
|
|
private func finishPreparingBatch() {
|
|
guard sftpChannel.hasPendingBatch else { return }
|
|
if sftpChannel.conflictCount > 0 { conflictPresented = true } else { enqueue(.replace) }
|
|
}
|
|
|
|
private func enqueue(_ policy: TransferConflictPolicy) {
|
|
Task {
|
|
if sftpChannel.pendingRemoteCopyBatch != nil {
|
|
await sftpChannel.enqueuePendingRemoteCopy(
|
|
policy: policy, coordinator: app.sftpTransfers
|
|
)
|
|
} else {
|
|
await sftpChannel.enqueuePending(policy: policy, coordinator: app.sftpTransfers)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SFTPEditPermissionsSheet: View {
|
|
let file: RemoteFile
|
|
let displayedPath: String
|
|
let save: (UInt32) async -> Bool
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@FocusState private var modeFieldFocused: Bool
|
|
@State private var draft: SFTPPermissionDraft
|
|
@State private var isSaving = false
|
|
|
|
init(
|
|
file: RemoteFile, displayedPath: String,
|
|
save: @escaping (UInt32) async -> Bool
|
|
) {
|
|
self.file = file
|
|
self.displayedPath = displayedPath
|
|
self.save = save
|
|
_draft = State(initialValue: SFTPPermissionDraft(permissions: file.permissions))
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
topBar
|
|
|
|
VStack(alignment: .leading, spacing: Metrics.Space.xxl) {
|
|
HStack(spacing: Metrics.Space.l) {
|
|
Text(displayedPath)
|
|
.font(.body.monospaced())
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.help(displayedPath)
|
|
Spacer(minLength: Metrics.Space.xl)
|
|
TextField(AppStrings.SFTP.mode.swiftUIKey, text: modeText.singleLineText, axis: .horizontal)
|
|
.singleLinePaste().lineLimit(1)
|
|
.labelsHidden()
|
|
.font(.body.monospacedDigit())
|
|
.multilineTextAlignment(.trailing)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(width: 68)
|
|
.focused($modeFieldFocused)
|
|
.accessibilityLabel(AppStrings.SFTP.octalPermissions.swiftUIKey)
|
|
}
|
|
|
|
permissionGrid
|
|
|
|
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
|
Text(AppStrings.SFTP.ownership).font(.headline)
|
|
ownershipRow(AppStrings.SFTP.uid, value: file.ownerUID)
|
|
Divider()
|
|
ownershipRow(AppStrings.SFTP.gid, value: file.ownerGID)
|
|
}
|
|
}
|
|
.padding(Metrics.Space.xxl)
|
|
}
|
|
.frame(width: 520)
|
|
.background(TermindColor.windowSurface)
|
|
.disabled(isSaving)
|
|
.onChange(of: modeFieldFocused) { _, focused in
|
|
if !focused { draft.normalizeText() }
|
|
}
|
|
}
|
|
|
|
private var topBar: some View {
|
|
HStack {
|
|
glassActionButton(AppStrings.Common.cancel, symbol: "xmark", identifier: "sftp-permissions.cancel") {
|
|
dismiss()
|
|
}
|
|
.keyboardShortcut(.cancelAction)
|
|
|
|
Spacer()
|
|
|
|
glassActionButton(
|
|
AppStrings.Common.save, symbol: "checkmark", identifier: "sftp-permissions.save",
|
|
showsProgress: isSaving
|
|
) {
|
|
Task { await commit() }
|
|
}
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(!draft.canSave || isSaving)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.overlay {
|
|
Text(AppStrings.SFTP.editPermissions).font(.headline)
|
|
}
|
|
.padding(.horizontal, Metrics.Space.xl)
|
|
.padding(.top, Metrics.Space.l)
|
|
.padding(.bottom, Metrics.Space.s)
|
|
.accessibilityElement(children: .contain)
|
|
}
|
|
|
|
private func glassActionButton(
|
|
_ label: LocalizedStringResource,
|
|
symbol: String,
|
|
identifier: String,
|
|
showsProgress: Bool = false,
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: action) {
|
|
ZStack {
|
|
Label(label, systemImage: symbol)
|
|
.labelStyle(.iconOnly)
|
|
.opacity(showsProgress ? 0 : 1)
|
|
if showsProgress {
|
|
ProgressView().controlSize(.small)
|
|
}
|
|
}
|
|
.frame(width: 30, height: 30)
|
|
.contentShape(.circle)
|
|
.accessibilityIdentifier(identifier)
|
|
}
|
|
.accessibilityIdentifier(identifier)
|
|
.buttonStyle(.plain)
|
|
.termindInputAwareFocusEffect()
|
|
.termindGlassCircle(isInteractive: true)
|
|
.help(label)
|
|
.accessibilityLabel(Text(label))
|
|
}
|
|
|
|
private var modeText: Binding<String> {
|
|
Binding(
|
|
get: { draft.text },
|
|
set: { _ = draft.accept($0) }
|
|
)
|
|
}
|
|
|
|
private var permissionGrid: some View {
|
|
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
|
Text(AppStrings.SFTP.fileAccess).font(.headline)
|
|
UnixPermissionGrid(draft: $draft, horizontalSpacing: Metrics.Space.xxl, verticalSpacing: Metrics.Space.l)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, Metrics.Space.s)
|
|
}
|
|
}
|
|
|
|
private func ownershipRow(_ label: LocalizedStringResource, value: UInt32) -> some View {
|
|
HStack {
|
|
Text(label).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(value.formatted())
|
|
.font(.body.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, Metrics.Space.xs)
|
|
}
|
|
|
|
@MainActor
|
|
private func commit() async {
|
|
guard !isSaving, let permissions = draft.parsedMode, draft.canSave else { return }
|
|
modeFieldFocused = false
|
|
isSaving = true
|
|
if await save(permissions) {
|
|
dismiss()
|
|
} else {
|
|
isSaving = false
|
|
}
|
|
}
|
|
}
|