Compare commits

...

12 commits

12 changed files with 489 additions and 155 deletions

View file

@ -1,6 +1,6 @@
[package]
name = "remux"
version = "0.3.1"
version = "0.3.4"
edition = "2021"
authors = [ "Valerie Wolfe <sleeplessval@gmail.com>" ]
description = "A friendly command shortener for tmux"

View file

@ -23,7 +23,6 @@ remux n foo
# list sessions
tmux ls
remux l
remux
# attach
tmux a -t foo
@ -48,8 +47,7 @@ tmux swi -t foo
rmux s foo
# cd to session path
tmux run 'printf "#{session_path}" > /tmp/tmux_path'
cd `cat /tmp/tmux_path`
cd `tmux display-mes -p "#{session_path}"`
cd `rmux p`
```

167
man/remux.1 Normal file
View file

@ -0,0 +1,167 @@
.Dd $Mdocdate$
.Dt REMUX 1
.Os
.Sh NAME
.Nm remux
.Nd a command shortener for
.Xr tmux 1
.Sh SYNOPSIS
.Nm remux
.Op Fl dhnqrtv
.Op command
.Op args...
.Sh DESCRIPTION
.Nm
is a wrapper and command shortener for
.Xr tmux 1 ,
primarily focused on improving the ergonomics of using named sessions.
.Pp
If no command is provided, remux will use the context action. Inside a repository, remux will attach or create. If in a session or outside a repository, remux will list sessions.
.Sh COMMANDS
.Nm remux
commands are split into two categories: global commands, which can be used anywhere, and session commands, which can only be used from inside a session.
.Ss GLOBAL COMMANDS
.Bl -tag -width Ds
.It Xo Ic attach
.Op Fl dnr
.Op Ar title
.Op Ar window
.Xc
.Bd -literal -compact
aliases: a
Attaches to an existing session.
.Ed
.Pp
.Bl -tag -width Ds -compact
.It Fl d , Fl -detach
Detach all other connections to the session.
.It Fl n , Fl -nest
Allow nesting (attaching a session from inside another session).
.It Fl r , Fl -read-only
Attach the session in read-only mode.
.It Ar title
The title of the target session. If not given, remux will try to use the name of the repository containing the current directory.
.It Ar window
The name of the window to attach to.
.El
.It Xo Ic detach
.Op Ar title
.Xc
.Bd -literal -compact
aliases: d
Detaches all clients from the target session.
.Ed
.It Xo Ic has
.Op Fl q
.Op Ar title
.Xc
.Bd -literal -compact
aliases: h
Checks whether or not a session exists.
.Ed
.Pp
.Bl -tag -width Ds -compact
.It Fl q , Fl -quiet
Run silently without printing to standard output.
.It Ar title
The title of the target session. If not given, remux will attempt to use the name of the repository containing the current directory.
.El
.It Ic list
.Bd -literal -compact
aliases: l, ls
Pretty-prints a list of tmux sessions.
.Ed
.It Xo Ic new
.Op Fl dn
.Op Fl t\ |\ --target Ar path
.Op Ar title
.Op Ar command
.Xc
.Bd -literal -compact
aliases: n
Creates a new session.
.Ed
.Pp
.Bl -tag -width Ds -compact
.It Fl d , Fl -detach
Creates the session without attaching.
.It Fl n , Fl -nest
Allow nesting (attaching a session from inside another session).
.It Fl t , Fl -target Ar path
Sets the session path to the provided directory.
.It Ar title
Create the session with the given title. If not given, remux will attempt to use the name of the repository containing the current directory.
.El
.El
.Ss SESSION COMMANDS
.Bl -tag -width Ds
.It Ic path
.Bd -literal -compact
aliases: p
Prints the session path.
.Ed
.It Xo Ic switch
.Op Fl r , Fl -read-only
.Ar title
.Xc
.Bd -literal -compact
aliases: s
Switches from the current session to the target.
.Ed
.Pp
.Bl -tag -width Ds -compact
.It Fl r , Fl -read-only
Switch to the target session in read-only mode.
.It Ar title
The title of the session to switch to.
.El
.It Ic title
.Bd -literal -compact
aliases: t, which
Prints the session title.
.El
.Sh ENVIRONMENT
.Bl -tag -width Ds
.It Ev REMUX_ATTACH_SYMBOL
Changes the symbol displayed for attached sessions in the
.Ic list
command.
Default: '*'
.It Ev REMUX_CURRENT_SYMBOL
Changes the symbol displayed for the current session in the
.Ic list
command.
Default: '>'
.It Ev REMUX_NEW_WINDOW
Provides a default windows name when creating a new session. Unused if empty.
Default: (unset)
.El
.Sh EXIT STATUS
.Bl -tag -Width Ds
.It 1
Unmatched command name.
.It 2
Unmatched target session.
.It 3
Unmatched help topic.
.It 4
Missing or invalid target; target was not given or is the same as the current session.
.It 5
.Nm remux
is not running from within a terminal.
.It 6
Nesting error; nest flag is missing or inappropriate.
.It 7
A session command was attempted outside a session.
.Sh EXAMPLES
Use
.Ic path
to navigate to the session path:
.Pp
.Dl $ cd `remux p`
.Pp
.Sh SEE ALSO
.Xr tmux 1
.Sh AUTHORS
.An -nosplit
.An Valerie Wolfe Aq Mt sleeplessval@gmail.com

View file

@ -1,26 +1,41 @@
//! commands accessible from within a session
use std::fs::read_to_string;
use pico_args::Arguments;
use tmux_interface::{
Tmux,
commands
};
use crate::{ error, flag, util };
use crate::{
error,
state::State,
util
};
const TMP_ROOT: &str = "/tmp/remux_path";
pub fn path(state: &mut State) {
state.session_enforce("path");
pub fn switch(pargs: &mut Arguments) {
let message = commands::DisplayMessage::new().print().message("#{session_path}");
let result = Tmux::new().add_command(message).output().unwrap();
let text = String::from_utf8(result.0.stdout);
if let Ok(output) = text {
// trim the trailing line break
let target = output.len() - 1;
println!("{}", &output[0..target]);
}
}
pub fn switch(state: &mut State) {
util::terminal_enforce();
// refuse to run outside a session
util::session_enforce("switch");
state.session_enforce("switch");
// consume optional flags
let read_only = pargs.contains(flag::READ_ONLY);
let read_only = state.flags.read_only;
//TODO: -d flag handling needs to be done manually
let args = pargs.clone().finish();
let args = state.args.clone().finish();
if args.len() < 1 { error::missing_target(); }
let target = args.get(0).unwrap().to_string_lossy().to_string();
@ -36,17 +51,8 @@ pub fn switch(pargs: &mut Arguments) {
.output().ok();
}
pub fn path() {
util::session_enforce("path");
let exec = commands::Run::new().shell_command("printf '#{session_path}' > ".to_string() + TMP_ROOT);
Tmux::new()
.add_command(exec)
.output().ok();
if let Ok(text) = read_to_string(TMP_ROOT) {
println!("{text}");
std::fs::remove_file(TMP_ROOT).ok();
}
pub fn title(state: State) {
state.session_enforce("title");
if let Some(title) = state.title { println!("{title}"); }
}

View file

@ -1,8 +1,5 @@
//! globally available tmux commands.
use std::{
ffi::OsString,
process::exit
};
use std::process::exit;
use pico_args::{ Arguments, Error };
use termion::{ color, style };
@ -15,31 +12,24 @@ use crate::{
env::{ self, env_var },
error,
flag,
state::State,
util
};
pub fn attach(pargs: &mut Arguments) {
// must be run from terminal
pub fn attach(state: &mut State) {
util::terminal_enforce();
// don't allow unflagged nests
util::prevent_nest();
state.nest_init();
// consume optional flags
let read_only = pargs.contains(flag::READ_ONLY);
let detach_other = pargs.contains(flag::DETACH);
let read_only = state.flags.read_only;
let detach_other = state.flags.detached;
let args = pargs.clone().finish();
let target: String;
let window: Option<&OsString>;
if args.len() < 1 {
// missing name will attempt to fall back to repository
target = util::repo_fallback();
if !util::session_exists(target.clone()) { error::missing_target(); }
window = None;
} else {
target = args.get(0).unwrap().to_string_lossy().to_string();
window = args.get(1);
}
// consume arguments
let target = state.target_title().unwrap();
let window = state.target();
// do not allow attaching to the same session
if state.session && target == state.title.clone().unwrap() { error::same_session(); }
// make sure the session exists
let exists = util::session_exists(target.clone());
@ -54,7 +44,7 @@ pub fn attach(pargs: &mut Arguments) {
let select_window: Option<commands::SelectWindow>;
if let Some(window) = window {
let mut command = commands::SelectWindow::new();
command.target_window = Some(window.to_string_lossy());
command.target_window = Some(window.into());
select_window = Some(command);
} else { select_window = None; }
@ -62,18 +52,33 @@ pub fn attach(pargs: &mut Arguments) {
let mut tmux = Tmux::new().add_command(attach);
if let Some(select_window) = select_window { tmux = tmux.add_command(select_window); }
tmux.output().ok();
state.nest_deinit();
}
pub fn detach(pargs: &mut Arguments) {
util::terminal_enforce();
// get target or fallback
let args = pargs.clone().finish();
let target: String;
if args.len() < 1 {
target = util::repo_fallback();
pub fn context_action(state: &State) {
if !state.session {
if let Some(repository) = &state.repository {
let target = repository.name.clone();
let mut args = Arguments::from_vec( vec![(&target).into()] );
let mut substate = State::new(&mut args);
substate.flags = state.flags.clone();
if util::session_exists(&target) {
attach(&mut substate);
} else {
target = args.get(0).unwrap().to_string_lossy().to_string();
new(&mut substate);
}
return;
}
}
// fallback behavior is list
list(&state);
}
pub fn detach(state: &mut State) {
util::terminal_enforce();
let target = state.target_title().unwrap();
// make sure the session exists
let exists = util::session_exists(target.clone());
@ -87,18 +92,12 @@ pub fn detach(pargs: &mut Arguments) {
.disable_echo().output().ok();
}
pub fn has(pargs: &mut Arguments) {
pub fn has(state: &mut State) {
// consume optional flags
let quiet = pargs.contains(flag::QUIET);
let quiet = state.flags.quiet;
// get target or fallback
let args = pargs.clone().finish();
let target: String;
if args.len() < 1 {
target = util::repo_fallback();
} else {
target = args.get(0).unwrap().to_string_lossy().to_string();
}
// get target
let target = state.target_title().unwrap();
// run command
let success = util::session_exists(target.clone());
@ -115,7 +114,7 @@ pub fn has(pargs: &mut Arguments) {
exit( if success { 0 } else { 1 });
}
pub fn list() {
pub fn list(state: &State) {
// get session list
let sessions = util::get_sessions().unwrap_or(Vec::new());
@ -127,55 +126,56 @@ pub fn list() {
// get attached session symbol
let attach_symbol = env_var(env::ATTACH_SYMBOL);
let current_symbol = env_var(env::CURRENT_SYMBOL);
// pretty print session list
println!("sessions:");
for session in sessions.into_iter() {
let group = session.group.unwrap_or("[untitled]".to_string());
let name = session.name.unwrap_or("[untitled]".to_string());
let id = session.id.unwrap();
let attached = session.attached.unwrap_or(0) > 0;
let current = Some(name.clone()) == state.title;
println!(
" {group} ({bold}{blue}{id}{reset}) {bold}{green}{attach}{reset}",
" {current} {name}{reset} ({bold}{blue}{id}{reset}) {bold}{green}{attach}{reset}",
// values
attach = if attached { attach_symbol.clone() } else { "".to_string() },
current = if current { current_symbol.clone() } else { " ".to_string() },
// formatting
bold = style::Bold,
blue = color::Fg(color::Blue),
green = color::Fg(color::LightGreen),
reset = style::Reset
reset = style::Reset,
);
}
}
pub fn new(pargs: &mut Arguments) {
pub fn new(state: &mut State) {
util::terminal_enforce();
// don't allow unflagged nesting
util::prevent_nest();
// get optional flags
let detached = pargs.contains(flag::DETACH);
let target_dir: Result<String, Error> = pargs.value_from_str(flag::TARGET);
let detached = state.flags.detached;
let target_dir: Result<String, Error> = state.args.value_from_str(flag::TARGET);
// delayed nest_init; detached behavior conflicts with nest
if !detached { state.nest_init(); }
else if state.flags.nested { error::conflict_nest(Some("detached session is not nesting")); }
// get environment variables
let window_name = env_var(env::NEW_WINDOW_NAME);
// get target or fallback
let args = pargs.clone().finish();
let title: String;
let command: Option<&OsString>;
if args.len() < 1 {
// attempt repo fallback
title = util::repo_fallback();
command = None;
} else {
title = args.get(0).unwrap().to_string_lossy().to_string();
command = args.get(1);
}
// consume arguments
let title = state.target_title().unwrap();
let command = state.target();
// don't allow duplicate names
let exists = util::session_exists(title.clone());
if exists { error::target_exists(title.clone()); }
let mut new = commands::NewSession::new();
new = new.group_name(title);
if let Some(command) = command { new.shell_command = Some(command.to_string_lossy()); }
new = new.session_name(title);
if let Some(command) = command { new.shell_command = Some(command.into()); }
if detached { new.detached = true; }
if let Ok(target_dir) = target_dir { new = new.start_directory(target_dir); }

View file

@ -3,11 +3,13 @@ use std::env::var;
pub type EnvVar = (&'static str, &'static str);
pub static ATTACH_SYMBOL: EnvVar = ("REMUX_ATTACH_SYMBOL", "*");
pub static CURRENT_SYMBOL: EnvVar = ("REMUX_CURRENT_SYMBOL", ">");
pub static NEW_WINDOW_NAME: EnvVar = ("REMUX_NEW_WINDOW", "");
pub static TMUX: &str = "TMUX";
/// get or default an environment variable
pub fn env_var(envvar: EnvVar) -> String {
var(envvar.0).unwrap_or(envvar.1.to_string())
}
pub fn tmux() -> bool { !var("TMUX").unwrap_or("".to_string()).is_empty() }

View file

@ -6,6 +6,7 @@ pub fn no_subcommand(subcommand: String) {
exit(1);
}
/// target session not found; code 2
pub fn no_target<S: Into<String>>(target: S) {
let target = target.into();
@ -13,30 +14,61 @@ pub fn no_target<S: Into<String>>(target: S) {
exit(2);
}
/// help topic doesn't exist; code 3
pub fn no_help(topic: String) {
println!("remux: no help for \"{topic}\"");
exit(3);
}
/// user provided no target; code 4
pub fn missing_target() {
println!("remux: no target provided");
exit(4);
}
/// refuse to attach to current session; code 4
pub fn same_session() {
println!("remux: cannot attach to same session");
exit(4);
}
/// a session with the target name already exists; code 4
pub fn target_exists<S: Into<String>>(target: S) {
let target = target.into();
println!("remux: session \"{target}\" already exists");
exit(4);
}
/// non-terminal environment prevention; code 5
pub fn not_terminal() {
println!("remux: not running from a terminal");
exit(5);
}
/// tried to nest while not in a session; code 6
pub fn not_nesting() {
println!("remux: cannot use nesting flag outside a TMUX session");
println!("remux: inappropriate nesting flag (-n); not in a session");
exit(6);
}
/// operation requires nesting flag; code 6
pub fn prevent_nest() {
println!("remux: the nesting flag (-n) is required for nesting operation");
exit(6);
}
/// operation conflicts with nesting flag; code 6
pub fn conflict_nest(reason: Option<&'static str>) {
if let Some(reason) = reason { println!("remux: inappropriate nesting flag (-n): {reason}"); }
else { println!("remux: nesting flag (-n) is inappropriate for this operation."); }
exit(6);
}
/// tried to run a session command outside a session; code 7
pub fn not_in_session(cmd: &'static str) {
println!("remux: '{cmd}' must be run from within a session");

View file

@ -1,4 +1,6 @@
use pico_args::Arguments;
type Flag = [&'static str;2];
pub static DETACH: Flag = ["-d", "--detach"];
@ -9,3 +11,41 @@ pub static READ_ONLY: Flag = ["-r", "--read-only"];
pub static TARGET: Flag = ["-t", "--target"];
pub static VERSION: Flag = ["-v", "--version"];
pub struct Flags {
pub detached: bool,
pub nested: bool,
pub quiet: bool,
pub read_only: bool,
pub target: Option<String>,
}
impl Flags {
pub fn from(args: &mut Arguments) -> Flags {
let detached = args.contains(DETACH);
let nested = args.contains(NEST);
let quiet = args.contains(QUIET);
let read_only = args.contains(READ_ONLY);
let target = args.value_from_str(TARGET).ok();
Flags {
detached,
nested,
quiet,
read_only,
target
}
}
pub fn clone(&self) -> Flags {
Flags {
detached: self.detached,
nested: self.nested,
quiet: self.quiet,
read_only: self.read_only,
target: None
}
}
}

View file

@ -13,7 +13,7 @@ println!("remux v{VERSION}
Valerie Wolfe <sleeplessval@gmail.com>
A command wrapper for tmux written in Rust.
usage: remux <command> [<args>]
usage: remux [command] [<args>]
commands:
help Show help text for remux, a command, or a help topic.
@ -25,6 +25,7 @@ commands:
path print session path (session)
switch switch to another session (session)
title print session title (session)
Use 'remux help <command>' to see detailed help text for each command.
@ -101,7 +102,7 @@ flags:
-n, --nest Create the session inside another session.
-t, --target <dir> Sets the target directory for the new session."),
Some("root")
Some("p" | "path")
=>
println!("remux path
Print the session path (#{{session_path}}) to standard output.
@ -126,6 +127,16 @@ flags:
-r, --read-only Attach the target session as read-only."),
Some("w" | "which" | "title")
=>
println!("remux which
Print the title of the current session.
usage: remux which
remux w
remux title"),
// TOPIC HELP
Some("env" | "vars")
@ -137,10 +148,15 @@ REMUX_ATTACH_SYMBOL
by the 'list' command.
Default: '*'
REMUX_CURRENT_SYMBOL
Changes the symbol displayed to denote the current session
in the 'list' command.
Default: '>'
REMUX_NEW_WINDOW
Provides a default window name when creating a session with
the 'new' command, if not empty.
Default: ''"),
Default: (unset)"),
// not found
_ => error::no_help(topic.unwrap())

View file

@ -1,4 +1,3 @@
use std::env::{ set_var, var };
use pico_args::Arguments;
@ -7,9 +6,11 @@ mod env;
mod error;
mod flag;
mod help;
mod state;
mod util;
use help::{ help, version };
use state::State;
static VERSION: &str = env!("CARGO_PKG_VERSION");
@ -28,51 +29,44 @@ fn main() {
return;
}
let nesting = args.contains(flag::NEST);
let tmux_var = var("TMUX").ok();
if nesting {
if tmux_var.is_none() {
error::not_nesting();
}
set_var("TMUX", "");
}
let mut state = State::new(&mut args);
let subcommand = args.subcommand().unwrap();
let target = state.target();
// invoke subcommand function
match subcommand.as_deref() {
match target.as_deref() {
Some("help")
=> help(&mut args),
None
=> command::share::context_action(&state),
Some("a" | "attach")
=> command::share::attach(&mut args),
=> command::share::attach(&mut state),
Some("d" | "detach")
=> command::share::detach(&mut args),
=> command::share::detach(&mut state),
Some("h" | "has")
=> command::share::has(&mut args),
=> command::share::has(&mut state),
None |
Some("l" | "ls" | "list")
=> command::share::list(),
=> command::share::list(&state),
Some("n" | "new")
=> command::share::new(&mut args),
=> command::share::new(&mut state),
Some("p" | "path")
=> command::session::path(),
=> command::session::path(&mut state),
Some("s" | "switch")
=> command::session::switch(&mut args),
=> command::session::switch(&mut state),
Some("t" | "title" | "which")
=> command::session::title(state),
_
=> error::no_subcommand(subcommand.unwrap())
=> error::no_subcommand(target.unwrap())
}
// re-set TMUX var if we unset it for nest mode
if nesting {
set_var("TMUX", tmux_var.unwrap());
}
}

96
src/state.rs Normal file
View file

@ -0,0 +1,96 @@
use std::{
env,
path::PathBuf
};
use pico_args::Arguments;
use crate::{
env::TMUX,
error,
flag::Flags,
util::{ find, session_name }
};
pub struct State<'a> {
pub args: &'a mut Arguments,
pub flags: Flags,
pub session: bool,
tmux_var: Option<String>,
pub title: Option<String>,
pub repository: Option<Repository>
}
impl State<'_> {
pub fn new(args: &mut Arguments) -> State {
let flags = Flags::from(args);
let tmux_var = env::var(TMUX).ok();
let session = tmux_var.is_some();
let title = if session { session_name() } else { None };
let repository = Repository::find();
State {
args,
flags,
session,
tmux_var,
title,
repository
}
}
pub fn nest_init(&self) {
if self.flags.nested {
if self.session { env::set_var(TMUX, ""); } // nesting & session => ok
else { error::not_nesting(); } // nesting & !session => error
} else if self.session { error::prevent_nest(); } // !nesting & session => error
// !nesting & !session => ok
}
pub fn nest_deinit(&self) {
if self.flags.nested && self.session {
env::set_var(TMUX, self.tmux_var.as_ref().unwrap());
}
}
pub fn session_enforce(&self, cmd: &'static str) {
if !self.session { error::not_in_session(cmd); }
}
pub fn target(&mut self) -> Option<String> { self.args.subcommand().unwrap_or(None) }
pub fn target_title(&mut self) -> Option<String> {
let from_args = self.target();
if from_args.is_some() { return from_args; }
else if let Some(repository) = &self.repository { Some(repository.name.clone()) }
else {
error::missing_target();
None
}
}
}
pub struct Repository {
pub path: PathBuf,
pub name: String
}
impl Repository {
pub fn find() -> Option<Repository> {
let path = find(".git", env::current_dir().unwrap());
if let Some(path) = path {
let name = path.file_name().unwrap().to_string_lossy().to_string();
let inner = Repository {
path,
name
};
Some(inner)
} else { None }
}
}

View file

@ -1,8 +1,6 @@
use std::{
env::current_dir,
io::{ stdout, IsTerminal },
path::PathBuf,
process::exit
path::PathBuf
};
use tmux_interface::{
@ -12,10 +10,19 @@ use tmux_interface::{
variables::session::SessionsCtl
};
use crate::{
env,
error
};
use crate::error;
pub fn session_name() -> Option<String> {
let message = commands::DisplayMessage::new().print().message("#{session_name}");
let result = Tmux::new().add_command(message).output();
if let Ok(output) = result {
let text = String::from_utf8(output.0.stdout);
if let Ok(title) = text {
Some(title[0..title.len() - 1].to_owned())
} else { None }
} else { None }
}
/// return a Vec of all sessions or None
pub fn get_sessions() -> Option<Vec<Session>> {
@ -25,19 +32,6 @@ pub fn get_sessions() -> Option<Vec<Session>> {
} else { return None; }
}
/// show the tmux nest text if env var is not unset
pub fn prevent_nest() {
if env::tmux() {
println!("To nest sessions, use the -n flag.");
exit(6);
}
}
/// enforce a command is being used in-session
pub fn session_enforce(cmd: &'static str) {
if !env::tmux() { error::not_in_session(cmd); }
}
/// check whether a target session exists
pub fn session_exists<S: Into<String>>(target: S) -> bool {
let has_session = commands::HasSession::new()
@ -53,23 +47,12 @@ pub fn terminal_enforce() {
if !stdout().is_terminal() { error::not_terminal(); }
}
/// attempt to return the repo name or exit
pub fn repo_fallback() -> String {
let repo = repo_root(current_dir().unwrap());
if repo.is_none() { error::missing_target(); }
/// recursively propagate up directories to find a child
pub fn find(target: &'static str, path: PathBuf) -> Option<PathBuf> {
if path.join(target).exists() { return Some(path); }
let target = repo.unwrap().file_name().unwrap().to_string_lossy().to_string();
target
}
/// recursively attempt to find a git root directory
pub fn repo_root(path: PathBuf) -> Option<PathBuf> {
// if .git dir is found, return
if path.join(".git").exists() { return Some(path); }
// otherwise, attempt to traverse
let parent = path.parent();
if let Some(parent) = parent { repo_root(parent.to_path_buf()) }
if let Some(parent) = parent { return find(target, parent.to_path_buf()) }
else { None }
}