13 Commits

Author SHA1 Message Date
github-actions[bot]
98c8b84d02 chore(master): release 1.4.0 2026-02-09 10:32:42 +02:00
cf1018abc3 feat: new UI 2026-02-09 10:27:47 +02:00
7534382360 feat: add support for aliases 2026-02-09 10:06:08 +02:00
github-actions[bot]
029556e681 chore(master): release 1.3.0 2026-02-08 23:35:25 +02:00
5c71d7bc11 feat: config name case insensitivity 2026-02-08 23:27:20 +02:00
github-actions[bot]
f8e2de631a chore(master): release 1.2.1 2026-02-01 10:58:29 +02:00
916bbef6e6 fix: select pane with window name 2026-02-01 10:56:36 +02:00
e917d6a5fc fix: version/verbose flag 2026-01-30 19:00:50 +02:00
github-actions[bot]
d02c4431a3 chore(master): release 1.2.0 2026-01-30 02:04:20 +02:00
ffd722b6c9 feat: add named layouts 2026-01-30 02:02:12 +02:00
a470a93a7d docs: update README.md 2026-01-30 01:42:23 +02:00
0c73e4c502 feat: add kill command 2026-01-30 01:40:06 +02:00
2e741464c1 feat: add session autocompletions 2026-01-30 01:35:45 +02:00
27 changed files with 1720 additions and 168 deletions

View File

@@ -1,5 +1,37 @@
# Changelog
## [1.4.0](https://github.com/chenasraf/tx/compare/v1.3.0...v1.4.0) (2026-02-09)
### Features
* add support for aliases ([7534382](https://github.com/chenasraf/tx/commit/7534382360f8b02ebadf575c53ed55dbd14e1047))
* new UI ([cf1018a](https://github.com/chenasraf/tx/commit/cf1018abc3ff108f5f7802ea7ef9fa881955e24f))
## [1.3.0](https://github.com/chenasraf/tx/compare/v1.2.1...v1.3.0) (2026-02-08)
### Features
* config name case insensitivity ([5c71d7b](https://github.com/chenasraf/tx/commit/5c71d7bc11ace64f7d96be07fba0fa7e3dae843f))
## [1.2.1](https://github.com/chenasraf/tx/compare/v1.2.0...v1.2.1) (2026-02-01)
### Bug Fixes
* select pane with window name ([916bbef](https://github.com/chenasraf/tx/commit/916bbef6e6680a4719af24dc08d89e81e85f1bae))
* version/verbose flag ([e917d6a](https://github.com/chenasraf/tx/commit/e917d6a5fc1325b3f54561b3589a9194db8c08dd))
## [1.2.0](https://github.com/chenasraf/tx/compare/v1.1.0...v1.2.0) (2026-01-30)
### Features
* add kill command ([0c73e4c](https://github.com/chenasraf/tx/commit/0c73e4c502bea7ec1002bcdc94e2f10d479d2c30))
* add named layouts ([ffd722b](https://github.com/chenasraf/tx/commit/ffd722b6c90dfa902f9e46419e523edc51737336))
* add session autocompletions ([2e74146](https://github.com/chenasraf/tx/commit/2e741464c117c68e164dbe776a18557a5f8ddf22))
## [1.1.0](https://github.com/chenasraf/tx/compare/v1.0.1...v1.1.0) (2026-01-29)

View File

@@ -88,6 +88,10 @@ tx attach [name]
# Remove a configuration
tx rm <name>
tx rm <name> -l # remove from local config
# Kill a running session
tx kill # kill current session
tx kill <name> # kill specific session
```
### Global Flags
@@ -116,6 +120,10 @@ File patterns searched:
Local config files (`.tmux_local.yaml`) are merged with global config, with local values taking
precedence.
Local configs are useful for setups where a global config is shared among computers, and you want
per-computer configs which might be gitignored. This allows you to not check-in your local configs
while also being able to share a config that might be checked into git.
### Configuration Format
```yaml
@@ -247,6 +255,7 @@ myproject:
| `shell` | Shell to use for command execution |
| `projects_path` | Directory for `tx prj` command (required for prj) |
| `default_layout` | Default pane layout for new windows (see below) |
| `named_layouts` | Reusable named layouts (see below) |
#### Default Layout
@@ -286,6 +295,36 @@ Example - horizontal split with vertical sub-split (default):
clock: true
```
#### Named Layouts
Define reusable layouts that can be referenced by name in session configurations:
```yaml
.config:
named_layouts:
dev:
cwd: .
cmd: npm run dev
split:
direction: h
child:
cwd: .
cmd: npm run test:watch
simple:
cwd: .
clock: true
myproject:
root: ~/Dev/myproject
windows:
- name: main
cwd: .
layout: dev # references the "dev" named layout
- name: logs
cwd: ./logs
layout: simple # references the "simple" named layout
```
#### Shell Resolution Order
The shell used for executing commands is determined in this order:

23
go.mod
View File

@@ -3,23 +3,30 @@ module github.com/chenasraf/tx
go 1.25.5
require (
github.com/ktr0731/go-fuzzyfinder v0.9.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.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/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // 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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // 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
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.24.0 // indirect
)

89
go.sum
View File

@@ -1,31 +1,37 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
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=
@@ -33,44 +39,17 @@ 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=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
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/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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=

View File

@@ -10,11 +10,12 @@ import (
)
var attachCmd = &cobra.Command{
Use: "attach [key]",
Aliases: []string{"a"},
Short: "Attach to a tmux session",
Args: cobra.MaximumNArgs(1),
RunE: runAttach,
Use: "attach [key]",
Aliases: []string{"a"},
Short: "Attach to a tmux session",
Args: cobra.MaximumNArgs(1),
RunE: runAttach,
ValidArgsFunction: completeSessionNames,
}
func runAttach(cmd *cobra.Command, args []string) error {
@@ -32,12 +33,12 @@ func runAttach(cmd *cobra.Command, args []string) error {
return err
}
item, exists := allConfig[key]
item, actualKey, exists := allConfig.Get(key)
if !exists {
return NewUserError("tmux config item '" + key + "' not found")
}
parsed := config.ParseConfig(key, item)
parsed := config.ParseConfig(actualKey, item)
if !tmux.SessionExists(opts, parsed.Name) {
return NewUserError("tmux session '" + parsed.Name + "' does not exist")

42
internal/cli/kill_cmd.go Normal file
View File

@@ -0,0 +1,42 @@
package cli
import (
"github.com/chenasraf/tx/internal/exec"
"github.com/chenasraf/tx/internal/tmux"
"github.com/spf13/cobra"
)
var killCmd = &cobra.Command{
Use: "kill [session]",
Aliases: []string{"k"},
Short: "Kill a running tmux session (current session if no arg)",
Args: cobra.MaximumNArgs(1),
RunE: runKill,
ValidArgsFunction: completeRunningSessions,
}
func runKill(cmd *cobra.Command, args []string) error {
opts := GetOpts()
if len(args) > 0 {
sessionName := args[0]
// Check if session exists
if !tmux.SessionExists(opts, sessionName) {
return NewUserError("tmux session '" + sessionName + "' does not exist")
}
return tmux.KillSession(opts, sessionName)
}
// No arg - kill current session
return exec.RunCommand(opts, "tmux kill-session")
}
// completeRunningSessions returns running session names for shell completion
func completeRunningSessions(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Don't complete if we already have an argument
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return tmux.GetSessionNames(), cobra.ShellCompDirectiveNoFileComp
}

View File

@@ -97,7 +97,12 @@ func runList(cmd *cobra.Command, args []string) error {
fmt.Println("tmux configurations:")
fmt.Println()
for _, k := range keys {
fmt.Println(" -", k)
item := rawConfig[k]
if len(item.Aliases) > 0 {
fmt.Printf(" - %s \033[2m%s\033[0m\n", k, strings.Join(item.Aliases, ", "))
} else {
fmt.Println(" -", k)
}
}
return nil

View File

@@ -24,20 +24,18 @@ func runMain(cmd *cobra.Command, args []string) error {
return err
}
keys := make([]string, 0, len(info.Merged.Config))
for k := range info.Merged.Config {
keys = append(keys, k)
}
items := buildFzfItems(info.Merged.Config)
selected, err := fzf.Run(keys, fzf.Options{})
selected, err := fzf.Run(items, fzf.Options{})
if err != nil {
return err
}
if _, exists := info.Merged.Config[selected]; !exists {
if _, actualKey, exists := info.Merged.Config.Get(selected); !exists {
return NewUserError("tmux config item '" + selected + "' not found")
} else {
key = actualKey
}
key = selected
}
// Get config
@@ -46,12 +44,12 @@ func runMain(cmd *cobra.Command, args []string) error {
return err
}
item, exists := allConfig[key]
item, actualKey, exists := allConfig.Get(key)
if !exists {
return NewUserError("tmux config item '" + key + "' not found")
}
parsed := config.ParseConfig(key, item)
parsed := config.ParseConfig(actualKey, item)
// Check if session exists
if tmux.SessionExists(opts, parsed.Name) {

View File

@@ -58,6 +58,41 @@ testproject:
}
}
func TestRunMain_CaseInsensitiveKey(t *testing.T) {
// Create a temp directory with a config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".tmux.yaml")
content := `
Notes:
root: /tmp/notes
windows:
- ./src
`
err := os.WriteFile(configPath, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
// Set XDG_CONFIG_HOME to temp directory so config is found
oldXDG := os.Getenv("XDG_CONFIG_HOME")
_ = os.Setenv("XDG_CONFIG_HOME", tmpDir)
defer func() { _ = os.Setenv("XDG_CONFIG_HOME", oldXDG) }()
dry = true
defer func() { dry = false }()
// Should succeed with different casings
for _, key := range []string{"Notes", "notes", "NOTES", "nOtEs"} {
t.Run(key, func(t *testing.T) {
err := runMain(nil, []string{key})
if err != nil {
t.Errorf("expected no error for key %q, got %v", key, err)
}
})
}
}
func TestRunMain_InvalidKey(t *testing.T) {
// Create a temp directory with a config file
tmpDir := t.TempDir()

View File

@@ -76,7 +76,11 @@ func runPrj(cmd *cobra.Command, args []string) error {
// If no name, use fuzzy finder to select from existing projects
if name == "" {
selected, err := fzf.Run(projects, fzf.Options{})
items := make([]fzf.Item, len(projects))
for i, p := range projects {
items[i] = fzf.Item{Key: p, Name: p}
}
selected, err := fzf.Run(items, fzf.Options{})
if err != nil {
return err
}

View File

@@ -11,11 +11,12 @@ import (
var removeLocal bool
var removeCmd = &cobra.Command{
Use: "remove <key>",
Aliases: []string{"rm"},
Short: "Remove a tmux workspace from the config file",
Args: cobra.ExactArgs(1),
RunE: runRemove,
Use: "remove <key>",
Aliases: []string{"rm"},
Short: "Remove a tmux workspace from the config file",
Args: cobra.ExactArgs(1),
RunE: runRemove,
ValidArgsFunction: completeSessionNames,
}
func init() {
@@ -32,11 +33,12 @@ func runRemove(cmd *cobra.Command, args []string) error {
return err
}
if _, exists := allConfig[key]; !exists {
_, actualKey, exists := allConfig.Get(key)
if !exists {
return NewUserError("tmux config item '" + key + "' not found")
}
err = config.RemoveConfigFromFile(key, removeLocal, opts.Dry)
err = config.RemoveConfigFromFile(actualKey, removeLocal, opts.Dry)
if err != nil {
return err
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/chenasraf/tx/internal/config"
"github.com/chenasraf/tx/internal/exec"
"github.com/chenasraf/tx/internal/fzf"
"github.com/spf13/cobra"
)
@@ -52,6 +53,39 @@ It supports complex pane layouts, fzf selection, and config merging.`,
RunE: runMain,
SilenceErrors: true, // We handle error printing in Execute()
SilenceUsage: true, // Don't print usage on runtime errors
ValidArgsFunction: completeSessionNames,
}
// completeSessionNames returns session names for shell completion
func completeSessionNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Don't complete if we already have an argument
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
cfg, err := config.GetTmuxConfig()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var names []string
for name, item := range cfg {
if name != config.ConfigKey {
names = append(names, name)
names = append(names, item.Aliases...)
}
}
return names, cobra.ShellCompDirectiveNoFileComp
}
// buildFzfItems creates fzf items from a config file
func buildFzfItems(cfg config.ConfigFile) []fzf.Item {
items := make([]fzf.Item, 0, len(cfg))
for k, v := range cfg {
items = append(items, fzf.Item{Key: k, Name: k, Aliases: v.Aliases})
}
return items
}
// initConfig loads global configuration and applies settings
@@ -67,6 +101,10 @@ func initConfig(cmd *cobra.Command, args []string) error {
if globalConfig.DefaultLayout != nil {
config.ConfiguredDefaultLayout = globalConfig.DefaultLayout
}
// Apply named layouts from config
if globalConfig.NamedLayouts != nil {
config.ConfiguredNamedLayouts = globalConfig.NamedLayouts
}
}
return nil
}
@@ -74,6 +112,7 @@ func initConfig(cmd *cobra.Command, args []string) error {
// Execute adds all child commands to the root command and sets flags appropriately
func Execute() {
rootCmd.Version = Version
rootCmd.SetVersionTemplate("{{.Version}}\n")
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "Error:", err.Error())
os.Exit(1)
@@ -82,8 +121,9 @@ func Execute() {
func init() {
// Global flags
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Verbose logging")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "V", false, "Verbose logging")
rootCmd.PersistentFlags().BoolVarP(&dry, "dry", "d", false, "Dry run (log commands, don't execute)")
rootCmd.Flags().BoolP("version", "v", false, "Print version")
// Add subcommands
rootCmd.AddCommand(listCmd)
@@ -93,4 +133,5 @@ func init() {
rootCmd.AddCommand(createCmd)
rootCmd.AddCommand(attachCmd)
rootCmd.AddCommand(prjCmd)
rootCmd.AddCommand(killCmd)
}

View File

@@ -12,11 +12,12 @@ import (
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,
Use: "show [key]",
Aliases: []string{"s"},
Short: "Show the tmux configuration for a specific key",
Args: cobra.MaximumNArgs(1),
RunE: runShow,
ValidArgsFunction: completeSessionNames,
}
func init() {
@@ -36,24 +37,21 @@ func runShow(cmd *cobra.Command, args []string) error {
// If no key, use fzf
if key == "" {
keys := make([]string, 0, len(allConfig))
for k := range allConfig {
keys = append(keys, k)
}
items := buildFzfItems(allConfig)
selected, err := fzf.Run(keys, fzf.Options{})
selected, err := fzf.Run(items, fzf.Options{})
if err != nil {
return err
}
key = selected
}
item, exists := allConfig[key]
item, actualKey, exists := allConfig.Get(key)
if !exists {
return NewUserError("tmux config item '" + key + "' not found")
}
parsed := config.ParseConfig(key, item)
parsed := config.ParseConfig(actualKey, item)
if showJSON {
data, err := json.Marshal(parsed)

View File

@@ -168,6 +168,9 @@ func mergeConfigs(configs ...ConfigFile) ConfigFile {
if len(value.Windows) > 0 {
merged.Windows = value.Windows
}
if len(value.Aliases) > 0 {
merged.Aliases = value.Aliases
}
out[key] = merged
} else {
out[key] = value
@@ -193,6 +196,14 @@ func mergeGlobalConfigs(configs ...*GlobalConfig) *GlobalConfig {
if cfg.DefaultLayout != nil {
result.DefaultLayout = cfg.DefaultLayout
}
if cfg.NamedLayouts != nil {
if result.NamedLayouts == nil {
result.NamedLayouts = make(map[string]*TmuxPaneLayout)
}
for name, layout := range cfg.NamedLayouts {
result.NamedLayouts[name] = layout
}
}
}
return result
}

View File

@@ -150,6 +150,42 @@ func TestMergeConfigs(t *testing.T) {
}
}
func TestMergeConfigs_Aliases(t *testing.T) {
config1 := ConfigFile{
"project": {Root: "/tmp/p1", Aliases: []string{"p1", "proj"}},
}
config2 := ConfigFile{
"project": {Root: "/tmp/p2", Aliases: []string{"p2"}},
}
merged := mergeConfigs(config1, config2)
// Aliases should be overridden by config2
if len(merged["project"].Aliases) != 1 {
t.Errorf("expected 1 alias, got %d", len(merged["project"].Aliases))
}
if merged["project"].Aliases[0] != "p2" {
t.Errorf("expected alias 'p2', got %q", merged["project"].Aliases[0])
}
}
func TestMergeConfigs_AliasesPreserved(t *testing.T) {
config1 := ConfigFile{
"project": {Root: "/tmp/p1", Aliases: []string{"p1", "proj"}},
}
config2 := ConfigFile{
"project": {Root: "/tmp/p2"}, // No aliases - should preserve config1's aliases
}
merged := mergeConfigs(config1, config2)
if len(merged["project"].Aliases) != 2 {
t.Errorf("expected 2 aliases preserved, got %d", len(merged["project"].Aliases))
}
}
func TestMergeConfigs_Nil(t *testing.T) {
config1 := ConfigFile{
"project1": {Root: "/tmp/p1"},
@@ -281,6 +317,196 @@ func TestMergeConfigs_SkipsConfigSection(t *testing.T) {
}
}
func TestMergeGlobalConfigs(t *testing.T) {
config1 := &GlobalConfig{
Shell: "/bin/bash",
ProjectsPath: "/old/path",
}
config2 := &GlobalConfig{
Shell: "/bin/zsh",
}
merged := mergeGlobalConfigs(config1, config2)
// Shell should be overridden by config2
if merged.Shell != "/bin/zsh" {
t.Errorf("expected Shell to be '/bin/zsh', got %q", merged.Shell)
}
// ProjectsPath should be preserved from config1
if merged.ProjectsPath != "/old/path" {
t.Errorf("expected ProjectsPath to be '/old/path', got %q", merged.ProjectsPath)
}
}
func TestMergeGlobalConfigs_Nil(t *testing.T) {
config1 := &GlobalConfig{
Shell: "/bin/bash",
}
merged := mergeGlobalConfigs(nil, config1, nil)
if merged.Shell != "/bin/bash" {
t.Errorf("expected Shell to be '/bin/bash', got %q", merged.Shell)
}
}
func TestMergeGlobalConfigs_NamedLayouts(t *testing.T) {
config1 := &GlobalConfig{
NamedLayouts: map[string]*TmuxPaneLayout{
"dev": {
Cwd: ".",
Cmd: "npm run dev",
},
"common": {
Cwd: ".",
Cmd: "original command",
},
},
}
config2 := &GlobalConfig{
NamedLayouts: map[string]*TmuxPaneLayout{
"test": {
Cwd: ".",
Cmd: "npm test",
},
"common": {
Cwd: ".",
Cmd: "overridden command",
},
},
}
merged := mergeGlobalConfigs(config1, config2)
if merged.NamedLayouts == nil {
t.Fatal("expected NamedLayouts to not be nil")
}
// Should have 3 layouts (dev, test, common)
if len(merged.NamedLayouts) != 3 {
t.Errorf("expected 3 named layouts, got %d", len(merged.NamedLayouts))
}
// dev should be from config1
if dev := merged.NamedLayouts["dev"]; dev == nil {
t.Error("expected 'dev' layout to exist")
} else if dev.Cmd != "npm run dev" {
t.Errorf("expected dev.Cmd to be 'npm run dev', got %q", dev.Cmd)
}
// test should be from config2
if test := merged.NamedLayouts["test"]; test == nil {
t.Error("expected 'test' layout to exist")
} else if test.Cmd != "npm test" {
t.Errorf("expected test.Cmd to be 'npm test', got %q", test.Cmd)
}
// common should be overridden by config2
if common := merged.NamedLayouts["common"]; common == nil {
t.Error("expected 'common' layout to exist")
} else if common.Cmd != "overridden command" {
t.Errorf("expected common.Cmd to be 'overridden command', got %q", common.Cmd)
}
}
func TestMergeGlobalConfigs_DefaultLayout(t *testing.T) {
config1 := &GlobalConfig{
DefaultLayout: &TmuxPaneLayout{
Cwd: ".",
Cmd: "original",
},
}
config2 := &GlobalConfig{
DefaultLayout: &TmuxPaneLayout{
Cwd: ".",
Clock: true,
},
}
merged := mergeGlobalConfigs(config1, config2)
if merged.DefaultLayout == nil {
t.Fatal("expected DefaultLayout to not be nil")
}
// DefaultLayout should be fully replaced by config2
if !merged.DefaultLayout.Clock {
t.Error("expected DefaultLayout.Clock to be true")
}
}
func TestLoadGlobalConfig_WithNamedLayouts(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".tmux.yaml")
content := `
.config:
shell: /bin/zsh
named_layouts:
dev:
cwd: .
cmd: npm run dev
split:
direction: h
child:
cwd: .
cmd: npm test
simple:
cwd: .
clock: true
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.NamedLayouts == nil {
t.Fatal("expected NamedLayouts to not be nil")
}
if len(globalConfig.NamedLayouts) != 2 {
t.Errorf("expected 2 named layouts, got %d", len(globalConfig.NamedLayouts))
}
dev := globalConfig.NamedLayouts["dev"]
if dev == nil {
t.Fatal("expected 'dev' layout to exist")
}
if dev.Cmd != "npm run dev" {
t.Errorf("expected dev.Cmd to be 'npm run dev', got %q", dev.Cmd)
}
if dev.Split == nil {
t.Fatal("expected dev.Split to not be nil")
}
if dev.Split.Direction != "h" {
t.Errorf("expected dev.Split.Direction to be 'h', got %q", dev.Split.Direction)
}
simple := globalConfig.NamedLayouts["simple"]
if simple == nil {
t.Fatal("expected 'simple' layout to exist")
}
if !simple.Clock {
t.Error("expected simple.Clock to be true")
}
}
func TestFindConfigFile(t *testing.T) {
// Create a temporary directory with a config file
tmpDir := t.TempDir()

View File

@@ -119,6 +119,11 @@ func parseLayout(layoutInput *TmuxLayoutInput, root string) TmuxPaneLayout {
}
if layoutInput.IsString {
// Check if it's a named layout reference
if namedLayout := GetNamedLayout(layoutInput.String); namedLayout != nil {
return parsePaneLayout(namedLayout, root)
}
// Otherwise treat as directory path
return TmuxPaneLayout{
Cwd: resolvePath(root, layoutInput.String),
Cmd: DefaultEmptyPane.Cmd,
@@ -164,9 +169,10 @@ func parseLayoutWithCwd(layoutInput *TmuxLayoutInput, cwd string) TmuxPaneLayout
// 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,
Cwd: resolvePath(root, pane.Cwd),
Cmd: pane.Cmd,
Zoom: pane.Zoom,
Clock: pane.Clock,
}
if pane.Split != nil {

View File

@@ -286,3 +286,123 @@ func TestResolvePath(t *testing.T) {
})
}
}
func TestParseConfig_NamedLayout(t *testing.T) {
// Save and restore original state
originalLayouts := ConfiguredNamedLayouts
defer func() { ConfiguredNamedLayouts = originalLayouts }()
// Configure named layouts
ConfiguredNamedLayouts = map[string]*TmuxPaneLayout{
"dev": {
Cwd: ".",
Cmd: "npm run dev",
Split: &TmuxSplitLayout{
Direction: "h",
Child: &TmuxPaneLayout{
Cwd: ".",
Cmd: "npm run test:watch",
},
},
},
"simple": {
Cwd: ".",
Clock: true,
},
}
input := TmuxConfigItemInput{
Root: "/tmp/myproject",
Name: "myproject",
Windows: []TmuxWindowInput{
{
Window: &TmuxWindow{
Name: "main",
Cwd: "./src",
Layout: &TmuxLayoutInput{
IsString: true,
String: "dev", // Reference named layout
},
},
},
{
Window: &TmuxWindow{
Name: "logs",
Cwd: "./logs",
Layout: &TmuxLayoutInput{
IsString: true,
String: "simple", // Reference named layout
},
},
},
},
}
result := ParseConfig("myproject", input)
if len(result.Windows) != 2 {
t.Fatalf("expected 2 windows, got %d", len(result.Windows))
}
// Check first window uses "dev" layout
mainLayout := result.Windows[0].Layout
if mainLayout.Cmd != "npm run dev" {
t.Errorf("expected first window Cmd to be 'npm run dev', got %q", mainLayout.Cmd)
}
if mainLayout.Split == nil {
t.Fatal("expected first window Split to not be nil")
}
if mainLayout.Split.Direction != "h" {
t.Errorf("expected first window Split.Direction to be 'h', got %q", mainLayout.Split.Direction)
}
if mainLayout.Split.Child == nil {
t.Fatal("expected first window Split.Child to not be nil")
}
if mainLayout.Split.Child.Cmd != "npm run test:watch" {
t.Errorf("expected first window child Cmd to be 'npm run test:watch', got %q", mainLayout.Split.Child.Cmd)
}
// Check second window uses "simple" layout
logsLayout := result.Windows[1].Layout
if !logsLayout.Clock {
t.Error("expected second window Clock to be true")
}
}
func TestParseConfig_NamedLayoutNotFound(t *testing.T) {
// Save and restore original state
originalLayouts := ConfiguredNamedLayouts
defer func() { ConfiguredNamedLayouts = originalLayouts }()
// No named layouts configured
ConfiguredNamedLayouts = nil
input := TmuxConfigItemInput{
Root: "/tmp/myproject",
Name: "myproject",
Windows: []TmuxWindowInput{
{
Window: &TmuxWindow{
Name: "main",
Cwd: "./src",
Layout: &TmuxLayoutInput{
IsString: true,
String: "nonexistent", // Not a named layout, should be treated as path
},
},
},
},
}
result := ParseConfig("myproject", input)
if len(result.Windows) != 1 {
t.Fatalf("expected 1 window, got %d", len(result.Windows))
}
// Should be treated as a directory path
layout := result.Windows[0].Layout
if layout.Cwd != "/tmp/myproject/src/nonexistent" {
t.Errorf("expected layout Cwd to be '/tmp/myproject/src/nonexistent', got %q", layout.Cwd)
}
}

View File

@@ -1,23 +1,54 @@
package config
import (
"strings"
"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"`
DefaultLayout *TmuxPaneLayout `yaml:"default_layout,omitempty"`
Shell string `yaml:"shell,omitempty"`
ProjectsPath string `yaml:"projects_path,omitempty"`
DefaultLayout *TmuxPaneLayout `yaml:"default_layout,omitempty"`
NamedLayouts map[string]*TmuxPaneLayout `yaml:"named_layouts,omitempty"`
}
// ConfigFile represents the top-level config file: map of session name -> config
type ConfigFile map[string]TmuxConfigItemInput
// Get performs a lookup of a key in the config file.
// It first tries exact key match, then case-insensitive key match,
// then matches against aliases (case-insensitive).
// It returns the config item, the actual key as stored in the config, and whether it was found.
func (c ConfigFile) Get(key string) (TmuxConfigItemInput, string, bool) {
// Try exact match first
if item, ok := c[key]; ok {
return item, key, true
}
// Fall back to case-insensitive match
lower := strings.ToLower(key)
for k, v := range c {
if strings.ToLower(k) == lower {
return v, k, true
}
}
// Fall back to alias match (case-insensitive)
for k, v := range c {
for _, alias := range v.Aliases {
if strings.ToLower(alias) == lower {
return v, k, true
}
}
}
return TmuxConfigItemInput{}, "", false
}
// TmuxConfigItemInput represents a single tmux session configuration
type TmuxConfigItemInput struct {
Root string `yaml:"root"`
Name string `yaml:"name,omitempty"`
Aliases []string `yaml:"aliases,omitempty"`
BlankWindow bool `yaml:"blank_window,omitempty"`
Windows []TmuxWindowInput `yaml:"windows,omitempty"`
}
@@ -134,6 +165,9 @@ var DefaultEmptyPane = TmuxPaneLayout{
// ConfiguredDefaultLayout holds the user-configured default layout (set from .config)
var ConfiguredDefaultLayout *TmuxPaneLayout
// ConfiguredNamedLayouts holds user-configured named layouts (set from .config)
var ConfiguredNamedLayouts map[string]*TmuxPaneLayout
// GetDefaultLayout returns the configured default layout or the hardcoded default
func GetDefaultLayout() *TmuxPaneLayout {
if ConfiguredDefaultLayout != nil {
@@ -142,6 +176,14 @@ func GetDefaultLayout() *TmuxPaneLayout {
return &DefaultEmptyLayout
}
// GetNamedLayout returns a named layout by name, or nil if not found
func GetNamedLayout(name string) *TmuxPaneLayout {
if ConfiguredNamedLayouts == nil {
return nil
}
return ConfiguredNamedLayouts[name]
}
// DefaultEmptyLayout is the default layout with horizontal and vertical splits
var DefaultEmptyLayout = TmuxPaneLayout{
Cwd: ".",

View File

@@ -180,6 +180,182 @@ another:
}
}
func TestConfigFile_Get_ByAlias(t *testing.T) {
config := ConfigFile{
"my_session": {Root: "/tmp/my", Aliases: []string{"ms", "foo-session"}},
"work": {Root: "/tmp/work"},
}
tests := []struct {
lookup string
wantKey string
wantFound bool
}{
{"ms", "my_session", true},
{"foo-session", "my_session", true},
{"MS", "my_session", true}, // case-insensitive alias
{"Foo-Session", "my_session", true}, // case-insensitive alias
{"my_session", "my_session", true}, // exact key still works
{"work", "work", true},
{"missing", "", false},
}
for _, tt := range tests {
t.Run(tt.lookup, func(t *testing.T) {
_, actualKey, ok := config.Get(tt.lookup)
if ok != tt.wantFound {
t.Errorf("Get(%q): found=%v, want %v", tt.lookup, ok, tt.wantFound)
}
if ok && actualKey != tt.wantKey {
t.Errorf("Get(%q): actualKey=%q, want %q", tt.lookup, actualKey, tt.wantKey)
}
})
}
}
func TestConfigFile_Get_KeyTakesPrecedenceOverAlias(t *testing.T) {
// If a key name matches another item's alias, the key should win
config := ConfigFile{
"ms": {Root: "/tmp/ms"},
"my_session": {Root: "/tmp/my", Aliases: []string{"ms"}},
}
_, actualKey, ok := config.Get("ms")
if !ok {
t.Fatal("expected to find 'ms'")
}
if actualKey != "ms" {
t.Errorf("expected exact key match 'ms', got %q", actualKey)
}
}
func TestTmuxConfigItemInput_Aliases_YAML(t *testing.T) {
yamlData := `
my_session:
root: ~/projects/foo
aliases: [ms, foo-session]
windows:
- ./src
`
var config ConfigFile
err := yaml.Unmarshal([]byte(yamlData), &config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
item, ok := config["my_session"]
if !ok {
t.Fatal("expected 'my_session' config")
}
if len(item.Aliases) != 2 {
t.Fatalf("expected 2 aliases, got %d", len(item.Aliases))
}
if item.Aliases[0] != "ms" {
t.Errorf("expected first alias 'ms', got %q", item.Aliases[0])
}
if item.Aliases[1] != "foo-session" {
t.Errorf("expected second alias 'foo-session', got %q", item.Aliases[1])
}
}
func TestConfigFile_Get_ExactMatch(t *testing.T) {
config := ConfigFile{
"notes": {Root: "/tmp/notes"},
"work": {Root: "/tmp/work"},
}
item, actualKey, ok := config.Get("notes")
if !ok {
t.Fatal("expected to find 'notes'")
}
if actualKey != "notes" {
t.Errorf("expected actualKey 'notes', got %q", actualKey)
}
if item.Root != "/tmp/notes" {
t.Errorf("expected Root '/tmp/notes', got %q", item.Root)
}
}
func TestConfigFile_Get_CaseInsensitive(t *testing.T) {
config := ConfigFile{
"Notes": {Root: "/tmp/notes"},
"work": {Root: "/tmp/work"},
}
tests := []struct {
lookup string
wantKey string
wantFound bool
}{
{"Notes", "Notes", true},
{"notes", "Notes", true},
{"NOTES", "Notes", true},
{"nOtEs", "Notes", true},
{"work", "work", true},
{"Work", "work", true},
{"WORK", "work", true},
{"missing", "", false},
}
for _, tt := range tests {
t.Run(tt.lookup, func(t *testing.T) {
_, actualKey, ok := config.Get(tt.lookup)
if ok != tt.wantFound {
t.Errorf("Get(%q): found=%v, want %v", tt.lookup, ok, tt.wantFound)
}
if ok && actualKey != tt.wantKey {
t.Errorf("Get(%q): actualKey=%q, want %q", tt.lookup, actualKey, tt.wantKey)
}
})
}
}
func TestConfigFile_Get_ExactMatchTakesPrecedence(t *testing.T) {
// If both "notes" and "Notes" exist, exact match should win
config := ConfigFile{
"notes": {Root: "/tmp/lower"},
"Notes": {Root: "/tmp/upper"},
}
_, actualKey, ok := config.Get("notes")
if !ok {
t.Fatal("expected to find 'notes'")
}
if actualKey != "notes" {
t.Errorf("expected exact match 'notes', got %q", actualKey)
}
_, actualKey, ok = config.Get("Notes")
if !ok {
t.Fatal("expected to find 'Notes'")
}
if actualKey != "Notes" {
t.Errorf("expected exact match 'Notes', got %q", actualKey)
}
}
func TestConfigFile_Get_NotFound(t *testing.T) {
config := ConfigFile{
"notes": {Root: "/tmp/notes"},
}
_, _, ok := config.Get("missing")
if ok {
t.Error("expected not found for 'missing'")
}
}
func TestConfigFile_Get_EmptyConfig(t *testing.T) {
config := ConfigFile{}
_, _, ok := config.Get("anything")
if ok {
t.Error("expected not found in empty config")
}
}
func TestDefaultEmptyLayout(t *testing.T) {
if DefaultEmptyLayout.Cwd != "." {
t.Errorf("expected Cwd to be '.', got %q", DefaultEmptyLayout.Cwd)
@@ -200,3 +376,130 @@ func TestDefaultEmptyLayout(t *testing.T) {
t.Errorf("expected nested Split.Direction to be 'v', got %q", DefaultEmptyLayout.Split.Child.Split.Direction)
}
}
func TestGetNamedLayout(t *testing.T) {
// Save and restore original state
originalLayouts := ConfiguredNamedLayouts
defer func() { ConfiguredNamedLayouts = originalLayouts }()
// Test when no named layouts are configured
ConfiguredNamedLayouts = nil
if layout := GetNamedLayout("test"); layout != nil {
t.Error("expected nil when no named layouts configured")
}
// Test when named layout exists
ConfiguredNamedLayouts = map[string]*TmuxPaneLayout{
"dev": {
Cwd: ".",
Cmd: "npm run dev",
},
"simple": {
Cwd: ".",
Clock: true,
},
}
layout := GetNamedLayout("dev")
if layout == nil {
t.Fatal("expected to find 'dev' layout")
}
if layout.Cmd != "npm run dev" {
t.Errorf("expected Cmd to be 'npm run dev', got %q", layout.Cmd)
}
layout = GetNamedLayout("simple")
if layout == nil {
t.Fatal("expected to find 'simple' layout")
}
if !layout.Clock {
t.Error("expected Clock to be true")
}
// Test when named layout doesn't exist
if layout := GetNamedLayout("nonexistent"); layout != nil {
t.Error("expected nil for nonexistent layout")
}
}
func TestGetDefaultLayout(t *testing.T) {
// Save and restore original state
originalLayout := ConfiguredDefaultLayout
defer func() { ConfiguredDefaultLayout = originalLayout }()
// Test when no custom default layout is configured
ConfiguredDefaultLayout = nil
layout := GetDefaultLayout()
if layout != &DefaultEmptyLayout {
t.Error("expected DefaultEmptyLayout when no custom layout configured")
}
// Test when custom default layout is configured
customLayout := &TmuxPaneLayout{
Cwd: ".",
Clock: true,
}
ConfiguredDefaultLayout = customLayout
layout = GetDefaultLayout()
if layout != customLayout {
t.Error("expected custom layout when configured")
}
}
func TestGlobalConfig_NamedLayouts(t *testing.T) {
yamlData := `
shell: /bin/zsh
named_layouts:
dev:
cwd: .
cmd: npm run dev
split:
direction: h
child:
cwd: .
cmd: npm test
simple:
cwd: .
clock: true
`
var cfg GlobalConfig
err := yaml.Unmarshal([]byte(yamlData), &cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Shell != "/bin/zsh" {
t.Errorf("expected Shell to be '/bin/zsh', got %q", cfg.Shell)
}
if cfg.NamedLayouts == nil {
t.Fatal("expected NamedLayouts to not be nil")
}
if len(cfg.NamedLayouts) != 2 {
t.Errorf("expected 2 named layouts, got %d", len(cfg.NamedLayouts))
}
dev := cfg.NamedLayouts["dev"]
if dev == nil {
t.Fatal("expected 'dev' layout to exist")
}
if dev.Cmd != "npm run dev" {
t.Errorf("expected dev.Cmd to be 'npm run dev', got %q", dev.Cmd)
}
if dev.Split == nil {
t.Fatal("expected dev.Split to not be nil")
}
if dev.Split.Direction != "h" {
t.Errorf("expected dev.Split.Direction to be 'h', got %q", dev.Split.Direction)
}
simple := cfg.NamedLayouts["simple"]
if simple == nil {
t.Fatal("expected 'simple' layout to exist")
}
if !simple.Clock {
t.Error("expected simple.Clock to be true")
}
}

View File

@@ -37,7 +37,7 @@ func AddSimpleConfigToFile(config ParsedTmuxConfigItem, local bool, dryRun bool)
return err
}
if _, exists := allConfigs[config.Name]; exists && !dryRun {
if _, _, exists := allConfigs.Get(config.Name); exists && !dryRun {
return fmt.Errorf("%w: '%s'", ErrConfigItemExists, config.Name)
}

View File

@@ -2,66 +2,309 @@ package fzf
import (
"errors"
"strings"
"github.com/ktr0731/go-fuzzyfinder"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ErrSelectionCancelled is returned when the user cancels selection
var ErrSelectionCancelled = errors.New("selection cancelled")
// Item represents a fuzzy finder item.
type Item struct {
Key string
Name string
Aliases []string
}
// Options for fuzzy finder
type Options struct {
AllowCustom bool // Note: go-fuzzyfinder doesn't support custom input like fzf --print-query
AllowCustom bool
}
// 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
}
// Styles used for rendering.
var (
normalStyle = lipgloss.NewStyle()
dimStyle = lipgloss.NewStyle().Faint(true)
matchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true)
dimMatchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Faint(true)
selectedStyle = lipgloss.NewStyle().Reverse(true)
promptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true)
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6"))
)
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
type model struct {
items []Item
filtered []filteredItem
query string
cursor int // index in filtered
offset int // scroll offset
width int
height int
selected string
cancelled bool
quitting bool
}
// 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
func initialModel(items []Item) model {
m := model{
items: items,
width: 80,
height: 24,
}
m.filtered = filterAndSort(items, "")
return m
}
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 ""
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.clampScroll()
return m, nil
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC, tea.KeyEsc:
m.cancelled = true
m.quitting = true
return m, tea.Quit
case tea.KeyEnter:
if len(m.filtered) > 0 && m.cursor < len(m.filtered) {
m.selected = m.filtered[m.cursor].item.Key
} else {
m.cancelled = true
}
return preview(i)
}),
)
m.quitting = true
return m, tea.Quit
if err != nil {
if errors.Is(err, fuzzyfinder.ErrAbort) {
return "", ErrSelectionCancelled
case tea.KeyBackspace:
if len(m.query) > 0 {
m.query = m.query[:len(m.query)-1]
m.refilter()
}
return m, nil
case tea.KeyCtrlU:
m.query = ""
m.refilter()
return m, nil
case tea.KeyUp, tea.KeyCtrlK:
if m.cursor < len(m.filtered)-1 {
m.cursor++
m.clampScroll()
}
return m, nil
case tea.KeyDown, tea.KeyCtrlJ:
if m.cursor > 0 {
m.cursor--
m.clampScroll()
}
return m, nil
case tea.KeyRunes:
m.query += string(msg.Runes)
m.refilter()
return m, nil
}
}
return m, nil
}
func (m *model) refilter() {
m.filtered = filterAndSort(m.items, m.query)
m.cursor = 0
m.offset = 0
}
func (m *model) clampScroll() {
// Available rows for items (height minus prompt row)
maxVisible := m.height - 1
if maxVisible < 1 {
maxVisible = 1
}
if m.cursor < m.offset {
m.offset = m.cursor
}
if m.cursor >= m.offset+maxVisible {
m.offset = m.cursor - maxVisible + 1
}
}
func (m model) View() string {
if m.quitting {
return ""
}
// Available rows for items (height minus prompt row)
maxVisible := m.height - 1
if maxVisible < 1 {
maxVisible = 1
}
end := m.offset + maxVisible
if end > len(m.filtered) {
end = len(m.filtered)
}
// Build item rows (reversed: highest index on top, lowest near prompt)
var rows []string
for i := m.offset; i < end; i++ {
fi := m.filtered[i]
rows = append(rows, m.renderItem(fi, i == m.cursor))
}
var b strings.Builder
// Pad with empty lines so items stick to the bottom
emptyLines := maxVisible - len(rows)
for i := 0; i < emptyLines; i++ {
b.WriteString("\n")
}
// Render items in reverse order (first match closest to prompt)
for i := len(rows) - 1; i >= 0; i-- {
b.WriteString(rows[i])
b.WriteString("\n")
}
// Prompt line at the bottom
b.WriteString(promptStyle.Render("> "))
b.WriteString(cursorStyle.Render(m.query))
b.WriteString(cursorStyle.Render("█"))
return b.String()
}
// renderItem renders a single filtered item row.
func (m model) renderItem(fi filteredItem, isCursor bool) string {
// Max width for the row content (leave space for cursor indicator)
maxW := m.width - 2
if maxW < 10 {
maxW = 10
}
var b strings.Builder
// Cursor indicator
if isCursor {
b.WriteString(promptStyle.Render("▸ "))
} else {
b.WriteString(" ")
}
// Render name with highlights
nameStr := highlightMatches(fi.item.Name, fi.namePositions, normalStyle, matchStyle)
// Render aliases if present
aliasStr := ""
if len(fi.item.Aliases) > 0 {
var parts []string
for i, alias := range fi.item.Aliases {
if i < len(fi.aliasMatches) && fi.aliasMatches[i].positions != nil {
parts = append(parts, highlightMatches(alias, fi.aliasMatches[i].positions, dimStyle, dimMatchStyle))
} else {
parts = append(parts, dimStyle.Render(alias))
}
}
aliasStr = dimStyle.Render(" (") + strings.Join(parts, dimStyle.Render(", ")) + dimStyle.Render(")")
}
row := nameStr + aliasStr
// Truncate if needed (approximate — styled strings contain escape codes)
// We just cap visible chars loosely; Lip Gloss handles the rest.
if isCursor {
// Apply reverse to the whole content portion
b.Reset()
content := nameStr + aliasStr
b.WriteString(selectedStyle.Render("▸ " + stripStyle(fi, maxW)))
_ = content // use styled version only in non-selected
return b.String()
}
b.WriteString(row)
return b.String()
}
// stripStyle produces a plain-text version of the item for reverse-video rendering.
func stripStyle(fi filteredItem, maxW int) string {
s := fi.item.Name
if len(fi.item.Aliases) > 0 {
s += " (" + strings.Join(fi.item.Aliases, ", ") + ")"
}
// Truncate to maxW runes
runes := []rune(s)
if len(runes) > maxW {
runes = runes[:maxW]
}
return string(runes)
}
// highlightMatches renders text with certain character positions styled differently.
func highlightMatches(text string, positions []int, base, highlight lipgloss.Style) string {
if len(positions) == 0 {
return base.Render(text)
}
posSet := make(map[int]bool, len(positions))
for _, p := range positions {
posSet[p] = true
}
var b strings.Builder
runes := []rune(text)
i := 0
for i < len(runes) {
if posSet[i] {
// Collect consecutive highlighted runes
j := i
for j < len(runes) && posSet[j] {
j++
}
b.WriteString(highlight.Render(string(runes[i:j])))
i = j
} else {
// Collect consecutive normal runes
j := i
for j < len(runes) && !posSet[j] {
j++
}
b.WriteString(base.Render(string(runes[i:j])))
i = j
}
}
return b.String()
}
// Run executes the fuzzy finder with the given items and returns the selected key.
func Run(items []Item, opts Options) (string, error) {
if len(items) == 0 {
return "", ErrSelectionCancelled
}
m := initialModel(items)
p := tea.NewProgram(m, tea.WithAltScreen())
result, err := p.Run()
if err != nil {
return "", err
}
return inputs[idx], nil
final := result.(model)
if final.cancelled {
return "", ErrSelectionCancelled
}
return final.selected, nil
}

View File

@@ -24,17 +24,7 @@ func TestOptions(t *testing.T) {
}
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 "" })
_, err := Run([]Item{}, Options{})
if err == nil {
t.Error("expected error for empty inputs")
}
@@ -50,9 +40,25 @@ func TestErrSelectionCancelled_Is(t *testing.T) {
}
}
// 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.
func TestItem(t *testing.T) {
item := Item{Key: "my_session", Name: "my_session", Aliases: []string{"ms", "foo-session"}}
if item.Key != "my_session" {
t.Errorf("expected Key 'my_session', got %q", item.Key)
}
if item.Name != "my_session" {
t.Errorf("expected Name 'my_session', got %q", item.Name)
}
if len(item.Aliases) != 2 {
t.Errorf("expected 2 aliases, got %d", len(item.Aliases))
}
}
func TestItem_NoAliases(t *testing.T) {
item := Item{Key: "simple", Name: "simple"}
if item.Key != item.Name {
t.Error("expected Key and Name to be equal for items without aliases")
}
if len(item.Aliases) != 0 {
t.Error("expected no aliases")
}
}

174
internal/fzf/match.go Normal file
View File

@@ -0,0 +1,174 @@
package fzf
import (
"sort"
"strings"
"unicode/utf8"
)
// matchSource indicates whether a match was found in the Name or an Alias.
type matchSource int
const (
matchName matchSource = iota
matchAlias
)
// aliasMatch holds match positions for a single alias.
type aliasMatch struct {
positions []int
}
// matchResult holds the result of matching a query against an Item.
type matchResult struct {
item Item
index int // original index in items slice
score int
namePositions []int // matched char positions in Name
aliasMatches []aliasMatch // per-alias match positions (nil entry = no match)
source matchSource
}
// filteredItem is a matchResult ready for display.
type filteredItem = matchResult
// fuzzyMatch performs a case-insensitive, left-to-right fuzzy match of query
// against candidate. It returns whether the query matched, the positions of
// matched characters in the candidate, and a score.
func fuzzyMatch(query, candidate string) (bool, []int, int) {
if query == "" {
return true, nil, 0
}
lowerCandidate := strings.ToLower(candidate)
lowerQuery := strings.ToLower(query)
positions := make([]int, 0, utf8.RuneCountInString(query))
score := 0
ci := 0 // candidate rune index
prevMatchIdx := -1
candidateRunes := []rune(lowerCandidate)
queryRunes := []rune(lowerQuery)
qi := 0
for ci < len(candidateRunes) && qi < len(queryRunes) {
if candidateRunes[ci] == queryRunes[qi] {
positions = append(positions, ci)
// Consecutive match bonus
if prevMatchIdx == ci-1 {
score += 4
}
// Word-boundary bonus: first char, or preceded by separator
if ci == 0 || isSeparator(candidateRunes[ci-1]) {
score += 3
}
// Exact case match bonus
origRunes := []rune(candidate)
qOrigRunes := []rune(query)
if origRunes[ci] == qOrigRunes[qi] {
score += 1
}
prevMatchIdx = ci
qi++
}
ci++
}
if qi < len(queryRunes) {
return false, nil, 0
}
// Base score for matching
score += 1
return true, positions, score
}
func isSeparator(r rune) bool {
return r == ' ' || r == '-' || r == '_' || r == '/' || r == '.'
}
// matchItem tries to fuzzy-match query against item's Name and all Aliases.
// It returns a matchResult with highlight positions for every field that matched.
// The score is taken from the best-matching field.
func matchItem(query string, item Item, index int) (matchResult, bool) {
if query == "" {
return matchResult{
item: item,
index: index,
score: 0,
}, true
}
anyMatched := false
bestScore := 0
bestSource := matchName
// Try name
var namePositions []int
nameMatched, nPos, nScore := fuzzyMatch(query, item.Name)
if nameMatched {
anyMatched = true
namePositions = nPos
bestScore = nScore + 10 // bonus for name match
}
// Try each alias — always, so we can highlight all that match
aliasMatches := make([]aliasMatch, len(item.Aliases))
for i, alias := range item.Aliases {
matched, positions, score := fuzzyMatch(query, alias)
if matched {
aliasMatches[i] = aliasMatch{positions: positions}
if !anyMatched || score > bestScore {
bestScore = score
bestSource = matchAlias
}
anyMatched = true
}
}
if !anyMatched {
return matchResult{}, false
}
return matchResult{
item: item,
index: index,
score: bestScore,
namePositions: namePositions,
aliasMatches: aliasMatches,
source: bestSource,
}, true
}
// filterAndSort filters items by query and returns sorted results.
// With an empty query, items are sorted alphabetically by name (A first, i.e.
// lowest index = A). With a non-empty query, items are sorted by match score
// descending (best match at lowest index).
func filterAndSort(items []Item, query string) []filteredItem {
var results []filteredItem
for i, item := range items {
if result, ok := matchItem(query, item, i); ok {
results = append(results, result)
}
}
if query == "" {
// Alphabetical by name (case-insensitive)
sort.SliceStable(results, func(i, j int) bool {
return strings.ToLower(results[i].item.Name) < strings.ToLower(results[j].item.Name)
})
} else {
// Best match first
sort.SliceStable(results, func(i, j int) bool {
return results[i].score > results[j].score
})
}
return results
}

216
internal/fzf/match_test.go Normal file
View File

@@ -0,0 +1,216 @@
package fzf
import (
"testing"
)
func TestFuzzyMatch_ExactPrefix(t *testing.T) {
matched, positions, score := fuzzyMatch("foo", "foobar")
if !matched {
t.Fatal("expected match")
}
if len(positions) != 3 {
t.Fatalf("expected 3 positions, got %d", len(positions))
}
for i, p := range positions {
if p != i {
t.Errorf("position %d: expected %d, got %d", i, i, p)
}
}
if score <= 0 {
t.Error("expected positive score")
}
}
func TestFuzzyMatch_ScatteredChars(t *testing.T) {
matched, positions, _ := fuzzyMatch("fb", "foobar")
if !matched {
t.Fatal("expected match")
}
if len(positions) != 2 {
t.Fatalf("expected 2 positions, got %d", len(positions))
}
if positions[0] != 0 || positions[1] != 3 {
t.Errorf("expected positions [0, 3], got %v", positions)
}
}
func TestFuzzyMatch_NoMatch(t *testing.T) {
matched, _, _ := fuzzyMatch("xyz", "foobar")
if matched {
t.Error("expected no match")
}
}
func TestFuzzyMatch_CaseInsensitive(t *testing.T) {
matched, positions, _ := fuzzyMatch("FOO", "foobar")
if !matched {
t.Fatal("expected match")
}
if len(positions) != 3 {
t.Fatalf("expected 3 positions, got %d", len(positions))
}
}
func TestFuzzyMatch_EmptyQuery(t *testing.T) {
matched, positions, score := fuzzyMatch("", "anything")
if !matched {
t.Fatal("expected match for empty query")
}
if len(positions) != 0 {
t.Error("expected no positions for empty query")
}
if score != 0 {
t.Error("expected zero score for empty query")
}
}
func TestFuzzyMatch_EmptyCandidate(t *testing.T) {
matched, _, _ := fuzzyMatch("a", "")
if matched {
t.Error("expected no match against empty candidate")
}
}
func TestFuzzyMatch_ConsecutiveBonus(t *testing.T) {
_, _, scoreConsec := fuzzyMatch("fo", "foobar")
_, _, scoreScatter := fuzzyMatch("fb", "foobar")
if scoreConsec <= scoreScatter {
t.Errorf("expected consecutive score (%d) > scattered score (%d)", scoreConsec, scoreScatter)
}
}
func TestFuzzyMatch_WordBoundaryBonus(t *testing.T) {
_, _, scoreBoundary := fuzzyMatch("fb", "foo-bar")
_, _, scoreMiddle := fuzzyMatch("fb", "fxxbxx")
if scoreBoundary <= scoreMiddle {
t.Errorf("expected boundary score (%d) > middle score (%d)", scoreBoundary, scoreMiddle)
}
}
func TestMatchItem_NameMatch(t *testing.T) {
item := Item{Key: "test", Name: "foobar", Aliases: []string{"baz"}}
result, ok := matchItem("foo", item, 0)
if !ok {
t.Fatal("expected match")
}
if result.source != matchName {
t.Error("expected name match source")
}
if len(result.namePositions) != 3 {
t.Errorf("expected 3 name positions, got %d", len(result.namePositions))
}
}
func TestMatchItem_AliasMatch(t *testing.T) {
item := Item{Key: "test", Name: "foobar", Aliases: []string{"baz", "qux"}}
result, ok := matchItem("baz", item, 0)
if !ok {
t.Fatal("expected match")
}
if result.source != matchAlias {
t.Error("expected alias match source")
}
if len(result.aliasMatches) != 2 {
t.Fatalf("expected 2 aliasMatches entries, got %d", len(result.aliasMatches))
}
if len(result.aliasMatches[0].positions) != 3 {
t.Errorf("expected 3 positions for first alias, got %d", len(result.aliasMatches[0].positions))
}
if result.aliasMatches[1].positions != nil {
t.Error("expected no match for second alias")
}
}
func TestMatchItem_NamePreferredOverAlias(t *testing.T) {
item := Item{Key: "test", Name: "abc", Aliases: []string{"abc"}}
result, ok := matchItem("abc", item, 0)
if !ok {
t.Fatal("expected match")
}
if result.source != matchName {
t.Error("expected name match to be preferred over alias")
}
}
func TestMatchItem_HighlightsBothNameAndAlias(t *testing.T) {
item := Item{Key: "test", Name: "abc", Aliases: []string{"axe", "abc"}}
result, ok := matchItem("a", item, 0)
if !ok {
t.Fatal("expected match")
}
// Name should have position 0 highlighted
if len(result.namePositions) != 1 || result.namePositions[0] != 0 {
t.Errorf("expected name position [0], got %v", result.namePositions)
}
// Both aliases contain 'a', both should have highlights
if len(result.aliasMatches) != 2 {
t.Fatalf("expected 2 aliasMatches, got %d", len(result.aliasMatches))
}
if len(result.aliasMatches[0].positions) != 1 {
t.Errorf("expected 1 position for first alias, got %d", len(result.aliasMatches[0].positions))
}
if len(result.aliasMatches[1].positions) != 1 {
t.Errorf("expected 1 position for second alias, got %d", len(result.aliasMatches[1].positions))
}
}
func TestMatchItem_NoMatch(t *testing.T) {
item := Item{Key: "test", Name: "foobar", Aliases: []string{"baz"}}
_, ok := matchItem("xyz", item, 0)
if ok {
t.Error("expected no match")
}
}
func TestMatchItem_EmptyQuery(t *testing.T) {
item := Item{Key: "test", Name: "foobar", Aliases: []string{"baz"}}
result, ok := matchItem("", item, 0)
if !ok {
t.Fatal("expected match for empty query")
}
if result.score != 0 {
t.Error("expected zero score for empty query")
}
}
func TestFilterAndSort_Basic(t *testing.T) {
items := []Item{
{Key: "a", Name: "alpha"},
{Key: "b", Name: "beta"},
{Key: "c", Name: "gamma"},
}
results := filterAndSort(items, "a")
if len(results) != 3 { // alpha, beta, and gamma all contain 'a'
t.Fatalf("expected 3 results, got %d", len(results))
}
// alpha should rank higher (word-boundary match at start)
if results[0].item.Key != "a" {
t.Errorf("expected 'a' first, got %q", results[0].item.Key)
}
}
func TestFilterAndSort_EmptyQuery(t *testing.T) {
items := []Item{
{Key: "a", Name: "alpha"},
{Key: "b", Name: "beta"},
}
results := filterAndSort(items, "")
if len(results) != 2 {
t.Fatalf("expected all items for empty query, got %d", len(results))
}
}
func TestFilterAndSort_NoMatches(t *testing.T) {
items := []Item{
{Key: "a", Name: "alpha"},
{Key: "b", Name: "beta"},
}
results := filterAndSort(items, "xyz")
if len(results) != 0 {
t.Fatalf("expected 0 results, got %d", len(results))
}
}

View File

@@ -60,7 +60,7 @@ func CreateFromConfig(opts exec.Opts, tmuxConfig config.ParsedTmuxConfigItem) er
}
// Select first pane
commands = append(commands, fmt.Sprintf("tmux select-pane -t %s.0", sessionName))
commands = append(commands, fmt.Sprintf("tmux select-pane -t %s:%s.0", sessionName, windowName))
}
// Select first window

View File

@@ -2,6 +2,7 @@ package tmux
import (
"os"
"strings"
"github.com/chenasraf/tx/internal/exec"
)
@@ -31,3 +32,24 @@ func ListSessions(opts exec.Opts) (string, error) {
output, _, err := exec.GetCommandOutput(opts, "tmux ls")
return output, err
}
// GetSessionNames returns a list of running tmux session names
func GetSessionNames() []string {
output, _, err := exec.GetCommandOutputSilent("tmux list-sessions -F '#{session_name}'")
if err != nil {
return nil
}
var names []string
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line != "" {
names = append(names, line)
}
}
return names
}
// KillSession kills a tmux session by name
func KillSession(opts exec.Opts, sessionName string) error {
return exec.RunCommand(opts, "tmux kill-session -t "+sessionName)
}

View File

@@ -1 +1 @@
1.1.0
1.4.0