mirror of
https://github.com/chenasraf/tx.git
synced 2026-05-18 01:29:08 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e6ded3040 | ||
| 627fde8b01 | |||
|
|
53c0c4654b | ||
| 54fa59be46 | |||
|
|
022b4de720 | ||
| 7a9ea1b41d | |||
|
|
779940ea3d | ||
| d067a6e964 | |||
|
|
c5e21a5897 | ||
| 296e13549e | |||
|
|
d8d915ec19 | ||
| 2e801cd91e | |||
|
|
1d2161f805 | ||
| 3f82e194bb | |||
|
|
41b7caa50f | ||
| 1b803786b8 | |||
|
|
98c8b84d02 | ||
| cf1018abc3 | |||
| 7534382360 | |||
|
|
029556e681 | ||
| 5c71d7bc11 | |||
|
|
f8e2de631a | ||
| 916bbef6e6 | |||
| e917d6a5fc |
83
CHANGELOG.md
83
CHANGELOG.md
@@ -1,5 +1,88 @@
|
||||
# Changelog
|
||||
|
||||
## [2.4.1](https://github.com/chenasraf/tx/compare/v2.4.0...v2.4.1) (2026-04-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use -B for background flag to prevent flags conflict ([627fde8](https://github.com/chenasraf/tx/commit/627fde8b01f685ff8ba0705fd2735b89fd92b356))
|
||||
|
||||
## [2.4.0](https://github.com/chenasraf/tx/compare/v2.3.0...v2.4.0) (2026-04-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add background flag ([54fa59b](https://github.com/chenasraf/tx/commit/54fa59be464c226e9da0510b8d2a290023782d4a))
|
||||
|
||||
## [2.3.0](https://github.com/chenasraf/tx/compare/v2.2.0...v2.3.0) (2026-04-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ls sessions as table ([7a9ea1b](https://github.com/chenasraf/tx/commit/7a9ea1b41d1fff87cbd160409453502b56dad29d))
|
||||
|
||||
## [2.2.0](https://github.com/chenasraf/tx/compare/v2.1.0...v2.2.0) (2026-03-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow pane size config ([d067a6e](https://github.com/chenasraf/tx/commit/d067a6e964f3ccd6c7e1cf322aa3997e943381c5))
|
||||
|
||||
## [2.1.0](https://github.com/chenasraf/tx/compare/v2.0.0...v2.1.0) (2026-03-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* accept multiple session names for rm/kill ([296e135](https://github.com/chenasraf/tx/commit/296e13549e2e962c4b2287f214ca03ecc9359d85))
|
||||
|
||||
## [2.0.0](https://github.com/chenasraf/tx/compare/v1.5.0...v2.0.0) (2026-03-20)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* tmux_local.yaml is no longer auto-discovered. Run `tx migrate` to add it as an explicit include. The `--local`/`-l` flag is removed in favor of `--config`/`-c`.
|
||||
|
||||
### Features
|
||||
|
||||
* replace tmux_local with config file includes ([2e801cd](https://github.com/chenasraf/tx/commit/2e801cd91e49d2547731a01fc97a48804aa3e7b2))
|
||||
|
||||
## [1.5.0](https://github.com/chenasraf/tx/compare/v1.4.1...v1.5.0) (2026-03-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* initial_window option ([3f82e19](https://github.com/chenasraf/tx/commit/3f82e194bb3352d7a8b889c2d190a118a0d0dd60))
|
||||
|
||||
## [1.4.1](https://github.com/chenasraf/tx/compare/v1.4.0...v1.4.1) (2026-02-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* pgup/pgdown scroll ([1b80378](https://github.com/chenasraf/tx/commit/1b803786b82ffecf8cd0691ea4605dac8246ed68))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
240
README.md
240
README.md
@@ -14,7 +14,7 @@ A tmux session manager that creates sessions from YAML configuration files.
|
||||
- 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)
|
||||
- Config file includes (compose configs from multiple files)
|
||||
- Quick project session creation from configurable projects directory
|
||||
|
||||
---
|
||||
@@ -70,7 +70,7 @@ tx show <name> -j # JSON output
|
||||
|
||||
# Edit configuration file
|
||||
tx edit
|
||||
tx edit -l # edit local config
|
||||
tx edit -c ~/local.yaml # edit a specific config file
|
||||
|
||||
# Create a temporary session
|
||||
tx create
|
||||
@@ -82,24 +82,32 @@ tx create -S # save only (don't create)
|
||||
tx prj [name]
|
||||
tx prj -s # save to config
|
||||
|
||||
# Create session in background (don't switch to it)
|
||||
tx -b my-session
|
||||
tx create -b
|
||||
tx prj -b myproject
|
||||
|
||||
# Attach to existing session
|
||||
tx attach [name]
|
||||
|
||||
# Remove a configuration
|
||||
# Remove configurations
|
||||
tx rm <name>
|
||||
tx rm <name> -l # remove from local config
|
||||
tx rm foo bar baz # remove multiple at once
|
||||
tx rm <name> -c ~/local.yaml # remove from a specific config file
|
||||
|
||||
# Kill a running session
|
||||
# Kill running sessions
|
||||
tx kill # kill current session
|
||||
tx kill <name> # kill specific session
|
||||
tx kill foo bar baz # kill multiple sessions
|
||||
```
|
||||
|
||||
### Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
| --------------- | ----------------------------------------- |
|
||||
| `-v, --verbose` | Verbose logging |
|
||||
| `-d, --dry` | Dry run (show commands without executing) |
|
||||
| Flag | Description |
|
||||
| ------------------ | ---------------------------------------------------- |
|
||||
| `-V, --verbose` | Verbose logging |
|
||||
| `-d, --dry` | Dry run (show commands without executing) |
|
||||
| `-b, --background` | Create session in background without switching to it |
|
||||
|
||||
---
|
||||
|
||||
@@ -117,12 +125,9 @@ File patterns searched:
|
||||
- `tmux.yaml` / `tmux.yml`
|
||||
- `.tmux.yaml` / `.tmux.yml`
|
||||
|
||||
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.
|
||||
You can compose your configuration from multiple files using the `include` setting under `.config`.
|
||||
Included files are merged with the main config, with later includes taking precedence. See
|
||||
[Config Includes](#config-includes) for details.
|
||||
|
||||
### Configuration Format
|
||||
|
||||
@@ -152,6 +157,14 @@ webapp:
|
||||
cwd: .
|
||||
cmd: npm run watch
|
||||
|
||||
# Session with initial window override
|
||||
webapp:
|
||||
root: ~/Dev/webapp
|
||||
initial_window: 0 # open on the "general" window instead of the first configured one
|
||||
windows:
|
||||
- ./src
|
||||
- ./lib
|
||||
|
||||
# Session with complex layout
|
||||
fullstack:
|
||||
root: ~/Dev/fullstack
|
||||
@@ -197,7 +210,27 @@ windows:
|
||||
|
||||
### Layout Configuration
|
||||
|
||||
Layouts define pane splits and commands:
|
||||
Layouts define pane splits and commands. When no layout is specified, the following default is used:
|
||||
|
||||
```yaml
|
||||
# Default layout - horizontal split with vertical sub-split and clock
|
||||
layout:
|
||||
cwd: .
|
||||
split:
|
||||
direction: h
|
||||
child:
|
||||
cwd: .
|
||||
split:
|
||||
direction: v
|
||||
child:
|
||||
cwd: .
|
||||
clock: true
|
||||
```
|
||||
|
||||
You can customize the default for all windows via [`default_layout`](#default-layout) in global
|
||||
settings, or override per window.
|
||||
|
||||
#### Layout Formats
|
||||
|
||||
**String** - just a directory:
|
||||
|
||||
@@ -224,16 +257,36 @@ layout:
|
||||
clock: false # show tmux clock mode
|
||||
split:
|
||||
direction: h # h (horizontal) or v (vertical)
|
||||
size: 30 # percentage of space for the child pane (1-100, default: 50)
|
||||
child:
|
||||
cwd: ./other
|
||||
cmd: npm test
|
||||
split: # nested splits
|
||||
direction: v
|
||||
size: 40
|
||||
child:
|
||||
cwd: .
|
||||
clock: true # show clock in this pane
|
||||
```
|
||||
|
||||
#### Pane Options
|
||||
|
||||
| Setting | Description |
|
||||
| ------- | ---------------------------------------- |
|
||||
| `cwd` | Working directory (defaults to window's) |
|
||||
| `cmd` | Command to run in the pane |
|
||||
| `zoom` | Zoom this pane (default: false) |
|
||||
| `clock` | Show tmux clock mode (default: false) |
|
||||
| `split` | Split this pane (see below) |
|
||||
|
||||
#### Split Options
|
||||
|
||||
| Setting | Description |
|
||||
| ----------- | ----------------------------------------------------------- |
|
||||
| `direction` | `h` (horizontal / side-by-side) or `v` (vertical / stacked) |
|
||||
| `size` | Percentage of space for the child pane (1-100, default: 50) |
|
||||
| `child` | Pane configuration for the new pane created by the split |
|
||||
|
||||
### Global Settings
|
||||
|
||||
The special `.config` key is reserved for global settings and won't be treated as a session:
|
||||
@@ -250,26 +303,19 @@ myproject:
|
||||
|
||||
#### Available Settings
|
||||
|
||||
| Setting | Description |
|
||||
| ---------------- | ------------------------------------------------- |
|
||||
| `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) |
|
||||
| Setting | Description |
|
||||
| ---------------- | -------------------------------------------------------------------- |
|
||||
| `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) |
|
||||
| `initial_window` | Window index to select on session creation (default: `1`, see below) |
|
||||
| `include` | List of additional config files to merge (see below) |
|
||||
|
||||
#### Default Layout
|
||||
|
||||
The `default_layout` setting configures the default pane arrangement for windows. Each pane can
|
||||
have:
|
||||
|
||||
| Setting | Description |
|
||||
| ------- | ------------------------------------------------------------- |
|
||||
| `cwd` | Working directory (defaults to window's directory) |
|
||||
| `cmd` | Command to run (defaults to none) |
|
||||
| `clock` | Show tmux clock mode (defaults to false) |
|
||||
| `split` | Create a split with direction (`h` or `v`) and a `child` pane |
|
||||
|
||||
Example - single pane with clock:
|
||||
The `default_layout` setting overrides the built-in default pane arrangement for all windows. It
|
||||
accepts the same [pane options](#pane-options) as a regular layout.
|
||||
|
||||
```yaml
|
||||
.config:
|
||||
@@ -278,23 +324,6 @@ Example - single pane with clock:
|
||||
clock: true
|
||||
```
|
||||
|
||||
Example - horizontal split with vertical sub-split (default):
|
||||
|
||||
```yaml
|
||||
.config:
|
||||
default_layout:
|
||||
cwd: .
|
||||
split:
|
||||
direction: h
|
||||
child:
|
||||
cwd: .
|
||||
split:
|
||||
direction: v
|
||||
child:
|
||||
cwd: .
|
||||
clock: true
|
||||
```
|
||||
|
||||
#### Named Layouts
|
||||
|
||||
Define reusable layouts that can be referenced by name in session configurations:
|
||||
@@ -319,12 +348,60 @@ myproject:
|
||||
windows:
|
||||
- name: main
|
||||
cwd: .
|
||||
layout: dev # references the "dev" named layout
|
||||
layout: dev # references the "dev" named layout
|
||||
- name: logs
|
||||
cwd: ./logs
|
||||
layout: simple # references the "simple" named layout
|
||||
layout: simple # references the "simple" named layout
|
||||
```
|
||||
|
||||
#### Initial Window
|
||||
|
||||
The `initial_window` setting controls which window is selected when a new session is created. Window
|
||||
`0` is the "general" window (created automatically with the session), and configured windows start
|
||||
at index `1`. The default is `1` (the first configured window).
|
||||
|
||||
This can be set globally under `.config` and overridden per session:
|
||||
|
||||
```yaml
|
||||
.config:
|
||||
initial_window: 1 # global default
|
||||
|
||||
myproject:
|
||||
root: ~/Dev/myproject
|
||||
initial_window: 0 # override: start on the "general" window
|
||||
windows:
|
||||
- ./src
|
||||
- ./lib
|
||||
```
|
||||
|
||||
#### Config Includes
|
||||
|
||||
The `include` setting lets you compose your configuration from multiple files. This is useful for
|
||||
separating machine-specific configs from shared ones, or simply organizing a large config into
|
||||
smaller pieces.
|
||||
|
||||
```yaml
|
||||
# ~/.tmux.yaml
|
||||
.config:
|
||||
include:
|
||||
- ./local.yaml # relative to this file's directory
|
||||
- ~/shared/team.yaml # ~ is expanded to home directory
|
||||
- /etc/tx/company.yaml # absolute paths work too
|
||||
|
||||
myproject:
|
||||
root: ~/Dev/myproject
|
||||
```
|
||||
|
||||
**Path resolution:**
|
||||
|
||||
- **Relative paths** are resolved relative to the directory of the config file containing the
|
||||
`include`
|
||||
- **`~`** is expanded to the home directory
|
||||
- **Absolute paths** are used as-is
|
||||
|
||||
Included files can themselves contain `include` lists (nested includes). Circular includes are
|
||||
detected and skipped. Later includes take precedence over earlier ones when merging.
|
||||
|
||||
#### Shell Resolution Order
|
||||
|
||||
The shell used for executing commands is determined in this order:
|
||||
@@ -375,6 +452,9 @@ tx create -r ~/myproject -w src -w lib -w test
|
||||
|
||||
# Create and save to config
|
||||
tx create -r ~/myproject -s
|
||||
|
||||
# Create in background (don't switch to it)
|
||||
tx create -b -r ~/myproject
|
||||
```
|
||||
|
||||
### Project Workflow
|
||||
@@ -401,6 +481,62 @@ tx prj myproject -s
|
||||
|
||||
---
|
||||
|
||||
## Migrating from v1.x to v2.x
|
||||
|
||||
Run `tx migrate` to automatically migrate your configuration. It finds your existing `tmux_local`
|
||||
config file and adds it as an `include` in your main config. Use `tx migrate -d` for a dry run to
|
||||
preview changes before applying.
|
||||
|
||||
### Local config replaced with includes
|
||||
|
||||
In v1.x, tx automatically searched for a `tmux_local.yaml` (or `.tmux_local.yaml`) file and merged
|
||||
it with the global config. In v2.x, this implicit behavior is replaced with an explicit `include`
|
||||
mechanism in `.config`.
|
||||
|
||||
**Before (v1.x):**
|
||||
|
||||
```
|
||||
~/.tmux.yaml # global config (auto-discovered)
|
||||
~/.tmux_local.yaml # local config (auto-discovered and merged)
|
||||
```
|
||||
|
||||
**After (v2.x):**
|
||||
|
||||
```yaml
|
||||
# ~/.tmux.yaml
|
||||
.config:
|
||||
include:
|
||||
- ./tmux_local.yaml # explicitly include the local config
|
||||
```
|
||||
|
||||
Your `tmux_local.yaml` file contents do not need to change — just add the `include` entry to your
|
||||
main config.
|
||||
|
||||
### `--local` flag replaced with `--config`
|
||||
|
||||
Commands that accepted `--local` / `-l` (such as `edit`, `create`, `remove`, `prj`) now use
|
||||
`--config` / `-c` instead, which takes a file path argument.
|
||||
|
||||
**Before (v1.x):**
|
||||
|
||||
```bash
|
||||
tx edit -l
|
||||
tx rm myproject -l
|
||||
tx create -s -l
|
||||
tx prj myproject -s -l
|
||||
```
|
||||
|
||||
**After (v2.x):**
|
||||
|
||||
```bash
|
||||
tx edit -c ~/tmux_local.yaml
|
||||
tx rm myproject -c ~/tmux_local.yaml
|
||||
tx create -s -c ~/tmux_local.yaml
|
||||
tx prj myproject -s -c ~/tmux_local.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Contributing
|
||||
|
||||
I am developing this package on my free time, so any support, whether code, issues, or just stars is
|
||||
|
||||
23
go.mod
23
go.mod
@@ -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
89
go.sum
@@ -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=
|
||||
|
||||
@@ -33,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")
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
createRootDir string
|
||||
createWindows []string
|
||||
createSave bool
|
||||
createSaveOnly bool
|
||||
createLocal bool
|
||||
createRootDir string
|
||||
createWindows []string
|
||||
createSave bool
|
||||
createSaveOnly bool
|
||||
createConfigFile string
|
||||
)
|
||||
|
||||
var createCmd = &cobra.Command{
|
||||
@@ -30,7 +30,7 @@ func init() {
|
||||
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")
|
||||
createCmd.Flags().StringVarP(&createConfigFile, "config", "c", "", "Save to a specific config file")
|
||||
}
|
||||
|
||||
func runCreate(cmd *cobra.Command, args []string) error {
|
||||
@@ -75,13 +75,17 @@ func runCreate(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Check if session exists
|
||||
if tmux.SessionExists(opts, parsed.Name) {
|
||||
if background {
|
||||
exec.Log(opts, "Session already exists (background mode, not attaching)")
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
if err := config.AddSimpleConfigToFile(parsed, createConfigFile, opts.Dry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -92,5 +96,5 @@ func runCreate(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Create the session
|
||||
return tmux.CreateFromConfig(opts, parsed)
|
||||
return tmux.CreateFromConfig(opts, parsed, background)
|
||||
}
|
||||
|
||||
@@ -60,11 +60,11 @@ func TestCreateCmd_Flags(t *testing.T) {
|
||||
t.Errorf("expected -S shorthand, got %q", saveOnlyFlag.Shorthand)
|
||||
}
|
||||
|
||||
localFlag := createCmd.Flags().Lookup("local")
|
||||
if localFlag == nil {
|
||||
t.Fatal("expected --local flag")
|
||||
configFlag := createCmd.Flags().Lookup("config")
|
||||
if configFlag == nil {
|
||||
t.Fatal("expected --config flag")
|
||||
}
|
||||
if localFlag.Shorthand != "l" {
|
||||
t.Errorf("expected -l shorthand, got %q", localFlag.Shorthand)
|
||||
if configFlag.Shorthand != "c" {
|
||||
t.Errorf("expected -c shorthand, got %q", configFlag.Shorthand)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var editLocal bool
|
||||
var editConfigFile string
|
||||
|
||||
var editCmd = &cobra.Command{
|
||||
Use: "edit",
|
||||
@@ -18,24 +18,20 @@ var editCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func init() {
|
||||
editCmd.Flags().BoolVarP(&editLocal, "local", "l", false, "Edit the local config file")
|
||||
editCmd.Flags().StringVarP(&editConfigFile, "config", "c", "", "Edit a specific 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
|
||||
if editConfigFile != "" {
|
||||
filepath = config.DirFix(editConfigFile)
|
||||
} else {
|
||||
configInfo, err := config.GetTmuxConfigFileInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if configInfo.Global == nil {
|
||||
return NewUserError("global config file not found")
|
||||
}
|
||||
|
||||
@@ -28,11 +28,11 @@ func TestEditCmd_Aliases(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEditCmd_Flags(t *testing.T) {
|
||||
localFlag := editCmd.Flags().Lookup("local")
|
||||
if localFlag == nil {
|
||||
t.Fatal("expected --local flag")
|
||||
configFlag := editCmd.Flags().Lookup("config")
|
||||
if configFlag == nil {
|
||||
t.Fatal("expected --config flag")
|
||||
}
|
||||
if localFlag.Shorthand != "l" {
|
||||
t.Errorf("expected -l shorthand, got %q", localFlag.Shorthand)
|
||||
if configFlag.Shorthand != "c" {
|
||||
t.Errorf("expected -c shorthand, got %q", configFlag.Shorthand)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,9 @@ import (
|
||||
)
|
||||
|
||||
var killCmd = &cobra.Command{
|
||||
Use: "kill [session]",
|
||||
Use: "kill [session...]",
|
||||
Aliases: []string{"k"},
|
||||
Short: "Kill a running tmux session (current session if no arg)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Short: "Kill running tmux sessions (current session if no arg)",
|
||||
RunE: runKill,
|
||||
ValidArgsFunction: completeRunningSessions,
|
||||
}
|
||||
@@ -19,24 +18,26 @@ 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")
|
||||
var errs []error
|
||||
for _, sessionName := range args {
|
||||
if !tmux.SessionExists(opts, sessionName) {
|
||||
errs = append(errs, NewUserError("tmux session '"+sessionName+"' does not exist"))
|
||||
continue
|
||||
}
|
||||
if err := tmux.KillSession(opts, sessionName); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return tmux.KillSession(opts, sessionName)
|
||||
return joinErrors(errs)
|
||||
}
|
||||
|
||||
// No arg - kill current session
|
||||
return exec.RunCommand(opts, "tmux kill-session")
|
||||
}
|
||||
|
||||
// completeRunningSessions returns running session names for shell completion
|
||||
// completeRunningSessions returns running session names for shell completion,
|
||||
// excluding sessions already provided as arguments.
|
||||
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
|
||||
all := tmux.GetSessionNames()
|
||||
return filterUsed(all, args), cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/chenasraf/tx/internal/config"
|
||||
"github.com/chenasraf/tx/internal/table"
|
||||
"github.com/chenasraf/tx/internal/tmux"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -61,13 +63,7 @@ func runList(cmd *cobra.Command, args []string) error {
|
||||
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"
|
||||
}
|
||||
}
|
||||
sessionsStr = formatSessionsTable(sessionsOutput, " ")
|
||||
} else {
|
||||
sessionsStr = " No tmux sessions\n"
|
||||
}
|
||||
@@ -89,16 +85,46 @@ func runList(cmd *cobra.Command, args []string) error {
|
||||
if configInfo.Global != nil {
|
||||
fmt.Println(" global:", configInfo.Global.Filepath)
|
||||
}
|
||||
if configInfo.Local != nil {
|
||||
fmt.Println(" local:", configInfo.Local.Filepath)
|
||||
for _, inc := range configInfo.Included {
|
||||
fmt.Println(" included:", inc.Filepath)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var sessionLineRe = regexp.MustCompile(`^([^:]+):\s*(\d+)\s+windows?\s*\(created\s+([^)]+)\)\s*(.*)$`)
|
||||
|
||||
// formatSessionsTable parses `tmux ls` output and renders it as a bordered
|
||||
// table with headers. Each output line is prefixed with indent.
|
||||
func formatSessionsTable(raw, indent string) string {
|
||||
lines := strings.Split(strings.TrimSpace(raw), "\n")
|
||||
rows := make([][]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
m := sessionLineRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
// Fallback: dump the whole line into the Name column
|
||||
rows = append(rows, []string{line, "", "", ""})
|
||||
continue
|
||||
}
|
||||
status := strings.TrimSpace(m[4])
|
||||
status = strings.TrimPrefix(status, "(")
|
||||
status = strings.TrimSuffix(status, ")")
|
||||
rows = append(rows, []string{m[1], m[2], m[3], status})
|
||||
}
|
||||
return table.Render([]string{"Name", "Windows", "Created", "Status"}, rows, indent)
|
||||
}
|
||||
|
||||
@@ -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,19 +44,23 @@ 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) {
|
||||
if background {
|
||||
exec.Log(opts, "Session exists (background mode, not attaching)")
|
||||
return nil
|
||||
}
|
||||
exec.Log(opts, "Session exists, attaching...")
|
||||
return tmux.AttachToSession(opts, parsed.Name)
|
||||
}
|
||||
|
||||
// Create session
|
||||
return tmux.CreateFromConfig(opts, parsed)
|
||||
return tmux.CreateFromConfig(opts, parsed, background)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
156
internal/cli/migrate_cmd.go
Normal file
156
internal/cli/migrate_cmd.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/chenasraf/tx/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var migrateCmd = &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Migrate configuration from v1.x to v2.x format",
|
||||
Long: `Migrates your configuration from v1.x to v2.x format.
|
||||
|
||||
This finds any tmux_local config file (previously auto-discovered) and adds it
|
||||
as an explicit include in your main config file's .config section.`,
|
||||
RunE: runMigrate,
|
||||
}
|
||||
|
||||
func runMigrate(cmd *cobra.Command, args []string) error {
|
||||
opts := GetOpts()
|
||||
|
||||
// Find the main config file
|
||||
info, err := config.GetTmuxConfigFileInfo()
|
||||
if err != nil {
|
||||
return NewUserError("no main config file found — nothing to migrate")
|
||||
}
|
||||
|
||||
globalPath := info.Global.Filepath
|
||||
|
||||
// Check if there's already an include list
|
||||
gc, _ := config.GetGlobalConfig()
|
||||
if gc != nil && len(gc.Include) > 0 {
|
||||
fmt.Println("Config already has includes — skipping migration.")
|
||||
fmt.Println(" includes:")
|
||||
for _, inc := range gc.Include {
|
||||
fmt.Println(" -", inc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find legacy tmux_local file
|
||||
localPath := config.FindLegacyLocalConfig()
|
||||
if localPath == "" {
|
||||
fmt.Println("No tmux_local config file found — nothing to migrate.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compute the include path relative to the main config's directory
|
||||
globalDir := filepath.Dir(globalPath)
|
||||
includePath, err := filepath.Rel(globalDir, localPath)
|
||||
if err != nil {
|
||||
// Fall back to absolute path
|
||||
includePath = localPath
|
||||
}
|
||||
// Prefix with ./ for clarity if relative
|
||||
if !filepath.IsAbs(includePath) && !strings.HasPrefix(includePath, ".") {
|
||||
includePath = "./" + includePath
|
||||
}
|
||||
|
||||
// Read the main config file
|
||||
data, err := os.ReadFile(globalPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
var newContent string
|
||||
|
||||
if strings.Contains(content, ".config:") {
|
||||
// .config section exists — inject include under it
|
||||
newContent = injectIncludeIntoConfig(content, includePath)
|
||||
} else {
|
||||
// No .config section — prepend one
|
||||
newContent = fmt.Sprintf(".config:\n include:\n - %s\n\n%s", includePath, content)
|
||||
}
|
||||
|
||||
if opts.Dry {
|
||||
fmt.Println("Would write to", globalPath)
|
||||
fmt.Println()
|
||||
fmt.Println(newContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(globalPath, []byte(newContent), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write config file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Migrated successfully!")
|
||||
fmt.Println()
|
||||
fmt.Printf(" Added include: %s\n", includePath)
|
||||
fmt.Printf(" Config file: %s\n", globalPath)
|
||||
fmt.Printf(" Local file: %s\n", localPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// injectIncludeIntoConfig inserts an include entry into an existing .config section.
|
||||
func injectIncludeIntoConfig(content, includePath string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
var result []string
|
||||
|
||||
configIdx := -1
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == ".config:" {
|
||||
configIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if configIdx == -1 {
|
||||
// Shouldn't happen since caller checks, but be safe
|
||||
return fmt.Sprintf(".config:\n include:\n - %s\n\n%s", includePath, content)
|
||||
}
|
||||
|
||||
// Find the end of the .config block (next top-level key or EOF)
|
||||
configEndIdx := len(lines)
|
||||
for i := configIdx + 1; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
if len(line) > 0 && line[0] != ' ' && line[0] != '\t' && line[0] != '#' {
|
||||
configEndIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check if include already exists in the block
|
||||
hasInclude := false
|
||||
for i := configIdx; i < configEndIdx; i++ {
|
||||
if strings.TrimSpace(lines[i]) == "include:" {
|
||||
hasInclude = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasInclude {
|
||||
// Add entry to existing include list — find the include: line and add after it
|
||||
for i := configIdx; i < configEndIdx; i++ {
|
||||
result = append(result, lines[i])
|
||||
if strings.TrimSpace(lines[i]) == "include:" {
|
||||
result = append(result, fmt.Sprintf(" - %s", includePath))
|
||||
}
|
||||
}
|
||||
result = append(result, lines[configEndIdx:]...)
|
||||
} else {
|
||||
// Add include key right after .config:
|
||||
result = append(result, lines[:configIdx+1]...)
|
||||
result = append(result, " include:")
|
||||
result = append(result, fmt.Sprintf(" - %s", includePath))
|
||||
result = append(result, lines[configIdx+1:]...)
|
||||
}
|
||||
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
77
internal/cli/migrate_cmd_test.go
Normal file
77
internal/cli/migrate_cmd_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrateCmd_Exists(t *testing.T) {
|
||||
if migrateCmd == nil {
|
||||
t.Error("expected migrateCmd to not be nil")
|
||||
}
|
||||
|
||||
if migrateCmd.Use != "migrate" {
|
||||
t.Errorf("unexpected Use: %q", migrateCmd.Use)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectIncludeIntoConfig_NoExistingConfig(t *testing.T) {
|
||||
content := `myproject:
|
||||
root: ~/Dev/myproject
|
||||
`
|
||||
result := injectIncludeIntoConfig(content, "./local.yaml")
|
||||
|
||||
// Should prepend .config with include
|
||||
if !strings.Contains(result, ".config:") {
|
||||
t.Error("expected .config section to be added")
|
||||
}
|
||||
if !strings.Contains(result, "include:") {
|
||||
t.Error("expected include key")
|
||||
}
|
||||
if !strings.Contains(result, "- ./local.yaml") {
|
||||
t.Error("expected include entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectIncludeIntoConfig_ExistingConfigNoInclude(t *testing.T) {
|
||||
content := `.config:
|
||||
shell: /bin/zsh
|
||||
|
||||
myproject:
|
||||
root: ~/Dev/myproject
|
||||
`
|
||||
result := injectIncludeIntoConfig(content, "./local.yaml")
|
||||
|
||||
if !strings.Contains(result, "include:") {
|
||||
t.Error("expected include key to be added")
|
||||
}
|
||||
if !strings.Contains(result, "- ./local.yaml") {
|
||||
t.Error("expected include entry")
|
||||
}
|
||||
// Should preserve existing config
|
||||
if !strings.Contains(result, "shell: /bin/zsh") {
|
||||
t.Error("expected existing shell config to be preserved")
|
||||
}
|
||||
if !strings.Contains(result, "myproject:") {
|
||||
t.Error("expected myproject to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectIncludeIntoConfig_ExistingInclude(t *testing.T) {
|
||||
content := `.config:
|
||||
include:
|
||||
- ./existing.yaml
|
||||
shell: /bin/zsh
|
||||
|
||||
myproject:
|
||||
root: ~/Dev/myproject
|
||||
`
|
||||
result := injectIncludeIntoConfig(content, "./local.yaml")
|
||||
|
||||
if !strings.Contains(result, "- ./existing.yaml") {
|
||||
t.Error("expected existing include to be preserved")
|
||||
}
|
||||
if !strings.Contains(result, "- ./local.yaml") {
|
||||
t.Error("expected new include entry")
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
prjSave bool
|
||||
prjLocal bool
|
||||
prjSave bool
|
||||
prjConfigFile string
|
||||
)
|
||||
|
||||
var prjCmd = &cobra.Command{
|
||||
@@ -28,7 +28,7 @@ var prjCmd = &cobra.Command{
|
||||
|
||||
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")
|
||||
prjCmd.Flags().StringVarP(&prjConfigFile, "config", "c", "", "Save to a specific config file")
|
||||
}
|
||||
|
||||
// ErrNoProjectsPath is returned when projects_path is not configured
|
||||
@@ -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
|
||||
}
|
||||
@@ -109,13 +113,13 @@ func runPrj(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Save if requested
|
||||
if prjSave {
|
||||
if err := config.AddSimpleConfigToFile(parsed, prjLocal, opts.Dry); err != nil {
|
||||
if err := config.AddSimpleConfigToFile(parsed, prjConfigFile, opts.Dry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Create session
|
||||
return tmux.CreateFromConfig(opts, parsed)
|
||||
return tmux.CreateFromConfig(opts, parsed, background)
|
||||
}
|
||||
|
||||
// getProjects returns directory names in the given path
|
||||
|
||||
@@ -39,12 +39,12 @@ func TestPrjCmd_Flags(t *testing.T) {
|
||||
t.Errorf("expected -s shorthand, got %q", saveFlag.Shorthand)
|
||||
}
|
||||
|
||||
localFlag := prjCmd.Flags().Lookup("local")
|
||||
if localFlag == nil {
|
||||
t.Fatal("expected --local flag")
|
||||
configFlag := prjCmd.Flags().Lookup("config")
|
||||
if configFlag == nil {
|
||||
t.Fatal("expected --config flag")
|
||||
}
|
||||
if localFlag.Shorthand != "l" {
|
||||
t.Errorf("expected -l shorthand, got %q", localFlag.Shorthand)
|
||||
if configFlag.Shorthand != "c" {
|
||||
t.Errorf("expected -c shorthand, got %q", configFlag.Shorthand)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,46 +8,49 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var removeLocal bool
|
||||
var removeConfigFile string
|
||||
|
||||
var removeCmd = &cobra.Command{
|
||||
Use: "remove <key>",
|
||||
Use: "remove <key...>",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "Remove a tmux workspace from the config file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Remove tmux workspaces from the config file",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: runRemove,
|
||||
ValidArgsFunction: completeSessionNames,
|
||||
ValidArgsFunction: completeSessionNamesMulti,
|
||||
}
|
||||
|
||||
func init() {
|
||||
removeCmd.Flags().BoolVarP(&removeLocal, "local", "l", false, "Remove from local config file")
|
||||
removeCmd.Flags().StringVarP(&removeConfigFile, "config", "c", "", "Remove from a specific 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")
|
||||
var errs []error
|
||||
for _, key := range args {
|
||||
_, actualKey, exists := allConfig.Get(key)
|
||||
if !exists {
|
||||
errs = append(errs, NewUserError("tmux config item '"+key+"' not found"))
|
||||
continue
|
||||
}
|
||||
|
||||
err = config.RemoveConfigFromFile(actualKey, removeConfigFile, opts.Dry)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !opts.Dry {
|
||||
fmt.Printf("Removed tmux config item '%s'\n", key)
|
||||
}
|
||||
|
||||
exec.Log(opts, "Removed config item:", key)
|
||||
}
|
||||
|
||||
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
|
||||
return joinErrors(errs)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ func TestRemoveCmd_Exists(t *testing.T) {
|
||||
t.Error("expected removeCmd to not be nil")
|
||||
}
|
||||
|
||||
if removeCmd.Use != "remove <key>" {
|
||||
if removeCmd.Use != "remove <key...>" {
|
||||
t.Errorf("unexpected Use: %q", removeCmd.Use)
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,12 @@ func TestRemoveCmd_Aliases(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRemoveCmd_Flags(t *testing.T) {
|
||||
localFlag := removeCmd.Flags().Lookup("local")
|
||||
if localFlag == nil {
|
||||
t.Fatal("expected --local flag")
|
||||
configFlag := removeCmd.Flags().Lookup("config")
|
||||
if configFlag == nil {
|
||||
t.Fatal("expected --config flag")
|
||||
}
|
||||
if localFlag.Shorthand != "l" {
|
||||
t.Errorf("expected -l shorthand, got %q", localFlag.Shorthand)
|
||||
if configFlag.Shorthand != "c" {
|
||||
t.Errorf("expected -c shorthand, got %q", configFlag.Shorthand)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/chenasraf/tx/internal/config"
|
||||
"github.com/chenasraf/tx/internal/exec"
|
||||
"github.com/chenasraf/tx/internal/fzf"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -14,8 +16,9 @@ var (
|
||||
Version string
|
||||
|
||||
// Global flags
|
||||
verbose bool
|
||||
dry bool
|
||||
verbose bool
|
||||
dry bool
|
||||
background bool
|
||||
)
|
||||
|
||||
// GetOpts returns the current execution options
|
||||
@@ -40,6 +43,11 @@ func NewUserError(message string) *UserError {
|
||||
return &UserError{Message: message}
|
||||
}
|
||||
|
||||
// joinErrors combines multiple errors into one, returning nil if there are none.
|
||||
func joinErrors(errs []error) error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// rootCmd represents the base command
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "tx [session]",
|
||||
@@ -68,15 +76,58 @@ func completeSessionNames(cmd *cobra.Command, args []string, toComplete string)
|
||||
}
|
||||
|
||||
var names []string
|
||||
for name := range cfg {
|
||||
for name, item := range cfg {
|
||||
if name != config.ConfigKey {
|
||||
names = append(names, name)
|
||||
names = append(names, item.Aliases...)
|
||||
}
|
||||
}
|
||||
|
||||
return names, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// completeSessionNamesMulti returns session names excluding already-provided args.
|
||||
func completeSessionNamesMulti(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
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 filterUsed(names, args), cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// filterUsed returns items from candidates that are not already in used.
|
||||
func filterUsed(candidates []string, used []string) []string {
|
||||
seen := make(map[string]bool, len(used))
|
||||
for _, u := range used {
|
||||
seen[u] = true
|
||||
}
|
||||
var result []string
|
||||
for _, c := range candidates {
|
||||
if !seen[c] {
|
||||
result = append(result, c)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 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
|
||||
func initConfig(cmd *cobra.Command, args []string) error {
|
||||
// Try to load global config (ignore errors - config may not exist)
|
||||
@@ -94,6 +145,10 @@ func initConfig(cmd *cobra.Command, args []string) error {
|
||||
if globalConfig.NamedLayouts != nil {
|
||||
config.ConfiguredNamedLayouts = globalConfig.NamedLayouts
|
||||
}
|
||||
// Apply initial window from config
|
||||
if globalConfig.InitialWindow != nil {
|
||||
config.ConfiguredInitialWindow = globalConfig.InitialWindow
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -101,6 +156,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)
|
||||
@@ -109,8 +165,10 @@ 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.PersistentFlags().BoolVarP(&background, "background", "B", false, "Create session in background without attaching")
|
||||
rootCmd.Flags().BoolP("version", "v", false, "Print version")
|
||||
|
||||
// Add subcommands
|
||||
rootCmd.AddCommand(listCmd)
|
||||
@@ -121,4 +179,5 @@ func init() {
|
||||
rootCmd.AddCommand(attachCmd)
|
||||
rootCmd.AddCommand(prjCmd)
|
||||
rootCmd.AddCommand(killCmd)
|
||||
rootCmd.AddCommand(migrateCmd)
|
||||
}
|
||||
|
||||
@@ -37,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)
|
||||
|
||||
@@ -17,10 +17,10 @@ type ConfigResult struct {
|
||||
Filepath string
|
||||
}
|
||||
|
||||
// ConfigInfo holds global, local, and merged configurations
|
||||
// ConfigInfo holds global, included, and merged configurations
|
||||
type ConfigInfo struct {
|
||||
Global *ConfigResult
|
||||
Local *ConfigResult
|
||||
Included []*ConfigResult
|
||||
Merged *ConfigResult
|
||||
GlobalConfig *GlobalConfig
|
||||
}
|
||||
@@ -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
|
||||
@@ -205,43 +208,115 @@ func mergeGlobalConfigs(configs ...*GlobalConfig) *GlobalConfig {
|
||||
return result
|
||||
}
|
||||
|
||||
// GetTmuxConfigFileInfo returns the global, local, and merged configurations
|
||||
// resolveIncludePath resolves an include path relative to the config file that contains it.
|
||||
// Absolute paths are returned as-is. Paths starting with ~ are expanded. Relative paths
|
||||
// are resolved against the directory of the parent config file.
|
||||
func resolveIncludePath(includePath, parentConfigPath string) string {
|
||||
// Expand ~
|
||||
includePath = DirFix(includePath)
|
||||
|
||||
// If absolute, return as-is
|
||||
if filepath.IsAbs(includePath) {
|
||||
return includePath
|
||||
}
|
||||
|
||||
// Resolve relative to parent config file's directory
|
||||
parentDir := filepath.Dir(parentConfigPath)
|
||||
return filepath.Join(parentDir, includePath)
|
||||
}
|
||||
|
||||
// loadIncludedConfigs loads all configs referenced by the include list in .config.
|
||||
// It returns the loaded config results and the merged global config from all includes.
|
||||
// Already-visited paths are tracked to prevent circular includes.
|
||||
func loadIncludedConfigs(parentPath string, includes []string, visited map[string]bool) ([]*ConfigResult, []*GlobalConfig) {
|
||||
var results []*ConfigResult
|
||||
var globalConfigs []*GlobalConfig
|
||||
|
||||
for _, inc := range includes {
|
||||
resolvedPath := resolveIncludePath(inc, parentPath)
|
||||
|
||||
// Resolve symlinks for dedup
|
||||
realPath, err := filepath.EvalSymlinks(resolvedPath)
|
||||
if err != nil {
|
||||
realPath = resolvedPath
|
||||
}
|
||||
|
||||
if visited[realPath] {
|
||||
continue
|
||||
}
|
||||
visited[realPath] = true
|
||||
|
||||
cfg, err := loadConfigFile(resolvedPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
result := &ConfigResult{
|
||||
Config: cfg,
|
||||
Filepath: resolvedPath,
|
||||
}
|
||||
|
||||
gc, _ := loadGlobalConfig(resolvedPath)
|
||||
|
||||
// Recursively load nested includes
|
||||
if gc != nil && len(gc.Include) > 0 {
|
||||
nestedResults, nestedGCs := loadIncludedConfigs(resolvedPath, gc.Include, visited)
|
||||
results = append(results, nestedResults...)
|
||||
globalConfigs = append(globalConfigs, nestedGCs...)
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
globalConfigs = append(globalConfigs, gc)
|
||||
}
|
||||
|
||||
return results, globalConfigs
|
||||
}
|
||||
|
||||
// GetTmuxConfigFileInfo returns the global, included, 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 {
|
||||
if info.Global == nil {
|
||||
return nil, ErrNoConfigFound
|
||||
}
|
||||
|
||||
// Load global config section
|
||||
baseGlobalConfig, _ := loadGlobalConfig(info.Global.Filepath)
|
||||
|
||||
// Process includes from .config.include
|
||||
var allGlobalConfigs []*GlobalConfig
|
||||
allGlobalConfigs = append(allGlobalConfigs, baseGlobalConfig)
|
||||
|
||||
configsToMerge := []ConfigFile{info.Global.Config}
|
||||
|
||||
if baseGlobalConfig != nil && len(baseGlobalConfig.Include) > 0 {
|
||||
visited := map[string]bool{}
|
||||
// Mark the base config as visited
|
||||
if realPath, err := filepath.EvalSymlinks(info.Global.Filepath); err == nil {
|
||||
visited[realPath] = true
|
||||
} else {
|
||||
visited[info.Global.Filepath] = true
|
||||
}
|
||||
|
||||
includedResults, includedGCs := loadIncludedConfigs(info.Global.Filepath, baseGlobalConfig.Include, visited)
|
||||
info.Included = includedResults
|
||||
|
||||
for _, r := range includedResults {
|
||||
configsToMerge = append(configsToMerge, r.Config)
|
||||
}
|
||||
allGlobalConfigs = append(allGlobalConfigs, includedGCs...)
|
||||
}
|
||||
|
||||
// Merge global configs
|
||||
info.GlobalConfig = mergeGlobalConfigs(globalGlobalConfig, localGlobalConfig)
|
||||
info.GlobalConfig = mergeGlobalConfigs(allGlobalConfigs...)
|
||||
|
||||
// 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)
|
||||
merged := mergeConfigs(configsToMerge...)
|
||||
info.Merged = &ConfigResult{
|
||||
Config: merged,
|
||||
Filepath: "merged",
|
||||
@@ -268,16 +343,31 @@ func GetGlobalConfig() (*GlobalConfig, error) {
|
||||
return info.GlobalConfig, nil
|
||||
}
|
||||
|
||||
// FindLegacyLocalConfig searches for a tmux_local config file using the v1.x
|
||||
// search patterns. Returns the file path if found, or empty string if not.
|
||||
func FindLegacyLocalConfig() string {
|
||||
patterns := searchPatterns("tmux_local")
|
||||
dirs := searchDirs()
|
||||
|
||||
for _, dir := range dirs {
|
||||
for _, pattern := range patterns {
|
||||
path := filepath.Join(dir, pattern)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
patterns := searchPatterns("tmux")
|
||||
for _, dir := range dirs {
|
||||
for _, pattern := range patterns {
|
||||
paths = append(paths, filepath.Join(dir, pattern))
|
||||
}
|
||||
}
|
||||
return paths
|
||||
|
||||
@@ -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"},
|
||||
@@ -189,23 +225,23 @@ func TestGetSearchedPaths(t *testing.T) {
|
||||
t.Error("expected at least some search paths")
|
||||
}
|
||||
|
||||
// Should contain both tmux and tmux_local patterns
|
||||
// Should contain tmux 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")
|
||||
|
||||
// Should NOT contain tmux_local patterns (replaced by include mechanism)
|
||||
for _, p := range paths {
|
||||
if filepath.Base(p) == ".tmux_local.yaml" || filepath.Base(p) == ".tmux_local.yml" {
|
||||
t.Error("should not contain tmux_local patterns")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,3 +546,186 @@ testproject:
|
||||
t.Error("expected config to contain 'testproject'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIncludePath(t *testing.T) {
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
include string
|
||||
parentPath string
|
||||
expected string
|
||||
}{
|
||||
{"absolute path", "/etc/tmux.yaml", "/home/user/.tmux.yaml", "/etc/tmux.yaml"},
|
||||
{"tilde path", "~/local.yaml", "/home/user/.tmux.yaml", filepath.Join(home, "local.yaml")},
|
||||
{"relative path", "local.yaml", "/home/user/.tmux.yaml", "/home/user/local.yaml"},
|
||||
{"relative subdir", "conf/extra.yaml", "/home/user/.tmux.yaml", "/home/user/conf/extra.yaml"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := resolveIncludePath(tt.include, tt.parentPath)
|
||||
if result != tt.expected {
|
||||
t.Errorf("resolveIncludePath(%q, %q) = %q, expected %q", tt.include, tt.parentPath, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTmuxConfigFileInfo_WithIncludes(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create included config file
|
||||
includedPath := filepath.Join(tmpDir, "extra.yaml")
|
||||
includedContent := `
|
||||
extraproject:
|
||||
root: /tmp/extra
|
||||
`
|
||||
if err := os.WriteFile(includedPath, []byte(includedContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write included config: %v", err)
|
||||
}
|
||||
|
||||
// Create main config that includes the extra file
|
||||
configPath := filepath.Join(tmpDir, ".tmux.yaml")
|
||||
mainContent := `.config:
|
||||
include:
|
||||
- extra.yaml
|
||||
|
||||
mainproject:
|
||||
root: /tmp/main
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(mainContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write main config: %v", err)
|
||||
}
|
||||
|
||||
// Set XDG_CONFIG_HOME to temp directory
|
||||
oldXDG := os.Getenv("XDG_CONFIG_HOME")
|
||||
_ = os.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||
defer func() { _ = os.Setenv("XDG_CONFIG_HOME", oldXDG) }()
|
||||
|
||||
info, err := GetTmuxConfigFileInfo()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get config info: %v", err)
|
||||
}
|
||||
|
||||
// Should have 1 included config
|
||||
if len(info.Included) != 1 {
|
||||
t.Fatalf("expected 1 included config, got %d", len(info.Included))
|
||||
}
|
||||
|
||||
// Merged config should contain both projects
|
||||
if _, ok := info.Merged.Config["mainproject"]; !ok {
|
||||
t.Error("expected merged config to contain 'mainproject'")
|
||||
}
|
||||
if _, ok := info.Merged.Config["extraproject"]; !ok {
|
||||
t.Error("expected merged config to contain 'extraproject'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTmuxConfigFileInfo_NestedIncludes(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create deeply nested config
|
||||
deepPath := filepath.Join(tmpDir, "deep.yaml")
|
||||
deepContent := `
|
||||
deepproject:
|
||||
root: /tmp/deep
|
||||
`
|
||||
if err := os.WriteFile(deepPath, []byte(deepContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write deep config: %v", err)
|
||||
}
|
||||
|
||||
// Create mid-level config that includes deep
|
||||
midPath := filepath.Join(tmpDir, "mid.yaml")
|
||||
midContent := `.config:
|
||||
include:
|
||||
- deep.yaml
|
||||
|
||||
midproject:
|
||||
root: /tmp/mid
|
||||
`
|
||||
if err := os.WriteFile(midPath, []byte(midContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write mid config: %v", err)
|
||||
}
|
||||
|
||||
// Create main config that includes mid
|
||||
configPath := filepath.Join(tmpDir, ".tmux.yaml")
|
||||
mainContent := `.config:
|
||||
include:
|
||||
- mid.yaml
|
||||
|
||||
mainproject:
|
||||
root: /tmp/main
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(mainContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write main config: %v", err)
|
||||
}
|
||||
|
||||
oldXDG := os.Getenv("XDG_CONFIG_HOME")
|
||||
_ = os.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||
defer func() { _ = os.Setenv("XDG_CONFIG_HOME", oldXDG) }()
|
||||
|
||||
info, err := GetTmuxConfigFileInfo()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get config info: %v", err)
|
||||
}
|
||||
|
||||
// Should have 2 included configs (deep is loaded before mid due to recursion order)
|
||||
if len(info.Included) != 2 {
|
||||
t.Fatalf("expected 2 included configs, got %d", len(info.Included))
|
||||
}
|
||||
|
||||
// Merged config should contain all three projects
|
||||
for _, name := range []string{"mainproject", "midproject", "deepproject"} {
|
||||
if _, ok := info.Merged.Config[name]; !ok {
|
||||
t.Errorf("expected merged config to contain %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTmuxConfigFileInfo_CircularIncludes(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create two configs that include each other
|
||||
aPath := filepath.Join(tmpDir, ".tmux.yaml")
|
||||
bPath := filepath.Join(tmpDir, "b.yaml")
|
||||
|
||||
aContent := `.config:
|
||||
include:
|
||||
- b.yaml
|
||||
|
||||
projectA:
|
||||
root: /tmp/a
|
||||
`
|
||||
bContent := `.config:
|
||||
include:
|
||||
- .tmux.yaml
|
||||
|
||||
projectB:
|
||||
root: /tmp/b
|
||||
`
|
||||
if err := os.WriteFile(aPath, []byte(aContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write config a: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(bPath, []byte(bContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write config b: %v", err)
|
||||
}
|
||||
|
||||
oldXDG := os.Getenv("XDG_CONFIG_HOME")
|
||||
_ = os.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||
defer func() { _ = os.Setenv("XDG_CONFIG_HOME", oldXDG) }()
|
||||
|
||||
// Should not infinite loop
|
||||
info, err := GetTmuxConfigFileInfo()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get config info: %v", err)
|
||||
}
|
||||
|
||||
// Should have both projects merged
|
||||
if _, ok := info.Merged.Config["projectA"]; !ok {
|
||||
t.Error("expected merged config to contain 'projectA'")
|
||||
}
|
||||
if _, ok := info.Merged.Config["projectB"]; !ok {
|
||||
t.Error("expected merged config to contain 'projectB'")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// dirFix expands ~ to home directory
|
||||
func dirFix(dir string) string {
|
||||
// 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:])
|
||||
@@ -36,7 +36,7 @@ func NameFix(name string) string {
|
||||
|
||||
// ParseConfig parses a raw config item into a resolved ParsedTmuxConfigItem
|
||||
func ParseConfig(key string, item TmuxConfigItemInput) ParsedTmuxConfigItem {
|
||||
root := dirFix(item.Root)
|
||||
root := DirFix(item.Root)
|
||||
|
||||
name := item.Name
|
||||
if name == "" {
|
||||
@@ -66,10 +66,20 @@ func ParseConfig(key string, item TmuxConfigItemInput) ParsedTmuxConfigItem {
|
||||
parsedWindows = append(parsedWindows, parseWindow(w, root))
|
||||
}
|
||||
|
||||
// Resolve initial window: per-session > global > default (1)
|
||||
initialWindow := 1
|
||||
if ConfiguredInitialWindow != nil {
|
||||
initialWindow = *ConfiguredInitialWindow
|
||||
}
|
||||
if item.InitialWindow != nil {
|
||||
initialWindow = *item.InitialWindow
|
||||
}
|
||||
|
||||
return ParsedTmuxConfigItem{
|
||||
Name: name,
|
||||
Root: root,
|
||||
Windows: parsedWindows,
|
||||
Name: name,
|
||||
Root: root,
|
||||
InitialWindow: initialWindow,
|
||||
Windows: parsedWindows,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +87,7 @@ func ParseConfig(key string, item TmuxConfigItemInput) ParsedTmuxConfigItem {
|
||||
func parseWindow(w TmuxWindowInput, root string) ParsedTmuxWindow {
|
||||
if w.IsString {
|
||||
// Window is just a directory path
|
||||
resolvedCwd := dirFix(resolvePath(root, w.String))
|
||||
resolvedCwd := DirFix(resolvePath(root, w.String))
|
||||
return ParsedTmuxWindow{
|
||||
Name: NameFix(filepath.Base(resolvedCwd)),
|
||||
Cwd: resolvedCwd,
|
||||
@@ -95,7 +105,7 @@ func parseWindow(w TmuxWindowInput, root string) ParsedTmuxWindow {
|
||||
}
|
||||
|
||||
// Window is a struct
|
||||
resolvedCwd := dirFix(resolvePath(root, w.Window.Cwd))
|
||||
resolvedCwd := DirFix(resolvePath(root, w.Window.Cwd))
|
||||
windowName := w.Window.Name
|
||||
if windowName == "" {
|
||||
windowName = NameFix(filepath.Base(resolvedCwd))
|
||||
@@ -178,6 +188,7 @@ func parsePaneLayout(pane *TmuxPaneLayout, root string) TmuxPaneLayout {
|
||||
if pane.Split != nil {
|
||||
result.Split = &TmuxSplitLayout{
|
||||
Direction: pane.Split.Direction,
|
||||
Size: pane.Split.Size,
|
||||
}
|
||||
if result.Split.Direction == "" {
|
||||
result.Split.Direction = "h"
|
||||
@@ -198,6 +209,7 @@ func copyTmuxSplitLayout(split *TmuxSplitLayout, root string) *TmuxSplitLayout {
|
||||
}
|
||||
result := &TmuxSplitLayout{
|
||||
Direction: split.Direction,
|
||||
Size: split.Size,
|
||||
}
|
||||
if split.Child != nil {
|
||||
child := TmuxPaneLayout{
|
||||
|
||||
@@ -45,9 +45,9 @@ func TestDirFix(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
result := dirFix(tt.input)
|
||||
result := DirFix(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("dirFix(%q) = %q, expected %q", tt.input, result, tt.expected)
|
||||
t.Errorf("DirFix(%q) = %q, expected %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -10,17 +12,48 @@ type GlobalConfig struct {
|
||||
ProjectsPath string `yaml:"projects_path,omitempty"`
|
||||
DefaultLayout *TmuxPaneLayout `yaml:"default_layout,omitempty"`
|
||||
NamedLayouts map[string]*TmuxPaneLayout `yaml:"named_layouts,omitempty"`
|
||||
InitialWindow *int `yaml:"initial_window,omitempty"`
|
||||
Include []string `yaml:"include,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"`
|
||||
BlankWindow bool `yaml:"blank_window,omitempty"`
|
||||
Windows []TmuxWindowInput `yaml:"windows,omitempty"`
|
||||
Root string `yaml:"root"`
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Aliases []string `yaml:"aliases,omitempty"`
|
||||
BlankWindow bool `yaml:"blank_window,omitempty"`
|
||||
InitialWindow *int `yaml:"initial_window,omitempty"`
|
||||
Windows []TmuxWindowInput `yaml:"windows,omitempty"`
|
||||
}
|
||||
|
||||
// TmuxWindowInput can be either a string (directory path) or a TmuxWindow struct
|
||||
@@ -108,15 +141,17 @@ type TmuxPaneLayout struct {
|
||||
|
||||
// TmuxSplitLayout represents a split configuration
|
||||
type TmuxSplitLayout struct {
|
||||
Direction string `yaml:"direction"` // "h" or "v"
|
||||
Direction string `yaml:"direction"` // "h" or "v"
|
||||
Size int `yaml:"size,omitempty"` // percentage (1-100) of the split given to the child pane
|
||||
Child *TmuxPaneLayout `yaml:"child"`
|
||||
}
|
||||
|
||||
// ParsedTmuxConfigItem is the resolved/parsed version of TmuxConfigItemInput
|
||||
type ParsedTmuxConfigItem struct {
|
||||
Name string
|
||||
Root string
|
||||
Windows []ParsedTmuxWindow
|
||||
Name string
|
||||
Root string
|
||||
InitialWindow int
|
||||
Windows []ParsedTmuxWindow
|
||||
}
|
||||
|
||||
// ParsedTmuxWindow is the resolved/parsed version of a window
|
||||
@@ -138,6 +173,9 @@ var ConfiguredDefaultLayout *TmuxPaneLayout
|
||||
// ConfiguredNamedLayouts holds user-configured named layouts (set from .config)
|
||||
var ConfiguredNamedLayouts map[string]*TmuxPaneLayout
|
||||
|
||||
// ConfiguredInitialWindow holds the user-configured default initial window (set from .config)
|
||||
var ConfiguredInitialWindow *int
|
||||
|
||||
// GetDefaultLayout returns the configured default layout or the hardcoded default
|
||||
func GetDefaultLayout() *TmuxPaneLayout {
|
||||
if ConfiguredDefaultLayout != nil {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,31 +13,41 @@ 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 {
|
||||
// resolveConfigTarget returns the config file path to write to.
|
||||
// If configFile is non-empty, it is expanded (~ resolved) and used directly.
|
||||
// Otherwise the global config file is used.
|
||||
func resolveConfigTarget(configFile string) (string, error) {
|
||||
if configFile != "" {
|
||||
return DirFix(configFile), nil
|
||||
}
|
||||
|
||||
files, err := GetTmuxConfigFileInfo()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if files.Global == nil {
|
||||
return "", ErrConfigNotFound
|
||||
}
|
||||
|
||||
return files.Global.Filepath, nil
|
||||
}
|
||||
|
||||
// AddSimpleConfigToFile appends a simple config to the config file.
|
||||
// If configFile is non-empty, it targets that file; otherwise the global config is used.
|
||||
func AddSimpleConfigToFile(config ParsedTmuxConfigItem, configFile string, dryRun bool) error {
|
||||
targetPath, err := resolveConfigTarget(configFile)
|
||||
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 {
|
||||
if _, _, exists := allConfigs.Get(config.Name); exists && !dryRun {
|
||||
return fmt.Errorf("%w: '%s'", ErrConfigItemExists, config.Name)
|
||||
}
|
||||
|
||||
@@ -57,13 +67,13 @@ func AddSimpleConfigToFile(config ParsedTmuxConfigItem, local bool, dryRun bool)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Println("Would have saved config to", file.Filepath)
|
||||
fmt.Println("Would have saved config to", targetPath)
|
||||
fmt.Println("Contents:")
|
||||
fmt.Println(sb.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(file.Filepath, os.O_APPEND|os.O_WRONLY, 0644)
|
||||
f, err := os.OpenFile(targetPath, os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -75,26 +85,16 @@ func AddSimpleConfigToFile(config ParsedTmuxConfigItem, local bool, dryRun bool)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveConfigFromFile removes a config item from the config file
|
||||
func RemoveConfigFromFile(key string, local bool, dryRun bool) error {
|
||||
files, err := GetTmuxConfigFileInfo()
|
||||
// RemoveConfigFromFile removes a config item from the config file.
|
||||
// If configFile is non-empty, it targets that file; otherwise the global config is used.
|
||||
func RemoveConfigFromFile(key string, configFile string, dryRun bool) error {
|
||||
targetPath, err := resolveConfigTarget(configFile)
|
||||
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)
|
||||
data, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -129,13 +129,13 @@ func RemoveConfigFromFile(key string, local bool, dryRun bool) error {
|
||||
result := strings.TrimRight(strings.Join(newContents, "\n"), "\n")
|
||||
|
||||
if dryRun {
|
||||
fmt.Println("Would have written to", file.Filepath)
|
||||
fmt.Println("Would have written to", targetPath)
|
||||
fmt.Println("New contents:")
|
||||
fmt.Println(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
return os.WriteFile(file.Filepath, []byte(result), 0644)
|
||||
return os.WriteFile(targetPath, []byte(result), 0644)
|
||||
}
|
||||
|
||||
// dirFixForWrite replaces home directory with ~
|
||||
|
||||
@@ -79,7 +79,7 @@ existing:
|
||||
}
|
||||
|
||||
// Dry run should not modify file
|
||||
err = AddSimpleConfigToFile(config, false, true)
|
||||
err = AddSimpleConfigToFile(config, "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("dry run failed: %v", err)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func TestAddSimpleConfigToFile(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
err = AddSimpleConfigToFile(config, false, false)
|
||||
err = AddSimpleConfigToFile(config, "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to add config: %v", err)
|
||||
}
|
||||
@@ -164,7 +164,7 @@ third:
|
||||
_ = os.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||
defer func() { _ = os.Setenv("XDG_CONFIG_HOME", oldXDG) }()
|
||||
|
||||
err = RemoveConfigFromFile("second", false, false)
|
||||
err = RemoveConfigFromFile("second", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to remove config: %v", err)
|
||||
}
|
||||
@@ -202,7 +202,7 @@ func TestRemoveConfigFromFile_NotFound(t *testing.T) {
|
||||
_ = os.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||
defer func() { _ = os.Setenv("XDG_CONFIG_HOME", oldXDG) }()
|
||||
|
||||
err = RemoveConfigFromFile("nonexistent", false, false)
|
||||
err = RemoveConfigFromFile("nonexistent", "", false)
|
||||
if err == nil {
|
||||
t.Error("expected error when removing nonexistent config")
|
||||
}
|
||||
@@ -226,7 +226,7 @@ func TestRemoveConfigFromFile_DryRun(t *testing.T) {
|
||||
_ = os.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||
defer func() { _ = os.Setenv("XDG_CONFIG_HOME", oldXDG) }()
|
||||
|
||||
err = RemoveConfigFromFile("toremove", false, true)
|
||||
err = RemoveConfigFromFile("toremove", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("dry run failed: %v", err)
|
||||
}
|
||||
@@ -262,7 +262,7 @@ last:
|
||||
_ = os.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||
defer func() { _ = os.Setenv("XDG_CONFIG_HOME", oldXDG) }()
|
||||
|
||||
err = RemoveConfigFromFile("last", false, false)
|
||||
err = RemoveConfigFromFile("last", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to remove last config: %v", err)
|
||||
}
|
||||
|
||||
@@ -2,66 +2,333 @@ 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.KeyPgUp:
|
||||
half := (m.height - 1) / 2
|
||||
if half < 1 {
|
||||
half = 1
|
||||
}
|
||||
m.cursor += half
|
||||
if m.cursor >= len(m.filtered) {
|
||||
m.cursor = len(m.filtered) - 1
|
||||
}
|
||||
m.clampScroll()
|
||||
return m, nil
|
||||
|
||||
case tea.KeyPgDown:
|
||||
half := (m.height - 1) / 2
|
||||
if half < 1 {
|
||||
half = 1
|
||||
}
|
||||
m.cursor -= half
|
||||
if m.cursor < 0 {
|
||||
m.cursor = 0
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
174
internal/fzf/match.go
Normal 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
216
internal/fzf/match_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
57
internal/table/table.go
Normal file
57
internal/table/table.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Package table renders simple bordered text tables with headers.
|
||||
package table
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Render returns a bordered table string with the given headers and rows.
|
||||
// Every line in the output is prefixed with indent. Column widths auto-size
|
||||
// to the widest cell (header included).
|
||||
func Render(headers []string, rows [][]string, indent string) string {
|
||||
widths := make([]int, len(headers))
|
||||
for i, h := range headers {
|
||||
widths[i] = utf8.RuneCountInString(h)
|
||||
}
|
||||
for _, row := range rows {
|
||||
for i, cell := range row {
|
||||
if i >= len(widths) {
|
||||
break
|
||||
}
|
||||
if w := utf8.RuneCountInString(cell); w > widths[i] {
|
||||
widths[i] = w
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
border := func(left, mid, right string) string {
|
||||
parts := make([]string, len(widths))
|
||||
for i, w := range widths {
|
||||
parts[i] = strings.Repeat("─", w+2)
|
||||
}
|
||||
return left + strings.Join(parts, mid) + right
|
||||
}
|
||||
rowLine := func(cells []string) string {
|
||||
parts := make([]string, len(widths))
|
||||
for i := range widths {
|
||||
cell := ""
|
||||
if i < len(cells) {
|
||||
cell = cells[i]
|
||||
}
|
||||
pad := max(widths[i]-utf8.RuneCountInString(cell), 0)
|
||||
parts[i] = " " + cell + strings.Repeat(" ", pad) + " "
|
||||
}
|
||||
return "│" + strings.Join(parts, "│") + "│"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(indent + border("┌", "┬", "┐") + "\n")
|
||||
b.WriteString(indent + rowLine(headers) + "\n")
|
||||
b.WriteString(indent + border("├", "┼", "┤") + "\n")
|
||||
for _, row := range rows {
|
||||
b.WriteString(indent + rowLine(row) + "\n")
|
||||
}
|
||||
b.WriteString(indent + border("└", "┴", "┘") + "\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
"github.com/chenasraf/tx/internal/exec"
|
||||
)
|
||||
|
||||
// CreateFromConfig creates a tmux session from a parsed config
|
||||
func CreateFromConfig(opts exec.Opts, tmuxConfig config.ParsedTmuxConfigItem) error {
|
||||
// CreateFromConfig creates a tmux session from a parsed config.
|
||||
// If background is true, the session is created but not attached to.
|
||||
func CreateFromConfig(opts exec.Opts, tmuxConfig config.ParsedTmuxConfigItem, background bool) error {
|
||||
root := tmuxConfig.Root
|
||||
windows := tmuxConfig.Windows
|
||||
sessionName := config.NameFix(tmuxConfig.Name)
|
||||
@@ -19,6 +20,10 @@ func CreateFromConfig(opts exec.Opts, tmuxConfig config.ParsedTmuxConfigItem) er
|
||||
|
||||
// Check if session already exists
|
||||
if SessionExists(opts, sessionName) {
|
||||
if background {
|
||||
exec.Log(opts, fmt.Sprintf("tmux session %s already exists (background mode, not attaching)", sessionName))
|
||||
return nil
|
||||
}
|
||||
exec.Log(opts, fmt.Sprintf("tmux session %s already exists, attaching...", sessionName))
|
||||
return AttachToSession(opts, sessionName)
|
||||
}
|
||||
@@ -60,11 +65,11 @@ 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
|
||||
commands = append(commands, fmt.Sprintf("tmux select-window -t %s:1", sessionName))
|
||||
// Select initial window
|
||||
commands = append(commands, fmt.Sprintf("tmux select-window -t %s:%d", sessionName, tmuxConfig.InitialWindow))
|
||||
|
||||
// Execute all commands
|
||||
for _, command := range commands {
|
||||
@@ -73,7 +78,10 @@ func CreateFromConfig(opts exec.Opts, tmuxConfig config.ParsedTmuxConfigItem) er
|
||||
}
|
||||
}
|
||||
|
||||
// Attach to the session
|
||||
// Attach to the session unless background mode
|
||||
if background {
|
||||
return nil
|
||||
}
|
||||
return AttachToSession(opts, sessionName)
|
||||
}
|
||||
|
||||
@@ -119,10 +127,14 @@ func getPaneCommands(opts exec.Opts, pane config.TmuxPaneLayout, sessionName, wi
|
||||
// Increment pane index for the new pane created by split
|
||||
*paneIndex++
|
||||
|
||||
commands = append(commands, fmt.Sprintf(
|
||||
splitCmd := fmt.Sprintf(
|
||||
"tmux split-window -%s -t %s:%s -c %s",
|
||||
direction, sessionName, windowName, cwd,
|
||||
))
|
||||
)
|
||||
if pane.Split.Size > 0 && pane.Split.Size <= 100 {
|
||||
splitCmd += fmt.Sprintf(" -p %d", pane.Split.Size)
|
||||
}
|
||||
commands = append(commands, splitCmd)
|
||||
|
||||
// Handle child pane
|
||||
if pane.Split.Child != nil {
|
||||
|
||||
@@ -251,7 +251,7 @@ func TestCreateFromConfig_DryRun(t *testing.T) {
|
||||
}
|
||||
|
||||
// In dry mode, this should succeed without actually running tmux
|
||||
err := CreateFromConfig(opts, tmuxConfig)
|
||||
err := CreateFromConfig(opts, tmuxConfig, false)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error in dry mode, got %v", err)
|
||||
}
|
||||
@@ -281,7 +281,7 @@ func TestCreateFromConfig_MultipleWindows(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
err := CreateFromConfig(opts, tmuxConfig)
|
||||
err := CreateFromConfig(opts, tmuxConfig, false)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error in dry mode, got %v", err)
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.2.0
|
||||
2.4.1
|
||||
|
||||
Reference in New Issue
Block a user