Files
Termind/TFM/Features/SFTP/SFTPBrowserView.swift

1525 lines
59 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
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
@AppStorage(DesktopSettingsKey.showStatusBar) private var showsStatusBar = true
@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(isEnabled: showsStatusBar))
}
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 {
let transport = app.hostLibrary.host(sftpChannel.hostID)?.preferredTerminalProtocol ?? .ssh
return 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()
Menu(
AppStrings.Connections.openWithTransport(transport.displayName).swiftUIKey,
systemImage: transport.symbol
) {
Button(AppStrings.Workspace.inCurrentWorkspace.swiftUIKey, systemImage: MenuSymbol.currentWorkspace) {
window.openCompanionSession(from: sftpChannel.id, placement: .currentWorkspace)
}
Button(AppStrings.Workspace.inNewWorkspace.swiftUIKey, systemImage: MenuSymbol.newWorkspace) {
window.openCompanionSession(from: sftpChannel.id, placement: .newWorkspace)
}
}
.disabled(!window.canOpenCompanionSession(from: sftpChannel.id))
Button(
(app.mainWindows.isTransfersWindowOpen
? AppStrings.SFTP.closeTransfers : AppStrings.SFTP.openTransfers).swiftUIKey,
systemImage: "arrow.up.arrow.down"
) {
app.mainWindows.toggleTransfers()
}
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(isEnabled: showsStatusBar)
+ 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
}
}
}