mirror of
https://github.com/chenasraf/tx.git
synced 2026-05-17 17:28:08 +00:00
feat: add named layouts
This commit is contained in:
31
README.md
31
README.md
@@ -255,6 +255,7 @@ myproject:
|
||||
| `shell` | Shell to use for command execution |
|
||||
| `projects_path` | Directory for `tx prj` command (required for prj) |
|
||||
| `default_layout` | Default pane layout for new windows (see below) |
|
||||
| `named_layouts` | Reusable named layouts (see below) |
|
||||
|
||||
#### Default Layout
|
||||
|
||||
@@ -294,6 +295,36 @@ Example - horizontal split with vertical sub-split (default):
|
||||
clock: true
|
||||
```
|
||||
|
||||
#### Named Layouts
|
||||
|
||||
Define reusable layouts that can be referenced by name in session configurations:
|
||||
|
||||
```yaml
|
||||
.config:
|
||||
named_layouts:
|
||||
dev:
|
||||
cwd: .
|
||||
cmd: npm run dev
|
||||
split:
|
||||
direction: h
|
||||
child:
|
||||
cwd: .
|
||||
cmd: npm run test:watch
|
||||
simple:
|
||||
cwd: .
|
||||
clock: true
|
||||
|
||||
myproject:
|
||||
root: ~/Dev/myproject
|
||||
windows:
|
||||
- name: main
|
||||
cwd: .
|
||||
layout: dev # references the "dev" named layout
|
||||
- name: logs
|
||||
cwd: ./logs
|
||||
layout: simple # references the "simple" named layout
|
||||
```
|
||||
|
||||
#### Shell Resolution Order
|
||||
|
||||
The shell used for executing commands is determined in this order:
|
||||
|
||||
@@ -90,6 +90,10 @@ func initConfig(cmd *cobra.Command, args []string) error {
|
||||
if globalConfig.DefaultLayout != nil {
|
||||
config.ConfiguredDefaultLayout = globalConfig.DefaultLayout
|
||||
}
|
||||
// Apply named layouts from config
|
||||
if globalConfig.NamedLayouts != nil {
|
||||
config.ConfiguredNamedLayouts = globalConfig.NamedLayouts
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -193,6 +193,14 @@ func mergeGlobalConfigs(configs ...*GlobalConfig) *GlobalConfig {
|
||||
if cfg.DefaultLayout != nil {
|
||||
result.DefaultLayout = cfg.DefaultLayout
|
||||
}
|
||||
if cfg.NamedLayouts != nil {
|
||||
if result.NamedLayouts == nil {
|
||||
result.NamedLayouts = make(map[string]*TmuxPaneLayout)
|
||||
}
|
||||
for name, layout := range cfg.NamedLayouts {
|
||||
result.NamedLayouts[name] = layout
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -281,6 +281,196 @@ func TestMergeConfigs_SkipsConfigSection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeGlobalConfigs(t *testing.T) {
|
||||
config1 := &GlobalConfig{
|
||||
Shell: "/bin/bash",
|
||||
ProjectsPath: "/old/path",
|
||||
}
|
||||
|
||||
config2 := &GlobalConfig{
|
||||
Shell: "/bin/zsh",
|
||||
}
|
||||
|
||||
merged := mergeGlobalConfigs(config1, config2)
|
||||
|
||||
// Shell should be overridden by config2
|
||||
if merged.Shell != "/bin/zsh" {
|
||||
t.Errorf("expected Shell to be '/bin/zsh', got %q", merged.Shell)
|
||||
}
|
||||
|
||||
// ProjectsPath should be preserved from config1
|
||||
if merged.ProjectsPath != "/old/path" {
|
||||
t.Errorf("expected ProjectsPath to be '/old/path', got %q", merged.ProjectsPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeGlobalConfigs_Nil(t *testing.T) {
|
||||
config1 := &GlobalConfig{
|
||||
Shell: "/bin/bash",
|
||||
}
|
||||
|
||||
merged := mergeGlobalConfigs(nil, config1, nil)
|
||||
|
||||
if merged.Shell != "/bin/bash" {
|
||||
t.Errorf("expected Shell to be '/bin/bash', got %q", merged.Shell)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeGlobalConfigs_NamedLayouts(t *testing.T) {
|
||||
config1 := &GlobalConfig{
|
||||
NamedLayouts: map[string]*TmuxPaneLayout{
|
||||
"dev": {
|
||||
Cwd: ".",
|
||||
Cmd: "npm run dev",
|
||||
},
|
||||
"common": {
|
||||
Cwd: ".",
|
||||
Cmd: "original command",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config2 := &GlobalConfig{
|
||||
NamedLayouts: map[string]*TmuxPaneLayout{
|
||||
"test": {
|
||||
Cwd: ".",
|
||||
Cmd: "npm test",
|
||||
},
|
||||
"common": {
|
||||
Cwd: ".",
|
||||
Cmd: "overridden command",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
merged := mergeGlobalConfigs(config1, config2)
|
||||
|
||||
if merged.NamedLayouts == nil {
|
||||
t.Fatal("expected NamedLayouts to not be nil")
|
||||
}
|
||||
|
||||
// Should have 3 layouts (dev, test, common)
|
||||
if len(merged.NamedLayouts) != 3 {
|
||||
t.Errorf("expected 3 named layouts, got %d", len(merged.NamedLayouts))
|
||||
}
|
||||
|
||||
// dev should be from config1
|
||||
if dev := merged.NamedLayouts["dev"]; dev == nil {
|
||||
t.Error("expected 'dev' layout to exist")
|
||||
} else if dev.Cmd != "npm run dev" {
|
||||
t.Errorf("expected dev.Cmd to be 'npm run dev', got %q", dev.Cmd)
|
||||
}
|
||||
|
||||
// test should be from config2
|
||||
if test := merged.NamedLayouts["test"]; test == nil {
|
||||
t.Error("expected 'test' layout to exist")
|
||||
} else if test.Cmd != "npm test" {
|
||||
t.Errorf("expected test.Cmd to be 'npm test', got %q", test.Cmd)
|
||||
}
|
||||
|
||||
// common should be overridden by config2
|
||||
if common := merged.NamedLayouts["common"]; common == nil {
|
||||
t.Error("expected 'common' layout to exist")
|
||||
} else if common.Cmd != "overridden command" {
|
||||
t.Errorf("expected common.Cmd to be 'overridden command', got %q", common.Cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeGlobalConfigs_DefaultLayout(t *testing.T) {
|
||||
config1 := &GlobalConfig{
|
||||
DefaultLayout: &TmuxPaneLayout{
|
||||
Cwd: ".",
|
||||
Cmd: "original",
|
||||
},
|
||||
}
|
||||
|
||||
config2 := &GlobalConfig{
|
||||
DefaultLayout: &TmuxPaneLayout{
|
||||
Cwd: ".",
|
||||
Clock: true,
|
||||
},
|
||||
}
|
||||
|
||||
merged := mergeGlobalConfigs(config1, config2)
|
||||
|
||||
if merged.DefaultLayout == nil {
|
||||
t.Fatal("expected DefaultLayout to not be nil")
|
||||
}
|
||||
|
||||
// DefaultLayout should be fully replaced by config2
|
||||
if !merged.DefaultLayout.Clock {
|
||||
t.Error("expected DefaultLayout.Clock to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadGlobalConfig_WithNamedLayouts(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, ".tmux.yaml")
|
||||
|
||||
content := `
|
||||
.config:
|
||||
shell: /bin/zsh
|
||||
named_layouts:
|
||||
dev:
|
||||
cwd: .
|
||||
cmd: npm run dev
|
||||
split:
|
||||
direction: h
|
||||
child:
|
||||
cwd: .
|
||||
cmd: npm test
|
||||
simple:
|
||||
cwd: .
|
||||
clock: true
|
||||
|
||||
testproject:
|
||||
root: /tmp/test
|
||||
`
|
||||
err := os.WriteFile(configPath, []byte(content), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write temp config: %v", err)
|
||||
}
|
||||
|
||||
globalConfig, err := loadGlobalConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load global config: %v", err)
|
||||
}
|
||||
|
||||
if globalConfig == nil {
|
||||
t.Fatal("expected globalConfig to not be nil")
|
||||
}
|
||||
|
||||
if globalConfig.NamedLayouts == nil {
|
||||
t.Fatal("expected NamedLayouts to not be nil")
|
||||
}
|
||||
|
||||
if len(globalConfig.NamedLayouts) != 2 {
|
||||
t.Errorf("expected 2 named layouts, got %d", len(globalConfig.NamedLayouts))
|
||||
}
|
||||
|
||||
dev := globalConfig.NamedLayouts["dev"]
|
||||
if dev == nil {
|
||||
t.Fatal("expected 'dev' layout to exist")
|
||||
}
|
||||
if dev.Cmd != "npm run dev" {
|
||||
t.Errorf("expected dev.Cmd to be 'npm run dev', got %q", dev.Cmd)
|
||||
}
|
||||
if dev.Split == nil {
|
||||
t.Fatal("expected dev.Split to not be nil")
|
||||
}
|
||||
if dev.Split.Direction != "h" {
|
||||
t.Errorf("expected dev.Split.Direction to be 'h', got %q", dev.Split.Direction)
|
||||
}
|
||||
|
||||
simple := globalConfig.NamedLayouts["simple"]
|
||||
if simple == nil {
|
||||
t.Fatal("expected 'simple' layout to exist")
|
||||
}
|
||||
if !simple.Clock {
|
||||
t.Error("expected simple.Clock to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConfigFile(t *testing.T) {
|
||||
// Create a temporary directory with a config file
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -119,6 +119,11 @@ func parseLayout(layoutInput *TmuxLayoutInput, root string) TmuxPaneLayout {
|
||||
}
|
||||
|
||||
if layoutInput.IsString {
|
||||
// Check if it's a named layout reference
|
||||
if namedLayout := GetNamedLayout(layoutInput.String); namedLayout != nil {
|
||||
return parsePaneLayout(namedLayout, root)
|
||||
}
|
||||
// Otherwise treat as directory path
|
||||
return TmuxPaneLayout{
|
||||
Cwd: resolvePath(root, layoutInput.String),
|
||||
Cmd: DefaultEmptyPane.Cmd,
|
||||
@@ -164,9 +169,10 @@ func parseLayoutWithCwd(layoutInput *TmuxLayoutInput, cwd string) TmuxPaneLayout
|
||||
// parsePaneLayout parses a TmuxPaneLayout resolving paths
|
||||
func parsePaneLayout(pane *TmuxPaneLayout, root string) TmuxPaneLayout {
|
||||
result := TmuxPaneLayout{
|
||||
Cwd: resolvePath(root, pane.Cwd),
|
||||
Cmd: pane.Cmd,
|
||||
Zoom: pane.Zoom,
|
||||
Cwd: resolvePath(root, pane.Cwd),
|
||||
Cmd: pane.Cmd,
|
||||
Zoom: pane.Zoom,
|
||||
Clock: pane.Clock,
|
||||
}
|
||||
|
||||
if pane.Split != nil {
|
||||
|
||||
@@ -286,3 +286,123 @@ func TestResolvePath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfig_NamedLayout(t *testing.T) {
|
||||
// Save and restore original state
|
||||
originalLayouts := ConfiguredNamedLayouts
|
||||
defer func() { ConfiguredNamedLayouts = originalLayouts }()
|
||||
|
||||
// Configure named layouts
|
||||
ConfiguredNamedLayouts = map[string]*TmuxPaneLayout{
|
||||
"dev": {
|
||||
Cwd: ".",
|
||||
Cmd: "npm run dev",
|
||||
Split: &TmuxSplitLayout{
|
||||
Direction: "h",
|
||||
Child: &TmuxPaneLayout{
|
||||
Cwd: ".",
|
||||
Cmd: "npm run test:watch",
|
||||
},
|
||||
},
|
||||
},
|
||||
"simple": {
|
||||
Cwd: ".",
|
||||
Clock: true,
|
||||
},
|
||||
}
|
||||
|
||||
input := TmuxConfigItemInput{
|
||||
Root: "/tmp/myproject",
|
||||
Name: "myproject",
|
||||
Windows: []TmuxWindowInput{
|
||||
{
|
||||
Window: &TmuxWindow{
|
||||
Name: "main",
|
||||
Cwd: "./src",
|
||||
Layout: &TmuxLayoutInput{
|
||||
IsString: true,
|
||||
String: "dev", // Reference named layout
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Window: &TmuxWindow{
|
||||
Name: "logs",
|
||||
Cwd: "./logs",
|
||||
Layout: &TmuxLayoutInput{
|
||||
IsString: true,
|
||||
String: "simple", // Reference named layout
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ParseConfig("myproject", input)
|
||||
|
||||
if len(result.Windows) != 2 {
|
||||
t.Fatalf("expected 2 windows, got %d", len(result.Windows))
|
||||
}
|
||||
|
||||
// Check first window uses "dev" layout
|
||||
mainLayout := result.Windows[0].Layout
|
||||
if mainLayout.Cmd != "npm run dev" {
|
||||
t.Errorf("expected first window Cmd to be 'npm run dev', got %q", mainLayout.Cmd)
|
||||
}
|
||||
if mainLayout.Split == nil {
|
||||
t.Fatal("expected first window Split to not be nil")
|
||||
}
|
||||
if mainLayout.Split.Direction != "h" {
|
||||
t.Errorf("expected first window Split.Direction to be 'h', got %q", mainLayout.Split.Direction)
|
||||
}
|
||||
if mainLayout.Split.Child == nil {
|
||||
t.Fatal("expected first window Split.Child to not be nil")
|
||||
}
|
||||
if mainLayout.Split.Child.Cmd != "npm run test:watch" {
|
||||
t.Errorf("expected first window child Cmd to be 'npm run test:watch', got %q", mainLayout.Split.Child.Cmd)
|
||||
}
|
||||
|
||||
// Check second window uses "simple" layout
|
||||
logsLayout := result.Windows[1].Layout
|
||||
if !logsLayout.Clock {
|
||||
t.Error("expected second window Clock to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfig_NamedLayoutNotFound(t *testing.T) {
|
||||
// Save and restore original state
|
||||
originalLayouts := ConfiguredNamedLayouts
|
||||
defer func() { ConfiguredNamedLayouts = originalLayouts }()
|
||||
|
||||
// No named layouts configured
|
||||
ConfiguredNamedLayouts = nil
|
||||
|
||||
input := TmuxConfigItemInput{
|
||||
Root: "/tmp/myproject",
|
||||
Name: "myproject",
|
||||
Windows: []TmuxWindowInput{
|
||||
{
|
||||
Window: &TmuxWindow{
|
||||
Name: "main",
|
||||
Cwd: "./src",
|
||||
Layout: &TmuxLayoutInput{
|
||||
IsString: true,
|
||||
String: "nonexistent", // Not a named layout, should be treated as path
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ParseConfig("myproject", input)
|
||||
|
||||
if len(result.Windows) != 1 {
|
||||
t.Fatalf("expected 1 window, got %d", len(result.Windows))
|
||||
}
|
||||
|
||||
// Should be treated as a directory path
|
||||
layout := result.Windows[0].Layout
|
||||
if layout.Cwd != "/tmp/myproject/src/nonexistent" {
|
||||
t.Errorf("expected layout Cwd to be '/tmp/myproject/src/nonexistent', got %q", layout.Cwd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ import (
|
||||
|
||||
// GlobalConfig holds global settings from the .config section
|
||||
type GlobalConfig struct {
|
||||
Shell string `yaml:"shell,omitempty"`
|
||||
ProjectsPath string `yaml:"projects_path,omitempty"`
|
||||
DefaultLayout *TmuxPaneLayout `yaml:"default_layout,omitempty"`
|
||||
Shell string `yaml:"shell,omitempty"`
|
||||
ProjectsPath string `yaml:"projects_path,omitempty"`
|
||||
DefaultLayout *TmuxPaneLayout `yaml:"default_layout,omitempty"`
|
||||
NamedLayouts map[string]*TmuxPaneLayout `yaml:"named_layouts,omitempty"`
|
||||
}
|
||||
|
||||
// ConfigFile represents the top-level config file: map of session name -> config
|
||||
@@ -134,6 +135,9 @@ var DefaultEmptyPane = TmuxPaneLayout{
|
||||
// ConfiguredDefaultLayout holds the user-configured default layout (set from .config)
|
||||
var ConfiguredDefaultLayout *TmuxPaneLayout
|
||||
|
||||
// ConfiguredNamedLayouts holds user-configured named layouts (set from .config)
|
||||
var ConfiguredNamedLayouts map[string]*TmuxPaneLayout
|
||||
|
||||
// GetDefaultLayout returns the configured default layout or the hardcoded default
|
||||
func GetDefaultLayout() *TmuxPaneLayout {
|
||||
if ConfiguredDefaultLayout != nil {
|
||||
@@ -142,6 +146,14 @@ func GetDefaultLayout() *TmuxPaneLayout {
|
||||
return &DefaultEmptyLayout
|
||||
}
|
||||
|
||||
// GetNamedLayout returns a named layout by name, or nil if not found
|
||||
func GetNamedLayout(name string) *TmuxPaneLayout {
|
||||
if ConfiguredNamedLayouts == nil {
|
||||
return nil
|
||||
}
|
||||
return ConfiguredNamedLayouts[name]
|
||||
}
|
||||
|
||||
// DefaultEmptyLayout is the default layout with horizontal and vertical splits
|
||||
var DefaultEmptyLayout = TmuxPaneLayout{
|
||||
Cwd: ".",
|
||||
|
||||
@@ -200,3 +200,130 @@ func TestDefaultEmptyLayout(t *testing.T) {
|
||||
t.Errorf("expected nested Split.Direction to be 'v', got %q", DefaultEmptyLayout.Split.Child.Split.Direction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNamedLayout(t *testing.T) {
|
||||
// Save and restore original state
|
||||
originalLayouts := ConfiguredNamedLayouts
|
||||
defer func() { ConfiguredNamedLayouts = originalLayouts }()
|
||||
|
||||
// Test when no named layouts are configured
|
||||
ConfiguredNamedLayouts = nil
|
||||
if layout := GetNamedLayout("test"); layout != nil {
|
||||
t.Error("expected nil when no named layouts configured")
|
||||
}
|
||||
|
||||
// Test when named layout exists
|
||||
ConfiguredNamedLayouts = map[string]*TmuxPaneLayout{
|
||||
"dev": {
|
||||
Cwd: ".",
|
||||
Cmd: "npm run dev",
|
||||
},
|
||||
"simple": {
|
||||
Cwd: ".",
|
||||
Clock: true,
|
||||
},
|
||||
}
|
||||
|
||||
layout := GetNamedLayout("dev")
|
||||
if layout == nil {
|
||||
t.Fatal("expected to find 'dev' layout")
|
||||
}
|
||||
if layout.Cmd != "npm run dev" {
|
||||
t.Errorf("expected Cmd to be 'npm run dev', got %q", layout.Cmd)
|
||||
}
|
||||
|
||||
layout = GetNamedLayout("simple")
|
||||
if layout == nil {
|
||||
t.Fatal("expected to find 'simple' layout")
|
||||
}
|
||||
if !layout.Clock {
|
||||
t.Error("expected Clock to be true")
|
||||
}
|
||||
|
||||
// Test when named layout doesn't exist
|
||||
if layout := GetNamedLayout("nonexistent"); layout != nil {
|
||||
t.Error("expected nil for nonexistent layout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDefaultLayout(t *testing.T) {
|
||||
// Save and restore original state
|
||||
originalLayout := ConfiguredDefaultLayout
|
||||
defer func() { ConfiguredDefaultLayout = originalLayout }()
|
||||
|
||||
// Test when no custom default layout is configured
|
||||
ConfiguredDefaultLayout = nil
|
||||
layout := GetDefaultLayout()
|
||||
if layout != &DefaultEmptyLayout {
|
||||
t.Error("expected DefaultEmptyLayout when no custom layout configured")
|
||||
}
|
||||
|
||||
// Test when custom default layout is configured
|
||||
customLayout := &TmuxPaneLayout{
|
||||
Cwd: ".",
|
||||
Clock: true,
|
||||
}
|
||||
ConfiguredDefaultLayout = customLayout
|
||||
layout = GetDefaultLayout()
|
||||
if layout != customLayout {
|
||||
t.Error("expected custom layout when configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalConfig_NamedLayouts(t *testing.T) {
|
||||
yamlData := `
|
||||
shell: /bin/zsh
|
||||
named_layouts:
|
||||
dev:
|
||||
cwd: .
|
||||
cmd: npm run dev
|
||||
split:
|
||||
direction: h
|
||||
child:
|
||||
cwd: .
|
||||
cmd: npm test
|
||||
simple:
|
||||
cwd: .
|
||||
clock: true
|
||||
`
|
||||
|
||||
var cfg GlobalConfig
|
||||
err := yaml.Unmarshal([]byte(yamlData), &cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Shell != "/bin/zsh" {
|
||||
t.Errorf("expected Shell to be '/bin/zsh', got %q", cfg.Shell)
|
||||
}
|
||||
|
||||
if cfg.NamedLayouts == nil {
|
||||
t.Fatal("expected NamedLayouts to not be nil")
|
||||
}
|
||||
|
||||
if len(cfg.NamedLayouts) != 2 {
|
||||
t.Errorf("expected 2 named layouts, got %d", len(cfg.NamedLayouts))
|
||||
}
|
||||
|
||||
dev := cfg.NamedLayouts["dev"]
|
||||
if dev == nil {
|
||||
t.Fatal("expected 'dev' layout to exist")
|
||||
}
|
||||
if dev.Cmd != "npm run dev" {
|
||||
t.Errorf("expected dev.Cmd to be 'npm run dev', got %q", dev.Cmd)
|
||||
}
|
||||
if dev.Split == nil {
|
||||
t.Fatal("expected dev.Split to not be nil")
|
||||
}
|
||||
if dev.Split.Direction != "h" {
|
||||
t.Errorf("expected dev.Split.Direction to be 'h', got %q", dev.Split.Direction)
|
||||
}
|
||||
|
||||
simple := cfg.NamedLayouts["simple"]
|
||||
if simple == nil {
|
||||
t.Fatal("expected 'simple' layout to exist")
|
||||
}
|
||||
if !simple.Clock {
|
||||
t.Error("expected simple.Clock to be true")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user