#!/usr/bin/env bash # tmex 2.0.0-rc.5 set -euo pipefail function processargs() { local args argidx arg flags idx char args=( "${@:-}" ) argidx=0 # session name argsession="${args[argidx]:-}" # check if we're skipping session name, if it matches another arg type if [[ "${argsession}" =~ ^--(detached|help|kill|npm|print|reattach|shellless|transpose|version)$ ]]; then argsession="" elif [[ "${argsession}" =~ ^--(focus|layout) ]]; then argsession="" elif [[ "${argsession}" =~ ^-[dfhklnprstvV]+ ]]; then argsession="" else (( argidx++ )) || true fi while true; do arg="${args[argidx]:-}" if [[ "${arg}" =~ ^(-([dhknprstvV]*)l|--layout) ]]; then (( argidx++ )) || true if [[ "${arg}" != '--layout' ]]; then arg="${arg/-${BASH_REMATCH[2]}/-}" fi if [[ "${arg}" == "-l" || "${arg}" == '--layout' ]]; then arg="${args[argidx]:-}" (( argidx++ )) || true fi arglayout="${arg}" arglayout="${arglayout#--layout}" # remove possible prefixes (if necessary) arglayout="${arglayout#-l}" arglayout="${arglayout#=}" flags="${BASH_REMATCH[2]}" elif [[ "${arg}" =~ ^(-([dhknprstvV]*)f|--focus) ]]; then (( argidx++ )) || true if [[ "${arg}" != '--focus' ]]; then arg="${arg/-${BASH_REMATCH[2]}/-}" fi if [[ "${arg}" == "-f" || "${arg}" == "--focus" ]]; then arg="${args[argidx]:-}" (( argidx++ )) || true fi argfocus="${arg}" argfocus="${argfocus#--focus}" # remove possible prefixes (if necessary) argfocus="${argfocus#-f}" argfocus="${argfocus#=}" flags="${BASH_REMATCH[2]}" if ! [[ "${argfocus}" =~ ^-?[0-9]+$ ]]; then echo "" echo "Invalid input: --focus (-f) arg value must be an integer." echo "" exit 1 fi elif [[ "${arg}" =~ ^--(detached|help|kill|npm|print|reattach|shellless|transpose|version|simulator)$ ]]; then (( argidx++ )) || true arg="${arg#--}" # remove "--" prefix case "${arg}" in 'detached') argdetached=TRUE;; 'help') arghelp=TRUE;; 'npm') argnpm=TRUE;; 'print') argprint=TRUE;; 'reattach') argreattach=TRUE;; 'shellless') argshellless=TRUE;; 'transpose') argtranspose=TRUE;; 'kill') argkill=TRUE;; 'version') argversion=TRUE;; 'simulator') argsimulator=TRUE;; esac elif [[ "${arg}" =~ ^-[dhknprstvV]+$ ]]; then (( argidx++ )) || true arg="${arg#-}" # remove "-" prefix flags="${arg}" else break fi if [[ -n "${flags:-}" ]]; then for (( idx = 0; idx < ${#flags}; idx++ )); do char="${flags:idx:1}" case "${char}" in 'd') argdetached=TRUE;; 'h') arghelp=TRUE;; 'n') argnpm=TRUE;; 'p') argprint=TRUE;; 'r') argreattach=TRUE;; 's') argshellless=TRUE;; 't') argtranspose=TRUE;; 'k') argkill=TRUE;; 'v') argversion=TRUE;; 'V') argversion=TRUE;; # accept either casing of v/V for parity with tmux esac done flags="" fi done # if next arg is a valid layout, use it as layout instead of shell command arg="${args[argidx]:-}" if [[ -z "${arglayout}" && -z "$( validatelayout "${arg}" )" ]]; then (( argidx++ )) || true arglayout="${arg}" # continue to parse args: processargs "${argsession}" "${args[@]:argidx}" return fi # use rest of args as shell commands argcmds=( "${args[@]:argidx}" ) # handle case where provided session name is actually a layout, and --npm was set so we don't need a session name: if [[ "${argnpm}" == TRUE && -z "${arglayout}" && -n "${argsession}" && -z "$( validatelayout "${argsession}" )" ]]; then arglayout="${argsession}" argsession='' fi # allow omission of session name if --npm is set if [[ -z "${argsession}" && "${argnpm}" == TRUE ]]; then argsession="${npm_package_name:-}" # if env var is undefined we're probably running manually instead of from an npm script; # try and pull package name from npm env manually if [[ -z "${argsession}" ]]; then argsession="$( cut -d "=" -f 2 <<< "$( npm run env 2>/dev/null | grep 'npm_package_name' )" )" fi fi # allow omission of session name if --kill is set if [[ -z "${argsession}" && "${argkill:-}" == TRUE ]]; then if (( ${#args[@]} == 2 )) && ! [[ "${args[1]}" =~ ^- ]]; then # if there's only one other arg and it doesn't start with "-" or "--", # treat this arg as the session name to kill: argsession="${args[1]}" argcmds=() # since arg is now session name, remove from argcmds elif [[ -n "${TMUX_PANE:-}" ]]; then # otherwise, if we're currently in a tmux session, kill that session: argsession="${TMUX_PANE:-}" fi fi # warn and exit of --kill was specified but other arguments were provided if [[ "${argkill:-}" == TRUE ]]; then if (( ${#argcmds[@]} > 0 )) || [[ "${argdetached}" == TRUE || "${argnpm}" == TRUE || "${argreattach}" == TRUE || "${argshellless}" == TRUE || "${argtranspose}" == TRUE ]]; then echo "You specified --kill (-k) but there are extra args." echo "Run command again without extra args if you want to kill the session." exit 1 fi fi if [[ -z "${argsession}" && "${arghelp}" == FALSE && "${argversion}" == FALSE ]]; then echo "" echo "Invalid input: Session name required. Try:" echo " $ tmex $*" echo "" exit 1 fi # tmux rewrites "."->"_", ":"->"_", "\"->"\\" when setting session names # by making these replacements, we can allow tmex to reattach/kill sessions # as expected later, even if these chars are present in the given session name: argsession="${argsession//./_}" argsession="${argsession//:/_}" argsession="${argsession//\\/\\\\}" } function generatelayout() { local size root quot rmdr # Generate a default layout based on specified size: # size:1 -> layout:"1" # size:2 -> layout:"11" # size:3 -> layout:"12" # size:4 -> layout:"22" # size:5 -> layout:"122" # size:6 -> layout:"222" # size:7 -> layout:"1222" # size:8 -> layout:"233" # ... # Params: # $1 size [integer] size of layout # Modifies external: # ${arglayout} size="$1" if (( size == 0 )); then arglayout="1" else root=$( bc <<< "scale=0; sqrt( ${size} + 1 )" ) root=${root%.*} # floor quot=$(( size / root )) quot=${quot%.*} # floor rmdr=$(( size % root )) arglayout=$( printf "%0.s${root}" $( seq 1 "${quot}" ) ) if (( rmdr > 0 )); then arglayout="${rmdr}${arglayout}" fi fi } function validatelayout() { local layout minsize idx char nextchar layoutsizesum local sizebracketsopen multidigitvalue # Ensure layout is valid and large enough to contain specified size. # Params: # $1 layout [string] layout string to be validated # $2 minsize [integer] (optional) minimum size of layout layout="$1" minsize="${2:-}" if ! [[ "${layout}" =~ ^[][}{0-9.+-]+$ ]]; then echo "can only contain these characters: 0-9 [ ] { } . + -" fi if [[ "${layout}" =~ {[^}{]*[^0-9.][^}{]*} ]]; then echo "cannot contain non-numeric characters within { } brackets" fi if [[ "${layout}" == *'{}'* ]]; then echo "cannot contain empty { } brackets" fi if [[ "${layout}" =~ ^[+] ]]; then echo "cannot start with + character" elif [[ "${layout}" =~ ^[-] ]]; then echo "cannot start with - character" fi if [[ "${layout}" =~ [+]+[^+]+[+]+ ]]; then echo "cannot contain multiple groups of + characters" elif [[ "${layout}" =~ [-]+[^-]+[-]+ ]]; then echo "cannot contain multiple groups of - characters" elif [[ "${layout}" =~ [+-]+[^+-]+[+-]+ ]]; then echo "cannot contain multiple groups of + and - characters" fi if [[ "${layout}" =~ [+] && "${layout}" =~ [-] ]]; then echo "cannot contain both + and - characters" fi if [[ "${layout}" =~ [^0-9+]\+ ]]; then echo "cannot contain + character immediately preceeded by a non-numeric character" fi if [[ "${layout}" =~ [^0-9-]\- ]]; then echo "cannot contain - character immediately preceeded by a non-numeric character" fi if [[ "${layout}" == *'..'* ]]; then echo "cannot contain multiple . characters in a row" fi if [[ -n "${minsize:-}" ]]; then layoutsizesum=0 sizebracketsopen=FALSE multidigitvalue='-1' if [[ "${layout}" =~ ^[0-9]+\. ]]; then multidigitvalue='' fi for (( idx = 0; idx < ${#layout}; idx++ )); do char="${layout:${idx}:1}" nextchar="${layout:$(( idx + 1 )):1}" if [[ "${char}" == '.' ]]; then if [[ "${nextchar}" =~ ^[0-9]$ ]]; then multidigitvalue='' fi continue elif [[ "${multidigitvalue}" != '-1' && "${char}" =~ ^[0-9]$ ]]; then multidigitvalue+="${char}" if [[ "${nextchar}" =~ ^[0-9]$ ]]; then continue else char="${multidigitvalue}" multidigitvalue='-1' fi else multidigitvalue='-1' fi if [[ "${char}" == '0' ]]; then continue elif [[ "${char}" == '{' ]]; then sizebracketsopen=TRUE elif [[ "${char}" == '}' ]]; then sizebracketsopen=FALSE elif [[ "${sizebracketsopen:-}" == FALSE && "${char}" =~ ^[0-9]+$ ]]; then (( layoutsizesum += char )) || true fi done # if size exceeds sum of layout string digits, exit with error if (( minsize > layoutsizesum )); then echo "is too small for number of commands provided" fi fi } function selectpane() { local direction flags repeat idx flag # Construct tmux select-pane args according to params. # Params: # $1 direction [string "v"|"h"] direction to select in (vertical or horizontal) # $2 flags [string] flags that will be used based on direction # $3 repeat [integer] repeat select-pane operation N times # Modifies external: # ${tmuxargs} direction="$1" flags="$2" repeat="$3" [[ "${direction}" == 'v' ]] && flag="${flags:0:1}" || flag="${flags:1:1}" for (( idx = 0; idx < repeat; idx++ )); do tmuxargs+=( ';' 'select-pane' "-${flag}" ) done } function executecmd() { local cmd # Construct tmux args to execute a given command. # Params: # $1 cmd [string] shell command that will be executed # References external: # ${argshellless} # Modifies external: # ${tmuxargs} cmd="$1" if [[ -n "${cmd}" ]]; then # if running in "shell-less" mode, execute command directly; # otherwise use send-keys to execute command within default shell if [[ "${argshellless}" == TRUE ]]; then tmuxargs+=( "${cmd}" ) else tmuxargs+=( ";" "send-keys" "${cmd}" "Enter" ) fi fi } function split1d() { local direction numpanes sizing cmds cmd idx char flag local half sizesum sizearr percentage pctprefix pctsuffix multidigitvalue # Construct tmux split-window and select-pane args that will split # the current pane into `numpanes` equally-sized separate panes. # Params: # $1 direction [string "v"|"h"] direction to split pane (vertical or horizontal) # $2 numpanes [integer] number of panes to split current pane into # $3 sizing [string] relative sizing of panes # $@ cmds [array] list of commands to be executed in resulting panes # References external: # ${argshellless} # Modifies external: # ${tmuxargs} direction="$1" numpanes="$2" sizing="$3" shift 3; cmds=( "${@:-}" ) # support for percentage values under `split-pane -l%` was added in tmux 3.1 # and `split-pane -p` was deprecated (fully removed in tmux 3.4) # see https://github.com/tmux/tmux/blob/master/CHANGES#L663-L665 for details if [[ -z "${tmuxversion}" ]] || (( $( bc -l <<< "${tmuxversion} < 3.1" ) )); then pctprefix="-p" pctsuffix="" else pctprefix="-l" pctsuffix="%" fi # convert sizing string to sizearr: # sizing: "1,2,3" -> [1, 2, 3] # sizing: "" -> [] # sizing: "2,3" -> [2, 3] # sizing: "12,13" -> [12, 13] # NOTE: leading/trailing commas on these strings are acceptable sizesum="${numpanes}" sizearr=() multidigitvalue='' for (( idx = 0; idx <= ${#sizing}; idx++ )); do char="${sizing:idx:1}" if [[ "${char}" =~ ^[0-9]$ ]]; then multidigitvalue+="${char}" elif [[ -n "${multidigitvalue}" ]]; then sizearr+=( "${multidigitvalue}" ) (( sizesum += ( multidigitvalue - 1 ) )) || true multidigitvalue='' fi done # if more than 2 panes and numpanes is even, split down the middle if (( numpanes > 2 && numpanes % 2 == 0 )); then # calculate half of size array half=$(( numpanes / 2 )) halfsizesum=0 for (( idx = 0; idx < half; idx++ )); do (( halfsizesum += ${sizearr[idx]:-1} )) || true done # percentage = 100 - round((halfsizesum * 100) / sizesum) percentage=$(( ( halfsizesum * 100 * 1000 ) / sizesum )) percentage=$(( ( percentage + 500 ) / 1000 )) # round percentage=$(( 100 - percentage )) # invert # split pane down the middle (adjusted according to size array) tmuxargs+=( ';' 'split-window' "-${direction}" "${pctprefix}${percentage}${pctsuffix}" ) executecmd "${cmds[${half}]:-}" selectpane "${direction}" 'UL' 1 # keep splitting - 1st half of panes, then select pane at middle, then 2nd half split1d "${direction}" "${half}" "$( IFS=','; echo "${sizearr[@]:0:half}" )" "${cmds[@]:0:half}" selectpane "${direction}" 'DR' 1 split1d "${direction}" "${half}" "$( IFS=','; echo "${sizearr[@]:half}" )" "${cmds[@]:half}" # if only 2 panes or numpanes is odd, split off first pane elif (( numpanes >= 2 )); then # percentage = 100 - round((sizearr[0] * 100) / sizesum) percentage=$(( ( ${sizearr[0]:-1} * 100 * 1000 ) / sizesum )) percentage=$(( ( percentage + 500 ) / 1000 )) # round percentage=$(( 100 - percentage )) # invert # below calculation would provide float percentages (2 decimal places) which # would result in more accurate pane proportions, but tmux doesn't support # decimal percentages at this time # percentage="$( # bc -l <<< "100 - ${sizearr[0]:-1} * 100 / ${sizesum}" | xargs printf %.2f # )" # split off first pane (adjusted according to size array) tmuxargs+=( ';' 'split-window' "-${direction}" "${pctprefix}${percentage}${pctsuffix}" ) executecmd "${cmds[1]:-}" # if more than 2 panes, keep splitting if (( numpanes > 2 && numpanes % 2 > 0 )); then split1d "${direction}" "$(( numpanes - 1 ))" "$( IFS=','; echo "${sizearr[@]:1}" )" "${cmds[@]:1}" fi fi } function split2d() { local direction layout cmds idx jdx char nextchar depth flag local sublayouts numsublayouts local sublayoutpanecounts sublayoutpanecount sublayoutsizings local initialcmds initialcmdidx subcmds sizebracketsopen multidigitvalue # Construct a set of tmux split-window and select-pane commands that will split # the current pane according to layout. # Params: # $1 direction [string "v"|"h"] direction to split pane (vertical or horizontal) # $2 layout [string] number of panes to split current pane into # $3 sizing [string] relative sizing of panes # $@ cmds [array] list of commands to be executed in resulting panes # Modifies external: # ${tmuxargs} # ${centerpaneidx} direction="$1" layout="$2" sizing="$3" shift 3; cmds=( "${@:-}" ) # expand layout string into three arrays: # e.g. "12{31}[3[45]{21}]{12}6" # -> ["1", "11", "3[45]{21}", "111111"] # sublayouts # -> [ 1, 2, 12, 6 ] # sublayoutpanecounts # -> ["", "3,1,", "1,2,", "" ] # sublayoutsizings depth=0 sublayouts=() sublayoutpanecounts=() sublayoutsizings=() sizebracketsopen=FALSE multidigitvalue='-1' if [[ "${layout}" =~ ^[0-9]+\. ]]; then multidigitvalue='' fi for (( idx = 0; idx < ${#layout}; idx++ )); do char="${layout:${idx}:1}" nextchar="${layout:$(( idx + 1 )):1}" if [[ "${char}" == '.' ]]; then if [[ "${nextchar}" =~ ^[0-9]$ ]]; then multidigitvalue='' fi if (( depth > 0 )); then sublayouts[${#sublayouts[@]} - 1]+="${char}" fi continue elif [[ "${multidigitvalue}" != '-1' && "${char}" =~ ^[0-9]$ ]]; then multidigitvalue+="${char}" if [[ "${nextchar}" =~ ^[0-9]$ ]]; then continue else char="${multidigitvalue}" multidigitvalue='-1' fi else multidigitvalue='-1' fi if [[ "${char}" == '0' ]]; then continue elif [[ "${char}" == '{' ]]; then sizebracketsopen=TRUE if [[ "${layout:$(( idx + 1 ))}" =~ ^[0-9]+\. ]]; then multidigitvalue='' fi elif [[ "${char}" == '}' ]]; then sizebracketsopen=FALSE elif [[ "${sizebracketsopen:-}" == TRUE ]] && (( depth == 0 )); then sublayoutsizings[${#sublayoutsizings[@]} - 1]+="${char}," fi if [[ "${char}" == ']' ]]; then (( depth -= 1 )) || true fi if (( depth > 0 )); then sublayouts[${#sublayouts[@]} - 1]+="${char}" if [[ "${char}" =~ ^[0-9]+$ && "${sizebracketsopen:-}" == FALSE ]]; then (( sublayoutpanecounts[${#sublayoutpanecounts[@]} - 1] += char )) || true fi fi if [[ "${char}" == '[' ]]; then if (( depth == 0 )); then sublayouts+=( "" ) sublayoutpanecounts+=( 0 ) sublayoutsizings+=( "" ) fi (( depth++ )) || true fi if [[ "${char}" =~ ^[0-9]+$ && "${sizebracketsopen:-}" == FALSE ]] && (( depth == 0 )); then sublayout=$( printf '%0.s1' $( seq 1 "${char}" ) ) # "111..1" str len == char sublayouts+=( "${sublayout}" ) sublayoutpanecounts+=( "${char}" ) sublayoutsizings+=( "" ) fi done numsublayouts="${#sublayouts[@]}" # LAYOUT DEBUG LOGGING: # echo "LAYOUT: ${layout}" # echo "SUBLAYOUTS: ${sublayouts[*]}" # echo "SUBLAYOUTPANECOUNTS: ${sublayoutpanecounts[*]}" # echo "SUBLAYOUTSIZINGS: ${sublayoutsizings[*]}" # echo "NUMSUBLAYOUTS: ${numsublayouts}" # construct initial set of panes initialcmds=() initialcmdidx=0 for (( idx = 0; idx < numsublayouts; idx++ )); do sublayoutpanecount="${sublayoutpanecounts[idx]}" if [[ "${argsimulator}" == TRUE ]]; then # layout debug feature: print each pane's sublayout and sublayout parent, # and keep the pane alive indefinitely for layout review initialcmds+=( "echo 'LAYOUT'; echo '${sublayouts[idx]}'; sleep 100000" ) else initialcmds+=( "${cmds[${initialcmdidx}]:-}" ) fi (( initialcmdidx += sublayoutpanecount )) || true done split1d "${direction}" "${numsublayouts}" "${sizing}" "${initialcmds[@]}" # select first pane selectpane "${direction}" 'UL' $(( numsublayouts - 1 )) # split each pane as specified by layout initialcmdidx=0 for (( idx = 0; idx < numsublayouts; idx++ )); do sublayout="${sublayouts[${idx}]}" sublayoutpanecount="${sublayoutpanecounts[${idx}]}" sublayoutsizing="${sublayoutsizings[${idx}]}" if (( idx > 0 )); then selectpane "${direction}" 'DR' 1 fi [[ "${direction}" == 'v' ]] && flag='h' || flag='v' subcmds=( "${cmds[@]:${initialcmdidx}:${sublayoutpanecount}}" ) # layout debug feature: print each pane's sublayout and sublayout parent, # and keep the pane alive indefinitely for layout review if [[ "${argsimulator}" == TRUE ]]; then subcmds=() for (( jdx = 0; jdx <= ( sublayoutpanecount + 1); jdx++ )); do subcmds+=( "echo '${sublayoutpanecount}'; sleep 100000" ) done fi # if layout contains only columns of 1, we can split 1-dimensional if [[ "${sublayout}" =~ ^1+$ ]]; then split1d "${flag}" "${sublayoutpanecount}" "${sublayoutsizing}" "${subcmds[@]:-}" else split2d "${flag}" "${sublayout}" "${sublayoutsizing}" "${subcmds[@]:-}" fi (( initialcmdidx += sublayoutpanecount )) || true done } function buildtmuxargs() { local pattern flag idx char nextchar local layoutsize partiallayout parentlayout invalidlayoutmsgs local toplevelsizing sizebracketsopen multidigitvalue npmcmds # Construct a tmux command that runs a set of commands inside a layout. # During normal operation, this will only be called once by the `main` function. # If multi-window mode is in use, it will be called once for each tmux window. # Params: # $@ args [array] subset of script args relevant to the current tmux command # Modifies external: # ${tmuxargs} # ${argcmds} # ${arglayout} # ${argtranspose} # ${argfocus} processargs "$@" if [[ "${arghelp}" == TRUE ]]; then echo "${version}" echo "" echo "${usage}" echo "${help}" exit 0 fi if [[ "${argversion}" == TRUE ]]; then echo "${version}" exit 0 fi # if -k/--kill specified tmux session and exit: if [[ "${argkill:-}" == TRUE ]]; then tmuxargs=("kill-session" "-t" "${argsession}") if [[ "${argprint}" == TRUE ]]; then echo "${tmuxargs[@]}" else tmux "${tmuxargs[@]}" fi exit 0 fi # if npm option is set, prefix commands `npm run ...` if [[ "${argnpm}" == TRUE ]]; then npmcmds="$( npm -h 2>/dev/null | awk '/access/,/whoami/' | sed -E 's/ (help|start|test),//g' | xargs | sed 's/, /|/g' )" || true for (( idx = 0; idx < ${#argcmds[@]}; idx++ )); do if ! [[ "${argcmds[idx]}" == 'npm '* ]]; then # if command is an npm builtin, use `npm ` instead of `npm run `: if [[ "|${npmcmds}|" == *"|${argcmds[idx]}|"* ]]; then argcmds[idx]="npm ${argcmds[idx]}" else argcmds[idx]="npm run ${argcmds[idx]}" fi fi done fi # if we're already in a tmux pane, ensure we're not in "shell-less" mode; # this would fail to run the 1st command in the 1st pane since the pane # would die after executing the nested tmex command if [[ -n "${TMUX_PANE:-}" ]]; then if [[ "${argshellless}" == TRUE ]]; then echo "Error: --shellless mode cannot be used when nesting tmex commands" exit 1 fi fi # if we're already in a tmux pane, further split the pane rather than nesting tmux sessions if [[ -n "${TMUX_PANE:-}" ]]; then if [[ -z "${argwindow}" ]]; then tmuxargs+=( ';' 'select-window' '-t' "$TMUX_PANE" ) tmuxargs+=( ';' 'select-pane' '-t' "$TMUX_PANE" ) else if (( ${#tmuxargs[@]} == 0 )); then tmuxargs+=( ';' 'select-window' '-t' "$TMUX_PANE" ) tmuxargs+=( ';' 'select-pane' '-t' "$TMUX_PANE" ) if [[ "${argwindow}" != '-' ]]; then tmuxargs+=( ';' 'rename-window' "${argwindow}" ) fi elif [[ "${argwindow}" != '-' ]]; then tmuxargs+=( ';' 'new-window' '-n' "${argwindow}" ) else tmuxargs+=( ';' 'new-window' ) fi fi # otherwise start a new session OR window else if (( ${#tmuxargs[@]} > 0 )); then if [[ -n "${argwindow}" && "${argwindow}" != '-' ]]; then tmuxargs+=( ';' 'new-window' '-n' "${argwindow}" ) else tmuxargs+=( ';' 'new-window' ) fi else tmuxargs+=( 'new-session' '-s' "${argsession}" ) if [[ -n "${argwindow}" && "${argwindow}" != '-' ]]; then tmuxargs+=( ';' 'rename-window' "${argwindow}" ) fi fi # if running in "detached" mode, add -d flag if [[ "${argdetached}" == TRUE ]]; then tmuxargs+=( '-d' ) fi fi if [[ "${argsimulator}" == TRUE ]]; then executecmd "echo 'LAYOUT'; echo '${arglayout}'; sleep 100000" else executecmd "${argcmds[0]:-}" fi # if layout is not specified, generate a default if [[ -z "${arglayout}" ]]; then generatelayout "${#argcmds[@]}" else arglayoutoriginal="${arglayout}" # --- Grid Sub-Layouts --- # # for each pane count followed by {+} within layout # replace with a default grid sub-layout with size matching digits: pattern="([0-9]|\.[0-9]+)\{\+\}" # normally treat pane counts as single-digit, but if count # is preceeded by a "." character allow multi-digit count while [[ "${arglayout}" =~ ${pattern} ]]; do layoutsize="${BASH_REMATCH[1]/./}" # remove possible "." from start of count parentlayout="${arglayout}" generatelayout "${layoutsize}" # wrap every pane count of new sub-layout within its own set of [[ ]] brackets, # so that slashes in adjacent sections of parent layout don't trigger multi-digit # counting logic within the new sub-layout: arglayout="$( sed -E 's/([0-9])/[[\1]]/g' <<< "${arglayout}" )" arglayout="${parentlayout/${layoutsize}\{+\}/${arglayout}}" done # if layout _starts_ with {+} exit with error if [[ "${arglayout}" == '{+}'* ]]; then echo "" echo "Invalid input: --layout=${arglayoutoriginal} cannot start with {+} clause." echo "" exit 1 fi # --- Top Level Sizing --- # # if layout starts with { } bracket sizing, automatically wrap the whole thing # in a sublayout and transpose it, so that top-level sizing is applied if [[ "${arglayout}" =~ ^(\{[0-9.]+\}) ]]; then toplevelsizing="${BASH_REMATCH[1]}" arglayout="[${arglayout/${toplevelsizing}/}]${toplevelsizing}" [[ "${argtranspose}" == TRUE ]] && argtranspose=FALSE || argtranspose=TRUE fi # --- Inter-Layout Pane Focus --- # # if a digit (or digits) representing a set of panes in a layout is followed by # any number of + or - chars, select the pane within that set corresponding with # the index represented by the number of + or - chars, # eg. +++ : pane index 2 (3rd from start), ---- : pane index -4 (4th from last) if [[ -z "${argfocus}" && "${arglayout}" =~ ^([^+-]*)([+-]+) ]]; then partiallayout="${BASH_REMATCH[1]}" if [[ "${BASH_REMATCH[2]}" == '+'* ]]; then argfocus="${#BASH_REMATCH[2]}" else argfocus="-${#BASH_REMATCH[2]}" fi multidigitvalue='-1' if [[ "${partiallayout}" =~ ^[0-9]+\. ]]; then multidigitvalue='' fi sizebracketsopen=FALSE for (( idx = 0; idx < ${#partiallayout}; idx++ )); do char="${partiallayout:${idx}:1}" nextchar="${partiallayout:$(( idx + 1 )):1}" if [[ "${char}" == '.' ]]; then if [[ "${nextchar}" =~ ^[0-9]$ ]]; then multidigitvalue='' fi continue elif [[ "${multidigitvalue}" != '-1' && "${char}" =~ ^[0-9]$ ]]; then multidigitvalue+="${char}" if [[ "${nextchar}" =~ ^[0-9]$ ]]; then continue else char="${multidigitvalue}" multidigitvalue='-1' fi else multidigitvalue='-1' fi if [[ "${char}" == '0' ]]; then continue elif [[ "${char}" == '{' ]]; then sizebracketsopen=TRUE elif [[ "${char}" == '}' ]]; then sizebracketsopen=FALSE elif [[ "${sizebracketsopen:-}" == FALSE && "${char}" =~ ^[0-9]+$ ]]; then (( argfocus += char )) || true fi done # if +++ chars are in use, algorithm is as follows: # - sum number of panes in each row/col PRECEEDING row/col with selected pane # - to accomplish this, sum all row/cols up to and INCLUDING selected, # then subtract that one at the end # - also subtract 1 since positive select-pane operations should be 0-indexed # - the resulting integer is the selected pane index, and is used as argfocus # if +++ chars are in use, algorithm is as follows: # - sum all row/cols up to and INCLUDING selected # - no decrement by 1 needed since negative select-pane operations are 1-indexed # - the resulting integer is the selected pane index, and is used as argfocus if [[ "${arglayout}" =~ [0-9]\+ ]]; then (( argfocus -= char )) || true (( argfocus -= 1 )) || true fi fi fi # ensure layout is valid and large enough to contain commands provided invalidlayoutmsgs="$( validatelayout "${arglayout}" "${#argcmds[@]}" )" if [[ -n "${invalidlayoutmsgs}" ]]; then echo "" echo "Invalid input: --layout=${arglayoutoriginal}" echo " - ${invalidlayoutmsgs//$'\n'/$'\n' - }" echo "" # prefix each message with hyphen ^ exit 1 fi # append layout commands onto tmuxargs [[ "${argtranspose}" == TRUE ]] && flag='v' || flag='h' split2d "${flag}" "${arglayout}" '' "${argcmds[@]:-}" # select a pane if focus arg was provided if [[ -n "${argfocus}" ]]; then tmuxargs+=( ';' 'select-pane' "-t${argfocus}" ) fi # if running shell-less, command exit will kill tmux pane which makes troubleshooting hard # set remain-on-exit on - more of a sane default since user will see when a command failed if [[ "${argshellless}" == TRUE ]]; then tmuxargs+=( ';' 'set-window-option' 'remain-on-exit' 'on' ) fi } function main() { local argdetached argfocus arghelp argkill arglayout arglayoutoriginal argcmds local argnpm argprint argreattach argsession argshellless argtranspose argversion local argwindow firstwindowarg local args tmexargs tmuxargs idx output version tmuxversion usage help # Construct and execute a tmux command that runs a set of commands inside a layout. # Params: # $@ args [array] arguments passed to script if [[ -z "${TMEX_SUPPRESS_WARNING_TMUX:-}" ]]; then if ! command -v tmux &>/dev/null; then echo "!!! WARNING: tmux is not yet installed, tmex will not work without tmux !!!" echo echo "Here are basic install instructions for tmux:" echo " brew install tmux # OSX (via Homebrew)" echo " sudo apt install tmux # Ubuntu, Debian, etc." echo echo "or refer to https://github.com/tmux/tmux/wiki/Installing" echo "for install instructions applicable to your platform." echo "'export TMEX_SUPPRESS_WARNING_TMUX=1' in shell rc file will hide this warning." echo echo fi fi tmuxversion="$( tmux -V )" if [[ "${tmuxversion}" =~ ([0-9]+(\.[0-9]+)?) ]]; then tmuxversion="${BASH_REMATCH[0]}" elif [[ -z "${TMEX_SUPPRESS_WARNING_PCT_FLAGS:-}" ]]; then echo "!!! WARNING: current tmux version could not be determined from !!!" echo " tmux -V >>> \"${tmuxversion}\"" echo "This can result in incorrect pane-splitting behavior since tmux has made" echo "backwards-incompatible changes to flags of the split-window command." echo "Please ensure your installation of tmux produces a semver string within" echo "the output of 'tmux -V'." echo "'export TMEX_SUPPRESS_WARNING_PCT_FLAGS=1' in shell rc file will hide warning." echo "See https://github.com/tmux/tmux/blob/master/CHANGES#L663-L665 for details." echo fi version="$( head -n 3 < "$0" | tail -1 )" version="${version#\# }" # remove "# " prefix usage='Usage: tmex -nt 1224 "cmd a" "cmd b" "cmd c" ... etc. | | | options --+ +-- layout +-- shell commands tmex ·············· session name required unless --npm or --kill set; all other args optional [-h|--help] [-v|--version] [-l|--layout] 0-9 [ ] { } . - + layout string, each digit represents number of panes in column [-f|--focus] 0-9 ············· tmux pane to select by index, must be an integer, positive or negative [-w|--window] "window-name" ···· separate sets of tmex args to start session with multiple tmux windows [-t|--transpose] ··············· build layout in left-to-right orientation instead of top-to-bottom [-n|--npm] ····················· if set, prefix each command with "npm run" for package.json scripts [-p|--print] ··················· emit command as string of tmux args instead of invoking tmux directly [-d|--detached] ················ invoke tmux with -d (detached session); useful for piping data to tmex [-r|--reattach] ················ if tmux session already exists, re-attach to it instead of replacing it [-s|--shellless] ··············· if set, invoke commands directly with tmux instead of running inside shell [-k|--kill] ···················· kill the current or specified tmux session (all other arguments ignored) ["command 1" "command 2" ...] ·· shell commands to be executed in each pane (num commands cannot exceed total pane count) ' # shellcheck disable=SC2016 help='-l, --layout If no layout is provided, a default will be generated to match the number of commands provided. Otherwise, layout must be a string of 0-9 [ ] { } . - + chars. Each digit divides a column into a number of panes, e.g. $ tmex -n --layout=1224 This layout produces 1 pane in the first column, 2 panes in the 2nd and 3rd columns, and 4 panes in the 4th column. [ ] and { } delineate sub-layouts and custom sizing, eg. $ tmex -n --layout=1[2{34}5]6 The layout contains the sub-layout 2{34}5 which will be constructed within the second column. The sub-layout specifies 3/4 sizing ratio for the 2 panes in its first row. -f, --focus To start your tmux session with a specific pane selected/focused, pass --focus or -f to your tmex command. Negative indices are supported (following same behavior as tmux'\''s select-pane command). Alternately, you may use + and - characters within a layout to select the first or last pane within a row/column of panes. $ tmex -n --layout=12+34 # select first pane in second column $ tmex -n --layout=1234+++ # select third pane in fourth column $ tmex -n --layout=123-4 # select last pane in third column $ tmex -n --layout=123--4 # select second-to-last pane in third column -w, --window To start your tmux session with multiple windows, separate multiple complete sets of tmex arguments with -w or --window: $ tmex your-session-name --window abc 123 -w efg 44 # create two windows named "abc" and "efg" with layouts 123 and 44 $ tmex your-session-name -w abc -f4 123 "cmd1" "cmd2" -w efg -f-2 44 "cmd3" # same as above, with commands added You can create "unnamed" windows using the following shorthand: $ tmex your-session-name -w- 123 -w- 44 # create two windows without names, with layouts 123 and 44 $ tmex your-session-name --window - 123 --window - 44 # equivalent For complete documentation, please see https://github.com/evnp/tmex#readme ' argdetached=FALSE # optional - if TRUE, invoke tmux with -d flag (detached session) argfocus='' # optional - tmux pane to select by index arghelp=FALSE # optional - if TRUE, display usage message and exit arglayout='' # optional - string of digits defining a custom layout argnpm=FALSE # optional - if TRUE, prefix each command with "npm run" for package.json scripts argprint=FALSE # optional - if TRUE, print final tmux command to console instead of executing it argreattach=FALSE # optional - if TRUE, re-attach to existing tmux session isntead of replacing it argsession='' # required - session name argwindow='' # optional - window name argshellless=FALSE # optional - if TRUE, execute commands standalone instead of in a shell argtranspose=FALSE # optional - if TRUE, layout in left-to-right instead of top-to-bottom orientation argversion=FALSE # optional - if TRUE, display version and exit argsimulator=FALSE # optional - if TRUE, use simulator-generated commands argcmds='' # optional - list of commands to be executed in resulting panes # begin to construct tmux arguments tmuxargs=() # --- Multi-Window Management --- # args=( "${@:-}" ) tmexargs=() for (( idx = 0; idx <= ${#args[@]}; idx++ )); do if (( idx == ${#args[@]} )) || [[ "${args[idx]}" =~ ^(-w|--window) ]]; then if [[ -z "${argwindow}" ]]; then firstwindowarg="${args[idx]:-}" if [[ "${args[idx]:-}" =~ ^(-w|--window)$ ]]; then firstwindowarg+=" ${args[$(( idx + 1 ))]:--}" fi fi if [[ -n "${argwindow}" ]]; then if [[ -n "${TMUX_PANE:-}" ]]; then buildtmuxargs "${TMUX_PANE}" "${tmexargs[@]:-}" elif (( ${#tmuxargs[@]} == 0 )); then buildtmuxargs "${tmexargs[@]:-}" else buildtmuxargs "${argsession}" "${tmexargs[@]:-}" fi tmexargs=() argwindow='' argfocus='' arglayout='' argnpm=FALSE argtranspose=FALSE argcmds='' fi if [[ "${args[idx]:-}" =~ ^(-w|--window)$ ]]; then (( idx++ )) || true argwindow="${args[idx]}" if [[ -z "${argwindow}" ]]; then argwindow='-' fi elif [[ "${args[idx]:-}" =~ ^(-w|--window)\=?(.*)$ ]]; then argwindow="${BASH_REMATCH[2]}" if [[ -z "${argwindow}" ]]; then argwindow='-' fi fi else tmexargs+=( "${args[idx]}" ) fi done if (( ${#tmuxargs[@]} == 0 )); then # not using multiple windows - build tmux args normally buildtmuxargs "$@" else # validate that first arg was session name in multi-window mode # OR that session name was omitted if using multi-windows within a tmux session if [[ -n "${TMUX_PANE:-}" ]]; then if ! [[ "$1" =~ ^(-w|--window) ]]; then echo "" echo "Invalid input: You must omit session name when using multi-window mode" echo " from within another tmux session. Try:" echo " $ tmex ${*:2}" echo "" exit 1 fi else if ! [[ "$1" == "${argsession}" ]]; then echo "" echo "Invalid input: You must define session name prior to 1st --window (-w)" echo " arg when using multi-window mode. Try:" echo " $ tmex ${argsession:-} $*" echo "" exit 1 fi fi fi if [[ -z "${TMEX_SUPPRESS_WARNING_NPM:-}" ]]; then if ! command -v npm &>/dev/null; then echo "!!! WARNING: You're using tmex with an --npm/-n flag but npm is not installed !!!" echo echo "You may want to install npm first, or remove --npm/-n from your tmex command." echo "'export TMEX_SUPPRESS_WARNING_NPM=1' in shell rc file will hide this warning." echo echo fi fi if [[ "${argprint}" == TRUE ]]; then output="" for (( idx = 0; idx < ${#tmuxargs[@]}; idx++ )); do arg= if [[ "${tmuxargs[idx]}" == *' '* ]]; then output+="\"${tmuxargs[idx]}\" " else output+="${tmuxargs[idx]} " fi done echo "${output}" else # either attach to an existing session or kill it based on specified behavior if [[ -z "${TMUX_PANE:-}" ]]; then # avoid doing this if we're already in tmux session if tmux has-session -t "${argsession}" 2>/dev/null; then if [[ "${argreattach}" == TRUE ]]; then tmux attach-session -t "${argsession}" exit 0 else tmux kill-session -t "${argsession}" fi fi fi # execute constructed tmux command tmux "${tmuxargs[@]}" # ouput session name for later attachment, setting options, etc. echo "${argsession}" fi } main "$@"