Friday September 25 2020

Effective Work Environments with Tmux and (optionally) Vim.

A number of years ago I read Tom Ryder’s series on Unix as an IDE. Which is a great read and starting point for anyone interested in this topic. It will be most useful to those who already spend much of their time in the terminal.

If you’ve spent any amount of time working with modern software frameworks like Django, Laravel, Rails, React, VueJS, Creating an HTTP application in Go or most any other modern software you know that it often involves spinning up a local server as well as a database–or worse–several.

If you’re in a corporate environment, often times the database(s) have been pieced together over years of last minute changes and poorly designed additions so there’s no easy story to spin up a local development environment.

So what’s the goal here? Well the goal should be able to set up everything you need to get started working straight away with a single command.

Tmux

Similar to the screen program that’s been around for years tmux is a more of a modern take on the terminal multiplexer with a bit more of a permissive liscense and IMO a more usable interface.

The long story short is that it lets you monitor a long running program detach, and reattach from a new SSH session, have multiple people attached at once viewing the same terminal and such.

Using tmux only for those features is leaving a lot on the table, the scripting interface is excellent and will likely be a huge improvement to your workflow.

Tmux crash course

If you’re not already familiar with tmux this is going to be a quick introduction that will have you creating a couple of sessions, windows, panes, switching between them as well as detaching and re-attaching.

Feel free to skip this if you’re already familiar.

Follow the commands one by one right after the other, read the comments as you go. Lines starting with $ or : are meant to be typed out without those chars, anything in <> is to be key presses or combination thereof. If there are multiple <> in one line enter them in quick succession, e.g. press <control-b>, let up then quickly <c>

Note that some of the commands listed below are context dependent in order to make sense.

$ tmux # Creates a new session by default, and attaches you to it
$ echo $TMUX # This displays info used by tmux to figure out your session
<control-b> <c> # *Creates* a new window
$ ls -la # list all of the files in the current directory.
<control-b> <p> # Go to *previous* window
<control-b> <n> # Go to *next* window
<control-b> <"> # Split window horizontally
<control-b> <o> # Cycle to *other* windows
<control-b> <%> # Split window horizontally
<control-b> <Alt-1> # Select even horizontal layout
<control-b> <Alt-2> # Select even vertical layout
<control-b> <Alt-3> # Select main horizontal layout
<control-b> <Alt-4> # Select main vertical layout
<control-b> <Alt-5> # Select tiled layout
<control-b> <z> # *Zoom* in on pane
$ hexdump -C < /dev/urandom | grep -i --color=force 'ca fe' | sed 1000q
<control-b> <[> # Enter copy mode
<alt-v> # Page up
<control-v> # Page down
<control-b> <:> # Enter command mode
:set mode-keys vi
<control-b> # Page up in vi mode
<control-f> # Page down in vi mode
<control-u> # Half Page up in vi mode
<control-d> # Half down in vi mode
:set mode-keys emacs # goes back to emacs mode. Choose whatever you prefer.
<control-b> <z> # *Zoom* out on pane
<control-b> <d> # Detach from session
$ tmux ls # List tmux sessions
$ tmux new -s WEE # Creates a new session named "WEE"
<control-b> <d> # Detach again
$ tmux att -t 0 # Attach now to the session we created earlier
<control-b> <w> # Opens the "chooser-tree"
<k> # Go up one line
<j> # Go down one line
<control-p> # Go up to previous line
<control-n> # Go to next line
# Now take note of this view, it's showing you the sessions you have open
# as well as all of the windows and panes. From here you can quickly
# select any of them. You can also search using `</>` or `<control-s>` just
# like you would in either Vim or Emacs respectively. This is possible
# in the copy-mode as well.
<control-b> <?> # Shows all of the bound hot keys in the form of tmux commands.

Now I want you to go through the help menu you just pulled up, and look for all of the hotkeys and commands you just entered. They’re doing nothing more than sending commands to tmux and all of them can be re-bound utilizing a configuration file.

Once you’re done with that take a moment and search for some of the commands in the man page and read up on their options.

Putting the pieces together

Now that you have a grasp on tmux, and the idea that everything can be sent as a command let’s go ahead and create a session with three windows. If you’re still attached to a tmux session, you should detach now.

$ tmux new-session -s "Three windows!" -n "Window #0" \;\
    new-window -n "Window #1!" \; new-window -n "Window #2!"

This should create a new session, called “Three windows!” ( which if you’re using the default configuration will have the session name on the lower left hand corner of the window cut off ) and three windows across the bottom with the names we specified as values to the -n flags above.

That’s all well and good, but the real magic comes in when we start specifying working directories and entering commands.

$ cd $HOME
$ tmux new-session -s "workdir" -c /tmp -n "In /tmp" \;\
   send-keys -t 0 "pwd" \; send-keys Enter

-c sets the working directory for the new-session command and send-keys will quite literally, send keys to a window.

We can then build upon what we’ve learned and create an even more complex, but useful example:

#!/bin/sh
tmux new-session -s "dispatch" -c "~/code/dispatch-tracker/app" -n "editor" \;\  
    send-keys "$EDITOR" \;\ send-keys Enter \;\
    new-window -n "shell" \; send-keys ". env/bin/activate" \;\ send-keys Enter \;\
    new-window -n "server" \; send-keys ". env/bin/activate" \;\ send-keys Enter \;\
      send-keys "python manage.py runserver 127.0.0.1:8090" \;\ send-keys Enter \;\

Breaking this apart, we have a new-session called dispatch, in the working directory of ~/code/dispatch-tracker/app and window name of editor.

From there we send $EDITOR and enter it in the first window.

We then create a second window called shell which will inherit the session’s working directory. We’ll then activate the python virtualenv by entering a command.

The third “server” window then does the same thing as the previous shell window but runs python manage.py runserver afterwords, so we don’t have to handle that by hand.

Expanding further

We can create a more useful script that checks out the repository, sets up the virtual environment, creates a Postgres server and starts the command line client too:

#!/bin/sh
set -e
workdir="$HOME/code/dispatch-tracker"
python="python3"
EDITOR="${EDITOR:-vi}"

if ! [ -d "$workdir" ] ; then
		git clone -b demo https://git.riedstra.dev/mitch/dispatch-tracker "$workdir"
fi

cd "$workdir"

if ! [ -d "app/env" ] ; then
		cd app
		$python -m venv env
		. env/bin/activate
		pip install -r requirements.txt
		deactivate
		cd ..
fi

! [ -d db ] && pg_ctl -D db initdb

tmux new-session -s "dispatch" -c "$workdir/app" -n "editor" \;\
	send-keys "$EDITOR" \;\ send-keys Enter \;\
	new-window -n "shell" \;\
		send-keys ". env/bin/activate" \; send-keys Enter \;\
	new-window -n "server" \;\
		send-keys ". env/bin/activate" \; send-keys Enter \;\
		send-keys "python manage.py runserver 127.0.0.1:8090" \; send-keys Enter \;\
	new-window -n "database" \;\
		send-keys "cd .. && postgres -D db" \; send-keys Enter \;\
	new-window -n "psql" \;\
		send-keys "sleep 3; psql postgres" \; send-keys Enter \;\
	select-window -t 0 \;\

From here it will take you next to no time to setup the config.yml and run ./manage.py migrate making future development on the application close to painless since the script does the work for you.

If you’re on Linux, you can even get clever and replace the postgres commands to run it from a docker image.

Organizing the scripts

I deal with a number of clients and projects, each with their own unique setup and requirements. Having the scripts easily accessible and in a layout that gives us tab completion is ideal.

For this I simply use the ~/dev directory in my home folder. Creating a layout like so:

$ tree dev
dev
├── <client_0>
│   ├── <project_0>
│   ├── <project_1>
│   ├── <project_2>
│   └── <project_3>
├── <client_1>
│   ├── <project_0>
│   ├── <project_1>
│   ├── <project_2>
│   └── <project_3>
├── <client_2>
│   ├── <project_0>
│   ├── <project_1>
│   ├── <project_2>
│   └── <project_3>
└── <client_3>
    ├── <project_0>
    ├── <project_1>
    ├── <project_2>
    └── <project_3>

Where <client> is a directory and <project> is a shell script that sets up the environment and everything I need to get to work on it.

I can easily see what projects I have for a client by simply doing:

$ dev/client/<tab><tab>

Or the clients with

$ dev/<tab><tab>

If necessary for complicated projects with sub-projects it’s also possible to just create a project directory with multiple shell scripts inside instead of just a script.

Removing repetition from the scripts

In nearly every development environment I setup I’m going to have three windows, one for my editor, one for a shell and another shell window specifically for git. In my shell configuration I have some functions that make the setup a little bit easier.

_tmux_session() {
session=""
window_name=""
working_directory="$(pwd)"
while [ $# -gt 0 ] ; do case $1 in
--) shift ; break;;
-s) session="$2" ; shift ; shift ;;
-n) window_name="$2" ; shift ; shift ;;
-w) working_directory="$2" ; shift ; shift ;;
*)echo "Invalid option $1" ; return ;;
esac ; done

cd "$working_directory" || { echo "Cannot change to: $working_directory" ; return; }

tmux new-session -s "$session" -n "$window_name" -c "$working_directory" \;\
  "$@"
}


_tmux_dev() {
session=""
working_directory="$(pwd)"
window_name="editor"
while [ $# -gt 0 ] ; do case $1 in
--) shift ; break;;
-s) session="$2" ; shift ; shift ;;
-n) window_name="$2" ; shift ; shift ;;
-w) working_directory="$2" ; shift ; shift ;;
*)echo "Invalid option $1" ; return ;;
esac ; done

cd "$working_directory" || { echo "Cannot change to: $working_directory" ; return; }

_tmux_session -s "$session" -w "$working_directory" -n "$window_name" \
  -- \
  send-keys -t 0 "$EDITOR" \; send-keys -t 0 Enter  \;\
  new-window -n "shell" \;\
  new-window -n "git" \;\
  send-keys -t 0 "git status" \; send-keys -t 0 Enter  \;\
  "$@"

}

With those defined in my ~/.mkshrc ( or ~/.profile, or ~/.bashrc, adjust to suit your needs ) I can adjust my scripts a little bit further to look something like this:

#!/bin/sh
. ~/.mkshrc

EDITOR='${EDITOR:-vi}'
export website_dir="/v/me/riedstra.dev"
go_website_dir="/v/me/go-website"
listen="127.0.0.1:8080"

_tmux_dev -s "website" -w "$website_dir" -- \
	new-window -c "$go_website_dir" -n "server" -t 9 \;\
	send-keys -t 9 "go run ./cmd/server -l \"$listen\" -d \"$website_dir\"" \;\
	send-keys -t 9 Enter \;\
	select-window -t 0 \;\

Which will create a session with four windows. My editor, shell and git windows as mentioned above and an additional one to run the go server that is used for development against my website.

This allows me to not only keep my scripts a bit shorter, but also gives me a central location in which to alter the way I setup the environments in the future if I so choose.

Logging into multiple servers at once

Another great feature of tmux is the ability to synchronize the panes and run the same commands interactively on multiple servers at once.

The following snippet will ssh into four servers, server0 through server3 tile the panes, and turn on synchronization:

#!/bin/sh

tmux new-session -s server-example \;\
	 send-keys 'ssh server0' \;\
	 split-window \;\
	 send-keys 'ssh server1' \;\
	 split-window \;\
	 send-keys 'ssh server2' \;\
	 split-window \;\
	 send-keys 'ssh server3' \;\
	 set synchronize-panes on \;\
	 select-layout tiled \;\
	 send-keys Enter

It works, though copying send-keys and split-window a bunch of times which is less than ideal.

With a little bit of effort into a shell function:

_tmux_servers_split_commands() {
n=0
server_cmds=""
while [ $# -gt 0 ] ; do
  if [ $n -eq 0 ] ; then
    server_cmds='send-keys "ssh \"'"$1"'\"" \; send-keys Enter \; '
  else
    server_cmds="${server_cmds} split-window \; "'send-keys "ssh \"'"$1"'\"" \; send-keys Enter \; '
  fi
  n="$(echo "$n+1" | bc)"
  shift
done
echo "$server_cmds"
}

_tmux_servers() {
session=""
working_directory="$HOME"
servers=""
layout="even-vertical"
while [ $# -gt 0 ] ; do case $1 in
--) shift ; break;;
-s) session="$2" ; shift ; shift ;;
-w) working_directory="$2" ; shift ; shift ;;
-servers) servers="$2"; shift ; shift ;;
-l) layout="$2"; shift ; shift ;;
*)echo "Invalid option $1" ; return ;;
esac ; done
cd "$working_directory" || { echo "Cannot change to: $working_directory" ; return; }

layout="select-layout $layout ';' set-window-option synchronize-panes on ';'"

eval _tmux_session -s "\$session" -w "\$working_directory" -n "main" \
  -- \
  $(_tmux_servers_split_commands $servers) \
  $layout \
  "\$@" \

}

We can reduce the above down to just:

#!/bin/sh
. ~/.mkshrc
_tmux_servers -s server-example -l tiled -servers "server0 server1 server2 server3"

Which yields the same result.

Sorting these scripts out on disk is also easy enough, I populate ~/servers with a similar format I did above for ~/dev which allows me to quickly access a particular setup for a client through tab completion. For instance:

$ tree servers
servers
├── <client0>
│   ├── <service_0>
│   ├── <service_1>
│   └── <service_2>
├── <client1>
│   ├── <service_0>
│   ├── <service_1>
│   └── <service_2>
└── <client2>
    ├── <service_0>
    ├── <service_1>
    └── <service_2>

Final thoughts

Hopefully this has been helpful, giving you a bit of insight as to how to organize and quickly bring up all of the tools you need to work on a given project. Additionally the shell functions provided above are available in my shell configuration. Which is also worth reading over as it includes a myarid of other useful utilities.