Nucleic-Session: 4E56990A-96AE-4597-9140-753CB82E769D Co-authored-by: Nucleic <[email protected]>
129 lines
5.8 KiB
Swift
129 lines
5.8 KiB
Swift
import SwiftUI
|
|
import NucleicProtocol
|
|
|
|
/// The defining interaction (UX_IOS §5): answer an approval. High-risk (destructive/network)
|
|
/// still routes through the app (never the notification banner) so approval is a deliberate,
|
|
/// in-context tap. First-responder-wins: if the Mac (or another phone) answers first, the
|
|
/// host's `approvalResolved` removes this card.
|
|
struct ApprovalCardView: View {
|
|
@EnvironmentObject var store: RemoteStore
|
|
let approval: ApprovalRequest
|
|
|
|
@State private var showAlwaysMenu = false
|
|
|
|
private var allowEnabled: Bool { store.connectivity.isLive }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
|
|
Text("Permission requested").font(.subheadline.weight(.semibold))
|
|
}
|
|
HStack(spacing: 6) {
|
|
Text(approval.toolName).font(.caption.weight(.bold))
|
|
Text(approval.risk.label)
|
|
.font(.caption2.weight(.semibold))
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.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)
|
|
}
|
|
|
|
HStack(spacing: 10) {
|
|
Button(role: .destructive) {
|
|
store.respond(approval, .deny(reason: nil))
|
|
} label: {
|
|
Text("Deny").frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
// Hardware-keyboard shortcuts for the defining interaction: Esc denies, Return
|
|
// allows (the approval bar replaces the composer, so Return is unclaimed here).
|
|
.keyboardShortcut(.cancelAction)
|
|
|
|
Button {
|
|
store.respond(approval, .allow())
|
|
} label: {
|
|
Text("Allow").frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(!allowEnabled)
|
|
.keyboardShortcut(.defaultAction)
|
|
}
|
|
|
|
// A destructive action (rm, force-push, reset --hard, …) offers no remembered
|
|
// allow: every one must be a deliberate, one-off approval, never granted in a
|
|
// way that lets the next one through unseen. Mirrors the Mac.
|
|
if approval.risk != .destructive, !store.capabilities.allowAlwaysScopes.isEmpty {
|
|
Menu("Allow always…") {
|
|
ForEach(store.capabilities.allowAlwaysScopes, id: \.self) { scope in
|
|
Button(alwaysLabel(scope)) {
|
|
store.respond(approval, .allowAlways(scope))
|
|
}
|
|
.disabled(!allowEnabled)
|
|
}
|
|
}
|
|
.font(.caption)
|
|
}
|
|
}
|
|
.padding()
|
|
.glassSurface(cornerRadius: 20)
|
|
}
|
|
|
|
private func alwaysLabel(_ scope: AlwaysScope) -> String {
|
|
switch scope {
|
|
case .session: "This command, this session"
|
|
case .toolName: "Any \(approval.toolName), this session"
|
|
case .toolNameWithPattern: "\(approval.toolName) matching this pattern"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
private struct ApprovalDetailBox: View {
|
|
let text: String
|
|
private let maxHeight: CGFloat = 200
|
|
@State private var contentHeight: CGFloat = 0
|
|
|
|
var body: some View {
|
|
ScrollView(.vertical) {
|
|
Text(text)
|
|
.font(.caption.monospaced())
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(8)
|
|
.background(GeometryReader { geo in
|
|
Color.clear.preference(key: ApprovalDetailHeightKey.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))
|
|
}
|
|
}
|
|
|
|
private struct ApprovalDetailHeightKey: PreferenceKey {
|
|
static var defaultValue: CGFloat { 0 }
|
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
value = max(value, nextValue())
|
|
}
|
|
}
|