diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx
index cbb6adf..a38c837 100644
--- a/ui/src/components/OperationPanel.jsx
+++ b/ui/src/components/OperationPanel.jsx
@@ -1219,18 +1219,69 @@ function ApplyModeChooser({ op, applyMode, setApplyMode, count }) {
return
}
-function RequestPreview({ payload }) {
+// The request as it will be sent. A 40rem-tall box in the corner of a docked
+// panel could show about six lines of it, which is enough to see that there is
+// JSON and not enough to read it, so it opens as a window of its own.
+//
+// It is the payload itself -- `submitOp` posts exactly what `buildPayload`
+// returns, with no last-minute additions -- so what is shown here is what goes
+// over the wire, minus the two things the browser never sees: the session that
+// names `pf_user`, and the territory predicate the server ANDs on.
+function RequestPreview({ payload, op, versionId }) {
const [open, setOpen] = useState(false)
+ const [copied, setCopied] = useState(false)
+
+ useEffect(() => {
+ if (!open) return
+ const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [open])
+
if (!payload) return null
+ const text = JSON.stringify(payload, null, 2)
+ const line = `POST /api/versions/${versionId}/${op}`
+
+ // navigator.clipboard is undefined on a plain-http origin, which this app is
+ // served over, so the fallback is not decoration.
+ const copy = async () => {
+ try {
+ if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text)
+ else {
+ const ta = document.createElement('textarea')
+ ta.value = text
+ ta.style.position = 'fixed'; ta.style.opacity = '0'
+ document.body.appendChild(ta); ta.select()
+ document.execCommand('copy')
+ document.body.removeChild(ta)
+ }
+ setCopied(true); setTimeout(() => setCopied(false), 1500)
+ } catch { /* leave the text on screen to select by hand */ }
+ }
+
return (