Move connection policy out of ProAccess, keep terminal uploads with their session, and share question interaction and highlight editing operations. Consolidate CPU usage and settings snapshot handling, and remove obsolete terminal selection, theme helpers, and hard-coded latest-schema assertions. Fix draining connection-pool recovery and stale pane upload bindings. Keep screenshot matrices opt-in and retain behavioral migration coverage. Validation: 2468 macOS tests passed serially, 31 iOS tests passed, and both SSH forwarding regressions passed against the local OpenSSH fixture. Both app builds, Swift formatting, AppStrings, and Chinese localization checks passed. Parallel test runs still exhibit intermittent timeouts.
1031 lines
42 KiB
Swift
1031 lines
42 KiB
Swift
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 deleting: RemoteFile?
|
|
@State private var renaming: RemoteFile?
|
|
@State private var editingPermissions: RemoteFile?
|
|
@State private var showingTransfers = false
|
|
@State private var uploadConflictPresented = false
|
|
@State private var uploadDropTargeted = false
|
|
@State private var workspaceIsVisible = false
|
|
@State private var uploadDropTask: Task<Void, Never>?
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@Environment(\.locale) private var locale
|
|
@Environment(\.mobileToolbarLayout) private var toolbarLayout
|
|
@TerminalFontSettings private var font
|
|
@State private var downloadTarget: RemoteFile?
|
|
@State private var previewURL: URL?
|
|
@State private var openWithPresenter = MobileSFTPOpenWithPresenter()
|
|
@State private var importingEditedCopy = false
|
|
@State private var editedCopyTarget: UUID?
|
|
@State private var exportedEditURL: URL?
|
|
@State private var showsExportedEdit = false
|
|
@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)
|
|
.toolbar(toolbarLayout.showsWorkspaceTabs ? .visible : .hidden, 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 == .ssh {
|
|
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: Binding(get: { previewURL != nil }, set: { if !$0 { previewURL = nil } }),
|
|
onDismiss: {
|
|
Task { await workspace.sftpEdits?.recheck() }
|
|
showsExportedEdit = exportedEditURL != nil
|
|
}
|
|
) {
|
|
if let previewURL {
|
|
MobileSFTPEditPreview(
|
|
url: previewURL,
|
|
allowsEditing: workspace.sftpEdits?.model.files.contains { $0.localURL == previewURL } == true,
|
|
changed: { Task { await workspace.sftpEdits?.recheck() } },
|
|
export: { exportedEditURL = $0 },
|
|
failed: { workspace.operationError = $0 },
|
|
dismiss: { self.previewURL = nil }
|
|
)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showsExportedEdit) {
|
|
if let exportedEditURL {
|
|
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: $importingEditedCopy, allowedContentTypes: [.data], allowsMultipleSelection: false
|
|
) { result in
|
|
let target = editedCopyTarget
|
|
editedCopyTarget = nil
|
|
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: Binding(
|
|
get: { downloadTarget != nil },
|
|
set: { if !$0 { downloadTarget = nil } }
|
|
),
|
|
allowedContentTypes: [.folder],
|
|
allowsMultipleSelection: false
|
|
) { result in
|
|
let target = downloadTarget
|
|
downloadTarget = nil
|
|
switch result {
|
|
case .success(let urls):
|
|
if let target, let destination = urls.first {
|
|
workspace.prepareDownload(target, to: destination)
|
|
}
|
|
case .failure(let error):
|
|
workspace.operationError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
private var workspaceDialogs: some View {
|
|
workspaceSheets
|
|
.onChange(of: workspace.transferConflictCount) { _, count in
|
|
uploadConflictPresented = count > 0
|
|
}
|
|
.onChange(of: uploadConflictPresented) { wasPresented, isPresented in
|
|
if wasPresented, !isPresented, workspace.hasPendingTransferConflict {
|
|
workspace.cancelPendingUpload()
|
|
}
|
|
}
|
|
.onDisappear {
|
|
workspace.cancelPendingUpload()
|
|
workspace.cancelCommandGeneration()
|
|
}
|
|
.confirmationDialog(
|
|
uploadConflictTitle,
|
|
isPresented: $uploadConflictPresented,
|
|
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) {}
|
|
}
|
|
.confirmationDialog(
|
|
deletionTitle, isPresented: deletingPresented,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) {
|
|
if let file = deleting { Task { await workspace.sftpChannel.delete(file) } }
|
|
deleting = nil
|
|
}
|
|
} message: {
|
|
if let deleting {
|
|
Text(AppStrings.SFTP.deletionMessage(paths: deleting.path, remainingCount: 0, locale: locale))
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
.terminalKeywordHighlighting(
|
|
state: workspace.terminal, model: workspace.terminalHighlightSettings
|
|
)
|
|
.modifier(
|
|
MobileTerminalInitialFocus(
|
|
terminal: workspace.terminal,
|
|
isReady: workspaceIsVisible && scenePhase == .active && workspace.connected
|
|
&& !showsJourney)
|
|
)
|
|
.id(workspace.id)
|
|
.onChange(of: workspace.terminalSettings.preferences, initial: true) { _, value in
|
|
workspace.terminal.applyPreferences(value, font: resolvedFont)
|
|
}
|
|
.onChange(of: resolvedFont) { _, value in
|
|
workspace.terminal.applyPreferences(workspace.terminalSettings.preferences, font: value)
|
|
}
|
|
.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
|
|
editedCopyTarget =
|
|
workspace.sftpEdits?.model.file(
|
|
hostID: workspace.descriptor.hostID, path: file.path)?.id
|
|
importingEditedCopy = editedCopyTarget != nil
|
|
},
|
|
download: { downloadTarget = $0 },
|
|
copy: copyRemoteItem,
|
|
paste: pasteRemoteItems,
|
|
rename: { renaming = $0 },
|
|
editPermissions: { editingPermissions = $0 },
|
|
delete: { deleting = $0 }
|
|
)
|
|
.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) {
|
|
exportedEditURL = nil
|
|
previewURL = 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 func enqueuePendingUpload(_ policy: TransferConflictPolicy) {
|
|
workspace.enqueuePendingTransfer(policy)
|
|
uploadConflictPresented = false
|
|
}
|
|
|
|
@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 == .ssh
|
|
? 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 var deletingPresented: Binding<Bool> {
|
|
Binding(
|
|
get: { deleting != nil },
|
|
set: { if !$0 { deleting = nil } }
|
|
)
|
|
}
|
|
|
|
private var deletionTitle: LocalizedStringResource {
|
|
deleting.map { AppStrings.SFTP.deleteConfirmation($0.name) }
|
|
?? AppStrings.Workspace.deleteFileConfirmation
|
|
}
|
|
|
|
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:
|
|
Button(AppStrings.Connections.openWithSsh.swiftUIKey, systemImage: "terminal") {
|
|
openSameDirectory(launch)
|
|
}
|
|
case .telnet:
|
|
EmptyView()
|
|
case .sftp:
|
|
Button(AppStrings.SFTP.openWithSftp.swiftUIKey, systemImage: "folder") { openSameDirectory(launch) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private var showsJourney: Bool {
|
|
workspace.journey.isPresented || workspace.authenticationRequired || workspace.hostKeyPrompt != nil
|
|
}
|
|
|
|
private var isSFTP: Bool { workspace.descriptor.kind == .sftp }
|
|
|
|
private var showsCommandGeneratorOverlay: Bool {
|
|
workspace.descriptor.kind == .ssh && 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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
private struct MobileWorkspaceSearchModifier: ViewModifier {
|
|
@Bindable var workspace: MobileWorkspace
|
|
@State private var queryRevision: UInt64 = 0
|
|
|
|
@ViewBuilder
|
|
func body(content: Content) -> some View {
|
|
switch workspace.descriptor.kind {
|
|
case .ssh, .telnet:
|
|
searchable(content)
|
|
case .sftp:
|
|
content
|
|
.mobileToolbarSearch(
|
|
text: Binding(
|
|
get: { workspace.sftpChannel.query },
|
|
set: { workspace.sftpChannel.query = $0 }
|
|
),
|
|
prompt: Text(AppStrings.Common.search)
|
|
)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
}
|
|
}
|
|
|
|
private func searchable(_ content: Content) -> some View {
|
|
content
|
|
.mobileToolbarSearch(
|
|
text: query,
|
|
isPresented: presentation,
|
|
prompt: Text(AppStrings.Workspace.find)
|
|
)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.onSubmit(of: .search) { workspace.find.presentAndNavigate(.previous) }
|
|
}
|
|
|
|
private var query: Binding<String> {
|
|
Binding(
|
|
get: { workspace.find.query },
|
|
set: { query in
|
|
queryRevision &+= 1
|
|
let revision = queryRevision
|
|
guard query.isEmpty else {
|
|
workspace.find.updateQuery(query)
|
|
return
|
|
}
|
|
|
|
// SwiftUI clears the text binding when its native Close button
|
|
// dismisses search. Defer an empty value for one turn so a
|
|
// genuine clear is still sent to Ghostty while dismissal keeps
|
|
// the retained query for the next presentation.
|
|
DispatchQueue.main.async {
|
|
guard revision == queryRevision, workspace.find.isPresented else { return }
|
|
workspace.find.updateQuery("")
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private var presentation: Binding<Bool> {
|
|
Binding(
|
|
get: { workspace.find.isPresented },
|
|
set: { presented in
|
|
if presented {
|
|
queryRevision &+= 1
|
|
workspace.find.present()
|
|
} else if workspace.find.isPresented {
|
|
workspace.find.dismiss()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|