134 lines
4.4 KiB
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)
|
|
}
|
|
}
|
|
}
|