feat: copy the output panel to the system clipboard (#11)
New app-level `copy` / `copy all` / `copy last` command (ADR-0041). Delivery is OSC 52 *and* a best-effort native write (arboard), always both — OSC 52 acceptance is undetectable, so a true fallback can't be built. Payload is the panel's plain text exactly as rendered (tags, ✓/✗, box-drawing), drift-locked to render_output_line. arboard added --no-default-features (X11-only; OSC 52 covers Wayland). Amends ADR-0003's command registry; requirements V6.
This commit is contained in:
Generated
+213
-9
@@ -47,6 +47,23 @@ dependencies = [
|
|||||||
"derive_arbitrary",
|
"derive_arbitrary",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arboard"
|
||||||
|
version = "3.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||||
|
dependencies = [
|
||||||
|
"clipboard-win",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-foundation",
|
||||||
|
"parking_lot",
|
||||||
|
"percent-encoding",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
"x11rb",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic"
|
name = "atomic"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -175,6 +192,15 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clipboard-win"
|
||||||
|
version = "5.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||||
|
dependencies = [
|
||||||
|
"error-code",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compact_str"
|
name = "compact_str"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -197,7 +223,7 @@ checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"encode_unicode",
|
"encode_unicode",
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -427,7 +453,17 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dispatch2"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -464,9 +500,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "error-code"
|
||||||
|
version = "3.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "euclid"
|
name = "euclid"
|
||||||
version = "0.22.14"
|
version = "0.22.14"
|
||||||
@@ -986,7 +1028,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"wasi",
|
"wasi",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1027,7 +1069,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1065,6 +1107,27 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
|
||||||
|
dependencies = [
|
||||||
|
"objc2-encode",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-app-kit"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-foundation"
|
name = "objc2-core-foundation"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -1072,6 +1135,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
|
"dispatch2",
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-graphics"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"dispatch2",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-io-surface",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-encode"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-foundation"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1084,6 +1179,17 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-io-surface"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -1128,6 +1234,12 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pest"
|
name = "pest"
|
||||||
version = "2.8.6"
|
version = "2.8.6"
|
||||||
@@ -1419,6 +1531,7 @@ name = "rdbms-playground"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"arboard",
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
@@ -1534,7 +1647,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1713,7 +1826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1807,7 +1920,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1957,7 +2070,7 @@ dependencies = [
|
|||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2422,6 +2535,15 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -2431,6 +2553,23 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.53.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-threading"
|
name = "windows-threading"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -2440,6 +2579,54 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
@@ -2534,6 +2721,23 @@ dependencies = [
|
|||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x11rb"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||||
|
dependencies = [
|
||||||
|
"gethostname",
|
||||||
|
"rustix",
|
||||||
|
"x11rb-protocol",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x11rb-protocol"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yansi"
|
name = "yansi"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ publish = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
|
# Native system-clipboard for the `copy` command (ADR-0041). Text
|
||||||
|
# only: `default-features = false` drops the heavy `image` crate. On
|
||||||
|
# Linux this is X11-only — `wayland-data-control` is deliberately
|
||||||
|
# omitted (it ~doubles the dep tree), as OSC 52 covers native-Wayland
|
||||||
|
# sessions and XWayland covers the rest.
|
||||||
|
arboard = { version = "3.6.1", default-features = false }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
# `clock` brings local-timezone support (UTC → machine local) for
|
# `clock` brings local-timezone support (UTC → machine local) for
|
||||||
# the undo-dialog snapshot timestamp (issue #13). No locale feature:
|
# the undo-dialog snapshot timestamp (issue #13). No locale feature:
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ explicitly; it is not heuristic. The initial canonical list is:
|
|||||||
| `mode` | Switch between simple and advanced (`mode simple`/`mode advanced`). |
|
| `mode` | Switch between simple and advanced (`mode simple`/`mode advanced`). |
|
||||||
| `help` | Show contextual help. |
|
| `help` | Show contextual help. |
|
||||||
| `hint` | Request a hint for the current input (ADR pending). |
|
| `hint` | Request a hint for the current input (ADR pending). |
|
||||||
|
| `copy` | Copy the output panel to the system clipboard (`copy` / `copy all` / `copy last`; ADR-0041, issue #11). |
|
||||||
| `quit` | Exit the application. |
|
| `quit` | Exit the application. |
|
||||||
|
|
||||||
This list is **definitive** and applies in both modes. Adding,
|
This list is **definitive** and applies in both modes. Adding,
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# ADR-0041: Copy the output panel to the system clipboard
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Accepted** — 2026-06-02 (issue #11). Amends ADR-0003's app-command
|
||||||
|
registry (adds `copy`). First feature to add a native clipboard
|
||||||
|
dependency (`arboard`); builds on the ADR-0040 echo/marker and
|
||||||
|
ADR-0037-Am1 tag model for the "what is on screen" definition.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Filing a bug report today means terminal-selecting the relevant region
|
||||||
|
of the output panel, fighting the panel border and line wrapping, and
|
||||||
|
pasting the result — often with stray box characters or truncated
|
||||||
|
lines. A built-in copy removes that friction and tightens the
|
||||||
|
bug-report → reproduction loop, which is the stated motivation of
|
||||||
|
issue #11.
|
||||||
|
|
||||||
|
The output panel is a rolling `VecDeque<OutputLine>` (`OUTPUT_CAPACITY`
|
||||||
|
= 1000). Each `OutputLine` carries the *raw* `text` plus a `kind`, a
|
||||||
|
`mode_at_submission`, optional `styled_runs`, and an echo `status`; the
|
||||||
|
**visible** text — the `[simple]`/`[system]`/`[error]` tag, the
|
||||||
|
`running:` prefix vs. the trailing `✓`/`✗` marker (ADR-0040), the
|
||||||
|
de-emphasised `Executing SQL:` teaching prefix (ADR-0038) — is composed
|
||||||
|
at **render time** in `render_output_line`, not stored in `text`.
|
||||||
|
|
||||||
|
Four design axes were open (the issue enumerated them). Each was
|
||||||
|
escalated to the user; the answers below are the user's.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. Command surface — `copy` / `copy all` / `copy last`
|
||||||
|
|
||||||
|
A new **app-level command** (works in both modes, sigil-free per
|
||||||
|
ADR-0009), added to the ADR-0003 registry:
|
||||||
|
|
||||||
|
| Form | Copies |
|
||||||
|
|-------------|-------------------------------------------------------|
|
||||||
|
| `copy` | the **entire** output panel (bare form = `all`) |
|
||||||
|
| `copy all` | the entire output panel (explicit) |
|
||||||
|
| `copy last` | the **most recent command's** output unit |
|
||||||
|
|
||||||
|
`copy last` is defined as **from the most recent `OutputKind::Echo`
|
||||||
|
line to the end of the buffer** — i.e. that command's echo, its result
|
||||||
|
body (table / plan / counts), and any teaching-echo / cascade notes.
|
||||||
|
If the buffer has no echo line, there is nothing to copy.
|
||||||
|
|
||||||
|
**Boundary note (accepted).** App-level commands (`mode`, `messages`,
|
||||||
|
`copy` itself) push `[system]` lines with *no* echo, so they have no
|
||||||
|
clean "last command" boundary: after one, `copy last` reaches back to
|
||||||
|
the previous *DSL* command's echo and bundles the intervening
|
||||||
|
app-command notes (including a prior `copy`'s own confirmation line, if
|
||||||
|
present). This is inherent to echo-less app commands and is accepted —
|
||||||
|
`copy last` targets the last *executed DSL/data/SQL* command, which is
|
||||||
|
the bug-report case; `copy all` is the catch-all.
|
||||||
|
|
||||||
|
No keybinding (user's choice — typed command only). An unknown
|
||||||
|
sub-word (`copy foo`) funnels to a friendly `copy.unknown` error,
|
||||||
|
mirroring `mode`/`messages`.
|
||||||
|
|
||||||
|
### 2. Mechanism — OSC 52 **and** native (`arboard`), always both
|
||||||
|
|
||||||
|
A copy **always** does two things, in order:
|
||||||
|
|
||||||
|
1. **Emit an OSC 52 escape** (`ESC ] 52 ; c ; <base64> BEL`) to the
|
||||||
|
terminal. Needs no new dependency — `base64` and `crossterm` are
|
||||||
|
already present. Works over SSH (the *local* terminal owns the
|
||||||
|
clipboard). Inside **tmux** the sequence is wrapped in tmux's DCS
|
||||||
|
passthrough (`ESC P tmux; … ESC \`, every inner `ESC` doubled),
|
||||||
|
detected via `$TMUX`.
|
||||||
|
2. **Attempt a native write** via `arboard`. Reaches the local desktop
|
||||||
|
clipboard reliably; a failure (e.g. a headless SSH host with no
|
||||||
|
display) is **silently ignored** — OSC 52 has already carried the
|
||||||
|
payload.
|
||||||
|
|
||||||
|
**Why both, unconditionally.** OSC 52 acceptance is *undetectable* — the
|
||||||
|
terminal sends no acknowledgement, so a true "fall back when OSC 52 is
|
||||||
|
unsupported" cannot be built. Doing both means at least one path
|
||||||
|
delivers in every environment: local desktop (native, plus a redundant
|
||||||
|
identical OSC 52 write — harmless), SSH (OSC 52), SSH-in-tmux (wrapped
|
||||||
|
OSC 52). The two writes carry identical content, so there is no
|
||||||
|
conflict.
|
||||||
|
|
||||||
|
### 3. Format — plain text, verbatim *as shown*
|
||||||
|
|
||||||
|
The clipboard receives the **rendered logical line** for each
|
||||||
|
`OutputLine` — tag included (`[simple] create table T ✓`,
|
||||||
|
`[system] Customers`, `[error] …`), the `✓`/`✗` marker, the
|
||||||
|
`Executing SQL:` prefix, and box-drawing tables — joined by `\n`. No
|
||||||
|
colour (clipboards are plain text), no Markdown conversion, no tag
|
||||||
|
stripping. "As shown" means the renderer's per-line content, **without
|
||||||
|
the viewport's right-edge space-padding or soft-wrapping**: the copied
|
||||||
|
text is the full logical line (so it reflows cleanly in the paste
|
||||||
|
target and carries no trailing whitespace), not the literal terminal
|
||||||
|
cells. So a pasted bug report reproduces the user's screen and tells
|
||||||
|
the maintainer which lines were echoes, system notes, or errors, and
|
||||||
|
what each command's outcome was. Fidelity is enforced by a drift-lock
|
||||||
|
test against `render_output_line` (see Implementation notes).
|
||||||
|
|
||||||
|
A short `[system]` confirmation line is appended after the copy
|
||||||
|
(`copy.done`, "Copied N line(s) to the clipboard."); an empty target
|
||||||
|
yields `copy.nothing` and no clipboard write.
|
||||||
|
|
||||||
|
### 4. `arboard` features — `--no-default-features` (X11 on Linux)
|
||||||
|
|
||||||
|
`arboard` is added with **default features off** (drops the heavy
|
||||||
|
`image` crate; we only handle text). On Linux this is **X11-only** —
|
||||||
|
the `wayland-data-control` feature was *deliberately not* enabled
|
||||||
|
because it nearly doubles the dependency tree (~30 crates:
|
||||||
|
`wl-clipboard-rs`, `wayland-*`, `quick-xml`, `nom`, `petgraph`), and
|
||||||
|
**OSC 52 already covers native-Wayland sessions** (and most Wayland
|
||||||
|
desktops run XWayland, so `x11rb` works regardless). Minimising
|
||||||
|
dependency surface is the secure-by-default posture (CLAUDE.md
|
||||||
|
security policy). Revisit only if a concrete native-Wayland-without-OSC-52
|
||||||
|
need appears.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
New dependency, so the Security-Reviewer lens applies (CLAUDE.md):
|
||||||
|
|
||||||
|
- **Maintainer/licence:** `arboard` `3.6.1`, maintained by 1Password,
|
||||||
|
`MIT OR Apache-2.0` (matches the project), MSRV 1.71 — the de-facto
|
||||||
|
standard Rust clipboard crate.
|
||||||
|
- **Scans (against the `--no-default-features` lockfile):**
|
||||||
|
`cargo audit` (41 crates) → 0 vulnerabilities; `osv-scanner` → no
|
||||||
|
issues. Re-run before signoff.
|
||||||
|
- **Feature posture:** write-only. We never *read* the clipboard, so the
|
||||||
|
OSC 52 *read* exfiltration vector is not in play; OSC 52/native *write*
|
||||||
|
of the user's own visible output is benign.
|
||||||
|
- **No secrets exposure:** the playground holds learning data, not
|
||||||
|
credentials; copied content is whatever the user already sees.
|
||||||
|
|
||||||
|
## Limitations (accepted, documented)
|
||||||
|
|
||||||
|
- **OSC 52 payload size:** some terminals cap the escape length (older
|
||||||
|
xterm defaults are small). A very large `copy all` may be truncated
|
||||||
|
*on the OSC 52 path* in such terminals; the native path delivers the
|
||||||
|
full text locally. The 1000-line buffer cap bounds the worst case.
|
||||||
|
- **OSC 52 terminal support varies** and **cannot be confirmed** (no
|
||||||
|
read-back). tmux needs `set-clipboard on`; `screen` passthrough is a
|
||||||
|
different format and is **not** wrapped (documented gap).
|
||||||
|
- **Native over SSH** writes the *remote* machine's clipboard (useless
|
||||||
|
to the user) — which is exactly why OSC 52 runs too and the native
|
||||||
|
error is ignored.
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
- **`Action::CopyToClipboard(String)`** — `update()` stays pure: it
|
||||||
|
builds the full text from `App.output` and returns the action; the
|
||||||
|
runtime performs the I/O. The confirmation `[system]` line is pushed
|
||||||
|
*after* the text is captured, so it is never part of the copy.
|
||||||
|
- **`OutputLine::plain_text()`** — a theme-free helper reproducing the
|
||||||
|
on-screen content (line content is theme-independent; only colour is
|
||||||
|
not). A **drift-lock test** asserts it equals the concatenation of
|
||||||
|
`render_output_line(line, &theme)` span contents for every line shape
|
||||||
|
(pending/ok/err echo, system, error, teaching echo, styled plan,
|
||||||
|
data-table row), so the copy can never silently diverge from the
|
||||||
|
renderer.
|
||||||
|
- **`clipboard` module** — `osc52_sequence(text, tmux) -> String` and
|
||||||
|
`emit_osc52(&mut impl Write, …)` are pure/injectable (unit-tested
|
||||||
|
against a `Vec<u8>`, no terminal needed). A long-lived
|
||||||
|
`arboard::Clipboard` is held by the runtime and **created lazily on
|
||||||
|
first copy** (so OSC-52-only users never pay the X11 connect), then
|
||||||
|
reused — required because arboard's X11 backend serves the selection
|
||||||
|
from a background thread owned by the `Clipboard`; dropping it after
|
||||||
|
each `set_text` would lose the contents.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Markdown / styled export** (the issue's option) — plain-text was
|
||||||
|
chosen; Markdown table/plan conversion is a separate effort.
|
||||||
|
- **Selection / range copy** and a **keybinding** — typed command only.
|
||||||
|
- **OSC 52 read / paste-in** — write-only; reading is the security
|
||||||
|
vector and is unneeded.
|
||||||
|
- **`screen` passthrough wrapping** — tmux only.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- ADR-0003 — the app-command registry `copy` joins.
|
||||||
|
- ADR-0040 / ADR-0037 Amendment 1 — the echo `✓`/`✗` marker and the
|
||||||
|
tag model that define "what is on screen".
|
||||||
|
- ADR-0038 — the teaching-echo line shape reproduced verbatim.
|
||||||
|
- ADR-0007 — `export` (the other "get data out" path; complementary).
|
||||||
|
- Issue #11 — the report and the four escalated design axes.
|
||||||
+2
-1
@@ -8,7 +8,7 @@ This directory contains the project's ADRs, recorded per
|
|||||||
- [ADR-0000 — Record architecture decisions](0000-record-architecture-decisions.md)
|
- [ADR-0000 — Record architecture decisions](0000-record-architecture-decisions.md)
|
||||||
- [ADR-0001 — Language and TUI framework](0001-language-and-tui-framework.md)
|
- [ADR-0001 — Language and TUI framework](0001-language-and-tui-framework.md)
|
||||||
- [ADR-0002 — Database engine](0002-database-engine.md)
|
- [ADR-0002 — Database engine](0002-database-engine.md)
|
||||||
- [ADR-0003 — Input modes and command dispatch](0003-input-modes-and-command-dispatch.md) — the persistent `Simple`/`Advanced` mode and the `:` one-shot escape. The **startup mode is no longer always `simple`**: it is restored from the project's stored mode and overridable with `--mode` (see **ADR-0015 Amendment 1**, issue #14)
|
- [ADR-0003 — Input modes and command dispatch](0003-input-modes-and-command-dispatch.md) — the persistent `Simple`/`Advanced` mode and the `:` one-shot escape. The **startup mode is no longer always `simple`**: it is restored from the project's stored mode and overridable with `--mode` (see **ADR-0015 Amendment 1**, issue #14). The app-command registry gains **`copy`** (ADR-0041, issue #11)
|
||||||
- [ADR-0004 — Project file format](0004-project-file-format.md)
|
- [ADR-0004 — Project file format](0004-project-file-format.md)
|
||||||
- [ADR-0005 — Column type vocabulary](0005-column-type-vocabulary.md)
|
- [ADR-0005 — Column type vocabulary](0005-column-type-vocabulary.md)
|
||||||
- [ADR-0006 — Undo snapshots and replay log](0006-undo-snapshots-and-replay-log.md) — **Accepted**. The **replay/journal half** (U3/U4) shipped via ADR-0034; the **undo/snapshot half** (U1/U2) is settled by **Amendment 1 (2026-05-24)** and **implemented 2026-05-24** (plan: `docs/plans/20260524-adr-0006-undo-snapshots.md`; ring in `src/undo.rs`, worker hook in `src/db.rs`). Amendment 1 **supersedes the original "snapshots only before destructive operations" model**: a snapshot is taken before **every** data/schema mutation (DSL + SQL) for familiar single-step (Ctrl-Z) undo — so the confirmation collapses to *naming the one command being undone* (no db-diff). Snapshot is a **hybrid whole-project copy** — database via the online backup API **plus** `project.yaml`/`data/*.csv` as files — reconciling this ADR with ADR-0015's "text is authoritative, db is derived"; undo restores all three directly. Staged before the mutation's transaction, finalised after the db commit (preserves ADR-0015 §6 commit-db-last); rolled-back ops leave no snapshot. **Persisted** ring under `.snapshots/`, **N = 50** (raised from 10), git-ignored + export-excluded + temp-cleanup-aware. `redo` supported, **redo stack discarded on new work**. **Batch ops record one undo step** (`replay` + future batch via a Begin/EndBatch worker primitive); **`import` is outside undo** (it switches projects per ADR-0015 §11, leaving the current project untouched). A **`--no-undo` CLI flag** disables snapshotting (hardware escape hatch). Adds the `backup` feature to `rusqlite`
|
- [ADR-0006 — Undo snapshots and replay log](0006-undo-snapshots-and-replay-log.md) — **Accepted**. The **replay/journal half** (U3/U4) shipped via ADR-0034; the **undo/snapshot half** (U1/U2) is settled by **Amendment 1 (2026-05-24)** and **implemented 2026-05-24** (plan: `docs/plans/20260524-adr-0006-undo-snapshots.md`; ring in `src/undo.rs`, worker hook in `src/db.rs`). Amendment 1 **supersedes the original "snapshots only before destructive operations" model**: a snapshot is taken before **every** data/schema mutation (DSL + SQL) for familiar single-step (Ctrl-Z) undo — so the confirmation collapses to *naming the one command being undone* (no db-diff). Snapshot is a **hybrid whole-project copy** — database via the online backup API **plus** `project.yaml`/`data/*.csv` as files — reconciling this ADR with ADR-0015's "text is authoritative, db is derived"; undo restores all three directly. Staged before the mutation's transaction, finalised after the db commit (preserves ADR-0015 §6 commit-db-last); rolled-back ops leave no snapshot. **Persisted** ring under `.snapshots/`, **N = 50** (raised from 10), git-ignored + export-excluded + temp-cleanup-aware. `redo` supported, **redo stack discarded on new work**. **Batch ops record one undo step** (`replay` + future batch via a Begin/EndBatch worker primitive); **`import` is outside undo** (it switches projects per ADR-0015 §11, leaving the current project untouched). A **`--no-undo` CLI flag** disables snapshotting (hardware escape hatch). Adds the `backup` feature to `rusqlite`
|
||||||
@@ -46,3 +46,4 @@ This directory contains the project's ADRs, recorded per
|
|||||||
- [ADR-0038 — The DSL → SQL teaching echo](0038-dsl-to-sql-teaching-echo.md) — **Accepted** (design agreed 2026-05-27; **fully implemented + verified** — every catalogue row in §7 Buckets A + B and the §6 category-3 prose round-trips per line through the advanced walker per §1, and the §4 de-emphasised styled-runs polish is wired: handoff-46 `04c8e42` shipped the channel + create-table slice, handoff-47 `90479cb` the full Bucket A expansion + a skeleton contract-gap fix (dropped per-column `DEFAULT`/`CHECK`), `275c726` the Bucket B resolved-name + multi-statement renderers (auto- and user-named `add index`, positional `drop index`, `add`/`drop relationship` in both selector forms, `drop column --cascade`, `add relationship --create-fk`), `e6ad1ae` the last category-3 line — the `change column --dont-convert` *caveat* (shortid + transform notes were already surfaced via pre-existing `client_side.*` keys), and `2aab457` the §4 styled-runs polish: a new `OutputKind::TeachingEcho` custom rendering branch (dimmed `Executing SQL:` prefix + the SQL re-lexed in advanced mode for token-class colouring, same as the input echo) plus a new `OutputStyleClass::Hint` for every cat-3 prose line — caveat *and* the existing illuminating notes, user-confirmed broader scope), **realises ADR-0030 §10** (the teaching bridge) — the Phase-5 echo **ADR-0035 §12 forward-referenced** — building on **ADR-0037** (the `SubmissionMode` gate) and **ADR-0035 Amendment 2** (standard-first dialect + `ALTER COLUMN` gap-fill). When a **DSL-form** command runs in advanced/one-shot mode, the worker emits the equivalent SQL beneath `[ok]` as a de-emphasised styled `OutputLine` (ADR-0028); the App renders it. **Defining invariant — the copy-paste contract:** every echoed line is *runnable advanced-mode SQL* (round-trip-tested: parse the echo → same-effect command; a planned "copy the echo" affordance depends on it). **Type vocabulary = the playground's own keywords** (`serial`/`shortid`/…, accepted by `from_sql_name`, decision (a)); **statement shape = the standard-first dialect** (Am2). **DML uses substituted literals, not `?`** (per-type `Value → SQL-literal`, round-trip-safe; `blob` moot — no literal syntax exists; auto-gen columns omitted to match `do_insert` + X4). **Firing reality — a DDL + `show data` feature:** in advanced mode `insert`/`update`/`delete … where` are SQL-first (`Sql*` = already SQL = nothing to echo per §10); only DSL-*only* spellings echo (DDL + `show data` + the `delete`/`update … --all-rows` fall-throughs — the latter via **ADR-0033 Amendment 4**, a bug-fix folded in here that reverses Amendment 3's `update … --all-rows` misparse). **Three-category framework** for "what happens beyond the literal SQL": **(1) engine-implementation-hiding** (the rebuild, rowid PK, non-PK `serial` MAX+1) — *never surfaced*; **(2) decomposable into advanced SQL** (`drop column --cascade`, `--create-fk` relationship) — *shown as the runnable multi-line sequence, one statement per line*; **(3) playground type-behaviour with no SQL-expressible form** (`shortid` generation — no `shortid()`; type-conversion transforms — no `USING`) — *de-emphasised prose expansion from the worker's `client_side.*` notes*. Carries the **full catalogue** (Buckets A single-statement / B resolved-name + multi-line / C no-echo) mapping every DSL-form command to its echo. OOS: reverse SQL→DSL echo (§13 OOS-5), app commands / `show table` / `explain` / `replay`, a `blob` literal, the column-level UNIQUE/CHECK drop residual (Bucket C until Am2's gap closes), and surfacing any category-1 engine internal
|
- [ADR-0038 — The DSL → SQL teaching echo](0038-dsl-to-sql-teaching-echo.md) — **Accepted** (design agreed 2026-05-27; **fully implemented + verified** — every catalogue row in §7 Buckets A + B and the §6 category-3 prose round-trips per line through the advanced walker per §1, and the §4 de-emphasised styled-runs polish is wired: handoff-46 `04c8e42` shipped the channel + create-table slice, handoff-47 `90479cb` the full Bucket A expansion + a skeleton contract-gap fix (dropped per-column `DEFAULT`/`CHECK`), `275c726` the Bucket B resolved-name + multi-statement renderers (auto- and user-named `add index`, positional `drop index`, `add`/`drop relationship` in both selector forms, `drop column --cascade`, `add relationship --create-fk`), `e6ad1ae` the last category-3 line — the `change column --dont-convert` *caveat* (shortid + transform notes were already surfaced via pre-existing `client_side.*` keys), and `2aab457` the §4 styled-runs polish: a new `OutputKind::TeachingEcho` custom rendering branch (dimmed `Executing SQL:` prefix + the SQL re-lexed in advanced mode for token-class colouring, same as the input echo) plus a new `OutputStyleClass::Hint` for every cat-3 prose line — caveat *and* the existing illuminating notes, user-confirmed broader scope), **realises ADR-0030 §10** (the teaching bridge) — the Phase-5 echo **ADR-0035 §12 forward-referenced** — building on **ADR-0037** (the `SubmissionMode` gate) and **ADR-0035 Amendment 2** (standard-first dialect + `ALTER COLUMN` gap-fill). When a **DSL-form** command runs in advanced/one-shot mode, the worker emits the equivalent SQL beneath `[ok]` as a de-emphasised styled `OutputLine` (ADR-0028); the App renders it. **Defining invariant — the copy-paste contract:** every echoed line is *runnable advanced-mode SQL* (round-trip-tested: parse the echo → same-effect command; a planned "copy the echo" affordance depends on it). **Type vocabulary = the playground's own keywords** (`serial`/`shortid`/…, accepted by `from_sql_name`, decision (a)); **statement shape = the standard-first dialect** (Am2). **DML uses substituted literals, not `?`** (per-type `Value → SQL-literal`, round-trip-safe; `blob` moot — no literal syntax exists; auto-gen columns omitted to match `do_insert` + X4). **Firing reality — a DDL + `show data` feature:** in advanced mode `insert`/`update`/`delete … where` are SQL-first (`Sql*` = already SQL = nothing to echo per §10); only DSL-*only* spellings echo (DDL + `show data` + the `delete`/`update … --all-rows` fall-throughs — the latter via **ADR-0033 Amendment 4**, a bug-fix folded in here that reverses Amendment 3's `update … --all-rows` misparse). **Three-category framework** for "what happens beyond the literal SQL": **(1) engine-implementation-hiding** (the rebuild, rowid PK, non-PK `serial` MAX+1) — *never surfaced*; **(2) decomposable into advanced SQL** (`drop column --cascade`, `--create-fk` relationship) — *shown as the runnable multi-line sequence, one statement per line*; **(3) playground type-behaviour with no SQL-expressible form** (`shortid` generation — no `shortid()`; type-conversion transforms — no `USING`) — *de-emphasised prose expansion from the worker's `client_side.*` notes*. Carries the **full catalogue** (Buckets A single-statement / B resolved-name + multi-line / C no-echo) mapping every DSL-form command to its echo. OOS: reverse SQL→DSL echo (§13 OOS-5), app commands / `show table` / `explain` / `replay`, a `blob` literal, the column-level UNIQUE/CHECK drop residual (Bucket C until Am2's gap closes), and surfacing any category-1 engine internal
|
||||||
- [ADR-0039 — EXPLAIN over advanced-mode SQL queries](0039-explain-over-advanced-sql.md) — **Accepted** (2026-05-27), **implemented 2026-05-30 (issue #7)**, **supersedes ADR-0030 §13 OOS-2**. Lets `explain` wrap the advanced SQL commands (`Select`/`SqlInsert`/`SqlUpdate`/`SqlDelete`, plus `with`/CTE which builds a `Select`) in addition to the DSL `ShowData`/`Update`/`Delete` it already covers (ADR-0028), running `EXPLAIN QUERY PLAN` over the validated SQL text through the existing ADR-0028 span-styled plan tree (advanced mode only; DSL `explain` unchanged in both modes). Implemented via a second `Advanced` `explain` CommandNode (`EXPLAIN_SQL`) registered under the shared `explain` entry word — reusing the established `insert`/`update`/`delete` shared-word dispatch (`decide`: SQL-first / DSL-fallback), so `explain show data …` and DSL-only `--all-rows` still reach the DSL node; rejected a `DynamicSubgrammar` mode-gate (its resolution cache key omits `mode`). `build_explain_sql` slices the inner SQL off the source (excludes `explain`) and reuses the existing SQL builders; `do_explain_plan` runs the carried text verbatim, no params. Advanced `explain update`/`delete` now route through SQL (identical plan, full SQL syntax); DSL-explain tests pinned to simple mode. Reframed OOS-2 as a *deferred* exclusion (per ADR-0000's out-of-scope discipline), not a rejection. OOS (deferred): EXPLAIN of DDL (no query plan exists)
|
- [ADR-0039 — EXPLAIN over advanced-mode SQL queries](0039-explain-over-advanced-sql.md) — **Accepted** (2026-05-27), **implemented 2026-05-30 (issue #7)**, **supersedes ADR-0030 §13 OOS-2**. Lets `explain` wrap the advanced SQL commands (`Select`/`SqlInsert`/`SqlUpdate`/`SqlDelete`, plus `with`/CTE which builds a `Select`) in addition to the DSL `ShowData`/`Update`/`Delete` it already covers (ADR-0028), running `EXPLAIN QUERY PLAN` over the validated SQL text through the existing ADR-0028 span-styled plan tree (advanced mode only; DSL `explain` unchanged in both modes). Implemented via a second `Advanced` `explain` CommandNode (`EXPLAIN_SQL`) registered under the shared `explain` entry word — reusing the established `insert`/`update`/`delete` shared-word dispatch (`decide`: SQL-first / DSL-fallback), so `explain show data …` and DSL-only `--all-rows` still reach the DSL node; rejected a `DynamicSubgrammar` mode-gate (its resolution cache key omits `mode`). `build_explain_sql` slices the inner SQL off the source (excludes `explain`) and reuses the existing SQL builders; `do_explain_plan` runs the carried text verbatim, no params. Advanced `explain update`/`delete` now route through SQL (identical plan, full SQL syntax); DSL-explain tests pinned to simple mode. Reframed OOS-2 as a *deferred* exclusion (per ADR-0000's out-of-scope discipline), not a rejection. OOS (deferred): EXPLAIN of DDL (no query plan exists)
|
||||||
- [ADR-0040 — A per-command completion marker (✓/✗) replaces the `[ok]` summary line](0040-completion-marker-replaces-ok-summary.md) — **Accepted 2026-05-30 (issue #9)**, amends ADR-0014 / ADR-0028 / ADR-0019 output conventions, builds on ADR-0037's mode-tagged echo. An audit of the whole command surface found the `[ok] <verb> <subject>` summary line duplicates the echo line above it (verb+subject) everywhere; its only unique contribution is the success-vs-error signal (and `explain select` even rendered `[ok] explain` with an empty subject post-ADR-0039). Decision: drop the `[ok]` line and the symmetric `"…" failed:` prefix; the echo line gains a trailing inline **✓** (green, success) / **✗** (red, failure) — `running:` becomes a pending state that resolves to `<input> ✓/✗` on completion (status set via the existing `rfind(Echo)` lookup). Content (row counts, structure, data, plan tree, teaching echo) unchanged. Scoped to the DSL/data/SQL family that has the redundant echo+`[ok]` pair; app-command `[ok]` lines (`rebuild`/`export`/`now editing`) are payload-bearing, have no echo to mark, and stay as-is. `ok.summary` retired; `dsl.failed` reduced to the rendered reason. Broad but mechanical snapshot churn. OOS: app-command `[ok]` lines, the `[WRN]` validity indicator, and the tag colours (issue #10)
|
- [ADR-0040 — A per-command completion marker (✓/✗) replaces the `[ok]` summary line](0040-completion-marker-replaces-ok-summary.md) — **Accepted 2026-05-30 (issue #9)**, amends ADR-0014 / ADR-0028 / ADR-0019 output conventions, builds on ADR-0037's mode-tagged echo. An audit of the whole command surface found the `[ok] <verb> <subject>` summary line duplicates the echo line above it (verb+subject) everywhere; its only unique contribution is the success-vs-error signal (and `explain select` even rendered `[ok] explain` with an empty subject post-ADR-0039). Decision: drop the `[ok]` line and the symmetric `"…" failed:` prefix; the echo line gains a trailing inline **✓** (green, success) / **✗** (red, failure) — `running:` becomes a pending state that resolves to `<input> ✓/✗` on completion (status set via the existing `rfind(Echo)` lookup). Content (row counts, structure, data, plan tree, teaching echo) unchanged. Scoped to the DSL/data/SQL family that has the redundant echo+`[ok]` pair; app-command `[ok]` lines (`rebuild`/`export`/`now editing`) are payload-bearing, have no echo to mark, and stay as-is. `ok.summary` retired; `dsl.failed` reduced to the rendered reason. Broad but mechanical snapshot churn. OOS: app-command `[ok]` lines, the `[WRN]` validity indicator, and the tag colours (issue #10)
|
||||||
|
- [ADR-0041 — Copy the output panel to the system clipboard](0041-copy-output-to-clipboard.md) — **Accepted 2026-06-02 (issue #11)**, amends ADR-0003's app-command registry (adds **`copy`** / `copy all` / `copy last`). The friction it removes: filing a bug report meant terminal-selecting the output panel and fighting wrapping/borders. New **app-level command** (sigil-free, both modes): `copy` / `copy all` copy the whole panel; `copy last` copies from the most recent echo line to the end. **Mechanism — OSC 52 *and* native (`arboard`), always both**, because OSC 52 acceptance is undetectable (no terminal ack), so a true "fall back when unsupported" can't be built: emit the OSC 52 escape (no new dep — `base64`+`crossterm`; works over SSH; tmux-passthrough-wrapped via `$TMUX`), then a best-effort native write whose failure is ignored (headless host — OSC 52 carried it); the two carry identical content. **Format — plain text verbatim as rendered** (tags, `✓`/`✗`, box-drawing) joined by `\n`, without viewport padding/wrapping; a drift-lock test pins `OutputLine::plain_text` to `render_output_line`. `arboard` added **`--no-default-features`** (drops the `image` crate; X11-only on Linux — `wayland-data-control` deliberately omitted as it ~doubles the dep tree and OSC 52 covers native-Wayland). Security: write-only, scans clean for arboard's tree (cargo audit / osv-scanner / grype), 1Password-maintained, minimal surface. OOS: Markdown export, selection/range, a keybinding, OSC 52 read, `screen` passthrough
|
||||||
|
|||||||
@@ -385,6 +385,15 @@ since ADR-0027.)
|
|||||||
redisplaying schema info on demand. *(Progress: `show table
|
redisplaying schema info on demand. *(Progress: `show table
|
||||||
<name>` and `show data <Table>` implemented;
|
<name>` and `show data <Table>` implemented;
|
||||||
`show tables`, `show relationships`, etc. pending.)*
|
`show tables`, `show relationships`, etc. pending.)*
|
||||||
|
- [x] **V6** Copy the output panel to the system clipboard
|
||||||
|
(issue #11, ADR-0041). `copy` / `copy all` copy the whole
|
||||||
|
panel; `copy last` copies the most recent command's output.
|
||||||
|
Delivery is OSC 52 (SSH-friendly, no native dep) *plus* a
|
||||||
|
best-effort native write (`arboard`), always both; the payload
|
||||||
|
is the panel's plain text exactly as rendered. Removes the
|
||||||
|
terminal-select-and-fight-wrapping friction of filing bug
|
||||||
|
reports. (Complements V4's planned Markdown export — a
|
||||||
|
different "get the session out" path.)
|
||||||
|
|
||||||
## Project lifecycle (per ADR-0004)
|
## Project lifecycle (per ADR-0004)
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,12 @@ pub enum Action {
|
|||||||
/// refreshes the table list + schema cache.
|
/// refreshes the table list + schema cache.
|
||||||
Undo,
|
Undo,
|
||||||
Redo,
|
Redo,
|
||||||
|
/// Copy text to the system clipboard (the `copy` command, ADR-0041).
|
||||||
|
/// The `App` builds the full text from `App.output` (so `update`
|
||||||
|
/// stays pure); the runtime performs the I/O — emit an OSC 52 escape
|
||||||
|
/// to the terminal *and* a best-effort native write, ignoring native
|
||||||
|
/// failure (a headless host is fine; OSC 52 carried it).
|
||||||
|
CopyToClipboard(String),
|
||||||
/// User changed the input mode mid-session (the `mode` command).
|
/// User changed the input mode mid-session (the `mode` command).
|
||||||
/// The runtime records it through the worker so `project.yaml`
|
/// The runtime records it through the worker so `project.yaml`
|
||||||
/// reflects the live mode and it is restored on the next open
|
/// reflects the live mode and it is restored on the next open
|
||||||
|
|||||||
+233
@@ -134,6 +134,50 @@ impl OutputLine {
|
|||||||
status: Some(EchoStatus::Pending),
|
status: Some(EchoStatus::Pending),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The plain-text form of this line *as rendered* (ADR-0041) — the
|
||||||
|
/// `[tag]`, the body, and the echo decoration (`running:` prefix or
|
||||||
|
/// trailing `✓`/`✗`) — without colour, viewport padding, or
|
||||||
|
/// wrapping. This is what the `copy` command puts on the clipboard.
|
||||||
|
///
|
||||||
|
/// It mirrors the content `render_output_line` (`ui.rs`) produces;
|
||||||
|
/// the line content is theme-independent (only colour is not), so no
|
||||||
|
/// theme is needed. A drift-lock test in `ui.rs`
|
||||||
|
/// (`plain_text_matches_rendered_line_content`) pins the two
|
||||||
|
/// together so a renderer change can't silently desync the copy.
|
||||||
|
#[must_use]
|
||||||
|
pub fn plain_text(&self) -> String {
|
||||||
|
let tag = match self.kind {
|
||||||
|
OutputKind::Echo => {
|
||||||
|
format!("[{}] ", self.mode_at_submission.label().to_lowercase())
|
||||||
|
}
|
||||||
|
OutputKind::System | OutputKind::TeachingEcho => "[system] ".to_string(),
|
||||||
|
OutputKind::Error => "[error] ".to_string(),
|
||||||
|
};
|
||||||
|
if self.kind == OutputKind::Echo {
|
||||||
|
// Pending / untracked echoes keep the `running: ` prefix;
|
||||||
|
// completed ones drop it and gain a ✓/✗ marker (ADR-0040).
|
||||||
|
let input = self
|
||||||
|
.text
|
||||||
|
.strip_prefix(crate::dsl::ECHO_PREFIX)
|
||||||
|
.unwrap_or(self.text.as_str());
|
||||||
|
let mut s = tag;
|
||||||
|
if !matches!(self.status, Some(EchoStatus::Ok | EchoStatus::Err)) {
|
||||||
|
s.push_str(crate::dsl::ECHO_PREFIX);
|
||||||
|
}
|
||||||
|
s.push_str(input);
|
||||||
|
match self.status {
|
||||||
|
Some(EchoStatus::Ok) => s.push_str(" ✓"),
|
||||||
|
Some(EchoStatus::Err) => s.push_str(" ✗"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
// System / Error / TeachingEcho / styled lines: the body is
|
||||||
|
// `self.text` verbatim (the teaching-echo `Executing SQL: `
|
||||||
|
// label and the styled-run slices all tile `self.text`).
|
||||||
|
format!("{tag}{}", self.text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What mode the next submission would be evaluated in.
|
/// What mode the next submission would be evaluated in.
|
||||||
@@ -1330,9 +1374,53 @@ impl App {
|
|||||||
}
|
}
|
||||||
AppCommand::Undo => self.handle_undo_command(false),
|
AppCommand::Undo => self.handle_undo_command(false),
|
||||||
AppCommand::Redo => self.handle_undo_command(true),
|
AppCommand::Redo => self.handle_undo_command(true),
|
||||||
|
AppCommand::Copy { scope } => self.handle_copy_command(scope),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `copy` / `copy all` / `copy last` (ADR-0041). Builds the
|
||||||
|
/// plain-text payload from the output panel and hands it to the
|
||||||
|
/// runtime via [`Action::CopyToClipboard`]; the confirmation note
|
||||||
|
/// is pushed *after* the text is captured, so it is never copied.
|
||||||
|
fn handle_copy_command(&mut self, scope: crate::dsl::CopyScope) -> Vec<Action> {
|
||||||
|
match self.copy_text(scope) {
|
||||||
|
None => {
|
||||||
|
self.note_system(crate::t!("copy.nothing"));
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Some(text) => {
|
||||||
|
let count = text.lines().count();
|
||||||
|
self.note_system(crate::t!("copy.done", count = count));
|
||||||
|
vec![Action::CopyToClipboard(text)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The clipboard payload for `scope`: every output line's
|
||||||
|
/// [`OutputLine::plain_text`] joined by `\n`. `All` is the whole
|
||||||
|
/// buffer; `Last` is from the most recent echo line to the end
|
||||||
|
/// (ADR-0041). `None` when there is nothing to copy (empty buffer,
|
||||||
|
/// or `Last` with no echo line).
|
||||||
|
fn copy_text(&self, scope: crate::dsl::CopyScope) -> Option<String> {
|
||||||
|
let start = match scope {
|
||||||
|
crate::dsl::CopyScope::All => 0,
|
||||||
|
crate::dsl::CopyScope::Last => self
|
||||||
|
.output
|
||||||
|
.iter()
|
||||||
|
.rposition(|l| l.kind == OutputKind::Echo)?,
|
||||||
|
};
|
||||||
|
let lines: Vec<String> = self
|
||||||
|
.output
|
||||||
|
.iter()
|
||||||
|
.skip(start)
|
||||||
|
.map(OutputLine::plain_text)
|
||||||
|
.collect();
|
||||||
|
if lines.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(lines.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
/// `undo` / `redo` dispatch. When undo is disabled (`--no-undo`)
|
/// `undo` / `redo` dispatch. When undo is disabled (`--no-undo`)
|
||||||
/// the command reports that and does nothing; otherwise it asks
|
/// the command reports that and does nothing; otherwise it asks
|
||||||
/// the runtime to peek the snapshot and open the confirmation
|
/// the runtime to peek the snapshot and open the confirmation
|
||||||
@@ -2945,6 +3033,151 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- copy to clipboard (ADR-0041, issue #11) ----
|
||||||
|
|
||||||
|
/// Two output lines: a completed (`✓`) simple-mode echo and a
|
||||||
|
/// `[system]` structure line — exercises the echo decoration and
|
||||||
|
/// the tag prefix in `plain_text`.
|
||||||
|
fn app_with_two_output_lines() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
let mut echo = OutputLine::echo("create table T", Mode::Simple);
|
||||||
|
echo.status = Some(EchoStatus::Ok);
|
||||||
|
app.output.push_back(echo);
|
||||||
|
app.output.push_back(OutputLine {
|
||||||
|
text: " T".to_string(),
|
||||||
|
kind: OutputKind::System,
|
||||||
|
mode_at_submission: Mode::Simple,
|
||||||
|
styled_runs: None,
|
||||||
|
status: None,
|
||||||
|
});
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_all_emits_action_with_whole_panel_and_confirms() {
|
||||||
|
let mut app = app_with_two_output_lines();
|
||||||
|
let before = app.output.len();
|
||||||
|
type_str(&mut app, "copy");
|
||||||
|
let actions = submit(&mut app);
|
||||||
|
|
||||||
|
// The payload is every line's rendered form, joined by \n —
|
||||||
|
// tag + ✓ on the echo, tag on the system line.
|
||||||
|
let expected = "[simple] create table T ✓\n[system] T";
|
||||||
|
assert_eq!(
|
||||||
|
actions,
|
||||||
|
vec![Action::CopyToClipboard(expected.to_string())],
|
||||||
|
"copy emits the whole-panel payload",
|
||||||
|
);
|
||||||
|
// A confirmation [system] line is appended — and is NOT part
|
||||||
|
// of the copied text (captured before the push).
|
||||||
|
assert_eq!(app.output.len(), before + 1, "one confirmation line added");
|
||||||
|
let note = app.output.back().unwrap();
|
||||||
|
assert_eq!(note.kind, OutputKind::System);
|
||||||
|
assert!(
|
||||||
|
note.text.contains("clipboard"),
|
||||||
|
"confirmation mentions the clipboard: {:?}",
|
||||||
|
note.text,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!expected.contains("clipboard"),
|
||||||
|
"the confirmation is never in the copied text",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_bare_is_identical_to_copy_all() {
|
||||||
|
let mut a1 = app_with_two_output_lines();
|
||||||
|
type_str(&mut a1, "copy");
|
||||||
|
let bare = submit(&mut a1);
|
||||||
|
let mut a2 = app_with_two_output_lines();
|
||||||
|
type_str(&mut a2, "copy all");
|
||||||
|
let all = submit(&mut a2);
|
||||||
|
assert_eq!(bare, all, "`copy` defaults to `copy all`");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_last_slices_from_the_most_recent_echo() {
|
||||||
|
let mut app = app_with_two_output_lines();
|
||||||
|
// An earlier, unrelated command + a fresh echo with a body.
|
||||||
|
app.output.push_front(OutputLine {
|
||||||
|
text: "earlier note".to_string(),
|
||||||
|
kind: OutputKind::System,
|
||||||
|
mode_at_submission: Mode::Simple,
|
||||||
|
styled_runs: None,
|
||||||
|
status: None,
|
||||||
|
});
|
||||||
|
let mut echo2 = OutputLine::echo("show data T", Mode::Simple);
|
||||||
|
echo2.status = Some(EchoStatus::Ok);
|
||||||
|
app.output.push_back(echo2);
|
||||||
|
app.output.push_back(OutputLine {
|
||||||
|
text: "(0 rows)".to_string(),
|
||||||
|
kind: OutputKind::System,
|
||||||
|
mode_at_submission: Mode::Simple,
|
||||||
|
styled_runs: None,
|
||||||
|
status: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
type_str(&mut app, "copy last");
|
||||||
|
let actions = submit(&mut app);
|
||||||
|
// Only the LAST command (its echo + body), not the earlier one.
|
||||||
|
let expected = "[simple] show data T ✓\n[system] (0 rows)";
|
||||||
|
assert_eq!(actions, vec![Action::CopyToClipboard(expected.to_string())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_on_empty_panel_notes_nothing_and_emits_no_action() {
|
||||||
|
let mut app = App::new();
|
||||||
|
assert!(app.output.is_empty());
|
||||||
|
type_str(&mut app, "copy");
|
||||||
|
let actions = submit(&mut app);
|
||||||
|
assert!(actions.is_empty(), "nothing to copy → no clipboard action");
|
||||||
|
let note = app.output.back().expect("a 'nothing to copy' note");
|
||||||
|
assert_eq!(note.kind, OutputKind::System);
|
||||||
|
assert!(note.text.contains("nothing to copy"), "{:?}", note.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_last_without_an_echo_notes_nothing() {
|
||||||
|
// A panel with only echo-less [system] lines has no "last
|
||||||
|
// command" boundary → nothing to copy (ADR-0041 boundary note).
|
||||||
|
let mut app = App::new();
|
||||||
|
app.output.push_back(OutputLine {
|
||||||
|
text: "just a note".to_string(),
|
||||||
|
kind: OutputKind::System,
|
||||||
|
mode_at_submission: Mode::Simple,
|
||||||
|
styled_runs: None,
|
||||||
|
status: None,
|
||||||
|
});
|
||||||
|
type_str(&mut app, "copy last");
|
||||||
|
let actions = submit(&mut app);
|
||||||
|
assert!(actions.is_empty());
|
||||||
|
assert!(
|
||||||
|
app.output.back().unwrap().text.contains("nothing to copy"),
|
||||||
|
"no echo → nothing to copy",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_with_unknown_target_is_a_friendly_error() {
|
||||||
|
let mut app = app_with_two_output_lines();
|
||||||
|
type_str(&mut app, "copy sideways");
|
||||||
|
let actions = submit(&mut app);
|
||||||
|
assert!(
|
||||||
|
!actions.iter().any(|a| matches!(a, Action::CopyToClipboard(_))),
|
||||||
|
"an unknown target does not copy",
|
||||||
|
);
|
||||||
|
let rendered = app
|
||||||
|
.output
|
||||||
|
.iter()
|
||||||
|
.map(|l| l.text.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
assert!(
|
||||||
|
rendered.contains("unknown copy target"),
|
||||||
|
"friendly copy.unknown wording shown: {rendered}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_switched_event_restores_the_stored_mode() {
|
fn project_switched_event_restores_the_stored_mode() {
|
||||||
// A switch carries the target project's stored mode; the
|
// A switch carries the target project's stored mode; the
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
//! System-clipboard delivery for the `copy` command (ADR-0041).
|
||||||
|
//!
|
||||||
|
//! A copy **always does both** of two writes, because OSC 52
|
||||||
|
//! acceptance is undetectable (the terminal sends no
|
||||||
|
//! acknowledgement, so a true "fall back when OSC 52 is
|
||||||
|
//! unsupported" cannot be built):
|
||||||
|
//!
|
||||||
|
//! 1. An **OSC 52** terminal escape (`ESC ] 52 ; c ; <base64> BEL`),
|
||||||
|
//! which needs no native dependency and reaches the *local*
|
||||||
|
//! clipboard even over SSH. Inside tmux it is wrapped in tmux's
|
||||||
|
//! DCS passthrough so it reaches the outer terminal.
|
||||||
|
//! 2. A best-effort **native** write (`arboard`). A failure (e.g. a
|
||||||
|
//! headless host with no display) is ignored — OSC 52 has already
|
||||||
|
//! carried the payload.
|
||||||
|
//!
|
||||||
|
//! The two carry identical content, so on a local desktop the
|
||||||
|
//! redundant double-write is harmless.
|
||||||
|
//!
|
||||||
|
//! The pure parts ([`osc52_sequence`], [`emit_osc52`], [`deliver`])
|
||||||
|
//! are unit-tested against in-memory sinks; the native side is
|
||||||
|
//! abstracted behind [`NativeClipboard`] so [`deliver`] can be tested
|
||||||
|
//! without a display or touching the real clipboard.
|
||||||
|
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
|
|
||||||
|
/// ASCII escape — opens the OSC and (doubled) the tmux passthrough.
|
||||||
|
const ESC: char = '\u{1b}';
|
||||||
|
/// ASCII bell — terminates the OSC 52 string.
|
||||||
|
const BEL: char = '\u{07}';
|
||||||
|
|
||||||
|
/// Build the OSC 52 clipboard-set escape for `text`.
|
||||||
|
///
|
||||||
|
/// When `tmux` is true the sequence is wrapped in tmux's DCS
|
||||||
|
/// passthrough (`ESC P tmux; … ESC \`), with every `ESC` in the
|
||||||
|
/// inner sequence doubled, so it survives a tmux layer (the common
|
||||||
|
/// SSH-into-tmux case). The selection parameter is `c` (clipboard).
|
||||||
|
#[must_use]
|
||||||
|
pub fn osc52_sequence(text: &str, tmux: bool) -> String {
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
|
||||||
|
let inner = format!("{ESC}]52;c;{b64}{BEL}");
|
||||||
|
if tmux {
|
||||||
|
let doubled = inner.replace(ESC, &format!("{ESC}{ESC}"));
|
||||||
|
format!("{ESC}Ptmux;{doubled}{ESC}\\")
|
||||||
|
} else {
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit the OSC 52 sequence for `text` to `w` and flush.
|
||||||
|
pub fn emit_osc52<W: Write>(w: &mut W, text: &str, tmux: bool) -> io::Result<()> {
|
||||||
|
w.write_all(osc52_sequence(text, tmux).as_bytes())?;
|
||||||
|
w.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when running inside tmux (`$TMUX` is set).
|
||||||
|
#[must_use]
|
||||||
|
pub fn in_tmux() -> bool {
|
||||||
|
std::env::var_os("TMUX").is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The native (OS) clipboard sink, abstracted so [`deliver`] is
|
||||||
|
/// testable without a real display.
|
||||||
|
pub trait NativeClipboard {
|
||||||
|
/// Write `text` to the OS clipboard, best-effort. Implementations
|
||||||
|
/// must swallow their own errors (a headless host is expected).
|
||||||
|
fn set_text(&mut self, text: &str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deliver `text` to the clipboard via **both** paths (ADR-0041):
|
||||||
|
/// the OSC 52 escape to `w`, then the best-effort native write.
|
||||||
|
///
|
||||||
|
/// The native write is attempted **regardless** of the OSC 52 result
|
||||||
|
/// (a stdout write error must not suppress the native path). The
|
||||||
|
/// returned `io::Result` is the OSC 52 outcome, for the caller to log.
|
||||||
|
pub fn deliver<W: Write, N: NativeClipboard>(
|
||||||
|
w: &mut W,
|
||||||
|
native: &mut N,
|
||||||
|
text: &str,
|
||||||
|
tmux: bool,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let osc = emit_osc52(w, text, tmux);
|
||||||
|
native.set_text(text);
|
||||||
|
osc
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The production [`NativeClipboard`] backed by `arboard`.
|
||||||
|
///
|
||||||
|
/// The `arboard::Clipboard` is created **lazily on first use** (so a
|
||||||
|
/// session that only ever relies on OSC 52 never opens an X11
|
||||||
|
/// connection) and then **kept alive** — arboard's X11 backend serves
|
||||||
|
/// the selection from a background thread owned by the `Clipboard`, so
|
||||||
|
/// dropping it after each write would lose the contents. If
|
||||||
|
/// construction fails (no display), it stays `None` and every write is
|
||||||
|
/// a silent no-op.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SystemClipboard {
|
||||||
|
inner: Option<arboard::Clipboard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SystemClipboard {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self { inner: None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeClipboard for SystemClipboard {
|
||||||
|
fn set_text(&mut self, text: &str) {
|
||||||
|
if self.inner.is_none() {
|
||||||
|
match arboard::Clipboard::new() {
|
||||||
|
Ok(cb) => self.inner = Some(cb),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(
|
||||||
|
error = %e,
|
||||||
|
"native clipboard unavailable; relying on OSC 52"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(cb) = &mut self.inner
|
||||||
|
&& let Err(e) = cb.set_text(text)
|
||||||
|
{
|
||||||
|
tracing::debug!(error = %e, "native clipboard write failed; OSC 52 carried it");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A test double recording the last native write.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SpyClipboard {
|
||||||
|
last: Option<String>,
|
||||||
|
calls: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeClipboard for SpyClipboard {
|
||||||
|
fn set_text(&mut self, text: &str) {
|
||||||
|
self.last = Some(text.to_owned());
|
||||||
|
self.calls += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc52_sequence_is_the_canonical_escape() {
|
||||||
|
// ESC ] 52 ; c ; <base64-of-"hi"> BEL. base64("hi") = "aGk=".
|
||||||
|
let seq = osc52_sequence("hi", false);
|
||||||
|
assert_eq!(seq, "\u{1b}]52;c;aGk=\u{07}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn osc52_base64_round_trips_the_payload() {
|
||||||
|
// Pull the base64 field out and decode it back to the input,
|
||||||
|
// proving we encode the raw UTF-8 bytes (incl. non-ASCII).
|
||||||
|
let text = "┌─┐ ✓ café";
|
||||||
|
let seq = osc52_sequence(text, false);
|
||||||
|
let b64 = seq
|
||||||
|
.strip_prefix("\u{1b}]52;c;")
|
||||||
|
.and_then(|s| s.strip_suffix('\u{07}'))
|
||||||
|
.expect("canonical OSC 52 shape");
|
||||||
|
let decoded = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(b64)
|
||||||
|
.expect("valid base64");
|
||||||
|
assert_eq!(String::from_utf8(decoded).unwrap(), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tmux_wraps_in_passthrough_and_doubles_the_esc() {
|
||||||
|
let plain = osc52_sequence("hi", false);
|
||||||
|
let wrapped = osc52_sequence("hi", true);
|
||||||
|
// The inner sequence has exactly one ESC (the OSC opener); the
|
||||||
|
// BEL terminator is not an ESC, so doubling touches only it.
|
||||||
|
let doubled_inner = plain.replace('\u{1b}', "\u{1b}\u{1b}");
|
||||||
|
assert_eq!(wrapped, format!("\u{1b}Ptmux;{doubled_inner}\u{1b}\\"));
|
||||||
|
// Concretely: leading `ESC ESC ]` and the trailing `ESC \`.
|
||||||
|
assert!(wrapped.starts_with("\u{1b}Ptmux;\u{1b}\u{1b}]52;c;"));
|
||||||
|
assert!(wrapped.ends_with("\u{07}\u{1b}\\"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emit_osc52_writes_the_sequence_to_the_sink() {
|
||||||
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
|
emit_osc52(&mut buf, "hi", false).unwrap();
|
||||||
|
assert_eq!(String::from_utf8(buf).unwrap(), osc52_sequence("hi", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deliver_writes_osc52_and_calls_native_once() {
|
||||||
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
|
let mut spy = SpyClipboard::default();
|
||||||
|
deliver(&mut buf, &mut spy, "payload", false).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8(buf).unwrap(),
|
||||||
|
osc52_sequence("payload", false)
|
||||||
|
);
|
||||||
|
assert_eq!(spy.last.as_deref(), Some("payload"));
|
||||||
|
assert_eq!(spy.calls, 1, "native is attempted exactly once");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deliver_still_calls_native_when_the_writer_errors() {
|
||||||
|
// A writer that always fails: the native path must still fire
|
||||||
|
// (OSC 52 failure must not suppress the native fallback).
|
||||||
|
struct FailWriter;
|
||||||
|
impl Write for FailWriter {
|
||||||
|
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
|
||||||
|
Err(io::Error::other("nope"))
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
Err(io::Error::other("nope"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut spy = SpyClipboard::default();
|
||||||
|
let res = deliver(&mut FailWriter, &mut spy, "payload", false);
|
||||||
|
assert!(res.is_err(), "the OSC 52 error is returned for logging");
|
||||||
|
assert_eq!(spy.last.as_deref(), Some("payload"), "native still fired");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1512,7 +1512,7 @@ mod tests {
|
|||||||
// commands in the entry-keyword set.
|
// commands in the entry-keyword set.
|
||||||
for expected in &[
|
for expected in &[
|
||||||
"quit", "help", "rebuild", "save", "new", "load", "export",
|
"quit", "help", "rebuild", "save", "new", "load", "export",
|
||||||
"import", "mode", "messages", "undo", "redo",
|
"import", "mode", "messages", "undo", "redo", "copy",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
cs.contains(&expected.to_string()),
|
cs.contains(&expected.to_string()),
|
||||||
|
|||||||
@@ -516,6 +516,19 @@ pub enum AppCommand {
|
|||||||
Undo,
|
Undo,
|
||||||
/// Re-apply the most recently undone change, after confirmation.
|
/// Re-apply the most recently undone change, after confirmation.
|
||||||
Redo,
|
Redo,
|
||||||
|
/// Copy the output panel to the system clipboard (ADR-0041).
|
||||||
|
/// `copy` / `copy all` copy the whole panel; `copy last` copies
|
||||||
|
/// the most recent command's output.
|
||||||
|
Copy { scope: CopyScope },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which slice of the output panel `copy` targets (ADR-0041).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CopyScope {
|
||||||
|
/// The entire output buffer (`copy` bare, or `copy all`).
|
||||||
|
All,
|
||||||
|
/// From the most recent echo line to the end (`copy last`).
|
||||||
|
Last,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -871,6 +884,7 @@ impl Command {
|
|||||||
AppCommand::Messages { .. } => "messages",
|
AppCommand::Messages { .. } => "messages",
|
||||||
AppCommand::Undo => "undo",
|
AppCommand::Undo => "undo",
|
||||||
AppCommand::Redo => "redo",
|
AppCommand::Redo => "redo",
|
||||||
|
AppCommand::Copy { .. } => "copy",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-1
@@ -7,7 +7,7 @@
|
|||||||
//! builder, help / usage references. The ast_builders match
|
//! builder, help / usage references. The ast_builders match
|
||||||
//! against the `MatchedPath` items in declaration order.
|
//! against the `MatchedPath` items in declaration order.
|
||||||
|
|
||||||
use crate::dsl::command::{AppCommand, Command, MessagesValue, ModeValue};
|
use crate::dsl::command::{AppCommand, Command, CopyScope, MessagesValue, ModeValue};
|
||||||
use crate::dsl::grammar::{
|
use crate::dsl::grammar::{
|
||||||
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError,
|
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError,
|
||||||
Word,
|
Word,
|
||||||
@@ -37,8 +37,16 @@ fn validate_unknown_messages(value: &str) -> Result<(), ValidationError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_unknown_copy(value: &str) -> Result<(), ValidationError> {
|
||||||
|
Err(ValidationError {
|
||||||
|
message_key: "copy.unknown",
|
||||||
|
args: vec![("value", value.to_string())],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const UNKNOWN_MODE_VALIDATOR: IdentValidator = validate_unknown_mode;
|
const UNKNOWN_MODE_VALIDATOR: IdentValidator = validate_unknown_mode;
|
||||||
const UNKNOWN_MESSAGES_VALIDATOR: IdentValidator = validate_unknown_messages;
|
const UNKNOWN_MESSAGES_VALIDATOR: IdentValidator = validate_unknown_messages;
|
||||||
|
const UNKNOWN_COPY_VALIDATOR: IdentValidator = validate_unknown_copy;
|
||||||
|
|
||||||
// --- Shapes (constants are referenced by Optional/Choice slices) --
|
// --- Shapes (constants are referenced by Optional/Choice slices) --
|
||||||
|
|
||||||
@@ -114,6 +122,29 @@ const MESSAGES_CHOICES: &[Node] = &[
|
|||||||
const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES);
|
const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES);
|
||||||
const MESSAGES_VALUE_OPT: Node = Node::Optional(&MESSAGES_VALUE);
|
const MESSAGES_VALUE_OPT: Node = Node::Optional(&MESSAGES_VALUE);
|
||||||
|
|
||||||
|
// `copy [all|last]`: same shape as `messages` — known scope words are
|
||||||
|
// `Word` siblings (so they reach completion + the expected set); the
|
||||||
|
// trailing catch-all `Ident` funnels any other word into the friendly
|
||||||
|
// `copy.unknown` validator. Bare `copy` (no value) means `all`.
|
||||||
|
const COPY_CHOICES: &[Node] = &[
|
||||||
|
Node::Word(Word::keyword("all")),
|
||||||
|
Node::Word(Word::keyword("last")),
|
||||||
|
Node::Ident {
|
||||||
|
source: IdentSource::Free,
|
||||||
|
role: "copy_value",
|
||||||
|
validator: Some(UNKNOWN_COPY_VALIDATOR),
|
||||||
|
highlight_override: None,
|
||||||
|
writes_table: false,
|
||||||
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
|
writes_table_alias: false,
|
||||||
|
writes_cte_name: false,
|
||||||
|
writes_projection_alias: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const COPY_VALUE: Node = Node::Choice(COPY_CHOICES);
|
||||||
|
const COPY_VALUE_OPT: Node = Node::Optional(©_VALUE);
|
||||||
|
|
||||||
const EMPTY_SEQ: Node = Node::Seq(&[]);
|
const EMPTY_SEQ: Node = Node::Seq(&[]);
|
||||||
const SAVE_AS_OPT: Node = Node::Optional(&SAVE_AS_WORD);
|
const SAVE_AS_OPT: Node = Node::Optional(&SAVE_AS_WORD);
|
||||||
|
|
||||||
@@ -202,6 +233,17 @@ fn build_messages(path: &MatchedPath, _source: &str) -> Result<Command, Validati
|
|||||||
Ok(Command::App(AppCommand::Messages { value }))
|
Ok(Command::App(AppCommand::Messages { value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_copy(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||||
|
// The unknown-value branch's validator always errors, so reaching
|
||||||
|
// here means either a known scope word or a bare `copy` (= all).
|
||||||
|
let scope = if path.contains_word("last") {
|
||||||
|
CopyScope::Last
|
||||||
|
} else {
|
||||||
|
CopyScope::All
|
||||||
|
};
|
||||||
|
Ok(Command::App(AppCommand::Copy { scope }))
|
||||||
|
}
|
||||||
|
|
||||||
// --- Command nodes -------------------------------------------------
|
// --- Command nodes -------------------------------------------------
|
||||||
|
|
||||||
pub static QUIT: CommandNode = CommandNode {
|
pub static QUIT: CommandNode = CommandNode {
|
||||||
@@ -287,3 +329,10 @@ pub static REDO: CommandNode = CommandNode {
|
|||||||
ast_builder: build_redo,
|
ast_builder: build_redo,
|
||||||
help_id: Some("app.redo"),
|
help_id: Some("app.redo"),
|
||||||
usage_ids: &["parse.usage.redo"],};
|
usage_ids: &["parse.usage.redo"],};
|
||||||
|
|
||||||
|
pub static COPY: CommandNode = CommandNode {
|
||||||
|
entry: Word::keyword("copy"),
|
||||||
|
shape: COPY_VALUE_OPT,
|
||||||
|
ast_builder: build_copy,
|
||||||
|
help_id: Some("app.copy"),
|
||||||
|
usage_ids: &["parse.usage.copy"],};
|
||||||
|
|||||||
@@ -613,6 +613,7 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
|||||||
(&app::MESSAGES, CommandCategory::Simple),
|
(&app::MESSAGES, CommandCategory::Simple),
|
||||||
(&app::UNDO, CommandCategory::Simple),
|
(&app::UNDO, CommandCategory::Simple),
|
||||||
(&app::REDO, CommandCategory::Simple),
|
(&app::REDO, CommandCategory::Simple),
|
||||||
|
(&app::COPY, CommandCategory::Simple),
|
||||||
(&ddl::DROP, CommandCategory::Simple),
|
(&ddl::DROP, CommandCategory::Simple),
|
||||||
(&ddl::ADD, CommandCategory::Simple),
|
(&ddl::ADD, CommandCategory::Simple),
|
||||||
(&ddl::RENAME, CommandCategory::Simple),
|
(&ddl::RENAME, CommandCategory::Simple),
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ pub mod walker;
|
|||||||
|
|
||||||
pub use action::ReferentialAction;
|
pub use action::ReferentialAction;
|
||||||
pub use command::{
|
pub use command::{
|
||||||
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, Expr,
|
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr,
|
||||||
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
|
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
|
||||||
SqlForeignKey,
|
SqlForeignKey,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("help.app.messages", &[]),
|
("help.app.messages", &[]),
|
||||||
("help.app.undo", &[]),
|
("help.app.undo", &[]),
|
||||||
("help.app.redo", &[]),
|
("help.app.redo", &[]),
|
||||||
|
("help.app.copy", &[]),
|
||||||
("help.ddl.create", &[]),
|
("help.ddl.create", &[]),
|
||||||
("help.ddl.sql_create_table", &[]),
|
("help.ddl.sql_create_table", &[]),
|
||||||
("help.ddl.sql_drop_table", &[]),
|
("help.ddl.sql_drop_table", &[]),
|
||||||
@@ -291,6 +292,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("parse.usage.export", &[]),
|
("parse.usage.export", &[]),
|
||||||
("parse.usage.help", &[]),
|
("parse.usage.help", &[]),
|
||||||
("parse.usage.import", &[]),
|
("parse.usage.import", &[]),
|
||||||
|
("parse.usage.copy", &[]),
|
||||||
("parse.usage.load", &[]),
|
("parse.usage.load", &[]),
|
||||||
("parse.usage.messages", &[]),
|
("parse.usage.messages", &[]),
|
||||||
("parse.usage.mode", &[]),
|
("parse.usage.mode", &[]),
|
||||||
@@ -468,6 +470,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("mode.show_simple", &[]),
|
("mode.show_simple", &[]),
|
||||||
("mode.unknown", &["value"]),
|
("mode.unknown", &["value"]),
|
||||||
("mode.usage", &[]),
|
("mode.usage", &[]),
|
||||||
|
// ---- copy (ADR-0041) ----
|
||||||
|
("copy.done", &["count"]),
|
||||||
|
("copy.nothing", &[]),
|
||||||
|
("copy.unknown", &["value"]),
|
||||||
// ---- DbError Display fallback ----
|
// ---- DbError Display fallback ----
|
||||||
("db.error.invalid_value", &["detail"]),
|
("db.error.invalid_value", &["detail"]),
|
||||||
("db.error.io", &["detail"]),
|
("db.error.io", &["detail"]),
|
||||||
|
|||||||
@@ -224,6 +224,9 @@ help:
|
|||||||
import <zip> [as <t>] Unpack <zip> into a new project and
|
import <zip> [as <t>] Unpack <zip> into a new project and
|
||||||
switch to it. <t> overrides the target
|
switch to it. <t> overrides the target
|
||||||
name (else taken from the zip).
|
name (else taken from the zip).
|
||||||
|
copy [all|last] Copy the output panel to the system
|
||||||
|
clipboard (`copy last` copies just the
|
||||||
|
most recent command's output).
|
||||||
# In-app `help` command output (ADR-0024 §help_id). The
|
# In-app `help` command output (ADR-0024 §help_id). The
|
||||||
# renderer iterates the command REGISTRY and translates each
|
# renderer iterates the command REGISTRY and translates each
|
||||||
# CommandNode's `help_id` — so a newly-registered command
|
# CommandNode's `help_id` — so a newly-registered command
|
||||||
@@ -266,6 +269,8 @@ help:
|
|||||||
undo — undo the last change (with confirmation)
|
undo — undo the last change (with confirmation)
|
||||||
redo: |-
|
redo: |-
|
||||||
redo — redo the last undone change (with confirmation)
|
redo — redo the last undone change (with confirmation)
|
||||||
|
copy: |-
|
||||||
|
copy [all|last] — copy the output panel to the clipboard (`copy last` = the most recent command)
|
||||||
ddl:
|
ddl:
|
||||||
create: |-
|
create: |-
|
||||||
create table <T> with pk [<col>(<type>), ...] — create a table
|
create table <T> with pk [<col>(<type>), ...] — create a table
|
||||||
@@ -563,6 +568,7 @@ parse:
|
|||||||
messages: "messages | messages short | messages verbose"
|
messages: "messages | messages short | messages verbose"
|
||||||
undo: "undo"
|
undo: "undo"
|
||||||
redo: "redo"
|
redo: "redo"
|
||||||
|
copy: "copy | copy all | copy last"
|
||||||
|
|
||||||
# ---- Pre-submit diagnostics (ADR-0027) -------------------------------
|
# ---- Pre-submit diagnostics (ADR-0027) -------------------------------
|
||||||
# Surfaced by the validity indicator and the hint panel before
|
# Surfaced by the validity indicator and the hint panel before
|
||||||
@@ -852,6 +858,12 @@ messages:
|
|||||||
set_verbose: "messages: verbose"
|
set_verbose: "messages: verbose"
|
||||||
unknown: "unknown messages mode '{value}' (expected 'short' or 'verbose')"
|
unknown: "unknown messages mode '{value}' (expected 'short' or 'verbose')"
|
||||||
|
|
||||||
|
# ---- copy (app-level command, ADR-0041) -----------------------------
|
||||||
|
copy:
|
||||||
|
done: "copied {count} line(s) to the clipboard"
|
||||||
|
nothing: "nothing to copy — the output panel is empty"
|
||||||
|
unknown: "unknown copy target '{value}' (expected 'all' or 'last')"
|
||||||
|
|
||||||
# ---- Database-error fallback wording + cascade summaries ------------
|
# ---- Database-error fallback wording + cascade summaries ------------
|
||||||
db:
|
db:
|
||||||
# DbError variants — fallback Display wording for paths that
|
# DbError variants — fallback Display wording for paths that
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod action;
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod archive;
|
pub mod archive;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
|
pub mod clipboard;
|
||||||
pub mod completion;
|
pub mod completion;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod dsl;
|
pub mod dsl;
|
||||||
|
|||||||
@@ -376,6 +376,12 @@ async fn run_loop(
|
|||||||
// no wake-ups. See `IndicatorDebounce` for the decision
|
// no wake-ups. See `IndicatorDebounce` for the decision
|
||||||
// logic; `app.input_indicator` mirrors it for the renderer.
|
// logic; `app.input_indicator` mirrors it for the renderer.
|
||||||
let mut debounce = IndicatorDebounce::default();
|
let mut debounce = IndicatorDebounce::default();
|
||||||
|
// Long-lived native clipboard for the `copy` command (ADR-0041).
|
||||||
|
// Created lazily on first copy (so an OSC-52-only session never
|
||||||
|
// opens an X11 connection) and kept alive for the session — the
|
||||||
|
// X11 backend serves the selection from a thread owned by this
|
||||||
|
// handle, so it must outlive each write.
|
||||||
|
let mut native_clipboard = crate::clipboard::SystemClipboard::new();
|
||||||
loop {
|
loop {
|
||||||
let event = if debounce.is_armed() {
|
let event = if debounce.is_armed() {
|
||||||
match tokio::time::timeout(INDICATOR_DEBOUNCE, event_rx.recv()).await {
|
match tokio::time::timeout(INDICATOR_DEBOUNCE, event_rx.recv()).await {
|
||||||
@@ -557,6 +563,20 @@ async fn run_loop(
|
|||||||
tracing::warn!(error = %e, "could not persist input mode");
|
tracing::warn!(error = %e, "could not persist input mode");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Action::CopyToClipboard(text) => {
|
||||||
|
// OSC 52 to the terminal + a best-effort native
|
||||||
|
// write (ADR-0041). Both always fire; an OSC 52
|
||||||
|
// write error is logged but never fatal.
|
||||||
|
let mut out = std::io::stdout();
|
||||||
|
if let Err(e) = crate::clipboard::deliver(
|
||||||
|
&mut out,
|
||||||
|
&mut native_clipboard,
|
||||||
|
&text,
|
||||||
|
crate::clipboard::in_tmux(),
|
||||||
|
) {
|
||||||
|
tracing::warn!(error = %e, "could not emit clipboard OSC 52 escape");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A keystroke hides the indicator and re-arms the
|
// A keystroke hides the indicator and re-arms the
|
||||||
|
|||||||
@@ -1376,6 +1376,81 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_text_matches_rendered_line_content() {
|
||||||
|
// ADR-0041 drift-lock: `OutputLine::plain_text()` (the `copy`
|
||||||
|
// payload) must equal the visible content `render_output_line`
|
||||||
|
// produces — the concatenation of its span texts — for every
|
||||||
|
// line shape. If the renderer changes how a line reads, this
|
||||||
|
// fails until `plain_text` is brought back in step, so the
|
||||||
|
// clipboard can never silently diverge from the screen.
|
||||||
|
let theme = Theme::dark();
|
||||||
|
let label = crate::echo::TEACHING_ECHO_LABEL;
|
||||||
|
|
||||||
|
let mut pending = OutputLine::echo("create table T", Mode::Simple);
|
||||||
|
pending.status = Some(EchoStatus::Pending);
|
||||||
|
let mut ok = OutputLine::echo("create table T", Mode::Simple);
|
||||||
|
ok.status = Some(EchoStatus::Ok);
|
||||||
|
let mut err = OutputLine::echo("insert into T values (1)", Mode::Advanced);
|
||||||
|
err.status = Some(EchoStatus::Err);
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
pending,
|
||||||
|
ok,
|
||||||
|
err,
|
||||||
|
OutputLine {
|
||||||
|
text: " T".to_string(),
|
||||||
|
kind: OutputKind::System,
|
||||||
|
mode_at_submission: Mode::Simple,
|
||||||
|
styled_runs: None,
|
||||||
|
status: None,
|
||||||
|
},
|
||||||
|
OutputLine {
|
||||||
|
text: "no such table".to_string(),
|
||||||
|
kind: OutputKind::Error,
|
||||||
|
mode_at_submission: Mode::Simple,
|
||||||
|
styled_runs: None,
|
||||||
|
status: None,
|
||||||
|
},
|
||||||
|
OutputLine {
|
||||||
|
text: format!("{label}CREATE TABLE T (id serial)"),
|
||||||
|
kind: OutputKind::TeachingEcho,
|
||||||
|
mode_at_submission: Mode::Advanced,
|
||||||
|
styled_runs: None,
|
||||||
|
status: None,
|
||||||
|
},
|
||||||
|
OutputLine::styled(
|
||||||
|
"SCAN Customers".to_string(),
|
||||||
|
OutputKind::System,
|
||||||
|
Mode::Simple,
|
||||||
|
vec![
|
||||||
|
OutputSpan {
|
||||||
|
byte_range: (0, 4),
|
||||||
|
class: OutputStyleClass::Expensive,
|
||||||
|
},
|
||||||
|
OutputSpan {
|
||||||
|
byte_range: (4, 14),
|
||||||
|
class: OutputStyleClass::Neutral,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for line in &lines {
|
||||||
|
let rendered: String = render_output_line(line, &theme)
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.content.as_ref())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
line.plain_text(),
|
||||||
|
rendered,
|
||||||
|
"plain_text drifted from render for a {:?} line",
|
||||||
|
line.kind,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn category_three_prose_line_renders_all_dim() {
|
fn category_three_prose_line_renders_all_dim() {
|
||||||
// ADR-0038 §6: the existing illuminating client_side notes and
|
// ADR-0038 §6: the existing illuminating client_side notes and
|
||||||
|
|||||||
@@ -129,6 +129,38 @@ fn messages_with_value_parses() {
|
|||||||
crate::snap!("messages_verbose", a);
|
crate::snap!("messages_verbose", a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_with_no_value_parses() {
|
||||||
|
// Copy scope is optional — bare `copy` copies the whole panel.
|
||||||
|
let a = assess_at_end("copy", &schema_empty());
|
||||||
|
assert!(matches!(a.state, InputState::Valid));
|
||||||
|
assert_eq!(a.parse_result.as_deref(), Ok("App(Copy)"));
|
||||||
|
crate::snap!("copy_no_value", a);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_all_parses() {
|
||||||
|
let a = assess_at_end("copy all", &schema_empty());
|
||||||
|
assert!(matches!(a.state, InputState::Valid));
|
||||||
|
assert_eq!(a.parse_result.as_deref(), Ok("App(Copy)"));
|
||||||
|
crate::snap!("copy_all", a);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_last_parses() {
|
||||||
|
let a = assess_at_end("copy last", &schema_empty());
|
||||||
|
assert!(matches!(a.state, InputState::Valid));
|
||||||
|
assert_eq!(a.parse_result.as_deref(), Ok("App(Copy)"));
|
||||||
|
crate::snap!("copy_last", a);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_space_offers_all_and_last() {
|
||||||
|
let a = assess_at_end("copy ", &schema_empty());
|
||||||
|
assert_candidate_present(&a, &["all", "last"]);
|
||||||
|
crate::snap!("copy_space", a);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn partial_entry_word_classifies_as_definite_error_but_completes() {
|
fn partial_entry_word_classifies_as_definite_error_but_completes() {
|
||||||
// `qu` — mid-typing the `quit` entry word.
|
// `qu` — mid-typing the `quit` entry word.
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
|||||||
AppCommand::Messages { .. } => "App(Messages)".into(),
|
AppCommand::Messages { .. } => "App(Messages)".into(),
|
||||||
AppCommand::Undo => "App(Undo)".into(),
|
AppCommand::Undo => "App(Undo)".into(),
|
||||||
AppCommand::Redo => "App(Redo)".into(),
|
AppCommand::Redo => "App(Redo)".into(),
|
||||||
|
AppCommand::Copy { .. } => "App(Copy)".into(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
source: tests/typing_surface/app_commands.rs
|
||||||
|
assertion_line: 146
|
||||||
|
description: "input=\"copy all\" cursor=8"
|
||||||
|
expression: "& a"
|
||||||
|
---
|
||||||
|
Assessment {
|
||||||
|
input: "copy all",
|
||||||
|
cursor: 8,
|
||||||
|
state: Valid,
|
||||||
|
hint: Some(
|
||||||
|
Prose(
|
||||||
|
"Submit with Enter",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
completion: None,
|
||||||
|
parse_result: Ok(
|
||||||
|
"App(Copy)",
|
||||||
|
),
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
source: tests/typing_surface/app_commands.rs
|
||||||
|
assertion_line: 154
|
||||||
|
description: "input=\"copy last\" cursor=9"
|
||||||
|
expression: "& a"
|
||||||
|
---
|
||||||
|
Assessment {
|
||||||
|
input: "copy last",
|
||||||
|
cursor: 9,
|
||||||
|
state: Valid,
|
||||||
|
hint: Some(
|
||||||
|
Prose(
|
||||||
|
"Submit with Enter",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
completion: None,
|
||||||
|
parse_result: Ok(
|
||||||
|
"App(Copy)",
|
||||||
|
),
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
source: tests/typing_surface/app_commands.rs
|
||||||
|
assertion_line: 161
|
||||||
|
description: "input=\"copy \" cursor=5"
|
||||||
|
expression: "& a"
|
||||||
|
---
|
||||||
|
Assessment {
|
||||||
|
input: "copy ",
|
||||||
|
cursor: 5,
|
||||||
|
state: Valid,
|
||||||
|
hint: Some(
|
||||||
|
Candidates {
|
||||||
|
items: [
|
||||||
|
Candidate {
|
||||||
|
text: "all",
|
||||||
|
kind: Keyword,
|
||||||
|
mode: Both,
|
||||||
|
},
|
||||||
|
Candidate {
|
||||||
|
text: "last",
|
||||||
|
kind: Keyword,
|
||||||
|
mode: Both,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selected: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
completion: Some(
|
||||||
|
Completion {
|
||||||
|
replaced_range: (
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
partial_prefix: "",
|
||||||
|
candidates: [
|
||||||
|
Candidate {
|
||||||
|
text: "all",
|
||||||
|
kind: Keyword,
|
||||||
|
mode: Both,
|
||||||
|
},
|
||||||
|
Candidate {
|
||||||
|
text: "last",
|
||||||
|
kind: Keyword,
|
||||||
|
mode: Both,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
parse_result: Ok(
|
||||||
|
"App(Copy)",
|
||||||
|
),
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
source: tests/typing_surface/app_commands.rs
|
||||||
|
assertion_line: 138
|
||||||
|
description: "input=\"copy\" cursor=4"
|
||||||
|
expression: "& a"
|
||||||
|
---
|
||||||
|
Assessment {
|
||||||
|
input: "copy",
|
||||||
|
cursor: 4,
|
||||||
|
state: Valid,
|
||||||
|
hint: Some(
|
||||||
|
Prose(
|
||||||
|
"Submit with Enter",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
completion: None,
|
||||||
|
parse_result: Ok(
|
||||||
|
"App(Copy)",
|
||||||
|
),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user