pi-remote-ios/Sources/UI/Input/PasteSheet.swift

134 lines
4.4 KiB
Swift

// PasteSheet.swift
// Confirm-before-paste sheet that previews clipboard content and lets
// the user approve or cancel before sending a bracketed-paste frame.
//
// Privacy note: `UIPasteboard.general.string` is accessed lazily when the
// sheet appears. iOS 16+ shows a system banner ("App pasted from ") but
// does not require an explicit entitlement for this access pattern.
import SwiftUI
import UIKit
// MARK: - PasteSheet
/// A modal sheet that displays the current clipboard text and asks the user
/// to confirm before sending it to the terminal as a `paste` frame.
///
/// Dismiss flow:
/// - **Paste** encodes content as `{ type:"paste", data:"" }` and calls
/// `onSend`, then sets `isPresented` to `false`.
/// - **Cancel** sets `isPresented` to `false` with no send.
@MainActor
struct PasteSheet: View {
// MARK: Bindings / callbacks
@Binding var isPresented: Bool
let onSend: (ClientToServer) -> Void
// MARK: Private state
/// The clipboard string captured when the view appears.
@State private var clipboardContent: String? = nil
// MARK: Body
var body: some View {
NavigationStack {
Group {
if let content = clipboardContent {
if content.isEmpty {
emptyClipboardView
} else {
previewView(content: content)
}
} else {
emptyClipboardView
}
}
.navigationTitle("Paste")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
isPresented = false
}
}
if let content = clipboardContent, !content.isEmpty {
ToolbarItem(placement: .confirmationAction) {
Button("Paste") {
onSend(.paste(data: content))
isPresented = false
}
.fontWeight(.semibold)
}
}
}
}
.onAppear {
// Capture clipboard when the sheet is presented.
// Accessing on the main actor satisfies UIKit's thread requirement.
clipboardContent = UIPasteboard.general.string
}
}
// MARK: Sub-views
/// Shown when the clipboard is nil or empty.
private var emptyClipboardView: some View {
VStack(spacing: 12) {
Image(systemName: "clipboard")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("Clipboard is empty")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
/// Scrollable preview of the clipboard text with a bottom Paste button.
private func previewView(content: String) -> some View {
VStack(spacing: 0) {
// Scrollable text preview
ScrollView([.vertical, .horizontal]) {
Text(content)
.font(.system(size: 13, design: .monospaced))
.foregroundStyle(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.textSelection(.enabled)
}
.background(Color(uiColor: .systemGroupedBackground))
Divider()
// Character count footer
HStack {
Label(
"\(content.count) character\(content.count == 1 ? "" : "s")",
systemImage: "text.cursor"
)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal)
.padding(.vertical, 8)
// Primary action button
Button {
onSend(.paste(data: content))
isPresented = false
} label: {
Label("Paste into Terminal", systemImage: "doc.on.clipboard")
.font(.body.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.padding(.horizontal)
.padding(.bottom, 16)
}
}
}