8 Commits

Author SHA1 Message Date
github-actions[bot]
9cad6b2cef chore(master): release 1.1.0 2026-01-30 01:29:20 +02:00
b52ef759b6 feat: update possible config locations 2026-01-30 01:27:36 +02:00
302ecb697e feat: allow overriding default layout 2026-01-30 01:20:14 +02:00
7a3b1364ae docs: update README.md 2026-01-29 14:23:05 +02:00
d913ae80f9 feat: add version flag 2026-01-29 14:21:21 +02:00
github-actions[bot]
45bd05c07d chore(master): release 1.0.1 2026-01-29 14:08:42 +02:00
b3d357b386 docs: update README.md 2026-01-29 14:08:21 +02:00
ae6de6bf57 fix: error & cancel outputs 2026-01-29 14:06:51 +02:00
23 changed files with 305 additions and 133 deletions

View File

@@ -1,5 +1,21 @@
# Changelog
## [1.1.0](https://github.com/chenasraf/tx/compare/v1.0.1...v1.1.0) (2026-01-29)
### Features
* add version flag ([d913ae8](https://github.com/chenasraf/tx/commit/d913ae80f9be09d4fdb20e3cfd23df071abf1fe6))
* allow overriding default layout ([302ecb6](https://github.com/chenasraf/tx/commit/302ecb697ef4e0fad79a0e87af108646a24eb870))
* update possible config locations ([b52ef75](https://github.com/chenasraf/tx/commit/b52ef759b6d1cd92c76e903852d16d64430ee649))
## [1.0.1](https://github.com/chenasraf/tx/compare/v1.0.0...v1.0.1) (2026-01-29)
### Bug Fixes
* error & cancel outputs ([ae6de6b](https://github.com/chenasraf/tx/commit/ae6de6bf578bd059e88633d87cd45c09bbd27fdd))
## 1.0.0 (2026-01-29)

View File

@@ -23,7 +23,7 @@ A tmux session manager that creates sessions from YAML configuration files.
### Download Precompiled Binaries
Precompiled binaries for `tx` are available for **Linux** and **macOS**:
Precompiled binaries for `tx` are available for **Linux**, **macOS** and **Windows**:
- Visit the [Releases Page](https://github.com/chenasraf/tx/releases/latest) to download the latest
version for your platform.
@@ -47,7 +47,8 @@ go install github.com/chenasraf/tx@latest
```bash
git clone https://github.com/chenasraf/tx.git
cd tx
go build -o tx .
make build # build only
make install # build & install to ~/.local/bin
```
---
@@ -102,16 +103,15 @@ tx rm <name> -l # remove from local config
tx searches for configuration files in these locations (in order):
1. Current working directory
2. Executable directory
3. Home directory (`~`)
4. `~/.dotfiles`
5. `$APPDATA` (if set)
1. Home directory (`~`)
2. `$XDG_CONFIG_HOME` (if set)
3. `~/.config`
4. `%APPDATA%` (Windows, if set)
File patterns searched:
- `tmux.yaml` / `tmux.yml`
- `.tmux.yaml` / `.tmux.yml`
- `.config/.tmux.yaml` / `.config/.tmux.yml`
Local config files (`.tmux_local.yaml`) are merged with global config, with local values taking
precedence.
@@ -213,6 +213,7 @@ layout:
cwd: .
cmd: npm start # command to run
zoom: true # zoom this pane
clock: false # show tmux clock mode
split:
direction: h # h (horizontal) or v (vertical)
child:
@@ -222,6 +223,7 @@ layout:
direction: v
child:
cwd: .
clock: true # show clock in this pane
```
### Global Settings
@@ -240,10 +242,49 @@ myproject:
#### Available Settings
| Setting | Description |
| --------------- | ------------------------------------------------- |
| `shell` | Shell to use for command execution |
| `projects_path` | Directory for `tx prj` command (required for prj) |
| 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) |
#### 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:
```yaml
.config:
default_layout:
cwd: .
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
```
#### Shell Resolution Order

View File

@@ -30,7 +30,7 @@ func TestCreateCmd_Aliases(t *testing.T) {
func TestCreateCmd_Flags(t *testing.T) {
rootDirFlag := createCmd.Flags().Lookup("root-dir")
if rootDirFlag == nil {
t.Error("expected --root-dir flag")
t.Fatal("expected --root-dir flag")
}
if rootDirFlag.Shorthand != "r" {
t.Errorf("expected -r shorthand, got %q", rootDirFlag.Shorthand)
@@ -38,7 +38,7 @@ func TestCreateCmd_Flags(t *testing.T) {
windowFlag := createCmd.Flags().Lookup("window")
if windowFlag == nil {
t.Error("expected --window flag")
t.Fatal("expected --window flag")
}
if windowFlag.Shorthand != "w" {
t.Errorf("expected -w shorthand, got %q", windowFlag.Shorthand)
@@ -46,7 +46,7 @@ func TestCreateCmd_Flags(t *testing.T) {
saveFlag := createCmd.Flags().Lookup("save")
if saveFlag == nil {
t.Error("expected --save flag")
t.Fatal("expected --save flag")
}
if saveFlag.Shorthand != "s" {
t.Errorf("expected -s shorthand, got %q", saveFlag.Shorthand)
@@ -54,7 +54,7 @@ func TestCreateCmd_Flags(t *testing.T) {
saveOnlyFlag := createCmd.Flags().Lookup("save-only")
if saveOnlyFlag == nil {
t.Error("expected --save-only flag")
t.Fatal("expected --save-only flag")
}
if saveOnlyFlag.Shorthand != "S" {
t.Errorf("expected -S shorthand, got %q", saveOnlyFlag.Shorthand)
@@ -62,7 +62,7 @@ func TestCreateCmd_Flags(t *testing.T) {
localFlag := createCmd.Flags().Lookup("local")
if localFlag == nil {
t.Error("expected --local flag")
t.Fatal("expected --local flag")
}
if localFlag.Shorthand != "l" {
t.Errorf("expected -l shorthand, got %q", localFlag.Shorthand)

View File

@@ -30,7 +30,7 @@ func TestEditCmd_Aliases(t *testing.T) {
func TestEditCmd_Flags(t *testing.T) {
localFlag := editCmd.Flags().Lookup("local")
if localFlag == nil {
t.Error("expected --local flag")
t.Fatal("expected --local flag")
}
if localFlag.Shorthand != "l" {
t.Errorf("expected -l shorthand, got %q", localFlag.Shorthand)

View File

@@ -34,7 +34,7 @@ func TestListCmd_Aliases(t *testing.T) {
func TestListCmd_Flags(t *testing.T) {
bareFlag := listCmd.Flags().Lookup("bare")
if bareFlag == nil {
t.Error("expected --bare flag")
t.Fatal("expected --bare flag")
}
if bareFlag.Shorthand != "b" {
t.Errorf("expected -b shorthand, got %q", bareFlag.Shorthand)
@@ -42,7 +42,7 @@ func TestListCmd_Flags(t *testing.T) {
sessionsFlag := listCmd.Flags().Lookup("sessions")
if sessionsFlag == nil {
t.Error("expected --sessions flag")
t.Fatal("expected --sessions flag")
}
if sessionsFlag.Shorthand != "s" {
t.Errorf("expected -s shorthand, got %q", sessionsFlag.Shorthand)

View File

@@ -9,9 +9,11 @@ import (
func TestRunMain_NoConfig(t *testing.T) {
// Create a temp directory with no config
tmpDir := t.TempDir()
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
// Set XDG_CONFIG_HOME to temp directory (which has no config)
oldXDG := os.Getenv("XDG_CONFIG_HOME")
_ = os.Setenv("XDG_CONFIG_HOME", tmpDir)
defer func() { _ = os.Setenv("XDG_CONFIG_HOME", oldXDG) }()
// Set dry mode to prevent actual tmux operations
dry = true
@@ -40,9 +42,10 @@ testproject:
t.Fatalf("failed to write temp config: %v", err)
}
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
// 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) }()
// Set dry mode
dry = true
@@ -69,9 +72,10 @@ existingproject:
t.Fatalf("failed to write temp config: %v", err)
}
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
// 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 }()

View File

@@ -33,7 +33,7 @@ func TestPrjCmd_Aliases(t *testing.T) {
func TestPrjCmd_Flags(t *testing.T) {
saveFlag := prjCmd.Flags().Lookup("save")
if saveFlag == nil {
t.Error("expected --save flag")
t.Fatal("expected --save flag")
}
if saveFlag.Shorthand != "s" {
t.Errorf("expected -s shorthand, got %q", saveFlag.Shorthand)
@@ -41,7 +41,7 @@ func TestPrjCmd_Flags(t *testing.T) {
localFlag := prjCmd.Flags().Lookup("local")
if localFlag == nil {
t.Error("expected --local flag")
t.Fatal("expected --local flag")
}
if localFlag.Shorthand != "l" {
t.Errorf("expected -l shorthand, got %q", localFlag.Shorthand)

View File

@@ -30,7 +30,7 @@ func TestRemoveCmd_Aliases(t *testing.T) {
func TestRemoveCmd_Flags(t *testing.T) {
localFlag := removeCmd.Flags().Lookup("local")
if localFlag == nil {
t.Error("expected --local flag")
t.Fatal("expected --local flag")
}
if localFlag.Shorthand != "l" {
t.Errorf("expected -l shorthand, got %q", localFlag.Shorthand)

View File

@@ -10,6 +10,9 @@ import (
)
var (
// Version is set by main.go from embedded version.txt
Version string
// Global flags
verbose bool
dry bool
@@ -47,6 +50,8 @@ It supports complex pane layouts, fzf selection, and config merging.`,
Args: cobra.MaximumNArgs(1),
PersistentPreRunE: initConfig,
RunE: runMain,
SilenceErrors: true, // We handle error printing in Execute()
SilenceUsage: true, // Don't print usage on runtime errors
}
// initConfig loads global configuration and applies settings
@@ -58,18 +63,19 @@ func initConfig(cmd *cobra.Command, args []string) error {
if globalConfig.Shell != "" {
exec.Shell = globalConfig.Shell
}
// Apply default layout from config
if globalConfig.DefaultLayout != nil {
config.ConfiguredDefaultLayout = globalConfig.DefaultLayout
}
}
return nil
}
// Execute adds all child commands to the root command and sets flags appropriately
func Execute() {
rootCmd.Version = Version
if err := rootCmd.Execute(); err != nil {
if _, ok := err.(*UserError); ok {
fmt.Fprintln(os.Stderr, "Error:", err.Error())
} else {
fmt.Fprintln(os.Stderr, err)
}
fmt.Fprintln(os.Stderr, "Error:", err.Error())
os.Exit(1)
}
}

View File

@@ -33,7 +33,7 @@ func TestShowCmd_Aliases(t *testing.T) {
func TestShowCmd_Flags(t *testing.T) {
jsonFlag := showCmd.Flags().Lookup("json")
if jsonFlag == nil {
t.Error("expected --json flag")
t.Fatal("expected --json flag")
}
if jsonFlag.Shorthand != "j" {
t.Errorf("expected -j shorthand, got %q", jsonFlag.Shorthand)

View File

@@ -30,22 +30,29 @@ var ErrNoConfigFound = errors.New("no config file found")
// searchDirs returns the directories to search for config files
func searchDirs() []string {
dirs := []string{
mustGetwd(),
}
// Add executable directory
if execPath, err := os.Executable(); err == nil {
dirs = append(dirs, filepath.Dir(execPath))
}
var dirs []string
// Add home directory
if home, err := os.UserHomeDir(); err == nil {
home, err := os.UserHomeDir()
if err == nil {
dirs = append(dirs, home)
dirs = append(dirs, filepath.Join(home, ".dotfiles"))
}
// Add APPDATA if set
// Add XDG config directory
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
if xdgConfig != "" {
dirs = append(dirs, xdgConfig)
}
// Add ~/.config as fallback (only if different from XDG_CONFIG_HOME)
if home != "" {
dotConfig := filepath.Join(home, ".config")
if dotConfig != xdgConfig {
dirs = append(dirs, dotConfig)
}
}
// Add APPDATA if set (Windows)
if appdata := os.Getenv("APPDATA"); appdata != "" {
dirs = append(dirs, appdata)
}
@@ -53,21 +60,13 @@ func searchDirs() []string {
return dirs
}
func mustGetwd() string {
wd, err := os.Getwd()
if err != nil {
return "."
}
return wd
}
// searchPatterns returns the file patterns to search for a given name
func searchPatterns(name string) []string {
return []string{
name + ".yaml",
name + ".yml",
"." + name + ".yaml",
"." + name + ".yml",
filepath.Join(".config", "."+name+".yaml"),
filepath.Join(".config", "."+name+".yml"),
}
}
@@ -191,6 +190,9 @@ func mergeGlobalConfigs(configs ...*GlobalConfig) *GlobalConfig {
if cfg.ProjectsPath != "" {
result.ProjectsPath = cfg.ProjectsPath
}
if cfg.DefaultLayout != nil {
result.DefaultLayout = cfg.DefaultLayout
}
}
return result
}

View File

@@ -10,10 +10,10 @@ func TestSearchPatterns(t *testing.T) {
patterns := searchPatterns("tmux")
expected := []string{
"tmux.yaml",
"tmux.yml",
".tmux.yaml",
".tmux.yml",
filepath.Join(".config", ".tmux.yaml"),
filepath.Join(".config", ".tmux.yml"),
}
if len(patterns) != len(expected) {
@@ -35,23 +35,23 @@ func TestSearchDirs(t *testing.T) {
t.Error("expected at least one search directory")
}
// First dir should be current working directory
cwd, _ := os.Getwd()
if dirs[0] != cwd {
t.Errorf("expected first dir to be cwd %q, got %q", cwd, dirs[0])
// First dir should be home directory
home, _ := os.UserHomeDir()
if dirs[0] != home {
t.Errorf("expected first dir to be home %q, got %q", home, dirs[0])
}
// Should contain home directory
home, _ := os.UserHomeDir()
// Should contain ~/.config
dotConfig := filepath.Join(home, ".config")
found := false
for _, d := range dirs {
if d == home {
if d == dotConfig {
found = true
break
}
}
if !found {
t.Error("expected search dirs to contain home directory")
t.Error("expected search dirs to contain ~/.config")
}
}
@@ -295,10 +295,10 @@ testproject:
t.Fatalf("failed to write temp config: %v", err)
}
// Change to temp directory
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
// Set XDG_CONFIG_HOME to temp directory so it's in the search path
oldXDG := os.Getenv("XDG_CONFIG_HOME")
_ = os.Setenv("XDG_CONFIG_HOME", tmpDir)
defer func() { _ = os.Setenv("XDG_CONFIG_HOME", oldXDG) }()
result, err := findConfigFile("tmux")
if err != nil {

View File

@@ -54,7 +54,7 @@ func ParseConfig(key string, item TmuxConfigItemInput) ParsedTmuxConfigItem {
Name: name,
Cwd: root,
Layout: &TmuxLayoutInput{
PaneLayout: &DefaultEmptyLayout,
PaneLayout: GetDefaultLayout(),
},
},
}
@@ -81,7 +81,7 @@ func parseWindow(w TmuxWindowInput, root string) ParsedTmuxWindow {
return ParsedTmuxWindow{
Name: NameFix(filepath.Base(resolvedCwd)),
Cwd: resolvedCwd,
Layout: parseLayoutWithCwd(&TmuxLayoutInput{PaneLayout: &DefaultEmptyLayout}, resolvedCwd),
Layout: parseLayoutWithCwd(&TmuxLayoutInput{PaneLayout: GetDefaultLayout()}, resolvedCwd),
}
}
@@ -90,7 +90,7 @@ func parseWindow(w TmuxWindowInput, root string) ParsedTmuxWindow {
return ParsedTmuxWindow{
Name: NameFix(filepath.Base(root)),
Cwd: root,
Layout: parseLayoutWithCwd(&TmuxLayoutInput{PaneLayout: &DefaultEmptyLayout}, root),
Layout: parseLayoutWithCwd(&TmuxLayoutInput{PaneLayout: GetDefaultLayout()}, root),
}
}
@@ -112,8 +112,8 @@ func parseWindow(w TmuxWindowInput, root string) ParsedTmuxWindow {
func parseLayout(layoutInput *TmuxLayoutInput, root string) TmuxPaneLayout {
if layoutInput == nil {
return TmuxPaneLayout{
Cwd: resolvePath(root, "."),
Zoom: DefaultEmptyLayout.Zoom,
Cwd: resolvePath(root, "."),
Zoom: DefaultEmptyLayout.Zoom,
Split: copyTmuxSplitLayout(DefaultEmptyLayout.Split, root),
}
}
@@ -139,7 +139,7 @@ func parseLayout(layoutInput *TmuxLayoutInput, root string) TmuxPaneLayout {
}
}
baseLayout := parseLayout(&TmuxLayoutInput{PaneLayout: &DefaultEmptyLayout}, root)
baseLayout := parseLayout(&TmuxLayoutInput{PaneLayout: GetDefaultLayout()}, root)
baseLayout.Split = split
return baseLayout
}
@@ -198,6 +198,7 @@ func copyTmuxSplitLayout(split *TmuxSplitLayout, root string) *TmuxSplitLayout {
Cwd: resolvePath(root, split.Child.Cwd),
Cmd: split.Child.Cmd,
Zoom: split.Child.Zoom,
Clock: split.Child.Clock,
Split: copyTmuxSplitLayout(split.Child.Split, root),
}
result.Child = &child

View File

@@ -15,7 +15,7 @@ func TestNameFix(t *testing.T) {
{"foo", "foo"},
{"foo.bar", "foo"},
{"foo.bar.baz", "foo"},
{".hidden", "hidden"}, // .hidden splits to ["", "hidden"], first non-empty is "hidden"
{".hidden", "hidden"}, // .hidden splits to ["", "hidden"], first non-empty is "hidden"
{"", ""},
{"noextension", "noextension"},
}

View File

@@ -6,8 +6,9 @@ import (
// GlobalConfig holds global settings from the .config section
type GlobalConfig struct {
Shell string `yaml:"shell,omitempty"`
ProjectsPath string `yaml:"projects_path,omitempty"`
Shell string `yaml:"shell,omitempty"`
ProjectsPath string `yaml:"projects_path,omitempty"`
DefaultLayout *TmuxPaneLayout `yaml:"default_layout,omitempty"`
}
// ConfigFile represents the top-level config file: map of session name -> config
@@ -100,6 +101,7 @@ type TmuxPaneLayout struct {
Cwd string `yaml:"cwd"`
Cmd string `yaml:"cmd,omitempty"`
Zoom bool `yaml:"zoom,omitempty"`
Clock bool `yaml:"clock,omitempty"`
Split *TmuxSplitLayout `yaml:"split,omitempty"`
}
@@ -129,6 +131,17 @@ var DefaultEmptyPane = TmuxPaneLayout{
Cmd: "",
}
// ConfiguredDefaultLayout holds the user-configured default layout (set from .config)
var ConfiguredDefaultLayout *TmuxPaneLayout
// GetDefaultLayout returns the configured default layout or the hardcoded default
func GetDefaultLayout() *TmuxPaneLayout {
if ConfiguredDefaultLayout != nil {
return ConfiguredDefaultLayout
}
return &DefaultEmptyLayout
}
// DefaultEmptyLayout is the default layout with horizontal and vertical splits
var DefaultEmptyLayout = TmuxPaneLayout{
Cwd: ".",
@@ -141,7 +154,8 @@ var DefaultEmptyLayout = TmuxPaneLayout{
Split: &TmuxSplitLayout{
Direction: "v",
Child: &TmuxPaneLayout{
Cwd: ".",
Cwd: ".",
Clock: true,
},
},
},

View File

@@ -67,9 +67,11 @@ func AddSimpleConfigToFile(config ParsedTmuxConfigItem, local bool, dryRun bool)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(sb.String())
if closeErr := f.Close(); err == nil {
err = closeErr
}
return err
}

View File

@@ -65,10 +65,10 @@ existing:
t.Fatalf("failed to write temp config: %v", err)
}
// Change to temp directory so config is found
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
// 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) }()
config := ParsedTmuxConfigItem{
Name: "newproject",
@@ -104,10 +104,10 @@ func TestAddSimpleConfigToFile(t *testing.T) {
t.Fatalf("failed to write temp config: %v", err)
}
// Change to temp directory so config is found
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
// 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) }()
config := ParsedTmuxConfigItem{
Name: "newproject",
@@ -159,10 +159,10 @@ third:
t.Fatalf("failed to write temp config: %v", err)
}
// Change to temp directory so config is found
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
// 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) }()
err = RemoveConfigFromFile("second", false, false)
if err != nil {
@@ -197,10 +197,10 @@ func TestRemoveConfigFromFile_NotFound(t *testing.T) {
t.Fatalf("failed to write temp config: %v", err)
}
// Change to temp directory so config is found
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
// 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) }()
err = RemoveConfigFromFile("nonexistent", false, false)
if err == nil {
@@ -221,10 +221,10 @@ func TestRemoveConfigFromFile_DryRun(t *testing.T) {
t.Fatalf("failed to write temp config: %v", err)
}
// Change to temp directory so config is found
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
// 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) }()
err = RemoveConfigFromFile("toremove", false, true)
if err != nil {
@@ -257,10 +257,10 @@ last:
t.Fatalf("failed to write temp config: %v", err)
}
// Change to temp directory so config is found
oldWd, _ := os.Getwd()
defer os.Chdir(oldWd)
os.Chdir(tmpDir)
// 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) }()
err = RemoveConfigFromFile("last", false, false)
if err != nil {

View File

@@ -159,8 +159,8 @@ func TestGetShell_Default(t *testing.T) {
// Unset env var
oldEnv := os.Getenv("SHELL")
os.Unsetenv("SHELL")
defer os.Setenv("SHELL", oldEnv)
_ = os.Unsetenv("SHELL")
defer func() { _ = os.Setenv("SHELL", oldEnv) }()
shell := getShell()
// Should return one of the default shells or "sh"
@@ -177,8 +177,8 @@ func TestGetShell_EnvVar(t *testing.T) {
// Set env var
oldEnv := os.Getenv("SHELL")
os.Setenv("SHELL", "/custom/shell")
defer os.Setenv("SHELL", oldEnv)
_ = os.Setenv("SHELL", "/custom/shell")
defer func() { _ = os.Setenv("SHELL", oldEnv) }()
shell := getShell()
if shell != "/custom/shell" {
@@ -194,8 +194,8 @@ func TestGetShell_ConfigOverridesEnv(t *testing.T) {
// Set env var too
oldEnv := os.Getenv("SHELL")
os.Setenv("SHELL", "/env/shell")
defer os.Setenv("SHELL", oldEnv)
_ = os.Setenv("SHELL", "/env/shell")
defer func() { _ = os.Setenv("SHELL", oldEnv) }()
shell := getShell()
// Config should take priority over env

View File

@@ -49,12 +49,17 @@ func CreateFromConfig(opts exec.Opts, tmuxConfig config.ParsedTmuxConfigItem) er
sessionName, i+1, windowName, dir,
))
// Get pane commands
paneCommands := getPaneCommands(opts, window.Layout, sessionName, windowName, root)
// Get pane commands - paneIndex starts at 0 for the window's initial pane
paneIndex := 0
paneCommands, timePanes := getPaneCommands(opts, window.Layout, sessionName, windowName, root, &paneIndex)
commands = append(commands, paneCommands...)
// Set clock mode and select first pane
commands = append(commands, fmt.Sprintf("tmux clock-mode -t %s:%s", sessionName, windowName))
// Execute clock-mode for panes that have Time: true
for _, paneIdx := range timePanes {
commands = append(commands, fmt.Sprintf("tmux clock-mode -t %s:%s.%d", sessionName, windowName, paneIdx))
}
// Select first pane
commands = append(commands, fmt.Sprintf("tmux select-pane -t %s.0", sessionName))
}
@@ -73,8 +78,17 @@ func CreateFromConfig(opts exec.Opts, tmuxConfig config.ParsedTmuxConfigItem) er
}
// getPaneCommands generates tmux commands for pane layout
func getPaneCommands(opts exec.Opts, pane config.TmuxPaneLayout, sessionName, windowName, rootDir string) []string {
// Returns the commands and a list of pane indices that should show time (clock-mode)
func getPaneCommands(opts exec.Opts, pane config.TmuxPaneLayout, sessionName, windowName, rootDir string, paneIndex *int) ([]string, []int) {
var commands []string
var timePanes []int
currentPane := *paneIndex
// Check if this pane should show time
if pane.Clock {
timePanes = append(timePanes, currentPane)
}
// Send command if specified
if pane.Cmd != "" {
@@ -102,6 +116,9 @@ func getPaneCommands(opts exec.Opts, pane config.TmuxPaneLayout, sessionName, wi
exec.Log(opts, "Splitting pane:", pane.Split, "direction:", direction)
// Increment pane index for the new pane created by split
*paneIndex++
commands = append(commands, fmt.Sprintf(
"tmux split-window -%s -t %s:%s -c %s",
direction, sessionName, windowName, cwd,
@@ -110,8 +127,9 @@ func getPaneCommands(opts exec.Opts, pane config.TmuxPaneLayout, sessionName, wi
// Handle child pane
if pane.Split.Child != nil {
exec.Log(opts, "Handling child pane:", pane.Split.Child)
childCommands := getPaneCommands(opts, *pane.Split.Child, sessionName, windowName, rootDir)
childCommands, childTimePanes := getPaneCommands(opts, *pane.Split.Child, sessionName, windowName, rootDir, paneIndex)
commands = append(commands, childCommands...)
timePanes = append(timePanes, childTimePanes...)
}
// Handle zoom
@@ -123,5 +141,5 @@ func getPaneCommands(opts exec.Opts, pane config.TmuxPaneLayout, sessionName, wi
}
}
return commands
return commands, timePanes
}

View File

@@ -14,7 +14,8 @@ func TestGetPaneCommands_NoSplit(t *testing.T) {
Cwd: "/tmp/test",
}
commands := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp")
paneIndex := 0
commands, _ := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp", &paneIndex)
// No split, no cmd = no commands
if len(commands) != 0 {
@@ -29,7 +30,8 @@ func TestGetPaneCommands_WithCmd(t *testing.T) {
Cmd: "npm start",
}
commands := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp")
paneIndex := 0
commands, _ := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp", &paneIndex)
if len(commands) != 1 {
t.Fatalf("expected 1 command, got %d: %v", len(commands), commands)
@@ -53,7 +55,8 @@ func TestGetPaneCommands_WithSplit(t *testing.T) {
},
}
commands := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp")
paneIndex := 0
commands, _ := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp", &paneIndex)
if len(commands) < 1 {
t.Fatalf("expected at least 1 command, got %d", len(commands))
@@ -77,7 +80,8 @@ func TestGetPaneCommands_WithVerticalSplit(t *testing.T) {
},
}
commands := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp")
paneIndex := 0
commands, _ := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp", &paneIndex)
if len(commands) < 1 {
t.Fatalf("expected at least 1 command, got %d", len(commands))
@@ -102,7 +106,8 @@ func TestGetPaneCommands_WithZoom(t *testing.T) {
},
}
commands := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp")
paneIndex := 0
commands, _ := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp", &paneIndex)
// Should have a resize-pane -Z command
found := false
@@ -136,7 +141,8 @@ func TestGetPaneCommands_NestedSplits(t *testing.T) {
},
}
commands := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp")
paneIndex := 0
commands, _ := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp", &paneIndex)
// Should have at least 2 split commands
splitCount := 0
@@ -163,7 +169,8 @@ func TestGetPaneCommands_DefaultDirection(t *testing.T) {
},
}
commands := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp")
paneIndex := 0
commands, _ := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp", &paneIndex)
if len(commands) < 1 {
t.Fatalf("expected at least 1 command, got %d", len(commands))
@@ -175,6 +182,57 @@ func TestGetPaneCommands_DefaultDirection(t *testing.T) {
}
}
func TestGetPaneCommands_WithClock(t *testing.T) {
opts := exec.Opts{Verbose: false, Dry: true}
pane := config.TmuxPaneLayout{
Cwd: "/tmp/test",
Clock: true,
}
paneIndex := 0
_, clockPanes := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp", &paneIndex)
if len(clockPanes) != 1 {
t.Errorf("expected 1 clock pane, got %d", len(clockPanes))
}
if len(clockPanes) > 0 && clockPanes[0] != 0 {
t.Errorf("expected clock pane index 0, got %d", clockPanes[0])
}
}
func TestGetPaneCommands_NestedClock(t *testing.T) {
opts := exec.Opts{Verbose: false, Dry: true}
pane := config.TmuxPaneLayout{
Cwd: "/tmp/test",
Split: &config.TmuxSplitLayout{
Direction: "h",
Child: &config.TmuxPaneLayout{
Cwd: "/tmp/test/child",
Split: &config.TmuxSplitLayout{
Direction: "v",
Child: &config.TmuxPaneLayout{
Cwd: "/tmp/test/child2",
Clock: true,
},
},
},
},
}
paneIndex := 0
_, clockPanes := getPaneCommands(opts, pane, "testsession", "testwindow", "/tmp", &paneIndex)
if len(clockPanes) != 1 {
t.Errorf("expected 1 clock pane, got %d", len(clockPanes))
}
// The clock pane should be index 2 (after 2 splits)
if len(clockPanes) > 0 && clockPanes[0] != 2 {
t.Errorf("expected clock pane index 2, got %d", clockPanes[0])
}
}
func TestCreateFromConfig_DryRun(t *testing.T) {
opts := exec.Opts{Verbose: false, Dry: true}

View File

@@ -19,10 +19,10 @@ func TestSessionExists_DryMode(t *testing.T) {
func TestAttachToSession_InsideTmux(t *testing.T) {
// Save original TMUX env
origTmux := os.Getenv("TMUX")
defer os.Setenv("TMUX", origTmux)
defer func() { _ = os.Setenv("TMUX", origTmux) }()
// Set TMUX to simulate being inside tmux
os.Setenv("TMUX", "/tmp/tmux-1000/default,12345,0")
_ = os.Setenv("TMUX", "/tmp/tmux-1000/default,12345,0")
opts := exec.Opts{Verbose: false, Dry: true}
err := AttachToSession(opts, "testsession")
@@ -36,10 +36,10 @@ func TestAttachToSession_InsideTmux(t *testing.T) {
func TestAttachToSession_OutsideTmux(t *testing.T) {
// Save original TMUX env
origTmux := os.Getenv("TMUX")
defer os.Setenv("TMUX", origTmux)
defer func() { _ = os.Setenv("TMUX", origTmux) }()
// Unset TMUX to simulate being outside tmux
os.Unsetenv("TMUX")
_ = os.Unsetenv("TMUX")
opts := exec.Opts{Verbose: false, Dry: true}
err := AttachToSession(opts, "testsession")

11
main.go
View File

@@ -1,7 +1,16 @@
package main
import "github.com/chenasraf/tx/internal/cli"
import (
_ "embed"
"strings"
"github.com/chenasraf/tx/internal/cli"
)
//go:embed version.txt
var version string
func main() {
cli.Version = strings.TrimSpace(version)
cli.Execute()
}

1
version.txt Normal file
View File

@@ -0,0 +1 @@
1.1.0