├── .gitignore ├── .travis.yml ├── Dockerfile ├── Makefile ├── README.md ├── config_loader.go ├── go.mod ├── go.sum ├── main.go ├── main_test.go ├── zsh-completion.json └── zsh_completions └── _ptmux /.gitignore: -------------------------------------------------------------------------------- 1 | /ptmux 2 | /vendor/ 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | sudo: false 3 | go: 4 | - 1.11 5 | env: 6 | global: 7 | - GO111MODULE=on 8 | matrix: 9 | - TMUX_VERSION=master 10 | - TMUX_VERSION=2.8 11 | - TMUX_VERSION=2.7 12 | - TMUX_VERSION=2.6 13 | - TMUX_VERSION=2.5 14 | 15 | addons: 16 | apt: 17 | packages: 18 | - libevent-dev 19 | 20 | before_script: 21 | - git clone https://github.com/tmux/tmux.git tmux 22 | - cd tmux 23 | - git checkout $TMUX_VERSION 24 | - sh autogen.sh 25 | - ./configure --prefix=$HOME/tmux && make && make install 26 | - export PATH=$HOME/tmux/bin:$PATH 27 | - cd .. 28 | - tmux -V 29 | 30 | script: 31 | - export PATH=$HOME/tmux/bin:$PATH 32 | - make test 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.11.1-stretch 2 | MAINTAINER Masataka Kuwabara 3 | 4 | RUN apt-get update && apt-get upgrade -y && \ 5 | apt-get install -y \ 6 | tmux \ 7 | git \ 8 | locales \ 9 | gcc && \ 10 | apt-get clean && \ 11 | rm -rf /var/lib/apt/lists/* 12 | 13 | RUN mkdir -p /go/src/github.com/pocke/ptmux 14 | RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 15 | 16 | ENV GO111MODULE=on \ 17 | LANG=en_US.utf8 18 | 19 | WORKDIR /go/src/github.com/pocke/ptmux 20 | CMD go test -v 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: dep 2 | go install 3 | 4 | test: 5 | go test -v 6 | 7 | test_with_docker: 8 | docker run -v $(PWD):/go/src/github.com/pocke/ptmux ptmux 9 | 10 | docker_build: 11 | docker build -t ptmux:latest . 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ptmux 2 | ============ 3 | 4 | [![Coverage Status](https://coveralls.io/repos/github/pocke/ptmux/badge.svg?branch=master)](https://coveralls.io/github/pocke/ptmux?branch=master) 5 | [![Build Status](https://travis-ci.org/pocke/ptmux.svg?branch=master)](https://travis-ci.org/pocke/ptmux) 6 | 7 | 8 | Requirements 9 | --- 10 | 11 | * Go 1.11 or later 12 | * `set -g base-index 1` in your `.tmux.conf` 13 | * tmux 2.5 or newer 14 | 15 | Installation 16 | ----------- 17 | 18 | ```sh 19 | export GO111MODULE=on 20 | go get github.com/pocke/ptmux 21 | ``` 22 | 23 | 24 | 25 | 26 | Usage 27 | ----------- 28 | 29 | ### Configure 30 | 31 | Edit `~/.config/ptmux/PROFILE_NAME.yaml` 32 | 33 | ```yaml 34 | # Example 35 | root: ~/path/to/your/project/dir 36 | env: 37 | DATABASE_URL: 'mysql2://username:topsecret@localhost/dbname' 38 | windows: 39 | - panes: 40 | - command: 'bin/rails s' 41 | - command: 'bundle exec sidekiq' 42 | - panes: 43 | - command: 'vim' 44 | - command: 'bundle exec guard' 45 | ``` 46 | 47 | 48 | ### Command line 49 | 50 | 51 | ```sh 52 | $ ptmux PROFILE_NAME 53 | ``` 54 | 55 | Links 56 | ------ 57 | 58 | - [tmuxinator の代替ツールを作っている話 - pockestrap](http://pocke.hatenablog.com/entry/2016/11/13/233258) 59 | 60 | Testing 61 | --- 62 | 63 | NOTE: The testing changes tmux options to test. it will set `base-index` to `1` and `renumber-windows` to `on`. It probably changes your tmux options. 64 | If you do not want side effect, run tests in a docker container. 65 | 66 | Run tests: 67 | 68 | ``` 69 | $ make test 70 | ``` 71 | 72 | Run tests in a docker container: 73 | 74 | ``` 75 | $ make docker_build 76 | $ make test_with_docker 77 | ``` 78 | 79 | 80 | License 81 | ------- 82 | 83 | These codes are licensed under CC0. 84 | 85 | [![CC0](http://i.creativecommons.org/p/zero/1.0/88x31.png "CC0")](http://creativecommons.org/publicdomain/zero/1.0/deed.en) 86 | -------------------------------------------------------------------------------- /config_loader.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | type ConfigLoader struct { 12 | // Table of extension to unmarshal func 13 | Unmarshals map[string]func([]byte, interface{}) error 14 | } 15 | 16 | func (c *ConfigLoader) Load(basePath string, obj interface{}) error { 17 | var fun func([]byte, interface{}) error 18 | var path string 19 | ok := false 20 | for ext, f := range c.Unmarshals { 21 | path = fmt.Sprintf("%s.%s", basePath, ext) 22 | if Exists(path) { 23 | fun = f 24 | ok = true 25 | break 26 | } 27 | } 28 | if !ok { 29 | return errors.Errorf("Cofnig file for %s does not exist", basePath) 30 | } 31 | 32 | b, err := ioutil.ReadFile(path) 33 | if err != nil { 34 | return errors.Wrap(err, "ReadFile is failed") 35 | } 36 | 37 | return fun(b, obj) 38 | } 39 | 40 | func Exists(filename string) bool { 41 | _, err := os.Stat(filename) 42 | return err == nil 43 | } 44 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pocke/ptmux 2 | 3 | require ( 4 | github.com/mitchellh/go-homedir v1.0.0 5 | github.com/pkg/errors v0.8.0 6 | github.com/shibukawa/shell v0.0.0-20150325034252-4afc7a145a49 7 | github.com/spf13/pflag v1.0.3 8 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 9 | gopkg.in/yaml.v2 v2.2.1 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/mitchellh/go-homedir v1.0.0 h1:vKb8ShqSby24Yrqr/yDYkuFz8d0WUjys40rvnGC8aR0= 2 | github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 3 | github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= 4 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 5 | github.com/shibukawa/shell v0.0.0-20150325034252-4afc7a145a49 h1:wQnwdR0BMqKmYiBcz4qUqzzBSq4NmriO5XerSbyHGy8= 6 | github.com/shibukawa/shell v0.0.0-20150325034252-4afc7a145a49/go.mod h1:CQ1p8Q2FB+S8n7dRk1JsLftCX5Fl8IqQEZJJUjj7TGw= 7 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 8 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 10 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= 12 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 13 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "syscall" 9 | 10 | yaml "gopkg.in/yaml.v2" 11 | 12 | homedir "github.com/mitchellh/go-homedir" 13 | "github.com/pkg/errors" 14 | "github.com/shibukawa/shell" 15 | "github.com/spf13/pflag" 16 | ) 17 | 18 | func main() { 19 | if err := Main(os.Args); err != nil { 20 | fmt.Fprintln(os.Stderr, err) 21 | os.Exit(1) 22 | } 23 | } 24 | 25 | func Main(args []string) error { 26 | f := new(Flag) 27 | fs := pflag.NewFlagSet("ptmux", pflag.ContinueOnError) 28 | fs.BoolVarP(&f.PrintCommands, "print-commands", "p", false, "print shell commands (for debug)") 29 | fs.BoolVarP(&f.Debug, "debug", "d", false, "print debug log") 30 | err := fs.Parse(args[1:]) 31 | if err != nil { 32 | if err == pflag.ErrHelp { 33 | return nil 34 | } 35 | return err 36 | } 37 | 38 | if len(fs.Args()) != 1 { 39 | return errors.New("Usage: ptmux ") 40 | } 41 | name := fs.Arg(0) 42 | conf, err := LoadConf(name) 43 | if err != nil { 44 | return err 45 | } 46 | sh := conf.ToShell() 47 | 48 | if f.PrintCommands { 49 | fmt.Println(sh) 50 | return nil 51 | } 52 | 53 | return Exec(sh, f.Debug) 54 | } 55 | 56 | func LoadConf(name string) (*Config, error) { 57 | confPath, err := homedir.Expand(fmt.Sprintf("~/.config/ptmux/%s", name)) 58 | if err != nil { 59 | return nil, errors.Wrap(err, "can't expand homedir") 60 | } 61 | 62 | configLoader := &ConfigLoader{ 63 | Unmarshals: map[string]func([]byte, interface{}) error{ 64 | "yaml": yaml.Unmarshal, 65 | "yml": yaml.Unmarshal, 66 | "json": json.Unmarshal, 67 | }, 68 | } 69 | 70 | c := new(Config) 71 | err = configLoader.Load(confPath, c) 72 | if err != nil { 73 | return nil, err 74 | } 75 | if c.InheritFrom == "" { 76 | return c, nil 77 | } 78 | 79 | base, err := LoadConf(c.InheritFrom) 80 | if err != nil { 81 | return nil, err 82 | } 83 | return base.Merge(c), nil 84 | } 85 | 86 | func Exec(shell string, debug bool) error { 87 | bin, err := exec.LookPath("sh") 88 | if err != nil { 89 | return errors.Wrap(err, "cant look up `sh`") 90 | } 91 | var opt string 92 | if debug { 93 | opt = "-xe" 94 | } else { 95 | opt = "-e" 96 | } 97 | args := []string{"bash", opt, "-c", shell} 98 | env := os.Environ() 99 | return syscall.Exec(bin, args, env) 100 | } 101 | 102 | type Config struct { 103 | Root string 104 | Env map[string]string 105 | Name string 106 | Windows []Window 107 | Attach *bool 108 | InheritFrom string `json:"inherit_from" yaml:"inherit_from"` 109 | } 110 | 111 | func (c *Config) Merge(right *Config) *Config { 112 | merged := new(Config) 113 | *merged = *c 114 | merged.InheritFrom = "" 115 | 116 | if right.Root != "" { 117 | merged.Root = right.Root 118 | } 119 | if right.Name != "" { 120 | merged.Name = right.Name 121 | } 122 | if right.Attach != nil { 123 | merged.Attach = right.Attach 124 | } 125 | 126 | wins := make([]Window, 0, len(c.Windows)+len(right.Windows)) 127 | wins = append(wins, c.Windows...) 128 | wins = append(wins, right.Windows...) 129 | merged.Windows = wins 130 | 131 | if merged.Env == nil { 132 | merged.Env = right.Env 133 | } else { 134 | for k, v := range right.Env { 135 | merged.Env[k] = v 136 | } 137 | } 138 | 139 | return merged 140 | } 141 | 142 | func (c *Config) ToShell() string { 143 | res := "" 144 | if c.Root != "" { 145 | res += fmt.Sprintf("cd %s\n", c.Root) 146 | } 147 | sessionName := "" 148 | if c.Name != "" { 149 | sessionName = fmt.Sprintf("-s %s", c.Name) 150 | } 151 | 152 | res += fmt.Sprintf("SESSION_NO=`tmux new-session -dP %s`\n\n", sessionName) 153 | 154 | for k, v := range c.Env { 155 | k = shell.Escape(k) 156 | v = shell.Escape(v) 157 | res += fmt.Sprintf("tmux set-environment -t $SESSION_NO %s %s\n", k, v) 158 | } 159 | 160 | for _, w := range c.Windows { 161 | res += w.ToShell() 162 | } 163 | 164 | res += "tmux kill-window -t ${SESSION_NO}1\n" 165 | 166 | if c.Attach == nil || *c.Attach { 167 | res += "tmux attach-session -t $SESSION_NO\n" 168 | } else { 169 | res += "echo $SESSION_NO\n" 170 | } 171 | return res 172 | } 173 | 174 | type Window struct { 175 | Panes []Pane 176 | } 177 | 178 | func (w *Window) ToShell() string { 179 | res := "WINDOW_NO=`tmux new-window -t $SESSION_NO -a -P`\n" 180 | 181 | for idx, p := range w.Panes { 182 | res += p.ToShell(idx == 0) 183 | } 184 | 185 | res += "\n" 186 | 187 | return res 188 | } 189 | 190 | type Pane struct { 191 | Command string 192 | } 193 | 194 | func (p *Pane) ToShell(isFirst bool) string { 195 | res := "" 196 | if isFirst { 197 | res += "PANE_NO=$WINDOW_NO\n" 198 | } else { 199 | res += "PANE_NO=`tmux split-window -t $WINDOW_NO -P`\n" 200 | } 201 | cmd := shell.Escape(p.Command) 202 | res += fmt.Sprintf("tmux send-keys -t $PANE_NO %s C-m\n", cmd) 203 | 204 | return res 205 | } 206 | 207 | type Flag struct { 208 | PrintCommands bool 209 | Debug bool 210 | } 211 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "os" 7 | "os/exec" 8 | "reflect" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | homedir "github.com/mitchellh/go-homedir" 14 | "github.com/pkg/errors" 15 | ) 16 | 17 | func TestMain(m *testing.M) { 18 | sessionID, err := exec.Command("tmux", "new-session", "-dP").Output() 19 | if err != nil { 20 | panic(err) 21 | } 22 | defer func() { 23 | exec.Command("tmux", "kill-session", string(sessionID[:len(sessionID)-1])).Run() 24 | }() 25 | 26 | err = exec.Command("tmux", "set", "-g", "base-index", "1").Run() 27 | if err != nil { 28 | panic(err) 29 | } 30 | err = exec.Command("tmux", "set-option", "-g", "renumber-windows", "on").Run() 31 | if err != nil { 32 | panic(err) 33 | } 34 | 35 | ret := m.Run() 36 | os.Exit(ret) 37 | } 38 | 39 | func TestExecute_WithSingleWindow(t *testing.T) { 40 | c := &Config{ 41 | Windows: []Window{ 42 | { 43 | Panes: []Pane{ 44 | {Command: "watch ls"}, 45 | }, 46 | }, 47 | }, 48 | Attach: boolPtr(false), 49 | } 50 | 51 | sessionID, err := Execute(t, c) 52 | if err != nil { 53 | t.Error(err) 54 | } 55 | defer CleanSession(sessionID) 56 | 57 | AssertWindowCount(t, sessionID, 1) 58 | RetryTest(t, 1*time.Second, 10, func() error { 59 | return AssertRunningCommand(t, sessionID, "1", []string{"watch"}) 60 | }) 61 | } 62 | 63 | func TestExecute_WithManyPanes(t *testing.T) { 64 | c := &Config{ 65 | Windows: []Window{ 66 | { 67 | Panes: []Pane{ 68 | {Command: "watch ls"}, 69 | {Command: "cat"}, 70 | {Command: "yes"}, 71 | {Command: "sh"}, 72 | }, 73 | }, 74 | }, 75 | Attach: boolPtr(false), 76 | } 77 | 78 | sessionID, err := Execute(t, c) 79 | if err != nil { 80 | t.Error(err) 81 | } 82 | defer CleanSession(sessionID) 83 | 84 | AssertWindowCount(t, sessionID, 1) 85 | RetryTest(t, 1*time.Second, 10, func() error { 86 | return AssertRunningCommand(t, sessionID, "1", []string{"watch", "sh", "yes", "cat"}) 87 | }) 88 | } 89 | 90 | func TestExecute_WithManyWindows(t *testing.T) { 91 | c := &Config{ 92 | Windows: []Window{ 93 | { 94 | Panes: []Pane{ 95 | {Command: "cat"}, 96 | }, 97 | }, 98 | { 99 | Panes: []Pane{ 100 | {Command: "yes"}, 101 | }, 102 | }, 103 | { 104 | Panes: []Pane{ 105 | {Command: "watch ls"}, 106 | }, 107 | }, 108 | { 109 | Panes: []Pane{ 110 | {Command: "sh"}, 111 | }, 112 | }, 113 | }, 114 | Attach: boolPtr(false), 115 | } 116 | 117 | sessionID, err := Execute(t, c) 118 | if err != nil { 119 | t.Error(err) 120 | } 121 | defer CleanSession(sessionID) 122 | 123 | AssertWindowCount(t, sessionID, 4) 124 | 125 | RetryTest(t, 1*time.Second, 10, func() error { 126 | return AssertRunningCommand(t, sessionID, "1", []string{"cat"}) 127 | }) 128 | RetryTest(t, 1*time.Second, 10, func() error { 129 | return AssertRunningCommand(t, sessionID, "2", []string{"yes"}) 130 | }) 131 | RetryTest(t, 1*time.Second, 10, func() error { 132 | return AssertRunningCommand(t, sessionID, "3", []string{"watch"}) 133 | }) 134 | RetryTest(t, 1*time.Second, 10, func() error { 135 | return AssertRunningCommand(t, sessionID, "4", []string{"sh"}) 136 | }) 137 | } 138 | 139 | func TestExecute_WithSessionName(t *testing.T) { 140 | c := &Config{ 141 | Windows: []Window{ 142 | {Panes: []Pane{{Command: "yes"}}}, 143 | }, 144 | Name: "testtest", 145 | Attach: boolPtr(false), 146 | } 147 | 148 | sessionID, err := Execute(t, c) 149 | if err != nil { 150 | t.Error(err) 151 | } 152 | defer CleanSession(sessionID) 153 | 154 | AssertWindowCount(t, sessionID, 1) 155 | if sessionID != "testtest:" { 156 | t.Errorf("session id should be testtest:, but got %s", sessionID) 157 | } 158 | } 159 | 160 | func TestExecute_WithSessionRoot(t *testing.T) { 161 | c := &Config{ 162 | Windows: []Window{ 163 | {Panes: []Pane{{Command: "./sh"}}}, 164 | }, 165 | Root: "/bin/", 166 | Attach: boolPtr(false), 167 | } 168 | 169 | sessionID, err := Execute(t, c) 170 | if err != nil { 171 | t.Error(err) 172 | } 173 | defer CleanSession(sessionID) 174 | 175 | AssertWindowCount(t, sessionID, 1) 176 | RetryTest(t, 1*time.Second, 10, func() error { 177 | return AssertRunningCommand(t, sessionID, "1", []string{"./sh"}) 178 | }) 179 | } 180 | 181 | func TestExecute_WithEnv(t *testing.T) { 182 | c := &Config{ 183 | Env: map[string]string{ 184 | "v1": "foo", 185 | "v2": "bar", 186 | "v3": "baz", 187 | "v4": "foobar", 188 | "v5": "aaa", 189 | }, 190 | Windows: []Window{ 191 | {Panes: []Pane{ 192 | {Command: "test $v1 = foo && watch ls"}, 193 | {Command: "test $v2 = bar && cat"}, 194 | }}, 195 | {Panes: []Pane{ 196 | {Command: "test $v3 = baz && yes"}, 197 | {Command: "test $v4 = foobar && watch ls"}, 198 | {Command: "test $v5 = aaa && cat"}, 199 | }}, 200 | }, 201 | Attach: boolPtr(false), 202 | } 203 | 204 | sessionID, err := Execute(t, c) 205 | if err != nil { 206 | t.Error(err) 207 | } 208 | defer CleanSession(sessionID) 209 | 210 | AssertWindowCount(t, sessionID, 2) 211 | RetryTest(t, 1*time.Second, 10, func() error { 212 | return AssertRunningCommand(t, sessionID, "1", []string{"watch", "cat"}) 213 | }) 214 | RetryTest(t, 1*time.Second, 10, func() error { 215 | return AssertRunningCommand(t, sessionID, "2", []string{"yes", "cat", "watch"}) 216 | }) 217 | } 218 | 219 | func TestLoadConf(t *testing.T) { 220 | contentYAML := `root: ~/hogehoge 221 | name: poyoyo 222 | windows: 223 | - panes: 224 | - command: 'bin/rails s' 225 | - command: 'bundle exec sidekiq' 226 | - command: 'bin/rails c' 227 | - panes: 228 | - command: 'gvim' 229 | - command: 'bundle exec guard' 230 | ` 231 | contentJSON := `{ 232 | "root": "~/hogehoge", 233 | "name": "poyoyo", 234 | "windows": [ 235 | { 236 | "panes": [ 237 | { 238 | "command": "bin/rails s" 239 | }, 240 | { 241 | "command": "bundle exec sidekiq" 242 | }, 243 | { 244 | "command": "bin/rails c" 245 | } 246 | ] 247 | }, 248 | { 249 | "panes": [ 250 | { 251 | "command": "gvim" 252 | }, 253 | { 254 | "command": "bundle exec guard" 255 | } 256 | ] 257 | } 258 | ] 259 | }` 260 | 261 | expected := &Config{ 262 | Root: "~/hogehoge", 263 | Name: "poyoyo", 264 | Windows: []Window{ 265 | { 266 | Panes: []Pane{ 267 | {Command: "bin/rails s"}, 268 | {Command: "bundle exec sidekiq"}, 269 | {Command: "bin/rails c"}, 270 | }, 271 | }, 272 | { 273 | Panes: []Pane{ 274 | {Command: "gvim"}, 275 | {Command: "bundle exec guard"}, 276 | }, 277 | }, 278 | }, 279 | } 280 | 281 | testCases := []struct { 282 | path string 283 | content string 284 | }{ 285 | { 286 | path: "~/.config/ptmux/testtest.yml", 287 | content: contentYAML, 288 | }, 289 | { 290 | path: "~/.config/ptmux/testtest.yaml", 291 | content: contentYAML, 292 | }, 293 | { 294 | path: "~/.config/ptmux/testtest.json", 295 | content: contentJSON, 296 | }, 297 | } 298 | 299 | for _, c := range testCases { 300 | func() { 301 | t.Logf("For %s", c.path) 302 | path, err := homedir.Expand(c.path) 303 | if err != nil { 304 | t.Error(err) 305 | } 306 | 307 | err = ioutil.WriteFile(path, []byte(c.content), 0644) 308 | if err != nil { 309 | t.Error(err) 310 | } 311 | defer os.Remove(path) 312 | 313 | conf, err := LoadConf("testtest") 314 | if err != nil { 315 | t.Error(err) 316 | } 317 | 318 | if !reflect.DeepEqual(conf, expected) { 319 | t.Errorf("Expected %+v, but got %+v", expected, conf) 320 | } 321 | }() 322 | } 323 | } 324 | 325 | func TestConfigToShell_WhenAttachIsNil(t *testing.T) { 326 | c := &Config{} 327 | 328 | sh := c.ToShell() 329 | if !strings.Contains(sh, "tmux attach-session -t $SESSION_NO") { 330 | t.Errorf("Can't find attach-session. code: %s", sh) 331 | } 332 | } 333 | 334 | func TestConfigToShell_WhenAttachIsTrue(t *testing.T) { 335 | c := &Config{ 336 | Attach: boolPtr(true), 337 | } 338 | 339 | sh := c.ToShell() 340 | if !strings.Contains(sh, "tmux attach-session -t $SESSION_NO") { 341 | t.Errorf("Can't find attach-session. code: %s", sh) 342 | } 343 | } 344 | 345 | func TestConfigToShell_WhenAttachIsFalse(t *testing.T) { 346 | c := &Config{ 347 | Attach: boolPtr(false), 348 | } 349 | 350 | sh := c.ToShell() 351 | if strings.Contains(sh, "tmux attach-session -t $SESSION_NO") { 352 | t.Errorf("attach-session is found. code:\n%s", sh) 353 | } 354 | if !strings.Contains(sh, "echo $SESSION_NO") { 355 | t.Errorf("Can't find printing SESSION_NO. code:\n%s", sh) 356 | } 357 | } 358 | 359 | // ---------------------------------------------- test helper 360 | func boolPtr(b bool) *bool { 361 | return &b 362 | } 363 | 364 | func Execute(t *testing.T, c *Config) (string, error) { 365 | sh := c.ToShell() 366 | s, err := execCommand(t, "bash", "-xe", "-c", sh) 367 | if err != nil { 368 | return "", err 369 | } 370 | return strings.TrimSpace(s), nil 371 | } 372 | 373 | func CleanSession(sessionID string) error { 374 | return exec.Command("tmux", "kill-session", "-t", sessionID).Run() 375 | } 376 | 377 | func execCommand(t *testing.T, c string, args ...string) (string, error) { 378 | stderr := bytes.NewBuffer([]byte{}) 379 | cmd := exec.Command(c, args...) 380 | cmd.Stderr = stderr 381 | t.Log(strings.Join(cmd.Args, " ")) 382 | b, err := cmd.Output() 383 | if err != nil { 384 | return "", errors.Wrap(err, stderr.String()) 385 | } 386 | return string(b), nil 387 | } 388 | 389 | func AssertWindowCount(t *testing.T, sessionID string, expected int) { 390 | s, err := execCommand(t, "tmux", "list-window", "-t", sessionID) 391 | if err != nil { 392 | t.Fatal(err) 393 | } 394 | if cnt := strings.Count(strings.TrimSpace(s), "\n") + 1; cnt != expected { 395 | t.Errorf("Window count should be %d, but got %d", expected, cnt) 396 | } 397 | } 398 | 399 | func RetryTest(t *testing.T, d time.Duration, n int, f func() error) { 400 | for i := 0; i < n; i++ { 401 | err := f() 402 | if err == nil { 403 | return 404 | } 405 | 406 | if i+1 == n { 407 | t.Error(err) 408 | return 409 | } 410 | t.Logf("Retrying... %d", i+1) 411 | time.Sleep(d) 412 | } 413 | } 414 | 415 | func AssertRunningCommand(t *testing.T, sessionID, windowID string, expected []string) error { 416 | s, err := execCommand(t, "tmux", "list-panes", "-t", sessionID+windowID, "-F", "#{pane_current_command}") 417 | if err != nil { 418 | t.Fatal(err) 419 | } 420 | cmds := strings.Split(strings.TrimSpace(s), "\n") 421 | if !reflect.DeepEqual(cmds, expected) { 422 | return errors.Errorf("Should execute %v, but got %v", expected, cmds) 423 | } 424 | return nil 425 | } 426 | 427 | func PrepareConfigDir() error { 428 | path, err := homedir.Expand("~/.config/ptmux") 429 | if err != nil { 430 | return err 431 | } 432 | 433 | return os.MkdirAll(path, 0755) 434 | } 435 | 436 | func init() { 437 | err := PrepareConfigDir() 438 | if err != nil { 439 | panic(err) 440 | } 441 | } 442 | -------------------------------------------------------------------------------- /zsh-completion.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "ptmux", 3 | "properties": { 4 | "author": "Masataka Kuwabara", 5 | "license": "CC0", 6 | "help": { 7 | "option": [ 8 | "--help" 9 | ], 10 | "description": "Print a brief help message." 11 | } 12 | }, 13 | "options": { 14 | "switch": [ 15 | { 16 | "option": [ 17 | "-p", 18 | "--print-commands" 19 | ], 20 | "description": "print shell commands (for debug)" 21 | } 22 | ], 23 | "flag": [] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /zsh_completions/_ptmux: -------------------------------------------------------------------------------- 1 | #compdef ptmux 2 | 3 | # Copyright (c) 2016 Masataka Kuwabara 4 | # License: CC0 5 | 6 | function _ptmux() { 7 | local context curcontext=$curcontext state line 8 | typeset -A opt_args 9 | local ret=1 10 | 11 | _arguments -C \ 12 | '(--help)--help[Print a brief help message.]' \ 13 | '(-p --print-commands)'{-p,--print-commands}'[print shell commands (for debug)]' \ 14 | && ret=0 15 | 16 | local -a names 17 | names=( $(ls ~/.config/ptmux/ | sed -E 's/^(.+)\.[^.]+$/\1/') ) 18 | compadd $names 19 | 20 | return ret 21 | } 22 | 23 | _ptmux "$@" 24 | --------------------------------------------------------------------------------