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:
+233
@@ -134,6 +134,50 @@ impl OutputLine {
|
||||
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.
|
||||
@@ -1330,9 +1374,53 @@ impl App {
|
||||
}
|
||||
AppCommand::Undo => self.handle_undo_command(false),
|
||||
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`)
|
||||
/// the command reports that and does nothing; otherwise it asks
|
||||
/// 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]
|
||||
fn project_switched_event_restores_the_stored_mode() {
|
||||
// A switch carries the target project's stored mode; the
|
||||
|
||||
Reference in New Issue
Block a user