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] 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? @Environment(\.scenePhase) private var scenePhase @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: { Label("Workspaces", systemImage: "rectangle.stack") } .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("Generate Command", systemImage: "sparkles") { workspace.generateCommand(using: commandGenerator) } Divider() Button("Snippets", systemImage: "curlybraces") { terminalSheet = .snippets } Button("History", systemImage: "clock") { terminalSheet = .commandHistory } Divider() Button("Next Match", systemImage: "chevron.down") { workspace.find.presentAndNavigate(.previous) } Button("Previous Match", systemImage: "chevron.up") { workspace.find.presentAndNavigate(.next) } Divider() sameDirectoryMenuAction workspaceLifecycleActions } } if workspace.descriptor.kind == .telnet { MobileToolbarOverflow(placement: .topBarTrailing) { Button("Snippets", systemImage: "curlybraces") { terminalSheet = .snippets } Divider() Button("Next Match", systemImage: "chevron.down") { workspace.find.presentAndNavigate(.previous) } Button("Previous Match", systemImage: "chevron.up") { workspace.find.presentAndNavigate(.next) } Divider() workspaceLifecycleActions } } if isSFTP { MobileToolbarOverflow(placement: .topBarTrailing) { Button("Previous Location", systemImage: "chevron.left") { Task { await workspace.sftpChannel.goBack() } } .disabled(!workspace.connected || !workspace.sftpChannel.canGoBack) Button("Next Location", systemImage: "chevron.right") { Task { await workspace.sftpChannel.goForward() } } .disabled(!workspace.connected || !workspace.sftpChannel.canGoForward) Divider() Button("Transfers", systemImage: "arrow.up.arrow.down") { showingTransfers = true } Button("History", systemImage: "clock") { terminalSheet = .sftpHistory } Divider() Button("Upload", systemImage: "square.and.arrow.up") { importing = true } .disabled(!workspace.connected) Button("New Folder", systemImage: "folder.badge.plus") { creatingDirectory = true } .disabled(!workspace.connected) Button("Paste", systemImage: "doc.on.clipboard", action: pasteRemoteItems) .disabled(!workspace.connected) sortMenu if workspace.sftpChannel.showsHiddenFiles { Button("Hide Hidden Files", systemImage: "eye.slash") { workspace.sftpChannel.showsHiddenFiles = false } } else { Button("Show Hidden Files", systemImage: "eye") { workspace.sftpChannel.showsHiddenFiles = true } } Button("Refresh", 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("The edited copy uses a different format").font(.headline) Text("Save this copy separately to keep the original remote file format.") ShareLink(item: exportedEditURL) { Label("Keep Local Copy", 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("Replace") { enqueuePendingUpload(.replace) } Button("Skip Existing") { enqueuePendingUpload(.skip) } Button("Keep Both") { enqueuePendingUpload(.keepBoth) } Button("Cancel", role: .cancel) { workspace.cancelPendingUpload() } } message: { Text("Use this choice for all conflicts in this batch.") } .alert("New Folder", isPresented: $creatingDirectory) { TextField("Name", text: $directoryName.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1) Button("Create") { Task { await workspace.sftpChannel.createDirectory(directoryName) directoryName = "" } } Button("Cancel", role: .cancel) {} } .confirmationDialog( "Delete File?", isPresented: deletingPresented, titleVisibility: .visible ) { Button("Delete", role: .destructive) { if let file = deleting { Task { await workspace.sftpChannel.delete(file) } } deleting = nil } } } private var workspaceAlerts: some View { workspaceDialogs .alert( operationAlertTitle, isPresented: operationErrorPresented ) { Button("OK", 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 { workspace.transferConflictCount == 1 ? String(localized: "1 item already exists") : String(localized: "\(workspace.transferConflictCount) items already exist") } private var uploadDropPresentation: DropTargetPresentation { if let problem = workspace.uploadTargetProblem { return DropTargetPresentation( title: String(localized: "Can't Upload Here"), detail: problem, systemImage: "exclamationmark.triangle.fill", tone: .unavailable ) } let hostName = library.host(workspace.descriptor.hostID)?.name ?? workspace.descriptor.title return DropTargetPresentation( title: String(localized: "Upload to \(hostName)"), detail: workspace.uploadDestination ?? String(localized: "The upload destination is unavailable."), 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("Name", field: .name, symbol: "textformat") sortButton("Date", field: .modified, symbol: "calendar") sortButton("Size", field: .size, symbol: "externaldrive") sortButton("Kind", field: .kind, symbol: "doc") } label: { Label { VStack(alignment: .leading) { Text("Sort By") Text(sortFieldTitle).font(.caption).foregroundStyle(.secondary) } } icon: { Image(systemName: "arrow.up.arrow.down.square") } } } private func sortButton( _ title: LocalizedStringKey, 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: "Name") case .modified: String(localized: "Date") case .size: String(localized: "Size") case .kind: String(localized: "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: "The copied SFTP items are invalid.") return } workspace.prepareRemoteCopy(payload) } private var presentedOperationError: String? { workspace.operationError ?? workspace.sftpChannel.operationError } private var operationAlertTitle: String { workspace.descriptor.kind == .ssh ? String(localized: "Upload Failed") : String(localized: "SFTP Error") } private var operationErrorPresented: Binding { Binding( get: { presentedOperationError != nil }, set: { if !$0 { workspace.operationError = nil workspace.sftpChannel.operationError = nil } } ) } private var deletingPresented: Binding { Binding( get: { deleting != nil }, set: { if !$0 { deleting = nil } } ) } @ViewBuilder private func workspaceTitleMenuLabel(for candidate: MobileWorkspaceDescriptor) -> some View { if candidate.id == workspace.id { Label(candidate.title, systemImage: "checkmark") } else { Label(candidate.title, systemImage: candidate.kind.symbol) } } @ViewBuilder private var sameDirectoryMenuAction: some View { if let launch = workspace.sameDirectoryLaunch { switch launch.kind { case .ssh: Button("Open with SSH", systemImage: "terminal") { openSameDirectory(launch) } case .telnet: EmptyView() case .sftp: Button("Open with SFTP", 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) }, keyboardAuthentication: workspace.keyboardAuthentication) } } struct MobileWorkspaceLifecycleMenuActions: View { let canCloseMultipleWorkspaces: Bool let rename: () -> Void let close: () -> Void let closeOthers: () -> Void var body: some View { Button("Rename", systemImage: "pencil", action: rename) Button("Close", systemImage: "xmark", role: .destructive, action: close) if canCloseMultipleWorkspaces { Divider() Button("Close Other", 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: "Generating Command") case .inserted: String(localized: "Command Inserted") case .failed: String(localized: "Couldn't generate command") } } private var detail: String? { switch state { case .idle: nil case .readingDraft, .generating: String(localized: "This May Take a Moment") case .inserted: String(localized: "Press Return to Run") 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: 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("Search") ) .textInputAutocapitalization(.never) .autocorrectionDisabled() } } private func searchable(_ content: Content) -> some View { content .mobileToolbarSearch( text: query, isPresented: presentation, prompt: Text("Find") ) .textInputAutocapitalization(.never) .autocorrectionDisabled() .onSubmit(of: .search) { workspace.find.presentAndNavigate(.previous) } } private var query: Binding { 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 { Binding( get: { workspace.find.isPresented }, set: { presented in if presented { queryRevision &+= 1 workspace.find.present() } else if workspace.find.isPresented { workspace.find.dismiss() } } ) } }