diff --git a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift index c833396..1d3d709 100644 --- a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift +++ b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift @@ -8,6 +8,9 @@ import NucleicProtocol struct ApprovalCardView: View { @EnvironmentObject var store: RemoteStore let approval: ApprovalRequest + /// The transcript's on-screen height, so the card can cap itself and scroll its detail rather + /// than growing past the screen and pushing Allow/Deny out of reach. 0 until measured. + var availableHeight: CGFloat = 0 @State private var showAlwaysMenu = false @@ -27,22 +30,14 @@ struct ApprovalCardView: View { .background(approval.risk.color.opacity(0.2), in: Capsule()) .foregroundStyle(approval.risk.color) } - // A git commit reads as a structured commit card (subject + Markdown body) so you can - // see exactly what you're granting; the literal command stays under "Show command". - // Otherwise a host_exec gate lays out its parsed breakdown + exact command, and every - // other tool shows its untruncated detail in a single scrollable box. - if let command = approval.input["command"]?.stringValue, - let commit = GitCommitSummary.parse(command) { - GitCommitCard(commit: commit, rawCommand: command) - } else if let command = approval.input["command"]?.stringValue, - let parsed = HostCommandSummary.summary(for: command), - approval.toolName == HostCommandSummary.hostExecToolName - || parsed.invocations.count > 1 || parsed.isDestructive { - HostCommandBreakdown(summary: parsed) - Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary) - ApprovalDetailBox(text: command) - } else if !approval.input.approvalDetail.isEmpty { - ApprovalDetailBox(text: approval.input.approvalDetail) + + // The detail (commit card, host breakdown + command, or plain text) scrolls inside a + // bounded region so a giant command can't grow the card off-screen; the buttons below + // stay pinned and always tappable. Rendered only when there's detail to show. + if hasDetail { + BoundedCardScroll(maxHeight: middleMaxHeight) { + VStack(alignment: .leading, spacing: 10) { detailContent } + } } HStack(spacing: 10) { @@ -83,6 +78,73 @@ struct ApprovalCardView: View { } .padding() .glassSurface(cornerRadius: 20) + // A hard ceiling on the whole card: even if the chrome estimate is off, the card can never + // exceed the screen. `nil` (before the height is measured) leaves it unconstrained. + .frame(maxHeight: availableHeight > 0 ? availableHeight - 12 : nil, alignment: .bottom) + } + + // MARK: - Detail + + /// The kinds of detail an approval carries, resolved once so the body and `hasDetail` agree. + /// A git commit reads as a structured commit card (subject + Markdown body); a host_exec gate + /// lays out its parsed breakdown + exact command; every other tool shows its untruncated detail. + private enum Detail { + case commit(GitCommitSummary.Commit, command: String) + case host(HostCommandSummary.Summary, command: String) + case text(String) + case none + } + + private var detail: Detail { + if let command = approval.input["command"]?.stringValue, + let commit = GitCommitSummary.parse(command) { + return .commit(commit, command: command) + } + if let command = approval.input["command"]?.stringValue, + let parsed = HostCommandSummary.summary(for: command), + approval.toolName == HostCommandSummary.hostExecToolName + || parsed.invocations.count > 1 || parsed.isDestructive { + return .host(parsed, command: command) + } + let text = approval.input.approvalDetail + return text.isEmpty ? .none : .text(text) + } + + private var hasDetail: Bool { + if case .none = detail { return false } + return true + } + + @ViewBuilder private var detailContent: some View { + switch detail { + case .commit(let commit, let command): + GitCommitCard(commit: commit, rawCommand: command) + case .host(let parsed, let command): + HostCommandBreakdown(summary: parsed) + Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary) + ApprovalDetailBox(text: Self.truncatedForDisplay(command)) + case .text(let text): + ApprovalDetailBox(text: Self.truncatedForDisplay(text)) + case .none: + EmptyView() + } + } + + /// The height the scrolling detail may take before it starts scrolling — the screen minus a + /// generous reserve for the header, risk tag, buttons, and "Allow always…" menu, so those + /// always have room. Falls back to a roomy default before the height is measured. + private var middleMaxHeight: CGFloat { + let base = availableHeight > 0 ? availableHeight - 12 : 640 + return max(140, base - 220) + } + + /// A very long command/detail is capped for display so one giant blob can't dominate the card; + /// the middle still scrolls for the rest. The full command is still what gets approved — the + /// breakdown above spells out its structure regardless. + private static func truncatedForDisplay(_ text: String, limit: Int = 4000) -> String { + guard text.count > limit else { return text } + let omitted = text.count - limit + return String(text.prefix(limit)) + "\n… [\(omitted) more characters truncated]" } private func alwaysLabel(_ scope: AlwaysScope) -> String { @@ -94,33 +156,47 @@ struct ApprovalCardView: View { } } -/// The full content of an approval, wrapped and selectable. The box sizes to its -/// content and only begins scrolling once the content exceeds `maxHeight`, so a short -/// command sits in a snug box while a large paste stays bounded. The text wraps; it -/// never scrolls horizontally. +/// The full content of an approval, wrapped and selectable. It no longer scrolls on its own — the +/// enclosing ``BoundedCardScroll`` owns the scrolling for the whole card — so it simply sizes to +/// its (already length-capped) text. The text wraps; it never scrolls horizontally. private struct ApprovalDetailBox: View { let text: String - private let maxHeight: CGFloat = 200 + + var body: some View { + Text(text) + .font(.caption.monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8)) + } +} + +/// Wraps the variable-height middle of an input card so it scrolls only once it would exceed +/// `maxHeight`. Below that it sizes to its content (a snug card); above it the content scrolls +/// while the card's header and action buttons stay pinned on-screen — so a giant command or a long +/// list of options can never push Allow/Deny (or Submit) off the bottom of the phone. Shared by the +/// approval and question cards. +struct BoundedCardScroll: View { + let maxHeight: CGFloat + @ViewBuilder var content: Content @State private var contentHeight: CGFloat = 0 var body: some View { ScrollView(.vertical) { - Text(text) - .font(.caption.monospaced()) - .textSelection(.enabled) + content .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) .background(GeometryReader { geo in - Color.clear.preference(key: ApprovalDetailHeightKey.self, value: geo.size.height) + Color.clear.preference(key: CardContentHeightKey.self, value: geo.size.height) }) } .frame(height: contentHeight == 0 ? nil : min(contentHeight, maxHeight)) - .onPreferenceChange(ApprovalDetailHeightKey.self) { contentHeight = $0 } - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8)) + .scrollBounceBehavior(.basedOnSize) + .onPreferenceChange(CardContentHeightKey.self) { contentHeight = $0 } } } -private struct ApprovalDetailHeightKey: PreferenceKey { +struct CardContentHeightKey: PreferenceKey { static var defaultValue: CGFloat { 0 } static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue())