10 Commits

Author SHA1 Message Date
github-actions[bot]
779940ea3d chore(master): release 2.2.0 2026-03-24 11:31:05 +02:00
d067a6e964 feat: allow pane size config 2026-03-24 11:28:27 +02:00
github-actions[bot]
c5e21a5897 chore(master): release 2.1.0 2026-03-23 23:19:40 +02:00
296e13549e feat: accept multiple session names for rm/kill 2026-03-23 23:15:45 +02:00
github-actions[bot]
d8d915ec19 chore(master): release 2.0.0 2026-03-20 15:07:17 +02:00
2e801cd91e feat!: replace tmux_local with config file includes
Replace the implicit tmux_local.yaml auto-discovery with an explicit
`include` array in `.config`. Included files are resolved relative to
the parent config, support `~` expansion and absolute paths, and can
be nested (with circular include protection).

Replace `--local`/`-l` flag with `--config`/`-c` on create, edit,
remove, and prj commands, accepting a target file path.

Add `tx migrate` command to automatically convert v1.x configs by
injecting the legacy tmux_local file as an include entry.

BREAKING CHANGE: 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`.
2026-03-20 15:04:20 +02:00
github-actions[bot]
1d2161f805 chore(master): release 1.5.0 2026-03-19 00:11:40 +02:00
3f82e194bb feat: initial_window option 2026-03-19 00:05:09 +02:00
github-actions[bot]
41b7caa50f chore(master): release 1.4.1 2026-02-19 21:19:58 +02:00
1b803786b8 fix: pgup/pgdown scroll 2026-02-09 23:03:06 +02:00
25 changed files with 993 additions and 233 deletions

View File

@@ -1,5 +1,44 @@
# Changelog
## [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)

223
README.md
View File

@@ -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
@@ -85,13 +85,15 @@ tx prj -s # save to config
# 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
@@ -117,12 +119,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 +151,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 +204,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 +251,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 +297,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 +318,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 +342,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:
@@ -401,6 +472,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

View File

@@ -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 {
@@ -81,7 +81,7 @@ func runCreate(cmd *cobra.Command, args []string) error {
// 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
}
}

View File

@@ -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)
}
}

View File

@@ -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")
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -89,8 +89,8 @@ 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()

156
internal/cli/migrate_cmd.go Normal file
View 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")
}

View 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")
}
}

View File

@@ -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
@@ -113,7 +113,7 @@ 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
}
}

View File

@@ -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)
}
}

View File

@@ -8,47 +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
}
_, actualKey, exists := allConfig.Get(key)
if !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(actualKey, 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)
}

View File

@@ -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)
}
}

View File

@@ -1,6 +1,7 @@
package cli
import (
"errors"
"fmt"
"os"
@@ -41,6 +42,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]",
@@ -79,6 +85,39 @@ func completeSessionNames(cmd *cobra.Command, args []string, toComplete string)
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))
@@ -105,6 +144,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
}
@@ -134,4 +177,5 @@ func init() {
rootCmd.AddCommand(attachCmd)
rootCmd.AddCommand(prjCmd)
rootCmd.AddCommand(killCmd)
rootCmd.AddCommand(migrateCmd)
}

View File

@@ -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
}
@@ -208,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",
@@ -271,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

View File

@@ -225,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")
}
}
}
@@ -546,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'")
}
}

View File

@@ -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{

View File

@@ -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)
}
})
}

View File

@@ -12,6 +12,8 @@ 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
@@ -46,11 +48,12 @@ func (c ConfigFile) Get(key string) (TmuxConfigItemInput, string, bool) {
// TmuxConfigItemInput represents a single tmux session configuration
type TmuxConfigItemInput struct {
Root string `yaml:"root"`
Name string `yaml:"name,omitempty"`
Aliases []string `yaml:"aliases,omitempty"`
BlankWindow bool `yaml:"blank_window,omitempty"`
Windows []TmuxWindowInput `yaml:"windows,omitempty"`
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
@@ -138,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
@@ -168,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 {

View File

@@ -13,24 +13,34 @@ 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 {
@@ -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 ~

View File

@@ -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)
}

View File

@@ -111,6 +111,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
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()

View File

@@ -63,8 +63,8 @@ func CreateFromConfig(opts exec.Opts, tmuxConfig config.ParsedTmuxConfigItem) er
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 {
@@ -119,10 +119,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 {

View File

@@ -1 +1 @@
1.4.0
2.2.0