Files
Termind/TFI/Features/Workspaces/MobileWorkspaceView.swift

944 lines
39 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import GhosttyTerminal
import QuickLook
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import UIKit
import UniformTypeIdentifiers
struct MobileWorkspaceView: View {
private enum TerminalSheet: String, Identifiable {
case snippets
case commandHistory
case sftpHistory
var id: String { rawValue }
}
let library: HostLibraryModel
let commandGenerator: any CommandGenerating
@Bindable var workspace: MobileWorkspace
let workspaces: [MobileWorkspaceDescriptor]
var unreadWorkspaceIDs: Set<UUID> = []
let selectWorkspace: (UUID) -> Void
let renameWorkspace: (MobileWorkspaceDescriptor) -> Void
let canCloseMultipleWorkspaces: Bool
let openSameDirectory: (MobileWorkspaceDirectoryLaunch) -> Void
let closeWorkspace: (UUID) -> Void
let closeOtherWorkspaces: (UUID) -> Void
@State private var terminalSheet: TerminalSheet?
@State private var importing = false
@State private var importingTerminalFiles = false
@State private var importingTerminalPhotos = false
@State private var terminalUploadTarget: TerminalUploadTarget?
private struct TerminalUploadTarget {
let workspaceID: UUID
let generation: UUID
let destination: String
}
@State private var directoryName = ""
@State private var creatingDirectory = false
@State private var renaming: RemoteFile?
@State private var editingPermissions: RemoteFile?
@State private var showingTransfers = false
@State private var uploadDropTargeted = false
@State private var workspaceIsVisible = false
@State private var uploadDropTask: Task<Void, Never>?
@Environment(\.scenePhase) private var scenePhase
@Environment(\.mobileToolbarLayout) private var toolbarLayout
@TerminalFontSettings private var font
@State private var downloadImport = MobileFileImportState<RemoteFile>()
@State private var preview = MobileSFTPPreviewState()
@State private var openWithPresenter = MobileSFTPOpenWithPresenter()
@State private var editedCopyImport = MobileFileImportState<UUID>()
@ScaledMetric private var editRowHeight = 76.0
private var resolvedFont: TerminalFontPreferences {
font.resolvingFamily(in: TerminalFontCatalog.shared.families)
}
private var editedFiles: [SFTPEditedFile] {
workspace.sftpEdits?.model.modifiedFiles(for: workspace.descriptor.hostID) ?? []
}
var body: some View {
workspaceAlerts
.background {
MobileSFTPOpenWithAnchor(
presenter: openWithPresenter,
isEnabled: workspaceIsVisible && scenePhase == .active,
failed: { [workspace] in workspace.operationError = $0 },
finished: { [edits = workspace.sftpEdits] in Task { await edits?.recheck() } }
)
MobileWindowDropTarget(
isTargeted: $uploadDropTargeted,
isEnabled: workspaceIsVisible && scenePhase == .active,
terminalView: workspace.uploadTerminalView,
receive: { receiveUploadProviders($0) }
)
.id(workspace.id)
}
.onAppear {
workspaceIsVisible = true
bindTerminalUploadMenu()
}
.onDisappear {
openWithPresenter.isEnabled = false
workspaceIsVisible = false
workspace.configureTerminalUpload(files: nil)
terminalUploadTarget = nil
uploadDropTargeted = false
cancelDropLoading()
}
.onChange(of: workspace.id) { _, _ in
openWithPresenter.invalidate()
terminalUploadTarget = nil
bindTerminalUploadMenu()
uploadDropTargeted = false
cancelDropLoading()
}
.onChange(of: workspace.uploadDestination) { _, _ in cancelDropLoading() }
.onChange(of: workspace.uploadConnectionGeneration) { _, _ in cancelDropLoading() }
.onChange(of: workspace.pendingExternalPreviewURL, initial: true) { _, url in
guard url != nil, let preview = workspace.consumePendingExternalPreviewURL() else { return }
presentPreview(preview)
}
.onChange(of: scenePhase) { _, phase in
if phase != .active { openWithPresenter.isEnabled = false }
if phase == .active { TerminalFontCatalog.shared.refresh() }
}
}
private var workspaceChrome: some View {
MobileRegionReader { regions in
MobilePaneLayout(frames: [regions.continuous]) {
workspaceContent
.overlay(alignment: .bottom) {
VStack(spacing: 12) {
if showsCommandGeneratorOverlay {
MobileCommandGeneratorOverlay(state: workspace.commandGeneratorState)
}
if let notice = workspace.uploadNotice {
MobileUploadNoticeOverlay(notice: notice)
}
}
.padding()
.allowsHitTesting(false)
}
.overlay {
if uploadDropTargeted { DropTargetOverlay(presentation: uploadDropPresentation) }
}
.clipped()
.opacity(MobileContentRegions.isUsable(regions.continuous) ? 1 : 0)
.allowsHitTesting(MobileContentRegions.isUsable(regions.continuous))
.accessibilityHidden(!MobileContentRegions.isUsable(regions.continuous))
}
}
.background {
if workspace.descriptor.kind != .sftp, !showsJourney {
TerminalBackground(state: workspace.terminal)
.ignoresSafeArea(.container)
}
}
.navigationTitle(workspace.descriptor.title)
.navigationBarTitleDisplayMode(.inline)
// Retained, inactive workspaces must not hide another destination's navigation when folding.
.toolbar(workspaceIsVisible && !toolbarLayout.showsWorkspaceTabs ? .hidden : .visible, for: .tabBar)
.toolbar(removing: toolbarLayout.usesIconSwitcher ? .title : nil)
.toolbar {
if toolbarLayout.usesIconSwitcher {
ToolbarItem(placement: .topBarLeading) {
Menu {
workspaceSwitchActions
} label: {
MobileNotificationMenuLabel(
title: String(localized: AppStrings.Navigation.workspaces), symbol: "rectangle.stack",
hasUnread: !unreadWorkspaceIDs.isEmpty, isToolbar: true)
}
.labelStyle(.iconOnly)
.accessibilityValue(workspace.descriptor.title)
}
.mobileProminentAction()
} else {
ToolbarTitleMenu { workspaceSwitchActions }
}
}
.modifier(MobileWorkspaceSearchModifier(workspace: workspace))
.toolbar { workspaceToolbar }
}
private var workspaceSwitchActions: some View {
ForEach(workspaces) { candidate in
Button {
guard candidate.id != workspace.id else { return }
selectWorkspace(candidate.id)
} label: {
workspaceTitleMenuLabel(for: candidate)
}
}
}
@ToolbarContentBuilder
private var workspaceToolbar: some ToolbarContent {
if workspace.descriptor.kind.isSSHTerminal {
MobileToolbarOverflow(placement: .topBarTrailing) {
Button(AppStrings.Workspace.generateCommand.swiftUIKey, systemImage: "sparkles") {
workspace.generateCommand(using: commandGenerator)
}
Divider()
Button(AppStrings.Navigation.snippets.swiftUIKey, systemImage: "curlybraces") {
terminalSheet = .snippets
}
Button(AppStrings.Common.history.swiftUIKey, systemImage: "clock") { terminalSheet = .commandHistory }
Divider()
Button(AppStrings.Workspace.nextMatch.swiftUIKey, systemImage: "chevron.down") {
workspace.find.presentAndNavigate(.previous)
}
Button(AppStrings.Workspace.previousMatch.swiftUIKey, systemImage: "chevron.up") {
workspace.find.presentAndNavigate(.next)
}
Divider()
sameDirectoryMenuAction
workspaceLifecycleActions
}
}
if workspace.descriptor.kind == .telnet {
MobileToolbarOverflow(placement: .topBarTrailing) {
Button(AppStrings.Navigation.snippets.swiftUIKey, systemImage: "curlybraces") {
terminalSheet = .snippets
}
Divider()
Button(AppStrings.Workspace.nextMatch.swiftUIKey, systemImage: "chevron.down") {
workspace.find.presentAndNavigate(.previous)
}
Button(AppStrings.Workspace.previousMatch.swiftUIKey, systemImage: "chevron.up") {
workspace.find.presentAndNavigate(.next)
}
Divider()
workspaceLifecycleActions
}
}
if isSFTP {
MobileToolbarOverflow(placement: .topBarTrailing) {
Button(AppStrings.Workspace.previousLocation.swiftUIKey, systemImage: "chevron.left") {
Task { await workspace.sftpChannel.goBack() }
}
.disabled(!workspace.connected || !workspace.sftpChannel.canGoBack)
Button(AppStrings.Workspace.nextLocation.swiftUIKey, systemImage: "chevron.right") {
Task { await workspace.sftpChannel.goForward() }
}
.disabled(!workspace.connected || !workspace.sftpChannel.canGoForward)
Divider()
Button(AppStrings.SFTP.transfers.swiftUIKey, systemImage: "arrow.up.arrow.down") {
showingTransfers = true
}
Button(AppStrings.Common.history.swiftUIKey, systemImage: "clock") { terminalSheet = .sftpHistory }
Divider()
Button(AppStrings.SFTP.upload.swiftUIKey, systemImage: "square.and.arrow.up") { importing = true }
.disabled(!workspace.connected)
Button(AppStrings.SFTP.newFolder.swiftUIKey, systemImage: "folder.badge.plus") {
creatingDirectory = true
}
.disabled(!workspace.connected)
Button(AppStrings.Common.paste.swiftUIKey, systemImage: "doc.on.clipboard", action: pasteRemoteItems)
.disabled(!workspace.connected)
sortMenu
if workspace.sftpChannel.showsHiddenFiles {
Button(AppStrings.SFTP.hideHiddenFiles.swiftUIKey, systemImage: "eye.slash") {
workspace.sftpChannel.showsHiddenFiles = false
}
} else {
Button(AppStrings.SFTP.showHiddenFiles.swiftUIKey, systemImage: "eye") {
workspace.sftpChannel.showsHiddenFiles = true
}
}
Button(AppStrings.Common.refresh.swiftUIKey, systemImage: "arrow.clockwise") {
Task { await workspace.sftpChannel.reload() }
}
.disabled(!workspace.connected || workspace.sftpChannel.isRefreshing)
Divider()
sameDirectoryMenuAction
workspaceLifecycleActions
}
}
}
@ViewBuilder
private var workspaceLifecycleActions: some View {
MobileWorkspaceLifecycleMenuActions(
canCloseMultipleWorkspaces: canCloseMultipleWorkspaces,
rename: { renameWorkspace(workspace.descriptor) },
close: { closeWorkspace(workspace.id) },
closeOthers: { closeOtherWorkspaces(workspace.id) }
)
}
private var workspaceSheets: some View {
workspaceChrome
.sheet(item: $terminalSheet) { sheet in
switch sheet {
case .snippets:
MobileTerminalSnippetsView(library: library, workspace: workspace)
case .commandHistory:
MobileCommandHistoryView(library: library, workspace: workspace)
case .sftpHistory:
MobileSFTPHistoryView(workspace: workspace, open: reopenHistory)
}
}
.sheet(item: $renaming) { MobileRemoteFileEditor(workspace: workspace, file: $0, mode: .rename) }
.sheet(item: $editingPermissions) {
MobileRemoteFileEditor(workspace: workspace, file: $0, mode: .permissions)
}
.sheet(isPresented: $showingTransfers) {
NavigationStack {
if let transfers = workspace.transfers {
MobileTransfersView(transfers: transfers, hosts: library)
}
}.mobileSheetPresentation(compact: true)
}
.sheet(
isPresented: $preview.isPreviewPresented,
onDismiss: {
Task { await workspace.sftpEdits?.recheck() }
preview.previewDidDismiss()
}
) {
if let previewURL = preview.previewURL {
MobileSFTPEditPreview(
url: previewURL,
allowsEditing: workspace.sftpEdits?.model.files.contains { $0.localURL == previewURL } == true,
changed: { Task { await workspace.sftpEdits?.recheck() } },
export: { preview.stageExport($0) },
failed: { workspace.operationError = $0 },
dismiss: { preview.dismissPreview() }
)
}
}
.sheet(isPresented: $preview.isExportPresented) {
if let exportedEditURL = preview.exportedCopyURL {
VStack(spacing: 20) {
Text(AppStrings.Workspace.theEditedCopyUsesADifferentFormat).font(.headline)
Text(AppStrings.SFTP.editedCopyFormatExplanation)
ShareLink(item: exportedEditURL) {
Label(AppStrings.SFTP.keepLocalCopy.swiftUIKey, systemImage: "square.and.arrow.up")
}
}
.padding()
.mobileSheetPresentation(compact: true)
}
}
.sheet(isPresented: $importingTerminalPhotos) {
MobileTerminalPhotoPicker { providers in
importingTerminalPhotos = false
receiveTerminalUpload(providers)
}
}
.fileImporter(
isPresented: $importingTerminalFiles,
allowedContentTypes: [.data, .folder], allowsMultipleSelection: true
) { result in
switch result {
case .success(let urls):
receiveTerminalUploadFiles(urls)
case .failure(let error):
terminalUploadTarget = nil
if (error as NSError).code != NSUserCancelledError {
workspace.reportUploadFailure(error.localizedDescription)
}
}
}
.fileImporter(
isPresented: $editedCopyImport.isPresented, allowedContentTypes: [.data], allowsMultipleSelection: false
) { result in
let target = editedCopyImport.takeTarget()
switch result {
case .success(let urls):
if let target, let source = urls.first {
Task { await workspace.importEditedCopy(source, for: target) }
}
case .failure(let error): workspace.operationError = error.localizedDescription
}
}
.fileImporter(
isPresented: $importing,
allowedContentTypes: [.data, .folder],
allowsMultipleSelection: true
) { result in
switch result {
case .success(let urls):
_ = receiveUpload(urls)
case .failure(let error): workspace.operationError = error.localizedDescription
}
}
.fileImporter(
isPresented: $downloadImport.isPresented,
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
let target = downloadImport.takeTarget()
switch result {
case .success(let urls):
if let target, let destination = urls.first {
workspace.prepareDownload(target, to: destination)
}
case .failure(let error):
workspace.operationError = error.localizedDescription
}
}
}
private var workspaceDialogs: some View {
workspaceSheets
.onDisappear {
workspace.cancelPendingUpload()
workspace.cancelCommandGeneration()
}
.confirmationDialog(
uploadConflictTitle,
isPresented: transferConflictPresented,
titleVisibility: .visible
) {
Button(AppStrings.SFTP.replace.swiftUIKey) { enqueuePendingUpload(.replace) }
Button(AppStrings.SFTP.skipExisting.swiftUIKey) { enqueuePendingUpload(.skip) }
Button(AppStrings.SFTP.keepBoth.swiftUIKey) { enqueuePendingUpload(.keepBoth) }
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { workspace.cancelPendingUpload() }
} message: {
Text(AppStrings.SFTP.applyToAllConflicts)
}
.alert(AppStrings.SFTP.newFolder, isPresented: $creatingDirectory) {
TextField(AppStrings.Common.name.swiftUIKey, text: $directoryName.singleLineText, axis: .horizontal)
.singleLinePaste().lineLimit(1)
Button(AppStrings.Common.create.swiftUIKey) {
Task {
await workspace.sftpChannel.createDirectory(directoryName)
directoryName = ""
}
}
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {}
}
}
private var workspaceAlerts: some View {
workspaceDialogs
.alert(
operationAlertTitle,
isPresented: operationErrorPresented
) {
Button(AppStrings.Common.ok.swiftUIKey, role: .cancel) {
workspace.operationError = nil
workspace.sftpChannel.operationError = nil
}
} message: {
Text(presentedOperationError ?? "")
}
}
@ViewBuilder
private var workspaceContent: some View {
if workspace.descriptor.kind != .sftp {
ZStack {
VStack(spacing: 0) {
TerminalSurfaceView(context: workspace.terminal)
.modifier(
MobileTerminalInitialFocus(
terminal: workspace.terminal,
isReady: workspaceIsVisible && scenePhase == .active && workspace.connected
&& !showsJourney,
blocksInput: showsJourney)
)
.id(workspace.id)
.onChange(of: workspace.terminalSettings.preferences, initial: true) { _, value in
workspace.terminal.applyPreferences(
value, font: resolvedFont, library: workspace.terminalSettings.themeLibrary)
}
.onChange(of: workspace.terminalSettings.themeLibrary) { _, library in
workspace.terminal.applyPreferences(
workspace.terminalSettings.preferences, font: resolvedFont, library: library)
}
.onChange(of: resolvedFont) { _, value in
workspace.terminal.applyPreferences(
workspace.terminalSettings.preferences, font: value,
library: workspace.terminalSettings.themeLibrary)
}
.onAppear { TerminalFontCatalog.shared.refresh() }
}.opacity(showsJourney ? 0 : 1)
.allowsHitTesting(!showsJourney).accessibilityHidden(showsJourney)
if showsJourney { connectionJourney.background(.background) }
}
} else if showsJourney {
connectionJourney
} else {
MobileSFTPBrowser(
channel: workspace.sftpChannel,
connected: workspace.connected,
bottomContentInset: editedFiles.isEmpty
? 0 : (editRowHeight + 1) * Double(min(3, editedFiles.count)) + 24,
open: open,
openWith: openWith,
canImportEditedCopy: { file in
guard
let edit = workspace.sftpEdits?.model.file(hostID: workspace.descriptor.hostID, path: file.path)
else { return false }
return !edit.isInvalidated && !edit.isPreparing && edit.transferID == nil
},
importEditedCopy: { file in
if let id = workspace.sftpEdits?.model.file(
hostID: workspace.descriptor.hostID, path: file.path)?.id
{
editedCopyImport.present(for: id)
}
},
download: { downloadImport.present(for: $0) },
copy: copyRemoteItem,
paste: pasteRemoteItems,
rename: { renaming = $0 },
editPermissions: { editingPermissions = $0 },
delete: { file in Task { await workspace.sftpChannel.delete(file) } }
)
.overlay(alignment: .bottom) {
SFTPEditUploadOverlay(
files: editedFiles, transfers: workspace.transfers?.sftpTransfers ?? [],
connected: workspace.connected,
upload: { await workspace.uploadEdit($0, replacing: $1) },
resume: { workspace.transfers?.resume($0) },
open: presentPreview,
discard: { await workspace.sftpEdits?.discard($0) }
)
.padding(12)
}
}
}
private func presentPreview(_ url: URL) {
preview.present(url)
}
private func open(_ file: RemoteFile) {
if file.isDirectory {
Task { await workspace.sftpChannel.openDirectory(file.path) }
} else {
_ = workspace.preview(file) { url in
Task { @MainActor in presentPreview(url) }
}
}
}
private func openWith(_ file: RemoteFile) {
guard !file.isDirectory, workspace.connected, let request = openWithPresenter.begin() else { return }
// preview prepares the same managed edit copy used by Quick Look and the upload overlay.
if !workspace.preview(
file,
completion: { [openWithPresenter] url in
openWithPresenter.downloaded(url, request: request)
})
{
openWithPresenter.invalidate()
}
}
private func reopenHistory(_ entry: SFTPHistoryEntry) {
switch entry.kind {
case .directory:
Task { await workspace.sftpChannel.openDirectory(entry.path) }
case .file:
Task {
_ = await workspace.reopenHistoryFile(at: entry.path) { url in
Task { @MainActor in presentPreview(url) }
}
}
}
}
private var uploadConflictTitle: String {
String(localized: AppStrings.SFTP.existingItemsMessage(count: workspace.transferConflictCount))
}
private var uploadDropPresentation: DropTargetPresentation {
if let problem = workspace.uploadTargetProblem {
return DropTargetPresentation(
title: String(localized: AppStrings.SFTP.uploadUnavailable),
detail: problem,
systemImage: "exclamationmark.triangle.fill",
tone: .unavailable
)
}
let hostName = library.host(workspace.descriptor.hostID)?.name ?? workspace.descriptor.title
return DropTargetPresentation(
title: String(localized: AppStrings.Workspace.uploadTo(hostName)),
detail: workspace.uploadDestination
?? String(localized: AppStrings.SFTP.theUploadDestinationIsUnavailable),
systemImage: "arrow.up.doc.fill"
)
}
private func bindTerminalUploadMenu() {
workspace.configureTerminalUpload(
files: { beginTerminalUpload(photos: false) },
photos: { beginTerminalUpload(photos: true) })
}
private func beginTerminalUpload(photos: Bool) {
guard workspaceIsVisible, workspace.uploadTargetProblem == nil,
let destination = workspace.uploadDestination
else { return }
terminalUploadTarget = TerminalUploadTarget(
workspaceID: workspace.id, generation: workspace.uploadConnectionGeneration,
destination: destination)
if photos { importingTerminalPhotos = true } else { importingTerminalFiles = true }
}
private var terminalUploadTargetIsCurrent: Bool {
guard let target = terminalUploadTarget, workspaceIsVisible else { return false }
return target.workspaceID == workspace.id
&& target.generation == workspace.uploadConnectionGeneration
&& target.destination == workspace.uploadDestination
}
private func receiveTerminalUploadFiles(_ urls: [URL]) {
defer { terminalUploadTarget = nil }
guard terminalUploadTargetIsCurrent else { return }
// Keep the original security-scoped URLs, as in the SFTP file importer.
_ = workspace.prepareUpload(urls, insertPaths: true)
}
private func receiveTerminalUpload(_ providers: [NSItemProvider]) {
defer { terminalUploadTarget = nil }
guard terminalUploadTargetIsCurrent else { return }
_ = receiveUploadProviders(providers, insertPaths: true)
}
private func receiveUpload(_ urls: [URL]) -> Bool {
workspace.prepareUpload(urls)
}
private func receiveUploadProviders(_ providers: [NSItemProvider], insertPaths: Bool = false) -> Bool {
guard MobileDropPayloadLoader.acceptsUpload(providers) else { return false }
uploadDropTask?.cancel()
workspace.cancelPendingUpload()
let targetWorkspace = workspace
let generation = workspace.uploadConnectionGeneration
let destination = workspace.uploadDestination
uploadDropTask = Task {
let payload = await MobileDropPayloadLoader.load(
providers,
kinds: [.files],
fileMode: .staged
)
guard !Task.isCancelled, workspace.id == targetWorkspace.id,
targetWorkspace.uploadConnectionGeneration == generation,
targetWorkspace.uploadDestination == destination
else {
if let stagedRoot = payload.stagedRoot { try? FileManager.default.removeItem(at: stagedRoot) }
return
}
if !payload.fileURLs.isEmpty {
let accepted = targetWorkspace.prepareUpload(payload.fileURLs, insertPaths: insertPaths)
if !accepted, let stagedRoot = payload.stagedRoot {
try? FileManager.default.removeItem(at: stagedRoot)
}
}
if let error = payload.failures.first {
targetWorkspace.reportUploadFailure(error)
}
}
return true
}
private func cancelDropLoading() {
uploadDropTask?.cancel()
uploadDropTask = nil
}
private var transferConflictPresented: Binding<Bool> {
let requestID = workspace.transferConflictID
return Binding(
get: { workspace.hasPendingTransferConflict },
set: { presented in
if !presented, let requestID { workspace.dismissTransferConflict(requestID) }
})
}
private func enqueuePendingUpload(_ policy: TransferConflictPolicy) {
workspace.enqueuePendingTransfer(policy)
}
@ViewBuilder
private var sortMenu: some View {
Menu {
sortButton(AppStrings.Common.name, field: .name, symbol: "textformat")
sortButton(AppStrings.SFTP.modifiedDate, field: .modified, symbol: "calendar")
sortButton(AppStrings.Common.size, field: .size, symbol: "externaldrive")
sortButton(AppStrings.Common.kind, field: .kind, symbol: "doc")
} label: {
Label {
VStack(alignment: .leading) {
Text(AppStrings.Common.sortBy)
Text(sortFieldTitle).font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "arrow.up.arrow.down.square")
}
}
}
private func sortButton(
_ title: LocalizedStringResource, field: RemoteFileSortField, symbol: String
) -> some View {
Button {
workspace.sftpChannel.sort(by: field)
} label: {
Label(
title,
systemImage: workspace.sftpChannel.sortField == field
? (workspace.sftpChannel.sortDirection == .ascending ? "chevron.up" : "chevron.down")
: symbol
)
}
}
private var sortFieldTitle: String {
switch workspace.sftpChannel.sortField {
case .name: String(localized: AppStrings.Common.name)
case .modified: String(localized: AppStrings.SFTP.modifiedDate)
case .size: String(localized: AppStrings.Common.size)
case .kind: String(localized: AppStrings.Common.kind)
}
}
private func copyRemoteItem(_ file: RemoteFile) {
guard let transfers = workspace.transfers,
let source = workspace.remoteCopySource(for: file),
let data = try? JSONEncoder().encode(transfers.registerRemoteCopySource(source))
else { return }
UIPasteboard.general.items = [
[
UTType.termindSFTPItems.identifier: data,
UTType.plainText.identifier: file.name,
]
]
}
private func pasteRemoteItems() {
guard
let data = UIPasteboard.general.data(
forPasteboardType: UTType.termindSFTPItems.identifier
), let payload = try? JSONDecoder().decode(SFTPRemoteItemsPayload.self, from: data),
workspace.transfers?.remoteCopySource(for: payload) != nil
else {
workspace.operationError = String(localized: AppStrings.SFTP.theCopiedSftpItemsAreInvalid)
return
}
workspace.prepareRemoteCopy(payload)
}
private var presentedOperationError: String? {
workspace.operationError ?? workspace.sftpChannel.operationError
}
private var operationAlertTitle: String {
workspace.descriptor.kind.isSSHTerminal
? String(localized: AppStrings.Workspace.uploadFailed)
: String(localized: AppStrings.SFTP.sftpError)
}
private var operationErrorPresented: Binding<Bool> {
Binding(
get: { presentedOperationError != nil },
set: {
if !$0 {
workspace.operationError = nil
workspace.sftpChannel.operationError = nil
}
}
)
}
private func workspaceTitleMenuLabel(for candidate: MobileWorkspaceDescriptor) -> some View {
MobileNotificationMenuLabel(
title: candidate.title,
symbol: candidate.id == workspace.id ? "checkmark" : candidate.kind.symbol,
hasUnread: unreadWorkspaceIDs.contains(candidate.id))
}
@ViewBuilder
private var sameDirectoryMenuAction: some View {
if let launch = workspace.sameDirectoryLaunch {
switch launch.kind {
case .ssh, .mosh, .eternalTerminal:
Button(
AppStrings.Connections.openWithTransport(
(launch.kind.terminalProtocol ?? .ssh).displayName
).swiftUIKey,
systemImage: launch.kind.symbol
) {
openVerifiedDirectory(launch)
}
case .telnet:
EmptyView()
case .sftp:
Button(AppStrings.SFTP.openWithSftp.swiftUIKey, systemImage: "folder") { openVerifiedDirectory(launch) }
}
}
}
private func openVerifiedDirectory(_ launch: MobileWorkspaceDirectoryLaunch) {
Task {
if let verified = await workspace.validatedSameDirectoryLaunch(launch) { openSameDirectory(verified) }
}
}
private var showsJourney: Bool {
workspace.journey.isPresented || workspace.authenticationRequired || workspace.hostKeyPrompt != nil
|| workspace.sessionPersistence.prompt != nil
}
private var isSFTP: Bool { workspace.descriptor.kind == .sftp }
private var showsCommandGeneratorOverlay: Bool {
workspace.descriptor.kind.isSSHTerminal && workspace.commandGeneratorState != .idle
}
private var connectionJourney: some View {
MobileConnectionJourneyView(
journey: workspace.journey, library: library,
authenticationPrompt: workspace.authenticationPrompt, hostKey: workspace.hostKeyPrompt,
reconnectState: workspace.reconnectState,
submit: { identity, username, password, passphrase, key, save in
workspace.submitAuthentication(
identityID: identity, username: username, password: password,
passphrase: passphrase, keyID: key, save: save)
}, accept: { workspace.trustHostKey(remember: $0) }, retry: { workspace.connect() },
retryReconnectNow: { workspace.retryReconnectNow() },
stopReconnecting: { workspace.stopReconnecting() }, cancel: { closeWorkspace(workspace.id) },
securityKeySigning: workspace.securityKeySigning,
keyboardAuthentication: workspace.keyboardAuthentication,
remoteSession: workspace.sessionPersistence)
}
}
struct MobileWorkspaceLifecycleMenuActions: View {
let canCloseMultipleWorkspaces: Bool
let rename: () -> Void
let close: () -> Void
let closeOthers: () -> Void
var hasUnread: Bool? = nil
var toggleRead: () -> Void = {}
var body: some View {
Button(AppStrings.Common.rename.swiftUIKey, systemImage: "pencil", action: rename)
if let hasUnread {
Button(
hasUnread ? AppStrings.Notifications.markRead : AppStrings.Notifications.markUnread,
systemImage: hasUnread ? "circle" : "circle.fill", action: toggleRead)
}
Button(AppStrings.Common.close.swiftUIKey, systemImage: "xmark", role: .destructive, action: close)
if canCloseMultipleWorkspaces {
Divider()
Button(
AppStrings.Workspace.closeOthers.swiftUIKey, systemImage: "xmark", role: .destructive,
action: closeOthers)
}
}
}
private struct MobileCommandGeneratorOverlay: View {
let state: CommandGeneratorState
var body: some View {
MobileTransientOverlay {
symbol
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.headline)
if let detail {
Text(detail).font(.caption).foregroundStyle(.secondary)
}
}
}
.accessibilityElement(children: .combine)
}
@ViewBuilder
private var symbol: some View {
switch state {
case .readingDraft, .generating:
ProgressView().controlSize(.small)
case .inserted:
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
case .failed:
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
case .idle:
EmptyView()
}
}
private var title: String {
switch state {
case .idle: ""
case .readingDraft, .generating: String(localized: AppStrings.Workspace.generatingCommand)
case .inserted: String(localized: AppStrings.Workspace.commandInserted)
case .failed: String(localized: AppStrings.Workspace.commandGenerationFailed)
}
}
private var detail: String? {
switch state {
case .idle: nil
case .readingDraft, .generating: String(localized: AppStrings.Workspace.thisMayTakeAMoment)
case .inserted: String(localized: AppStrings.Workspace.pressReturnToRun)
case .failed(let message): message
}
}
}
private struct MobileUploadNoticeOverlay: View {
let notice: WorkspaceUploadNotice
var body: some View {
MobileTransientOverlay {
symbol
VStack(alignment: .leading, spacing: 2) {
Text(notice.title).font(.headline)
Text(notice.detail)
.font(notice.isRejected ? .caption : .caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
.truncationMode(notice.isRejected ? .tail : .middle)
}
}
.accessibilityElement(children: .combine)
}
@ViewBuilder
private var symbol: some View {
switch notice {
case .preparing:
ProgressView().controlSize(.small)
case .queued, .rejected:
Image(systemName: notice.symbol ?? "")
.foregroundStyle(notice.isRejected ? .orange : .green)
}
}
}
private struct MobileTransientOverlay<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
HStack(spacing: 12) { content }
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(.regularMaterial, in: .rect(cornerRadius: 16))
.overlay {
RoundedRectangle(cornerRadius: 16)
.strokeBorder(Color(uiColor: .separator).opacity(0.35), lineWidth: 1)
}
.shadow(color: .black.opacity(0.12), radius: 12, y: 4)
}
}