feat: add named layouts

This commit is contained in:
2026-01-30 01:57:47 +02:00
parent a470a93a7d
commit ffd722b6c9
8 changed files with 504 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: ".",

View File

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