commit 0f3d2a36d95e6b01b425d0988309266da178177a Author: Chen Asraf Date: Thu Jan 29 09:53:59 2026 +0200 feat: initial commit diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100755 index 0000000..6be5fe8 --- /dev/null +++ b/.github/FUNDING.yml @@ -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" diff --git a/.github/workflows/manual-homebrew-release.yml b/.github/workflows/manual-homebrew-release.yml new file mode 100755 index 0000000..073449b --- /dev/null +++ b/.github/workflows/manual-homebrew-release.yml @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100755 index 0000000..4328aff --- /dev/null +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100755 index 0000000..d1ba8a2 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..566c3a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +tx diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ee25c1f --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..f9f2928 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0580c6b --- /dev/null +++ b/README.md @@ -0,0 +1,343 @@ +# tx + +A tmux session manager that creates sessions from YAML configuration files. + +![Release](https://img.shields.io/github/v/release/chenasraf/tx) +![Downloads](https://img.shields.io/github/downloads/chenasraf/tx/total) +![License](https://img.shields.io/github/license/chenasraf/tx) + +--- + +## 🚀 Features + +- Create tmux sessions with predefined window layouts +- Complex pane splits (horizontal/vertical, nested) +- Run commands in panes on session creation +- Fuzzy finder for session selection +- Global and local config file support (with merging) +- Quick project session creation from configurable projects directory + +--- + +## 🎯 Installation + +### Download Precompiled Binaries + +Precompiled binaries for `tx` are available for **Linux** and **macOS**: + +- Visit the [Releases Page](https://github.com/chenasraf/tx/releases/latest) to download the latest + version for your platform. + +### Homebrew (macOS/Linux) + +Install from a custom tap: + +```bash +brew install chenasraf/tap/tx +``` + +### Go Install + +```bash +go install github.com/chenasraf/tx@latest +``` + +### Build from Source + +```bash +git clone https://github.com/chenasraf/tx.git +cd tx +go build -o tx . +``` + +--- + +## 🔧 Usage + +```bash +# Open a session (fuzzy finder if no name given) +tx [session-name] + +# List all configurations and active sessions +tx list +tx ls -b # bare output (just names) +tx ls -s # show only active sessions + +# Show configuration details +tx show +tx show -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 +tx rm -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! + + + Buy Me a Coffee at ko-fi.com + + +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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2d34fa9 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9e21e32 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/cli/attach_cmd.go b/internal/cli/attach_cmd.go new file mode 100644 index 0000000..e05173b --- /dev/null +++ b/internal/cli/attach_cmd.go @@ -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") +} diff --git a/internal/cli/attach_cmd_test.go b/internal/cli/attach_cmd_test.go new file mode 100644 index 0000000..3bfd218 --- /dev/null +++ b/internal/cli/attach_cmd_test.go @@ -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") + } +} diff --git a/internal/cli/create_cmd.go b/internal/cli/create_cmd.go new file mode 100644 index 0000000..388fa7b --- /dev/null +++ b/internal/cli/create_cmd.go @@ -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) +} diff --git a/internal/cli/create_cmd_test.go b/internal/cli/create_cmd_test.go new file mode 100644 index 0000000..0137aa5 --- /dev/null +++ b/internal/cli/create_cmd_test.go @@ -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) + } +} diff --git a/internal/cli/edit_cmd.go b/internal/cli/edit_cmd.go new file mode 100644 index 0000000..34dc8bd --- /dev/null +++ b/internal/cli/edit_cmd.go @@ -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) +} diff --git a/internal/cli/edit_cmd_test.go b/internal/cli/edit_cmd_test.go new file mode 100644 index 0000000..ad7499a --- /dev/null +++ b/internal/cli/edit_cmd_test.go @@ -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) + } +} diff --git a/internal/cli/list_cmd.go b/internal/cli/list_cmd.go new file mode 100644 index 0000000..5b88c95 --- /dev/null +++ b/internal/cli/list_cmd.go @@ -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 +} diff --git a/internal/cli/list_cmd_test.go b/internal/cli/list_cmd_test.go new file mode 100644 index 0000000..4664b2b --- /dev/null +++ b/internal/cli/list_cmd_test.go @@ -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) + } +} diff --git a/internal/cli/main_cmd.go b/internal/cli/main_cmd.go new file mode 100644 index 0000000..3a9314e --- /dev/null +++ b/internal/cli/main_cmd.go @@ -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) +} diff --git a/internal/cli/main_cmd_test.go b/internal/cli/main_cmd_test.go new file mode 100644 index 0000000..f4b1132 --- /dev/null +++ b/internal/cli/main_cmd_test.go @@ -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) + } +} diff --git a/internal/cli/prj_cmd.go b/internal/cli/prj_cmd.go new file mode 100644 index 0000000..cdf6581 --- /dev/null +++ b/internal/cli/prj_cmd.go @@ -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 +} diff --git a/internal/cli/prj_cmd_test.go b/internal/cli/prj_cmd_test.go new file mode 100644 index 0000000..4b480c8 --- /dev/null +++ b/internal/cli/prj_cmd_test.go @@ -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 + } + } +} diff --git a/internal/cli/remove_cmd.go b/internal/cli/remove_cmd.go new file mode 100644 index 0000000..32701a2 --- /dev/null +++ b/internal/cli/remove_cmd.go @@ -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 ", + 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 +} diff --git a/internal/cli/remove_cmd_test.go b/internal/cli/remove_cmd_test.go new file mode 100644 index 0000000..146cc1c --- /dev/null +++ b/internal/cli/remove_cmd_test.go @@ -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 " { + 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") + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..81c5ba9 --- /dev/null +++ b/internal/cli/root.go @@ -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) +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..1d98d9f --- /dev/null +++ b/internal/cli/root_test.go @@ -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") + } +} diff --git a/internal/cli/show_cmd.go b/internal/cli/show_cmd.go new file mode 100644 index 0000000..9d323be --- /dev/null +++ b/internal/cli/show_cmd.go @@ -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+" ") + } + } +} diff --git a/internal/cli/show_cmd_test.go b/internal/cli/show_cmd_test.go new file mode 100644 index 0000000..e1f682c --- /dev/null +++ b/internal/cli/show_cmd_test.go @@ -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 +} diff --git a/internal/config/loader.go b/internal/config/loader.go new file mode 100644 index 0000000..20e1ee9 --- /dev/null +++ b/internal/config/loader.go @@ -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 +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go new file mode 100644 index 0000000..e8261df --- /dev/null +++ b/internal/config/loader_test.go @@ -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'") + } +} diff --git a/internal/config/parser.go b/internal/config/parser.go new file mode 100644 index 0000000..ba6241a --- /dev/null +++ b/internal/config/parser.go @@ -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) +} diff --git a/internal/config/parser_test.go b/internal/config/parser_test.go new file mode 100644 index 0000000..a6c8bcf --- /dev/null +++ b/internal/config/parser_test.go @@ -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) + } + }) + } +} diff --git a/internal/config/types.go b/internal/config/types.go new file mode 100644 index 0000000..130b668 --- /dev/null +++ b/internal/config/types.go @@ -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: ".", + }, + }, + }, + }, +} diff --git a/internal/config/types_test.go b/internal/config/types_test.go new file mode 100644 index 0000000..0ce37c8 --- /dev/null +++ b/internal/config/types_test.go @@ -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) + } +} diff --git a/internal/config/writer.go b/internal/config/writer.go new file mode 100644 index 0000000..0ea74fa --- /dev/null +++ b/internal/config/writer.go @@ -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) +} diff --git a/internal/config/writer_test.go b/internal/config/writer_test.go new file mode 100644 index 0000000..e697816 --- /dev/null +++ b/internal/config/writer_test.go @@ -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") + } +} diff --git a/internal/exec/runner.go b/internal/exec/runner.go new file mode 100644 index 0000000..efff19d --- /dev/null +++ b/internal/exec/runner.go @@ -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 +} diff --git a/internal/exec/runner_test.go b/internal/exec/runner_test.go new file mode 100644 index 0000000..5441510 --- /dev/null +++ b/internal/exec/runner_test.go @@ -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]) + } +} diff --git a/internal/fzf/fzf.go b/internal/fzf/fzf.go new file mode 100644 index 0000000..40be933 --- /dev/null +++ b/internal/fzf/fzf.go @@ -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 +} diff --git a/internal/fzf/fzf_test.go b/internal/fzf/fzf_test.go new file mode 100644 index 0000000..e27cf12 --- /dev/null +++ b/internal/fzf/fzf_test.go @@ -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. diff --git a/internal/tmux/builder.go b/internal/tmux/builder.go new file mode 100644 index 0000000..b03795d --- /dev/null +++ b/internal/tmux/builder.go @@ -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 +} diff --git a/internal/tmux/builder_test.go b/internal/tmux/builder_test.go new file mode 100644 index 0000000..673d444 --- /dev/null +++ b/internal/tmux/builder_test.go @@ -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) + } +} diff --git a/internal/tmux/keys.go b/internal/tmux/keys.go new file mode 100644 index 0000000..3ea090f --- /dev/null +++ b/internal/tmux/keys.go @@ -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() +} diff --git a/internal/tmux/keys_test.go b/internal/tmux/keys_test.go new file mode 100644 index 0000000..96787f8 --- /dev/null +++ b/internal/tmux/keys_test.go @@ -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) + } + }) + } +} diff --git a/internal/tmux/session.go b/internal/tmux/session.go new file mode 100644 index 0000000..f0de37b --- /dev/null +++ b/internal/tmux/session.go @@ -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 +} diff --git a/internal/tmux/session_test.go b/internal/tmux/session_test.go new file mode 100644 index 0000000..a316a13 --- /dev/null +++ b/internal/tmux/session_test.go @@ -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) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4c1a9f5 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/chenasraf/tx/internal/cli" + +func main() { + cli.Execute() +}