mirror of
https://github.com/chenasraf/tx.git
synced 2026-05-18 01:29:08 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cad6b2cef | ||
| b52ef759b6 | |||
| 302ecb697e | |||
| 7a3b1364ae | |||
| d913ae80f9 | |||
|
|
45bd05c07d | ||
| b3d357b386 | |||
| ae6de6bf57 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
|
||||
65
README.md
65
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
11
main.go
@@ -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
1
version.txt
Normal file
@@ -0,0 +1 @@
|
||||
1.1.0
|
||||
Reference in New Issue
Block a user