Files
Termind/TFI/Features/Workspaces/MobileWorkspaceView.swift
T
Kaede Akino 816a6ff121 feat(ssh): support hardware security key authentication
Add native security key registration, reference import, and signing on macOS and iOS. Store references in Vault metadata and route authentication through connection, Assistant, MCP, and agent forwarding flows.

Preserve signing cancellation and actionable authentication errors for security keys and Secure Enclave. Pin the released libssh2 forks as nested submodules and refresh package resolution.
2026-09-28 23:37:49 +08:00

960 lines
39 KiB
Swift

import GhosttyTerminal
import QuickLook
import SwiftUI
import TermindApplication
import TermindDomain
import TermindInfrastructure
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 pro: ProAccessController
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 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
)
.id(workspace.id)
}
.onAppear { workspaceIsVisible = true }
.onDisappear {
openWithPresenter.isEnabled = false
workspaceIsVisible = false
uploadDropTargeted = false
cancelDropLoading()
}
.onChange(of: workspace.id) { _, _ in
openWithPresenter.invalidate()
uploadDropTargeted = false
cancelDropLoading()
}
.onChange(of: workspace.uploadDestination) { _, _ 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))
}
}
.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: Binding(
get: { workspace.terminalTextSelection },
set: { if $0 == nil { workspace.dismissTerminalTextSelection() } }
),
onDismiss: { workspace.terminal.requestFocus() }
) { selection in
MobileTerminalTextSelectionView(selection: selection)
.mobileSheetPresentation(compact: true)
}
.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)
}
}
.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)
.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() }
.onTapGesture { workspace.terminal.requestFocus() }
MobileTerminalKeyboard(state: workspace.terminal) {
guard let text = UIPasteboard.general.string else { return }
workspace.insertTerminalText(text)
}
}.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 receiveUpload(_ urls: [URL]) -> Bool {
if workspace.descriptor.kind == .ssh,
!pro.require(.terminalFileUpload)
{
return false
}
return workspace.prepareUpload(urls)
}
private func receiveUploadProviders(_ providers: [NSItemProvider]) -> Bool {
guard MobileDropPayloadLoader.acceptsUpload(providers) else { return false }
uploadDropTask?.cancel()
workspace.cancelPendingUpload()
let destination = workspace.uploadDestination
uploadDropTask = Task {
let payload = await MobileDropPayloadLoader.load(
providers,
kinds: [.files],
fileMode: .staged
)
guard !Task.isCancelled, workspace.uploadDestination == destination else {
if let stagedRoot = payload.stagedRoot { try? FileManager.default.removeItem(at: stagedRoot) }
return
}
if !payload.fileURLs.isEmpty {
let accepted = receiveUpload(payload.fileURLs)
if !accepted, let stagedRoot = payload.stagedRoot {
try? FileManager.default.removeItem(at: stagedRoot)
}
}
if let error = payload.failures.first {
workspace.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()
}
}
)
}
}