use std::io::Write as _;
use std::process::{Command, Stdio};
use anyhow::Result;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use super::flat::FlatLine;
use super::format::{
format_ago, format_collapsed_plain, format_message_plain, format_pair_plain, format_scope_plain,
};
use super::panel::{PanelState, frontmatter_lines};
use super::tree::{SessionTree, TreeItem};
#[derive(Debug, Clone)]
pub struct VisualSelection {
anchor: usize,
cursor: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectionSource {
Keyboard,
Mouse,
}
impl VisualSelection {
#[must_use]
pub const fn new(anchor: usize) -> Self {
Self {
anchor,
cursor: anchor,
}
}
pub const fn extend(&mut self, new_cursor: usize) {
self.cursor = new_cursor;
}
#[must_use]
pub const fn range(&self) -> (usize, usize) {
if self.anchor <= self.cursor {
(self.anchor, self.cursor)
} else {
(self.cursor, self.anchor)
}
}
#[must_use]
pub const fn contains(&self, index: usize) -> bool {
let (start, end) = self.range();
index >= start && index <= end
}
#[must_use]
pub const fn line_count(&self) -> usize {
let (start, end) = self.range();
end - start + 1
}
}
fn flat_line_plain(fl: &FlatLine, panel: &PanelState<'_>) -> String {
match fl {
FlatLine::MessageHeader {
message_index,
paired_response,
} => panel
.messages
.get(*message_index)
.map_or_else(String::new, |msg| {
paired_response
.and_then(|ri| panel.messages.get(ri))
.map_or_else(
|| format_message_plain(msg),
|resp| format_pair_plain(msg, resp),
)
}),
FlatLine::Detail {
message_index,
detail_index,
} => panel
.messages
.get(*message_index)
.map_or_else(String::new, |msg| {
let details = frontmatter_lines(msg, panel.theme);
details.get(*detail_index).map_or_else(String::new, |line| {
line.spans.iter().map(|s| s.content.as_ref()).collect()
})
}),
FlatLine::CollapsedHeader {
start_index,
end_index,
count,
} => format_collapsed_plain(&panel.messages, *start_index, *end_index, *count),
FlatLine::ScopeHeader {
parent,
child_count,
position,
..
} => format_scope_plain(parent, *child_count, *position, &panel.messages),
FlatLine::Separator => "---".to_string(),
FlatLine::ScopeChild { depth, inner, .. } => {
let indent = " ".repeat(depth * 4);
let inner_text = flat_line_plain(inner, panel);
format!("{indent}{inner_text}")
}
}
}
#[must_use]
pub fn yank_text(panel: &PanelState<'_>, selection: &VisualSelection) -> String {
let flat = panel.flat_lines();
let (start, end) = selection.range();
let mut lines: Vec<String> = Vec::with_capacity(end.saturating_sub(start) + 1);
for fl in flat.iter().skip(start).take(end - start + 1) {
lines.push(flat_line_plain(fl, panel));
}
lines.join("\n")
}
#[must_use]
pub fn yank_tree_text(tree: &SessionTree, selection: &VisualSelection) -> String {
let items = tree.visible_items();
let (start, end) = selection.range();
let mut lines: Vec<String> = Vec::with_capacity(end.saturating_sub(start) + 1);
for item in items.iter().skip(start).take(end - start + 1) {
match item {
TreeItem::Workspace { node, .. } => {
lines.push(node.path.clone());
}
TreeItem::Session { row, .. } => {
let id_short = if row.info.id.len() > 8 {
&row.info.id[..8]
} else {
&row.info.id
};
let client = row.info.client_name.as_deref().unwrap_or("unknown");
let status = if row.alive { "active" } else { "dead" };
let age = format_ago(row.info.started_at);
lines.push(format!("{id_short} {client} {status} {age}"));
}
}
}
lines.join("\n")
}
#[allow(
clippy::cast_possible_truncation,
reason = "terminal coordinates are always small"
)]
pub fn render_selection_highlight(
selection: &VisualSelection,
visible_start: usize,
buf: &mut Buffer,
content_area: Rect,
style: Style,
) {
for row_offset in 0..content_area.height {
let index = visible_start + row_offset as usize;
if selection.contains(index) {
let y = content_area.y + row_offset;
for x in content_area.x..content_area.x + content_area.width {
buf[(x, y)].set_style(style);
}
}
}
}
pub fn copy_to_clipboard(text: &str) -> Result<()> {
let commands: &[&[&str]] = &[
&["wl-copy"],
&["xclip", "-selection", "clipboard"],
&["xsel", "--clipboard", "--input"],
&["pbcopy"],
];
for cmd in commands {
let program = cmd[0];
let args = &cmd[1..];
let child = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
match child {
Ok(mut child) => {
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(text.as_bytes())?;
}
child.wait()?;
return Ok(());
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
}
Err(e) => {
return Err(e.into());
}
}
}
Ok(())
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests use expect for readable assertions"
)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::style::Modifier;
use chrono::{TimeDelta, Utc};
use crate::config::IconConfig;
use crate::session::{SessionInfo, SessionMessage};
use crate::tui::data::SessionRow;
use crate::tui::icons::IconSet;
use crate::tui::panel::PanelState;
use crate::tui::theme::Theme;
use crate::tui::tree::SessionTree;
fn test_theme() -> Theme {
Theme::new()
}
fn test_icons() -> IconSet {
IconSet::from_config(IconConfig::default())
}
fn make_message(r#type: &str, method: &str, server: &str) -> SessionMessage {
SessionMessage {
id: 0,
r#type: r#type.to_string(),
method: method.to_string(),
server: server.to_string(),
client: "catenary".to_string(),
request_id: None,
parent_id: None,
timestamp: chrono::Utc::now(),
payload: serde_json::json!({}),
}
}
fn make_message_with_payload(
r#type: &str,
method: &str,
server: &str,
payload: serde_json::Value,
) -> SessionMessage {
SessionMessage {
id: 0,
r#type: r#type.to_string(),
method: method.to_string(),
server: server.to_string(),
client: "catenary".to_string(),
request_id: None,
parent_id: None,
timestamp: chrono::Utc::now(),
payload,
}
}
#[test]
fn test_selection_new_single_line() {
let sel = VisualSelection::new(5);
assert_eq!(sel.range(), (5, 5));
assert_eq!(sel.line_count(), 1);
assert!(sel.contains(5));
assert!(!sel.contains(4));
assert!(!sel.contains(6));
}
#[test]
fn test_selection_extend_forward() {
let mut sel = VisualSelection::new(3);
sel.extend(7);
assert_eq!(sel.range(), (3, 7));
assert_eq!(sel.line_count(), 5);
assert!(sel.contains(5));
}
#[test]
fn test_selection_extend_backward() {
let mut sel = VisualSelection::new(7);
sel.extend(3);
assert_eq!(sel.range(), (3, 7));
assert_eq!(sel.line_count(), 5);
assert!(sel.contains(5));
}
#[test]
fn test_selection_contains_boundaries() {
let mut sel = VisualSelection::new(3);
sel.extend(7);
assert!(sel.contains(3), "start boundary should be inclusive");
assert!(sel.contains(7), "end boundary should be inclusive");
assert!(!sel.contains(2));
assert!(!sel.contains(8));
}
#[test]
fn test_yank_text_headers_only() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("test".to_string(), &theme, &icons);
let messages: Vec<SessionMessage> = (0..5)
.map(|i| make_message("hook", &format!("test-{i}"), "catenary"))
.collect();
panel.load_messages(messages);
let sel = {
let mut s = VisualSelection::new(1);
s.extend(3);
s
};
let text = yank_text(&panel, &sel);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3);
for line in &lines {
assert!(line.contains("test-"), "each line should contain method");
}
}
#[test]
fn test_yank_text_with_detail() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("test".to_string(), &theme, &icons);
let messages = vec![
make_message("lsp", "initialized", "rust-analyzer"),
make_message_with_payload(
"hook",
"post-tool",
"catenary",
serde_json::json!({
"file": "/src/lib.rs",
"count": 2,
"preview": "\t:12:1 [error] rustc: bad thing\n\t:34:1 [warning] rustc: meh"
}),
),
make_message("mcp", "tools/list", "catenary"),
];
panel.load_messages(messages);
panel.expanded.insert(1);
let sel = {
let mut s = VisualSelection::new(1);
s.extend(3);
s
};
let text = yank_text(&panel, &sel);
assert!(text.contains("lib.rs"), "header should mention file");
assert!(
text.contains("count"),
"detail should contain payload content"
);
assert!(
text.lines().count() >= 3,
"should have at least 3 lines of output"
);
}
#[test]
fn test_selection_survives_push_message() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("test".to_string(), &theme, &icons);
let messages: Vec<SessionMessage> = (0..10)
.map(|_| make_message("lsp", "initialized", "rust-analyzer"))
.collect();
panel.load_messages(messages);
panel.tail_attached = false;
let mut sel = VisualSelection::new(3);
sel.extend(7);
assert_eq!(sel.range(), (3, 7));
panel.push_message(make_message("mcp", "tools/list", "catenary"));
assert_eq!(panel.messages.len(), 11);
assert_eq!(sel.range(), (3, 7));
assert!(sel.contains(5));
}
#[test]
fn test_render_selection_highlight() {
let theme = test_theme();
let backend = TestBackend::new(60, 7);
let mut terminal = Terminal::new(backend).expect("terminal creation");
terminal
.draw(|f| {
let area = f.area();
for row in 0..area.height {
let y = area.y + row;
for x in area.x..area.x + area.width {
f.buffer_mut()[(x, y)].set_symbol("x");
}
}
let sel = {
let mut s = VisualSelection::new(1);
s.extend(3);
s
};
render_selection_highlight(&sel, 0, f.buffer_mut(), area, theme.selection);
})
.expect("draw");
let buf = terminal.backend().buffer().clone();
let x = 0u16;
for row in 1..=3u16 {
let cell = &buf[(x, row)];
assert!(
cell.modifier.contains(Modifier::REVERSED),
"row {row} should have REVERSED modifier"
);
}
let cell_0 = &buf[(x, 0)];
assert!(
!cell_0.modifier.contains(Modifier::REVERSED),
"row 0 should not have REVERSED"
);
let cell_4 = &buf[(x, 4)];
assert!(
!cell_4.modifier.contains(Modifier::REVERSED),
"row 4 should not have REVERSED"
);
}
#[test]
fn test_copy_to_clipboard_helper() {
let result = copy_to_clipboard("test selection text");
assert!(
result.is_ok(),
"copy_to_clipboard should not error (even without clipboard tools)"
);
}
fn make_session(id: &str, workspace: &str, alive: bool, mins_ago: i64) -> SessionRow {
SessionRow {
info: SessionInfo {
id: id.to_string(),
pid: 1234,
workspace: workspace.to_string(),
started_at: Utc::now() - TimeDelta::minutes(mins_ago),
client_name: Some("test-client".to_string()),
client_version: None,
client_session_id: None,
},
alive,
languages: vec![],
}
}
#[test]
fn test_yank_tree_text_sessions() {
let sessions = vec![
make_session("aaa11111", "/ws/alpha", true, 5),
make_session("bbb22222", "/ws/alpha", false, 10),
];
let tree = SessionTree::from_sessions(sessions);
let sel = {
let mut s = VisualSelection::new(0);
s.extend(2);
s
};
let text = yank_tree_text(&tree, &sel);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3);
assert!(
lines[0].contains("/ws/alpha"),
"first line should be workspace path"
);
assert!(
lines[1].contains("aaa11111"),
"second line should contain session ID"
);
assert!(
lines[1].contains("active"),
"active session should say 'active'"
);
assert!(
lines[2].contains("bbb22222"),
"third line should contain session ID"
);
assert!(lines[2].contains("dead"), "dead session should say 'dead'");
assert!(
lines[1].contains("test-client"),
"should include client name"
);
}
#[test]
fn test_yank_tree_text_workspace_only() {
let sessions = vec![make_session("aaa11111", "/ws/alpha", true, 5)];
let tree = SessionTree::from_sessions(sessions);
let sel = VisualSelection::new(0);
let text = yank_tree_text(&tree, &sel);
assert_eq!(text, "/ws/alpha");
}
}