├── .gitignore ├── README.md ├── examples ├── ws ├── ganetivms ├── logmon.cfg └── tmux.conf ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── ci.yml ├── Cargo.toml ├── LICENSE ├── src ├── fromenvstatic.rs ├── config.rs ├── main.rs └── session.rs ├── old ├── _tm ├── README.org └── tm ├── deny.toml └── Cargo.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Migrated to 2 | -------------------------------------------------------------------------------- /examples/ws: -------------------------------------------------------------------------------- 1 | workstations 2 | NONE 3 | ws02 4 | ws03 5 | ws04 6 | ws05 7 | ws06 8 | ws08 9 | ws09 10 | ws10 11 | ws11 12 | ws12 13 | ws13 14 | ws14 15 | -------------------------------------------------------------------------------- /examples/ganetivms: -------------------------------------------------------------------------------- 1 | vm_foobar 2 | NONE 3 | LIST ssh ganetimaster sudo /usr/sbin/gnt-instance list --no-headers -o name --filter '("foo" in tags and "bar" in tags)' 4 | user@anothermachine 5 | LIST cat /home/user/morehosts.list 6 | -------------------------------------------------------------------------------- /examples/logmon.cfg: -------------------------------------------------------------------------------- 1 | logmon 2 | NONE 3 | new-session -d -s SESSION -n SESSION "ssh -t host1 'cd / ; TERM=xterm sudo /some/prog/logmon -t'" 4 | split-window -d -t SESSION:TMWIN "ssh -t host2 'cd / ; TERM=xterm sudo /other/prog/logmon -t'" 5 | set-window-option -t SESSION:TMWIN automatic-rename off 6 | set-window-option -t SESSION:TMWIN allow-rename off 7 | set-window-option -t SESSION:TMWIN synchronize-pane 8 | select-layout -t SESSION:TMWIN even-horizontal 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | 13 | # Maintain dependencies for GitHub Actions 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: "daily" 18 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tm" 3 | description = "tmux helper" 4 | version = "0.9.2" 5 | edition = "2021" 6 | authors = ["Jörg Jaspert "] 7 | license = "BSD-2-Clause" 8 | keywords = [ "tmux", "shell", "helper", "terminal" ] 9 | categories = [ "command-line-utilities" ] 10 | repository = "https://github.com/Ganneff/tm" 11 | 12 | [dependencies] 13 | anyhow = "^1.0" 14 | clap = { version = "4", features = ["derive", "env", "wrap_help"] } 15 | clap-verbosity-flag = "^2" 16 | directories = "^5.0" 17 | fehler = "1.0.0" 18 | itertools = "0.11" 19 | log = "^0.4" 20 | rand = "^0.8" 21 | shellexpand = { version = "3.1.0", features = ["full"] } 22 | shlex = "1.3.0" 23 | tmux_interface = { version = "^0.3" } 24 | tracing = { version = "0.1.40", features = ["attributes"], default-features = false } 25 | tracing-subscriber = { features = ["ansi", "tracing-log", "chrono"], default-features = false, version = "0.3.18" } 26 | 27 | [workspace] 28 | 29 | [dev-dependencies] 30 | regex = "1.11" 31 | 32 | [lints.rust] 33 | unexpected_cfgs = { level = "warn", check-cfg = ["cfg(tarpaulin_include)"] } 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v[0-9]+.* 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | create-release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: taiki-e/create-gh-release-action@v1 17 | with: 18 | # Only create releases based on main branch 19 | branch: main 20 | env: 21 | # (Required) GitHub token for creating GitHub Releases. 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | 24 | upload-assets: 25 | strategy: 26 | matrix: 27 | include: 28 | - target: x86_64-unknown-linux-gnu 29 | - target: x86_64-unknown-linux-musl 30 | - target: x86_64-apple-darwin 31 | os: macos-latest 32 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: taiki-e/upload-rust-binary-action@v1 36 | with: 37 | bin: tm 38 | target: ${{ matrix.target || '' }} 39 | tar: all 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2011-2022 Joerg Jaspert 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIEDi 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 21 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | schedule: 9 | - cron: "0 7 * * *" 10 | workflow_dispatch: 11 | 12 | env: 13 | CARGO_TERM_COLOR: always 14 | 15 | jobs: 16 | test: 17 | name: Run ${{ matrix.jobs.name }} with Rust ${{ matrix.rust }} on ${{ matrix.os }} 18 | runs-on: ${{ matrix.os }} 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | os: [ubuntu-latest] 23 | rust: [stable] 24 | jobs: 25 | - name: Clippy 26 | task: cargo clippy --workspace --all-targets --verbose 27 | - name: tests 28 | task: cargo test --workspace --verbose 29 | - name: fmt 30 | task: cargo fmt --all -- --check 31 | include: 32 | - name: Clippy 33 | env: 34 | RUSTFLAGS: -Dwarnings 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Prepare Rust 38 | uses: hecrj/setup-rust-action@v2 39 | with: 40 | rust-version: ${{ matrix.rust }} 41 | components: clippy, rustfmt 42 | - name: Cache cargo registry 43 | uses: actions/cache@v4 44 | continue-on-error: false 45 | with: 46 | path: | 47 | ~/.cargo/registry 48 | ~/.cargo/git 49 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 50 | restore-keys: | 51 | ${{ runner.os }}-cargo- 52 | - name: apt-get install a package 53 | run: sudo apt-get install -y tmux 54 | - name: Run task ${{ matrix.jobs.name }} 55 | run: ${{ matrix.jobs.task }} 56 | 57 | coverage: 58 | name: Tarpaulin coverage 59 | runs-on: ubuntu-latest 60 | steps: 61 | - name: Checkout repository 62 | uses: actions/checkout@v4 63 | - name: Install stable toolchain 64 | uses: actions-rs/toolchain@v1 65 | with: 66 | toolchain: stable 67 | override: true 68 | - name: apt-get install a package 69 | run: sudo apt-get install -y tmux 70 | - name: Run cargo-tarpaulin 71 | uses: actions-rs/tarpaulin@v0.1 72 | with: 73 | version: '0.22.0' 74 | - name: Upload to codecov.io 75 | uses: codecov/codecov-action@v4.6.0 76 | with: 77 | token: ${{secrets.CODECOV_TOKEN}} 78 | fail_ci_if_error: false 79 | - name: Archive code coverage results 80 | uses: actions/upload-artifact@v4 81 | with: 82 | name: code-coverage-report 83 | path: cobertura.xml 84 | -------------------------------------------------------------------------------- /src/fromenvstatic.rs: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////// 2 | // Macros 3 | //////////////////////////////////////////////////////////////////////// 4 | /// Help setting up static variables based on user environment. 5 | /// 6 | /// We allow the user to configure certain properties/behaviours of tm 7 | /// using environment variables. To reduce boilerplate in code, we use a 8 | /// macro for setting them. We use [mod@`lazy_static`] to define them as 9 | /// global variables, so they are available throughout the whole program - 10 | /// they aren't going to change during runtime, ever, anyways. 11 | /// 12 | /// # Examples 13 | /// 14 | /// ``` 15 | /// # fn main() { 16 | /// static ref TMPDIR: String = fromenvstatic!(asString "TMPDIR", "/tmp"); 17 | /// static ref TMSORT: bool = fromenvstatic!(asBool "TMSORT", true); 18 | /// static ref TMWIN: u8 = fromenvstatic!(asU32 "TMWIN", 1); 19 | /// # } 20 | /// ``` 21 | macro_rules! fromenvstatic { 22 | (asString $envvar:literal, $default:expr) => { 23 | match env::var($envvar) { 24 | Ok(val) => val, 25 | Err(_) => $default.to_string(), 26 | } 27 | }; 28 | (asBool $envvar:literal, $default:literal) => { 29 | match env::var($envvar) { 30 | Ok(val) => match val.to_ascii_lowercase().as_str() { 31 | "true" => true, 32 | "false" => false, 33 | &_ => { 34 | // Test run as "cargo test -- --nocapture" will print this 35 | if cfg!(test) { 36 | println!( 37 | "Variable {} expects true or false, not {}, assuming {}", 38 | $envvar, val, $default 39 | ); 40 | } 41 | error!( 42 | "Variable {} expects true or false, not {}, assuming {}", 43 | $envvar, val, $default 44 | ); 45 | return $default; 46 | } 47 | }, 48 | Err(_) => $default, 49 | } 50 | }; 51 | (asU32 $envvar:literal, $default:literal) => { 52 | match env::var($envvar) { 53 | Ok(val) => { 54 | return val.parse::().unwrap_or_else(|err| { 55 | if cfg!(test) { 56 | println!( 57 | "Couldn't parse variable {} (value: {}) as number (error: {}), assuming {}", 58 | $envvar, val, err, $default 59 | ); 60 | } 61 | error!( 62 | "Couldn't parse variable {} (value: {}) as number (error: {}), assuming {}", 63 | $envvar, val, err, $default 64 | ); 65 | $default 66 | }); 67 | } 68 | Err(_) => $default, 69 | } 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /old/_tm: -------------------------------------------------------------------------------- 1 | #compdef tm 2 | #autoload 3 | 4 | # Copyright (c) 2013 Joerg Jaspert 5 | # Author: Joerg Jaspert 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | # . 11 | # 1. Redistributions of source code must retain the above copyright 12 | # notice, this list of conditions and the following disclaimer. 13 | # 2. Redistributions in binary form must reproduce the above copyright 14 | # notice, this list of conditions and the following disclaimer in the 15 | # documentation and/or other materials provided with the distribution. 16 | # . 17 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 18 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 19 | # OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 20 | # IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 22 | # NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 23 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 24 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 26 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | 28 | zmodload zsh/mapfile 29 | local expl state curcontext="$curcontext" 30 | _tm_done=0 31 | _tm_sess=0 32 | 33 | function _tm_get_sessions { 34 | typeset -ag _tm_sessions 35 | if out=$(tmux list-sessions -F "#{session_name}" 2>/dev/null); then 36 | for sess in ${=out}; do 37 | _tm_sessions+="${sess}:Existing session ${sess}" 38 | done 39 | fi 40 | _tm_done=1 41 | _tm_sess=$#_tm_sessions 42 | 43 | local TMDIR=${TMDIR:-"${HOME}/.tmux.d"} 44 | for file in ${TMDIR}/*; do 45 | _tm_sessions+=${file##*/}:${(f)${(f)mapfile[$file]}[1]} 46 | done 47 | } 48 | 49 | function _tm_action { 50 | typeset -a actions 51 | if [[ $_tm_sess -ne $(tmux list-sessions|wc -l) ]]; then 52 | _tm_get_sessions 53 | fi 54 | ((_tm_done)) || _tm_get_sessions 55 | actions=( 56 | 'ls:List running sessions' 57 | 's:Open ssh session to a single host' 58 | 'ms:Open ssh session to multiple hosts' 59 | '-l:List running sessions' 60 | '-s:Open ssh session to a single host' 61 | '-m:Open ssh session to multiple hosts' 62 | '-e:use existing session' 63 | ${_tm_sessions[@]} 64 | ) 65 | _describe action actions 66 | } 67 | 68 | function _tm_arguments() { 69 | case ${words[1]} in 70 | s) 71 | _arguments '::hostname:_hosts' 72 | ;; 73 | ms) 74 | _arguments -C '*:hostnames:->hosts' 75 | ;; 76 | *) 77 | echo "lala" 78 | ;; 79 | esac 80 | } 81 | 82 | function _tm() { 83 | _arguments -s\ 84 | '-n[Open new multisession even if same exists]' \ 85 | '-c+[Setup session according to TMDIR file]:_tm_sessions: ' \ 86 | ':action:_tm_action' \ 87 | ':session:->session' \ 88 | '*::arguments:_tm_arguments' 89 | 90 | case $state in 91 | hosts) 92 | _description hosts expl "hosts" 93 | _wanted hosts expl hostname _hosts && return 94 | ;; 95 | session) 96 | _tm_get_sessions 97 | ;; 98 | esac 99 | } 100 | 101 | _tm "$@" 102 | -------------------------------------------------------------------------------- /old/README.org: -------------------------------------------------------------------------------- 1 | * tm - tmux manager / helper 2 | 3 | This is a medium sized shell script of mine, used to ease my 4 | day-to-day work with [[http://tmux.sourceforge.net/][tmux]]. 5 | It allows easy handling of various types of tmux sessions, as well as 6 | complex setups. 7 | 8 | ** The boring stuff, license / copyright 9 | Copyright (C) 2011, 2012, 2013, 2014 Joerg Jaspert 10 | 11 | Redistribution and use in source and binary forms, with or without 12 | modification, are permitted provided that the following conditions 13 | are met: 14 | . 15 | 1. Redistributions of source code must retain the above copyright 16 | notice, this list of conditions and the following disclaimer. 17 | 2. Redistributions in binary form must reproduce the above copyright 18 | notice, this list of conditions and the following disclaimer in the 19 | documentation and/or other materials provided with the distribution. 20 | . 21 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 22 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 23 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 24 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 25 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 26 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 30 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | * Usage 33 | As tm started as a very small wrapper around tmux, there wasn't much 34 | commandline parsing. Later on it got a getopts style interface tacked 35 | onto, so now there is a traditional and a getopts style way of 36 | using it. Personally I like the traditional one more... 37 | 38 | - Traditional :: /home/joerg/bin/tm CMD [host|$anything]... 39 | - Getopts :: /home/joerg/bin/tm [-s host] [-m hostlist] [-l] [-n] [-h] [-c config] [-e] [-r REPLACE] 40 | 41 | ** Traditional 42 | #+BEGIN_QUOTE 43 | /home/joerg/bin/tm CMD [host]... 44 | #+END_QUOTE 45 | 46 | CMD is one of 47 | + ls :: List running sessions 48 | + s :: Open ssh session to host 49 | + ms :: Open multi ssh sessions to hosts, synchronizing input 50 | - If you need to open a second session to the same set of 51 | hosts (and not just want to be dropped back into the 52 | already existing session), put a -m in front of ms, 53 | ie. as first parameter to tm. 54 | + $anything :: Either plain tmux session with name of $anything or 55 | session according to a TMDIR file 56 | 57 | ** Getopts 58 | #+BEGIN_QUOTE 59 | /home/joerg/bin/tm [-s host] [-m hostlist] [-l] [-n] [-h] [-c config] [-e] 60 | #+END_QUOTE 61 | 62 | Options: 63 | + -l :: List running sessions 64 | + -s host :: Open ssh session to host 65 | + -m hostlist :: Open multi ssh sessions to hosts, synchronizing input 66 | - Due to the way getopts works, hostlist must be enclosed in "" 67 | + -n :: Open a second session to the same set of hosts 68 | + -c config :: Setup session according to TMDIR file 69 | + -e SESSION :: Use existion session named SESSION 70 | + -r REPLACE :: Value to use for replacing in session files 71 | 72 | 73 | ** TMDIR files 74 | Each file in $TMDIR, which defaults to =~/.tmux.d/=, defines a tmux 75 | session. There are two types of files, those without an extension and 76 | those with the extension =.cfg=. The filename corresponds to the 77 | commandline =$anything= (or =-c=). 78 | 79 | *** Extensionless TMDIR files 80 | - First line :: Session name 81 | - Second line :: extra tmux commandline options 82 | - Any following line :: A hostname to open a shell with in the normal 83 | ssh syntax. (ie [user@]hostname). The [user@]hostname part can be 84 | followed by any option ssh understands. 85 | 86 | *** .cfg TMDIR files 87 | - First line :: Session name 88 | - Second line :: extra tmux commandline options 89 | - Third line :: The new-session command to use. Place NONE here if you 90 | want plain defaults, though that may mean just a shell. Otherwise 91 | put the full new-session command with all options you want here. 92 | - Any following line :: Any tmux command you can find in the tmux 93 | manpage. You should ensure that commands arrive at the right tmux 94 | session / window. To help you with this, there are some variables 95 | available which you can use, they are replaced with values right 96 | before commands are executed: 97 | - SESSION :: replaced with the session name 98 | - TMWIN :: see below for explanation of TMWIN Environment variable 99 | 100 | *** External listings of hostnames 101 | For both types of TMDIR files the hostname/command lines may start 102 | with the word LIST. Everything after it is taken as a shell command 103 | and executed as given. The output is read in line by line and added to 104 | the list of hostnames/commands already given. 105 | 106 | This feature works recursive, so be careful to not build a loop! 107 | *** Different SSH command / options 108 | The environment variable TMSSHCMD can be used to alter the default ssh 109 | command and its options used by tm globally. By default it is a plain 110 | "ssh". Inside an extensionless TMDIR file and on hosts added to the 111 | list using the LIST option described above, ssh options can be set by 112 | simply appending them, space separated, after the hostname. So the 113 | hostlist 114 | #+BEGIN_QUOTE 115 | user@ws01 116 | ws02 117 | root@ws03 -v 118 | #+END_QUOTE 119 | will open 3 connections, one of which using ssh verbose output. 120 | 121 | As this may not be enough or one wants a different ssh command just 122 | for one TMDIR session, the session file recognizes SSHCMD as a token. 123 | The values given after will replace the value of TMSSHCMD for the 124 | session defined by the TMDIR file. 125 | Note: The last defined SSHCMD in the TMDIR file wins. 126 | 127 | ** Environment variables recognized by this script: 128 | - TMPDIR :: Where tmux stores its session information. DEFAULT: If unset: /tmp 129 | - TMSORT :: Should ms sort the hostnames, so it always opens the same 130 | session, no matter in which order hostnames are presented. DEFAULT: true 131 | - TMOPTS :: Extra options to give to the tmux call. Note that this 132 | ONLY affects the final tmux call to attach to the session, not to 133 | the earlier ones creating it. DEFAULT: -2 134 | - TMDIR :: Where are session information files stored. DEFAULT: /$HOME/.tmux.d 135 | - TMWIN :: Where does your tmux starts numbering its windows? This 136 | script tries to find the information in your config, but as it only 137 | checks /$HOME/.tmux.conf it might fail. So if your window 138 | numbers start at anything different to 0, like mine do at 1, then 139 | you can set TMWIN to 1 140 | - TMSESSHOST :: Should the hostname appear in session names? DEFAULT: true 141 | - TMSSHCMD :: Allow to globally define a custom ssh command line. 142 | This can be just the command or any option one wishes to have 143 | everywhere. DEFAULT: ssh 144 | 145 | ** Replacing of variables in session files 146 | In session files you can use the token ++TMREPLACETM++ at any point. 147 | This will be replaced by the value of the -r option (if you use 148 | getopts style) or by the LAST argument on the line if you use 149 | traditional calling. Note that with traditional calling, the argument 150 | will also be tried as a hostname, so it may not make much sense there, 151 | unless using a session file that contains solely of LIST commands. 152 | 153 | * Example usage 154 | You can find three example config files in the =examples/= subdir of 155 | this git repository. 156 | 157 | The first, =logmon.cfg=, defines a slightly more complex tmux session 158 | by giving full tmux commands. It will open a session called logmon, 159 | connect to two hosts and run some logmon program there. The tmux 160 | window will be split into two panes, their input will be synchronized, 161 | so both hosts are controlled at the same time. Additionally some 162 | window options are set, and the layout switched to evenly give both 163 | hosts window space. 164 | 165 | The second, =ws=, is an easy file. It defines a session called 166 | workstations, and simply opens a tmux window split into multiple 167 | panes connecting to a number of workstation hosts. The layout will be 168 | tiled and the input will be synchronized, so all hosts are controlled 169 | at the same time. 170 | 171 | A similar session than the above second example can be started by 172 | using 173 | #+BEGIN_SRC shell 174 | tm ms ws02 ws03 ws04 [...] 175 | #+END_SRC 176 | with the only difference that this needs more typing, so for repeated 177 | usage putting it into a file is easier. 178 | 179 | The third file, =ganetivms=, uses the syntax of the easy files, but 180 | only has one hostname defined statically (including a different 181 | username than normal) and gets most of the hostnames by first asking a 182 | /ganetimaster/ instance for machines that are tagged /foo/ and /bar/ 183 | and then adding the contents of a /morehosts.list/ file. Should 184 | /morehosts.list/ contain another *LIST* line, it would also execute it 185 | and use append its output to the hostlist. 186 | 187 | A command of 188 | #+BEGIN_SRC shell 189 | tm s user@host 190 | #+END_SRC 191 | will open a single ssh session to the given user@host. Later on 192 | repeating this command will attach to the old session. 193 | 194 | * Completion 195 | For zsh users tab completion is available. Simply copy the file =_tm= 196 | to the right place. 197 | This is more likely alpha quality completion, feel free to send 198 | patches to make it better. :) 199 | -------------------------------------------------------------------------------- /examples/tmux.conf: -------------------------------------------------------------------------------- 1 | # Ganneffs tmux config 2 | # the colors in here DO NEED a 256 colors terminal. I am using 3 | # the rxvt from package rxvt-unicode-256color 4 | 5 | # Screen like Ctrl-a for prefix 6 | unbind C-b 7 | set -g prefix ^A 8 | # And pass it through when pressing twice 9 | bind a send-prefix 10 | 11 | # Allow ^A^c to create a new window, not just ^Ac 12 | bind ^c new-window -c "#{pane_current_path}" 13 | bind c new-window -c "#{pane_current_path}" 14 | 15 | # last active window 16 | bind-key C-a last-window 17 | 18 | # Bind function keys. 19 | # -n means - no need to press ^A first. 20 | bind-key -n C-F1 select-window -t 1 21 | bind-key -n C-F2 select-window -t 2 22 | bind-key -n C-F3 select-window -t 3 23 | bind-key -n C-F4 select-window -t 4 24 | bind-key -n C-F5 select-window -t 5 25 | bind-key -n C-F6 select-window -t 6 26 | bind-key -n C-F7 select-window -t 7 27 | bind-key -n C-F8 select-window -t 8 28 | bind-key -n C-F9 select-window -t 9 29 | bind-key -n C-F10 select-window -t 10 30 | bind-key -n C-F11 select-window -t 11 31 | bind-key -n C-F12 select-window -t 12 32 | 33 | # And let Meta-number switch to the pane with that number 34 | # This drops the M-1 .. M-5 keys to switch the layout, 35 | # i just cycle through that with C-a space when I switch them. 36 | bind-key M-1 select-pane -t 1 37 | bind-key M-2 select-pane -t 2 38 | bind-key M-3 select-pane -t 3 39 | bind-key M-4 select-pane -t 4 40 | bind-key M-5 select-pane -t 5 41 | bind-key M-6 select-pane -t 6 42 | bind-key M-7 select-pane -t 7 43 | bind-key M-8 select-pane -t 8 44 | bind-key M-9 select-pane -t 9 45 | bind-key M-0 select-pane -t 10 46 | 47 | # vi* style pane movement 48 | bind-key h select-pane -L 49 | bind-key C-h select-pane -L 50 | bind-key j select-pane -D 51 | bind-key C-j select-pane -D 52 | # Already in use for me as kill-window 53 | #bind-key k select-pane -U 54 | #bind-key C-k select-pane -U 55 | bind-key l select-pane -R 56 | bind-key C-l select-pane -R 57 | 58 | bind-key -r "<" swap-window -t -1 59 | bind-key -r ">" swap-window -t +1 60 | 61 | bind-key -r H resize-pane -L 5 62 | bind-key -r J resize-pane -D 5 63 | bind-key -r K resize-pane -U 5 64 | bind-key -r L resize-pane -R 5 65 | 66 | bind-key "|" split-window -h -c "#{pane_current_path}" 67 | bind-key "-" split-window -v -c "#{pane_current_path}" 68 | 69 | # Toggle activity monitoring 70 | bind-key m setw monitor-activity 71 | 72 | # Force a window width/height. Good for stupid things like ilo. 73 | bind-key C-w setw force-width 80 74 | bind-key C-u setw force-width 0 75 | bind-key C-i setw force-height 0 76 | bind-key C-h setw force-height 24 77 | 78 | # In "multi-screen" mode, synchronized panes that is, toggle synced input 79 | bind-key C-s setw synchronize-panes 80 | 81 | # | and - for pane splitting 82 | unbind-key % # Remove default binding since we’re replacing 83 | bind-key | split-window -h 84 | # of course this looses "delete buffer" 85 | bind-key - split-window -v 86 | 87 | # open ssh to somewhere. 88 | bind-key S command-prompt -p "SSH Target: " "new-window -n %1 'exec ssh %1'" 89 | 90 | # reload the config 91 | bind-key R source-file ~/.tmux.conf \; display-message "tmux.conf reloaded!" 92 | 93 | # confirm before killing a window or the server 94 | bind-key k confirm kill-window 95 | #bind-key K confirm kill-server 96 | 97 | # open a man page in new window 98 | bind-key / command-prompt "split-window 'exec man %%'" 99 | 100 | # Pipe any output in the active pane into a file 101 | bind-key C-p pipe-pane -o 'cat >>~/tmuxoutput.#I-#P' 102 | 103 | # Less ugly key for the copy mode 104 | bind-key Escape copy-mode -u 105 | 106 | # Start window numbering at 1 107 | set -g base-index 1 108 | # Like base-index, but set the starting index for pane numbers. 109 | set-window-option -g pane-base-index 1 110 | 111 | # No delay in command sequences 112 | set -s escape-time 0 113 | 114 | # Rather than constraining window size to the maximum size of any client 115 | # connected to the *session*, constrain window size to the maximum size of any 116 | # client connected to *that window*. Much more reasonable. 117 | setw -g aggressive-resize on 118 | 119 | # Activity monitoring 120 | #setw -g monitor-activity on 121 | #set -g visual-activity on 122 | 123 | # Some default options 124 | 125 | # Show or hide the status line. 126 | set -g status on 127 | # Update the status bar every interval seconds. By default, updates 128 | # will occur every 15 seconds. A setting of zero disables redrawing at 129 | # interval. 130 | set -g status-interval 1 131 | 132 | #### COLOUR (Solarized 256) 133 | 134 | # default statusbar colors 135 | set-option -g status-bg colour235 #base02 136 | set-option -g status-fg colour136 #yellow 137 | set-option -g status-attr default 138 | 139 | # default window title colors 140 | set-window-option -g window-status-fg colour244 #base0 141 | set-window-option -g window-status-bg default 142 | #set-window-option -g window-status-attr dim 143 | 144 | # active window title colors 145 | set-window-option -g window-status-current-fg colour166 #orange 146 | set-window-option -g window-status-current-bg default 147 | #set-window-option -g window-status-current-attr bright 148 | 149 | # pane border 150 | set-option -g pane-border-fg colour235 #base02 151 | set-option -g pane-active-border-fg colour240 #base01 152 | 153 | # message text 154 | set-option -g message-bg colour235 #base02 155 | set-option -g message-fg colour166 #orange 156 | 157 | # pane number display 158 | set-option -g display-panes-active-colour colour33 #blue 159 | set-option -g display-panes-colour colour166 #orange 160 | 161 | # clock 162 | set-window-option -g clock-mode-colour colour64 #green 163 | 164 | # Display string to the left of the status bar. string will be passed 165 | # through strftime(3) before being used. By default, the session name 166 | # is shown. string may contain any of the following special character 167 | # sequences: 168 | # 169 | # Character pair Replaced with 170 | # #(shell-command) First line of the command's 171 | # output 172 | # #[attributes] Colour or attribute change 173 | # #H Hostname of local host 174 | # #h Hostname of local host without 175 | # the domain name 176 | # #F Current window flag 177 | # #I Current window index 178 | # #P Current pane index 179 | # #S Session name 180 | # #T Current window title 181 | # #W Current window name 182 | # ## A literal `#' 183 | # 184 | # The #(shell-command) form executes `shell-command' and 185 | # inserts the first line of its output. Note that shell 186 | # commands are only executed once at the interval specified 187 | # by the status-interval option: if the status line is 188 | # redrawn in the meantime, the previous result is used. 189 | # Shell commands are executed with the tmux global 190 | # environment set. 191 | # 192 | # The window title (#T) is the title set by the program 193 | # running within the window using the OSC title setting 194 | # sequence, for example: 195 | # 196 | # $ printf '\033]2;My Title\033\\' 197 | # 198 | # When a window is first created, its title is the 199 | # hostname. 200 | # 201 | # #[attributes] allows a comma-separated list of attributes 202 | # to be specified, these may be `fg=colour' to set the 203 | # foreground colour, `bg=colour' to set the background 204 | # colour, the name of one of the attributes (listed under 205 | # the message-attr option) to turn an attribute on, or an 206 | # attribute prefixed with `no' to turn one off, for example 207 | # nobright. Examples are: 208 | # 209 | # #(sysctl vm.loadavg) 210 | # #[fg=yellow,bold]#(apm -l)%%#[default] [#S] 211 | # 212 | # Where appropriate, special character sequences may be 213 | # prefixed with a number to specify the maximum length, for 214 | # example `#24T'. 215 | # 216 | # By default, UTF-8 in string is not interpreted, to enable 217 | # UTF-8, use the status-utf8 option. 218 | #set -g status-left "" 219 | #set -g status-right "#(uptime|awk '{print $11}')" 220 | 221 | #set -g status-right "#[fg=green,bold]%H:%M:%S" # %d-%b-%y 222 | set -g status-left '#[fg=colour14,bold]%d-%m-%y %H:%M:%S' 223 | set -g status-left-length 42 224 | set -g status-right '#[fg=colour143,bold]#(cut -d " " -f 1-4 /proc/loadavg)#[default] #[default] #[fg=green,bold]#H#[default]' 225 | set -g status-right-length 52 226 | 227 | # Enable utf8 228 | set -g utf8 on 229 | 230 | # Instruct tmux to treat top-bit-set characters in the status-left and 231 | # status-right strings as UTF-8; notably, this is important for wide 232 | # characters. This option defaults to off. 233 | set -g status-utf8 on 234 | 235 | set-window-option -g window-status-format '#P###I:#W#F' 236 | set-window-option -g window-status-current-format '#P###I:#W#F' 237 | 238 | # Monitor for activity in the window. Windows with activity are 239 | # highlighted in the status line. 240 | #set-window-option -g monitor-activity on 241 | 242 | # Set the amount of time for which status line messages and other on-screen 243 | # indicators are displayed. time is in milliseconds. 244 | set -g display-time 3000 245 | 246 | # We like zsh 247 | set -g default-command zsh 248 | 249 | # Set the number of error or information messages to save in the message 250 | # log for each client. The default is 20. 251 | set -g message-limit 100 252 | 253 | # If on, ring the terminal bell when an activity, content or silence alert occurs. 254 | set -g bell-on-alert on 255 | # listen for activity on all windows 256 | set -g bell-action any 257 | 258 | # Set the maximum number of lines held in window history. 259 | # This setting applies only to new windows - existing window 260 | # histories are not resized and retain the limit at the point 261 | # they were created. 262 | set -g history-limit 100000 263 | 264 | # If on, tmux captures the mouse and allows panes to be resized by 265 | # dragging on their borders. 266 | # Kills selection, so turned off. 267 | set -g mouse-resize-pane off 268 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # This template contains all of the possible sections and their default values 2 | 3 | # Note that all fields that take a lint level have these possible values: 4 | # * deny - An error will be produced and the check will fail 5 | # * warn - A warning will be produced, but the check will not fail 6 | # * allow - No warning or error will be produced, though in some cases a note 7 | # will be 8 | 9 | # The values provided in this template are the default values that will be used 10 | # when any section or field is not specified in your own configuration 11 | 12 | # If 1 or more target triples (and optionally, target_features) are specified, 13 | # only the specified targets will be checked when running `cargo deny check`. 14 | # This means, if a particular package is only ever used as a target specific 15 | # dependency, such as, for example, the `nix` crate only being used via the 16 | # `target_family = "unix"` configuration, that only having windows targets in 17 | # this list would mean the nix crate, as well as any of its exclusive 18 | # dependencies not shared by any other crates, would be ignored, as the target 19 | # list here is effectively saying which targets you are building for. 20 | targets = [ 21 | { triple = "x86_64-unknown-linux-gnu" }, 22 | { triple = "x86_64-unknown-linux-musl" }, 23 | { triple = "x86_64-apple-darwin" }, 24 | # The triple can be any string, but only the target triples built in to 25 | # rustc (as of 1.40) can be checked against actual config expressions 26 | #{ triple = "x86_64-unknown-linux-musl" }, 27 | # You can also specify which target_features you promise are enabled for a 28 | # particular target. target_features are currently not validated against 29 | # the actual valid features supported by the target architecture. 30 | #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, 31 | ] 32 | 33 | # This section is considered when running `cargo deny check advisories` 34 | # More documentation for the advisories section can be found here: 35 | # https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html 36 | [advisories] 37 | # The path where the advisory database is cloned/fetched into 38 | db-path = "~/.cargo/advisory-db" 39 | # The url(s) of the advisory databases to use 40 | db-urls = ["https://github.com/rustsec/advisory-db"] 41 | # The lint level for security vulnerabilities 42 | vulnerability = "deny" 43 | # The lint level for unmaintained crates 44 | unmaintained = "warn" 45 | # The lint level for crates that have been yanked from their source registry 46 | yanked = "warn" 47 | # The lint level for crates with security notices. Note that as of 48 | # 2019-12-17 there are no security notice advisories in 49 | # https://github.com/rustsec/advisory-db 50 | notice = "warn" 51 | # A list of advisory IDs to ignore. Note that ignored advisories will still 52 | # output a note when they are encountered. 53 | ignore = [ 54 | #"RUSTSEC-0000-0000", 55 | ] 56 | # Threshold for security vulnerabilities, any vulnerability with a CVSS score 57 | # lower than the range specified will be ignored. Note that ignored advisories 58 | # will still output a note when they are encountered. 59 | # * None - CVSS Score 0.0 60 | # * Low - CVSS Score 0.1 - 3.9 61 | # * Medium - CVSS Score 4.0 - 6.9 62 | # * High - CVSS Score 7.0 - 8.9 63 | # * Critical - CVSS Score 9.0 - 10.0 64 | #severity-threshold = 65 | 66 | # This section is considered when running `cargo deny check licenses` 67 | # More documentation for the licenses section can be found here: 68 | # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html 69 | [licenses] 70 | # The lint level for crates which do not have a detectable license 71 | unlicensed = "deny" 72 | # List of explicitly allowed licenses 73 | # See https://spdx.org/licenses/ for list of possible licenses 74 | # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. 75 | allow = [ 76 | #"MIT", 77 | #"Apache-2.0", 78 | #"Apache-2.0 WITH LLVM-exception", 79 | ] 80 | # List of explicitly disallowed licenses 81 | # See https://spdx.org/licenses/ for list of possible licenses 82 | # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. 83 | deny = [ 84 | # "Nokia", 85 | ] 86 | # Lint level for licenses considered copyleft 87 | copyleft = "warn" 88 | # Blanket approval or denial for OSI-approved or FSF Free/Libre licenses 89 | # * both - The license will be approved if it is both OSI-approved *AND* FSF 90 | # * either - The license will be approved if it is either OSI-approved *OR* FSF 91 | # * osi-only - The license will be approved if is OSI-approved *AND NOT* FSF 92 | # * fsf-only - The license will be approved if is FSF *AND NOT* OSI-approved 93 | # * neither - This predicate is ignored and the default lint level is used 94 | allow-osi-fsf-free = "either" 95 | # Lint level used when no other predicates are matched 96 | # 1. License isn't in the allow or deny lists 97 | # 2. License isn't copyleft 98 | # 3. License isn't OSI/FSF, or allow-osi-fsf-free = "neither" 99 | default = "deny" 100 | # The confidence threshold for detecting a license from license text. 101 | # The higher the value, the more closely the license text must be to the 102 | # canonical license text of a valid SPDX license file. 103 | # [possible values: any between 0.0 and 1.0]. 104 | confidence-threshold = 0.8 105 | # Allow 1 or more licenses on a per-crate basis, so that particular licenses 106 | # aren't accepted for every possible crate as with the normal allow list 107 | exceptions = [ 108 | # Each entry is the crate and version constraint, and its specific allow 109 | # list 110 | #{ allow = ["Zlib"], name = "adler32", version = "*" }, 111 | ] 112 | 113 | # Some crates don't have (easily) machine readable licensing information, 114 | # adding a clarification entry for it allows you to manually specify the 115 | # licensing information 116 | #[[licenses.clarify]] 117 | # The name of the crate the clarification applies to 118 | #name = "ring" 119 | # The optional version constraint for the crate 120 | #version = "*" 121 | # The SPDX expression for the license requirements of the crate 122 | #expression = "MIT AND ISC AND OpenSSL" 123 | # One or more files in the crate's source used as the "source of truth" for 124 | # the license expression. If the contents match, the clarification will be used 125 | # when running the license check, otherwise the clarification will be ignored 126 | # and the crate will be checked normally, which may produce warnings or errors 127 | # depending on the rest of your configuration 128 | #license-files = [ 129 | # Each entry is a crate relative path, and the (opaque) hash of its contents 130 | #{ path = "LICENSE", hash = 0xbd0eed23 } 131 | #] 132 | 133 | [licenses.private] 134 | # If true, ignores workspace crates that aren't published, or are only 135 | # published to private registries. 136 | # To see how to mark a crate as unpublished (to the official registry), 137 | # visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. 138 | ignore = false 139 | # One or more private registries that you might publish crates to, if a crate 140 | # is only published to private registries, and ignore is true, the crate will 141 | # not have its license(s) checked 142 | registries = [ 143 | #"https://sekretz.com/registry 144 | ] 145 | 146 | # This section is considered when running `cargo deny check bans`. 147 | # More documentation about the 'bans' section can be found here: 148 | # https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html 149 | [bans] 150 | # Lint level for when multiple versions of the same crate are detected 151 | multiple-versions = "warn" 152 | # Lint level for when a crate version requirement is `*` 153 | wildcards = "allow" 154 | # The graph highlighting used when creating dotgraphs for crates 155 | # with multiple versions 156 | # * lowest-version - The path to the lowest versioned duplicate is highlighted 157 | # * simplest-path - The path to the version with the fewest edges is highlighted 158 | # * all - Both lowest-version and simplest-path are used 159 | highlight = "all" 160 | # List of crates that are allowed. Use with care! 161 | allow = [ 162 | #{ name = "ansi_term", version = "=0.11.0" }, 163 | ] 164 | # List of crates to deny 165 | deny = [ 166 | # Each entry the name of a crate and a version range. If version is 167 | # not specified, all versions will be matched. 168 | #{ name = "ansi_term", version = "=0.11.0" }, 169 | # 170 | # Wrapper crates can optionally be specified to allow the crate when it 171 | # is a direct dependency of the otherwise banned crate 172 | #{ name = "ansi_term", version = "=0.11.0", wrappers = [] }, 173 | ] 174 | # Certain crates/versions that will be skipped when doing duplicate detection. 175 | skip = [ 176 | #{ name = "ansi_term", version = "=0.11.0" }, 177 | ] 178 | # Similarly to `skip` allows you to skip certain crates during duplicate 179 | # detection. Unlike skip, it also includes the entire tree of transitive 180 | # dependencies starting at the specified crate, up to a certain depth, which is 181 | # by default infinite 182 | skip-tree = [ 183 | #{ name = "ansi_term", version = "=0.11.0", depth = 20 }, 184 | ] 185 | 186 | # This section is considered when running `cargo deny check sources`. 187 | # More documentation about the 'sources' section can be found here: 188 | # https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html 189 | [sources] 190 | # Lint level for what to happen when a crate from a crate registry that is not 191 | # in the allow list is encountered 192 | unknown-registry = "warn" 193 | # Lint level for what to happen when a crate from a git repository that is not 194 | # in the allow list is encountered 195 | unknown-git = "warn" 196 | # List of URLs for allowed crate registries. Defaults to the crates.io index 197 | # if not specified. If it is specified but empty, no registries are allowed. 198 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 199 | # List of URLs for allowed Git repositories 200 | allow-git = [] 201 | 202 | # [sources.allow-org] 203 | # # 1 or more github.com organizations to allow git sources for 204 | # github = [""] 205 | # # 1 or more gitlab.com organizations to allow git sources for 206 | # gitlab = [""] 207 | # # 1 or more bitbucket.org organizations to allow git sources for 208 | # bitbucket = [""] 209 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | //! tm - a tmux helper 2 | //! 3 | //! SPDX-License-Identifier: BSD-2-Clause 4 | //! 5 | //! Copyright (C) 2011-2024 Joerg Jaspert 6 | //! 7 | 8 | #![warn(missing_docs)] 9 | 10 | use anyhow::anyhow; 11 | use clap::{Parser, Subcommand}; 12 | use fehler::{throw, throws}; 13 | use rand::Rng; 14 | use tracing::debug; 15 | 16 | use crate::{TMSESSHOST, TMSORT}; 17 | 18 | use crate::Session; 19 | 20 | #[derive(Debug, Parser)] 21 | #[non_exhaustive] 22 | #[clap(author, version, about)] 23 | #[clap(propagate_version = true)] 24 | #[clap(arg_required_else_help = true)] 25 | #[clap(dont_collapse_args_in_usage = true)] 26 | /// Options for tm, they are closely resembling (ought to be 27 | /// compatible to) the ones from the old shell script. 28 | pub struct Cli { 29 | /// subcommands 30 | #[clap(subcommand)] 31 | pub command: Option, 32 | 33 | /// List running sessions 34 | /// 35 | /// This is basically `tmux ls` 36 | #[clap(short, display_order = 10)] 37 | pub ls: bool, 38 | 39 | /// Open SSH session to the destination 40 | /// 41 | /// The arguments are the destinations for `ssh(1)`, which may be 42 | /// specified as either \[user@]hostname or a URI of the form 43 | /// ssh://\[user@]hostname\[:port]. 44 | /// 45 | /// When multiple destinations are specified, they are all opened 46 | /// into separate tmux windows (not sessions!). 47 | #[clap(short = 's', display_order = 15, num_args = 1..)] 48 | pub sshhosts: Option>, 49 | 50 | /// Open multi SSH sessions to hosts, synchronizing input. 51 | /// 52 | /// The same details for the arguments as for [Cli::sshhosts] applies. 53 | /// 54 | /// When multiple destinations are specified, they are all opened 55 | /// into one single tmux window with many panes in there. 56 | /// Additionally, the "synchronize-input" option is turned on, so 57 | /// that anything entered will be send to every host. 58 | #[clap(short = 'm', display_order = 20, num_args = 1..)] 59 | pub multihosts: Option>, 60 | 61 | /// Open as second session to the same set of hosts as an existing 62 | /// one, instead of attaching to the existing 63 | /// 64 | /// This way more than one session to the same set of ssh 65 | /// destinations can be opened and used. 66 | #[clap(short = 'n', display_order = 25)] 67 | pub second: bool, 68 | 69 | /// Group session - attach to an existing session, but keep 70 | /// separate window config 71 | /// 72 | /// This will show the same set of windows, but allow different 73 | /// handling of the session according to client. This way one 74 | /// client could display the first, another the second window. 75 | /// Without this option, a second client would always show the 76 | /// same content as the first. 77 | #[clap(short = 'g', display_order = 30)] 78 | pub group: bool, 79 | 80 | /// Kill a session, Session name as shown by ls 81 | #[clap(short = 'k', display_order = 35)] 82 | pub kill: Option, 83 | 84 | /// Setup session according to config file in TMDIR 85 | #[clap(short = 'c', display_order = 40)] 86 | pub config: Option, 87 | 88 | #[clap(flatten)] 89 | pub verbose: clap_verbosity_flag::Verbosity, 90 | 91 | /// Either plain tmux session name, or session/file found in TMDIR 92 | /// 93 | /// If this exists as a tmux session, it behaves like `tmux 94 | /// attach`. Otherwise it checks TMDIR for existence of a config 95 | /// file and will open a session as specified in there. 96 | #[clap(display_order = 50)] 97 | pub session: Option, 98 | 99 | /// Value to use for replacing in session files (see their help) 100 | #[clap(display_order = 55)] 101 | pub replace: Option, 102 | 103 | /// Break a multi-session pane into single windows 104 | #[clap(short = 'b', display_order = 60)] 105 | pub breakw: Option, 106 | 107 | /// Join multiple windows into one single one with many panes 108 | #[clap(short = 'j', display_order = 65)] 109 | pub joinw: Option, 110 | } 111 | 112 | #[derive(Subcommand, Debug, PartialEq)] 113 | #[non_exhaustive] 114 | /// Holds list of subcommands in use for tm 115 | pub enum Commands { 116 | /// List running sessions 117 | /// 118 | /// This is basically `tmux ls` 119 | #[clap(display_order = 10)] 120 | Ls {}, 121 | 122 | /// Open SSH session to the destination 123 | /// 124 | /// When multiple destinations are specified, they are all opened 125 | /// into separate tmux windows (not sessions!). 126 | #[clap(display_order = 15)] 127 | S { 128 | /// Target destinations for `ssh(1)`, which may be specified as 129 | /// either \[user@]hostname or a URI of the form 130 | /// ssh://\[user@]hostname\[:port]. 131 | #[clap(required = true)] 132 | hosts: Vec, 133 | }, 134 | 135 | /// Open multi SSH sessions to hosts, synchronizing input. 136 | /// 137 | /// When multiple destinations are specified, they are all opened 138 | /// into one single tmux window and many panes in there. 139 | /// Additionally, the "synchronize-input" option is turned on, so 140 | /// that anything entered will be send to every host. 141 | #[clap(display_order = 20)] 142 | Ms { 143 | /// List of target destinations for `ssh(1)`, the same details 144 | /// for the arguments as for [Cli::sshhosts] applies. 145 | #[clap(required = true)] 146 | hosts: Vec, 147 | }, 148 | 149 | /// Kill a session 150 | #[clap(display_order = 25)] 151 | K { 152 | /// Session name as shown by ls to kill, same as [Session](Cli::kill) 153 | #[clap(required = true)] 154 | sesname: String, 155 | }, 156 | 157 | /// Break a multi-session pane into single windows 158 | #[clap(display_order = 30)] 159 | B { 160 | /// Sessiion name for which to break panes into windows 161 | #[clap(required = true)] 162 | sesname: String, 163 | }, 164 | 165 | /// Join multiple windows into one single one with many panes 166 | #[clap(display_order = 35)] 167 | J { 168 | /// Sessiion name for which to join windows into panes 169 | #[clap(required = true)] 170 | sesname: String, 171 | }, 172 | } 173 | 174 | /// Some additional functions for Cli, to make our life easier 175 | impl Cli { 176 | /// Return a session name 177 | /// 178 | /// This checks 179 | /// - [struct@TMSORT], to maybe sort the hostnames, 180 | /// - the [Cli::second] option, to maybe add a random number in 181 | /// the range of [u16] using [rand::thread_rng]. 182 | /// 183 | /// It also "cleans" the session name, that is, it replaces 184 | /// spaces, :, " and . with _ (underscores). 185 | #[throws(anyhow::Error)] 186 | #[tracing::instrument(level = "trace", ret, err, skip(self), fields(name, second = self.second))] 187 | pub fn session_name_from_hosts(&self) -> String { 188 | let mut hosts = self.get_hosts()?; 189 | debug!( 190 | "Need to build session name from: {:?}, TMSESSHOST: {}", 191 | hosts, *TMSESSHOST 192 | ); 193 | 194 | if *TMSORT { 195 | hosts.sort(); 196 | } 197 | hosts.insert(0, self.get_insert()?); 198 | 199 | if self.second { 200 | let mut rng = rand::thread_rng(); 201 | let insert: u16 = rng.gen(); 202 | debug!( 203 | "Second session wanted, inserting {} into session name", 204 | insert 205 | ); 206 | hosts.insert(1, insert.to_string()); 207 | } 208 | let name = hosts.join("_"); 209 | tracing::Span::current().record("name", &name); 210 | name 211 | } 212 | 213 | /// Find (and set) a session name. Appears we have many 214 | /// possibilities to get at one, depending how we are called. 215 | #[throws(anyhow::Error)] 216 | #[tracing::instrument(level = "trace", skip(self), ret, err, err)] 217 | pub fn find_session_name(&self, session: &mut Session) -> String { 218 | let possiblename: String = { 219 | if self.kill.is_some() { 220 | self.kill.clone().unwrap() 221 | } else if self.session.is_some() { 222 | self.session.clone().unwrap() 223 | } else if self.config.is_some() { 224 | self.config.clone().unwrap() 225 | } else if self.sshhosts.is_some() || self.multihosts.is_some() { 226 | self.session_name_from_hosts()? 227 | } else if self.breakw.is_some() { 228 | self.breakw.as_ref().unwrap().clone() 229 | } else if self.joinw.is_some() { 230 | self.joinw.as_ref().unwrap().clone() 231 | } else if self.command.is_some() { 232 | match &self.command.as_ref().unwrap() { 233 | Commands::S { hosts: _ } | Commands::Ms { hosts: _ } => { 234 | self.session_name_from_hosts()? 235 | } 236 | Commands::K { sesname } | Commands::B { sesname } | Commands::J { sesname } => { 237 | sesname.to_string() 238 | } 239 | &_ => "Unknown".to_string(), 240 | } 241 | } else { 242 | "Unhandled command so unknown session name".to_string() 243 | } 244 | }; 245 | session.set_name(possiblename); 246 | session.name.to_string() 247 | } 248 | 249 | /// Returns a string depending on subcommand called, to adjust 250 | /// session name with. 251 | #[tracing::instrument(level = "trace", skip(self), ret)] 252 | #[throws(anyhow::Error)] 253 | pub fn get_insert(&self) -> String { 254 | match &self.sshhosts { 255 | Some(_) => "s".to_string(), 256 | None => match &self.multihosts { 257 | Some(_) => "ms".to_string(), 258 | None => match &self.command.as_ref().unwrap() { 259 | Commands::S { hosts: _ } => 's'.to_string(), 260 | Commands::Ms { hosts: _ } => "ms".to_string(), 261 | &_ => "".to_string(), 262 | }, 263 | }, 264 | } 265 | } 266 | 267 | /// Return a hostlist. 268 | /// 269 | /// The list can either be from the s or ms command. 270 | #[throws(anyhow::Error)] 271 | #[tracing::instrument(level = "trace", skip(self), ret, err)] 272 | pub fn get_hosts(&self) -> Vec { 273 | match &self.sshhosts { 274 | Some(v) => v.to_vec(), 275 | None => match &self.multihosts { 276 | Some(v) => v.to_vec(), 277 | None => match &self.command.as_ref().unwrap() { 278 | Commands::S { hosts } | Commands::Ms { hosts } => hosts.clone(), 279 | &_ => throw!(anyhow!("No hosts supplied, can not get any")), 280 | }, 281 | }, 282 | } 283 | } 284 | } 285 | 286 | #[cfg(test)] 287 | mod tests { 288 | use super::{Cli, Commands}; 289 | use crate::session::Session; 290 | use clap::Parser; 291 | use regex::Regex; 292 | 293 | #[test] 294 | fn test_cmdline_getopts_simpleopt() { 295 | let mut session = Session { 296 | ..Default::default() 297 | }; 298 | 299 | // Just a session 300 | let mut cli = Cli::parse_from("tm foo".split_whitespace()); 301 | assert_eq!( 302 | cli.find_session_name(&mut session).unwrap(), 303 | "foo".to_string() 304 | ); 305 | 306 | // -l is ls 307 | cli = Cli::parse_from("tm -l".split_whitespace()); 308 | assert!(cli.ls); 309 | 310 | // -k to kill a session 311 | cli = Cli::parse_from("tm -k killsession".split_whitespace()); 312 | assert_eq!(cli.kill, Some("killsession".to_string())); 313 | assert_eq!( 314 | cli.find_session_name(&mut session).unwrap(), 315 | "killsession".to_string() 316 | ); 317 | 318 | // -b to break a session into many windows 319 | cli = Cli::parse_from("tm -b breaksession".split_whitespace()); 320 | assert_eq!(cli.breakw, Some("breaksession".to_string())); 321 | assert_eq!( 322 | cli.find_session_name(&mut session).unwrap(), 323 | "breaksession".to_string() 324 | ); 325 | 326 | // -j to join many windows into one pane 327 | cli = Cli::parse_from("tm -j joinsession".split_whitespace()); 328 | assert_eq!(cli.joinw, Some("joinsession".to_string())); 329 | assert_eq!( 330 | cli.find_session_name(&mut session).unwrap(), 331 | "joinsession".to_string() 332 | ); 333 | 334 | // -k to kill a session - second value on commandline should not 335 | // adjust session name. 336 | cli = Cli::parse_from("tm -k session ses2".split_whitespace()); 337 | assert_eq!(cli.session, Some("ses2".to_string())); 338 | assert_eq!( 339 | cli.find_session_name(&mut session).unwrap(), 340 | "session".to_string() 341 | ); 342 | 343 | // -v/-q goes via clap_verbosity, just check that we did not suddenly redefine it 344 | assert_eq!(cli.verbose.log_level_filter(), log::LevelFilter::Error); 345 | assert!(!cli.verbose.is_silent()); 346 | cli = Cli::parse_from("tm -v".split_whitespace()); 347 | assert_eq!(cli.verbose.log_level_filter(), log::LevelFilter::Warn); 348 | assert!(!cli.verbose.is_silent()); 349 | cli = Cli::parse_from("tm -vvvv".split_whitespace()); 350 | assert_eq!(cli.verbose.log_level_filter(), log::LevelFilter::Trace); 351 | cli = Cli::parse_from("tm -q".split_whitespace()); 352 | assert_eq!(cli.verbose.log_level_filter(), log::LevelFilter::Off); 353 | assert!(cli.verbose.is_silent()); 354 | 355 | // -n wants a second session to same hosts as existing one 356 | let mut cli = Cli::parse_from("tm -n".split_whitespace()); 357 | assert!(cli.second); 358 | 359 | // -g attaches existing session, but different window config 360 | cli = Cli::parse_from("tm -g".split_whitespace()); 361 | assert!(cli.group); 362 | } 363 | #[test] 364 | fn test_cmdline_getopts_s() { 365 | let mut session = Session { 366 | ..Default::default() 367 | }; 368 | // -s is ssh to one or more hosts 369 | let mut cli = Cli::parse_from("tm -s testhost".split_whitespace()); 370 | assert_eq!(cli.sshhosts, Some(vec!["testhost".to_string()])); 371 | assert_eq!( 372 | cli.find_session_name(&mut session).unwrap(), 373 | "s_testhost".to_string() 374 | ); 375 | cli = Cli::parse_from("tm -s testhost morehost andonemore".split_whitespace()); 376 | assert_eq!( 377 | cli.sshhosts, 378 | Some(vec![ 379 | "testhost".to_string(), 380 | "morehost".to_string(), 381 | "andonemore".to_string() 382 | ]) 383 | ); 384 | assert_eq!( 385 | cli.find_session_name(&mut session).unwrap(), 386 | "s_andonemore_morehost_testhost".to_string() 387 | ); 388 | 389 | // Combine with -n 390 | cli = Cli::parse_from("tm -n -s testhost".split_whitespace()); 391 | let sesname = cli.find_session_name(&mut session).unwrap(); 392 | // -n puts a random number into the name, so check with regex 393 | let re = Regex::new(r"^s_\d+_testhost$").unwrap(); 394 | assert!(re.is_match(&sesname)); 395 | assert_ne!( 396 | cli.find_session_name(&mut session).unwrap(), 397 | "s_testhost".to_string() 398 | ); 399 | } 400 | 401 | #[test] 402 | fn test_cmdline_getopts_ms() { 403 | let mut session = Session { 404 | ..Default::default() 405 | }; 406 | 407 | // -m is ssh to one or more hosts, synchronized input 408 | let mut cli = Cli::parse_from("tm -m testhost".split_whitespace()); 409 | assert_eq!(cli.multihosts, Some(vec!["testhost".to_string()])); 410 | cli = Cli::parse_from("tm -m testhost morehost andonemore".split_whitespace()); 411 | assert_eq!( 412 | cli.multihosts, 413 | Some(vec![ 414 | "testhost".to_string(), 415 | "morehost".to_string(), 416 | "andonemore".to_string() 417 | ]) 418 | ); 419 | assert_eq!( 420 | cli.find_session_name(&mut session).unwrap(), 421 | "ms_andonemore_morehost_testhost".to_string() 422 | ); 423 | 424 | // Combine with -n 425 | cli = Cli::parse_from("tm -n ms testhost morehost".split_whitespace()); 426 | let sesname = cli.find_session_name(&mut session).unwrap(); 427 | // -n puts a random number into the name, so check with regex 428 | let re = Regex::new(r"^ms_\d+_morehost_testhost$").unwrap(); 429 | assert!(re.is_match(&sesname)); 430 | assert_ne!( 431 | cli.find_session_name(&mut session).unwrap(), 432 | "ms_morehost_testhosts".to_string() 433 | ); 434 | } 435 | 436 | #[test] 437 | #[allow(clippy::bool_assert_comparison)] 438 | fn test_cmdline_ls() { 439 | let mut cli = Cli::parse_from("tm ls".split_whitespace()); 440 | assert!(!cli.ls); 441 | assert_eq!(cli.command, Some(Commands::Ls {})); 442 | 443 | // -v/-q goes via clap_verbosity, just check that we did not suddenly redefine it 444 | assert_eq!(cli.verbose.log_level_filter(), log::LevelFilter::Error); 445 | assert!(!cli.verbose.is_silent()); 446 | cli = Cli::parse_from("tm -v".split_whitespace()); 447 | assert_eq!(cli.verbose.log_level_filter(), log::LevelFilter::Warn); 448 | assert!(!cli.verbose.is_silent()); 449 | } 450 | 451 | #[test] 452 | #[allow(clippy::bool_assert_comparison)] 453 | fn test_cmdline_s() { 454 | let mut session = Session { 455 | ..Default::default() 456 | }; 457 | // s is ssh to one or more hosts 458 | let mut cli = Cli::parse_from("tm s testhost".split_whitespace()); 459 | let mut cc = cli.command.as_ref().unwrap(); 460 | let mut val = vec!["testhost".to_string()]; 461 | assert_eq!(cc, &Commands::S { hosts: val }); 462 | assert_eq!( 463 | cli.find_session_name(&mut session).unwrap(), 464 | "s_testhost".to_string() 465 | ); 466 | cli = Cli::parse_from("tm s testhost morehost andonemore".split_whitespace()); 467 | cc = cli.command.as_ref().unwrap(); 468 | val = vec![ 469 | "testhost".to_string(), 470 | "morehost".to_string(), 471 | "andonemore".to_string(), 472 | ]; 473 | assert_eq!(cc, &Commands::S { hosts: val }); 474 | assert_eq!( 475 | cli.find_session_name(&mut session).unwrap(), 476 | "s_andonemore_morehost_testhost".to_string() 477 | ); 478 | } 479 | 480 | #[test] 481 | fn test_cmdline_k() { 482 | let mut session = Session { 483 | ..Default::default() 484 | }; 485 | // k is kill that session 486 | let cli = Cli::parse_from("tm k session".split_whitespace()); 487 | assert_eq!( 488 | cli.find_session_name(&mut session).unwrap(), 489 | "session".to_string() 490 | ); 491 | let cc = cli.command.as_ref().unwrap(); 492 | assert_eq!( 493 | cc, 494 | &Commands::K { 495 | sesname: "session".to_string() 496 | } 497 | ); 498 | assert_eq!( 499 | cli.find_session_name(&mut session).unwrap(), 500 | "session".to_string() 501 | ); 502 | } 503 | 504 | #[test] 505 | fn test_cmdline_b() { 506 | let mut session = Session { 507 | ..Default::default() 508 | }; 509 | // k is kill that session 510 | let cli = Cli::parse_from("tm b session".split_whitespace()); 511 | assert_eq!( 512 | cli.find_session_name(&mut session).unwrap(), 513 | "session".to_string() 514 | ); 515 | let cc = cli.command.as_ref().unwrap(); 516 | assert_eq!( 517 | cc, 518 | &Commands::B { 519 | sesname: "session".to_string() 520 | } 521 | ); 522 | assert_eq!( 523 | cli.find_session_name(&mut session).unwrap(), 524 | "session".to_string() 525 | ); 526 | } 527 | 528 | #[test] 529 | fn test_cmdline_j() { 530 | let mut session = Session { 531 | ..Default::default() 532 | }; 533 | // k is kill that session 534 | let cli = Cli::parse_from("tm j session".split_whitespace()); 535 | assert_eq!( 536 | cli.find_session_name(&mut session).unwrap(), 537 | "session".to_string() 538 | ); 539 | let cc = cli.command.as_ref().unwrap(); 540 | assert_eq!( 541 | cc, 542 | &Commands::J { 543 | sesname: "session".to_string() 544 | } 545 | ); 546 | assert_eq!( 547 | cli.find_session_name(&mut session).unwrap(), 548 | "session".to_string() 549 | ); 550 | } 551 | } 552 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | //! tm - a tmux helper 2 | //! 3 | //! SPDX-License-Identifier: BSD-2-Clause 4 | //! 5 | //! Copyright (C) 2011-2024 Joerg Jaspert 6 | //! 7 | //! There are two ways to call tm. Traditional and "getopts" style. 8 | //! 9 | //! Traditional call as: 10 | //! ```shell 11 | //! tm CMD [host]...[host] 12 | //! ``` 13 | //! 14 | //! Getopts call as: 15 | //! ```shell 16 | //! tm [-s host] [-m hostlist] [-k name] [-l] [-n] [-h] [-c config] [-e] 17 | //! ``` 18 | //! 19 | //! Note that traditional and getopts can be mixed, sometimes. 20 | 21 | #![warn(missing_docs)] 22 | 23 | mod config; 24 | #[macro_use] 25 | mod fromenvstatic; 26 | mod session; 27 | 28 | use crate::config::{Cli, Commands}; 29 | use crate::session::Session; 30 | use anyhow::{anyhow, Result}; 31 | use clap::Parser; 32 | use directories::UserDirs; 33 | use shlex::Shlex; 34 | use std::{ 35 | env, 36 | ffi::OsString, 37 | io::{self, BufWriter, Write}, 38 | path::Path, 39 | process::Command, 40 | sync::LazyLock, 41 | }; 42 | use tmux_interface::{ListSessions, NewSession, ShowOptions, Tmux}; 43 | use tracing::{debug, error, event, info, trace, warn, Level}; 44 | use tracing_subscriber::{fmt::time::ChronoLocal, FmtSubscriber}; 45 | 46 | //////////////////////////////////////////////////////////////////////// 47 | 48 | // A bunch of "static" variables, though computed at program start, as they 49 | // depend on the users environment. 50 | /// We want a useful tmpdir, so set one if it isn't already. That's 51 | /// the place where tmux puts its socket, so you want to ensure it 52 | /// doesn't change under your feet - like for those with a 53 | /// daily-changing tmpdir in their home... 54 | static TMPDIR: LazyLock = LazyLock::new(|| fromenvstatic!(asString "TMPDIR", "/tmp")); 55 | 56 | /// Do you want me to sort the arguments when opening an 57 | /// ssh/multi-ssh session? The only use of the sorted list is for 58 | /// the session name, to allow you to get the same session again no 59 | /// matter how you order the hosts on commandline. 60 | static TMSORT: LazyLock = LazyLock::new(|| fromenvstatic!(asBool "TMSORT", true)); 61 | 62 | /// Want some extra options given to tmux? Define TMOPTS in your 63 | /// environment. Note, this is only used in the final tmux call 64 | /// where we actually attach to the session! 65 | static TMOPTS: LazyLock = LazyLock::new(|| fromenvstatic!(asString "TMOPTS", "-2")); 66 | 67 | /// The following directory can hold session config for us, so you 68 | /// can use it as a shortcut. 69 | static TMDIR: LazyLock = LazyLock::new(|| { 70 | if let Some(user_dirs) = UserDirs::new() { 71 | Path::join(user_dirs.home_dir(), Path::new(".tmux.d")).into_os_string() 72 | } else { 73 | error!("No idea where your homedir is, using /tmp"); 74 | Path::new("/tmp").as_os_str().to_owned() 75 | } 76 | }); 77 | 78 | /// Prepend the hostname to autogenerated session names? 79 | /// 80 | /// Example: Call `tm ms host1 host2`. 81 | /// * TMSESSHOST=true -> session name is `HOSTNAME_host1_host2` 82 | /// * TMSESSHOST=false -> session name is `host1_host2` 83 | static TMSESSHOST: LazyLock = LazyLock::new(|| fromenvstatic!(asBool "TMSESSHOST", false)); 84 | 85 | /// Allow to globally define a custom ssh command line. 86 | static TMSSHCMD: LazyLock = LazyLock::new(|| fromenvstatic!(asString "TMSSHCMD", "ssh")); 87 | 88 | /// From where does tmux start numbering its windows. Old shell 89 | /// script used a stupid way of config parsing or setting it via 90 | /// environment var. We now just use show_options, and in the 91 | /// unlikely case this fails, try parsing the old environment var 92 | /// TMWIN, and if that doesn't exist (quite likely now), just use 93 | /// 1. 94 | // FIXME: This depends on a running tmux daemon. None running -> data fetching 95 | // fails. Could fix to detect that and start one first, later killing that. 96 | static TMWIN: LazyLock = LazyLock::new(|| { 97 | match Tmux::with_command( 98 | ShowOptions::new() 99 | .global() 100 | .quiet() 101 | .value() 102 | .option("base-index"), 103 | ) 104 | .output() 105 | { 106 | Ok(v) => v.to_string().trim().parse().unwrap_or(1), 107 | Err(_) => fromenvstatic!(asU32 "TMWIN", 1), 108 | } 109 | }); 110 | 111 | /// Run ls 112 | /// 113 | /// Simply runs `tmux list-sessions` 114 | #[tracing::instrument(level = "trace", skip(handle), ret, err)] 115 | fn ls(handle: &mut BufWriter) -> Result<()> { 116 | Ok(writeln!( 117 | handle, 118 | "{}", 119 | Tmux::with_command(ListSessions::new()).output()? 120 | )?) 121 | } 122 | 123 | /// Tiny helper to replace the magic ++TMREPLACETM++ 124 | #[doc(hidden)] 125 | #[tracing::instrument(level = "trace", ret, err)] 126 | fn tmreplace(input: &str, replace: &Option) -> Result { 127 | match replace { 128 | Some(v) => Ok(input.replace("++TMREPLACETM++", v)), 129 | None => Ok(input.to_string()), 130 | } 131 | } 132 | 133 | /// Parse a line of a simple_config file. 134 | /// 135 | /// If a LIST command is found, execute that, and parse its output - 136 | /// if that contains LIST again, recurse. 137 | /// 138 | /// Return all found hostnames. 139 | #[tracing::instrument(level = "trace", ret, err)] 140 | fn parse_line(line: &str, replace: &Option, current_dir: &Path) -> Result> { 141 | // We are interested in the first word to decide what we see 142 | let first = line.split_whitespace().next(); 143 | match first { 144 | // LIST, we are asked to execute something and read its stdout 145 | Some("LIST") => { 146 | debug!("LIST command found"); 147 | // The rest of the line (command and arguments) 148 | let mut cmdparser = { 149 | let rest = line.trim_start_matches("LIST"); 150 | debug!("Parsing command line: {:?}", rest); 151 | // Do a shell-conform split of the command and arguments, 152 | // ie take care of " and things. 153 | Shlex::new(rest) 154 | }; 155 | 156 | // The command ought to be the second word on the line, 157 | // but obviously people may mistype and have a single LIST 158 | // in a line. 159 | // Also, ~ and $HOME/${HOME} expansion are supported. 160 | let cmd: String = shellexpand::full( 161 | &cmdparser 162 | .next() 163 | .ok_or_else(|| anyhow!("Empty LIST found - no command given"))? 164 | .replace("$HOME", "~/") 165 | .replace("${HOME}", "~/"), 166 | )? 167 | .to_string(); 168 | // Next we want the arguments. 169 | // Also, ~ and $HOME/${HOME} expansion are supported. 170 | let args: Vec = cmdparser 171 | .map(|l| l.replace("$HOME", "~/").replace("${HOME}", "~/")) 172 | .map(|l| shellexpand::full(&l).expect("Could not expand").to_string()) 173 | .collect(); 174 | debug!("cmd is {}", cmd); 175 | debug!("args are {:?}", args); 176 | 177 | // Our process spawner, pleased to hand us results as a nice 178 | // string separated by newline (well, if output contains newlines) 179 | let cmdout = String::from_utf8( 180 | Command::new(&cmd) 181 | .current_dir(current_dir) 182 | .args(&args) 183 | .output()? 184 | .stdout, 185 | )?; 186 | debug!("Command returned: {:?}", cmdout); 187 | if !cmdout.is_empty() { 188 | let mut plhosts: Vec = Vec::new(); 189 | for plline in cmdout.lines() { 190 | trace!("Read line: '{}'", plline); 191 | // Replace magic token, if exists and asked for 192 | let plline = tmreplace(plline, replace)?; 193 | debug!("Processing line: '{}'", plline); 194 | // And process the line, may contain another command 195 | // OR just a hostname 196 | plhosts.append(&mut parse_line(&plline, replace, current_dir)?); 197 | } 198 | Ok(plhosts) 199 | } else { 200 | Err(anyhow!( 201 | "LIST command {cmd} {args:?} produced no output, can not build session" 202 | )) 203 | } 204 | } 205 | Some(&_) => { 206 | trace!("SSH destination, returning"); 207 | Ok(vec![line.to_string()]) 208 | } 209 | None => { 210 | trace!("Empty line, ignoring"); 211 | Ok(vec![]) 212 | } 213 | } 214 | } 215 | 216 | static VERSION: &str = env!("CARGO_PKG_VERSION"); 217 | static APPLICATION: &str = env!("CARGO_PKG_NAME"); 218 | 219 | // Can't sensibly test main() 220 | #[cfg(not(tarpaulin_include))] 221 | /// main, start it all off 222 | fn main() -> Result<()> { 223 | let cli = Cli::parse(); 224 | let filter = cli.verbose.log_level_filter(); 225 | let subscriberbuild = FmtSubscriber::builder() 226 | .with_max_level({ 227 | match filter { 228 | log::LevelFilter::Off => tracing_subscriber::filter::LevelFilter::OFF, 229 | log::LevelFilter::Error => tracing_subscriber::filter::LevelFilter::ERROR, 230 | log::LevelFilter::Warn => tracing_subscriber::filter::LevelFilter::WARN, 231 | log::LevelFilter::Info => tracing_subscriber::filter::LevelFilter::INFO, 232 | log::LevelFilter::Debug => tracing_subscriber::filter::LevelFilter::DEBUG, 233 | log::LevelFilter::Trace => tracing_subscriber::filter::LevelFilter::TRACE, 234 | } 235 | }) 236 | .with_ansi(true) 237 | .with_target(true) 238 | .with_file(true) 239 | .with_line_number(true) 240 | .with_timer(ChronoLocal::rfc_3339()) 241 | .pretty(); 242 | 243 | let subscriber = match filter { 244 | log::LevelFilter::Trace => subscriberbuild 245 | .with_span_events( 246 | tracing_subscriber::fmt::format::FmtSpan::ACTIVE 247 | | tracing_subscriber::fmt::format::FmtSpan::CLOSE, 248 | ) 249 | .finish(), 250 | _ => subscriberbuild 251 | .with_span_events(tracing_subscriber::fmt::format::FmtSpan::ACTIVE) 252 | .finish(), 253 | }; 254 | 255 | tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); 256 | 257 | info!("Starting {APPLICATION}, version {VERSION}"); 258 | event!( 259 | Level::DEBUG, 260 | msg = "Program started", 261 | cli = ?cli, 262 | TMPDIR = *TMPDIR, 263 | TMSORT = *TMSORT, 264 | TMOPTS =*TMOPTS, 265 | TMDIR = ?*TMDIR, 266 | TMSESSHOST = *TMSESSHOST, 267 | TMSSHCMD = *TMSSHCMD, 268 | TMWIN = *TMWIN, 269 | ); 270 | 271 | let mut session = Session { 272 | ..Default::default() 273 | }; 274 | 275 | // First get a session name 276 | let sesname = cli.find_session_name(&mut session)?; 277 | // Should, if we attach, this be grouped? 278 | if cli.group { 279 | session.grouped = true; 280 | } 281 | 282 | // Store the replacement token 283 | session.replace = cli.replace.clone(); 284 | 285 | // First we check what the tm shell called "getopt-style" 286 | if cli.ls { 287 | let stdout = io::stdout(); 288 | let mut handle = BufWriter::new(stdout.lock()); 289 | ls(&mut handle)?; 290 | handle.flush()?; 291 | } else if cli.kill.is_some() { 292 | session.kill()?; 293 | } else if cli.session.is_some() || cli.config.is_some() { 294 | let sespath = if cli.session.is_some() { 295 | Path::join(Path::new(&*TMDIR), Path::new(&cli.session.clone().unwrap())) 296 | } else { 297 | Path::join(Path::new(&*TMDIR), Path::new(&cli.config.clone().unwrap())) 298 | }; 299 | 300 | if Path::new(&sespath).exists() { 301 | trace!( 302 | "Should attach session {} or configure session from {:?}", 303 | sesname, 304 | sespath 305 | ); 306 | session.sesfile = sespath; 307 | session.read_session_file_and_attach()?; 308 | } else { 309 | trace!("Should attach or create session {}", sesname); 310 | match session.attach() { 311 | Ok(true) => debug!("Successfully attached"), 312 | Ok(false) => { 313 | debug!("Session not found, creating new one"); 314 | Tmux::with_command(NewSession::new().session_name(&session.name)).output()?; 315 | } 316 | Err(val) => error!("Error: {val}"), 317 | } 318 | }; 319 | } else if cli.sshhosts.is_some() || cli.multihosts.is_some() { 320 | trace!("Session connecting somewhere"); 321 | if session.exists() { 322 | session.attach()?; 323 | } else { 324 | if cli.sshhosts.is_some() { 325 | // sshhosts -> Multiple windows, not synced input 326 | session.synced = false; 327 | } else { 328 | // not sshhost, aka multihosts -> One window, many panes, synced input 329 | session.synced = true; 330 | } 331 | session.targets = cli.get_hosts()?; 332 | if session.setup_simple_session()? { 333 | session.attach()?; 334 | } 335 | } 336 | } else if cli.breakw.is_some() { 337 | trace!("Breaking up session"); 338 | if session.exists() { 339 | session.break_panes()?; 340 | } else { 341 | info!("No session {} exists, can not break", session.name); 342 | println!("No session {sesname}"); 343 | } 344 | } else if cli.joinw.is_some() { 345 | trace!("Joining session"); 346 | if session.exists() { 347 | session.join_windows()?; 348 | } else { 349 | info!("No session {} exists, can not join", session.name); 350 | println!("No session {sesname}"); 351 | } 352 | }; 353 | 354 | // Now we check what the tm shell called traditional Yeah, this 355 | // can run things twice, e.g. if we are called as tm -l ls it will 356 | // show ls twice. But we want to be able to combine the two 357 | // styles, e.g. call as tm -n ms SOMETHING. 358 | // I bet there is a way to get rid of this double thing and merge 359 | // the above and the following, so: 360 | // FIXME: Merge the above if and the match here, somehow, dedupe 361 | // the code. 362 | if cli.command.is_some() { 363 | match &cli.command.as_ref().unwrap() { 364 | Commands::Ls {} => { 365 | let stdout = io::stdout(); 366 | let mut handle = BufWriter::new(stdout.lock()); 367 | ls(&mut handle)?; 368 | handle.flush()?; 369 | } 370 | Commands::S { hosts: _ } => { 371 | trace!("ssh subcommand called"); 372 | if session.exists() { 373 | session.attach()?; 374 | } else { 375 | session.synced = false; 376 | session.targets = cli.get_hosts()?; 377 | session.setup_simple_session()?; 378 | session.attach()?; 379 | } 380 | } 381 | Commands::Ms { hosts: _ } => { 382 | trace!("ms subcommand called"); 383 | if session.exists() { 384 | session.attach()?; 385 | } else { 386 | session.synced = true; 387 | session.targets = cli.get_hosts()?; 388 | session.setup_simple_session()?; 389 | session.attach()?; 390 | } 391 | } 392 | Commands::K { sesname } => { 393 | trace!("k subcommand called, killing {sesname}"); 394 | session.kill()?; 395 | } 396 | Commands::B { sesname } => { 397 | trace!("b subcommand called, breaking panes into windows for {sesname}"); 398 | if session.exists() { 399 | session.break_panes()?; 400 | } else { 401 | info!("No session {sesname} exists, can not break"); 402 | println!("No session {sesname}"); 403 | } 404 | } 405 | Commands::J { sesname } => { 406 | trace!("j subcommand called, joining windows into panes for {sesname}"); 407 | session.join_windows()?; 408 | } 409 | } 410 | } 411 | info!("All done, end"); 412 | Ok(()) 413 | } 414 | 415 | #[cfg(test)] 416 | mod tests { 417 | use crate::{ 418 | ls, parse_line, tmreplace, Session, TMOPTS, TMPDIR, TMSESSHOST, TMSORT, TMSSHCMD, TMWIN, 419 | }; 420 | use std::{ 421 | env, 422 | io::{BufWriter, Write}, 423 | path::Path, 424 | }; 425 | use tmux_interface::{NewSession, Tmux}; 426 | 427 | #[test] 428 | fn test_fromenvstatic() { 429 | // Testing with env vars isn't nice - users may set them randomly. 430 | // So pre-define a known set, so we at least can test the code 431 | // around fromenvstatic 432 | env::set_var("TMPDIR", "/tmp"); 433 | env::set_var("TMOPTS", "-2"); 434 | env::set_var("TMSORT", "true"); 435 | env::set_var("TMSESSHOST", "false"); 436 | env::set_var("TMSSHCMD", "ssh"); 437 | env::set_var("TMWIN", "1"); 438 | assert_eq!(*TMPDIR, "/tmp"); 439 | assert_eq!(*TMOPTS, "-2"); 440 | assert!(*TMSORT); 441 | assert!(!*TMSESSHOST); 442 | assert_eq!(*TMSSHCMD, "ssh"); 443 | assert_eq!(*TMWIN, 1); 444 | } 445 | 446 | #[test] 447 | fn test_attach() { 448 | let mut session = Session { 449 | ..Default::default() 450 | }; 451 | // We want a new session 452 | session.set_name("fakeattach"); 453 | // Shouldn't exist 454 | assert!(!session.exists()); 455 | // Lets create it 456 | Tmux::with_command( 457 | NewSession::new() 458 | .session_name(&session.name) 459 | .detached() 460 | .shell_command("/bin/bash"), 461 | ) 462 | .output() 463 | .unwrap(); 464 | // Is it there? 465 | assert!(session.exists()); 466 | // Now try the attach. if cfg!(test) code should just return true 467 | assert!(session.attach().unwrap()); 468 | // Grouped sessions are nice 469 | session.grouped = true; 470 | // Try attach again 471 | assert!(session.attach().unwrap()); 472 | // gsesname will contain session name plus random string 473 | // FIXME: Better check with a regex to be written 474 | assert_ne!(session.name, session.gsesname); 475 | println!("Grouped session name: {}", session.gsesname); 476 | // Get rid of session - this will remove the original one 477 | session.kill().unwrap(); 478 | // FIXME: Check that it only removed the original one 479 | // Now get rid of the grouped session too. 480 | assert!(session.realkill(&session.gsesname).unwrap()); 481 | 482 | assert!(!session.exists()); 483 | // And now we test something that shouldn't work for attach 484 | session.set_name("notfakeattach"); 485 | // Not grouped 486 | session.grouped = false; 487 | // Shouldn't exist 488 | assert!(!session.exists()); 489 | // Lets create it 490 | Tmux::with_command( 491 | NewSession::new() 492 | .session_name(&session.name) 493 | .detached() 494 | .shell_command("/bin/bash"), 495 | ) 496 | .output() 497 | .unwrap(); 498 | // Is it there? 499 | assert!(session.exists()); 500 | // Now try the attach. if cfg!(test) code should just return false here 501 | assert!(!session.attach().unwrap()); 502 | // Get rid of session 503 | session.kill().unwrap(); 504 | } 505 | 506 | #[test] 507 | fn test_kill_ls_and_exists() { 508 | let mut session = Session { 509 | ..Default::default() 510 | }; 511 | session.set_name("tmtestsession"); 512 | assert!(!session.exists()); 513 | Tmux::with_command( 514 | NewSession::new() 515 | .session_name(&session.name) 516 | .detached() 517 | .shell_command("/bin/bash"), 518 | ) 519 | .output() 520 | .unwrap(); 521 | assert!(session.exists()); 522 | 523 | // We want to check the output of ls contains our session from 524 | // above, so have it "write" it to a variable, then check if 525 | // the variable contains the session name. 526 | let lstext = Vec::new(); 527 | let mut handle = BufWriter::new(lstext); 528 | ls(&mut handle).unwrap(); 529 | handle.flush().unwrap(); 530 | 531 | assert!(session.kill().unwrap()); 532 | assert!(session.kill().is_err()); 533 | assert!(!session.exists()); 534 | 535 | // And now check what got "written" into the variable 536 | let (recovered_writer, _buffered_data) = handle.into_parts(); 537 | let output = String::from_utf8(recovered_writer).unwrap(); 538 | assert!(output.contains(&session.name)); 539 | } 540 | 541 | #[test] 542 | fn test_tmreplace() { 543 | assert_eq!(tmreplace("test", &None).unwrap(), "test".to_string()); 544 | assert_eq!( 545 | tmreplace("test", &Some("foo".to_string())).unwrap(), 546 | "test".to_string() 547 | ); 548 | assert_eq!( 549 | tmreplace("test++TMREPLACETM++", &Some("foo".to_string())).unwrap(), 550 | "testfoo".to_string() 551 | ); 552 | } 553 | 554 | #[test] 555 | fn test_parse_line() { 556 | let mut line = "justonehost"; 557 | let mut replace = None; 558 | let mut current_dir = Path::new("/"); 559 | let mut res = parse_line(line, &replace, current_dir).unwrap(); 560 | assert_eq!(res, vec!["justonehost".to_string()]); 561 | line = "LIST /bin/echo \"onehost\ntwohost\nthreehost\""; 562 | replace = None; 563 | current_dir = Path::new("/"); 564 | res = parse_line(line, &replace, current_dir).unwrap(); 565 | assert_eq!( 566 | res, 567 | vec![ 568 | "onehost".to_string(), 569 | "twohost".to_string(), 570 | "threehost".to_string() 571 | ] 572 | ); 573 | line = "LIST /bin/echo \"onehost\ntwohost\nthreehost\nfoobar\nLIST /bin/echo \"LIST /bin/echo \"bar\nbaz\n\"\n\"\""; 574 | replace = None; 575 | current_dir = Path::new("/"); 576 | res = parse_line(line, &replace, current_dir).unwrap(); 577 | assert_eq!( 578 | res, 579 | vec![ 580 | "onehost".to_string(), 581 | "twohost".to_string(), 582 | "threehost".to_string(), 583 | "foobar".to_string(), 584 | "bar".to_string(), 585 | "baz".to_string() 586 | ] 587 | ); 588 | line = " "; 589 | replace = None; 590 | current_dir = Path::new("/"); 591 | res = parse_line(line, &replace, current_dir).unwrap(); 592 | let empty: Vec = vec![]; 593 | assert_eq!(res, empty); 594 | } 595 | } 596 | -------------------------------------------------------------------------------- /old/tm: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright (C) 2011, 2012, 2013, 2014, 2016 Joerg Jaspert 4 | # 5 | # Redistribution and use in source and binary forms, with or without 6 | # modification, are permitted provided that the following conditions 7 | # are met: 8 | # . 9 | # 1. Redistributions of source code must retain the above copyright 10 | # notice, this list of conditions and the following disclaimer. 11 | # 2. Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in the 13 | # documentation and/or other materials provided with the distribution. 14 | # . 15 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 16 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 17 | # OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 18 | # IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 19 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 20 | # NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 21 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 22 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 24 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | 26 | # Always exit on errors 27 | set -e 28 | # Undefined variables, we don't like you 29 | set -u 30 | # ERR traps are inherited by shell functions, command substitutions and 31 | # commands executed in a subshell environment. 32 | set -E 33 | 34 | ######################################################################## 35 | # The following variables can be overwritten outside the script. 36 | # 37 | 38 | # We want a useful tmpdir, so set one if it isn't already. That's the 39 | # place where tmux puts its socket, so you want to ensure it doesn't 40 | # change under your feet - like for those with a daily-changing tmpdir 41 | # in their home... 42 | declare -r TMPDIR=${TMPDIR:-"/tmp"} 43 | 44 | # Do you want me to sort the arguments when opening an ssh/multi-ssh session? 45 | # The only use of the sorted list is for the session name, to allow you to 46 | # get the same session again no matter how you order the hosts on commandline. 47 | declare -r TMSORT=${TMSORT:-"true"} 48 | 49 | # Want some extra options given to tmux? Define TMOPTS in your environment. 50 | # Note, this is only used in the final tmux call where we actually 51 | # attach to the session! 52 | TMOPTS=${TMOPTS:-"-2"} 53 | 54 | # The following directory can hold session config for us, so you can use them 55 | # as a shortcut. 56 | declare -r TMDIR=${TMDIR:-"${HOME}/.tmux.d"} 57 | 58 | # Should we prepend the hostname to autogenerated session names? 59 | # Example: Call tm ms host1 host2. 60 | # TMSESSHOST=true -> session name is HOSTNAME_host1_host2 61 | # TMSESSHOST=false -> session name is host1_host2 62 | declare -r TMSESSHOST=${TMSESSHOST:-"true"} 63 | 64 | # Allow to globally define a custom ssh command line. 65 | TMSSHCMD=${TMSSHCMD:-"ssh"} 66 | 67 | # Debug output 68 | declare -r DEBUG=${DEBUG:-"false"} 69 | 70 | # Save the last argument, it may be used (traditional style) for 71 | # replacing 72 | args=$# 73 | TMREPARG=${!args} 74 | 75 | # Where does your tmux starts numbering its windows? Mine does at 1, 76 | # default for tmux is 0. We try to find it out, but if we fail, (as we 77 | # only check $HOME/.tmux.conf you can set this variable to whatever it 78 | # is for your environment. 79 | if [[ -f ${HOME}/.tmux.conf ]]; then 80 | bindex=$(grep ' base-index ' ${HOME}/.tmux.conf || echo 0 ) 81 | bindex=${bindex//* } 82 | else 83 | bindex=0 84 | fi 85 | declare TMWIN=${TMWIN:-$bindex} 86 | unset bindex 87 | 88 | ######################################################################## 89 | # Nothing below here to configure 90 | 91 | # Should we group the session to another? Set to true if -g on 92 | # commandline 93 | GROUPSESSION=false 94 | 95 | # Should we open another session, even if we already have one with 96 | # this name? (Ie. second multisession to the same set of hosts) 97 | # This is either set by the getopts option -n or by having -n 98 | # as very first parameter after the tm command 99 | if [[ $# -ge 1 ]] && [[ "${1}" = "-n" ]]; then 100 | DOUBLENAME=true 101 | # And now get rid of it. getopts won't see it, as it was first and 102 | # we remove it - but it doesn't matter, we set it already. 103 | # getopts is only used if it appears somewhere else in the 104 | # commandline 105 | shift 106 | else 107 | DOUBLENAME=false 108 | fi 109 | 110 | # Store the first commandline parameter 111 | cmdline=${1:-""} 112 | 113 | # Get the tmux version and split it in major/minor 114 | TMUXVERS=$(tmux -V 2>/dev/null || echo "tmux 1.3") 115 | declare -r TMUXVERS=${TMUXVERS##* } 116 | declare -r TMUXMAJOR=${TMUXVERS%%.*} 117 | declare -r TMUXMINOR=${TMUXVERS##*.} 118 | 119 | # Save IFS 120 | declare -r OLDIFS=${IFS} 121 | 122 | # To save session file data 123 | TMDATA="" 124 | 125 | # Freeform .cfg file or other session file? 126 | TMSESCFG="" 127 | 128 | ######################################################################## 129 | function usage() { 130 | echo "tmux helper by Joerg Jaspert " 131 | echo "There are two ways to call it. Traditional and \"getopts\" style." 132 | echo "Traditional call as: $0 CMD [host]...[host]" 133 | echo "Getopts call as: $0 [-s host] [-m hostlist] [-k name] [-b session] [-j session] [-l] [-n] [-h] [-c config] [-e]" 134 | echo "Note that traditional and getopts can be mixed, sometimes." 135 | echo "" 136 | echo "Traditional:" 137 | echo "CMD is one of" 138 | echo " ls List running sessions" 139 | echo " s Open ssh session to host" 140 | echo " ms Open multi ssh sessions to hosts, synchronizing input" 141 | echo " To open a second session to the same set of hosts put a" 142 | echo " -n in front of ms" 143 | echo " k Kill a session. Note that this needs the exact session name" 144 | echo " as shown by tm ls" 145 | echo " b Break all panes of given session into single windows in that session" 146 | echo " j Join all windows of given session into single window in that session" 147 | echo " \$anything Either plain tmux session with name of \$anything or" 148 | echo " session according to TMDIR file" 149 | echo "" 150 | echo "Getopts style:" 151 | echo "-l List running sessions" 152 | echo "-s host Open ssh session to host" 153 | echo "-m hostlist Open multi ssh sessions to hosts, synchronizing input" 154 | echo " Due to the way getopts works, hostlist must be enclosed in \"\"" 155 | echo "-n Open a second session to the same set of hosts" 156 | echo "-g Group session - attach to an existing session, but keep separate" 157 | echo " window control" 158 | echo "-k name Kill a session. Note that this needs the exact session name" 159 | echo " as shown by tm ls" 160 | echo "-b X Break all panes of given session into single windows in that session" 161 | echo "-j X Join all windows of given session into single window in that session" 162 | echo "-c config Setup session according to TMDIR file" 163 | echo "-e SESSION Use existion session named SESSION" 164 | echo "-r REPLACE Value to use for replacing in session files" 165 | echo "" 166 | echo "TMDIR file:" 167 | echo "Each file in \$TMDIR defines a tmux session. There are two types of files," 168 | echo "those without an extension and those with the extension \".cfg\" (no \"\")." 169 | echo "The filename corresponds to the commandline \$anything (or -c)." 170 | echo "" 171 | echo "Content of extensionless files is defined as:" 172 | echo " First line: Session name" 173 | echo " Second line: extra tmux commandline options" 174 | echo " Any following line: A hostname to open a shell with in the normal" 175 | echo " ssh syntax. (ie [user@]hostname)" 176 | echo "" 177 | echo "Content of .cfg files is defined as:" 178 | echo " First line: Session name" 179 | echo " Second line: extra tmux commandline options" 180 | echo " Third line: The new-session command to use. Place NONE here if you want plain" 181 | echo " defaults, though that may mean just a shell. Otherwise put the full" 182 | echo " new-session command with all options you want here." 183 | echo " Any following line: Any tmux command you can find in the tmux manpage." 184 | echo " You should ensure that commands arrive at the right tmux session / window." 185 | echo " To help you with this, there are some variables available which you" 186 | echo " can use, they are replaced with values right before commands are executed:" 187 | echo " SESSION - replaced with the session name" 188 | echo " TMWIN - see below for explanation of TMWIN Environment variable" 189 | echo "" 190 | echo "NOTE: Both types of files accept external listings of hostnames." 191 | echo " That is, the output of any shell command given will be used as a list" 192 | echo " of hostnames to connect to (or a set of tmux commands to run)." 193 | echo "" 194 | echo "NOTE: Session files can include the Token ++TMREPLACETM++ at any point. This" 195 | echo " will be replaced by the value of the -r option (if you use getopts style) or" 196 | echo " by the LAST argument on the line if you use traditional calling." 197 | echo " Note that with traditional calling, the argument will also be tried as a hostname," 198 | echo " so it may not make much sense there, unless using a session file that contains" 199 | echo " solely of LIST commands." 200 | echo "" 201 | echo "NOTE: Session files can include any existing environment variable at any point (but" 202 | echo " only one per line). Those get replaced during tm execution time with the actual" 203 | echo " value of the environment variable. Common usage is $HOME, but any existing var" 204 | echo " works fine." 205 | echo "" 206 | echo "Environment variables recognized by this script:" 207 | echo "TMPDIR - Where tmux stores its session information" 208 | echo " DEFAULT: If unset: /tmp" 209 | echo "TMSORT - Should ms sort the hostnames, so it always opens the same" 210 | echo " session, no matter in which order hostnames are presented" 211 | echo " DEFAULT: true" 212 | echo "TMOPTS - Extra options to give to the tmux call" 213 | echo " Note that this ONLY affects the final tmux call to attach" 214 | echo " to the session, not to the earlier ones creating it" 215 | echo " DEFAULT: -2" 216 | echo "TMDIR - Where are session information files stored" 217 | echo " DEFAULT: ${HOME}/.tmux.d" 218 | echo "TMWIN - Where does your tmux starts numbering its windows?" 219 | echo " This script tries to find the information in your config," 220 | echo " but as it only checks $HOME/.tmux.conf it might fail". 221 | echo " So if your window numbers start at anything different to 0," 222 | echo " like mine do at 1, then you can set TMWIN to 1" 223 | echo "TMSESSHOST - Should the hostname appear in session names?" 224 | echo " DEFAULT: true" 225 | echo "TMSSHCMD - Allow to globally define a custom ssh command line." 226 | echo " This can be just the command or any option one wishes to have" 227 | echo " everywhere." 228 | echo " DEFAULT: ssh" 229 | echo "DEBUG - Show debug output (remember to redirect it to a file)" 230 | echo "" 231 | exit 42 232 | } 233 | 234 | # Simple "cleanup" of a variable, removing space and dots as we don't 235 | # want them in our tmux session name 236 | function clean_session() { 237 | local toclean=${*:-""} 238 | 239 | # Neither space nor dot nor : or " are friends in the SESSION name 240 | toclean=${toclean// /_} 241 | toclean=${toclean//:/_} 242 | toclean=${toclean//\"/} 243 | echo ${toclean//./_} 244 | } 245 | 246 | # Merge the commandline parameters (hosts) into a usable session name 247 | # for tmux 248 | function ssh_sessname() { 249 | if [[ ${TMSORT} = true ]]; then 250 | local one=$1 251 | # get rid of first argument (s|ms), we don't want to sort this 252 | shift 253 | local sess=$(for i in $@; do echo $i; done | sort | tr '\n' ' ') 254 | sess="${one} ${sess% *}" 255 | else 256 | # no sorting wanted 257 | local sess="${*}" 258 | fi 259 | clean_session ${sess} 260 | } 261 | 262 | # Setup functions for all tmux commands 263 | function setup_command_aliases() { 264 | local command 265 | local SESNAME 266 | SESNAME="tmlscm$$" 267 | if [[ ${TMUXMAJOR:0:1} -lt 2 ]] || [[ ${TMUXMINOR:0:1} -lt 2 ]]; then 268 | # Starting tmux 2.2, this is no longer needed 269 | # Debian Bug #718777 - tmux needs a session to have lscm work 270 | tmux new-session -d -s ${SESNAME} -n "check" "sleep 3" 271 | fi 272 | for command in $(tmux list-commands|awk '{print $1}'); do 273 | eval "tm_$command() { tmux $command \"\$@\" >/dev/null; }" 274 | eval "tm_out_$command() { tmux $command \"\$@\" ; }" 275 | done 276 | if [[ ${TMUXMAJOR:0:1} -lt 2 ]] || [[ ${TMUXMINOR:0:1} -lt 2 ]]; then 277 | tmux kill-session -t ${SESNAME} || true 278 | fi 279 | } 280 | 281 | # Run a command (function) after replacing variables 282 | function do_cmd() { 283 | local cmd=$* 284 | cmd1=${cmd%% *} 285 | if [[ ${cmd1} =~ ^# ]]; then 286 | return 287 | elif [[ ${cmd1} =~ new-window ]]; then 288 | TMWIN=$(( TMWIN + 1 )) 289 | fi 290 | 291 | cmd=${cmd//SESSION/$SESSION} 292 | cmd=${cmd//TMWIN/$TMWIN} 293 | cmd=${cmd/$cmd1 /} 294 | debug $cmd1 $cmd 295 | eval tm_$cmd1 $cmd 296 | } 297 | 298 | # Use a configuration file to setup the tmux parameters/session 299 | function own_config() { 300 | if [[ ${1} =~ .cfg$ ]]; then 301 | TMSESCFG="free" 302 | fi 303 | # Set IFS to be NEWLINE only, not also space/tab, as our input files 304 | # are \n separated (one entry per line) and lines may well have spaces. 305 | local IFS=" 306 | " 307 | # Fill an array with our config 308 | if [[ -n ${TMDATA[@]:-""} ]] && [[ ${#TMDATA[@]} -gt 0 ]]; then 309 | olddata=("${TMDATA[@]}") 310 | fi 311 | 312 | TMDATA=( $(sed -e "s/++TMREPLACETM++/${TMREPARG}/g" "${TMDIR}/$1") ) 313 | # Restore IFS 314 | IFS=${OLDIFS} 315 | 316 | SESSION=${SESSION:-$(clean_session ${TMDATA[0]})} 317 | 318 | if [ "${TMDATA[1]}" != "NONE" ]; then 319 | TMOPTS=${TMDATA[1]} 320 | fi 321 | 322 | # Separate the lines we work with 323 | local IFS="" 324 | local -a workdata=(${TMDATA[@]:2}) 325 | IFS=${OLDIFS} 326 | 327 | # Lines (starting with line 3) may start with LIST, then we get 328 | # the list of hosts from elsewhere. So if one does, we exec the 329 | # command given, then append the output to TMDATA - while deleting 330 | # the actual line with LIST in. 331 | local TMPDATA=$(mktemp -u -p ${TMPDIR} .tmux_tm_XXXXXXXXXX) 332 | trap "rm -f ${TMPDATA}" EXIT ERR HUP INT QUIT TERM 333 | local index=0 334 | while [[ ${index} -lt ${#workdata[@]} ]]; do 335 | if [[ "${workdata[${index}]}" =~ ^LIST\ (.*)$ ]]; then 336 | # printf -- 'workdata: %s\n' "${workdata[@]}" 337 | local cmd=${BASH_REMATCH[1]} 338 | if [[ ${cmd} =~ \$\{([0-9a-zA-Z_]+)\} ]]; then 339 | repvar=${BASH_REMATCH[1]} 340 | reptext=${!repvar} 341 | cmd=${cmd//\$\{$repvar\}/$reptext} 342 | fi 343 | echo "Line ${index}: Fetching hostnames using provided shell command '${cmd}', please stand by..." 344 | 345 | $( ${cmd} >| "${TMPDATA}" ) 346 | # Set IFS to be NEWLINE only, not also space/tab, the list may have ssh options 347 | # and what not, so \n is our separator, not more. 348 | IFS=" 349 | " 350 | out=( $(tr -d '\r' < "${TMPDATA}" ) ) 351 | 352 | # Restore IFS 353 | IFS=${OLDIFS} 354 | 355 | workdata+=( "${out[@]}" ) 356 | unset workdata[${index}] 357 | unset out 358 | # printf -- 'workdata: %s\n' "${workdata[@]}" 359 | elif [[ "${workdata[${index}]}" =~ ^SSHCMD\ (.*)$ ]]; then 360 | TMSSHCMD=${BASH_REMATCH[1]} 361 | fi 362 | index=$(( index + 1 )) 363 | done 364 | rm -f "${TMPDATA}" 365 | trap - EXIT ERR HUP INT QUIT TERM 366 | debug "TMDATA: ${TMDATA[@]}" 367 | debug "olddata: ${olddata[@]:-''}" 368 | if [[ -n ${olddata[@]:-""} ]]; then 369 | TMDATA=( "${olddata[@]}" "${workdata[@]}" ) 370 | else 371 | TMDATA=( "${TMDATA[@]:0:2}" "${workdata[@]}" ) 372 | fi 373 | declare -r TMDATA=( "${TMDATA[@]}" ) 374 | debug "TMDATA now ${TMDATA[@]}" 375 | } 376 | 377 | # Simple overview of running sessions 378 | function list_sessions() { 379 | local IFS="" 380 | if output=$(tmux list-sessions 2>/dev/null); then 381 | echo $output 382 | else 383 | echo "No tmux sessions available" 384 | fi 385 | } 386 | 387 | # We either have a debug function that shows output, or one that 388 | # plainly returns 389 | if [[ ${DEBUG} == true ]]; then 390 | eval "debug() { >&2 echo \$* ; }" 391 | else 392 | eval "debug() { return ; }" 393 | fi 394 | 395 | setup_command_aliases 396 | 397 | ######################################################################## 398 | # MAIN work follows here 399 | # Check the first cmdline parameter, we might want to prepare something 400 | case ${cmdline} in 401 | ls) 402 | list_sessions 403 | exit 0 404 | ;; 405 | s|ms|k) 406 | # Yay, we want ssh to a remote host - or even a multi session setup - or kill one 407 | # So we have to prepare our session name to fit in what tmux (and shell) 408 | # allow us to have. And so that we can reopen an existing session, if called 409 | # with the same hosts again. 410 | SESSION=$(ssh_sessname $@) 411 | declare -r cmdline 412 | shift 413 | ;; 414 | b) 415 | declare -r cmdline=b 416 | shift 417 | declare -r SESSION=$@ 418 | ;; 419 | j) 420 | declare -r cmdline=j 421 | shift 422 | declare -r SESSION=$@ 423 | ;; 424 | -*) 425 | while getopts "lnhgs:m:c:e:r:k:b:j:" OPTION; do 426 | case ${OPTION} in 427 | l) # ls 428 | list_sessions 429 | exit 0 430 | ;; 431 | s) # ssh 432 | SESSION=$(ssh_sessname s ${OPTARG}) 433 | declare -r cmdline=s 434 | shift 435 | ;; 436 | k) # kill session 437 | SESSION=$(ssh_sessname s ${OPTARG}) 438 | declare -r cmdline=k 439 | shift 440 | ;; 441 | m) # ms (needs hostnames in "") 442 | SESSION=$(ssh_sessname ms ${OPTARG}) 443 | declare -r cmdline=ms 444 | shift 445 | ;; 446 | b) # b (break panes in session X, window Y) 447 | declare -r SESSION=${OPTARG} 448 | declare -r cmdline=b 449 | shift 450 | ;; 451 | j) # j (join windows in session X to first window) 452 | declare -r SESSION=${OPTARG} 453 | declare -r cmdline=j 454 | shift 455 | ;; 456 | c) # pre-defined config 457 | own_config ${OPTARG} 458 | ;; 459 | e) # existing session name 460 | SESSION=$(clean_session ${OPTARG}) 461 | shift 462 | ;; 463 | n) # new session even if same name one already exists 464 | DOUBLENAME=true 465 | ;; 466 | r) # replacement arg 467 | TMREPARG=${OPTARG} 468 | ;; 469 | g) # Group session, not simple attach 470 | declare -r GROUPSESSION=true 471 | ;; 472 | h) 473 | usage 474 | ;; 475 | esac 476 | done 477 | ;; 478 | *) 479 | # Nothing special (or something in our tmux.d) 480 | if [ $# -lt 1 ]; then 481 | SESSION=${SESSION:-""} 482 | if [[ -n "${SESSION}" ]]; then 483 | # Environment has SESSION set, wherever from. So lets 484 | # see if it's an actual tmux session 485 | if ! tmux has-session -t "${SESSION}" 2>/dev/null; then 486 | # It is not. And no argument. Show usage 487 | usage 488 | fi 489 | else 490 | usage 491 | fi 492 | elif [ -r "${TMDIR}/${cmdline}" ]; then 493 | own_config ${1} 494 | else 495 | # Not a config file, so just session name. 496 | SESSION=${cmdline} 497 | fi 498 | ;; 499 | esac 500 | 501 | havesession="false" 502 | if tmux has-session -t ${SESSION} 2>/dev/null; then 503 | havesession="true" 504 | fi 505 | declare -r havesession 506 | 507 | # And now check if we would end up with a doubled session name. 508 | # If so add something "random" to the new name, like our pid. 509 | if [[ ${DOUBLENAME} == true ]] && [[ ${havesession} == true ]]; then 510 | # Session exist but we are asked to open another one, 511 | # so adjust our session name 512 | if [[ ${#TMDATA} -eq 0 ]] && [[ ${SESSION} =~ ([ms]+)_(.*) ]]; then 513 | SESSION="${BASH_REMATCH[1]}_$$_${BASH_REMATCH[2]}" 514 | else 515 | SESSION="$$_${SESSION}" 516 | fi 517 | fi 518 | 519 | if [[ ${TMSESSHOST} = true ]]; then 520 | declare -r SESSION="$(uname -n|cut -d. -f1)_${SESSION}" 521 | else 522 | declare -r SESSION 523 | fi 524 | 525 | # We only do special work if the SESSION does not already exist. 526 | if [[ ${cmdline} != k ]] && [[ ${havesession} == false ]]; then 527 | # In case we want some extra things... 528 | # Check stupid users 529 | if [ $# -lt 1 ]; then 530 | usage 531 | fi 532 | tm_pane_error="create pane failed: pane too small" 533 | case ${cmdline} in 534 | s) 535 | # The user wants to open ssh to one or more hosts 536 | do_cmd new-session -d -s ${SESSION} -n "${1}" "'${TMSSHCMD} ${1}'" 537 | # We disable any automated renaming, as that lets tmux set 538 | # the pane title to the process running in the pane. Which 539 | # means you can end up with tons of "bash". With this 540 | # disabled you will have panes named after the host. 541 | do_cmd set-window-option -t ${SESSION} automatic-rename off >/dev/null 542 | # If we have at least tmux 1.7, allow-rename works, such also disabling 543 | # any rename based on shell escape codes. 544 | if [ ${TMUXMINOR//[!0-9]/} -ge 7 ] || [ ${TMUXMAJOR//[!0-9]/} -gt 1 ]; then 545 | do_cmd set-window-option -t ${SESSION} allow-rename off >/dev/null 546 | fi 547 | shift 548 | count=2 549 | while [ $# -gt 0 ]; do 550 | do_cmd new-window -d -t ${SESSION}:${count} -n "${1}" "${TMSSHCMD} ${1}" 551 | do_cmd set-window-option -t ${SESSION}:${count} automatic-rename off >/dev/null 552 | # If we have at least tmux 1.7, allow-rename works, such also disabling 553 | # any rename based on shell escape codes. 554 | if [ ${TMUXMINOR//[!0-9]/} -ge 7 ] || [ ${TMUXMAJOR//[!0-9]/} -gt 1 ]; then 555 | do_cmd set-window-option -t ${SESSION}:${count} allow-rename off >/dev/null 556 | fi 557 | count=$(( count + 1 )) 558 | shift 559 | done 560 | ;; 561 | ms) 562 | # We open a multisession window. That is, we tile the window as many times 563 | # as we have hosts, display them all and have the user input send to all 564 | # of them at once. 565 | do_cmd new-session -d -s ${SESSION} -n "Multisession" "'${TMSSHCMD} ${1}'" 566 | shift 567 | while [ $# -gt 0 ]; do 568 | set +e 569 | output=$(do_cmd split-window -d -t ${SESSION}:${TMWIN} "'${TMSSHCMD} ${1}'" 2>&1) 570 | ret=$? 571 | set -e 572 | if [[ ${ret} -ne 0 ]] && [[ ${output} == ${tm_pane_error} ]]; then 573 | # No more space -> have tmux redo the 574 | # layout, so all windows are evenly sized. 575 | do_cmd select-layout -t ${SESSION}:${TMWIN} main-horizontal >/dev/null 576 | # And don't shift parameter away 577 | continue 578 | fi 579 | shift 580 | done 581 | # Now synchronize them 582 | do_cmd set-window-option -t ${SESSION}:${TMWIN} synchronize-pane >/dev/null 583 | # And set a final layout which ensures they all have about the same size 584 | do_cmd select-layout -t ${SESSION}:${TMWIN} tiled >/dev/null 585 | ;; 586 | *) 587 | # Whatever string, so either a plain session or something from our tmux.d 588 | if [ -z "${TMDATA}" ]; then 589 | # the easy case, just a plain session name 590 | do_cmd new-session -d -s ${SESSION} 591 | else 592 | # data in our data array, the user wants his own config 593 | if [[ ${TMSESCFG} = free ]]; then 594 | if [[ ${TMDATA[2]} = NONE ]]; then 595 | # We have a free form config where we get the actual tmux commands 596 | # supplied by the user, so just issue them after creating the session. 597 | do_cmd new-session -d -s ${SESSION} -n "'${TMDATA[0]}'" 598 | else 599 | do_cmd ${TMDATA[2]} 600 | fi 601 | tmcount=${#TMDATA[@]} 602 | index=3 603 | while [ ${index} -lt ${tmcount} ]; do 604 | do_cmd ${TMDATA[$index]} 605 | (( index++ )) 606 | done 607 | else 608 | # So lets start with the "first" line, before dropping into a loop 609 | do_cmd new-session -d -s ${SESSION} -n "${TMDATA[0]}" "'${TMSSHCMD} ${TMDATA[2]}'" 610 | 611 | tmcount=${#TMDATA[@]} 612 | index=3 613 | while [ ${index} -lt ${tmcount} ]; do 614 | # List of hostnames, open a new connection per line 615 | set +e 616 | output=$(do_cmd split-window -d -t ${SESSION}:${TMWIN} "'${TMSSHCMD} ${TMDATA[$index]}'" 2>&1) 617 | set -e 618 | if [[ ${output} =~ ${tm_pane_error} ]]; then 619 | # No more space -> have tmux redo the 620 | # layout, so all windows are evenly sized. 621 | do_cmd select-layout -t ${SESSION}:${TMWIN} main-horizontal >/dev/null 622 | # And again, don't increase index 623 | continue 624 | fi 625 | (( index++ )) 626 | done 627 | # Now synchronize them 628 | do_cmd set-window-option -t ${SESSION}:${TMWIN} synchronize-pane >/dev/null 629 | # And set a final layout which ensures they all have about the same size 630 | do_cmd select-layout -t ${SESSION}:${TMWIN} tiled >/dev/null 631 | fi 632 | fi 633 | ;; 634 | esac 635 | # Build up new session, ensure we start in the first window 636 | do_cmd select-window -t ${SESSION}:${TMWIN} 637 | elif [[ ${cmdline} == k ]]; then 638 | # So we are asked to kill a session 639 | tokill=${SESSION//k_/} 640 | do_cmd kill-session -t ${tokill} 641 | exit 0 642 | elif [[ ${cmdline} == b ]]; then 643 | # Split all panes of the given session and its window into multiple windows 644 | for pane in $(do_cmd out_list-panes -s -t${SESSION} -F'#{pane_id}'); do 645 | do_cmd break-pane -s"${pane}" 2>/dev/null || true 646 | done 647 | exit 0 648 | elif [[ ${cmdline} == j ]]; then 649 | # Join all windows in a session into the first window 650 | for window in $(do_cmd out_list-windows -t${SESSION} -F'#{window_id}'|tail -n +2); do 651 | do_cmd join-pane -s${window} -t${SESSION}:${TMWIN} 652 | # Optimize by checking for pane too small 653 | do_cmd select-layout -t ${SESSION}:${TMWIN} tiled >/dev/null 654 | #2>/dev/null || true 655 | done 656 | # Now synchronize them 657 | do_cmd set-window-option -t ${SESSION}:${TMWIN} synchronize-pane >/dev/null 658 | exit 0 659 | fi 660 | 661 | # If we should group our session or not 662 | if [[ ${GROUPSESSION} == true ]]; then 663 | # Grouping means opening a new session, but sharing the sessions with 664 | # another session (existing and new windows). But window control is separate. 665 | sesname="$$_${SESSION}" 666 | tmux ${TMOPTS} new-session -s ${sesname} -t ${SESSION} 667 | tmux ${TMOPTS} kill-session -t ${sesname} 668 | else 669 | # Do not group, just attach 670 | tmux ${TMOPTS} attach -t ${SESSION} 671 | fi 672 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "android-tzdata" 16 | version = "0.1.1" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 19 | 20 | [[package]] 21 | name = "android_system_properties" 22 | version = "0.1.5" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 25 | dependencies = [ 26 | "libc", 27 | ] 28 | 29 | [[package]] 30 | name = "anstream" 31 | version = "0.6.13" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb" 34 | dependencies = [ 35 | "anstyle", 36 | "anstyle-parse", 37 | "anstyle-query", 38 | "anstyle-wincon", 39 | "colorchoice", 40 | "utf8parse", 41 | ] 42 | 43 | [[package]] 44 | name = "anstyle" 45 | version = "1.0.1" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd" 48 | 49 | [[package]] 50 | name = "anstyle-parse" 51 | version = "0.2.1" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" 54 | dependencies = [ 55 | "utf8parse", 56 | ] 57 | 58 | [[package]] 59 | name = "anstyle-query" 60 | version = "1.0.0" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" 63 | dependencies = [ 64 | "windows-sys 0.48.0", 65 | ] 66 | 67 | [[package]] 68 | name = "anstyle-wincon" 69 | version = "3.0.2" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" 72 | dependencies = [ 73 | "anstyle", 74 | "windows-sys 0.52.0", 75 | ] 76 | 77 | [[package]] 78 | name = "anyhow" 79 | version = "1.0.86" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" 82 | 83 | [[package]] 84 | name = "autocfg" 85 | version = "1.4.0" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 88 | 89 | [[package]] 90 | name = "bitflags" 91 | version = "1.3.2" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 94 | 95 | [[package]] 96 | name = "bitflags" 97 | version = "2.6.0" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 100 | 101 | [[package]] 102 | name = "bstr" 103 | version = "1.10.0" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c" 106 | dependencies = [ 107 | "memchr", 108 | "regex-automata", 109 | "serde", 110 | ] 111 | 112 | [[package]] 113 | name = "bumpalo" 114 | version = "3.16.0" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" 117 | 118 | [[package]] 119 | name = "cc" 120 | version = "1.0.79" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" 123 | 124 | [[package]] 125 | name = "cfg-if" 126 | version = "1.0.0" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 129 | 130 | [[package]] 131 | name = "chrono" 132 | version = "0.4.38" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" 135 | dependencies = [ 136 | "android-tzdata", 137 | "iana-time-zone", 138 | "num-traits", 139 | "windows-targets 0.52.4", 140 | ] 141 | 142 | [[package]] 143 | name = "clap" 144 | version = "4.4.18" 145 | source = "registry+https://github.com/rust-lang/crates.io-index" 146 | checksum = "1e578d6ec4194633722ccf9544794b71b1385c3c027efe0c55db226fc880865c" 147 | dependencies = [ 148 | "clap_builder", 149 | "clap_derive", 150 | ] 151 | 152 | [[package]] 153 | name = "clap-verbosity-flag" 154 | version = "2.2.2" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "e099138e1807662ff75e2cebe4ae2287add879245574489f9b1588eb5e5564ed" 157 | dependencies = [ 158 | "clap", 159 | "log", 160 | ] 161 | 162 | [[package]] 163 | name = "clap_builder" 164 | version = "4.4.18" 165 | source = "registry+https://github.com/rust-lang/crates.io-index" 166 | checksum = "4df4df40ec50c46000231c914968278b1eb05098cf8f1b3a518a95030e71d1c7" 167 | dependencies = [ 168 | "anstream", 169 | "anstyle", 170 | "clap_lex", 171 | "strsim", 172 | "terminal_size", 173 | ] 174 | 175 | [[package]] 176 | name = "clap_derive" 177 | version = "4.4.7" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" 180 | dependencies = [ 181 | "heck", 182 | "proc-macro2", 183 | "quote", 184 | "syn 2.0.81", 185 | ] 186 | 187 | [[package]] 188 | name = "clap_lex" 189 | version = "0.6.0" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" 192 | 193 | [[package]] 194 | name = "colorchoice" 195 | version = "1.0.0" 196 | source = "registry+https://github.com/rust-lang/crates.io-index" 197 | checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" 198 | 199 | [[package]] 200 | name = "core-foundation-sys" 201 | version = "0.8.7" 202 | source = "registry+https://github.com/rust-lang/crates.io-index" 203 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 204 | 205 | [[package]] 206 | name = "directories" 207 | version = "5.0.1" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" 210 | dependencies = [ 211 | "dirs-sys", 212 | ] 213 | 214 | [[package]] 215 | name = "dirs" 216 | version = "5.0.1" 217 | source = "registry+https://github.com/rust-lang/crates.io-index" 218 | checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" 219 | dependencies = [ 220 | "dirs-sys", 221 | ] 222 | 223 | [[package]] 224 | name = "dirs-sys" 225 | version = "0.4.1" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" 228 | dependencies = [ 229 | "libc", 230 | "option-ext", 231 | "redox_users", 232 | "windows-sys 0.48.0", 233 | ] 234 | 235 | [[package]] 236 | name = "either" 237 | version = "1.13.0" 238 | source = "registry+https://github.com/rust-lang/crates.io-index" 239 | checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" 240 | 241 | [[package]] 242 | name = "errno" 243 | version = "0.3.1" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" 246 | dependencies = [ 247 | "errno-dragonfly", 248 | "libc", 249 | "windows-sys 0.48.0", 250 | ] 251 | 252 | [[package]] 253 | name = "errno-dragonfly" 254 | version = "0.1.2" 255 | source = "registry+https://github.com/rust-lang/crates.io-index" 256 | checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" 257 | dependencies = [ 258 | "cc", 259 | "libc", 260 | ] 261 | 262 | [[package]] 263 | name = "fehler" 264 | version = "1.0.0" 265 | source = "registry+https://github.com/rust-lang/crates.io-index" 266 | checksum = "d5729fe49ba028cd550747b6e62cd3d841beccab5390aa398538c31a2d983635" 267 | dependencies = [ 268 | "fehler-macros", 269 | ] 270 | 271 | [[package]] 272 | name = "fehler-macros" 273 | version = "1.0.0" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "ccb5acb1045ebbfa222e2c50679e392a71dd77030b78fb0189f2d9c5974400f9" 276 | dependencies = [ 277 | "proc-macro2", 278 | "quote", 279 | "syn 1.0.109", 280 | ] 281 | 282 | [[package]] 283 | name = "getrandom" 284 | version = "0.2.10" 285 | source = "registry+https://github.com/rust-lang/crates.io-index" 286 | checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" 287 | dependencies = [ 288 | "cfg-if", 289 | "libc", 290 | "wasi", 291 | ] 292 | 293 | [[package]] 294 | name = "heck" 295 | version = "0.4.1" 296 | source = "registry+https://github.com/rust-lang/crates.io-index" 297 | checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 298 | 299 | [[package]] 300 | name = "iana-time-zone" 301 | version = "0.1.61" 302 | source = "registry+https://github.com/rust-lang/crates.io-index" 303 | checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" 304 | dependencies = [ 305 | "android_system_properties", 306 | "core-foundation-sys", 307 | "iana-time-zone-haiku", 308 | "js-sys", 309 | "wasm-bindgen", 310 | "windows-core", 311 | ] 312 | 313 | [[package]] 314 | name = "iana-time-zone-haiku" 315 | version = "0.1.2" 316 | source = "registry+https://github.com/rust-lang/crates.io-index" 317 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 318 | dependencies = [ 319 | "cc", 320 | ] 321 | 322 | [[package]] 323 | name = "itertools" 324 | version = "0.11.0" 325 | source = "registry+https://github.com/rust-lang/crates.io-index" 326 | checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" 327 | dependencies = [ 328 | "either", 329 | ] 330 | 331 | [[package]] 332 | name = "js-sys" 333 | version = "0.3.72" 334 | source = "registry+https://github.com/rust-lang/crates.io-index" 335 | checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" 336 | dependencies = [ 337 | "wasm-bindgen", 338 | ] 339 | 340 | [[package]] 341 | name = "lazy_static" 342 | version = "1.5.0" 343 | source = "registry+https://github.com/rust-lang/crates.io-index" 344 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 345 | 346 | [[package]] 347 | name = "libc" 348 | version = "0.2.147" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" 351 | 352 | [[package]] 353 | name = "linux-raw-sys" 354 | version = "0.4.14" 355 | source = "registry+https://github.com/rust-lang/crates.io-index" 356 | checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" 357 | 358 | [[package]] 359 | name = "log" 360 | version = "0.4.22" 361 | source = "registry+https://github.com/rust-lang/crates.io-index" 362 | checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" 363 | 364 | [[package]] 365 | name = "memchr" 366 | version = "2.7.4" 367 | source = "registry+https://github.com/rust-lang/crates.io-index" 368 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 369 | 370 | [[package]] 371 | name = "nu-ansi-term" 372 | version = "0.46.0" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" 375 | dependencies = [ 376 | "overload", 377 | "winapi", 378 | ] 379 | 380 | [[package]] 381 | name = "num-traits" 382 | version = "0.2.19" 383 | source = "registry+https://github.com/rust-lang/crates.io-index" 384 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 385 | dependencies = [ 386 | "autocfg", 387 | ] 388 | 389 | [[package]] 390 | name = "once_cell" 391 | version = "1.20.2" 392 | source = "registry+https://github.com/rust-lang/crates.io-index" 393 | checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 394 | 395 | [[package]] 396 | name = "option-ext" 397 | version = "0.2.0" 398 | source = "registry+https://github.com/rust-lang/crates.io-index" 399 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 400 | 401 | [[package]] 402 | name = "os_str_bytes" 403 | version = "6.6.1" 404 | source = "registry+https://github.com/rust-lang/crates.io-index" 405 | checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" 406 | dependencies = [ 407 | "memchr", 408 | ] 409 | 410 | [[package]] 411 | name = "overload" 412 | version = "0.1.1" 413 | source = "registry+https://github.com/rust-lang/crates.io-index" 414 | checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" 415 | 416 | [[package]] 417 | name = "pin-project-lite" 418 | version = "0.2.14" 419 | source = "registry+https://github.com/rust-lang/crates.io-index" 420 | checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" 421 | 422 | [[package]] 423 | name = "ppv-lite86" 424 | version = "0.2.17" 425 | source = "registry+https://github.com/rust-lang/crates.io-index" 426 | checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" 427 | 428 | [[package]] 429 | name = "proc-macro2" 430 | version = "1.0.86" 431 | source = "registry+https://github.com/rust-lang/crates.io-index" 432 | checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" 433 | dependencies = [ 434 | "unicode-ident", 435 | ] 436 | 437 | [[package]] 438 | name = "quote" 439 | version = "1.0.37" 440 | source = "registry+https://github.com/rust-lang/crates.io-index" 441 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 442 | dependencies = [ 443 | "proc-macro2", 444 | ] 445 | 446 | [[package]] 447 | name = "rand" 448 | version = "0.8.5" 449 | source = "registry+https://github.com/rust-lang/crates.io-index" 450 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 451 | dependencies = [ 452 | "libc", 453 | "rand_chacha", 454 | "rand_core", 455 | ] 456 | 457 | [[package]] 458 | name = "rand_chacha" 459 | version = "0.3.1" 460 | source = "registry+https://github.com/rust-lang/crates.io-index" 461 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 462 | dependencies = [ 463 | "ppv-lite86", 464 | "rand_core", 465 | ] 466 | 467 | [[package]] 468 | name = "rand_core" 469 | version = "0.6.4" 470 | source = "registry+https://github.com/rust-lang/crates.io-index" 471 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 472 | dependencies = [ 473 | "getrandom", 474 | ] 475 | 476 | [[package]] 477 | name = "redox_syscall" 478 | version = "0.2.16" 479 | source = "registry+https://github.com/rust-lang/crates.io-index" 480 | checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" 481 | dependencies = [ 482 | "bitflags 1.3.2", 483 | ] 484 | 485 | [[package]] 486 | name = "redox_users" 487 | version = "0.4.3" 488 | source = "registry+https://github.com/rust-lang/crates.io-index" 489 | checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" 490 | dependencies = [ 491 | "getrandom", 492 | "redox_syscall", 493 | "thiserror", 494 | ] 495 | 496 | [[package]] 497 | name = "regex" 498 | version = "1.11.0" 499 | source = "registry+https://github.com/rust-lang/crates.io-index" 500 | checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" 501 | dependencies = [ 502 | "aho-corasick", 503 | "memchr", 504 | "regex-automata", 505 | "regex-syntax", 506 | ] 507 | 508 | [[package]] 509 | name = "regex-automata" 510 | version = "0.4.8" 511 | source = "registry+https://github.com/rust-lang/crates.io-index" 512 | checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" 513 | dependencies = [ 514 | "aho-corasick", 515 | "memchr", 516 | "regex-syntax", 517 | ] 518 | 519 | [[package]] 520 | name = "regex-syntax" 521 | version = "0.8.5" 522 | source = "registry+https://github.com/rust-lang/crates.io-index" 523 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 524 | 525 | [[package]] 526 | name = "rustix" 527 | version = "0.38.13" 528 | source = "registry+https://github.com/rust-lang/crates.io-index" 529 | checksum = "d7db8590df6dfcd144d22afd1b83b36c21a18d7cbc1dc4bb5295a8712e9eb662" 530 | dependencies = [ 531 | "bitflags 2.6.0", 532 | "errno", 533 | "libc", 534 | "linux-raw-sys", 535 | "windows-sys 0.48.0", 536 | ] 537 | 538 | [[package]] 539 | name = "serde" 540 | version = "1.0.210" 541 | source = "registry+https://github.com/rust-lang/crates.io-index" 542 | checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" 543 | dependencies = [ 544 | "serde_derive", 545 | ] 546 | 547 | [[package]] 548 | name = "serde_derive" 549 | version = "1.0.210" 550 | source = "registry+https://github.com/rust-lang/crates.io-index" 551 | checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" 552 | dependencies = [ 553 | "proc-macro2", 554 | "quote", 555 | "syn 2.0.81", 556 | ] 557 | 558 | [[package]] 559 | name = "sharded-slab" 560 | version = "0.1.7" 561 | source = "registry+https://github.com/rust-lang/crates.io-index" 562 | checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 563 | dependencies = [ 564 | "lazy_static", 565 | ] 566 | 567 | [[package]] 568 | name = "shellexpand" 569 | version = "3.1.0" 570 | source = "registry+https://github.com/rust-lang/crates.io-index" 571 | checksum = "da03fa3b94cc19e3ebfc88c4229c49d8f08cdbd1228870a45f0ffdf84988e14b" 572 | dependencies = [ 573 | "bstr", 574 | "dirs", 575 | "os_str_bytes", 576 | ] 577 | 578 | [[package]] 579 | name = "shlex" 580 | version = "1.3.0" 581 | source = "registry+https://github.com/rust-lang/crates.io-index" 582 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 583 | 584 | [[package]] 585 | name = "strsim" 586 | version = "0.10.0" 587 | source = "registry+https://github.com/rust-lang/crates.io-index" 588 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 589 | 590 | [[package]] 591 | name = "syn" 592 | version = "1.0.109" 593 | source = "registry+https://github.com/rust-lang/crates.io-index" 594 | checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 595 | dependencies = [ 596 | "proc-macro2", 597 | "quote", 598 | "unicode-ident", 599 | ] 600 | 601 | [[package]] 602 | name = "syn" 603 | version = "2.0.81" 604 | source = "registry+https://github.com/rust-lang/crates.io-index" 605 | checksum = "198514704ca887dd5a1e408c6c6cdcba43672f9b4062e1b24aa34e74e6d7faae" 606 | dependencies = [ 607 | "proc-macro2", 608 | "quote", 609 | "unicode-ident", 610 | ] 611 | 612 | [[package]] 613 | name = "terminal_size" 614 | version = "0.3.0" 615 | source = "registry+https://github.com/rust-lang/crates.io-index" 616 | checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" 617 | dependencies = [ 618 | "rustix", 619 | "windows-sys 0.48.0", 620 | ] 621 | 622 | [[package]] 623 | name = "thiserror" 624 | version = "1.0.64" 625 | source = "registry+https://github.com/rust-lang/crates.io-index" 626 | checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" 627 | dependencies = [ 628 | "thiserror-impl", 629 | ] 630 | 631 | [[package]] 632 | name = "thiserror-impl" 633 | version = "1.0.64" 634 | source = "registry+https://github.com/rust-lang/crates.io-index" 635 | checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" 636 | dependencies = [ 637 | "proc-macro2", 638 | "quote", 639 | "syn 2.0.81", 640 | ] 641 | 642 | [[package]] 643 | name = "thread_local" 644 | version = "1.1.8" 645 | source = "registry+https://github.com/rust-lang/crates.io-index" 646 | checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" 647 | dependencies = [ 648 | "cfg-if", 649 | "once_cell", 650 | ] 651 | 652 | [[package]] 653 | name = "tm" 654 | version = "0.9.2" 655 | dependencies = [ 656 | "anyhow", 657 | "clap", 658 | "clap-verbosity-flag", 659 | "directories", 660 | "fehler", 661 | "itertools", 662 | "log", 663 | "rand", 664 | "regex", 665 | "shellexpand", 666 | "shlex", 667 | "tmux_interface", 668 | "tracing", 669 | "tracing-subscriber", 670 | ] 671 | 672 | [[package]] 673 | name = "tmux_interface" 674 | version = "0.3.2" 675 | source = "registry+https://github.com/rust-lang/crates.io-index" 676 | checksum = "3885889703554d7b0dd515111432fc3b2f9cceb15b2139f9b12da73365299c05" 677 | 678 | [[package]] 679 | name = "tracing" 680 | version = "0.1.40" 681 | source = "registry+https://github.com/rust-lang/crates.io-index" 682 | checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" 683 | dependencies = [ 684 | "pin-project-lite", 685 | "tracing-attributes", 686 | "tracing-core", 687 | ] 688 | 689 | [[package]] 690 | name = "tracing-attributes" 691 | version = "0.1.27" 692 | source = "registry+https://github.com/rust-lang/crates.io-index" 693 | checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" 694 | dependencies = [ 695 | "proc-macro2", 696 | "quote", 697 | "syn 2.0.81", 698 | ] 699 | 700 | [[package]] 701 | name = "tracing-core" 702 | version = "0.1.32" 703 | source = "registry+https://github.com/rust-lang/crates.io-index" 704 | checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" 705 | dependencies = [ 706 | "once_cell", 707 | "valuable", 708 | ] 709 | 710 | [[package]] 711 | name = "tracing-log" 712 | version = "0.2.0" 713 | source = "registry+https://github.com/rust-lang/crates.io-index" 714 | checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 715 | dependencies = [ 716 | "log", 717 | "once_cell", 718 | "tracing-core", 719 | ] 720 | 721 | [[package]] 722 | name = "tracing-subscriber" 723 | version = "0.3.18" 724 | source = "registry+https://github.com/rust-lang/crates.io-index" 725 | checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" 726 | dependencies = [ 727 | "chrono", 728 | "nu-ansi-term", 729 | "sharded-slab", 730 | "thread_local", 731 | "tracing-core", 732 | "tracing-log", 733 | ] 734 | 735 | [[package]] 736 | name = "unicode-ident" 737 | version = "1.0.13" 738 | source = "registry+https://github.com/rust-lang/crates.io-index" 739 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 740 | 741 | [[package]] 742 | name = "utf8parse" 743 | version = "0.2.1" 744 | source = "registry+https://github.com/rust-lang/crates.io-index" 745 | checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" 746 | 747 | [[package]] 748 | name = "valuable" 749 | version = "0.1.0" 750 | source = "registry+https://github.com/rust-lang/crates.io-index" 751 | checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" 752 | 753 | [[package]] 754 | name = "wasi" 755 | version = "0.11.0+wasi-snapshot-preview1" 756 | source = "registry+https://github.com/rust-lang/crates.io-index" 757 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 758 | 759 | [[package]] 760 | name = "wasm-bindgen" 761 | version = "0.2.95" 762 | source = "registry+https://github.com/rust-lang/crates.io-index" 763 | checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" 764 | dependencies = [ 765 | "cfg-if", 766 | "once_cell", 767 | "wasm-bindgen-macro", 768 | ] 769 | 770 | [[package]] 771 | name = "wasm-bindgen-backend" 772 | version = "0.2.95" 773 | source = "registry+https://github.com/rust-lang/crates.io-index" 774 | checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" 775 | dependencies = [ 776 | "bumpalo", 777 | "log", 778 | "once_cell", 779 | "proc-macro2", 780 | "quote", 781 | "syn 2.0.81", 782 | "wasm-bindgen-shared", 783 | ] 784 | 785 | [[package]] 786 | name = "wasm-bindgen-macro" 787 | version = "0.2.95" 788 | source = "registry+https://github.com/rust-lang/crates.io-index" 789 | checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" 790 | dependencies = [ 791 | "quote", 792 | "wasm-bindgen-macro-support", 793 | ] 794 | 795 | [[package]] 796 | name = "wasm-bindgen-macro-support" 797 | version = "0.2.95" 798 | source = "registry+https://github.com/rust-lang/crates.io-index" 799 | checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" 800 | dependencies = [ 801 | "proc-macro2", 802 | "quote", 803 | "syn 2.0.81", 804 | "wasm-bindgen-backend", 805 | "wasm-bindgen-shared", 806 | ] 807 | 808 | [[package]] 809 | name = "wasm-bindgen-shared" 810 | version = "0.2.95" 811 | source = "registry+https://github.com/rust-lang/crates.io-index" 812 | checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" 813 | 814 | [[package]] 815 | name = "winapi" 816 | version = "0.3.9" 817 | source = "registry+https://github.com/rust-lang/crates.io-index" 818 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 819 | dependencies = [ 820 | "winapi-i686-pc-windows-gnu", 821 | "winapi-x86_64-pc-windows-gnu", 822 | ] 823 | 824 | [[package]] 825 | name = "winapi-i686-pc-windows-gnu" 826 | version = "0.4.0" 827 | source = "registry+https://github.com/rust-lang/crates.io-index" 828 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 829 | 830 | [[package]] 831 | name = "winapi-x86_64-pc-windows-gnu" 832 | version = "0.4.0" 833 | source = "registry+https://github.com/rust-lang/crates.io-index" 834 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 835 | 836 | [[package]] 837 | name = "windows-core" 838 | version = "0.52.0" 839 | source = "registry+https://github.com/rust-lang/crates.io-index" 840 | checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" 841 | dependencies = [ 842 | "windows-targets 0.52.4", 843 | ] 844 | 845 | [[package]] 846 | name = "windows-sys" 847 | version = "0.48.0" 848 | source = "registry+https://github.com/rust-lang/crates.io-index" 849 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 850 | dependencies = [ 851 | "windows-targets 0.48.1", 852 | ] 853 | 854 | [[package]] 855 | name = "windows-sys" 856 | version = "0.52.0" 857 | source = "registry+https://github.com/rust-lang/crates.io-index" 858 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 859 | dependencies = [ 860 | "windows-targets 0.52.4", 861 | ] 862 | 863 | [[package]] 864 | name = "windows-targets" 865 | version = "0.48.1" 866 | source = "registry+https://github.com/rust-lang/crates.io-index" 867 | checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" 868 | dependencies = [ 869 | "windows_aarch64_gnullvm 0.48.0", 870 | "windows_aarch64_msvc 0.48.0", 871 | "windows_i686_gnu 0.48.0", 872 | "windows_i686_msvc 0.48.0", 873 | "windows_x86_64_gnu 0.48.0", 874 | "windows_x86_64_gnullvm 0.48.0", 875 | "windows_x86_64_msvc 0.48.0", 876 | ] 877 | 878 | [[package]] 879 | name = "windows-targets" 880 | version = "0.52.4" 881 | source = "registry+https://github.com/rust-lang/crates.io-index" 882 | checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" 883 | dependencies = [ 884 | "windows_aarch64_gnullvm 0.52.4", 885 | "windows_aarch64_msvc 0.52.4", 886 | "windows_i686_gnu 0.52.4", 887 | "windows_i686_msvc 0.52.4", 888 | "windows_x86_64_gnu 0.52.4", 889 | "windows_x86_64_gnullvm 0.52.4", 890 | "windows_x86_64_msvc 0.52.4", 891 | ] 892 | 893 | [[package]] 894 | name = "windows_aarch64_gnullvm" 895 | version = "0.48.0" 896 | source = "registry+https://github.com/rust-lang/crates.io-index" 897 | checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" 898 | 899 | [[package]] 900 | name = "windows_aarch64_gnullvm" 901 | version = "0.52.4" 902 | source = "registry+https://github.com/rust-lang/crates.io-index" 903 | checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" 904 | 905 | [[package]] 906 | name = "windows_aarch64_msvc" 907 | version = "0.48.0" 908 | source = "registry+https://github.com/rust-lang/crates.io-index" 909 | checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" 910 | 911 | [[package]] 912 | name = "windows_aarch64_msvc" 913 | version = "0.52.4" 914 | source = "registry+https://github.com/rust-lang/crates.io-index" 915 | checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" 916 | 917 | [[package]] 918 | name = "windows_i686_gnu" 919 | version = "0.48.0" 920 | source = "registry+https://github.com/rust-lang/crates.io-index" 921 | checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" 922 | 923 | [[package]] 924 | name = "windows_i686_gnu" 925 | version = "0.52.4" 926 | source = "registry+https://github.com/rust-lang/crates.io-index" 927 | checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" 928 | 929 | [[package]] 930 | name = "windows_i686_msvc" 931 | version = "0.48.0" 932 | source = "registry+https://github.com/rust-lang/crates.io-index" 933 | checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" 934 | 935 | [[package]] 936 | name = "windows_i686_msvc" 937 | version = "0.52.4" 938 | source = "registry+https://github.com/rust-lang/crates.io-index" 939 | checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" 940 | 941 | [[package]] 942 | name = "windows_x86_64_gnu" 943 | version = "0.48.0" 944 | source = "registry+https://github.com/rust-lang/crates.io-index" 945 | checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" 946 | 947 | [[package]] 948 | name = "windows_x86_64_gnu" 949 | version = "0.52.4" 950 | source = "registry+https://github.com/rust-lang/crates.io-index" 951 | checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" 952 | 953 | [[package]] 954 | name = "windows_x86_64_gnullvm" 955 | version = "0.48.0" 956 | source = "registry+https://github.com/rust-lang/crates.io-index" 957 | checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" 958 | 959 | [[package]] 960 | name = "windows_x86_64_gnullvm" 961 | version = "0.52.4" 962 | source = "registry+https://github.com/rust-lang/crates.io-index" 963 | checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" 964 | 965 | [[package]] 966 | name = "windows_x86_64_msvc" 967 | version = "0.48.0" 968 | source = "registry+https://github.com/rust-lang/crates.io-index" 969 | checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" 970 | 971 | [[package]] 972 | name = "windows_x86_64_msvc" 973 | version = "0.52.4" 974 | source = "registry+https://github.com/rust-lang/crates.io-index" 975 | checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" 976 | -------------------------------------------------------------------------------- /src/session.rs: -------------------------------------------------------------------------------- 1 | //! tm - a tmux helper 2 | //! 3 | //! SPDX-License-Identifier: BSD-2-Clause 4 | //! 5 | //! Copyright (C) 2011-2024 Joerg Jaspert 6 | //! 7 | 8 | #![warn(missing_docs)] 9 | 10 | use crate::{parse_line, tmreplace, TMSSHCMD, TMWIN}; 11 | use anyhow::{anyhow, Result}; 12 | use itertools::Itertools; 13 | use rand::distributions::Alphanumeric; 14 | use rand::{thread_rng, Rng}; 15 | use std::{ 16 | ffi::OsStr, 17 | fs::File, 18 | io::{BufRead, BufReader}, 19 | path::PathBuf, 20 | process::Stdio, 21 | }; 22 | use tmux_interface::{ 23 | AttachSession, BreakPane, HasSession, JoinPane, KillSession, ListPanes, ListWindows, 24 | NewSession, NewWindow, RunShell, SelectLayout, SetOption, SplitWindow, Tmux, 25 | }; 26 | use tracing::{debug, info, trace}; 27 | 28 | #[derive(Default, Debug)] 29 | #[non_exhaustive] 30 | /// Possible Session types 31 | pub enum SessionType { 32 | #[default] 33 | /// Simple - 2 initial lines, followed by 1 to many SSH destinations 34 | Simple, 35 | /// Extended - 2 initial lines, followed by 1 to many tmux commands 36 | Extended, 37 | } 38 | 39 | #[derive(Debug, Default)] 40 | #[non_exhaustive] 41 | /// Store session related information 42 | pub struct Session { 43 | /// The session name 44 | pub name: String, 45 | /// Should this be "grouped" - shares the same set of windows, new 46 | /// windows are linked to all sessions in the group, any window 47 | /// closed is removed from all sessions. But sessions are 48 | /// separate, as are their current/previous window and session 49 | /// options. 50 | pub grouped: bool, 51 | /// The session name when grouped 52 | pub gsesname: String, 53 | /// The path to a session file 54 | pub sesfile: PathBuf, 55 | /// Type of session (file), from extension of file 56 | pub sestype: SessionType, 57 | /// Synchronized session? (Synchronized - input goes to all visible panes in tmux at once) 58 | pub synced: bool, 59 | /// List of SSH Destinations / commands for the session 60 | pub targets: Vec, 61 | /// Token for the string replacement in session files 62 | pub replace: Option, 63 | } 64 | 65 | impl Session { 66 | /// Takes a string, applies some cleanup, then stores it as 67 | /// session name 68 | #[tracing::instrument(level = "trace")] 69 | pub fn set_name(&mut self, newname: S) 70 | where 71 | S: AsRef + std::fmt::Display + std::fmt::Debug, 72 | { 73 | let newname = newname.as_ref(); 74 | // Replace a set of characters we do not want in the session name with _ 75 | self.name = newname.replace(&[' ', ':', '"', '.'][..], "_"); 76 | debug!("Session name now: {}", self.name); 77 | } 78 | 79 | /// Kill session 80 | #[tracing::instrument(level = "trace", ret, err)] 81 | pub fn realkill(&self, tokill: S) -> Result 82 | where 83 | S: AsRef + std::fmt::Display + std::fmt::Debug, 84 | { 85 | let tokill = tokill.as_ref(); 86 | if Tmux::with_command(HasSession::new().target_session(tokill)) 87 | .into_command() 88 | .stderr(Stdio::null()) 89 | .status()? 90 | .success() 91 | { 92 | debug!("Asked to kill session {}", tokill); 93 | if Tmux::with_command(KillSession::new().target_session(tokill)) 94 | .status()? 95 | .success() 96 | { 97 | info!("Session {} is no more", tokill); 98 | Ok(true) 99 | } else { 100 | info!("Session {} could not be killed!", tokill); 101 | Err(anyhow!("Session {} could not be killed!", tokill)) 102 | } 103 | } else { 104 | debug!("No such session {}", tokill); 105 | Err(anyhow!("No such session {}", tokill)) 106 | } 107 | } 108 | 109 | /// Kill current known session 110 | #[tracing::instrument(level = "trace", ret, err)] 111 | pub fn kill(&self) -> Result { 112 | self.realkill(&self.name) 113 | } 114 | 115 | /// Check if a session exists 116 | #[tracing::instrument(level = "trace", ret)] 117 | pub fn exists(&self) -> bool { 118 | Tmux::with_command(HasSession::new().target_session(&self.name)) 119 | .into_command() 120 | .stderr(Stdio::null()) 121 | .status() 122 | .unwrap() 123 | .success() 124 | } 125 | 126 | /// Attach to a running (or just prepared) tmux session 127 | #[tracing::instrument(level = "trace", ret, err, skip(self), fields(self.sesname))] 128 | pub fn attach(&mut self) -> Result { 129 | let ret = if self.exists() { 130 | if self.grouped { 131 | let mut rng = rand::thread_rng(); 132 | let insert: u16 = rng.gen(); 133 | self.gsesname = format!("{}_{}", insert, self.name); 134 | debug!( 135 | "Grouped session wanted, setting new session {}, linking it with {}", 136 | self.gsesname, self.name 137 | ); 138 | if cfg!(test) { 139 | Tmux::with_command( 140 | NewSession::new() 141 | .detached() 142 | .session_name(&self.gsesname) 143 | .group_name(&self.name), 144 | ) 145 | .status()?; 146 | } else { 147 | Tmux::with_command( 148 | NewSession::new() 149 | .session_name(&self.gsesname) 150 | .group_name(&self.name), 151 | ) 152 | .status()?; 153 | } 154 | info!( 155 | "Removing grouped session {} (not the original!)", 156 | &self.gsesname 157 | ); 158 | if cfg!(test) { 159 | println!("Not removing grouped session {}", self.gsesname); 160 | } else { 161 | self.realkill(&self.gsesname)?; 162 | } 163 | true 164 | } else if cfg!(test) { 165 | println!("Can not attach in test mode"); 166 | match self.name.as_str() { 167 | "fakeattach" => true, 168 | &_ => false, 169 | } 170 | } else { 171 | Tmux::with_command(AttachSession::new().target_session(&self.name)) 172 | .status()? 173 | .success() 174 | } 175 | } else { 176 | false 177 | }; 178 | Ok(ret) 179 | } 180 | 181 | /// Read and parse a session file, run an action to create the session, then attach to it. 182 | /// 183 | /// This will create a new tmux session according to the session file, either a _simple_ or an _extended_ 184 | /// style config file found at `sesfile`. 185 | /// 186 | /// # Config file formats 187 | /// ## Simple 188 | /// The _simple_ config style is a line based one, with the following properties: 189 | /// 190 | /// 1. Session name 191 | /// 1. Extra tmux command line options, most commonly **NONE**. _(currently options are unsupported in the rust tm version)_ 192 | /// 1. Either an SSH destination (\[user@]hostname) **or** the LIST command. 193 | /// 1. [...] As many SSH destinations/LIST commands as wanted and needed. 194 | /// 195 | /// ### SSH destination 196 | /// Taken from the ssh(1) manpage: 197 | /// ssh connects and logs into the specified destination, which may be 198 | /// specified as either \[user@]hostname or a URI of the form 199 | /// ssh://\[user@]hostname\[:port]. 200 | /// 201 | /// ### LIST command 202 | /// Instead of an SSH destination, the command **LIST** followed by an 203 | /// argument is also accepted. The argument must be a runnable command 204 | /// that outputs a list of SSH destinations on stdout and exits 205 | /// successfully. 206 | /// 207 | /// The command will be run in the same directory the simple config 208 | /// file is found in. 209 | /// 210 | /// **Note**: This is recursive, so if output of a command contains 211 | /// **LIST** again, it will also be executed and its output used. 212 | /// There is no limit (except stack size) on recursion, so yes, you 213 | /// can build a loop and then watch tm race to a panic.. 214 | /// 215 | /// ## Extended 216 | /// The _extended_ config style is also line based, with the following properties: 217 | /// 218 | /// 1. Session name 219 | /// 1. Extra tmux command line options, most commonly **NONE**. _(currently options are unsupported in the rust tm version)_ 220 | /// 1. Any tmux(1) command with whatever option tmux supports. 221 | /// 1. [...] As many tmux(1) commands as wanted and needed 222 | /// 223 | /// ### Replacement tags 224 | /// While parsing the commands, the following tags get replaced: 225 | /// 226 | /// | TAG | Replacement 227 | /// |---------|------------ 228 | /// | SESSION | Session name (first line value) 229 | /// | TMWIN | Current window (starts with whatever tmux option base-index has, increases on every new-window found) 230 | /// | $HOME | User home directory 231 | /// | ${HOME} | Same as $HOME 232 | /// 233 | /// # Examples 234 | /// The following will open a tmux session named `examplesession`, connection to two hosts. 235 | /// ``` 236 | /// examplesession 237 | /// NONE 238 | /// ganneff@host1 239 | /// user@host2 240 | /// ``` 241 | /// 242 | /// The following will open a tmux session `anotherexample`, 243 | /// connecting to at least one host, but possibly more, depending on 244 | /// how many lines of SSH destinations the `cat foo.list` command will 245 | /// print to stdout. 246 | /// ```text 247 | /// anotherexample 248 | /// NONE 249 | /// ganneff@host3 250 | /// LIST cat foo.list 251 | /// ``` 252 | /// 253 | /// The following will open a tmux session `logmon`, with a window 254 | /// split in 3 panes, one tail-ing the messages log, another 255 | /// showing current date/time and a third showing htop. It will 256 | /// forbid tmux to rename the window. 257 | /// ```text 258 | /// nagioscfg 259 | /// NONE 260 | /// new-session -d -s SESSION -n SESSION ssh -t localhost 'TERM=xterm tail -f /var/log/messages' 261 | /// split-window -h -p 50 -d -t SESSION:TMWIN ssh -t localhost 'watch -n1 -d date -u' 262 | /// split-window -v -p 70 -d -t SESSION:TMWIN ssh -t localhost 'TERM=xterm htop' 263 | /// set-window-option -t SESSION:TMWIN automatic-rename off 264 | /// set-window-option -t SESSION:TMWIN allow-rename off 265 | /// ``` 266 | #[tracing::instrument(level = "trace", ret, err)] 267 | pub fn read_session_file_and_attach(&mut self) -> Result<()> { 268 | // Get the path of the session file 269 | let sesfile = self.sesfile.clone(); 270 | let sesfilepath = sesfile.parent().ok_or_else(|| { 271 | anyhow!( 272 | "Could not determine directory for {}", 273 | self.sesfile.display() 274 | ) 275 | })?; 276 | // Check if the file exists 277 | if !self.sesfile.exists() { 278 | return Err(anyhow!("Session file {} not found", self.sesfile.display())); 279 | } 280 | 281 | match self.sesfile.extension().and_then(OsStr::to_str) { 282 | None => self.sestype = SessionType::Simple, 283 | Some(v) => match v { 284 | "cfg" => self.sestype = SessionType::Extended, 285 | &_ => return Err(anyhow!("Unknown file extension {v}")), 286 | }, 287 | } 288 | 289 | // Want to read the session config file 290 | let sesreader = BufReader::new(File::open(&self.sesfile)?); 291 | 292 | let mut tmwin = *TMWIN; 293 | 294 | for (index, line) in sesreader.lines().enumerate() { 295 | trace!("Read line {}: {:?}", index + 1, line); 296 | // Replace token, if exists 297 | let line = tmreplace(&line?, &self.replace)?; 298 | debug!("Processing line {}: '{}'", index + 1, line); 299 | // Action to come depends on line we read and SessionType 300 | match index { 301 | 0 => { 302 | // First line is session name 303 | debug!("Possible session name: {}", &line); 304 | // Before we get to this place, we already tried 305 | // looking for a session with the name the user gave 306 | // us as argument. And it did not exist. 307 | // 308 | // Unlucky us, this first line may NOT be the same 309 | // as that session name. So we check again, and if 310 | // a session name like this already exists, we try 311 | // attaching to it. 312 | self.set_name(line); 313 | if self.exists() { 314 | info!("Session matches existing one, attaching"); 315 | self.attach()?; 316 | return Ok(()); 317 | } else { 318 | debug!("Calculated session name: {}", self.name); 319 | } 320 | } 321 | 1 => trace!("Ignoring 2nd line"), 322 | _ => { 323 | debug!("Content line"); 324 | // Third and following lines are "content", so for 325 | // simple configs either hostnames or LIST 326 | // commands, for extended ones they are commands. 327 | match &self.sestype { 328 | SessionType::Simple => { 329 | self.targets 330 | .append(&mut parse_line(&line, &self.replace, sesfilepath)?) 331 | } 332 | SessionType::Extended => { 333 | if line.contains("new-window") { 334 | tmwin += 1; 335 | } 336 | let modline = shellexpand::full( 337 | &line 338 | .replace("SESSION", &self.name) 339 | .replace("$HOME", "~/") 340 | .replace("${HOME}", "~/") 341 | .replace("TMWIN", &tmwin.to_string()), 342 | )? 343 | .to_string(); 344 | // .expand_home()? 345 | // .into_os_string() 346 | // .into_string() 347 | // .expect("String convert failed"); 348 | self.targets.push(modline); 349 | } 350 | } 351 | } 352 | } 353 | } 354 | trace!("Finished parsing session file"); 355 | debug!("Targets: {:#?}", self.targets); 356 | // Depending on session type, different action will happen 357 | match &self.sestype { 358 | SessionType::Simple => { 359 | // We have a nice set of hosts and a session name, lets set it all up 360 | self.synced = true; 361 | self.setup_simple_session()?; 362 | } 363 | SessionType::Extended => { 364 | self.setup_extended_session()?; 365 | } 366 | } 367 | self.attach()?; 368 | Ok(()) 369 | } 370 | 371 | /// Create a tmux session from an "extended" config. 372 | /// 373 | /// This just goes over all entries in [Session::targets] and executes 374 | /// them using tmux run-shell ability. Whatever the user setup in 375 | /// .cfg is executed - provided that tmux(1) knows it, ie. it is a 376 | /// valid tmux command. 377 | #[tracing::instrument(level = "trace", ret, err)] 378 | pub fn setup_extended_session(&mut self) -> Result { 379 | if self.targets.is_empty() { 380 | return Err(anyhow!("No targets setup, can not open session")); 381 | } 382 | 383 | for mut command in self.targets.clone() { 384 | debug!("Command: {}", command); 385 | // The trick with run-shell later is nice, but if we 386 | // happen to be the very first tmux session to start, it 387 | // will break with "No tmux server running". So whenever 388 | // we see a "new-session" command, we setup a fake session 389 | // which closes itself after one second, just so that one 390 | // is there and makes run-shell work. 391 | // 392 | // Alternative would be parsing the new-session line to 393 | // correctly run new-session ourself, but I do not want 394 | // to parse. 395 | // 396 | // FIXME: We should check if a tmux is running and only 397 | // then do the trick. 398 | let first = command.split_whitespace().next(); 399 | match first { 400 | Some("new-session") => { 401 | debug!("New Session"); 402 | let tempsesname: String = thread_rng() 403 | .sample_iter(&Alphanumeric) 404 | .take(30) 405 | .map(char::from) 406 | .collect(); 407 | Tmux::with_command( 408 | NewSession::new() 409 | .detached() 410 | .session_name(&tempsesname) 411 | .window_name("to be killed") 412 | .shell_command("sleep 1"), 413 | ) 414 | .status()?; 415 | } 416 | Some(&_) => { 417 | debug!("Whatever else"); 418 | } 419 | None => {} 420 | } 421 | command.insert_str(0, "tmux "); 422 | trace!("Actually running: {}", command); 423 | let output = Tmux::with_command(RunShell::new().shell_command(command)).output()?; 424 | trace!("Shell: {:?}", output); 425 | } 426 | Ok(true) 427 | } 428 | 429 | /// Create a simple tmux session, that is, a session with one or 430 | /// multiple windows or panes opening SSH connections to a set of 431 | /// targets. 432 | /// 433 | /// Depending on value of session field [Session::synced] it will 434 | /// setup multiple windows, or one window with multiple panes. 435 | #[tracing::instrument(level = "trace", ret, err, skip(self), fields(self.sesname, self.targets))] 436 | pub fn setup_simple_session(&mut self) -> Result { 437 | if self.targets.is_empty() { 438 | return Err(anyhow!("No targets setup, can not open session")); 439 | } 440 | 441 | // And start the session by opening it with the shell command 442 | // directly going to the first target 443 | Tmux::with_command( 444 | NewSession::new() 445 | .detached() 446 | .session_name(&self.name) 447 | .window_name(&self.targets[0]) 448 | .shell_command(format!("{} {}", *TMSSHCMD, &self.targets[0])), 449 | ) 450 | .status()?; 451 | trace!("Session started"); 452 | 453 | // Which window are we at? Start with TMWIN, later on count up (if 454 | // we open more than one) 455 | let mut wincount = *TMWIN; 456 | self.setwinopt(wincount, "automatic-rename", "off")?; 457 | self.setwinopt(wincount, "allow-rename", "off")?; 458 | 459 | // Next check if there was more than one host, if so, open windows/panes 460 | // for them too. 461 | debug!(?self.targets); 462 | if self.targets.len() >= 2 { 463 | debug!("Got more than 1 target"); 464 | let mut others = self.targets.clone().into_iter(); 465 | // Skip the first, we already opened a connection 466 | others.next(); 467 | for target in others { 468 | // For the syncssh session, we count how often we tried to create a pane 469 | let mut count = 1; 470 | loop { 471 | debug!( 472 | "Opening window/pane for {}, destination {}", 473 | self.name, target 474 | ); 475 | match self.synced { 476 | true => { 477 | // split pane 478 | let output = Tmux::with_command( 479 | SplitWindow::new() 480 | .detached() 481 | .target_window(&self.name) 482 | .size(&tmux_interface::commands::PaneSize::Percentage(1)) 483 | .target_pane(format!("{}:1", self.name)) 484 | .shell_command(format!("{} {}", *TMSSHCMD, &target)), 485 | ) 486 | .output()?; 487 | 488 | trace!("New pane: {:?}", output); 489 | if output.0.status.success() { 490 | // Exit the loop, we made it and got the window 491 | debug!("Pane opened successfully"); 492 | break; 493 | } else { 494 | // Didn't work, lets help tmux along and then retry this 495 | debug!("split-window did not work"); 496 | if count >= 3 { 497 | return Err(anyhow!("Could not successfully create another pane for {}, tried {} times", target, count)); 498 | } 499 | count += 1; 500 | 501 | let reason: String = String::from_utf8(output.0.stderr) 502 | .expect("Could not parse tmux fail reason"); 503 | 504 | debug!("Failure reason: {}", reason.trim()); 505 | if reason.trim().eq_ignore_ascii_case("no space for new pane") { 506 | debug!("Panes getting too small, need to adjust layout"); 507 | // No space for new pane -> redo the layout so windows are equally sized again 508 | let out = Tmux::with_command( 509 | SelectLayout::new() 510 | .target_pane(format!("{}:{}", self.name, wincount)) 511 | .layout_name("main-horizontal"), 512 | ) 513 | .output()?; 514 | trace!("Layout result: {:#?}", out); 515 | }; 516 | }; 517 | // And one more round 518 | continue; 519 | } 520 | false => { 521 | // For the plain ssh session, we count the window we are in 522 | wincount += 1; 523 | // new window 524 | Tmux::with_command( 525 | NewWindow::new() 526 | .detached() 527 | .after() 528 | .window_name(&target) 529 | .target_window(&self.name) 530 | .shell_command(format!("{} {}", *TMSSHCMD, &target)), 531 | ) 532 | .status()?; 533 | debug!("Window/Pane {} opened", wincount); 534 | self.setwinopt(wincount, "automatic-rename", "off")?; 535 | self.setwinopt(wincount, "allow-rename", "off")?; 536 | break; 537 | } 538 | } 539 | } 540 | } 541 | match self.synced { 542 | true => { 543 | // Now synchronize their input 544 | self.setwinopt(wincount, "synchronize-pane", "on")?; 545 | // And select a final layout that all of them have roughly the same size 546 | if Tmux::with_command(SelectLayout::new().layout_name("tiled")) 547 | .status()? 548 | .success() 549 | { 550 | trace!("synced setup successful"); 551 | return Ok(true); 552 | } else { 553 | trace!("synced setup failed"); 554 | return Err(anyhow!("Setting layout failed")); 555 | } 556 | } 557 | false => { 558 | return Ok(true); 559 | } 560 | } 561 | } 562 | Ok(true) 563 | } 564 | 565 | /// Set an option for a tmux window 566 | /// 567 | /// tmux windows can have a large set of options attached. We do 568 | /// regularly want to set some. 569 | /// 570 | /// # Example 571 | /// ``` 572 | /// # fn main() { 573 | /// session.setwinopt(windowindex, "automatic-rename", "off"); 574 | /// # } 575 | /// ``` 576 | #[tracing::instrument(level = "trace", ret, err, skip(self))] 577 | pub fn setwinopt(&mut self, index: u32, option: S, value: T) -> Result 578 | where 579 | S: AsRef + std::fmt::Display + std::fmt::Debug, 580 | T: AsRef + std::fmt::Display + std::fmt::Debug, 581 | { 582 | let option = option.as_ref(); 583 | let value = value.as_ref(); 584 | let target = format!("{}:{}", self.name, index); 585 | debug!("Setting Window ({}) option {} to {}", target, option, value); 586 | match Tmux::with_command( 587 | SetOption::new() 588 | .window() 589 | .target_pane(&target) 590 | .option(option) 591 | .value(value), 592 | ) 593 | .output() 594 | { 595 | Ok(_) => { 596 | debug!("Window option successfully set"); 597 | Ok(true) 598 | } 599 | Err(error) => Err(anyhow!( 600 | "Could not set window option {}: {:#?}", 601 | option, 602 | error 603 | )), 604 | } 605 | } 606 | 607 | /// Break a session with many panes in one window into one with 608 | /// many windows. 609 | #[tracing::instrument(level = "trace", ret, err)] 610 | pub fn break_panes(&mut self) -> Result { 611 | // List of panes 612 | let panes: Vec<(String, String)> = String::from_utf8( 613 | Tmux::with_command( 614 | ListPanes::new() 615 | .format("#{s/ssh //:pane_start_command} #{pane_id}") 616 | .session() 617 | .target(&self.name), 618 | ) 619 | .output()? 620 | .stdout(), 621 | )? 622 | .split_terminator('\n') 623 | .map(|x| { 624 | x.split_whitespace() 625 | .map(|y| y.trim().to_string()) 626 | .collect_tuple::<(String, String)>() 627 | .unwrap_or_else(|| panic!("Could not split pane information: {}", x)) 628 | }) 629 | .collect(); 630 | trace!("{:#?}", panes); 631 | 632 | // Go over all panes, break them out into new windows. Window 633 | // name is whatever they had, minus a (possible) ssh in front 634 | for (pname, pid) in panes { 635 | trace!("Breaking off pane {pname}, id {pid}"); 636 | Tmux::with_command( 637 | BreakPane::new() 638 | .detached() 639 | .window_name(&pname) 640 | .src_pane(&pid) 641 | .dst_window(self.name.to_string()), 642 | ) 643 | .status()?; 644 | } 645 | 646 | Ok(true) 647 | } 648 | 649 | /// Join many windows into one window with many panes 650 | #[tracing::instrument(level = "trace", ret, err)] 651 | pub fn join_windows(&mut self) -> Result { 652 | let windowlist: Vec = String::from_utf8( 653 | Tmux::with_command( 654 | ListWindows::new() 655 | .format("#{window_id}") 656 | .target_session(&self.name), 657 | ) 658 | .output()? 659 | .stdout(), 660 | )? 661 | .split_terminator('\n') 662 | .map(|s| s.to_string()) 663 | .collect(); 664 | debug!("Window IDs: {:#?}", windowlist); 665 | let first = windowlist.clone().into_iter().next().unwrap(); 666 | debug!("First: {first:#?}"); 667 | for id in windowlist { 668 | if id != first { 669 | let mut count = 1; 670 | loop { 671 | trace!("Joining {} to {}", &id, &first); 672 | let output = Tmux::with_command( 673 | JoinPane::new() 674 | .detached() 675 | .src_pane(&id) 676 | .dst_pane(format!("{}:{}", self.name, first)), 677 | ) 678 | .output()?; 679 | trace!("Output: {:?}", output); 680 | if output.0.status.success() { 681 | // Exit the loop, we made it and got the window joined as a pane 682 | debug!("Window {} joined successfully", &id); 683 | break; 684 | } else { 685 | // Didn't work, lets help tmux along and then retry this 686 | debug!("join-pane did not work"); 687 | if count >= 3 { 688 | return Err(anyhow!( 689 | "Could not successfully join window {} into {}, tried {} times", 690 | id, 691 | first, 692 | count 693 | )); 694 | } 695 | count += 1; 696 | 697 | let reason: String = String::from_utf8(output.0.stderr) 698 | .expect("Could not parse tmux fail reason"); 699 | 700 | debug!("Failure reason: {}", reason.trim()); 701 | if reason 702 | .trim() 703 | .eq_ignore_ascii_case("create pane failed: pane too small") 704 | { 705 | debug!("Panes getting too small, need to adjust layout"); 706 | // No space for new pane -> redo the layout so windows are equally sized again 707 | let out = Tmux::with_command( 708 | SelectLayout::new() 709 | .target_pane(&first) 710 | .layout_name("main-horizontal"), 711 | ) 712 | .output()?; 713 | trace!("Layout result: {:?}", out); 714 | }; 715 | }; 716 | // And one more round 717 | continue; 718 | } 719 | } 720 | } 721 | self.setwinopt(*TMWIN, "synchronize-pane", "on")?; 722 | if Tmux::with_command(SelectLayout::new().target_pane(&first).layout_name("tiled")) 723 | .status()? 724 | .success() 725 | { 726 | trace!("joining windows successful"); 727 | Ok(true) 728 | } else { 729 | trace!("joining windows in pane {} failed", &first); 730 | Err(anyhow!("Setting layout failed")) 731 | } 732 | } 733 | } 734 | 735 | #[cfg(test)] 736 | mod tests { 737 | use super::Session; 738 | use crate::TMWIN; 739 | 740 | #[test] 741 | fn test_set_name() { 742 | let mut session = Session { 743 | ..Default::default() 744 | }; 745 | 746 | session.set_name("test"); 747 | assert_eq!("test", session.name); 748 | session.set_name("test second"); 749 | assert_eq!("test_second", session.name); 750 | session.set_name("test:third"); 751 | assert_eq!("test_third", session.name); 752 | session.set_name("test_fourth_fifth_more_words_here\"set_in"); 753 | assert_eq!("test_fourth_fifth_more_words_here_set_in", session.name); 754 | } 755 | #[test] 756 | fn test_setup_extended_session() { 757 | let mut session = Session { 758 | ..Default::default() 759 | }; 760 | session.set_name("testextended"); 761 | // Fail, we have no data in session.targets yet 762 | assert!(session.setup_extended_session().is_err()); 763 | 764 | // Put two lines in 765 | session.targets.push(format!( 766 | "new-session -d -s {0} -n {0} /bin/bash", 767 | session.name 768 | )); 769 | session.targets.push(format!( 770 | "split-window -h -p 50 -d -t {}:{} /bin/bash -c 'watch -n1 -d date -u'", 771 | session.name, *TMWIN 772 | )); 773 | session.targets.push(format!( 774 | "new-window -d -t {}:{} /bin/bash -c 'watch -n1 -d date -u'", 775 | session.name, 3 776 | )); 777 | 778 | // This should work out 779 | session.setup_extended_session().unwrap(); 780 | 781 | // // We want to check the output of ls contains our session from 782 | // // above, so have it "write" it to a variable, then check if 783 | // // the variable contains the session name and that it has two windows 784 | // let lstext = Vec::new(); 785 | // let mut handle = BufWriter::new(lstext); 786 | // ls(&mut handle).unwrap(); 787 | // handle.flush().unwrap(); 788 | 789 | // // And now check what got "written" into the variable 790 | // let (recovered_writer, _buffered_data) = handle.into_parts(); 791 | // let output = String::from_utf8(recovered_writer).unwrap(); 792 | // let checktext = format!("{}: 2 windows", session.sesname); 793 | // assert!( 794 | // output.contains(&checktext), 795 | // "Could not correctly setup extended session, output is {:#?}", 796 | // output 797 | // ); 798 | 799 | // At the end, get rid of the test session 800 | assert!(session.kill().unwrap()); 801 | } 802 | } 803 | --------------------------------------------------------------------------------