Skip to content

Commit 10ebb81

Browse files
committed
feat: named buffers, libtmux native API compat, PR #207 workaround elimination
Named Buffers (-b name): - set-buffer -b name, show-buffer -b name, delete-buffer -b name, paste-buffer -b name - Independent HashMap storage separate from positional buffer stack - list-buffers shows both positional and named buffers - Format variables (buffer_name, buffer_size, buffer_sample) support named buffer override libtmux Native API Compatibility: - Handle \ session ID targets (e.g. -t \) — treat as current session - Handle @n window ID targets via FocusWindowById/FocusWindowByIdTemp - list-panes -t @n now correctly lists panes of the targeted window - list-windows -t \ works (session ID ignored, uses current session) - Enables libtmux Server.sessions, .windows, .panes to work natively PR #207 Workaround Elimination (all 6 workarounds proven unnecessary): - WA1: list-sessions -F format flag works correctly - WA2: Concatenated -Fformat syntax works - WA3: has-session with = prefix for exact matching - WA4: Environment variable propagation via set-environment - WA5: Named buffers for concurrent buffer operations - WA6: Bracketed paste mode support Tests: - 22 Rust unit tests for named buffers - 11 Rust unit tests for PR207 compat - 12 E2E PowerShell tests for named buffers - 22 E2E PowerShell tests for PR207 compat - 26 E2E workaround elimination tests - 33 Python tests (25 subprocess + 8 libtmux native API)
1 parent e9ef4a3 commit 10ebb81

13 files changed

Lines changed: 2779 additions & 58 deletions

src/cli.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,9 @@ pub fn print_commands() {
510510
pub fn parse_target(target: &str) -> ParsedTarget {
511511
let mut result = ParsedTarget::default();
512512

513+
// Strip leading '=' prefix (tmux exact-match semantics)
514+
let target = target.strip_prefix('=').unwrap_or(target);
515+
513516
if target.starts_with('%') {
514517
if let Ok(pid) = target[1..].parse::<usize>() {
515518
result.pane = Some(pid);
@@ -524,9 +527,26 @@ pub fn parse_target(target: &str) -> ParsedTarget {
524527
}
525528
return result;
526529
}
530+
// $N is a tmux session ID (e.g., "$0"). In psmux each server process
531+
// hosts exactly one session (always id 0), so session IDs are not
532+
// meaningful for routing. Treat "$N" as "current session" by leaving
533+
// session = None (the caller will fall through to the default session).
534+
if target.starts_with('$') && target[1..].parse::<usize>().is_ok() {
535+
return result;
536+
}
527537

528538
let (session_part, window_pane_part) = if let Some(colon_pos) = target.find(':') {
529-
let session = if colon_pos == 0 { None } else { Some(target[..colon_pos].to_string()) };
539+
let session = if colon_pos == 0 {
540+
None
541+
} else {
542+
let s = &target[..colon_pos];
543+
// $N session IDs (e.g. "$0:1") — ignore the session part
544+
if s.starts_with('$') && s[1..].parse::<usize>().is_ok() {
545+
None
546+
} else {
547+
Some(s.to_string())
548+
}
549+
};
530550
(session, Some(&target[colon_pos + 1..]))
531551
} else if target.starts_with('.') {
532552
(None, Some(target))
@@ -591,6 +611,19 @@ pub fn extract_session_from_target(target: &str) -> String {
591611
parsed.session.unwrap_or_else(|| "default".to_string())
592612
}
593613

614+
/// Extract a flag value from args, supporting both two-token (`-F value`)
615+
/// and concatenated (`-Fvalue`) forms, matching tmux CLI behavior.
616+
pub fn extract_flag_value<'a>(args: &[&'a str], flag: &str) -> Option<String> {
617+
// Two-token form: -F value
618+
if let Some(w) = args.windows(2).find(|w| w[0] == flag) {
619+
return Some(w[1].to_string());
620+
}
621+
// Concatenated form: -Fvalue
622+
args.iter()
623+
.find(|a| a.starts_with(flag) && a.len() > flag.len())
624+
.map(|a| a[flag.len()..].to_string())
625+
}
626+
594627
#[cfg(test)]
595628
mod tests {
596629
use super::*;

src/commands.rs

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,25 +1452,73 @@ fn execute_command_string_single(app: &mut AppState, cmd: &str) -> io::Result<()
14521452
paste_latest(app)?;
14531453
}
14541454
"set-buffer" | "setb" => {
1455-
if let Some(text) = parts.get(1) {
1456-
app.paste_buffers.insert(0, text.to_string());
1457-
if app.paste_buffers.len() > 10 { app.paste_buffers.pop(); }
1455+
// Parse -b name and extract content, skipping flags
1456+
let mut i = 1;
1457+
let mut buf_name: Option<String> = None;
1458+
let mut content: Option<String> = None;
1459+
while i < parts.len() {
1460+
if parts[i] == "-b" {
1461+
if let Some(name) = parts.get(i + 1) {
1462+
buf_name = Some(name.to_string());
1463+
}
1464+
i += 2; // skip -b and its value (buffer name)
1465+
} else if parts[i].starts_with('-') {
1466+
i += 1; // skip unknown flags
1467+
} else {
1468+
// Everything from here is content
1469+
content = Some(parts[i..].join(" "));
1470+
break;
1471+
}
1472+
}
1473+
if let Some(text) = content {
1474+
if let Some(name) = buf_name {
1475+
app.named_buffers.insert(name, text);
1476+
} else {
1477+
app.paste_buffers.insert(0, text);
1478+
if app.paste_buffers.len() > 10 { app.paste_buffers.pop(); }
1479+
}
14581480
}
14591481
}
14601482
"delete-buffer" | "deleteb" => {
1461-
if !app.paste_buffers.is_empty() { app.paste_buffers.remove(0); }
1483+
let buf_name: Option<String> = parts.windows(2).find(|w| w[0] == "-b").map(|w| w[1].to_string());
1484+
if let Some(name) = buf_name {
1485+
if let Ok(idx) = name.parse::<usize>() {
1486+
if idx < app.paste_buffers.len() { app.paste_buffers.remove(idx); }
1487+
} else {
1488+
app.named_buffers.remove(&name);
1489+
}
1490+
} else {
1491+
if !app.paste_buffers.is_empty() { app.paste_buffers.remove(0); }
1492+
}
14621493
}
14631494
"list-buffers" | "lsb" => {
14641495
let mut output = String::new();
14651496
for (i, buf) in app.paste_buffers.iter().enumerate() {
14661497
output.push_str(&format!("buffer{}: {} bytes: \"{}\"\n", i,
14671498
buf.len(), &buf.chars().take(50).collect::<String>()));
14681499
}
1500+
// List named buffers
1501+
let mut names: Vec<&String> = app.named_buffers.keys().collect();
1502+
names.sort();
1503+
for name in names {
1504+
let buf = &app.named_buffers[name];
1505+
let preview: String = buf.chars().take(50).collect();
1506+
output.push_str(&format!("{}: {} bytes: \"{}\"\n", name, buf.len(), preview));
1507+
}
14691508
if output.is_empty() { output.push_str("(no buffers)\n"); }
14701509
show_output_popup(app, "list-buffers", output);
14711510
}
14721511
"show-buffer" | "showb" => {
1473-
if let Some(buf) = app.paste_buffers.first() {
1512+
let buf_name: Option<String> = parts.windows(2).find(|w| w[0] == "-b").map(|w| w[1].to_string());
1513+
if let Some(name) = buf_name {
1514+
if let Ok(idx) = name.parse::<usize>() {
1515+
if let Some(buf) = app.paste_buffers.get(idx) {
1516+
show_output_popup(app, "show-buffer", buf.clone());
1517+
}
1518+
} else if let Some(buf) = app.named_buffers.get(&name) {
1519+
show_output_popup(app, "show-buffer", buf.clone());
1520+
}
1521+
} else if let Some(buf) = app.paste_buffers.first() {
14741522
show_output_popup(app, "show-buffer", buf.clone());
14751523
}
14761524
}
@@ -2346,3 +2394,11 @@ mod tests_issue245_mouse_selection;
23462394
#[cfg(test)]
23472395
#[path = "../tests-rs/test_pr255_active_border.rs"]
23482396
mod tests_pr255_active_border;
2397+
2398+
#[cfg(test)]
2399+
#[path = "../tests-rs/test_pr207_compat_bugs.rs"]
2400+
mod tests_pr207_compat_bugs;
2401+
2402+
#[cfg(test)]
2403+
#[path = "../tests-rs/test_named_buffers.rs"]
2404+
mod tests_named_buffers;

src/format.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
// -F custom format for list commands.
99

1010
use std::env;
11-
use std::cell::Cell;
11+
use std::cell::{Cell, RefCell};
1212

1313
use crate::types::{AppState, Node, LayoutKind, Pane, Mode, VERSION};
1414
use crate::tree::{split_with_gaps, get_active_pane_id, active_pane, count_panes};
@@ -20,13 +20,19 @@ use crate::config::format_key_binding;
2020
thread_local! {
2121
static PANE_POS_OVERRIDE: Cell<Option<usize>> = const { Cell::new(None) };
2222
static BUFFER_IDX_OVERRIDE: Cell<Option<usize>> = const { Cell::new(None) };
23+
static NAMED_BUFFER_OVERRIDE: RefCell<Option<String>> = const { RefCell::new(None) };
2324
}
2425

2526
/// Set the buffer index for per-buffer format expansion in list-buffers -F.
2627
pub fn set_buffer_idx_override(idx: Option<usize>) {
2728
BUFFER_IDX_OVERRIDE.set(idx);
2829
}
2930

31+
/// Set the named buffer override for per-buffer format expansion in list-buffers -F.
32+
pub fn set_named_buffer_override(name: Option<String>) {
33+
NAMED_BUFFER_OVERRIDE.with(|c| *c.borrow_mut() = name);
34+
}
35+
3036
// ─────────────────── tmux window_layout generation ────────────────────
3137

3238
/// Generate a tmux-compatible window_layout string from the pane tree.
@@ -1487,14 +1493,27 @@ pub fn expand_var(var: &str, app: &AppState, win_idx: usize) -> String {
14871493

14881494
// ── Buffer ──
14891495
"buffer_size" => {
1496+
// Check named buffer override first
1497+
let named = NAMED_BUFFER_OVERRIDE.with(|c| c.borrow().clone());
1498+
if let Some(ref name) = named {
1499+
return app.named_buffers.get(name).map(|b| b.len().to_string()).unwrap_or("0".into());
1500+
}
14901501
let idx = BUFFER_IDX_OVERRIDE.get().unwrap_or(0);
14911502
app.paste_buffers.get(idx).map(|b| b.len().to_string()).unwrap_or("0".into())
14921503
}
14931504
"buffer_sample" => {
1505+
let named = NAMED_BUFFER_OVERRIDE.with(|c| c.borrow().clone());
1506+
if let Some(ref name) = named {
1507+
return app.named_buffers.get(name).map(|b| b.chars().take(50).collect::<String>()).unwrap_or_default();
1508+
}
14941509
let idx = BUFFER_IDX_OVERRIDE.get().unwrap_or(0);
14951510
app.paste_buffers.get(idx).map(|b| b.chars().take(50).collect::<String>()).unwrap_or_default()
14961511
}
14971512
"buffer_name" => {
1513+
let named = NAMED_BUFFER_OVERRIDE.with(|c| c.borrow().clone());
1514+
if let Some(name) = named {
1515+
return name;
1516+
}
14981517
let idx = BUFFER_IDX_OVERRIDE.get().unwrap_or(0);
14991518
if idx < app.paste_buffers.len() { format!("buffer{:04}", idx) } else { String::new() }
15001519
}

src/main.rs

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,9 @@ fn run_main() -> io::Result<()> {
435435
i += 1;
436436
}
437437
}
438+
s if s.starts_with("-F") && s.len() > 2 => {
439+
format_str = Some(s[2..].to_string());
440+
}
438441
"-f" => {
439442
if let Some(f) = cmd_args.get(i + 1) {
440443
filter_str = Some(f.to_string());
@@ -680,18 +683,33 @@ fn run_main() -> io::Result<()> {
680683
while k < chars.len() {
681684
let c = chars[k];
682685
// Value-consuming flags: when in a combined group,
683-
// the value is the next cmd_args element (getopt style).
686+
// remaining chars after the flag letter are the value (getopt style).
687+
// If no remaining chars, the value is the next cmd_args element.
688+
macro_rules! consume_value {
689+
() => {{
690+
if k + 1 < chars.len() {
691+
// Rest of this arg is the value (e.g., -F#{fmt})
692+
let val: String = chars[k+1..].iter().collect();
693+
(val, true)
694+
} else {
695+
// Value is the next arg
696+
i += 1;
697+
let val = if i < cmd_args.len() { cmd_args[i].to_string() } else { String::new() };
698+
(val, true)
699+
}
700+
}};
701+
}
684702
match c {
685-
's' => { i += 1; if i < cmd_args.len() { session_name = Some(cmd_args[i].to_string()); } break; }
686-
'n' => { i += 1; if i < cmd_args.len() { window_name = Some(cmd_args[i].to_string()); } break; }
687-
'F' => { i += 1; if i < cmd_args.len() { format_str = Some(cmd_args[i].trim_matches('"').to_string()); } break; }
688-
'c' => { i += 1; if i < cmd_args.len() { start_dir = Some(cmd_args[i].trim_matches('"').to_string()); } break; }
689-
'x' => { i += 1; if i < cmd_args.len() { init_width = cmd_args[i].parse::<u16>().ok(); } break; }
690-
'y' => { i += 1; if i < cmd_args.len() { init_height = cmd_args[i].parse::<u16>().ok(); } break; }
703+
's' => { let (v, _) = consume_value!(); session_name = Some(v); break; }
704+
'n' => { let (v, _) = consume_value!(); window_name = Some(v); break; }
705+
'F' => { let (v, _) = consume_value!(); format_str = Some(v.trim_matches('"').to_string()); break; }
706+
'c' => { let (v, _) = consume_value!(); start_dir = Some(v.trim_matches('"').to_string()); break; }
707+
'x' => { let (v, _) = consume_value!(); init_width = v.parse::<u16>().ok(); break; }
708+
'y' => { let (v, _) = consume_value!(); init_height = v.parse::<u16>().ok(); break; }
691709
'e' => {
692-
i += 1;
710+
let (v, _) = consume_value!();
693711
match crate::util::parse_new_session_e_value_token(
694-
cmd_args.get(i).map(|s| s.as_str()),
712+
Some(v.as_str()),
695713
) {
696714
Ok(pair) => env_vars.push(pair),
697715
Err(msg) => {
@@ -700,8 +718,8 @@ fn run_main() -> io::Result<()> {
700718
}
701719
break;
702720
}
703-
'f' => { i += 1; break; /* skip value */ }
704-
't' => { i += 1; if i < cmd_args.len() { group_target = Some(cmd_args[i].to_string()); } break; }
721+
'f' => { let _ = consume_value!(); break; /* skip value */ }
722+
't' => { let (v, _) = consume_value!(); group_target = Some(v); break; }
705723
// Boolean flags
706724
'd' => { detached = true; }
707725
'P' => { print_info = true; }
@@ -1007,6 +1025,7 @@ fn run_main() -> io::Result<()> {
10071025
match a {
10081026
"-n" => { i += 1; if i < cmd_args.len() { name_arg = Some(cmd_args[i].trim_matches('"').to_string()); } }
10091027
"-F" => { i += 1; if i < cmd_args.len() { format_str = Some(cmd_args[i].trim_matches('"').to_string()); } }
1028+
s if s.starts_with("-F") && s.len() > 2 => { format_str = Some(s[2..].trim_matches('"').to_string()); }
10101029
"-c" => { i += 1; if i < cmd_args.len() { start_dir = Some(cmd_args[i].trim_matches('"').to_string()); } }
10111030
"-t" | "-e" | "-S" => { i += 1; /* skip value */ }
10121031
"-d" => { detached = true; }
@@ -1062,6 +1081,7 @@ fn run_main() -> io::Result<()> {
10621081
if a == "--" { sw_positional.extend(cmd_args[i+1..].iter().map(|s| s.to_string())); break; }
10631082
match a {
10641083
"-F" => { i += 1; if i < cmd_args.len() { format_str = Some(cmd_args[i].trim_matches('"').to_string()); } }
1084+
s if s.starts_with("-F") && s.len() > 2 => { format_str = Some(s[2..].trim_matches('"').to_string()); }
10651085
"-c" => { i += 1; if i < cmd_args.len() { start_dir = Some(cmd_args[i].trim_matches('"').to_string()); } }
10661086
"-p" => { i += 1; if i < cmd_args.len() { size_pct = Some(cmd_args[i].to_string()); size_cells = None; } }
10671087
"-l" => { i += 1; if i < cmd_args.len() { let v = cmd_args[i].to_string(); if v.ends_with('%') { size_pct = Some(v); size_cells = None; } else { size_cells = Some(v); size_pct = None; } } }
@@ -1295,6 +1315,9 @@ fn run_main() -> io::Result<()> {
12951315
i += 1;
12961316
}
12971317
}
1318+
s if s.starts_with("-F") && s.len() > 2 => {
1319+
cmd.push_str(&format!(" -F \"{}\"", s[2..].trim_matches('"').replace("\"", "\\\"")));
1320+
}
12981321
_ => {}
12991322
}
13001323
i += 1;
@@ -1318,6 +1341,9 @@ fn run_main() -> io::Result<()> {
13181341
i += 1;
13191342
}
13201343
}
1344+
s if s.starts_with("-F") && s.len() > 2 => {
1345+
cmd.push_str(&format!(" -F \"{}\"", s[2..].trim_matches('"').replace("\"", "\\\"")));
1346+
}
13211347
"-t" => {
13221348
if let Some(t) = cmd_args.get(i + 1) {
13231349
cmd.push_str(&format!(" -t {}", t));
@@ -1407,10 +1433,14 @@ fn run_main() -> io::Result<()> {
14071433
let mut i = 1;
14081434
while i < cmd_args.len() {
14091435
if cmd_args[i].as_str() == "-t" {
1410-
if let Some(v) = cmd_args.get(i + 1) { t = v.to_string(); }
1436+
if let Some(v) = cmd_args.get(i + 1) {
1437+
// Strip leading '=' prefix (tmux exact-match semantics)
1438+
t = v.strip_prefix('=').unwrap_or(v).to_string();
1439+
}
14111440
i += 1;
14121441
} else if !cmd_args[i].starts_with('-') {
1413-
t = cmd_args[i].to_string();
1442+
let raw = &cmd_args[i];
1443+
t = raw.strip_prefix('=').unwrap_or(raw).to_string();
14141444
break;
14151445
}
14161446
i += 1;
@@ -1612,6 +1642,9 @@ fn run_main() -> io::Result<()> {
16121642
i += 1;
16131643
}
16141644
}
1645+
s if s.starts_with("-F") && s.len() > 2 => {
1646+
format_str = Some(s[2..].to_string());
1647+
}
16151648
"-t" => { i += 1; } // skip target
16161649
_ => {}
16171650
}

0 commit comments

Comments
 (0)