mirror of
https://github.com/chenasraf/tx.git
synced 2026-05-17 17:28:08 +00:00
feat: initial commit
This commit is contained in:
13
.github/FUNDING.yml
vendored
Executable file
13
.github/FUNDING.yml
vendored
Executable 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¤cy_code=ILS&source=url"
|
||||
12
.github/workflows/manual-homebrew-release.yml
vendored
Executable file
12
.github/workflows/manual-homebrew-release.yml
vendored
Executable 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
20
.github/workflows/release.yml
vendored
Executable 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
44
.github/workflows/test.yml
vendored
Executable 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
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
tx
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
58
Makefile
Executable 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
343
README.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# tx
|
||||
|
||||
A tmux session manager that creates sessions from YAML configuration files.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🚀 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
25
go.mod
Normal 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
77
go.sum
Normal 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=
|
||||
56
internal/cli/attach_cmd.go
Normal file
56
internal/cli/attach_cmd.go
Normal 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")
|
||||
}
|
||||
28
internal/cli/attach_cmd_test.go
Normal file
28
internal/cli/attach_cmd_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
96
internal/cli/create_cmd.go
Normal file
96
internal/cli/create_cmd.go
Normal 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)
|
||||
}
|
||||
70
internal/cli/create_cmd_test.go
Normal file
70
internal/cli/create_cmd_test.go
Normal 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
51
internal/cli/edit_cmd.go
Normal 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)
|
||||
}
|
||||
38
internal/cli/edit_cmd_test.go
Normal file
38
internal/cli/edit_cmd_test.go
Normal 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
104
internal/cli/list_cmd.go
Normal 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
|
||||
}
|
||||
50
internal/cli/list_cmd_test.go
Normal file
50
internal/cli/list_cmd_test.go
Normal 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
64
internal/cli/main_cmd.go
Normal 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)
|
||||
}
|
||||
91
internal/cli/main_cmd_test.go
Normal file
91
internal/cli/main_cmd_test.go
Normal 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
145
internal/cli/prj_cmd.go
Normal 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
|
||||
}
|
||||
95
internal/cli/prj_cmd_test.go
Normal file
95
internal/cli/prj_cmd_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
52
internal/cli/remove_cmd.go
Normal file
52
internal/cli/remove_cmd.go
Normal 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
|
||||
}
|
||||
45
internal/cli/remove_cmd_test.go
Normal file
45
internal/cli/remove_cmd_test.go
Normal 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
90
internal/cli/root.go
Normal 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
93
internal/cli/root_test.go
Normal 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
91
internal/cli/show_cmd.go
Normal 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+" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
62
internal/cli/show_cmd_test.go
Normal file
62
internal/cli/show_cmd_test.go
Normal 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
274
internal/config/loader.go
Normal 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
|
||||
}
|
||||
322
internal/config/loader_test.go
Normal file
322
internal/config/loader_test.go
Normal 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
217
internal/config/parser.go
Normal 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)
|
||||
}
|
||||
288
internal/config/parser_test.go
Normal file
288
internal/config/parser_test.go
Normal 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
149
internal/config/types.go
Normal 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: ".",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
202
internal/config/types_test.go
Normal file
202
internal/config/types_test.go
Normal 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
161
internal/config/writer.go
Normal 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)
|
||||
}
|
||||
280
internal/config/writer_test.go
Normal file
280
internal/config/writer_test.go
Normal 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
125
internal/exec/runner.go
Normal 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
|
||||
}
|
||||
215
internal/exec/runner_test.go
Normal file
215
internal/exec/runner_test.go
Normal 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
67
internal/fzf/fzf.go
Normal 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
58
internal/fzf/fzf_test.go
Normal 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
127
internal/tmux/builder.go
Normal 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
|
||||
}
|
||||
230
internal/tmux/builder_test.go
Normal file
230
internal/tmux/builder_test.go
Normal 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
29
internal/tmux/keys.go
Normal 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
101
internal/tmux/keys_test.go
Normal 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
33
internal/tmux/session.go
Normal 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
|
||||
}
|
||||
65
internal/tmux/session_test.go
Normal file
65
internal/tmux/session_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user