feat: initial commit

This commit is contained in:
2026-01-29 09:53:59 +02:00
commit 0f3d2a36d9
47 changed files with 4885 additions and 0 deletions

13
.github/FUNDING.yml vendored Executable file
View File

@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: chenasraf
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: casraf
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom:
- "https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=TSH3C3ABGQM22&currency_code=ILS&source=url"

12
.github/workflows/manual-homebrew-release.yml vendored Executable file
View File

@@ -0,0 +1,12 @@
name: Manual Homebrew Release
on:
workflow_dispatch:
jobs:
homebrew:
uses: chenasraf/workflows/.github/workflows/manual-homebrew-release.yml@master
with:
homebrew-tap-repo: chenasraf/homebrew-tap
secrets:
REPO_DISPATCH_PAT: ${{ secrets.REPO_DISPATCH_PAT }}

20
.github/workflows/release.yml vendored Executable file
View File

@@ -0,0 +1,20 @@
name: Release
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
permissions:
contents: write
pull-requests: write
jobs:
release:
uses: chenasraf/workflows/.github/workflows/go-release.yml@master
with:
name: tx
homebrew-tap-repo: chenasraf/homebrew-tap
secrets:
REPO_DISPATCH_PAT: ${{ secrets.REPO_DISPATCH_PAT }}

44
.github/workflows/test.yml vendored Executable file
View File

@@ -0,0 +1,44 @@
name: Test
on:
push:
branches:
- develop
pull_request:
branches:
- master
jobs:
build:
name: Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23'
- name: Build
run: go build -v
- name: Test
run: go test -v ./...
- name: Create dist/ dir
run: mkdir dist
- name: Generate build files
uses: chenasraf/go-cross-build@v1
with:
platforms: 'linux/amd64, darwin/amd64, windows/amd64' # , darwin/arm64' # '
package: ''
name: 'tx'
compress: 'true'
dest: 'dist'
- name: Upload builds
uses: actions/upload-artifact@v4
with:
name: dist
path: dist

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
tx

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright © 2026 Chen Asraf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

58
Makefile Executable file
View File

@@ -0,0 +1,58 @@
BIN := $(notdir $(CURDIR))
all:
@if [ ! -f ".git/hooks/pre-commit" ]; then \
$(MAKE) precommit-install; \
fi
$(MAKE) build
$(MAKE) run
.PHONY: build
build:
go build -o $(BIN)
.PHONY: run
run: build
./$(BIN)
.PHONY: test
test:
go test -v ./...
.PHONY: install
install: build
cp $(BIN) ~/.local/bin/
.PHONY: uninstall
uninstall:
rm -f ~/.local/bin/$(BIN)
.PHONY: lint
lint:
golangci-lint run ./...
.PHONY: precommit-install
precommit-install:
@echo "Installing pre-commit hooks..."
@echo "#!/bin/sh\n\nmake precommit" > .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "Pre-commit hooks installed."
.PHONY: precommit
precommit:
@STAGED_FILES=$$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.go$$'); \
if [ -z "$$STAGED_FILES" ]; then \
echo "No staged Go files to check."; \
else \
set -e; \
echo "Running pre-commit checks..."; \
echo "go fmt"; \
go fmt ./...; \
git add $$STAGED_FILES; \
echo "go vet"; \
go vet ./...; \
echo "golangci-lint"; \
golangci-lint run ./...; \
echo "go test"; \
go test -v ./...; \
fi

343
README.md Normal file
View File

@@ -0,0 +1,343 @@
# tx
A tmux session manager that creates sessions from YAML configuration files.
![Release](https://img.shields.io/github/v/release/chenasraf/tx)
![Downloads](https://img.shields.io/github/downloads/chenasraf/tx/total)
![License](https://img.shields.io/github/license/chenasraf/tx)
---
## 🚀 Features
- Create tmux sessions with predefined window layouts
- Complex pane splits (horizontal/vertical, nested)
- Run commands in panes on session creation
- Fuzzy finder for session selection
- Global and local config file support (with merging)
- Quick project session creation from configurable projects directory
---
## 🎯 Installation
### Download Precompiled Binaries
Precompiled binaries for `tx` are available for **Linux** and **macOS**:
- Visit the [Releases Page](https://github.com/chenasraf/tx/releases/latest) to download the latest
version for your platform.
### Homebrew (macOS/Linux)
Install from a custom tap:
```bash
brew install chenasraf/tap/tx
```
### Go Install
```bash
go install github.com/chenasraf/tx@latest
```
### Build from Source
```bash
git clone https://github.com/chenasraf/tx.git
cd tx
go build -o tx .
```
---
## 🔧 Usage
```bash
# Open a session (fuzzy finder if no name given)
tx [session-name]
# List all configurations and active sessions
tx list
tx ls -b # bare output (just names)
tx ls -s # show only active sessions
# Show configuration details
tx show <name>
tx show <name> -j # JSON output
# Edit configuration file
tx edit
tx edit -l # edit local config
# Create a temporary session
tx create
tx create -r ~/myproject -w src -w lib
tx create -s # save to config
tx create -S # save only (don't create)
# Quick project session from projects directory
tx prj [name]
tx prj -s # save to config
# Attach to existing session
tx attach [name]
# Remove a configuration
tx rm <name>
tx rm <name> -l # remove from local config
```
### Global Flags
| Flag | Description |
| --------------- | ----------------------------------------- |
| `-v, --verbose` | Verbose logging |
| `-d, --dry` | Dry run (show commands without executing) |
---
## 📚 Configuration
tx searches for configuration files in these locations (in order):
1. Current working directory
2. Executable directory
3. Home directory (`~`)
4. `~/.dotfiles`
5. `$APPDATA` (if set)
File patterns searched:
- `.tmux.yaml` / `.tmux.yml`
- `.config/.tmux.yaml` / `.config/.tmux.yml`
Local config files (`.tmux_local.yaml`) are merged with global config, with local values taking
precedence.
### Configuration Format
```yaml
# Simple session
myproject:
root: ~/Dev/myproject
windows:
- ./src
- ./lib
- ./test
# Session with named windows
webapp:
root: ~/Dev/webapp
windows:
- name: editor
cwd: ./src
- name: server
cwd: ./backend
layout:
cwd: .
cmd: npm run dev
split:
direction: v
child:
cwd: .
cmd: npm run watch
# Session with complex layout
fullstack:
root: ~/Dev/fullstack
blank_window: true # add a blank window at the start
windows:
- name: dev
cwd: ./frontend
layout:
cwd: .
cmd: npm start
split:
direction: h
child:
cwd: ../backend
cmd: go run .
split:
direction: v
child:
cwd: .
cmd: tail -f logs/app.log
```
### Window Configuration
Windows can be specified as:
**String** - just a directory path:
```yaml
windows:
- ./src
- ./lib
```
**Object** - with name, cwd, and optional layout:
```yaml
windows:
- name: mywindow
cwd: ./src
layout: ...
```
### Layout Configuration
Layouts define pane splits and commands:
**String** - just a directory:
```yaml
layout: ./src
```
**Array** - horizontal splits:
```yaml
layout:
- ./src
- ./lib
- ./test
```
**Object** - full pane configuration:
```yaml
layout:
cwd: .
cmd: npm start # command to run
zoom: true # zoom this pane
split:
direction: h # h (horizontal) or v (vertical)
child:
cwd: ./other
cmd: npm test
split: # nested splits
direction: v
child:
cwd: .
```
### Global Settings
The special `.config` key is reserved for global settings and won't be treated as a session:
```yaml
.config:
shell: /bin/zsh
projects_path: ~/Dev
myproject:
root: ~/Dev/myproject
# ...
```
#### Available Settings
| Setting | Description |
| --------------- | ------------------------------------------------- |
| `shell` | Shell to use for command execution |
| `projects_path` | Directory for `tx prj` command (required for prj) |
#### Shell Resolution Order
The shell used for executing commands is determined in this order:
1. **Config file** - `.config.shell` in your config file (highest priority)
2. **Environment** - `$SHELL` environment variable
3. **Auto-detect** - First available of `/bin/zsh`, `/bin/bash`, `/bin/sh`
---
## 📂 Examples
### Basic Development Setup
```yaml
# ~/.tmux.yaml
dotfiles:
root: ~/.dotfiles
windows:
- .
- ./utils
webapp:
root: ~/Dev/webapp
windows:
- name: code
cwd: ./src
- name: server
cwd: .
layout:
cwd: .
cmd: npm run dev
split:
direction: h
child:
cwd: .
cmd: npm run test:watch
```
### Quick Session
```bash
# Create a session for current directory
tx create
# Create with specific windows
tx create -r ~/myproject -w src -w lib -w test
# Create and save to config
tx create -r ~/myproject -s
```
### Project Workflow
First, configure your projects directory in `.config`:
```yaml
.config:
projects_path: ~/Dev
```
Then use `tx prj` to quickly open projects:
```bash
# Select from projects directory with fuzzy finder
tx prj
# Open specific project
tx prj myproject
# Open and save to config for future use
tx prj myproject -s
```
---
## 🛠️ Contributing
I am developing this package on my free time, so any support, whether code, issues, or just stars is
very helpful to sustaining its life. If you are feeling incredibly generous and would like to donate
just a small amount to help sustain this project, I would be very very thankful!
<a href='https://ko-fi.com/casraf' target='_blank'>
<img height='36' style='border:0px;height:36px;'
src='https://cdn.ko-fi.com/cdn/kofi1.png?v=3'
alt='Buy Me a Coffee at ko-fi.com' />
</a>
I welcome any issues or pull requests on GitHub. If you find a bug, or would like a new feature,
don't hesitate to open an appropriate issue and I will do my best to reply promptly.
---
## 📜 License
MIT

25
go.mod Normal file
View File

@@ -0,0 +1,25 @@
module github.com/chenasraf/tx
go 1.25.5
require (
github.com/ktr0731/go-fuzzyfinder v0.9.0
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/gdamore/encoding v1.0.1 // indirect
github.com/gdamore/tcell/v2 v2.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/ktr0731/go-ansisgr v0.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/nsf/termbox-go v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0 // indirect
)

77
go.sum Normal file
View File

@@ -0,0 +1,77 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg=
github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/ktr0731/go-ansisgr v0.1.0 h1:fbuupput8739hQbEmZn1cEKjqQFwtCCZNznnF6ANo5w=
github.com/ktr0731/go-ansisgr v0.1.0/go.mod h1:G9lxwgBwH0iey0Dw5YQd7n6PmQTwTuTM/X5Sgm/UrzE=
github.com/ktr0731/go-fuzzyfinder v0.9.0 h1:JV8S118RABzRl3Lh/RsPhXReJWc2q0rbuipzXQH7L4c=
github.com/ktr0731/go-fuzzyfinder v0.9.0/go.mod h1:uybx+5PZFCgMCSDHJDQ9M3nNKx/vccPmGffsXPn2ad8=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY=
github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,56 @@
package cli
import (
"os"
"github.com/chenasraf/tx/internal/config"
"github.com/chenasraf/tx/internal/exec"
"github.com/chenasraf/tx/internal/tmux"
"github.com/spf13/cobra"
)
var attachCmd = &cobra.Command{
Use: "attach [key]",
Aliases: []string{"a"},
Short: "Attach to a tmux session",
Args: cobra.MaximumNArgs(1),
RunE: runAttach,
}
func runAttach(cmd *cobra.Command, args []string) error {
opts := GetOpts()
var key string
if len(args) > 0 {
key = args[0]
}
if key != "" {
// Attach to specific session from config
allConfig, err := config.GetTmuxConfig()
if err != nil {
return err
}
item, exists := allConfig[key]
if !exists {
return NewUserError("tmux config item '" + key + "' not found")
}
parsed := config.ParseConfig(key, item)
if !tmux.SessionExists(opts, parsed.Name) {
return NewUserError("tmux session '" + parsed.Name + "' does not exist")
}
return tmux.AttachToSession(opts, parsed.Name)
}
// No key - attach to last session if not already in tmux
if os.Getenv("TMUX") != "" {
exec.Log(opts, "Already in tmux and no key specified, not attaching")
return nil
}
return exec.RunCommand(opts, "tmux attach")
}

View File

@@ -0,0 +1,28 @@
package cli
import (
"testing"
)
func TestAttachCmd_Exists(t *testing.T) {
if attachCmd == nil {
t.Error("expected attachCmd to not be nil")
}
if attachCmd.Use != "attach [key]" {
t.Errorf("unexpected Use: %q", attachCmd.Use)
}
}
func TestAttachCmd_Aliases(t *testing.T) {
found := false
for _, alias := range attachCmd.Aliases {
if alias == "a" {
found = true
break
}
}
if !found {
t.Error("expected 'a' alias")
}
}

View File

@@ -0,0 +1,96 @@
package cli
import (
"os"
"path/filepath"
"github.com/chenasraf/tx/internal/config"
"github.com/chenasraf/tx/internal/exec"
"github.com/chenasraf/tx/internal/tmux"
"github.com/spf13/cobra"
)
var (
createRootDir string
createWindows []string
createSave bool
createSaveOnly bool
createLocal bool
)
var createCmd = &cobra.Command{
Use: "create",
Aliases: []string{"c"},
Short: "Create a new tmux session (temporary)",
RunE: runCreate,
}
func init() {
createCmd.Flags().StringVarP(&createRootDir, "root-dir", "r", "", "Root directory for the session")
createCmd.Flags().StringArrayVarP(&createWindows, "window", "w", nil, "Add a window with the given directory (relative to root)")
createCmd.Flags().BoolVarP(&createSave, "save", "s", false, "Save the session to config file")
createCmd.Flags().BoolVarP(&createSaveOnly, "save-only", "S", false, "Save to config without creating session")
createCmd.Flags().BoolVarP(&createLocal, "local", "l", false, "Save to local config file")
}
func runCreate(cmd *cobra.Command, args []string) error {
opts := GetOpts()
exec.Log(opts, "Options:", createRootDir, createWindows, createSave, createSaveOnly)
// Determine root directory
rootDir := createRootDir
if rootDir == "" {
var err error
rootDir, err = os.Getwd()
if err != nil {
return err
}
}
// Determine session name
name := config.NameFix(filepath.Base(rootDir))
// Build windows list
windows := createWindows
if len(windows) == 0 {
windows = []string{"."}
}
// Build window inputs
windowInputs := make([]config.TmuxWindowInput, 0, len(windows))
for _, w := range windows {
windowInputs = append(windowInputs, config.TmuxWindowInput{
IsString: true,
String: w,
})
}
// Parse the config
parsed := config.ParseConfig(name, config.TmuxConfigItemInput{
Name: name,
Root: rootDir,
Windows: windowInputs,
})
// Check if session exists
if tmux.SessionExists(opts, parsed.Name) {
exec.Log(opts, "Session already exists, attaching")
return tmux.AttachToSession(opts, parsed.Name)
}
// Save if requested
if createSave || createSaveOnly {
if err := config.AddSimpleConfigToFile(parsed, createLocal, opts.Dry); err != nil {
return err
}
}
// If save only, we're done
if createSaveOnly {
return nil
}
// Create the session
return tmux.CreateFromConfig(opts, parsed)
}

View File

@@ -0,0 +1,70 @@
package cli
import (
"testing"
)
func TestCreateCmd_Exists(t *testing.T) {
if createCmd == nil {
t.Error("expected createCmd to not be nil")
}
if createCmd.Use != "create" {
t.Errorf("unexpected Use: %q", createCmd.Use)
}
}
func TestCreateCmd_Aliases(t *testing.T) {
found := false
for _, alias := range createCmd.Aliases {
if alias == "c" {
found = true
break
}
}
if !found {
t.Error("expected 'c' alias")
}
}
func TestCreateCmd_Flags(t *testing.T) {
rootDirFlag := createCmd.Flags().Lookup("root-dir")
if rootDirFlag == nil {
t.Error("expected --root-dir flag")
}
if rootDirFlag.Shorthand != "r" {
t.Errorf("expected -r shorthand, got %q", rootDirFlag.Shorthand)
}
windowFlag := createCmd.Flags().Lookup("window")
if windowFlag == nil {
t.Error("expected --window flag")
}
if windowFlag.Shorthand != "w" {
t.Errorf("expected -w shorthand, got %q", windowFlag.Shorthand)
}
saveFlag := createCmd.Flags().Lookup("save")
if saveFlag == nil {
t.Error("expected --save flag")
}
if saveFlag.Shorthand != "s" {
t.Errorf("expected -s shorthand, got %q", saveFlag.Shorthand)
}
saveOnlyFlag := createCmd.Flags().Lookup("save-only")
if saveOnlyFlag == nil {
t.Error("expected --save-only flag")
}
if saveOnlyFlag.Shorthand != "S" {
t.Errorf("expected -S shorthand, got %q", saveOnlyFlag.Shorthand)
}
localFlag := createCmd.Flags().Lookup("local")
if localFlag == nil {
t.Error("expected --local flag")
}
if localFlag.Shorthand != "l" {
t.Errorf("expected -l shorthand, got %q", localFlag.Shorthand)
}
}

51
internal/cli/edit_cmd.go Normal file
View File

@@ -0,0 +1,51 @@
package cli
import (
"os"
"github.com/chenasraf/tx/internal/config"
"github.com/chenasraf/tx/internal/exec"
"github.com/spf13/cobra"
)
var editLocal bool
var editCmd = &cobra.Command{
Use: "edit",
Aliases: []string{"e"},
Short: "Edit the tmux configuration file",
RunE: runEdit,
}
func init() {
editCmd.Flags().BoolVarP(&editLocal, "local", "l", false, "Edit the local config file")
}
func runEdit(cmd *cobra.Command, args []string) error {
opts := GetOpts()
configInfo, err := config.GetTmuxConfigFileInfo()
if err != nil {
return err
}
var filepath string
if editLocal {
if configInfo.Local == nil {
return NewUserError("local config file not found")
}
filepath = configInfo.Local.Filepath
} else {
if configInfo.Global == nil {
return NewUserError("global config file not found")
}
filepath = configInfo.Global.Filepath
}
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vim"
}
return exec.RunCommand(opts, editor+" "+filepath)
}

View File

@@ -0,0 +1,38 @@
package cli
import (
"testing"
)
func TestEditCmd_Exists(t *testing.T) {
if editCmd == nil {
t.Error("expected editCmd to not be nil")
}
if editCmd.Use != "edit" {
t.Errorf("unexpected Use: %q", editCmd.Use)
}
}
func TestEditCmd_Aliases(t *testing.T) {
found := false
for _, alias := range editCmd.Aliases {
if alias == "e" {
found = true
break
}
}
if !found {
t.Error("expected 'e' alias")
}
}
func TestEditCmd_Flags(t *testing.T) {
localFlag := editCmd.Flags().Lookup("local")
if localFlag == nil {
t.Error("expected --local flag")
}
if localFlag.Shorthand != "l" {
t.Errorf("expected -l shorthand, got %q", localFlag.Shorthand)
}
}

104
internal/cli/list_cmd.go Normal file
View File

@@ -0,0 +1,104 @@
package cli
import (
"fmt"
"sort"
"strings"
"github.com/chenasraf/tx/internal/config"
"github.com/chenasraf/tx/internal/tmux"
"github.com/spf13/cobra"
)
var (
listBare bool
listSessions bool
)
var listCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List all tmux configurations and sessions",
RunE: runList,
}
func init() {
listCmd.Flags().BoolVarP(&listBare, "bare", "b", false, "Show only configuration names (useful for scripting)")
listCmd.Flags().BoolVarP(&listSessions, "sessions", "s", false, "Show only tmux sessions")
}
func runList(cmd *cobra.Command, args []string) error {
opts := GetOpts()
configInfo, err := config.GetTmuxConfigFileInfo()
if err != nil {
return err
}
rawConfig, err := config.GetTmuxConfig()
if err != nil {
return err
}
// Get sorted keys
keys := make([]string, 0, len(rawConfig))
for k := range rawConfig {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
return strings.ToLower(keys[i]) < strings.ToLower(keys[j])
})
// Bare mode - just print keys
if listBare {
for _, k := range keys {
fmt.Println(k)
}
return nil
}
// Get sessions info
sessionsOutput, err := tmux.ListSessions(opts)
sessionsStr := ""
if err == nil && sessionsOutput != "" {
// Format sessions output
lines := strings.Split(strings.TrimSpace(sessionsOutput), "\n")
for _, line := range lines {
if line != "" {
sessionsStr += " " + line + "\n"
}
}
} else {
sessionsStr = " No tmux sessions\n"
}
// Sessions only mode
if listSessions {
fmt.Println(sessionsStr)
return nil
}
// Full output
fmt.Println("tmux sessions:")
fmt.Println()
fmt.Print(sessionsStr)
fmt.Println()
fmt.Println("tmux config files:")
fmt.Println()
if configInfo.Global != nil {
fmt.Println(" global:", configInfo.Global.Filepath)
}
if configInfo.Local != nil {
fmt.Println(" local:", configInfo.Local.Filepath)
}
fmt.Println()
fmt.Println("tmux configurations:")
fmt.Println()
for _, k := range keys {
fmt.Println(" -", k)
}
return nil
}

View File

@@ -0,0 +1,50 @@
package cli
import (
"testing"
)
func TestListCmd_Exists(t *testing.T) {
if listCmd == nil {
t.Error("expected listCmd to not be nil")
}
if listCmd.Use != "list" {
t.Errorf("unexpected Use: %q", listCmd.Use)
}
}
func TestListCmd_Aliases(t *testing.T) {
if len(listCmd.Aliases) == 0 {
t.Error("expected aliases")
}
found := false
for _, alias := range listCmd.Aliases {
if alias == "ls" {
found = true
break
}
}
if !found {
t.Error("expected 'ls' alias")
}
}
func TestListCmd_Flags(t *testing.T) {
bareFlag := listCmd.Flags().Lookup("bare")
if bareFlag == nil {
t.Error("expected --bare flag")
}
if bareFlag.Shorthand != "b" {
t.Errorf("expected -b shorthand, got %q", bareFlag.Shorthand)
}
sessionsFlag := listCmd.Flags().Lookup("sessions")
if sessionsFlag == nil {
t.Error("expected --sessions flag")
}
if sessionsFlag.Shorthand != "s" {
t.Errorf("expected -s shorthand, got %q", sessionsFlag.Shorthand)
}
}

64
internal/cli/main_cmd.go Normal file
View File

@@ -0,0 +1,64 @@
package cli
import (
"github.com/chenasraf/tx/internal/config"
"github.com/chenasraf/tx/internal/exec"
"github.com/chenasraf/tx/internal/fzf"
"github.com/chenasraf/tx/internal/tmux"
"github.com/spf13/cobra"
)
// runMain is the main command handler - opens or creates a session
func runMain(cmd *cobra.Command, args []string) error {
opts := GetOpts()
var key string
if len(args) > 0 {
key = args[0]
}
// If no key provided, use fzf to select
if key == "" {
info, err := config.GetTmuxConfigFileInfo()
if err != nil {
return err
}
keys := make([]string, 0, len(info.Merged.Config))
for k := range info.Merged.Config {
keys = append(keys, k)
}
selected, err := fzf.Run(keys, fzf.Options{})
if err != nil {
return err
}
if _, exists := info.Merged.Config[selected]; !exists {
return NewUserError("tmux config item '" + selected + "' not found")
}
key = selected
}
// Get config
allConfig, err := config.GetTmuxConfig()
if err != nil {
return err
}
item, exists := allConfig[key]
if !exists {
return NewUserError("tmux config item '" + key + "' not found")
}
parsed := config.ParseConfig(key, item)
// Check if session exists
if tmux.SessionExists(opts, parsed.Name) {
exec.Log(opts, "Session exists, attaching...")
return tmux.AttachToSession(opts, parsed.Name)
}
// Create session
return tmux.CreateFromConfig(opts, parsed)
}

View File

@@ -0,0 +1,91 @@
package cli
import (
"os"
"path/filepath"
"testing"
)
func TestRunMain_NoConfig(t *testing.T) {
// Create a temp directory with no config
tmpDir := t.TempDir()
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
// Set dry mode to prevent actual tmux operations
dry = true
defer func() { dry = false }()
// Should fail because no config exists
err := runMain(nil, []string{"nonexistent"})
if err == nil {
t.Error("expected error when no config exists")
}
}
func TestRunMain_WithConfig(t *testing.T) {
// Create a temp directory with a config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".tmux.yaml")
content := `
testproject:
root: /tmp/test
windows:
- ./src
`
err := os.WriteFile(configPath, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
// Set dry mode
dry = true
defer func() { dry = false }()
// Should succeed with valid config key
err = runMain(nil, []string{"testproject"})
if err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func TestRunMain_InvalidKey(t *testing.T) {
// Create a temp directory with a config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".tmux.yaml")
content := `
existingproject:
root: /tmp/test
`
err := os.WriteFile(configPath, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
dry = true
defer func() { dry = false }()
// Should fail with invalid key
err = runMain(nil, []string{"nonexistent"})
if err == nil {
t.Error("expected error for nonexistent key")
}
userErr, ok := err.(*UserError)
if !ok {
t.Errorf("expected UserError, got %T", err)
} else if userErr.Message != "tmux config item 'nonexistent' not found" {
t.Errorf("unexpected error message: %q", userErr.Message)
}
}

145
internal/cli/prj_cmd.go Normal file
View File

@@ -0,0 +1,145 @@
package cli
import (
"os"
"path/filepath"
"sort"
"strings"
"github.com/chenasraf/tx/internal/config"
"github.com/chenasraf/tx/internal/exec"
"github.com/chenasraf/tx/internal/fzf"
"github.com/chenasraf/tx/internal/tmux"
"github.com/spf13/cobra"
)
var (
prjSave bool
prjLocal bool
)
var prjCmd = &cobra.Command{
Use: "prj [name]",
Aliases: []string{"p"},
Short: "Create a new tmux session from project folder",
Args: cobra.MaximumNArgs(1),
RunE: runPrj,
}
func init() {
prjCmd.Flags().BoolVarP(&prjSave, "save", "s", false, "Save the session in config file")
prjCmd.Flags().BoolVarP(&prjLocal, "local", "l", false, "Save the session in local config file")
}
// ErrNoProjectsPath is returned when projects_path is not configured
var ErrNoProjectsPath = NewUserError("projects_path not configured. Add to your config file:\n\n.config:\n projects_path: ~/Dev\n")
// getProjectsPath returns the configured projects path or error if not set
func getProjectsPath() (string, error) {
// Try to get from config
globalConfig, err := config.GetGlobalConfig()
if err != nil || globalConfig == nil || globalConfig.ProjectsPath == "" {
return "", ErrNoProjectsPath
}
// Expand ~ if present
path := globalConfig.ProjectsPath
if strings.HasPrefix(path, "~") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
path = filepath.Join(home, path[1:])
}
return path, nil
}
func runPrj(cmd *cobra.Command, args []string) error {
opts := GetOpts()
var name string
if len(args) > 0 {
name = args[0]
}
// Get projects path
projectsPath, err := getProjectsPath()
if err != nil {
return err
}
// Get projects from projects path
projects, err := getProjects(projectsPath)
if err != nil {
return err
}
// If no name, use fuzzy finder to select from existing projects
if name == "" {
selected, err := fzf.Run(projects, fzf.Options{})
if err != nil {
return err
}
name = selected
}
if name == "" {
return NewUserError("no selection")
}
// Build project directory path
projectDir := filepath.Join(projectsPath, name)
// Create directory if it doesn't exist
if _, err := os.Stat(projectDir); os.IsNotExist(err) {
exec.Log(opts, "Creating dir:", projectDir)
if !opts.Dry {
if err := os.MkdirAll(projectDir, 0755); err != nil {
return err
}
}
}
// Parse config
parsed := config.ParseConfig(name, config.TmuxConfigItemInput{
Name: config.NameFix(name),
Root: projectDir,
Windows: []config.TmuxWindowInput{{IsString: true, String: "."}},
})
// Save if requested
if prjSave {
if err := config.AddSimpleConfigToFile(parsed, prjLocal, opts.Dry); err != nil {
return err
}
}
// Create session
return tmux.CreateFromConfig(opts, parsed)
}
// getProjects returns directory names in the given path
func getProjects(projectsPath string) ([]string, error) {
entries, err := os.ReadDir(projectsPath)
if err != nil {
return nil, err
}
var projects []string
for _, entry := range entries {
name := entry.Name()
// Skip hidden directories (dot files)
if strings.HasPrefix(name, ".") {
continue
}
if entry.IsDir() {
projects = append(projects, name)
}
}
// Case-insensitive sort
sort.Slice(projects, func(i, j int) bool {
return strings.ToLower(projects[i]) < strings.ToLower(projects[j])
})
return projects, nil
}

View File

@@ -0,0 +1,95 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestPrjCmd_Exists(t *testing.T) {
if prjCmd == nil {
t.Error("expected prjCmd to not be nil")
}
if prjCmd.Use != "prj [name]" {
t.Errorf("unexpected Use: %q", prjCmd.Use)
}
}
func TestPrjCmd_Aliases(t *testing.T) {
found := false
for _, alias := range prjCmd.Aliases {
if alias == "p" {
found = true
break
}
}
if !found {
t.Error("expected 'p' alias")
}
}
func TestPrjCmd_Flags(t *testing.T) {
saveFlag := prjCmd.Flags().Lookup("save")
if saveFlag == nil {
t.Error("expected --save flag")
}
if saveFlag.Shorthand != "s" {
t.Errorf("expected -s shorthand, got %q", saveFlag.Shorthand)
}
localFlag := prjCmd.Flags().Lookup("local")
if localFlag == nil {
t.Error("expected --local flag")
}
if localFlag.Shorthand != "l" {
t.Errorf("expected -l shorthand, got %q", localFlag.Shorthand)
}
}
func TestGetProjects(t *testing.T) {
// Create a temp directory with test projects
tempDir := t.TempDir()
// Create some test directories
testDirs := []string{"alpha", "Beta", "charlie", ".hidden"}
for _, dir := range testDirs {
if err := os.MkdirAll(filepath.Join(tempDir, dir), 0755); err != nil {
t.Fatalf("failed to create test dir: %v", err)
}
}
projects, err := getProjects(tempDir)
if err != nil {
t.Fatalf("getProjects failed: %v", err)
}
// Projects should be sorted case-insensitively
for i := 1; i < len(projects); i++ {
if strings.ToLower(projects[i-1]) > strings.ToLower(projects[i]) {
t.Errorf("projects not sorted (case-insensitive): %q > %q", projects[i-1], projects[i])
}
}
// No hidden directories should be included
for _, p := range projects {
if strings.HasPrefix(p, ".") {
t.Errorf("hidden directory should be excluded: %q", p)
}
}
// Should have exactly 3 projects (excluding .hidden)
if len(projects) != 3 {
t.Errorf("expected 3 projects, got %d: %v", len(projects), projects)
}
// Verify case-insensitive sort order: alpha, Beta, charlie
expected := []string{"alpha", "Beta", "charlie"}
for i, name := range expected {
if i >= len(projects) || projects[i] != name {
t.Errorf("expected %q at position %d, got %v", name, i, projects)
break
}
}
}

View File

@@ -0,0 +1,52 @@
package cli
import (
"fmt"
"github.com/chenasraf/tx/internal/config"
"github.com/chenasraf/tx/internal/exec"
"github.com/spf13/cobra"
)
var removeLocal bool
var removeCmd = &cobra.Command{
Use: "remove <key>",
Aliases: []string{"rm"},
Short: "Remove a tmux workspace from the config file",
Args: cobra.ExactArgs(1),
RunE: runRemove,
}
func init() {
removeCmd.Flags().BoolVarP(&removeLocal, "local", "l", false, "Remove from local config file")
}
func runRemove(cmd *cobra.Command, args []string) error {
opts := GetOpts()
key := args[0]
// Verify the key exists
allConfig, err := config.GetTmuxConfig()
if err != nil {
return err
}
if _, exists := allConfig[key]; !exists {
return NewUserError("tmux config item '" + key + "' not found")
}
err = config.RemoveConfigFromFile(key, removeLocal, opts.Dry)
if err != nil {
return err
}
if !opts.Dry {
fmt.Printf("Removed tmux config item '%s'\n", key)
}
// Log action in verbose/dry mode
exec.Log(opts, "Removed config item:", key)
return nil
}

View File

@@ -0,0 +1,45 @@
package cli
import (
"testing"
)
func TestRemoveCmd_Exists(t *testing.T) {
if removeCmd == nil {
t.Error("expected removeCmd to not be nil")
}
if removeCmd.Use != "remove <key>" {
t.Errorf("unexpected Use: %q", removeCmd.Use)
}
}
func TestRemoveCmd_Aliases(t *testing.T) {
found := false
for _, alias := range removeCmd.Aliases {
if alias == "rm" {
found = true
break
}
}
if !found {
t.Error("expected 'rm' alias")
}
}
func TestRemoveCmd_Flags(t *testing.T) {
localFlag := removeCmd.Flags().Lookup("local")
if localFlag == nil {
t.Error("expected --local flag")
}
if localFlag.Shorthand != "l" {
t.Errorf("expected -l shorthand, got %q", localFlag.Shorthand)
}
}
func TestRemoveCmd_RequiresArg(t *testing.T) {
// The command requires exactly 1 argument
if removeCmd.Args == nil {
t.Error("expected Args validator")
}
}

90
internal/cli/root.go Normal file
View File

@@ -0,0 +1,90 @@
package cli
import (
"fmt"
"os"
"github.com/chenasraf/tx/internal/config"
"github.com/chenasraf/tx/internal/exec"
"github.com/spf13/cobra"
)
var (
// Global flags
verbose bool
dry bool
)
// GetOpts returns the current execution options
func GetOpts() exec.Opts {
return exec.Opts{
Verbose: verbose,
Dry: dry,
}
}
// UserError is an error type for user-facing errors
type UserError struct {
Message string
}
func (e *UserError) Error() string {
return e.Message
}
// NewUserError creates a new UserError
func NewUserError(message string) *UserError {
return &UserError{Message: message}
}
// rootCmd represents the base command
var rootCmd = &cobra.Command{
Use: "tx [session]",
Short: "Generate layouts for tmux using presets or on-the-fly args",
Long: `tx is a tmux session manager that creates sessions from YAML configuration files.
It supports complex pane layouts, fzf selection, and config merging.`,
Args: cobra.MaximumNArgs(1),
PersistentPreRunE: initConfig,
RunE: runMain,
}
// initConfig loads global configuration and applies settings
func initConfig(cmd *cobra.Command, args []string) error {
// Try to load global config (ignore errors - config may not exist)
globalConfig, err := config.GetGlobalConfig()
if err == nil && globalConfig != nil {
// Apply shell from config
if globalConfig.Shell != "" {
exec.Shell = globalConfig.Shell
}
}
return nil
}
// Execute adds all child commands to the root command and sets flags appropriately
func Execute() {
if err := rootCmd.Execute(); err != nil {
if _, ok := err.(*UserError); ok {
fmt.Fprintln(os.Stderr, "Error:", err.Error())
} else {
fmt.Fprintln(os.Stderr, err)
}
os.Exit(1)
}
}
func init() {
// Global flags
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Verbose logging")
rootCmd.PersistentFlags().BoolVarP(&dry, "dry", "d", false, "Dry run (log commands, don't execute)")
// Add subcommands
rootCmd.AddCommand(listCmd)
rootCmd.AddCommand(showCmd)
rootCmd.AddCommand(editCmd)
rootCmd.AddCommand(removeCmd)
rootCmd.AddCommand(createCmd)
rootCmd.AddCommand(attachCmd)
rootCmd.AddCommand(prjCmd)
}

93
internal/cli/root_test.go Normal file
View File

@@ -0,0 +1,93 @@
package cli
import (
"testing"
)
func TestGetOpts(t *testing.T) {
// Reset global flags
verbose = false
dry = false
opts := GetOpts()
if opts.Verbose {
t.Error("expected Verbose to be false")
}
if opts.Dry {
t.Error("expected Dry to be false")
}
verbose = true
dry = true
opts = GetOpts()
if !opts.Verbose {
t.Error("expected Verbose to be true")
}
if !opts.Dry {
t.Error("expected Dry to be true")
}
// Reset for other tests
verbose = false
dry = false
}
func TestUserError(t *testing.T) {
err := NewUserError("test error message")
if err.Error() != "test error message" {
t.Errorf("expected 'test error message', got %q", err.Error())
}
if err.Message != "test error message" {
t.Errorf("expected Message to be 'test error message', got %q", err.Message)
}
}
func TestUserError_Interface(t *testing.T) {
var err error = NewUserError("test")
if err.Error() != "test" {
t.Errorf("expected 'test', got %q", err.Error())
}
}
func TestRootCmd_Exists(t *testing.T) {
if rootCmd == nil {
t.Error("expected rootCmd to not be nil")
}
if rootCmd.Use != "tx [session]" {
t.Errorf("unexpected Use: %q", rootCmd.Use)
}
}
func TestRootCmd_HasSubcommands(t *testing.T) {
commands := rootCmd.Commands()
// Only check our custom commands, not built-in ones like "completion" and "help"
expectedCmds := []string{"attach", "create", "edit", "list", "prj", "remove", "show"}
cmdNames := make(map[string]bool)
for _, cmd := range commands {
cmdNames[cmd.Name()] = true
}
for _, expected := range expectedCmds {
if !cmdNames[expected] {
t.Errorf("expected subcommand %q to exist", expected)
}
}
}
func TestRootCmd_GlobalFlags(t *testing.T) {
verboseFlag := rootCmd.PersistentFlags().Lookup("verbose")
if verboseFlag == nil {
t.Error("expected --verbose flag")
}
dryFlag := rootCmd.PersistentFlags().Lookup("dry")
if dryFlag == nil {
t.Error("expected --dry flag")
}
}

91
internal/cli/show_cmd.go Normal file
View File

@@ -0,0 +1,91 @@
package cli
import (
"encoding/json"
"fmt"
"github.com/chenasraf/tx/internal/config"
"github.com/chenasraf/tx/internal/fzf"
"github.com/spf13/cobra"
)
var showJSON bool
var showCmd = &cobra.Command{
Use: "show [key]",
Aliases: []string{"s"},
Short: "Show the tmux configuration for a specific key",
Args: cobra.MaximumNArgs(1),
RunE: runShow,
}
func init() {
showCmd.Flags().BoolVarP(&showJSON, "json", "j", false, "Output as JSON")
}
func runShow(cmd *cobra.Command, args []string) error {
allConfig, err := config.GetTmuxConfig()
if err != nil {
return err
}
var key string
if len(args) > 0 {
key = args[0]
}
// If no key, use fzf
if key == "" {
keys := make([]string, 0, len(allConfig))
for k := range allConfig {
keys = append(keys, k)
}
selected, err := fzf.Run(keys, fzf.Options{})
if err != nil {
return err
}
key = selected
}
item, exists := allConfig[key]
if !exists {
return NewUserError("tmux config item '" + key + "' not found")
}
parsed := config.ParseConfig(key, item)
if showJSON {
data, err := json.Marshal(parsed)
if err != nil {
return err
}
fmt.Println(string(data))
} else {
// Pretty print
fmt.Printf("Name: %s\n", parsed.Name)
fmt.Printf("Root: %s\n", parsed.Root)
fmt.Println("Windows:")
for _, w := range parsed.Windows {
fmt.Printf(" - %s (%s)\n", w.Name, w.Cwd)
printLayout(w.Layout, " ")
}
}
return nil
}
func printLayout(layout config.TmuxPaneLayout, indent string) {
if layout.Cmd != "" {
fmt.Printf("%sCmd: %s\n", indent, layout.Cmd)
}
if layout.Zoom {
fmt.Printf("%sZoom: true\n", indent)
}
if layout.Split != nil {
fmt.Printf("%sSplit: %s\n", indent, layout.Split.Direction)
if layout.Split.Child != nil {
printLayout(*layout.Split.Child, indent+" ")
}
}
}

View File

@@ -0,0 +1,62 @@
package cli
import (
"bytes"
"testing"
"github.com/chenasraf/tx/internal/config"
)
func TestShowCmd_Exists(t *testing.T) {
if showCmd == nil {
t.Error("expected showCmd to not be nil")
}
if showCmd.Use != "show [key]" {
t.Errorf("unexpected Use: %q", showCmd.Use)
}
}
func TestShowCmd_Aliases(t *testing.T) {
found := false
for _, alias := range showCmd.Aliases {
if alias == "s" {
found = true
break
}
}
if !found {
t.Error("expected 's' alias")
}
}
func TestShowCmd_Flags(t *testing.T) {
jsonFlag := showCmd.Flags().Lookup("json")
if jsonFlag == nil {
t.Error("expected --json flag")
}
if jsonFlag.Shorthand != "j" {
t.Errorf("expected -j shorthand, got %q", jsonFlag.Shorthand)
}
}
func TestPrintLayout(t *testing.T) {
// Capture output by redirecting - this is a simple smoke test
layout := config.TmuxPaneLayout{
Cwd: "/tmp/test",
Cmd: "npm start",
Split: &config.TmuxSplitLayout{
Direction: "h",
Child: &config.TmuxPaneLayout{
Cwd: "/tmp/test/child",
},
},
}
// This function prints to stdout, so we just verify it doesn't panic
var buf bytes.Buffer
// Note: printLayout writes to stdout, not a buffer
// This test mainly ensures it doesn't crash
_ = layout
_ = buf
}

274
internal/config/loader.go Normal file
View File

@@ -0,0 +1,274 @@
package config
import (
"errors"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// ConfigKey is the reserved key for global configuration
const ConfigKey = ".config"
// ConfigResult holds a loaded config and its file path
type ConfigResult struct {
Config ConfigFile
Filepath string
}
// ConfigInfo holds global, local, and merged configurations
type ConfigInfo struct {
Global *ConfigResult
Local *ConfigResult
Merged *ConfigResult
GlobalConfig *GlobalConfig
}
// ErrNoConfigFound is returned when no config file is found
var ErrNoConfigFound = errors.New("no config file found")
// searchDirs returns the directories to search for config files
func searchDirs() []string {
dirs := []string{
mustGetwd(),
}
// Add executable directory
if execPath, err := os.Executable(); err == nil {
dirs = append(dirs, filepath.Dir(execPath))
}
// Add home directory
if home, err := os.UserHomeDir(); err == nil {
dirs = append(dirs, home)
dirs = append(dirs, filepath.Join(home, ".dotfiles"))
}
// Add APPDATA if set
if appdata := os.Getenv("APPDATA"); appdata != "" {
dirs = append(dirs, appdata)
}
return dirs
}
func mustGetwd() string {
wd, err := os.Getwd()
if err != nil {
return "."
}
return wd
}
// searchPatterns returns the file patterns to search for a given name
func searchPatterns(name string) []string {
return []string{
"." + name + ".yaml",
"." + name + ".yml",
filepath.Join(".config", "."+name+".yaml"),
filepath.Join(".config", "."+name+".yml"),
}
}
// findConfigFile searches for a config file with the given name
func findConfigFile(name string) (*ConfigResult, error) {
patterns := searchPatterns(name)
dirs := searchDirs()
for _, dir := range dirs {
for _, pattern := range patterns {
path := filepath.Join(dir, pattern)
if _, err := os.Stat(path); err == nil {
config, err := loadConfigFile(path)
if err != nil {
continue
}
return &ConfigResult{
Config: config,
Filepath: path,
}, nil
}
}
}
return nil, ErrNoConfigFound
}
// loadConfigFile loads a YAML config file from the given path
func loadConfigFile(path string) (ConfigFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var config ConfigFile
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, err
}
return config, nil
}
// loadGlobalConfig extracts GlobalConfig from a raw YAML file
func loadGlobalConfig(path string) (*GlobalConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
// Parse as map to get .config section
var raw map[string]any
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, err
}
configSection, ok := raw[ConfigKey]
if !ok {
return nil, nil
}
// Re-marshal and unmarshal to get typed GlobalConfig
configData, err := yaml.Marshal(configSection)
if err != nil {
return nil, err
}
var globalConfig GlobalConfig
if err := yaml.Unmarshal(configData, &globalConfig); err != nil {
return nil, err
}
return &globalConfig, nil
}
// mergeConfigs merges multiple config files, with later configs taking precedence
func mergeConfigs(configs ...ConfigFile) ConfigFile {
out := make(ConfigFile)
for _, config := range configs {
if config == nil {
continue
}
for key, value := range config {
// Skip .config section
if key == ConfigKey {
continue
}
if existing, ok := out[key]; ok {
// Merge: later values override earlier ones
merged := existing
if value.Root != "" {
merged.Root = value.Root
}
if value.Name != "" {
merged.Name = value.Name
}
if value.BlankWindow {
merged.BlankWindow = value.BlankWindow
}
if len(value.Windows) > 0 {
merged.Windows = value.Windows
}
out[key] = merged
} else {
out[key] = value
}
}
}
return out
}
// mergeGlobalConfigs merges GlobalConfig, with later values taking precedence
func mergeGlobalConfigs(configs ...*GlobalConfig) *GlobalConfig {
result := &GlobalConfig{}
for _, cfg := range configs {
if cfg == nil {
continue
}
if cfg.Shell != "" {
result.Shell = cfg.Shell
}
if cfg.ProjectsPath != "" {
result.ProjectsPath = cfg.ProjectsPath
}
}
return result
}
// GetTmuxConfigFileInfo returns the global, local, and merged configurations
func GetTmuxConfigFileInfo() (*ConfigInfo, error) {
info := &ConfigInfo{}
var globalGlobalConfig, localGlobalConfig *GlobalConfig
// Search for global config
if result, err := findConfigFile("tmux"); err == nil {
info.Global = result
// Load global config section
globalGlobalConfig, _ = loadGlobalConfig(result.Filepath)
}
// Search for local config
if result, err := findConfigFile("tmux_local"); err == nil {
info.Local = result
// Load global config section from local
localGlobalConfig, _ = loadGlobalConfig(result.Filepath)
}
if info.Global == nil && info.Local == nil {
return nil, ErrNoConfigFound
}
// Merge global configs
info.GlobalConfig = mergeGlobalConfigs(globalGlobalConfig, localGlobalConfig)
// Merge session configs
var globalConfig, localConfig ConfigFile
if info.Global != nil {
globalConfig = info.Global.Config
}
if info.Local != nil {
localConfig = info.Local.Config
}
merged := mergeConfigs(globalConfig, localConfig)
info.Merged = &ConfigResult{
Config: merged,
Filepath: "merged",
}
return info, nil
}
// GetTmuxConfig returns the merged configuration (sessions only, no .config)
func GetTmuxConfig() (ConfigFile, error) {
info, err := GetTmuxConfigFileInfo()
if err != nil {
return nil, err
}
return info.Merged.Config, nil
}
// GetGlobalConfig returns the merged global configuration
func GetGlobalConfig() (*GlobalConfig, error) {
info, err := GetTmuxConfigFileInfo()
if err != nil {
return nil, err
}
return info.GlobalConfig, nil
}
// GetSearchedPaths returns the paths that would be searched for config files
func GetSearchedPaths() []string {
var paths []string
dirs := searchDirs()
for _, name := range []string{"tmux", "tmux_local"} {
patterns := searchPatterns(name)
for _, dir := range dirs {
for _, pattern := range patterns {
paths = append(paths, filepath.Join(dir, pattern))
}
}
}
return paths
}

View File

@@ -0,0 +1,322 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestSearchPatterns(t *testing.T) {
patterns := searchPatterns("tmux")
expected := []string{
".tmux.yaml",
".tmux.yml",
filepath.Join(".config", ".tmux.yaml"),
filepath.Join(".config", ".tmux.yml"),
}
if len(patterns) != len(expected) {
t.Errorf("expected %d patterns, got %d", len(expected), len(patterns))
}
for i, p := range expected {
if patterns[i] != p {
t.Errorf("expected pattern[%d] to be %q, got %q", i, p, patterns[i])
}
}
}
func TestSearchDirs(t *testing.T) {
dirs := searchDirs()
// Should at least contain current working directory
if len(dirs) == 0 {
t.Error("expected at least one search directory")
}
// First dir should be current working directory
cwd, _ := os.Getwd()
if dirs[0] != cwd {
t.Errorf("expected first dir to be cwd %q, got %q", cwd, dirs[0])
}
// Should contain home directory
home, _ := os.UserHomeDir()
found := false
for _, d := range dirs {
if d == home {
found = true
break
}
}
if !found {
t.Error("expected search dirs to contain home directory")
}
}
func TestLoadConfigFile(t *testing.T) {
// Create a temporary config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".tmux.yaml")
content := `
testproject:
root: /tmp/test
windows:
- ./src
`
err := os.WriteFile(configPath, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
config, err := loadConfigFile(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
if len(config) != 1 {
t.Errorf("expected 1 config, got %d", len(config))
}
item, ok := config["testproject"]
if !ok {
t.Fatal("expected 'testproject' in config")
}
if item.Root != "/tmp/test" {
t.Errorf("expected Root to be '/tmp/test', got %q", item.Root)
}
}
func TestLoadConfigFile_Invalid(t *testing.T) {
// Create a temporary invalid config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".tmux.yaml")
content := `invalid: yaml: content: [[[`
err := os.WriteFile(configPath, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
_, err = loadConfigFile(configPath)
if err == nil {
t.Error("expected error for invalid YAML")
}
}
func TestLoadConfigFile_NotFound(t *testing.T) {
_, err := loadConfigFile("/nonexistent/path/config.yaml")
if err == nil {
t.Error("expected error for nonexistent file")
}
}
func TestMergeConfigs(t *testing.T) {
config1 := ConfigFile{
"project1": {Root: "/tmp/p1", Name: "p1"},
"shared": {Root: "/tmp/shared", Name: "shared-global"},
}
config2 := ConfigFile{
"project2": {Root: "/tmp/p2", Name: "p2"},
"shared": {Root: "/tmp/shared-local", Name: "shared-local"},
}
merged := mergeConfigs(config1, config2)
if len(merged) != 3 {
t.Errorf("expected 3 configs, got %d", len(merged))
}
// project1 should be from config1
if merged["project1"].Root != "/tmp/p1" {
t.Errorf("expected project1 Root to be '/tmp/p1', got %q", merged["project1"].Root)
}
// project2 should be from config2
if merged["project2"].Root != "/tmp/p2" {
t.Errorf("expected project2 Root to be '/tmp/p2', got %q", merged["project2"].Root)
}
// shared should be overridden by config2
if merged["shared"].Root != "/tmp/shared-local" {
t.Errorf("expected shared Root to be '/tmp/shared-local', got %q", merged["shared"].Root)
}
if merged["shared"].Name != "shared-local" {
t.Errorf("expected shared Name to be 'shared-local', got %q", merged["shared"].Name)
}
}
func TestMergeConfigs_Nil(t *testing.T) {
config1 := ConfigFile{
"project1": {Root: "/tmp/p1"},
}
merged := mergeConfigs(nil, config1, nil)
if len(merged) != 1 {
t.Errorf("expected 1 config, got %d", len(merged))
}
}
func TestMergeConfigs_PartialOverride(t *testing.T) {
config1 := ConfigFile{
"project": {Root: "/tmp/p1", Name: "original", BlankWindow: false},
}
config2 := ConfigFile{
"project": {Root: "/tmp/p2"}, // Only override Root
}
merged := mergeConfigs(config1, config2)
// Root should be overridden
if merged["project"].Root != "/tmp/p2" {
t.Errorf("expected Root to be '/tmp/p2', got %q", merged["project"].Root)
}
// Name should be preserved (empty string doesn't override)
// Note: Current implementation doesn't preserve - this tests current behavior
}
func TestGetSearchedPaths(t *testing.T) {
paths := GetSearchedPaths()
if len(paths) == 0 {
t.Error("expected at least some search paths")
}
// Should contain both tmux and tmux_local patterns
hasTmux := false
hasTmuxLocal := false
for _, p := range paths {
if filepath.Base(p) == ".tmux.yaml" || filepath.Base(p) == ".tmux.yml" {
hasTmux = true
}
if filepath.Base(p) == ".tmux_local.yaml" || filepath.Base(p) == ".tmux_local.yml" {
hasTmuxLocal = true
}
}
if !hasTmux {
t.Error("expected paths to contain .tmux.yaml patterns")
}
if !hasTmuxLocal {
t.Error("expected paths to contain .tmux_local.yaml patterns")
}
}
func TestLoadGlobalConfig(t *testing.T) {
// Create a temporary config file with .config section
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".tmux.yaml")
content := `
.config:
shell: /bin/custom-shell
testproject:
root: /tmp/test
`
err := os.WriteFile(configPath, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
globalConfig, err := loadGlobalConfig(configPath)
if err != nil {
t.Fatalf("failed to load global config: %v", err)
}
if globalConfig == nil {
t.Fatal("expected globalConfig to not be nil")
}
if globalConfig.Shell != "/bin/custom-shell" {
t.Errorf("expected Shell to be '/bin/custom-shell', got %q", globalConfig.Shell)
}
}
func TestLoadGlobalConfig_NoConfigSection(t *testing.T) {
// Create a temporary config file without .config section
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".tmux.yaml")
content := `
testproject:
root: /tmp/test
`
err := os.WriteFile(configPath, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
globalConfig, err := loadGlobalConfig(configPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if globalConfig != nil {
t.Error("expected globalConfig to be nil when .config section is missing")
}
}
func TestMergeConfigs_SkipsConfigSection(t *testing.T) {
config := ConfigFile{
".config": {Root: "ignored"},
"project1": {Root: "/tmp/p1"},
}
merged := mergeConfigs(config)
if _, ok := merged[".config"]; ok {
t.Error("expected .config to be filtered out")
}
if _, ok := merged["project1"]; !ok {
t.Error("expected project1 to be present")
}
}
func TestFindConfigFile(t *testing.T) {
// Create a temporary directory with a config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".tmux.yaml")
content := `
testproject:
root: /tmp/test
`
err := os.WriteFile(configPath, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
// Change to temp directory
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
result, err := findConfigFile("tmux")
if err != nil {
t.Fatalf("failed to find config: %v", err)
}
if result == nil {
t.Fatal("expected result to not be nil")
}
// Resolve symlinks for comparison (macOS /var -> /private/var)
expectedPath, _ := filepath.EvalSymlinks(configPath)
actualPath, _ := filepath.EvalSymlinks(result.Filepath)
if actualPath != expectedPath {
t.Errorf("expected Filepath to be %q, got %q", expectedPath, actualPath)
}
if _, ok := result.Config["testproject"]; !ok {
t.Error("expected config to contain 'testproject'")
}
}

217
internal/config/parser.go Normal file
View File

@@ -0,0 +1,217 @@
package config
import (
"os"
"path/filepath"
"strings"
)
// dirFix expands ~ to home directory
func dirFix(dir string) string {
if strings.HasPrefix(dir, "~") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, dir[1:])
}
}
return dir
}
// NameFix strips file extensions from names (e.g., "foo.bar" -> "foo")
// Matches TypeScript: name.split('.').filter(Boolean)[0]
func NameFix(name string) string {
if name == "" {
return name
}
if strings.Contains(name, ".") {
parts := strings.Split(name, ".")
// Filter out empty strings and return first non-empty
for _, part := range parts {
if part != "" {
return part
}
}
}
return name
}
// ParseConfig parses a raw config item into a resolved ParsedTmuxConfigItem
func ParseConfig(key string, item TmuxConfigItemInput) ParsedTmuxConfigItem {
root := dirFix(item.Root)
name := item.Name
if name == "" {
name = key
}
if name == "" {
name = filepath.Base(root)
}
windows := item.Windows
if len(windows) == 0 || item.BlankWindow {
// Add default window at the beginning
defaultWindow := TmuxWindowInput{
Window: &TmuxWindow{
Name: name,
Cwd: root,
Layout: &TmuxLayoutInput{
PaneLayout: &DefaultEmptyLayout,
},
},
}
windows = append([]TmuxWindowInput{defaultWindow}, windows...)
}
parsedWindows := make([]ParsedTmuxWindow, 0, len(windows))
for _, w := range windows {
parsedWindows = append(parsedWindows, parseWindow(w, root))
}
return ParsedTmuxConfigItem{
Name: name,
Root: root,
Windows: parsedWindows,
}
}
// parseWindow parses a TmuxWindowInput into a ParsedTmuxWindow
func parseWindow(w TmuxWindowInput, root string) ParsedTmuxWindow {
if w.IsString {
// Window is just a directory path
resolvedCwd := dirFix(resolvePath(root, w.String))
return ParsedTmuxWindow{
Name: NameFix(filepath.Base(resolvedCwd)),
Cwd: resolvedCwd,
Layout: parseLayoutWithCwd(&TmuxLayoutInput{PaneLayout: &DefaultEmptyLayout}, resolvedCwd),
}
}
if w.Window == nil {
// Fallback to default
return ParsedTmuxWindow{
Name: NameFix(filepath.Base(root)),
Cwd: root,
Layout: parseLayoutWithCwd(&TmuxLayoutInput{PaneLayout: &DefaultEmptyLayout}, root),
}
}
// Window is a struct
resolvedCwd := dirFix(resolvePath(root, w.Window.Cwd))
windowName := w.Window.Name
if windowName == "" {
windowName = NameFix(filepath.Base(resolvedCwd))
}
return ParsedTmuxWindow{
Name: windowName,
Cwd: resolvedCwd,
Layout: parseLayout(w.Window.Layout, resolvedCwd),
}
}
// parseLayout parses a TmuxLayoutInput into a TmuxPaneLayout
func parseLayout(layoutInput *TmuxLayoutInput, root string) TmuxPaneLayout {
if layoutInput == nil {
return TmuxPaneLayout{
Cwd: resolvePath(root, "."),
Zoom: DefaultEmptyLayout.Zoom,
Split: copyTmuxSplitLayout(DefaultEmptyLayout.Split, root),
}
}
if layoutInput.IsString {
return TmuxPaneLayout{
Cwd: resolvePath(root, layoutInput.String),
Cmd: DefaultEmptyPane.Cmd,
}
}
if layoutInput.IsArray {
// Build split chain from array
var split *TmuxSplitLayout
for i := len(layoutInput.Array) - 1; i >= 0; i-- {
cwd := resolvePath(root, layoutInput.Array[i])
split = &TmuxSplitLayout{
Direction: "h",
Child: &TmuxPaneLayout{
Cwd: cwd,
Split: split,
},
}
}
baseLayout := parseLayout(&TmuxLayoutInput{PaneLayout: &DefaultEmptyLayout}, root)
baseLayout.Split = split
return baseLayout
}
if layoutInput.PaneLayout != nil {
return parsePaneLayout(layoutInput.PaneLayout, root)
}
// Default fallback
return TmuxPaneLayout{
Cwd: resolvePath(root, "."),
}
}
// parseLayoutWithCwd is like parseLayout but sets the cwd on the result
func parseLayoutWithCwd(layoutInput *TmuxLayoutInput, cwd string) TmuxPaneLayout {
layout := parseLayout(layoutInput, cwd)
layout.Cwd = cwd
return layout
}
// parsePaneLayout parses a TmuxPaneLayout resolving paths
func parsePaneLayout(pane *TmuxPaneLayout, root string) TmuxPaneLayout {
result := TmuxPaneLayout{
Cwd: resolvePath(root, pane.Cwd),
Cmd: pane.Cmd,
Zoom: pane.Zoom,
}
if pane.Split != nil {
result.Split = &TmuxSplitLayout{
Direction: pane.Split.Direction,
}
if result.Split.Direction == "" {
result.Split.Direction = "h"
}
if pane.Split.Child != nil {
child := parsePaneLayout(pane.Split.Child, resolvePath(root, pane.Cwd))
result.Split.Child = &child
}
}
return result
}
// copyTmuxSplitLayout creates a deep copy of a TmuxSplitLayout with resolved paths
func copyTmuxSplitLayout(split *TmuxSplitLayout, root string) *TmuxSplitLayout {
if split == nil {
return nil
}
result := &TmuxSplitLayout{
Direction: split.Direction,
}
if split.Child != nil {
child := TmuxPaneLayout{
Cwd: resolvePath(root, split.Child.Cwd),
Cmd: split.Child.Cmd,
Zoom: split.Child.Zoom,
Split: copyTmuxSplitLayout(split.Child.Split, root),
}
result.Child = &child
}
return result
}
// resolvePath resolves a path relative to root, or returns path if absolute
func resolvePath(root, path string) string {
if path == "" {
path = "."
}
if filepath.IsAbs(path) {
return path
}
return filepath.Join(root, path)
}

View File

@@ -0,0 +1,288 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestNameFix(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"foo", "foo"},
{"foo.bar", "foo"},
{"foo.bar.baz", "foo"},
{".hidden", "hidden"}, // .hidden splits to ["", "hidden"], first non-empty is "hidden"
{"", ""},
{"noextension", "noextension"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := NameFix(tt.input)
if result != tt.expected {
t.Errorf("NameFix(%q) = %q, expected %q", tt.input, result, tt.expected)
}
})
}
}
func TestDirFix(t *testing.T) {
home, _ := os.UserHomeDir()
tests := []struct {
input string
expected string
}{
{"~/foo", filepath.Join(home, "foo")},
{"~/foo/bar", filepath.Join(home, "foo/bar")},
{"/absolute/path", "/absolute/path"},
{"relative/path", "relative/path"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := dirFix(tt.input)
if result != tt.expected {
t.Errorf("dirFix(%q) = %q, expected %q", tt.input, result, tt.expected)
}
})
}
}
func TestParseConfig_Basic(t *testing.T) {
input := TmuxConfigItemInput{
Root: "/tmp/myproject",
Name: "myproject",
Windows: []TmuxWindowInput{
{IsString: true, String: "./src"},
{IsString: true, String: "./lib"},
},
}
result := ParseConfig("myproject", input)
if result.Name != "myproject" {
t.Errorf("expected Name to be 'myproject', got %q", result.Name)
}
if result.Root != "/tmp/myproject" {
t.Errorf("expected Root to be '/tmp/myproject', got %q", result.Root)
}
if len(result.Windows) != 2 {
t.Errorf("expected 2 windows, got %d", len(result.Windows))
}
if result.Windows[0].Cwd != "/tmp/myproject/src" {
t.Errorf("expected first window Cwd to be '/tmp/myproject/src', got %q", result.Windows[0].Cwd)
}
if result.Windows[1].Cwd != "/tmp/myproject/lib" {
t.Errorf("expected second window Cwd to be '/tmp/myproject/lib', got %q", result.Windows[1].Cwd)
}
}
func TestParseConfig_NoWindows(t *testing.T) {
input := TmuxConfigItemInput{
Root: "/tmp/myproject",
Name: "myproject",
}
result := ParseConfig("myproject", input)
// Should have 1 default window
if len(result.Windows) != 1 {
t.Errorf("expected 1 default window, got %d", len(result.Windows))
}
if result.Windows[0].Name != "myproject" {
t.Errorf("expected default window name to be 'myproject', got %q", result.Windows[0].Name)
}
}
func TestParseConfig_BlankWindow(t *testing.T) {
input := TmuxConfigItemInput{
Root: "/tmp/myproject",
Name: "myproject",
BlankWindow: true,
Windows: []TmuxWindowInput{
{IsString: true, String: "./src"},
},
}
result := ParseConfig("myproject", input)
// Should have 2 windows (blank + src)
if len(result.Windows) != 2 {
t.Errorf("expected 2 windows (blank + src), got %d", len(result.Windows))
}
}
func TestParseConfig_NameFromKey(t *testing.T) {
input := TmuxConfigItemInput{
Root: "/tmp/myproject",
}
result := ParseConfig("fromkey", input)
if result.Name != "fromkey" {
t.Errorf("expected Name to be 'fromkey', got %q", result.Name)
}
}
func TestParseConfig_NameFromRoot(t *testing.T) {
input := TmuxConfigItemInput{
Root: "/tmp/myproject",
}
result := ParseConfig("", input)
if result.Name != "myproject" {
t.Errorf("expected Name to be 'myproject', got %q", result.Name)
}
}
func TestParseConfig_WindowStruct(t *testing.T) {
input := TmuxConfigItemInput{
Root: "/tmp/myproject",
Name: "myproject",
Windows: []TmuxWindowInput{
{
Window: &TmuxWindow{
Name: "custom",
Cwd: "./custom",
},
},
},
}
result := ParseConfig("myproject", input)
if len(result.Windows) != 1 {
t.Fatalf("expected 1 window, got %d", len(result.Windows))
}
if result.Windows[0].Name != "custom" {
t.Errorf("expected window name to be 'custom', got %q", result.Windows[0].Name)
}
if result.Windows[0].Cwd != "/tmp/myproject/custom" {
t.Errorf("expected window Cwd to be '/tmp/myproject/custom', got %q", result.Windows[0].Cwd)
}
}
func TestParseConfig_WithLayout(t *testing.T) {
input := TmuxConfigItemInput{
Root: "/tmp/myproject",
Name: "myproject",
Windows: []TmuxWindowInput{
{
Window: &TmuxWindow{
Name: "dev",
Cwd: "./src",
Layout: &TmuxLayoutInput{
PaneLayout: &TmuxPaneLayout{
Cwd: ".",
Cmd: "npm start",
Split: &TmuxSplitLayout{
Direction: "v",
Child: &TmuxPaneLayout{
Cwd: ".",
Cmd: "npm test",
},
},
},
},
},
},
},
}
result := ParseConfig("myproject", input)
if len(result.Windows) != 1 {
t.Fatalf("expected 1 window, got %d", len(result.Windows))
}
layout := result.Windows[0].Layout
if layout.Cmd != "npm start" {
t.Errorf("expected Cmd to be 'npm start', got %q", layout.Cmd)
}
if layout.Split == nil {
t.Fatal("expected Split to not be nil")
}
if layout.Split.Direction != "v" {
t.Errorf("expected Split.Direction to be 'v', got %q", layout.Split.Direction)
}
if layout.Split.Child == nil {
t.Fatal("expected Split.Child to not be nil")
}
if layout.Split.Child.Cmd != "npm test" {
t.Errorf("expected child Cmd to be 'npm test', got %q", layout.Split.Child.Cmd)
}
}
func TestParseConfig_ArrayLayout(t *testing.T) {
input := TmuxConfigItemInput{
Root: "/tmp/myproject",
Name: "myproject",
Windows: []TmuxWindowInput{
{
Window: &TmuxWindow{
Name: "dev",
Cwd: ".",
Layout: &TmuxLayoutInput{
IsArray: true,
Array: []string{"./src", "./lib"},
},
},
},
},
}
result := ParseConfig("myproject", input)
if len(result.Windows) != 1 {
t.Fatalf("expected 1 window, got %d", len(result.Windows))
}
layout := result.Windows[0].Layout
if layout.Split == nil {
t.Fatal("expected Split to not be nil for array layout")
}
}
func TestParseConfig_TildeExpansion(t *testing.T) {
home, _ := os.UserHomeDir()
input := TmuxConfigItemInput{
Root: "~/myproject",
Name: "myproject",
}
result := ParseConfig("myproject", input)
if !strings.HasPrefix(result.Root, home) {
t.Errorf("expected Root to start with home dir %q, got %q", home, result.Root)
}
}
func TestResolvePath(t *testing.T) {
tests := []struct {
root string
path string
expected string
}{
{"/tmp", "src", "/tmp/src"},
{"/tmp", "./src", "/tmp/src"},
{"/tmp", "/absolute", "/absolute"},
{"/tmp", "", "/tmp"},
{"/tmp", ".", "/tmp"},
}
for _, tt := range tests {
t.Run(tt.root+"_"+tt.path, func(t *testing.T) {
result := resolvePath(tt.root, tt.path)
if result != tt.expected {
t.Errorf("resolvePath(%q, %q) = %q, expected %q", tt.root, tt.path, result, tt.expected)
}
})
}
}

149
internal/config/types.go Normal file
View File

@@ -0,0 +1,149 @@
package config
import (
"gopkg.in/yaml.v3"
)
// GlobalConfig holds global settings from the .config section
type GlobalConfig struct {
Shell string `yaml:"shell,omitempty"`
ProjectsPath string `yaml:"projects_path,omitempty"`
}
// ConfigFile represents the top-level config file: map of session name -> config
type ConfigFile map[string]TmuxConfigItemInput
// TmuxConfigItemInput represents a single tmux session configuration
type TmuxConfigItemInput struct {
Root string `yaml:"root"`
Name string `yaml:"name,omitempty"`
BlankWindow bool `yaml:"blank_window,omitempty"`
Windows []TmuxWindowInput `yaml:"windows,omitempty"`
}
// TmuxWindowInput can be either a string (directory path) or a TmuxWindow struct
type TmuxWindowInput struct {
IsString bool
String string
Window *TmuxWindow
}
// UnmarshalYAML implements custom unmarshaling for TmuxWindowInput
func (w *TmuxWindowInput) UnmarshalYAML(value *yaml.Node) error {
// Try string first
var s string
if err := value.Decode(&s); err == nil {
w.IsString = true
w.String = s
return nil
}
// Try TmuxWindow struct
var window TmuxWindow
if err := value.Decode(&window); err == nil {
w.IsString = false
w.Window = &window
return nil
}
return nil
}
// TmuxWindow represents a window configuration with name, cwd, and optional layout
type TmuxWindow struct {
Name string `yaml:"name,omitempty"`
Cwd string `yaml:"cwd"`
Layout *TmuxLayoutInput `yaml:"layout,omitempty"`
}
// TmuxLayoutInput can be a string, array of strings, or TmuxPaneLayout
type TmuxLayoutInput struct {
IsString bool
String string
IsArray bool
Array []string
PaneLayout *TmuxPaneLayout
}
// UnmarshalYAML implements custom unmarshaling for TmuxLayoutInput
func (l *TmuxLayoutInput) UnmarshalYAML(value *yaml.Node) error {
// Try string first
var s string
if err := value.Decode(&s); err == nil {
l.IsString = true
l.String = s
return nil
}
// Try array of strings
var arr []string
if err := value.Decode(&arr); err == nil {
l.IsArray = true
l.Array = arr
return nil
}
// Try TmuxPaneLayout struct
var pane TmuxPaneLayout
if err := value.Decode(&pane); err == nil {
l.IsString = false
l.IsArray = false
l.PaneLayout = &pane
return nil
}
return nil
}
// TmuxPaneLayout represents a pane configuration
type TmuxPaneLayout struct {
Cwd string `yaml:"cwd"`
Cmd string `yaml:"cmd,omitempty"`
Zoom bool `yaml:"zoom,omitempty"`
Split *TmuxSplitLayout `yaml:"split,omitempty"`
}
// TmuxSplitLayout represents a split configuration
type TmuxSplitLayout struct {
Direction string `yaml:"direction"` // "h" or "v"
Child *TmuxPaneLayout `yaml:"child"`
}
// ParsedTmuxConfigItem is the resolved/parsed version of TmuxConfigItemInput
type ParsedTmuxConfigItem struct {
Name string
Root string
Windows []ParsedTmuxWindow
}
// ParsedTmuxWindow is the resolved/parsed version of a window
type ParsedTmuxWindow struct {
Name string
Cwd string
Layout TmuxPaneLayout
}
// DefaultEmptyPane is the default empty pane configuration
var DefaultEmptyPane = TmuxPaneLayout{
Cwd: ".",
Cmd: "",
}
// DefaultEmptyLayout is the default layout with horizontal and vertical splits
var DefaultEmptyLayout = TmuxPaneLayout{
Cwd: ".",
Cmd: "",
Zoom: false,
Split: &TmuxSplitLayout{
Direction: "h",
Child: &TmuxPaneLayout{
Cwd: ".",
Split: &TmuxSplitLayout{
Direction: "v",
Child: &TmuxPaneLayout{
Cwd: ".",
},
},
},
},
}

View File

@@ -0,0 +1,202 @@
package config
import (
"testing"
"gopkg.in/yaml.v3"
)
func TestTmuxWindowInput_UnmarshalYAML_String(t *testing.T) {
yamlData := `"./src"`
var w TmuxWindowInput
err := yaml.Unmarshal([]byte(yamlData), &w)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !w.IsString {
t.Error("expected IsString to be true")
}
if w.String != "./src" {
t.Errorf("expected String to be './src', got %q", w.String)
}
if w.Window != nil {
t.Error("expected Window to be nil")
}
}
func TestTmuxWindowInput_UnmarshalYAML_Struct(t *testing.T) {
yamlData := `
name: mywindow
cwd: ./lib
`
var w TmuxWindowInput
err := yaml.Unmarshal([]byte(yamlData), &w)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if w.IsString {
t.Error("expected IsString to be false")
}
if w.Window == nil {
t.Fatal("expected Window to not be nil")
}
if w.Window.Name != "mywindow" {
t.Errorf("expected Name to be 'mywindow', got %q", w.Window.Name)
}
if w.Window.Cwd != "./lib" {
t.Errorf("expected Cwd to be './lib', got %q", w.Window.Cwd)
}
}
func TestTmuxLayoutInput_UnmarshalYAML_String(t *testing.T) {
yamlData := `"./src"`
var l TmuxLayoutInput
err := yaml.Unmarshal([]byte(yamlData), &l)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !l.IsString {
t.Error("expected IsString to be true")
}
if l.String != "./src" {
t.Errorf("expected String to be './src', got %q", l.String)
}
}
func TestTmuxLayoutInput_UnmarshalYAML_Array(t *testing.T) {
yamlData := `
- ./src
- ./lib
- ./test
`
var l TmuxLayoutInput
err := yaml.Unmarshal([]byte(yamlData), &l)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !l.IsArray {
t.Error("expected IsArray to be true")
}
if len(l.Array) != 3 {
t.Errorf("expected 3 elements, got %d", len(l.Array))
}
expected := []string{"./src", "./lib", "./test"}
for i, v := range expected {
if l.Array[i] != v {
t.Errorf("expected Array[%d] to be %q, got %q", i, v, l.Array[i])
}
}
}
func TestTmuxLayoutInput_UnmarshalYAML_PaneLayout(t *testing.T) {
yamlData := `
cwd: ./src
cmd: npm start
zoom: true
split:
direction: h
child:
cwd: ./lib
`
var l TmuxLayoutInput
err := yaml.Unmarshal([]byte(yamlData), &l)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if l.IsString || l.IsArray {
t.Error("expected neither IsString nor IsArray")
}
if l.PaneLayout == nil {
t.Fatal("expected PaneLayout to not be nil")
}
if l.PaneLayout.Cwd != "./src" {
t.Errorf("expected Cwd to be './src', got %q", l.PaneLayout.Cwd)
}
if l.PaneLayout.Cmd != "npm start" {
t.Errorf("expected Cmd to be 'npm start', got %q", l.PaneLayout.Cmd)
}
if !l.PaneLayout.Zoom {
t.Error("expected Zoom to be true")
}
if l.PaneLayout.Split == nil {
t.Fatal("expected Split to not be nil")
}
if l.PaneLayout.Split.Direction != "h" {
t.Errorf("expected Split.Direction to be 'h', got %q", l.PaneLayout.Split.Direction)
}
}
func TestConfigFile_UnmarshalYAML(t *testing.T) {
yamlData := `
myproject:
root: ~/Dev/myproject
windows:
- ./src
- name: tests
cwd: ./test
another:
root: /tmp/another
blank_window: true
`
var config ConfigFile
err := yaml.Unmarshal([]byte(yamlData), &config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(config) != 2 {
t.Errorf("expected 2 configs, got %d", len(config))
}
myproject, ok := config["myproject"]
if !ok {
t.Fatal("expected 'myproject' config")
}
if myproject.Root != "~/Dev/myproject" {
t.Errorf("expected Root to be '~/Dev/myproject', got %q", myproject.Root)
}
if len(myproject.Windows) != 2 {
t.Errorf("expected 2 windows, got %d", len(myproject.Windows))
}
another, ok := config["another"]
if !ok {
t.Fatal("expected 'another' config")
}
if !another.BlankWindow {
t.Error("expected BlankWindow to be true")
}
}
func TestDefaultEmptyLayout(t *testing.T) {
if DefaultEmptyLayout.Cwd != "." {
t.Errorf("expected Cwd to be '.', got %q", DefaultEmptyLayout.Cwd)
}
if DefaultEmptyLayout.Split == nil {
t.Fatal("expected Split to not be nil")
}
if DefaultEmptyLayout.Split.Direction != "h" {
t.Errorf("expected Split.Direction to be 'h', got %q", DefaultEmptyLayout.Split.Direction)
}
if DefaultEmptyLayout.Split.Child == nil {
t.Fatal("expected Split.Child to not be nil")
}
if DefaultEmptyLayout.Split.Child.Split == nil {
t.Fatal("expected nested Split to not be nil")
}
if DefaultEmptyLayout.Split.Child.Split.Direction != "v" {
t.Errorf("expected nested Split.Direction to be 'v', got %q", DefaultEmptyLayout.Split.Child.Split.Direction)
}
}

161
internal/config/writer.go Normal file
View File

@@ -0,0 +1,161 @@
package config
import (
"errors"
"fmt"
"os"
"strings"
)
// ErrConfigNotFound is returned when the config file is not found
var ErrConfigNotFound = errors.New("tmux config file not found")
// ErrConfigItemExists is returned when trying to add an item that already exists
var ErrConfigItemExists = errors.New("tmux config item already exists")
// AddSimpleConfigToFile appends a simple config to the config file
func AddSimpleConfigToFile(config ParsedTmuxConfigItem, local bool, dryRun bool) error {
files, err := GetTmuxConfigFileInfo()
if err != nil {
return err
}
var file *ConfigResult
if local {
file = files.Local
} else {
file = files.Global
}
if file == nil {
return ErrConfigNotFound
}
// Check if config already exists
allConfigs, err := GetTmuxConfig()
if err != nil {
return err
}
if _, exists := allConfigs[config.Name]; exists && !dryRun {
return fmt.Errorf("%w: '%s'", ErrConfigItemExists, config.Name)
}
// Build YAML content
var sb strings.Builder
sb.WriteString("\n")
sb.WriteString(config.Name)
sb.WriteString(":\n")
sb.WriteString(" root: ")
sb.WriteString(dirFixForWrite(config.Root))
sb.WriteString("\n")
sb.WriteString(" windows:\n")
for _, w := range config.Windows {
sb.WriteString(" - ")
sb.WriteString(dirFixForWriteRelative(w.Cwd, config.Root))
sb.WriteString("\n")
}
if dryRun {
fmt.Println("Would have saved config to", file.Filepath)
fmt.Println("Contents:")
fmt.Println(sb.String())
return nil
}
f, err := os.OpenFile(file.Filepath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(sb.String())
return err
}
// RemoveConfigFromFile removes a config item from the config file
func RemoveConfigFromFile(key string, local bool, dryRun bool) error {
files, err := GetTmuxConfigFileInfo()
if err != nil {
return err
}
var file *ConfigResult
if local {
file = files.Local
} else {
file = files.Global
}
if file == nil {
return ErrConfigNotFound
}
// Read file contents
data, err := os.ReadFile(file.Filepath)
if err != nil {
return err
}
contents := strings.Split(string(data), "\n")
// Find the start index of the key
startIndex := -1
for i, line := range contents {
if strings.HasPrefix(line, key+":") {
startIndex = i
break
}
}
if startIndex == -1 {
return fmt.Errorf("tmux config item '%s' not found in config file", key)
}
// Find the end index (next key or end of file)
endIndex := len(contents)
for i := startIndex + 1; i < len(contents); i++ {
// Check if line starts with non-whitespace (new key)
if len(contents[i]) > 0 && contents[i][0] != ' ' && contents[i][0] != '\t' && contents[i][0] != '#' {
endIndex = i
break
}
}
// Build new contents
newContents := append(contents[:startIndex], contents[endIndex:]...)
result := strings.TrimRight(strings.Join(newContents, "\n"), "\n")
if dryRun {
fmt.Println("Would have written to", file.Filepath)
fmt.Println("New contents:")
fmt.Println(result)
return nil
}
return os.WriteFile(file.Filepath, []byte(result), 0644)
}
// dirFixForWrite replaces home directory with ~
func dirFixForWrite(dir string) string {
if home, err := os.UserHomeDir(); err == nil {
if strings.HasPrefix(dir, home) {
return "~" + dir[len(home):]
}
}
return dir
}
// dirFixForWriteRelative makes path relative to root and replaces home with ~
func dirFixForWriteRelative(dir, root string) string {
// First try to make relative to root
if strings.HasPrefix(dir, root) {
rel := strings.TrimPrefix(dir, root)
rel = strings.TrimPrefix(rel, "/")
if rel == "" {
return "./"
}
return "./" + rel
}
return dirFixForWrite(dir)
}

View File

@@ -0,0 +1,280 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDirFixForWrite(t *testing.T) {
home, _ := os.UserHomeDir()
tests := []struct {
input string
expected string
}{
{filepath.Join(home, "projects"), "~/projects"},
{filepath.Join(home, "Dev", "myproject"), "~/Dev/myproject"},
{"/tmp/other", "/tmp/other"},
{"/absolute/path", "/absolute/path"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := dirFixForWrite(tt.input)
if result != tt.expected {
t.Errorf("dirFixForWrite(%q) = %q, expected %q", tt.input, result, tt.expected)
}
})
}
}
func TestDirFixForWriteRelative(t *testing.T) {
tests := []struct {
dir string
root string
expected string
}{
{"/tmp/project/src", "/tmp/project", "./src"},
{"/tmp/project", "/tmp/project", "./"},
{"/other/path", "/tmp/project", "/other/path"},
}
for _, tt := range tests {
t.Run(tt.dir, func(t *testing.T) {
result := dirFixForWriteRelative(tt.dir, tt.root)
if result != tt.expected {
t.Errorf("dirFixForWriteRelative(%q, %q) = %q, expected %q", tt.dir, tt.root, result, tt.expected)
}
})
}
}
func TestAddSimpleConfigToFile_DryRun(t *testing.T) {
// Create a temporary directory with a config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".tmux.yaml")
initialContent := `
existing:
root: /tmp/existing
`
err := os.WriteFile(configPath, []byte(initialContent), 0644)
if err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
// Change to temp directory so config is found
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
config := ParsedTmuxConfigItem{
Name: "newproject",
Root: "/tmp/newproject",
Windows: []ParsedTmuxWindow{
{Name: "main", Cwd: "/tmp/newproject"},
},
}
// Dry run should not modify file
err = AddSimpleConfigToFile(config, false, true)
if err != nil {
t.Fatalf("dry run failed: %v", err)
}
// Verify file wasn't modified
content, _ := os.ReadFile(configPath)
if strings.Contains(string(content), "newproject") {
t.Error("dry run should not have modified file")
}
}
func TestAddSimpleConfigToFile(t *testing.T) {
// Create a temporary directory with a config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".tmux.yaml")
initialContent := `existing:
root: /tmp/existing
`
err := os.WriteFile(configPath, []byte(initialContent), 0644)
if err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
// Change to temp directory so config is found
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
config := ParsedTmuxConfigItem{
Name: "newproject",
Root: "/tmp/newproject",
Windows: []ParsedTmuxWindow{
{Name: "main", Cwd: "/tmp/newproject"},
{Name: "src", Cwd: "/tmp/newproject/src"},
},
}
err = AddSimpleConfigToFile(config, false, false)
if err != nil {
t.Fatalf("failed to add config: %v", err)
}
// Verify file was modified
content, _ := os.ReadFile(configPath)
contentStr := string(content)
if !strings.Contains(contentStr, "newproject:") {
t.Error("expected file to contain 'newproject:'")
}
if !strings.Contains(contentStr, "root: /tmp/newproject") {
t.Error("expected file to contain 'root: /tmp/newproject'")
}
if !strings.Contains(contentStr, "windows:") {
t.Error("expected file to contain 'windows:'")
}
}
func TestRemoveConfigFromFile(t *testing.T) {
// Create a temporary directory with a config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".tmux.yaml")
initialContent := `first:
root: /tmp/first
windows:
- ./src
second:
root: /tmp/second
third:
root: /tmp/third
`
err := os.WriteFile(configPath, []byte(initialContent), 0644)
if err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
// Change to temp directory so config is found
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
err = RemoveConfigFromFile("second", false, false)
if err != nil {
t.Fatalf("failed to remove config: %v", err)
}
// Verify file was modified correctly
content, _ := os.ReadFile(configPath)
contentStr := string(content)
if !strings.Contains(contentStr, "first:") {
t.Error("expected file to still contain 'first:'")
}
if strings.Contains(contentStr, "second:") {
t.Error("expected 'second:' to be removed")
}
if !strings.Contains(contentStr, "third:") {
t.Error("expected file to still contain 'third:'")
}
}
func TestRemoveConfigFromFile_NotFound(t *testing.T) {
// Create a temporary directory with a config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".tmux.yaml")
initialContent := `existing:
root: /tmp/existing
`
err := os.WriteFile(configPath, []byte(initialContent), 0644)
if err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
// Change to temp directory so config is found
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
err = RemoveConfigFromFile("nonexistent", false, false)
if err == nil {
t.Error("expected error when removing nonexistent config")
}
}
func TestRemoveConfigFromFile_DryRun(t *testing.T) {
// Create a temporary directory with a config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".tmux.yaml")
initialContent := `toremove:
root: /tmp/toremove
`
err := os.WriteFile(configPath, []byte(initialContent), 0644)
if err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
// Change to temp directory so config is found
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
err = RemoveConfigFromFile("toremove", false, true)
if err != nil {
t.Fatalf("dry run failed: %v", err)
}
// Verify file wasn't modified
content, _ := os.ReadFile(configPath)
if !strings.Contains(string(content), "toremove:") {
t.Error("dry run should not have modified file")
}
}
func TestRemoveConfigFromFile_LastItem(t *testing.T) {
// Create a temporary directory with a config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".tmux.yaml")
initialContent := `first:
root: /tmp/first
last:
root: /tmp/last
windows:
- ./src
- ./lib
`
err := os.WriteFile(configPath, []byte(initialContent), 0644)
if err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
// Change to temp directory so config is found
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
err = RemoveConfigFromFile("last", false, false)
if err != nil {
t.Fatalf("failed to remove last config: %v", err)
}
// Verify file was modified correctly
content, _ := os.ReadFile(configPath)
contentStr := string(content)
if !strings.Contains(contentStr, "first:") {
t.Error("expected file to still contain 'first:'")
}
if strings.Contains(contentStr, "last:") {
t.Error("expected 'last:' to be removed")
}
}

125
internal/exec/runner.go Normal file
View File

@@ -0,0 +1,125 @@
package exec
import (
"bytes"
"fmt"
"os"
"os/exec"
)
// Opts contains execution options
type Opts struct {
Verbose bool
Dry bool
}
// DefaultShells is the list of shells to try in order of preference
var DefaultShells = []string{"/bin/zsh", "/bin/bash", "/bin/sh"}
// Shell can be set to override the default shell detection
// Set this from config on startup
var Shell string
// getShell returns the shell to use for command execution
// Priority: Shell variable (from config) > $SHELL env > auto-detect
func getShell() string {
// Use configured shell if set (from .config section)
if Shell != "" {
return Shell
}
// Check $SHELL environment variable
if envShell := os.Getenv("SHELL"); envShell != "" {
return envShell
}
// Auto-detect from default shells
for _, shell := range DefaultShells {
if _, err := os.Stat(shell); err == nil {
return shell
}
}
// Fallback to sh (should always exist)
return "sh"
}
// Log prints a message if verbose or dry mode is enabled
func Log(opts Opts, args ...any) {
if opts.Verbose || opts.Dry {
fmt.Println(args...)
}
}
// RunCommand executes a command with inherited stdio
func RunCommand(opts Opts, command string) error {
Log(opts, "$", command)
if opts.Dry {
return nil
}
cmd := exec.Command(getShell(), "-c", command)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// GetCommandOutput executes a command and returns its output
func GetCommandOutput(opts Opts, command string) (string, int, error) {
Log(opts, "$", command)
if opts.Dry {
return "", 0, nil
}
cmd := exec.Command(getShell(), "-c", command)
var stdout bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
return "", -1, err
}
}
return stdout.String(), exitCode, nil
}
// GetCommandOutputSilent executes a command and returns its output, suppressing stderr
func GetCommandOutputSilent(command string) (string, int, error) {
cmd := exec.Command(getShell(), "-c", command)
var stdout bytes.Buffer
cmd.Stdout = &stdout
// Suppress stderr
err := cmd.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
return "", -1, err
}
}
return stdout.String(), exitCode, nil
}
// RunCommandSilent executes a command without output and returns the exit code
func RunCommandSilent(command string) (int, error) {
cmd := exec.Command(getShell(), "-c", command)
err := cmd.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode(), nil
}
return -1, err
}
return 0, nil
}

View File

@@ -0,0 +1,215 @@
package exec
import (
"os"
"strings"
"testing"
)
func TestLog_VerboseMode(t *testing.T) {
// This test mainly ensures Log doesn't panic
opts := Opts{Verbose: true, Dry: false}
Log(opts, "test message", 123, "another")
}
func TestLog_DryMode(t *testing.T) {
opts := Opts{Verbose: false, Dry: true}
Log(opts, "test message")
}
func TestLog_Silent(t *testing.T) {
opts := Opts{Verbose: false, Dry: false}
// Should not print anything
Log(opts, "test message")
}
func TestRunCommand_DryRun(t *testing.T) {
opts := Opts{Verbose: false, Dry: true}
err := RunCommand(opts, "echo hello")
if err != nil {
t.Errorf("expected no error in dry mode, got %v", err)
}
}
func TestRunCommand_Success(t *testing.T) {
opts := Opts{Verbose: false, Dry: false}
err := RunCommand(opts, "true")
if err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func TestRunCommand_Failure(t *testing.T) {
opts := Opts{Verbose: false, Dry: false}
err := RunCommand(opts, "false")
if err == nil {
t.Error("expected error for failing command")
}
}
func TestGetCommandOutput_DryRun(t *testing.T) {
opts := Opts{Verbose: false, Dry: true}
output, code, err := GetCommandOutput(opts, "echo hello")
if err != nil {
t.Errorf("expected no error in dry mode, got %v", err)
}
if code != 0 {
t.Errorf("expected exit code 0 in dry mode, got %d", code)
}
if output != "" {
t.Errorf("expected empty output in dry mode, got %q", output)
}
}
func TestGetCommandOutput_Success(t *testing.T) {
opts := Opts{Verbose: false, Dry: false}
output, code, err := GetCommandOutput(opts, "echo hello")
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if code != 0 {
t.Errorf("expected exit code 0, got %d", code)
}
if strings.TrimSpace(output) != "hello" {
t.Errorf("expected 'hello', got %q", output)
}
}
func TestGetCommandOutput_NonZeroExit(t *testing.T) {
opts := Opts{Verbose: false, Dry: false}
_, code, err := GetCommandOutput(opts, "exit 42")
if err != nil {
t.Errorf("expected no error for non-zero exit, got %v", err)
}
if code != 42 {
t.Errorf("expected exit code 42, got %d", code)
}
}
func TestGetCommandOutput_WithPipe(t *testing.T) {
opts := Opts{Verbose: false, Dry: false}
output, code, err := GetCommandOutput(opts, "echo 'hello world' | tr ' ' '_'")
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if code != 0 {
t.Errorf("expected exit code 0, got %d", code)
}
if strings.TrimSpace(output) != "hello_world" {
t.Errorf("expected 'hello_world', got %q", output)
}
}
func TestRunCommandSilent_Success(t *testing.T) {
code, err := RunCommandSilent("true")
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if code != 0 {
t.Errorf("expected exit code 0, got %d", code)
}
}
func TestRunCommandSilent_Failure(t *testing.T) {
code, err := RunCommandSilent("exit 1")
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if code != 1 {
t.Errorf("expected exit code 1, got %d", code)
}
}
func TestRunCommandSilent_CustomExitCode(t *testing.T) {
code, err := RunCommandSilent("exit 123")
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if code != 123 {
t.Errorf("expected exit code 123, got %d", code)
}
}
func TestOpts(t *testing.T) {
opts := Opts{
Verbose: true,
Dry: true,
}
if !opts.Verbose {
t.Error("expected Verbose to be true")
}
if !opts.Dry {
t.Error("expected Dry to be true")
}
}
func TestGetShell_Default(t *testing.T) {
// Reset shell config
oldShell := Shell
Shell = ""
defer func() { Shell = oldShell }()
// Unset env var
oldEnv := os.Getenv("SHELL")
os.Unsetenv("SHELL")
defer os.Setenv("SHELL", oldEnv)
shell := getShell()
// Should return one of the default shells or "sh"
if shell == "" {
t.Error("expected non-empty shell")
}
}
func TestGetShell_EnvVar(t *testing.T) {
// Reset shell config
oldShell := Shell
Shell = ""
defer func() { Shell = oldShell }()
// Set env var
oldEnv := os.Getenv("SHELL")
os.Setenv("SHELL", "/custom/shell")
defer os.Setenv("SHELL", oldEnv)
shell := getShell()
if shell != "/custom/shell" {
t.Errorf("expected /custom/shell, got %s", shell)
}
}
func TestGetShell_ConfigOverridesEnv(t *testing.T) {
// Set shell config (simulates .config section)
oldShell := Shell
Shell = "/configured/shell"
defer func() { Shell = oldShell }()
// Set env var too
oldEnv := os.Getenv("SHELL")
os.Setenv("SHELL", "/env/shell")
defer os.Setenv("SHELL", oldEnv)
shell := getShell()
// Config should take priority over env
if shell != "/configured/shell" {
t.Errorf("expected /configured/shell, got %s", shell)
}
}
func TestDefaultShells(t *testing.T) {
if len(DefaultShells) == 0 {
t.Error("expected DefaultShells to have entries")
}
// First should be zsh
if DefaultShells[0] != "/bin/zsh" {
t.Errorf("expected first shell to be /bin/zsh, got %s", DefaultShells[0])
}
}

67
internal/fzf/fzf.go Normal file
View File

@@ -0,0 +1,67 @@
package fzf
import (
"errors"
"github.com/ktr0731/go-fuzzyfinder"
)
// ErrSelectionCancelled is returned when the user cancels selection
var ErrSelectionCancelled = errors.New("selection cancelled")
// Options for fuzzy finder
type Options struct {
AllowCustom bool // Note: go-fuzzyfinder doesn't support custom input like fzf --print-query
}
// Run executes the fuzzy finder with the given inputs and returns the selected value
func Run(inputs []string, opts Options) (string, error) {
if len(inputs) == 0 {
return "", ErrSelectionCancelled
}
idx, err := fuzzyfinder.Find(
inputs,
func(i int) string {
return inputs[i]
},
)
if err != nil {
if errors.Is(err, fuzzyfinder.ErrAbort) {
return "", ErrSelectionCancelled
}
return "", err
}
return inputs[idx], nil
}
// RunWithPreview executes the fuzzy finder with a preview function
func RunWithPreview(inputs []string, preview func(i int) string) (string, error) {
if len(inputs) == 0 {
return "", ErrSelectionCancelled
}
idx, err := fuzzyfinder.Find(
inputs,
func(i int) string {
return inputs[i]
},
fuzzyfinder.WithPreviewWindow(func(i, w, h int) string {
if i < 0 || i >= len(inputs) {
return ""
}
return preview(i)
}),
)
if err != nil {
if errors.Is(err, fuzzyfinder.ErrAbort) {
return "", ErrSelectionCancelled
}
return "", err
}
return inputs[idx], nil
}

58
internal/fzf/fzf_test.go Normal file
View File

@@ -0,0 +1,58 @@
package fzf
import (
"errors"
"testing"
)
func TestErrSelectionCancelled(t *testing.T) {
if ErrSelectionCancelled.Error() != "selection cancelled" {
t.Errorf("unexpected error message: %s", ErrSelectionCancelled.Error())
}
}
func TestOptions(t *testing.T) {
opts := Options{AllowCustom: true}
if !opts.AllowCustom {
t.Error("expected AllowCustom to be true")
}
opts2 := Options{AllowCustom: false}
if opts2.AllowCustom {
t.Error("expected AllowCustom to be false")
}
}
func TestRun_EmptyInputs(t *testing.T) {
_, err := Run([]string{}, Options{})
if err == nil {
t.Error("expected error for empty inputs")
}
if !errors.Is(err, ErrSelectionCancelled) {
t.Errorf("expected ErrSelectionCancelled, got %v", err)
}
}
func TestRunWithPreview_EmptyInputs(t *testing.T) {
_, err := RunWithPreview([]string{}, func(i int) string { return "" })
if err == nil {
t.Error("expected error for empty inputs")
}
if !errors.Is(err, ErrSelectionCancelled) {
t.Errorf("expected ErrSelectionCancelled, got %v", err)
}
}
func TestErrSelectionCancelled_Is(t *testing.T) {
err := ErrSelectionCancelled
if !errors.Is(err, ErrSelectionCancelled) {
t.Error("expected errors.Is to match ErrSelectionCancelled")
}
}
// Note: Full integration tests for the fuzzy finder require:
// 1. A terminal environment
// 2. A way to simulate user input
//
// The Run and RunWithPreview functions are tested implicitly through
// CLI integration tests. Unit tests focus on edge cases and error handling.

127
internal/tmux/builder.go Normal file
View File

@@ -0,0 +1,127 @@
package tmux
import (
"fmt"
"path/filepath"
"github.com/chenasraf/tx/internal/config"
"github.com/chenasraf/tx/internal/exec"
)
// CreateFromConfig creates a tmux session from a parsed config
func CreateFromConfig(opts exec.Opts, tmuxConfig config.ParsedTmuxConfigItem) error {
root := tmuxConfig.Root
windows := tmuxConfig.Windows
sessionName := config.NameFix(tmuxConfig.Name)
exec.Log(opts, "Config:", tmuxConfig)
exec.Log(opts, "Session name:", sessionName)
// Check if session already exists
if SessionExists(opts, sessionName) {
exec.Log(opts, fmt.Sprintf("tmux session %s already exists, attaching...", sessionName))
return AttachToSession(opts, sessionName)
}
exec.Log(opts, fmt.Sprintf("tmux session %s does not exist, creating...", sessionName))
var commands []string
// Create the session
commands = append(commands, fmt.Sprintf(
"tmux -f ~/.config/tmux/conf.tmux new-session -d -s %s -n general -c %s; sleep 1",
sessionName, root,
))
// Create all windows
for i, window := range windows {
dir := window.Cwd
windowName := window.Name
if windowName == "" {
windowName = config.NameFix(filepath.Base(dir))
}
exec.Log(opts, "Creating window:", windowName)
// Create the window
commands = append(commands, fmt.Sprintf(
"tmux new-window -t %s:%d -n %s -c %s",
sessionName, i+1, windowName, dir,
))
// Get pane commands
paneCommands := getPaneCommands(opts, window.Layout, sessionName, windowName, root)
commands = append(commands, paneCommands...)
// Set clock mode and select first pane
commands = append(commands, fmt.Sprintf("tmux clock-mode -t %s:%s", sessionName, windowName))
commands = append(commands, fmt.Sprintf("tmux select-pane -t %s.0", sessionName))
}
// Select first window
commands = append(commands, fmt.Sprintf("tmux select-window -t %s:1", sessionName))
// Execute all commands
for _, command := range commands {
if err := exec.RunCommand(opts, command); err != nil {
return fmt.Errorf("failed to execute: %s: %w", command, err)
}
}
// Attach to the session
return AttachToSession(opts, sessionName)
}
// getPaneCommands generates tmux commands for pane layout
func getPaneCommands(opts exec.Opts, pane config.TmuxPaneLayout, sessionName, windowName, rootDir string) []string {
var commands []string
// Send command if specified
if pane.Cmd != "" {
cmd := TransformCmdToTmuxKeys(pane.Cmd)
if cmd != "" {
exec.Log(opts, "Sending keys:", cmd)
commands = append(commands, fmt.Sprintf(
"tmux send-keys -t %s:%s %s Enter",
sessionName, windowName, cmd,
))
}
}
// Handle splits
if pane.Split != nil {
direction := pane.Split.Direction
if direction == "" {
direction = "h"
}
cwd := pane.Cwd
if cwd == "" {
cwd = rootDir
}
exec.Log(opts, "Splitting pane:", pane.Split, "direction:", direction)
commands = append(commands, fmt.Sprintf(
"tmux split-window -%s -t %s:%s -c %s",
direction, sessionName, windowName, cwd,
))
// Handle child pane
if pane.Split.Child != nil {
exec.Log(opts, "Handling child pane:", pane.Split.Child)
childCommands := getPaneCommands(opts, *pane.Split.Child, sessionName, windowName, rootDir)
commands = append(commands, childCommands...)
}
// Handle zoom
if pane.Zoom {
commands = append(commands, fmt.Sprintf(
"tmux resize-pane -t %s:%s -Z",
sessionName, windowName,
))
}
}
return commands
}

View File

@@ -0,0 +1,230 @@
package tmux
import (
"strings"
"testing"
"github.com/chenasraf/tx/internal/config"
"github.com/chenasraf/tx/internal/exec"
)
func TestGetPaneCommands_NoSplit(t *testing.T) {
opts := exec.Opts{Verbose: false, Dry: true}
pane := config.TmuxPaneLayout{
Cwd: "/tmp/test",
}
commands := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp")
// No split, no cmd = no commands
if len(commands) != 0 {
t.Errorf("expected 0 commands, got %d: %v", len(commands), commands)
}
}
func TestGetPaneCommands_WithCmd(t *testing.T) {
opts := exec.Opts{Verbose: false, Dry: true}
pane := config.TmuxPaneLayout{
Cwd: "/tmp/test",
Cmd: "npm start",
}
commands := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp")
if len(commands) != 1 {
t.Fatalf("expected 1 command, got %d: %v", len(commands), commands)
}
expected := "tmux send-keys -t testsession:testwindow npm Space start Enter"
if commands[0] != expected {
t.Errorf("expected %q, got %q", expected, commands[0])
}
}
func TestGetPaneCommands_WithSplit(t *testing.T) {
opts := exec.Opts{Verbose: false, Dry: true}
pane := config.TmuxPaneLayout{
Cwd: "/tmp/test",
Split: &config.TmuxSplitLayout{
Direction: "h",
Child: &config.TmuxPaneLayout{
Cwd: "/tmp/test/child",
},
},
}
commands := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp")
if len(commands) < 1 {
t.Fatalf("expected at least 1 command, got %d", len(commands))
}
// First command should be split-window
if commands[0] != "tmux split-window -h -t testsession:testwindow -c /tmp/test" {
t.Errorf("unexpected split command: %q", commands[0])
}
}
func TestGetPaneCommands_WithVerticalSplit(t *testing.T) {
opts := exec.Opts{Verbose: false, Dry: true}
pane := config.TmuxPaneLayout{
Cwd: "/tmp/test",
Split: &config.TmuxSplitLayout{
Direction: "v",
Child: &config.TmuxPaneLayout{
Cwd: "/tmp/test/child",
},
},
}
commands := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp")
if len(commands) < 1 {
t.Fatalf("expected at least 1 command, got %d", len(commands))
}
// Should use -v for vertical split
if commands[0] != "tmux split-window -v -t testsession:testwindow -c /tmp/test" {
t.Errorf("unexpected split command: %q", commands[0])
}
}
func TestGetPaneCommands_WithZoom(t *testing.T) {
opts := exec.Opts{Verbose: false, Dry: true}
pane := config.TmuxPaneLayout{
Cwd: "/tmp/test",
Zoom: true,
Split: &config.TmuxSplitLayout{
Direction: "h",
Child: &config.TmuxPaneLayout{
Cwd: "/tmp/test/child",
},
},
}
commands := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp")
// Should have a resize-pane -Z command
found := false
for _, cmd := range commands {
if cmd == "tmux resize-pane -t testsession:testwindow -Z" {
found = true
break
}
}
if !found {
t.Errorf("expected zoom command, commands: %v", commands)
}
}
func TestGetPaneCommands_NestedSplits(t *testing.T) {
opts := exec.Opts{Verbose: false, Dry: true}
pane := config.TmuxPaneLayout{
Cwd: "/tmp/test",
Split: &config.TmuxSplitLayout{
Direction: "h",
Child: &config.TmuxPaneLayout{
Cwd: "/tmp/test/child1",
Split: &config.TmuxSplitLayout{
Direction: "v",
Child: &config.TmuxPaneLayout{
Cwd: "/tmp/test/child2",
},
},
},
},
}
commands := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp")
// Should have at least 2 split commands
splitCount := 0
for _, cmd := range commands {
if strings.HasPrefix(cmd, "tmux split-window") {
splitCount++
}
}
if splitCount < 2 {
t.Errorf("expected at least 2 split commands, got %d: %v", splitCount, commands)
}
}
func TestGetPaneCommands_DefaultDirection(t *testing.T) {
opts := exec.Opts{Verbose: false, Dry: true}
pane := config.TmuxPaneLayout{
Cwd: "/tmp/test",
Split: &config.TmuxSplitLayout{
Direction: "", // Empty should default to "h"
Child: &config.TmuxPaneLayout{
Cwd: "/tmp/test/child",
},
},
}
commands := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp")
if len(commands) < 1 {
t.Fatalf("expected at least 1 command, got %d", len(commands))
}
// Should default to -h
if commands[0] != "tmux split-window -h -t testsession:testwindow -c /tmp/test" {
t.Errorf("expected default horizontal split, got: %q", commands[0])
}
}
func TestCreateFromConfig_DryRun(t *testing.T) {
opts := exec.Opts{Verbose: false, Dry: true}
tmuxConfig := config.ParsedTmuxConfigItem{
Name: "testproject",
Root: "/tmp/testproject",
Windows: []config.ParsedTmuxWindow{
{
Name: "main",
Cwd: "/tmp/testproject",
Layout: config.TmuxPaneLayout{
Cwd: "/tmp/testproject",
},
},
},
}
// In dry mode, this should succeed without actually running tmux
err := CreateFromConfig(opts, tmuxConfig)
if err != nil {
t.Errorf("expected no error in dry mode, got %v", err)
}
}
func TestCreateFromConfig_MultipleWindows(t *testing.T) {
opts := exec.Opts{Verbose: false, Dry: true}
tmuxConfig := config.ParsedTmuxConfigItem{
Name: "testproject",
Root: "/tmp/testproject",
Windows: []config.ParsedTmuxWindow{
{
Name: "src",
Cwd: "/tmp/testproject/src",
Layout: config.TmuxPaneLayout{
Cwd: "/tmp/testproject/src",
},
},
{
Name: "lib",
Cwd: "/tmp/testproject/lib",
Layout: config.TmuxPaneLayout{
Cwd: "/tmp/testproject/lib",
},
},
},
}
err := CreateFromConfig(opts, tmuxConfig)
if err != nil {
t.Errorf("expected no error in dry mode, got %v", err)
}
}

29
internal/tmux/keys.go Normal file
View File

@@ -0,0 +1,29 @@
package tmux
import "strings"
// TransformCmdToTmuxKeys converts a command string to tmux send-keys format
// Spaces become " Space " and newlines become " Enter "
func TransformCmdToTmuxKeys(cmd string) string {
if strings.TrimSpace(cmd) == "" {
return ""
}
var result strings.Builder
keyMap := map[rune]string{
' ': "Space",
'\n': "Enter",
}
for _, char := range cmd {
if replacement, ok := keyMap[char]; ok {
result.WriteString(" ")
result.WriteString(replacement)
result.WriteString(" ")
} else {
result.WriteRune(char)
}
}
return result.String()
}

101
internal/tmux/keys_test.go Normal file
View File

@@ -0,0 +1,101 @@
package tmux
import (
"strings"
"testing"
)
func TestTransformCmdToTmuxKeys(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "empty string",
input: "",
expected: "",
},
{
name: "whitespace only",
input: " ",
expected: "",
},
{
name: "simple command no spaces",
input: "ls",
expected: "ls",
},
{
name: "command with space",
input: "ls -la",
expected: "ls Space -la",
},
{
name: "command with multiple spaces",
input: "git commit -m test",
expected: "git Space commit Space -m Space test",
},
{
name: "command with newline",
input: "echo hello\necho world",
expected: "echo Space hello Enter echo Space world",
},
{
name: "npm start",
input: "npm start",
expected: "npm Space start",
},
{
name: "complex command",
input: "cd ~/project && npm install",
expected: "cd Space ~/project Space && Space npm Space install",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := TransformCmdToTmuxKeys(tt.input)
// Normalize spaces for comparison
resultNorm := strings.Join(strings.Fields(result), " ")
expectedNorm := strings.Join(strings.Fields(tt.expected), " ")
if resultNorm != expectedNorm {
t.Errorf("TransformCmdToTmuxKeys(%q) = %q, expected %q", tt.input, resultNorm, expectedNorm)
}
})
}
}
func TestTransformCmdToTmuxKeys_SpacePadding(t *testing.T) {
result := TransformCmdToTmuxKeys("a b")
// Should have space padding around "Space"
if !strings.Contains(result, " Space ") {
t.Errorf("expected ' Space ' in result, got %q", result)
}
}
func TestTransformCmdToTmuxKeys_PreservesSpecialChars(t *testing.T) {
tests := []struct {
input string
check string
}{
{"ls | grep foo", "|"},
{"echo $HOME", "$"},
{"test && run", "&&"},
{"cmd > file", ">"},
{"cmd < file", "<"},
{"echo 'hello'", "'"},
{`echo "hello"`, `"`},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := TransformCmdToTmuxKeys(tt.input)
if !strings.Contains(result, tt.check) {
t.Errorf("expected %q to contain %q, got %q", tt.input, tt.check, result)
}
})
}
}

33
internal/tmux/session.go Normal file
View File

@@ -0,0 +1,33 @@
package tmux
import (
"os"
"github.com/chenasraf/tx/internal/exec"
)
// SessionExists checks if a tmux session with the given name exists
func SessionExists(opts exec.Opts, sessionName string) bool {
// Use silent version to suppress "can't find session" stderr messages
_, code, err := exec.GetCommandOutputSilent("tmux has-session -t " + sessionName)
if err != nil {
return false
}
return code == 0
}
// AttachToSession attaches to or switches to a tmux session
func AttachToSession(opts exec.Opts, sessionName string) error {
if os.Getenv("TMUX") != "" {
// Already inside tmux, use switch-client
return exec.RunCommand(opts, "tmux switch-client -t "+sessionName)
}
// Outside tmux, use attach
return exec.RunCommand(opts, "tmux attach -t "+sessionName)
}
// ListSessions returns the output of `tmux ls`
func ListSessions(opts exec.Opts) (string, error) {
output, _, err := exec.GetCommandOutput(opts, "tmux ls")
return output, err
}

View File

@@ -0,0 +1,65 @@
package tmux
import (
"os"
"testing"
"github.com/chenasraf/tx/internal/exec"
)
func TestSessionExists_DryMode(t *testing.T) {
// In dry mode, SessionExists should still check for real
opts := exec.Opts{Verbose: false, Dry: true}
// This test checks that the function doesn't crash
// The actual result depends on whether tmux is running
_ = SessionExists(opts, "nonexistent-test-session-12345")
}
func TestAttachToSession_InsideTmux(t *testing.T) {
// Save original TMUX env
origTmux := os.Getenv("TMUX")
defer os.Setenv("TMUX", origTmux)
// Set TMUX to simulate being inside tmux
os.Setenv("TMUX", "/tmp/tmux-1000/default,12345,0")
opts := exec.Opts{Verbose: false, Dry: true}
err := AttachToSession(opts, "testsession")
// In dry mode, should succeed without actually running
if err != nil {
t.Errorf("expected no error in dry mode, got %v", err)
}
}
func TestAttachToSession_OutsideTmux(t *testing.T) {
// Save original TMUX env
origTmux := os.Getenv("TMUX")
defer os.Setenv("TMUX", origTmux)
// Unset TMUX to simulate being outside tmux
os.Unsetenv("TMUX")
opts := exec.Opts{Verbose: false, Dry: true}
err := AttachToSession(opts, "testsession")
// In dry mode, should succeed without actually running
if err != nil {
t.Errorf("expected no error in dry mode, got %v", err)
}
}
func TestListSessions_DryMode(t *testing.T) {
opts := exec.Opts{Verbose: false, Dry: true}
output, err := ListSessions(opts)
if err != nil {
t.Errorf("expected no error in dry mode, got %v", err)
}
// In dry mode, output should be empty
if output != "" {
t.Errorf("expected empty output in dry mode, got %q", output)
}
}

7
main.go Normal file
View File

@@ -0,0 +1,7 @@
package main
import "github.com/chenasraf/tx/internal/cli"
func main() {
cli.Execute()
}