From ffd722b6c90dfa902f9e46419e523edc51737336 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Fri, 30 Jan 2026 01:57:47 +0200 Subject: [PATCH] feat: add named layouts --- README.md | 31 ++++++ internal/cli/root.go | 4 + internal/config/loader.go | 8 ++ internal/config/loader_test.go | 190 +++++++++++++++++++++++++++++++++ internal/config/parser.go | 12 ++- internal/config/parser_test.go | 120 +++++++++++++++++++++ internal/config/types.go | 18 +++- internal/config/types_test.go | 127 ++++++++++++++++++++++ 8 files changed, 504 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ca9ae18..f7807b6 100644 --- a/README.md +++ b/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: diff --git a/internal/cli/root.go b/internal/cli/root.go index c3ee1cf..dfb5fd8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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 } diff --git a/internal/config/loader.go b/internal/config/loader.go index e1bf3d3..ff4acd1 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -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 } diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 8b09d54..a0f1c8f 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -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() diff --git a/internal/config/parser.go b/internal/config/parser.go index 40f2d5b..f08a1e6 100644 --- a/internal/config/parser.go +++ b/internal/config/parser.go @@ -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 { diff --git a/internal/config/parser_test.go b/internal/config/parser_test.go index e86614f..455aec8 100644 --- a/internal/config/parser_test.go +++ b/internal/config/parser_test.go @@ -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) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index a4b0d6d..9beec20 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -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: ".", diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 0ce37c8..a987575 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -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") + } +}