diff --git a/.gitignore b/.gitignore index 485dee6..3e05054 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ +# IDE .idea + +# Nix +.envrc +.direnv +result diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fa61e3b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# syntax = docker/dockerfile:1.4 +FROM nixos/nix:2.22.0 AS builder + +WORKDIR /tmp/build +RUN mkdir /tmp/nix-store-closure +COPY . . + +RUN \ + --mount=type=cache,target=/nix,from=nixos/nix:2.22.0,source=/nix \ + --mount=type=cache,target=/root/.cache \ + --mount=type=bind,target=/tmp/build \ + < - One prefix key to rule them all (with [fzf](https://github.com/junegunn/fzf) & [zoxide](https://github.com/ajeetdsouza/zoxide)): + - Creating a new session from a list of recently accessed directories -- Naming a session after a folder/project +- Naming a session after a directory/project - Switching sessions - Viewing current or creating new sessions in one popup ### Elevator Pitch -Tmux is powerful, yes, but why is creating/switching sessions (arguably its main feature) is so damn hard to do? To create a new session for a project you have to run `tmux new-session -s -c `. What if you're inside tmux? Oh, wait you have to use `-d` followed by `tmux switch-client -t `. Oh, wait again! What if you're outside tmux and you want to attach to an existing session? now you have to run `tmux attach -t ` instead. What if you can't remember whether you have a session for that project or not. Guess what? Now you have to run `tmux has-session -t `. What if your project folder contains characters not accepted by tmux as a session name? What if you want to show a list of existing sessions? You run `tmux list-sessions`. What if you want to create a session for a project you've recently navigated to? What if, what if, what if.... HOW IS THAT BETTER THAN HAVING 20 TERMINAL WINDOWS OPEN? +Tmux is powerful, yes, but why is creating/switching sessions (arguably its main feature) is so damn hard to do? To create a new session for a project you have to run `tmux new-session -s -c `. What if you're inside tmux? Oh, wait you have to use `-d` followed by `tmux switch-client -t `. Oh, wait again! What if you're outside tmux and you want to attach to an existing session? now you have to run `tmux attach -t ` instead. What if you can't remember whether you have a session for that project or not. Guess what? Now you have to run `tmux has-session -t `. What if your project folder contains characters not accepted by tmux as a session name? What if you want to show a list of existing sessions? You run `tmux list-sessions`. What if you want to create a session for a project you've recently navigated to? What if, what if, what if.... HOW IS THAT BETTER THAN HAVING 20 TERMINAL WINDOWS OPEN? What if you could use 1 prefix key to do all of this? Read on! @@ -20,6 +20,7 @@ What if you could use 1 prefix key to do all of this? Read on! `prefix + T` (customisable) - displays a pop-up with [fzf](https://github.com/junegunn/fzf) which displays the existing sessions followed by recently accessed directories (using [zoxide](https://github.com/ajeetdsouza/zoxide)). Choose the session or the directory and voila! You're in that session. If the session doesn't exist, it will be created. ### Required + You must have [fzf](https://github.com/junegunn/fzf), [zoxide](https://github.com/ajeetdsouza/zoxide) installed and available in your path. ### Installation with [Tmux Plugin Manager](https://github.com/tmux-plugins/tpm) (recommended) @@ -62,19 +63,82 @@ set -g @session-wizard-height 40 set -g @session-wizard-width 80 ``` +To customise the way session names are created, use `@session-wizard-mode` option. Allowed values are: + +- `directory` (default) +- `full-path` +- `short-path` + +```tmux +set -g @session-wizard-mode "full-path" +``` + ### (Optional) Using the script outside of tmux -Run the following to download the script and add it to your path. -```bash -curl https://raw.githubusercontent.com/27medkamal/tmux-session-wizard/master/session-wizard.sh > /usr/local/bin/t && chmod u+x /usr/local/bin/t +**Note:** you'll need to check the path of your tpm plugins. It may be `~/.tmux/plugins` or `~/.config/tmux/plugins` depending on where your `tmux.conf` is located. + +
+bash + +Add the following line to `~/.bashrc` + +```sh +# ~/.tmux/plugins +export PATH=$HOME/.tmux/plugins/tmux-session-wizard/bin:$PATH +# ~/.config/tmux/plugins +export PATH=$HOME/.config/tmux/plugins/tmux-session-wizard/bin:$PATH +``` + +
+ +
+zsh + +Add the following line to `~/.zprofile` + +```sh +# ~/.tmux/plugins +export PATH=$HOME/.tmux/plugins/tmux-session-wizard/bin:$PATH +# ~/.config/tmux/plugins +export PATH=$HOME/.config/tmux/plugins/tmux-session-wizard/bin:$PATH +``` + +
+ +
+fish + +Add the following line to `~/.config/fish/config.fish` + +```fish +# ~/.tmux/plugins +fish_add_path $HOME/.tmux/plugins/tmux-session-wizard/bin +# ~/.config/tmux/plugins +fish_add_path $HOME/.config/tmux/plugins/tmux-session-wizard/bin ``` -You can then run `t` from anywhere to use the script. + +
+ +You can then run `t` from anywhere to use the script. You can also run `t` with a relative or absolute path to a directory (similar to [zoxide](https://github.com/ajeetdsouza/zoxide)) to create a session for that directory. For example, `t ~/projects/my-project` will create a session named `my-project` and cd into that directory. Also, depending on the terminal emulator you use, you can make it always start what that script. +### Development + +The development environment is built with Nix and Nix's Flakes, if you have it on your system then just run `nix develop` and you are ready to go. Other method is to build the Docker image based on provided Dockerfile: + +```bash +docker build --tag tmux-session-wizard:dev --file ./Dockerfile . +``` + +To run the tests, just run `bats ./tests` for local development environment or `docker run --rm -it -u $(id -u):$(id -g) -v $PWD:$PWD -w $PWD tmux-session-wizard:dev bats ./tests` if you are using Docker. + +There is also the helper script for it _./scripts/run-tests.sh_, run `./scripts/run-tests.sh -h` to get more information about usage. + ### Inspiration + - ThePrimeagen's [tmux-sessionizer](https://github.com/ThePrimeagen/.dotfiles/blob/master/bin/.local/scripts/tmux-sessionizer) - Josh Medeski's [t-smart-tmux-session-manager](https://github.com/joshmedeski/t-smart-tmux-session-manager) diff --git a/bin/t b/bin/t new file mode 100755 index 0000000..12178c3 --- /dev/null +++ b/bin/t @@ -0,0 +1,75 @@ +#!/bin/bash + +CURRENT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$CURRENT_DIR/../src/helpers.sh" + +# Usage: t +# If no argument is given, a combination of existing sessions and a zoxide query will be displayed in a FZF + +# Parse optional argument +if [ "$1" ]; then + # Argument is given + eval "$(zoxide init bash)" + RESULT=$(z $@ && pwd) +else + # No argument is given. Use FZF + RESULT=$( ( + tmux list-sessions -F "#{session_last_attached} #{session_name}: #{session_windows} window(s)\ +#{?session_grouped, (group ,}#{session_group}#{?session_grouped,),}#{?session_attached, (attached),}" | + sort -r | (if [ -n "$TMUX" ]; then grep -v " $(tmux display-message -p '#S'):"; else cat; fi) | cut -d' ' -f2- + zoxide query -l | sed -e "$HOME_REPLACER" + ) | $(__fzfcmd) --reverse --print-query | tail -n 1) + if [ -z "$RESULT" ]; then + exit 0 + fi +fi + +# Makes sure tmux is running in order to get all the correct tmux options below. Gets cleaned at the bottom +if ! tmux info &>/dev/null; then + TMP_SESSION_DIR=$(mktemp -d) + TMP_SESSION_NAME=$(session_name --full-path "$TMP_SESSION_DIR") + tmux new-session -d -s "$TMP_SESSION_NAME" -c "$TMP_SESSION_DIR" +fi + +# Get or create session +if [[ $RESULT == *":"* ]]; then + # RESULT comes from list-sessions + SESSION=$(echo $RESULT | awk '{print $1}') + SESSION=${SESSION//:/} +else + # RESULT is a path + + DIR_FULL=$(echo "$RESULT" | sed -e "s|^~/|$HOME/|") + DIR_WITH_TILDE=$(echo "$RESULT" | sed -e "$HOME_REPLACER") # in case it came from a direct usage of `t ` + + # Quit if directory does not exists + if [ ! -d "$DIR_FULL" ]; then + exit 0 + fi + + # Promote rank in zoxide. + zoxide add "$DIR_FULL" + + MODE=$(get_tmux_option "@session-wizard-mode" "directory") + SESSION=$(session_name --"$MODE" "$DIR_WITH_TILDE") + + if ! tmux has-session -t="$SESSION" 2>/dev/null; then + tmux new-session -d -s "$SESSION" -c "$DIR_FULL" + fi +fi + +# Clean up tmp session +if [[ -n "$TMP_SESSION_NAME" ]]; then + tmux kill-session -t "$TMP_SESSION_NAME" 2>/dev/null + rm -rf "$TMP_SESSION_DIR" +fi + +# Attach to session +# Escape tilde which if appears by itself, tmux will interpret as a marked target +# https://github.com/tmux/tmux/blob/master/cmd-find.c#L1024C51-L1024C57 +SESSION=$(echo "$SESSION" | sed 's/^~$/\\~/') +if [ -z "$TMUX" ]; then + tmux attach -t "$SESSION" +else + tmux switch-client -t "$SESSION" +fi diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..11adba7 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1707743206, + "narHash": "sha256-AehgH64b28yKobC/DAWYZWkJBxL/vP83vkY+ag2Hhy4=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "2d627a2a704708673e56346fcb13d25344b8eaf3", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..3aaf5c7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,48 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + devPackages = [ + (pkgs.bats.withLibraries (p: [ p.bats-support p.bats-assert ])) + pkgs.watchexec + ]; + plugin = pkgs.tmuxPlugins.mkTmuxPlugin { + pluginName = "session-wizard"; + rtpFilePath = "session-wizard.tmux"; + version = "unstable"; + src = self; + nativeBuildInputs = [ pkgs.makeWrapper ]; + postInstall = '' + ls -al $target && \ + substituteInPlace $target/session-wizard.tmux \ + --replace \$CURRENT_DIR/bin/t $target/bin/t + wrapProgram $target/bin/t \ + --prefix PATH : ${with pkgs; lib.makeBinPath ([ fzf zoxide coreutils gnugrep gnused ])} + ''; + }; + in + { + packages.dev = (pkgs.symlinkJoin + { + name = "dev-environment"; + paths = [ + plugin + plugin.buildInputs + pkgs.tmux + pkgs.bashInteractive + pkgs.busybox + ] ++ devPackages; + }); + + devShell = pkgs.mkShell { + buildInputs = devPackages; + }; + } + ); +} diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100755 index 0000000..ec353fc --- /dev/null +++ b/scripts/run-tests.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# For debugging purposes +# set -x + +PROJECT_ROOT="$(dirname "$(dirname "$(realpath "$0")")")" +IMAGE="tmux-session-wizard:dev" + +while getopts "crwh" opt; do + case $opt in + c) CONTAINER=true + ;; + r) REBUILD=true + ;; + w) WATCH=true + ;; + h) + echo "Usage: run-tests.sh" + echo "Run tests for the project" + echo " -c Run tests inside a container (image: ${IMAGE})" + echo " -r Rebuild the container image before running tests, set also -c opiton by default" + echo " -w Watch changes in project and then run tests" + echo " -h Display this help message" + exit 0 + ;; + \?) + echo "Invalid option: -$OPTARG" >&2 + exit 1 + ;; + esac +done + +# Basic command to run tests +CMD=(bats "$PROJECT_ROOT/tests") + +# Run tests in watch mode +if [ "$WATCH" = true ]; then + CMD=(watchexec "${CMD[@]}") +fi + +# Run tests inside a container +if [ "$CONTAINER" = true ] || [ "$REBUILD" = true ]; then + CMD=(docker run --rm -it --user "$(id -u):$(id -g)" -v "$PROJECT_ROOT:$PROJECT_ROOT" -w "$PROJECT_ROOT" "$IMAGE" "${CMD[@]}") + IS_IMAGE_EXISTS=$(docker images -q ${IMAGE}) +fi + +if [ -z "$IS_IMAGE_EXISTS" ] && [ "$CONTAINER" = true ] || [ "$REBUILD" = true ] ; then + docker build -t ${IMAGE} -f "$PROJECT_ROOT/Dockerfile" "$PROJECT_ROOT" +fi + +echo "----------------------------------------------------------------------------" +echo "Running tests with command: ${CMD[*]}" +echo "----------------------------------------------------------------------------" +"${CMD[@]}" + + diff --git a/session-wizard.sh b/session-wizard.sh deleted file mode 100755 index 3074044..0000000 --- a/session-wizard.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash - -# Usage: t -# If no argument is given, a combination of existing sessions and a zoxide query will be displayed in a FZF - -__fzfcmd() { - [ -n "$TMUX_PANE" ] && { [ "${FZF_TMUX:-0}" != 0 ] || [ -n "$FZF_TMUX_OPTS" ]; } && - echo "fzf-tmux ${FZF_TMUX_OPTS:--d${FZF_TMUX_HEIGHT:-40%}} -- " || echo "fzf" -} - -# Parse optional argument -if [ "$1" ]; then - # Argument is given - eval "$(zoxide init bash)" - RESULT=$(z $@ && pwd) -else - # No argument is given. Use FZF - RESULT=$( (tmux list-sessions -F "#{session_last_attached} #{session_name}: #{session_windows} window(s)\ -#{?session_grouped, (group ,}#{session_group}#{?session_grouped,),}#{?session_attached, (attached),}"\ -| sort -r | (if [ -n "$TMUX" ]; then grep -v " $(tmux display-message -p '#S'):"; else cat; fi) | cut -d' ' -f2-; zoxide query -l) | $(__fzfcmd) --reverse --print-query | tail -n 1) - if [ -z "$RESULT" ]; then - exit 0 - fi -fi - -# Get or create session -if [[ $RESULT == *":"* ]]; then - # RESULT comes from list-sessions - SESSION=$(echo $RESULT | awk '{print $1}') - SESSION=${SESSION//:/} -else - # RESULT is a path - - # Quit if directory does not exists - if [ ! -d "$RESULT" ]; then - exit 0 - fi - - # Promote rank in zoxide. - zoxide add "$RESULT" - - SESSION=$(basename "$RESULT" | tr . - | tr ' ' - | tr ':' - | tr '[:upper:]' '[:lower:]') - if ! tmux has-session -t=$SESSION 2> /dev/null; then - tmux new-session -d -s $SESSION -c "$RESULT" - fi -fi - -# Attach to session -if [ -z "$TMUX" ]; then - tmux attach -t $SESSION -else - tmux switch-client -t $SESSION -fi - diff --git a/session-wizard.tmux b/session-wizard.tmux index dd358b5..f126e53 100755 --- a/session-wizard.tmux +++ b/session-wizard.tmux @@ -1,6 +1,7 @@ #!/usr/bin/env bash -CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +CURRENT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$CURRENT_DIR/src/helpers.sh" default_key_bindings_session_wizard="T" tmux_option_session_wizard="@session-wizard" @@ -11,31 +12,19 @@ default_width=80 # Multiple bindings can be set. Default binding is "T". set_session_wizard_options() { - local key_bindings - key_bindings=$(get_tmux_option "$tmux_option_session_wizard" "$default_key_bindings_session_wizard") - local height - height=$(get_tmux_option "$tmux_option_session_wizard_height" "$default_height") - local width - width=$(get_tmux_option "$tmux_option_session_wizard_width" "$default_width") - local key - for key in $(echo "${key_bindings}" | sed 's/ /\n/g'); do - tmux bind "$key" display-popup -w "$width"% -h "$height"% -E "$CURRENT_DIR/session-wizard.sh" - done -} - -get_tmux_option() { - local option=$1 - local default_value=$2 - local option_value - option_value="$(tmux show-option -gqv "$option")" - if [ "$option_value" = "" ]; then - echo "$default_value" - else - echo "$option_value" - fi + local key_bindings + key_bindings=$(get_tmux_option "$tmux_option_session_wizard" "$default_key_bindings_session_wizard") + local height + height=$(get_tmux_option "$tmux_option_session_wizard_height" "$default_height") + local width + width=$(get_tmux_option "$tmux_option_session_wizard_width" "$default_width") + local key + for key in $(echo "${key_bindings}" | sed 's/ /\n/g'); do + tmux bind "$key" display-popup -w "$width"% -h "$height"% -E "$CURRENT_DIR/bin/t" + done } function main { - set_session_wizard_options + set_session_wizard_options } main diff --git a/src/helpers.sh b/src/helpers.sh new file mode 100644 index 0000000..d9b889d --- /dev/null +++ b/src/helpers.sh @@ -0,0 +1,44 @@ +_normalize() { + cat | tr ' .:' '-' | tr '[:upper:]' '[:lower:]' +} + +# helper functions +get_tmux_option() { + local option="$1" + local default_value="$2" + local option_value + option_value=$(tmux show-option -gqv "$option") + if [ -z "$option_value" ]; then + echo "$default_value" + else + echo "$option_value" + fi +} + +session_name() { + if [ "$1" = "--directory" ]; then + shift + basename "$@" | _normalize + elif [ "$1" = "--full-path" ]; then + shift + echo "$@" | _normalize | sed 's/\/$//' + elif [ "$1" = "--short-path" ]; then + shift + echo "$(echo "${@%/*}" | sed -r 's;/([^/]{1,2})[^/]*;/\1;g' | _normalize)/$(basename "$@" | _normalize)" + else + echo "Wrong argument, you can use --directory, --full-path or --short-path, got $1" + return 1 + fi +} + +HOME_REPLACER="" # default to a noop +echo "$HOME" | grep -E "^[a-zA-Z0-9\-_/.@]+$" &>/dev/null # chars safe to use in sed +HOME_SED_SAFE=$? +if [ $HOME_SED_SAFE -eq 0 ]; then # $HOME should be safe to use in sed + HOME_REPLACER="s|^$HOME|~|" +fi + +__fzfcmd() { + [ -n "$TMUX_PANE" ] && { [ "${FZF_TMUX:-0}" != 0 ] || [ -n "$FZF_TMUX_OPTS" ]; } && + echo "fzf-tmux ${FZF_TMUX_OPTS:--d${FZF_TMUX_HEIGHT:-40%}} -- " || echo "fzf" +} diff --git a/tests/helpers.bats b/tests/helpers.bats new file mode 100644 index 0000000..d140235 --- /dev/null +++ b/tests/helpers.bats @@ -0,0 +1,51 @@ +setup() { + bats_load_library 'bats-support' + bats_load_library 'bats-assert' + DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" >/dev/null 2>&1 && pwd)" + SRC_DIR="$DIR/../src" + source "$SRC_DIR/helpers.sh" + TEST_PATH="/MOO/.foo BAR/.moo FOO-bar.baz" +} + +unset() { + unset TEST_PATH +} + +# TODO: use better stubbing for tmux (tmux show-option) +@test "get tmux option with default value" { + # stub tmux + function tmux() { + assert_equal "$1" "show-option" + assert_equal "$3" "moo-foo-bar" + } + run get_tmux_option "moo-foo-bar" "bar" + assert_output "bar" +} + +@test "get tmux option with value" { + # stub tmux + function tmux() { + assert_equal "$1" "show-option" + assert_equal "$3" "moo-foo-bar" + # option value is set to "foo" + echo "foo" + } + run get_tmux_option "moo-foo-bar" "bar" + assert_output "foo" +} + +@test "create session name with last directory in path" { + run session_name --directory "$TEST_PATH" + assert_output "-moo-foo-bar-baz" +} + +@test "create session name with full path" { + run session_name --full-path "$TEST_PATH" + assert_output "/moo/-foo-bar/-moo-foo-bar-baz" +} + +@test "create session name with shortened path and last directory in path" { + run session_name --short-path "$TEST_PATH" + assert_output "/mo/-f/-moo-foo-bar-baz" +} +