VSCode Workspaces to Tmux Session

Is there a way to work with VSCode workspaces in a Tmux session? I've found a way to do it and work with Vim in each workspace.

  ·   8 min read

Intro

In my previous post, I’ve shown how I set up a Working Environment Bootstrapper to code with Neovim for my Go projects. Now, my team usually works with VSCode using workspaces to manage the monorepos. Since I set the working environment, I’ve been wondering if there is a way to use the workspaces with Neovim.

Now a thought hit me: I was trying to replicate the VSCode model, but maybe it was not necessary to do so. Actually, I have a way to make the most of my Tmux setup. Let me recap.

Tmux allows you to handle different sessions with multiple windows (or tabs) for the session. So, my idea was to have the workspace in a Tmux session, and each folder specified in the VSCode workspace file would become a window tab in that Tmux session. This would allow me to switch between windows to navigate through the folders inside the workspace.

Now, the question is, what if I have many workspaces and I want to switch between them? One of the Tmux plugins I have in my bootstrapper is tmux-resurrect. This one allows me to store a Tmux set of sessions and their internal windows.

This, of course, would not only allow me to switch between windows and sessions, but also to save them. This way, if I reboot my laptop or simply want to load different workspaces, I could do that by invoking tmux-resurrect to load them back.

Showcase

I’ve prepared this showcase to demonstrate precisely how I can make use of it.

What’s happening here?

  • 00:14: We start a normal Tmux session on any random folder.
  • 00:31: We detach the current session (it’s still running in the background).
  • 00:41: We go to the sample VSCode-based project and we check the content of the workspace file.
  • 00:52: We invoke the converter. This will turn create the Tmux session based on the folders definition from the workspace file.
  • 00:58: The Tmux session has a set of windows that were created upon the workspace file definition.
  • 01:42: Using <Ctrl> + b, s (the Tmux prefix and the target key) we can choose between sessions and their set of windows.
  • 01:51: Using <Ctrl> + b, <Ctrl> + s we invoke tmux-resurrect to save the current set of sessions.
  • 02:12: All the Tmux session have been terminated. There is none to attach to.
  • 02:20: We open a new empty Tmux session and then using <Ctrl> + b, <Ctrl> + r we restore the latest Tmux set of sessions back.

The Converter

Right now, the converter is the only utility shell script I have in the bin folder (later I’m sure I’ll add more). Let’s check what it does.

Shebang & safety

1#!/usr/bin/env bash
2set -euo pipefail
  • Shebang: run with the first bash in PATH (portable).
  • set -euo pipefail:
    • -e: exit on any non-zero command.
    • -u: error on unset variables.
    • -o pipefail: a pipeline fails if any command in it fails (not just the last).

Usage helper

1usage() {
2  cat <<'EOF'
3  ...
4EOF
5}
  • A function that prints help text.
  • The single-quoted heredoc (<<'EOF') prevents variable/command expansion inside the block.

Dependency checks

1require() { command -v "$1" >/dev/null 2>&1 || {
2  echo "Missing dependency: $1" >&2
3  exit 1
4}; }
5require tmux
6require jq
7command -v perl >/dev/null 2>&1 || { ... }
  • require <cmd> fails early if a command isn’t installed.
  • This script needs: tmux, jq, and perl.

JSONC : JSON (VS Code workspace files)

1jsonc_to_json() {
2  perl -0777 -pe '
3    s{/\*.*?\*/}{}gs;        # remove /* ... */ comments
4    s{//[^\n\r]*}{}g;        # remove // comments
5    s/,\s*([}\]])/\1/g;      # strip trailing commas
6  ' "$1"
7}

VSCode’s *.code-workspace is JSON with comments & trailing commas (JSONC). jq only accepts strict JSON, so this function:

  • Reads the whole file (-0777).
  • Removes /* ... */ and // ... comments.
  • Removes trailing commas before } or ].

Result: clean JSON text you can pipe to jq.

Utility helpers

1sanitize() { echo "$1" | tr ' ' '-' | tr -cd '[:alnum:]_-'; }
  • Turns any string into a tmux-safe session name:
  • spaces: dashes
  • keep only letters/digits/_/-
1resolve_path() {
2  local base="$1" p="$2"
3  case "$p" in
4  ~/*) p="${p/#\~/$HOME}" ;;   # expand ~/
5  /*) ;;                       # absolute path:  keep as is
6  *) p="$base/$p" ;;           # relative:  make it absolute w.r.t. base
7  esac
8  (cd "$p" 2>/dev/null && pwd) || echo ""
9}

Normalizes folder paths from the workspace file:

  • Handles ~/..., absolute, or relative paths (relative to the workspace file’s directory).
  • Returns an absolute path or empty string on failure.

Argument parsing

 1WS_FILE=""
 2RUN_CMD=""
 3ATTACH=1
 4OVERRIDE_SESSION=""
 5
 6while [[ $# -gt 0 ]]; do
 7  case "$1" in
 8    -h|--help) usage; exit 0 ;;
 9    --no-attach) ATTACH=0; shift ;;
10    --cmd) RUN_CMD="${2:-}"; shift 2 ;;
11    --session) OVERRIDE_SESSION="${2:-}"; shift 2 ;;
12    *.code-workspace) WS_FILE="$1"; shift ;;
13    *) echo "Unknown arg: $1" >&2; usage; exit 1 ;;
14  esac
15done

Supports:

  • codews2tmux <file.code-workspace>
  • --cmd 'nvim .': run this in each new window unless the folder overrides it
  • --no-attach: don’t attach at the end (just create session)
  • --session NAME: force a specific tmux session name

Locate a workspace file if none given

 1if [[ -z "${WS_FILE}" ]]; then
 2  mapfile -t CANDIDATES < <(ls -1 *.code-workspace 2>/dev/null || true)
 3  ...
 4  if command -v fzf >/dev/null 2>&1; then
 5    WS_FILE="$(printf '%s\n' "${CANDIDATES[@]}" | fzf ...)"
 6  else
 7    # print list and exit
 8  fi
 9fi
10[[ -f "$WS_FILE" ]] || { echo "Workspace file not found"; exit 1; }
  • If no file was passed, it searches current directory.
  • If multiple found and fzf exists: interactive picker.
  • Otherwise it prints choices and exits.
  • mapfile reads command output into an array (CANDIDATES).

Parse workspace JSON safely

1JSON="$(jsonc_to_json "$WS_FILE")"
2WS_DIR="$(cd "$(dirname "$WS_FILE")" && pwd)"
  • JSON is the cleaned JSON text (not a file path).
  • WS_DIR is the directory containing the workspace file.
1RAW_NAME="$(jq -r '.name // empty' <<<"$JSON")"
2if [[ -z "$RAW_NAME" ]]; then
3  RAW_NAME="$(basename "$WS_FILE")"
4  RAW_NAME="${RAW_NAME%.*}"
5fi
6SESSION="${OVERRIDE_SESSION:-$(sanitize "$RAW_NAME")}"
7[[ -n "$SESSION" ]] || SESSION="ws"
  • Workspace session name comes from .name or filename (sans extension).
  • Sanitized for tmux (no spaces/specials).
  • --session overrides this.
1FOLDERS_JSON=()
2while IFS= read -r line; do FOLDERS_JSON+=("$line"); done < <(jq -c '.folders[]?' <<<"$JSON")
  • Extracts each .folders[] entry as a compact JSON line into the FOLDERS_JSON array.
  • -c gives one JSON object per line — perfect for arrays.

tmux session handling

1if tmux has-session -t "$SESSION" 2>/dev/null; then
2  echo "tmux session '$SESSION' already exists."
3  [[ $ATTACH -eq 1 ]] && exec tmux attach -t "$SESSION"
4  exit 0
5fi
  • If the session already exists: optional attach, then exit.
  • exec tmux attach ... replaces the current process (clean exit after you detach).

Build windows from workspace folders

 1first=1
 2win_index=0
 3
 4for fjson in "${FOLDERS_JSON[@]}"; do
 5  PATH_VAL="$(jq -r '.path // empty' <<<"$fjson")"
 6  ...
 7  ABS_DIR="$(resolve_path "$WS_DIR" "$PATH_VAL")"
 8  ...
 9  WIN_NAME="$(jq -r '.name // .label // empty' <<<"$fjson")"
10  [[ -n "$WIN_NAME" ]] || WIN_NAME="$(basename "$ABS_DIR")"
11
12  FOLDER_CMD="$(jq -r '."x-tmux-cmd" // empty' <<<"$fjson")"
13  SKIP="$(jq -r '."x-tmux-skip" // false' <<<"$fjson")"
14  [[ "$SKIP" == "true" ]] && { echo "Skipping ..."; continue; }
15
16  if [[ $first -eq 1 ]]; then
17    tmux new-session -d -s "$SESSION" -n "$WIN_NAME" -c "$ABS_DIR"
18    if [[ -n "$FOLDER_CMD" ]]; then
19      tmux send-keys -t "$SESSION:0.0" "$FOLDER_CMD" C-m
20    elif [[ -n "$RUN_CMD" ]]; then
21      tmux send-keys -t "$SESSION:0.0" "$RUN_CMD" C-m
22    fi
23    first=0
24  else
25    tmux new-window -t "$SESSION:" -n "$WIN_NAME" -c "$ABS_DIR"
26    if [[ -n "$FOLDER_CMD" ]]; then
27      tmux send-keys -t "$SESSION:$win_index.0" "$FOLDER_CMD" C-m
28    elif [[ -n "$RUN_CMD" ]]; then
29      tmux send-keys -t "$SESSION:$win_index.0" "$RUN_CMD" C-m
30    fi
31  fi
32
33  win_index=$((win_index + 1))
34done
35
36tmux select-window -t "$SESSION:0"
37echo "Created tmux session: $SESSION"
38[[ $ATTACH -eq 1 ]] && exec tmux attach -t "$SESSION"

For each folder object:

  • path: resolved to absolute dir (works with relative, absolute, or ~/...).
  • Window name from name or label (fallback: directory basename).

Per-folder overrides:

  • "x-tmux-cmd": "nvim .": send this command to the window on creation.
  • "x-tmux-skip": true: skip creating that window.
  • First folder creates the session (tmux new-session -d), next ones create windows in it.
  • Sends ENTER (C-m) after the command to execute it.
  • Tracks win_index so it can address SESSION:<index>.0 cleanly.
  • Selects window 0 at the end and optionally attaches to the session.

How to run

# auto-discover workspace in current dir
codews2tmux

# pick a workspace with fzf if multiple exist
codews2tmux

# explicit file, set a default command for each window, don't attach
codews2tmux ./foo.code-workspace --cmd 'nvim .' --no-attach

# override session name
codews2tmux ./foo.code-workspace --session demo

Conclusion

Despite not having done proper research on available Neovim plugins to handle the VSCode workspace problem, I think I’m satisfied with the outcome. I have a decent way to work where I can load as many workspaces as I need and save them into a tmux-resurrect set of sessions. This way, I can resume my work by loading the stuff I was working with.

For future work, I think I can improve this by setting up sets of sessions based on different contexts. For instance, if I’m working with a set of three workspaces for a given situation, I could load just those three. But also, at the same time, I might need to load a different set of workspaces (for example, if I’m studying rather than working), and I could load only the sessions that make sense for that.

I’ll share my progress on that if I make it happen.