├── .github └── workflows │ ├── golangci-lint.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── README.md ├── TODO ├── UNLICENSE ├── cmd ├── attach.go ├── cat.go ├── cmd.go ├── edit.go ├── embed │ ├── help │ └── init.kak ├── env.go ├── external.go ├── get.go ├── init.go ├── kill.go ├── list.go ├── new.go ├── root.go └── send.go ├── go.mod ├── go.sum ├── kak ├── connect.go ├── filepath.go ├── filepath_test.go ├── get.go ├── kak.go ├── kak_test.go ├── run.go ├── send.go ├── start.go └── tmp.go ├── main.go └── scripts ├── kks-buffers ├── kks-fifo ├── kks-files ├── kks-filetypes ├── kks-git-files ├── kks-grep ├── kks-lf ├── kks-lines ├── kks-md-heading ├── kks-mru └── kks-select /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | branches: 8 | - master 9 | - main 10 | pull_request: 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | golangci: 17 | name: lint 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: golangci-lint 22 | uses: golangci/golangci-lint-action@v2 23 | with: 24 | version: latest 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | goreleaser: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 18 | - name: Set up Go 19 | uses: actions/setup-go@v2 20 | with: 21 | go-version: 1.17 22 | - name: Run GoReleaser 23 | uses: goreleaser/goreleaser-action@v2 24 | with: 25 | # either 'goreleaser' (default) or 'goreleaser-pro' 26 | distribution: goreleaser 27 | version: latest 28 | args: release --rm-dist 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | kks 2 | 3 | dist/ 4 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - linux 9 | - darwin 10 | archives: 11 | - format: binary 12 | checksum: 13 | disable: true 14 | snapshot: 15 | name_template: "{{ incpatch .Version }}-next" 16 | changelog: 17 | filters: 18 | exclude: 19 | - "^doc" 20 | - "^test" 21 | - "^todo" 22 | - "^minor" 23 | - "^WIP" 24 | - "typo" 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kks 2 | 3 | Handy Kakoune companion. 4 | 5 | ## Installation 6 | 7 | ### From release binaries 8 | 9 | Download the compiled binary for your system from 10 | [Releases](https://github.com/kkga/kks/releases) page and put it somewhere in 11 | your `$PATH`. 12 | 13 | ### From source 14 | 15 | Requires [Go](https://golang.org/) installed on your system. 16 | 17 | Clone the repository and run `go build`, then copy the compiled binary somewhere 18 | in your `$PATH`. 19 | 20 | If Go is [configured](https://golang.org/ref/mod#go-install) to install packages 21 | in `$PATH`, it's also possible to install without cloning the repository: run 22 | `go install github.com/kkga/kks@latest`. 23 | 24 | ### AUR 25 | 26 | `kks` is packaged in the Arch User Repository: 27 | https://aur.archlinux.org/packages/kks/ 28 | 29 | ## Kakoune and shell integration 30 | 31 | ### Kakoune configuration 32 | 33 | Source `kks init` to add `kks-connect` command to Kakoune... 34 | 35 | ```kak 36 | eval %sh{ kks init } 37 | ``` 38 | 39 | ... and use your terminal integration to connect 40 | [provided scripts](#provided-scripts), for example: 41 | `kks-connect terminal kks-files`. 42 | 43 | ### Kakoune mappings example 44 | 45 | ```kak 46 | map global normal -docstring 'terminal' ': kks-connect terminal' 47 | map global normal -docstring 'files' ': kks-connect terminal-popup kks-files' 48 | map global normal -docstring 'buffers' ': kks-connect terminal-popup kks-buffers' 49 | map global normal -docstring 'live grep' ': kks-connect terminal-popup kks-grep' 50 | map global normal -docstring 'lines in buffer' ': kks-connect terminal-popup kks-lines' 51 | map global normal -docstring 'recent files' ': kks-connect terminal-popup kks-mru' 52 | map global normal -docstring 'vcs client' ': kks-connect terminal-popup lazygit' 53 | map global normal -docstring 'file browser' ': kks-connect terminal-panel kks-lf' 54 | ``` 55 | 56 | Or, if you prefer having a dedicated user mode: 57 | 58 | ```kak 59 | declare-user-mode pick 60 | map global normal -docstring 'pick mode' ': enter-user-mode pick' 61 | map global pick f -docstring 'files' ': kks-connect terminal-popup kks-files' 62 | map global pick F -docstring 'files (all)' ': kks-connect terminal-popup kks-files -HI' 63 | map global pick g -docstring 'git files' ': kks-connect terminal-popup kks-git-files' 64 | map global pick b -docstring 'buffers' ': kks-connect terminal-popup kks-buffers' 65 | map global pick / -docstring 'live grep' ': kks-connect terminal-popup kks-grep' 66 | map global pick l -docstring 'lines in buffer' ': kks-connect terminal-popup kks-lines' 67 | map global pick r -docstring 'recent files' ': kks-connect terminal-popup kks-mru' 68 | map global pick -docstring 'filetypes' ': kks-connect terminal-popup kks-filetypes' 69 | ``` 70 | 71 | For more terminal integrations and for the (quite handy) `popup` command, see: 72 | 73 | - [alacritty.kak](https://github.com/alexherbo2/alacritty.kak) 74 | - [foot.kak](https://github.com/kkga/foot.kak) 75 | 76 | ### Shell configuration 77 | 78 | You may want to set the `EDITOR` variable to `kks edit` so that connected 79 | programs work as intended: 80 | 81 | ```sh 82 | export EDITOR='kks edit' 83 | ``` 84 | 85 | Possibly useful aliases: 86 | 87 | ```sh 88 | alias k='kks edit' 89 | alias ks='eval $(kks-select)' 90 | alias ka='kks attach' 91 | alias kkd='kks kill; unset KKS_SESSION KKS_CLIENT' # kill+detach 92 | alias kcd='cd $(kks get %sh{pwd})' 93 | ``` 94 | 95 | ## Commands 96 | 97 | This is the output of `kks -h`. Certain commands take additional flags, see 98 | `kks -h` to learn more. 99 | 100 | ``` 101 | USAGE 102 | kks [-s ] [-c ] [] 103 | 104 | COMMANDS 105 | new, n create new session 106 | edit, e edit file 107 | send, s send command 108 | attach, a attach to session 109 | kill kill session 110 | ls list sessions and clients 111 | get get %val{..}, %opt{..} and friends 112 | cat print buffer content 113 | env print env 114 | init print Kakoune definitions 115 | 116 | ENVIRONMENT VARIABLES 117 | KKS_SESSION 118 | Kakoune session 119 | KKS_CLIENT 120 | Kakoune client 121 | KKS_DEFAULT_SESSION 122 | Session to try when KKS_SESSION is empty 123 | KKS_USE_GITDIR_SESSIONS 124 | If set, use git root dir name for creating/connecting to session 125 | 126 | Use "kks -h" for command usage. 127 | ``` 128 | 129 | ### Unknown command 130 | 131 | When unknown command is run, `kks` will try to find an executable named 132 | `kks-` in `$PATH`. If the executable is found, `kks` will run it with 133 | all arguments that were provided to the unknown command. 134 | 135 | ## Configuration 136 | 137 | `kks` can be configured through environment variables. 138 | 139 | ### Automatic sessions based on git directory 140 | 141 | ``` 142 | export KKS_USE_GITDIR_SESSIONS=1 143 | ``` 144 | 145 | When `KKS_USE_GITDIR_SESSIONS` is set to any value and `KKS_SESSION` is empty, 146 | running `kks edit` will do the following: 147 | 148 | - if file is inside a git directory, `kks` will search for an existing session 149 | based on top-level git directory name and connect to it; 150 | - if a session for the directory doesn't exist, `kks` will start a new session 151 | and connect to it. 152 | 153 | ### Default session 154 | 155 | ``` 156 | export KKS_DEFAULT_SESSION='mysession' 157 | ``` 158 | 159 | When context is not set (`KKS_SESSION` is empty), running `kks edit` will check 160 | for a session defined by `KKS_DEFAULT_SESSION` variable. If the session is 161 | running, `kks` will connect to it instead of starting a new session. 162 | 163 | `kks` will not start the default session if it's not running. You can use the 164 | autostarting mechanism of your desktop to start it with `kak -d -s mysession`. 165 | 166 | ## Provided scripts 167 | 168 | | script | function | 169 | | -------------------------------------------- | ------------------------------------------------------- | 170 | | [`kks-buffers`](./scripts/kks-buffers) | pick buffers | 171 | | [`kks-fifo`](./scripts/kks-fifo) | pipe stdin to Kakoune fifo buffer | 172 | | [`kks-files`](./scripts/kks-files) | pick files | 173 | | [`kks-filetypes`](./scripts/kks-filetypes) | pick and set filetype in current buffer | 174 | | [`kks-git-files`](./scripts/kks-git-files) | pick files from `git ls-files` | 175 | | [`kks-grep`](./scripts/kks-grep) | search for pattern in working directory | 176 | | [`kks-lf`](./scripts/kks-lf) | open [lf] with current buffer file selected | 177 | | [`kks-lines`](./scripts/kks-lines) | jump to line in buffer | 178 | | [`kks-md-heading`](./scripts/kks-md-heading) | jump to markdown heading | 179 | | [`kks-mru`](./scripts/kks-mru) | pick recently opened file | 180 | | [`kks-select`](./scripts/kks-select) | select Kakoune session and client to set up environment | 181 | 182 | [lf]: https://github.com/gokcehan/lf 183 | 184 | ## Similar projects 185 | 186 | - [kakoune.cr](https://github.com/alexherbo2/kakoune.cr) 187 | - [kakoune-remote-control](https://github.com/danr/kakoune-remote-control) 188 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | - [x] add command to reset environment to kks-select script 2 | - [x] turns out there's no way in fzf to output some arbitrary line of text? 3 | - [ ] add note about using 'kks edit' as EDITOR 4 | - make a wrapper shell script "kks-edit" that exec 'kks edit $@' 5 | - [x] !!! use file path for git parsing instead of cwd 6 | 7 | - [x] add version flag 8 | - [x] don't pass `0` line/col to kak 9 | 10 | - [x] add key bindings to delete buffers in `kks-buffers` 11 | - [ ] add key bindings to create files in `kks-files` 12 | 13 | - [x] add extra fzf bindings in kks select 14 | - [x] creating/deleting 15 | 16 | - [x] add initial documentation 17 | - [ ] refactor Edit into smaller parts 18 | - [x] if no client set, use connect 19 | - [x] in edit without context, create new session and attach 20 | - [x] move session creation into separate command that handles `setsid` 21 | - [x] resolve relative path before sending to kak 22 | - [ ] ? make default cmd run edit, like kak (any sideeffects?) 23 | 24 | - [ ] need separate kak connect command for gui programs (wofi/rofi/etc) 25 | 26 | - [x] add configuration env var for automatic git-based sessions 27 | - [x] add configuration env var for default session name 28 | - [x] `KKS_DEFAULT_SESSION=default` 29 | 30 | - [x] refactor context init 31 | - [x] construct context from env and args before running command 32 | - [x] if cmd requires context, check for context in root before run 33 | - [x] kill cmd 34 | - [x] edit cmd needs line:col support 35 | - [x] grep cmd 36 | - [x] find solution to remove the timeout in Get 37 | - [x] learned how channels work while doing this, implemented fsnotify 38 | - [x] cat cmd, needs -b flag for specific buffer 39 | - [x] refactor cat cmd to use readTmp from Get 40 | - [x] need to be able to create new sessions 41 | - [x] ??? buflist should return relative to cwd 42 | - [x] kks-files from non-workdir doesn't work (need to resolve path before cmd.Edit()?) 43 | - [x] add kks send commands in kks-select script to indicate highlighted client 44 | - [x] add '-a' flag to kill cmd for killing all sessions 45 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /cmd/attach.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "flag" 5 | 6 | "github.com/kkga/kks/kak" 7 | ) 8 | 9 | func NewAttachCmd() *AttachCmd { 10 | c := &AttachCmd{Cmd: Cmd{ 11 | fs: flag.NewFlagSet("attach", flag.ExitOnError), 12 | aliases: []string{"a"}, 13 | description: "Attach to Kakoune session with a new client.", 14 | usageLine: "[options] [file] [+[:[:]]", 17 | }} 18 | c.fs.StringVar(&c.session, "s", "", "session") 19 | c.fs.StringVar(&c.client, "c", "", "client") 20 | return c 21 | } 22 | 23 | type EditCmd struct { 24 | Cmd 25 | } 26 | 27 | func (c *EditCmd) Run() error { 28 | fp := kak.NewFilepath(c.fs.Args()) 29 | 30 | if c.kctx.Session.Name == "" { 31 | if err := findOrRunSession(c, fp); err != nil { 32 | return err 33 | } 34 | } else { 35 | if err := connectOrEditInClient(c, fp); err != nil { 36 | return err 37 | } 38 | } 39 | return nil 40 | } 41 | 42 | func findOrRunSession(c *EditCmd, fp *kak.Filepath) error { 43 | kctx := &kak.Context{} 44 | 45 | if c.useGitDirSessions { 46 | kctx.Session = kak.Session{Name: fp.ParseGitDir()} 47 | 48 | if kctx.Session.Name != "" { 49 | if exists, _ := kctx.Session.Exists(); !exists { 50 | sessionName, err := kak.Start(kctx.Session.Name) 51 | if err != nil { 52 | return err 53 | } 54 | fmt.Println("new session for git directory started:", sessionName) 55 | } 56 | } 57 | } 58 | 59 | if kctx.Session.Name == "" { 60 | kctx.Session = kak.Session{Name: c.defaultSession} 61 | } 62 | 63 | sessionExists, err := kctx.Session.Exists() 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if sessionExists { 69 | if err := kak.Connect(kctx, fp); err != nil { 70 | return err 71 | } 72 | } else { 73 | if err := kak.Run(&kak.Context{}, []string{}, fp); err != nil { 74 | return err 75 | } 76 | } 77 | 78 | return nil 79 | } 80 | 81 | func connectOrEditInClient(c *EditCmd, fp *kak.Filepath) error { 82 | if c.kctx.Client.Name == "" { 83 | // if no client, attach to session with new client 84 | if err := kak.Connect(c.kctx, fp); err != nil { 85 | return err 86 | } 87 | } else { 88 | // if client set, send 'edit [file]' to client 89 | sb := strings.Builder{} 90 | sb.WriteString(fmt.Sprintf("edit -existing %s", fp.Name)) 91 | if fp.Line != 0 { 92 | sb.WriteString(fmt.Sprintf(" %d", fp.Line)) 93 | } 94 | if fp.Column != 0 { 95 | sb.WriteString(fmt.Sprintf(" %d", fp.Column)) 96 | } 97 | 98 | if err := kak.Send(c.kctx, sb.String(), nil); err != nil { 99 | return err 100 | } 101 | } 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /cmd/embed/help: -------------------------------------------------------------------------------- 1 | Handy Kakoune companion. 2 | 3 | USAGE 4 | kks [-s ] [-c ] [] 5 | 6 | COMMANDS 7 | new, n create new session 8 | edit, e edit file 9 | send, s send command 10 | attach, a attach to session 11 | kill kill session 12 | ls list sessions and clients 13 | get get %val{..}, %opt{..} and friends 14 | cat print buffer content 15 | env print env 16 | init print Kakoune definitions 17 | 18 | ENVIRONMENT VARIABLES 19 | KKS_SESSION 20 | Kakoune session 21 | KKS_CLIENT 22 | Kakoune client 23 | KKS_DEFAULT_SESSION 24 | Session to try when KKS_SESSION is empty 25 | KKS_USE_GITDIR_SESSIONS 26 | If set, use git root dir name for creating/connecting to session 27 | 28 | FLAGS 29 | -v print version 30 | -h print help 31 | 32 | Use "kks -h" for command usage. 33 | -------------------------------------------------------------------------------- /cmd/embed/init.kak: -------------------------------------------------------------------------------- 1 | define-command -override kks-connect -params 1.. -command-completion %{ 2 | %arg{1} sh -c %{ 3 | export EDITOR="kks edit" 4 | export KKS_SESSION=$1 5 | export KKS_CLIENT=$2 6 | shift 3 7 | 8 | [ $# = 0 ] && set "$SHELL" 9 | 10 | "$@" 11 | } -- %val{session} %val{client} %arg{@} 12 | } -docstring 'run Kakoune command in connected context' 13 | 14 | define-command -override kks-run -params 1.. -shell-completion %{ 15 | nop %sh{ 16 | export EDITOR="kks edit" 17 | export KKS_SESSION="$kak_session" 18 | export KKS_CLIENT="$kak_client" 19 | "$@" 20 | } 21 | } -docstring 'run program in connected context' 22 | -------------------------------------------------------------------------------- /cmd/env.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | ) 8 | 9 | func NewEnvCmd() *EnvCmd { 10 | c := &EnvCmd{Cmd: Cmd{ 11 | fs: flag.NewFlagSet("env", flag.ExitOnError), 12 | aliases: []string{""}, 13 | description: "Print current Kakoune context set by environment to stdout.", 14 | usageLine: "[options]", 15 | sessionRequired: true, 16 | }} 17 | c.fs.BoolVar(&c.json, "json", false, "json output") 18 | return c 19 | } 20 | 21 | type EnvCmd struct { 22 | Cmd 23 | json bool 24 | } 25 | 26 | func (c *EnvCmd) Run() error { 27 | if c.json { 28 | j, err := json.MarshalIndent( 29 | map[string]string{ 30 | "session": c.session, 31 | "client": c.client, 32 | }, "", " ") 33 | if err != nil { 34 | return err 35 | } 36 | fmt.Println(string(j)) 37 | } else { 38 | fmt.Printf("session: %s\n", c.session) 39 | fmt.Printf("client: %s\n", c.client) 40 | } 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /cmd/external.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "syscall" 9 | ) 10 | 11 | func External(args []string, original error) error { 12 | if len(args) == 0 { 13 | return original 14 | } 15 | 16 | thisExecutable := filepath.Base(os.Args[0]) 17 | path, err := exec.LookPath(fmt.Sprintf("%s-%s", thisExecutable, args[0])) 18 | if err != nil { 19 | // no such executable - return original error 20 | return original 21 | } 22 | if len(args) < 1 { 23 | args = args[1:] 24 | } 25 | 26 | return syscall.Exec(path, args, os.Environ()) 27 | } 28 | -------------------------------------------------------------------------------- /cmd/get.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/kkga/kks/kak" 10 | ) 11 | 12 | func NewGetCmd() *GetCmd { 13 | c := &GetCmd{Cmd: Cmd{ 14 | fs: flag.NewFlagSet("get", flag.ExitOnError), 15 | aliases: []string{""}, 16 | description: "Get states from Kakoune context.", 17 | usageLine: "[options] (<%val{..}> | <%opt{..}> | <%reg{..}> | <%sh{..}>)", 18 | sessionRequired: true, 19 | }} 20 | c.fs.StringVar(&c.session, "s", "", "session") 21 | c.fs.StringVar(&c.client, "c", "", "client") 22 | c.fs.StringVar(&c.buffer, "b", "", "buffer") 23 | c.fs.BoolVar(&c.raw, "R", false, "raw output") 24 | return c 25 | } 26 | 27 | type GetCmd struct { 28 | Cmd 29 | raw bool 30 | } 31 | 32 | type kakErr struct { 33 | err string 34 | } 35 | 36 | func (e *kakErr) Error() string { 37 | return fmt.Sprintf("kak_error: %s", e.err) 38 | } 39 | 40 | func (c *GetCmd) Run() error { 41 | query := c.fs.Arg(0) 42 | if query == "" { 43 | err := errors.New("argument required, see: kks get -h") 44 | return err 45 | } 46 | 47 | resp, err := kak.Get(c.kctx, query) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | if strings.HasPrefix(resp, kak.EchoErrPrefix) { 53 | kakOutErr := strings.TrimPrefix(resp, kak.EchoErrPrefix) 54 | kakOutErr = strings.TrimSpace(kakOutErr) 55 | return &kakErr{kakOutErr} 56 | } 57 | 58 | if c.raw { 59 | fmt.Println(resp) 60 | } else { 61 | ss := strings.Split(resp, "' '") 62 | for i, val := range ss { 63 | ss[i] = strings.Trim(val, "'") 64 | } 65 | 66 | fmt.Println(strings.Join(ss, "\n")) 67 | } 68 | 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /cmd/init.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | _ "embed" 5 | "flag" 6 | "fmt" 7 | ) 8 | 9 | //go:embed embed/init.kak 10 | var initKak string 11 | 12 | func NewInitCmd() *InitCmd { 13 | c := &InitCmd{Cmd: Cmd{ 14 | fs: flag.NewFlagSet("init", flag.ExitOnError), 15 | aliases: []string{""}, 16 | description: "Print Kakoune command definitions to stdout.", 17 | usageLine: "", 18 | }} 19 | return c 20 | } 21 | 22 | type InitCmd struct { 23 | Cmd 24 | } 25 | 26 | func (c *InitCmd) Run() error { 27 | if _, err := fmt.Print(initKak); err != nil { 28 | return err 29 | } 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /cmd/kill.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "flag" 5 | 6 | "github.com/kkga/kks/kak" 7 | ) 8 | 9 | func NewKillCmd() *KillCmd { 10 | c := &KillCmd{Cmd: Cmd{ 11 | fs: flag.NewFlagSet("kill", flag.ExitOnError), 12 | aliases: []string{""}, 13 | description: "Terminate Kakoune session.", 14 | usageLine: "[options]", 15 | }} 16 | c.fs.BoolVar(&c.all, "a", false, "all sessions") 17 | c.fs.StringVar(&c.session, "s", "", "session") 18 | return c 19 | } 20 | 21 | type KillCmd struct { 22 | Cmd 23 | all bool 24 | } 25 | 26 | func (c *KillCmd) Run() error { 27 | sendCmd := "kill" 28 | 29 | if c.all { 30 | sessions, err := kak.Sessions() 31 | if err != nil { 32 | return err 33 | } 34 | for _, s := range sessions { 35 | sessCtx := &kak.Context{ 36 | Session: s, 37 | Client: c.kctx.Client, 38 | Buffer: c.kctx.Buffer, 39 | } 40 | if err := kak.Send(sessCtx, sendCmd, nil); err != nil { 41 | return err 42 | } 43 | } 44 | } else { 45 | if c.kctx.Session.Name == "" { 46 | return errNoSession 47 | } 48 | if err := kak.Send(c.kctx, sendCmd, nil); err != nil { 49 | return err 50 | } 51 | } 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /cmd/list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "text/tabwriter" 9 | 10 | "github.com/kkga/kks/kak" 11 | ) 12 | 13 | func NewListCmd() *ListCmd { 14 | c := &ListCmd{Cmd: Cmd{ 15 | fs: flag.NewFlagSet("list", flag.ExitOnError), 16 | aliases: []string{"ls", "l"}, 17 | description: "List Kakoune sessions and clients.", 18 | usageLine: "[options]", 19 | }} 20 | c.fs.BoolVar(&c.json, "json", false, "json output") 21 | return c 22 | } 23 | 24 | type ListCmd struct { 25 | Cmd 26 | json bool 27 | } 28 | 29 | func (c *ListCmd) Run() error { 30 | kakSessions, err := kak.Sessions() 31 | if err != nil { 32 | return err 33 | } 34 | 35 | if c.json { 36 | type session struct { 37 | Name string `json:"name"` 38 | Clients []string `json:"clients"` 39 | Dir string `json:"dir"` 40 | } 41 | 42 | sessions := make([]session, len(kakSessions)) 43 | 44 | for i, s := range kakSessions { 45 | d, err := s.Dir() 46 | if err != nil { 47 | return err 48 | } 49 | 50 | sessions[i] = session{Name: s.Name, Clients: []string{}, Dir: d} 51 | 52 | clients, err := s.Clients() 53 | if err != nil { 54 | return err 55 | } 56 | for _, c := range clients { 57 | if c.Name != "" { 58 | sessions[i].Clients = append(sessions[i].Clients, c.Name) 59 | } 60 | } 61 | } 62 | 63 | j, err := json.MarshalIndent(sessions, "", " ") 64 | if err != nil { 65 | return err 66 | } 67 | 68 | fmt.Println(string(j)) 69 | } else { 70 | w := new(tabwriter.Writer) 71 | w.Init(os.Stdout, 0, 8, 1, '\t', 0) 72 | 73 | for _, s := range kakSessions { 74 | c, err := s.Clients() 75 | if err != nil { 76 | return err 77 | } 78 | 79 | d, err := s.Dir() 80 | if err != nil { 81 | return err 82 | } 83 | 84 | if len(c) == 0 { 85 | fmt.Fprintf(w, "%s\t: %s\t: %s\n", s.Name, " ", d) 86 | } else { 87 | for _, cl := range c { 88 | fmt.Fprintf(w, "%s\t: %s\t: %s\n", s.Name, cl.Name, d) 89 | } 90 | } 91 | } 92 | 93 | w.Flush() 94 | } 95 | 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /cmd/new.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | 7 | "github.com/kkga/kks/kak" 8 | ) 9 | 10 | func NewNewCmd() *NewCmd { 11 | c := &NewCmd{Cmd: Cmd{ 12 | fs: flag.NewFlagSet("new", flag.ExitOnError), 13 | aliases: []string{"n"}, 14 | description: "Start new headless Kakoune session.", 15 | usageLine: "[]", 16 | }} 17 | return c 18 | } 19 | 20 | type NewCmd struct { 21 | Cmd 22 | name string 23 | } 24 | 25 | func (c *NewCmd) Run() error { 26 | c.name = c.fs.Arg(0) 27 | 28 | sessions, err := kak.Sessions() 29 | if err != nil { 30 | return err 31 | } 32 | 33 | for _, s := range sessions { 34 | if s.Name == c.name { 35 | return fmt.Errorf("session already exists: %s", c.name) 36 | } 37 | } 38 | 39 | sessionName, err := kak.Start(c.name) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | fmt.Println("session started:", sessionName) 45 | 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | _ "embed" 5 | "errors" 6 | "fmt" 7 | "os" 8 | ) 9 | 10 | //go:embed embed/help 11 | var helpTxt string 12 | 13 | var ErrUnknownSubcommand = errors.New("unknown subcommand") 14 | 15 | func Root(args []string) error { 16 | if len(args) < 1 || args[0] == "-h" || args[0] == "--help" { 17 | printHelp() 18 | os.Exit(0) 19 | } 20 | 21 | cmds := []Runner{ 22 | NewNewCmd(), 23 | NewEditCmd(), 24 | NewAttachCmd(), 25 | NewSendCmd(), 26 | NewGetCmd(), 27 | NewCatCmd(), 28 | NewListCmd(), 29 | NewInitCmd(), 30 | NewEnvCmd(), 31 | NewKillCmd(), 32 | } 33 | 34 | subcommand := os.Args[1] 35 | 36 | for _, cmd := range cmds { 37 | if cmd.Name() == subcommand || containsString(cmd.Alias(), subcommand) { 38 | if err := cmd.Init(os.Args[2:]); err != nil { 39 | return err 40 | } 41 | return cmd.Run() 42 | } 43 | } 44 | 45 | return fmt.Errorf("can't run %s: %w", subcommand, ErrUnknownSubcommand) 46 | } 47 | 48 | func containsString(s []string, e string) bool { 49 | for _, a := range s { 50 | if a == e { 51 | return true 52 | } 53 | } 54 | return false 55 | } 56 | 57 | func printHelp() { 58 | fmt.Print(helpTxt) 59 | } 60 | -------------------------------------------------------------------------------- /cmd/send.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "flag" 5 | "strings" 6 | 7 | "github.com/kkga/kks/kak" 8 | ) 9 | 10 | func NewSendCmd() *SendCmd { 11 | c := &SendCmd{Cmd: Cmd{ 12 | fs: flag.NewFlagSet("send", flag.ExitOnError), 13 | aliases: []string{"s"}, 14 | description: "Send commands to Kakoune context.", 15 | usageLine: "[options] ", 16 | }} 17 | c.fs.BoolVar(&c.all, "a", false, "all sessions and clients") 18 | c.fs.StringVar(&c.session, "s", "", "session") 19 | c.fs.StringVar(&c.client, "c", "", "client") 20 | c.fs.StringVar(&c.buffer, "b", "", "buffer") 21 | return c 22 | } 23 | 24 | type SendCmd struct { 25 | Cmd 26 | all bool 27 | } 28 | 29 | func (c *SendCmd) Run() error { 30 | sendCmd := strings.Join(c.fs.Args(), " ") 31 | 32 | if c.all { 33 | sessions, err := kak.Sessions() 34 | if err != nil { 35 | return err 36 | } 37 | for _, s := range sessions { 38 | clients, err := s.Clients() 39 | for _, c := range clients { 40 | clientCtx := &kak.Context{Session: s, Client: c} 41 | if err := kak.Send(clientCtx, sendCmd, nil); err != nil { 42 | return err 43 | } 44 | } 45 | if err != nil { 46 | return err 47 | } 48 | } 49 | } else { 50 | if c.kctx.Session.Name == "" { 51 | return errNoSession 52 | } 53 | if err := kak.Send(c.kctx, sendCmd, nil); err != nil { 54 | return err 55 | } 56 | } 57 | 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kkga/kks 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/fsnotify/fsnotify v1.5.1 7 | github.com/google/go-cmp v0.5.6 8 | ) 9 | 10 | require golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= 2 | github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= 3 | github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= 4 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 5 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= 6 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 7 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 8 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 9 | -------------------------------------------------------------------------------- /kak/connect.go: -------------------------------------------------------------------------------- 1 | package kak 2 | 3 | func Connect(kctx *Context, fp *Filepath) error { 4 | return Run(kctx, []string{"-c"}, fp) 5 | } 6 | -------------------------------------------------------------------------------- /kak/filepath.go: -------------------------------------------------------------------------------- 1 | package kak 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "path" 7 | "path/filepath" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | type Filepath struct { 13 | Name string 14 | Line int 15 | Column int 16 | Raw []string 17 | } 18 | 19 | func NewFilepath(args []string) *Filepath { 20 | fp := &Filepath{Raw: args} 21 | 22 | if len(args) > 0 { 23 | name, line, col, err := fp.parse() 24 | if err != nil { 25 | return nil 26 | } 27 | fp.Name = name 28 | fp.Line = line 29 | fp.Column = col 30 | } 31 | 32 | return fp 33 | } 34 | 35 | func (fp *Filepath) Dir() (dir string, err error) { 36 | info, err := os.Stat(fp.Name) 37 | if err != nil { 38 | return "", err 39 | } 40 | 41 | if info.IsDir() { 42 | dir = fp.Name 43 | } else { 44 | dir = path.Dir(fp.Name) 45 | } 46 | 47 | return 48 | } 49 | 50 | func (fp *Filepath) ParseGitDir() string { 51 | dir, _ := fp.Dir() 52 | gitOut, err := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel").Output() 53 | if err != nil { 54 | return "" 55 | } 56 | 57 | sessName := strings.Trim(strings.TrimSpace(strings.ReplaceAll(path.Base(string(gitOut)), ".", "-")), "-") 58 | return sessName 59 | } 60 | 61 | func (fp *Filepath) parse() (absName string, line, col int, err error) { 62 | r := fp.Raw 63 | 64 | rawName := r[0] 65 | 66 | if filepath.IsAbs(rawName) { 67 | absName = rawName 68 | } else { 69 | cwd, _ := os.Getwd() 70 | absName = path.Join(cwd, rawName) 71 | } 72 | 73 | if len(r) > 1 && strings.HasPrefix(r[1], "+") { 74 | if strings.Contains(r[1], ":") { 75 | lineStr := strings.ReplaceAll(strings.Split(r[1], ":")[0], "+", "") 76 | lineInt, err := strconv.Atoi(lineStr) 77 | if err != nil { 78 | return "", 0, 0, err 79 | } 80 | line = lineInt 81 | 82 | colStr := strings.Split(r[1], ":")[1] 83 | colInt, err := strconv.Atoi(colStr) 84 | if err != nil { 85 | return "", 0, 0, err 86 | } 87 | col = colInt 88 | } else { 89 | lineStr := strings.ReplaceAll(r[1], "+", "") 90 | lineInt, err := strconv.Atoi(lineStr) 91 | if err != nil { 92 | return "", 0, 0, err 93 | } 94 | line = lineInt 95 | } 96 | } 97 | 98 | return absName, line, col, err 99 | } 100 | -------------------------------------------------------------------------------- /kak/filepath_test.go: -------------------------------------------------------------------------------- 1 | package kak 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | ) 8 | 9 | func TestFilepathDir(t *testing.T) { 10 | tests := []struct { 11 | fp Filepath 12 | want string 13 | }{ 14 | { 15 | Filepath{Name: "/home/kkga"}, 16 | "/home/kkga", 17 | }, 18 | { 19 | Filepath{Name: "/home/kkga/README.md"}, 20 | "/home/kkga", 21 | }, 22 | { 23 | Filepath{Name: "/home/kkga/projects/kks/"}, 24 | "/home/kkga/projects/kks/", 25 | }, 26 | { 27 | Filepath{Name: "/home/kkga/projects/kks/cmd/attach.go"}, 28 | "/home/kkga/projects/kks/cmd", 29 | }, 30 | } 31 | for _, tt := range tests { 32 | t.Run("", func(t *testing.T) { 33 | got, err := tt.fp.Dir() 34 | if err != nil { 35 | t.Fatal("can't get Filepath.Dir(): ", err) 36 | } 37 | if !cmp.Equal(tt.want, got) { 38 | t.Errorf("Filepath.Dir() mismatch:\n%s", cmp.Diff(tt.want, got)) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | func TestParseGitDir(t *testing.T) { 45 | tests := []struct { 46 | fp Filepath 47 | want string 48 | }{ 49 | { 50 | Filepath{Name: "/home/kkga"}, 51 | "", 52 | }, 53 | { 54 | Filepath{Name: "/home/kkga/README.md"}, 55 | "", 56 | }, 57 | { 58 | Filepath{Name: "/home/kkga/projects/kks/"}, 59 | "kks", 60 | }, 61 | { 62 | Filepath{Name: "/home/kkga/projects/kks/cmd/attach.go"}, 63 | "kks", 64 | }, 65 | { 66 | Filepath{Name: "/home/kkga/projects/foot.kak/rc/foot.kak"}, 67 | "foot-kak", 68 | }, 69 | { 70 | Filepath{Name: "/home/kkga/repos/kakoune/rc/detection/editorconfig.kak"}, 71 | "kakoune", 72 | }, 73 | } 74 | for _, tt := range tests { 75 | t.Run("", func(t *testing.T) { 76 | got := tt.fp.ParseGitDir() 77 | if !cmp.Equal(tt.want, got) { 78 | t.Errorf("Filepath.ParseGitDir() mismatch:\n%s", cmp.Diff(tt.want, got)) 79 | } 80 | }) 81 | } 82 | } 83 | 84 | func TestNewFilepath(t *testing.T) { 85 | tests := []struct { 86 | args []string 87 | want Filepath 88 | }{ 89 | { 90 | []string{"file"}, 91 | Filepath{ 92 | Name: "/home/kkga/projects/kks/kak/file", 93 | Raw: []string{"file"}, 94 | }, 95 | }, 96 | { 97 | []string{"../file.kak", "+22"}, 98 | Filepath{ 99 | Name: "/home/kkga/projects/kks/file.kak", 100 | Line: 22, 101 | Raw: []string{"../file.kak", "+22"}, 102 | }, 103 | }, 104 | { 105 | []string{"/etc/readme", "+10:2"}, 106 | Filepath{ 107 | Name: "/etc/readme", 108 | Line: 10, Column: 2, 109 | Raw: []string{"/etc/readme", "+10:2"}, 110 | }, 111 | }, 112 | { 113 | []string{"../../../downloads/readme", ":2"}, 114 | Filepath{ 115 | Name: "/home/kkga/downloads/readme", 116 | Raw: []string{"../../../downloads/readme", ":2"}, 117 | }, 118 | }, 119 | } 120 | for _, tt := range tests { 121 | t.Run("", func(t *testing.T) { 122 | got := NewFilepath(tt.args) 123 | if !cmp.Equal(tt.want, *got) { 124 | t.Errorf("Filepath mismatch:\n%s", cmp.Diff(tt.want, got)) 125 | } 126 | }) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /kak/get.go: -------------------------------------------------------------------------------- 1 | package kak 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | // EchoPrefix is a prefix added to Kakoune's echo output 10 | const EchoPrefix = "__kak_echo__" 11 | 12 | // EchoErrPrefix is a prefix added when Kakoune's evaluation catches an error 13 | const EchoErrPrefix = "__kak_error__" 14 | 15 | func Get(kctx *Context, query string) (string, error) { 16 | // create a tmp file for kak to echo the value 17 | tmp, err := os.CreateTemp("", "kks-tmp") 18 | if err != nil { 19 | return "", err 20 | } 21 | 22 | // kak will output to file, so we create a chan for reading 23 | ch := make(chan string) 24 | go ReadTmp(tmp, ch) 25 | 26 | // tell kak to echo the requested state 27 | // the '__kak_echo__' is there to ensure that file gets written even kak's echo is empty 28 | sendCmd := fmt.Sprintf("echo -quoting kakoune -to-file %s %%{ %s %s }", tmp.Name(), EchoPrefix, query) 29 | if err := Send(kctx, sendCmd, tmp); err != nil { 30 | return "", err 31 | } 32 | 33 | // wait until tmp file is populated and read 34 | output := <-ch 35 | 36 | output = strings.TrimPrefix(output, fmt.Sprintf("'%s'", EchoPrefix)) 37 | output = strings.TrimSpace(output) 38 | 39 | tmp.Close() 40 | os.Remove(tmp.Name()) 41 | 42 | return output, nil 43 | } 44 | -------------------------------------------------------------------------------- /kak/kak.go: -------------------------------------------------------------------------------- 1 | package kak 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "os/exec" 8 | "strings" 9 | ) 10 | 11 | type Context struct { 12 | Session Session 13 | Client Client 14 | Buffer Buffer 15 | } 16 | 17 | type ( 18 | Client struct{ Name string } 19 | Session struct{ Name string } 20 | Buffer struct{ Name string } 21 | ) 22 | 23 | func (s *Session) Exists() (exists bool, err error) { 24 | sessions, err := Sessions() 25 | for _, session := range sessions { 26 | if session.Name == s.Name { 27 | exists = true 28 | break 29 | } 30 | } 31 | return 32 | } 33 | 34 | func (s *Session) Dir() (dir string, err error) { 35 | sessCtx := &Context{Session: *s} 36 | resp, err := Get(sessCtx, "%sh{pwd}") 37 | if err != nil { 38 | return "", err 39 | } 40 | 41 | dir = strings.Trim(resp, "'") 42 | return 43 | } 44 | 45 | func (s *Session) Clients() (clients []Client, err error) { 46 | sessCtx := &Context{Session: *s} 47 | resp, err := Get(sessCtx, "%val{client_list}") 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | ss := strings.Split(resp, "' '") 53 | for i, val := range ss { 54 | ss[i] = strings.Trim(val, "'") 55 | } 56 | 57 | for _, c := range ss { 58 | clients = append(clients, Client{c}) 59 | } 60 | 61 | return 62 | } 63 | 64 | func Sessions() (sessions []Session, err error) { 65 | kakExec, err := kakExec() 66 | if err != nil { 67 | return 68 | } 69 | 70 | err = clearSessions() 71 | if err != nil { 72 | return 73 | } 74 | 75 | o, err := exec.Command(kakExec, "-l").Output() 76 | 77 | scanner := bufio.NewScanner(bytes.NewBuffer(o)) 78 | for scanner.Scan() { 79 | if s := scanner.Text(); s != "" { 80 | sessions = append(sessions, Session{s}) 81 | } 82 | } 83 | 84 | return 85 | } 86 | 87 | func clearSessions() error { 88 | kakExec, err := kakExec() 89 | if err != nil { 90 | return err 91 | } 92 | 93 | err = exec.Command(kakExec, "-clear").Run() 94 | if err != nil { 95 | return err 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func kakExec() (kakExec string, err error) { 102 | kakExec, err = exec.LookPath("kak") 103 | if err != nil { 104 | return "", errors.New("'kak' executable not found in $PATH") 105 | } 106 | 107 | return 108 | } 109 | -------------------------------------------------------------------------------- /kak/kak_test.go: -------------------------------------------------------------------------------- 1 | package kak 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | ) 9 | 10 | func TestSessionDir(t *testing.T) { 11 | tests := []struct { 12 | kakdir string 13 | want string 14 | }{ 15 | { 16 | "/home/kkga", 17 | "/home/kkga", 18 | }, 19 | { 20 | "~/downloads/", 21 | "/home/kkga/downloads", 22 | }, 23 | { 24 | "/etc/", 25 | "/etc", 26 | }, 27 | } 28 | for i, tt := range tests { 29 | t.Run("", func(t *testing.T) { 30 | testSession, err := Start(fmt.Sprintf("kks-test-%d", i)) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | kctx := &Context{} 36 | kctx.Session = Session{testSession} 37 | 38 | defer func() { 39 | err = Send(kctx, "kill", nil) 40 | }() 41 | 42 | if err := Send(kctx, fmt.Sprintf("cd %s", tt.kakdir), nil); err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | got, err := kctx.Session.Dir() 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | 51 | if !cmp.Equal(tt.want, got) { 52 | t.Errorf("Sessiond.Dir() mismatch:\n%s", cmp.Diff(tt.want, got)) 53 | } 54 | }) 55 | } 56 | } 57 | 58 | func TestSessionExists(t *testing.T) { 59 | tests := []struct { 60 | session Session 61 | }{ 62 | { 63 | Session{"kks-test-hey"}, 64 | }, 65 | { 66 | Session{"kks-test-yo"}, 67 | }, 68 | { 69 | Session{"kks-wassup12348fkqwer-qw"}, 70 | }, 71 | } 72 | for _, tt := range tests { 73 | t.Run("", func(t *testing.T) { 74 | _, err := Start(tt.session.Name) 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | kctx := &Context{} 79 | kctx.Session = tt.session 80 | 81 | defer func() { 82 | err = Send(kctx, "kill", nil) 83 | }() 84 | 85 | got, err := kctx.Session.Exists() 86 | if err != nil { 87 | t.Fatal(err) 88 | } 89 | 90 | if !cmp.Equal(true, got) { 91 | t.Errorf("Sessiond.Dir() mismatch:\n%s", cmp.Diff(true, got)) 92 | } 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /kak/run.go: -------------------------------------------------------------------------------- 1 | package kak 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "syscall" 7 | ) 8 | 9 | func Run(kctx *Context, kakArgs []string, fp *Filepath) error { 10 | kakExec, err := kakExec() 11 | if err != nil { 12 | return err 13 | } 14 | 15 | kakExecArgs := []string{kakExec} 16 | 17 | for _, a := range kakArgs { 18 | switch a { 19 | case "-c": 20 | kakExecArgs = append(kakExecArgs, "-c", kctx.Session.Name) 21 | default: 22 | return fmt.Errorf("Unknown argument to Run: %s", a) 23 | } 24 | } 25 | 26 | if fp.Name != "" { 27 | kakExecArgs = append(kakExecArgs, fp.Name) 28 | 29 | if fp.Line != 0 { 30 | kakExecArgs = append(kakExecArgs, fmt.Sprintf("+%d:%d", fp.Line, fp.Column)) 31 | } 32 | 33 | } 34 | 35 | execErr := syscall.Exec(kakExec, kakExecArgs, os.Environ()) 36 | if execErr != nil { 37 | return execErr 38 | } 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /kak/send.go: -------------------------------------------------------------------------------- 1 | package kak 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | ) 10 | 11 | func Send(kctx *Context, kakCommand string, errOutFile *os.File) error { 12 | kakExec, err := kakExec() 13 | if err != nil { 14 | return err 15 | } 16 | cmd := exec.Command(kakExec, "-p", kctx.Session.Name) 17 | 18 | stdin, err := cmd.StdinPipe() 19 | if err != nil { 20 | return err 21 | } 22 | 23 | var sb strings.Builder 24 | 25 | // wrap Kakoune command in try-catch 26 | // try 27 | sb.WriteString("try %{") 28 | sb.WriteString(" eval") 29 | if kctx.Buffer.Name != "" { 30 | sb.WriteString(fmt.Sprintf(" -buffer %s", kctx.Buffer.Name)) 31 | } else if kctx.Client.Name != "" { 32 | sb.WriteString(fmt.Sprintf(" -try-client %s", kctx.Client.Name)) 33 | } 34 | sb.WriteString(fmt.Sprintf(" %s", kakCommand)) 35 | sb.WriteString(" }") 36 | 37 | // catch 38 | sb.WriteString(" catch %{") 39 | // echo error to Kakoune's debug buffer 40 | sb.WriteString(" echo -debug kks: %val{error}\n") 41 | if errOutFile != nil { 42 | // write a prefixed error to tmp file so that we can parse it in runner and decide what to do 43 | sb.WriteString(fmt.Sprintf(" echo -to-file %s %s %%val{error}", errOutFile.Name(), EchoErrPrefix)) 44 | sb.WriteString("\n") 45 | } 46 | // echo error in client 47 | sb.WriteString(" eval") 48 | if kctx.Client.Name != "" { 49 | sb.WriteString(fmt.Sprintf(" -try-client %s", kctx.Client.Name)) 50 | } 51 | sb.WriteString(" %{ echo -markup {Error}kks: %val{error} }") 52 | sb.WriteString(" }") 53 | 54 | go func() { 55 | defer stdin.Close() 56 | io.WriteString(stdin, sb.String()) //nolint 57 | }() 58 | 59 | _, err = cmd.CombinedOutput() 60 | if err != nil { 61 | return err 62 | } 63 | 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /kak/start.go: -------------------------------------------------------------------------------- 1 | package kak 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "os/exec" 7 | "time" 8 | ) 9 | 10 | func Start(name string) (sessionName string, err error) { 11 | sessionName = name 12 | 13 | if sessionName == "" { 14 | sessionName, err = uniqSessionName() 15 | if err != nil { 16 | return "", err 17 | } 18 | } 19 | 20 | kakExec, err := kakExec() 21 | if err != nil { 22 | return 23 | } 24 | 25 | cmd := exec.Command(kakExec, "-s", sessionName, "-d") 26 | 27 | err = cmd.Start() 28 | if err != nil { 29 | return "", err 30 | } 31 | 32 | // Ensure session exists before returning 33 | ch := make(chan bool) 34 | go func() { 35 | err = waitForSession(ch, sessionName) 36 | }() 37 | 38 | <-ch 39 | 40 | return 41 | } 42 | 43 | func waitForSession(ch chan bool, name string) error { 44 | Out: 45 | for { 46 | sessions, err := Sessions() 47 | if err != nil { 48 | return err 49 | } 50 | for _, s := range sessions { 51 | if s.Name == name { 52 | ch <- true 53 | break Out 54 | 55 | } 56 | } 57 | time.Sleep(time.Millisecond * 10) 58 | } 59 | return nil 60 | } 61 | 62 | func uniqSessionName() (name string, err error) { 63 | sessions, err := Sessions() 64 | if err != nil { 65 | return 66 | } 67 | Out: 68 | for { 69 | name = fmt.Sprintf("kks-%d", rand.Intn(999-100)+100) 70 | if len(sessions) > 0 { 71 | for i, s := range sessions { 72 | if s.Name == name { 73 | break 74 | } else if i == len(sessions)-1 { 75 | break Out 76 | } 77 | } 78 | } else { 79 | break Out 80 | } 81 | } 82 | return 83 | } 84 | -------------------------------------------------------------------------------- /kak/tmp.go: -------------------------------------------------------------------------------- 1 | package kak 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/fsnotify/fsnotify" 8 | ) 9 | 10 | func ReadTmp(tmp *os.File, c chan string) { 11 | // create a watcher 12 | watcher, err := fsnotify.NewWatcher() 13 | if err != nil { 14 | log.Fatal(err) 15 | } 16 | defer watcher.Close() 17 | 18 | // add file to watch 19 | err = watcher.Add(tmp.Name()) 20 | if err != nil { 21 | log.Fatal(err) 22 | } 23 | 24 | // while we don't get the value 25 | for { 26 | select { 27 | case event, ok := <-watcher.Events: 28 | if !ok { 29 | return 30 | } 31 | // if file written, read it, send to chan and close/clean 32 | if event.Op&fsnotify.Write == fsnotify.Write { 33 | dat, err := os.ReadFile(tmp.Name()) 34 | if err != nil { 35 | log.Fatal(err) 36 | } 37 | c <- string(dat) 38 | watcher.Close() 39 | tmp.Close() 40 | os.Remove(tmp.Name()) 41 | } 42 | case err, ok := <-watcher.Errors: 43 | if !ok { 44 | return 45 | } 46 | log.Println("error:", err) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/kkga/kks/cmd" 10 | ) 11 | 12 | var version = "dev" 13 | 14 | func main() { 15 | log.SetFlags(0) 16 | 17 | if len(os.Args) > 1 && os.Args[1] == "-v" { 18 | fmt.Printf("kks %s\n", version) 19 | os.Exit(0) 20 | } 21 | 22 | err := cmd.Root(os.Args[1:]) 23 | 24 | if err != nil && errors.Is(err, cmd.ErrUnknownSubcommand) { 25 | err = cmd.External(os.Args[1:], err) 26 | } 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /scripts/kks-buffers: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # pick buffers 4 | # 5 | # requires: 6 | # - fzf (https://github.com/junegunn/fzf) 7 | # - bat (change to your liking) (https://github.com/sharkdp/bat) 8 | 9 | preview_cmd="bat --color=always --line-range=:500" 10 | history_file="$HOME/.cache/kks-buffers-history" 11 | 12 | [ -f "$history_file" ] || touch "$history_file" 13 | 14 | kks get '%val{buflist}' | 15 | grep -F "$*" | 16 | fzf --height 100% --prompt 'buf> ' --preview "kks cat -b {} | $preview_cmd" \ 17 | --header="[c-x] delete, [c-t] new scratch" \ 18 | --bind="ctrl-x:execute-silent(kks send -b {} delete-buffer)+reload(kks get '%val{buflist}')" \ 19 | --bind="ctrl-t:execute-silent(kks send edit -scratch {q})+reload(kks get '%val{buflist}')" \ 20 | --history="$history_file" | 21 | while read -r name; do 22 | kks send buffer "$name" 23 | done 24 | -------------------------------------------------------------------------------- /scripts/kks-fifo: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # pipe stdin into Kakoune fifo buffer 4 | # 5 | # Example: 6 | # make | kks-fifo 7 | set -euf 8 | 9 | # Fail early if no session in context 10 | kks env >/dev/null 2>&1 || { 11 | # show error 12 | kks env 13 | exit 1 14 | } 15 | 16 | tmp=$(mktemp -d "${TMPDIR:-/tmp}"/kks-fifo.XXXXXXXX) 17 | fifo="$tmp/fifo" 18 | 19 | cleanup() { 20 | rm -r "$tmp" 21 | } 22 | 23 | trap 'cleanup; trap - EXIT' EXIT INT HUP 24 | mkfifo "$fifo" 25 | kks send edit! -fifo "$fifo" '*fifo*' 26 | cat >"$fifo" 27 | -------------------------------------------------------------------------------- /scripts/kks-files: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # pick files 4 | # 5 | # requires: 6 | # - fd (https://github.com/sharkdp/fd) 7 | # - fzf (https://github.com/junegunn/fzf) 8 | # - bat (change to your liking) (https://github.com/sharkdp/bat) 9 | 10 | preview_cmd="bat --color=always --line-range=:500" 11 | history_file="$HOME/.cache/kks-files-history" 12 | 13 | [ -f "$history_file" ] || touch "$history_file" 14 | 15 | fd --type file . "$@" | 16 | fzf --multi --height 100% --prompt 'files> ' \ 17 | --preview "$preview_cmd {}" --history="$history_file" | 18 | while read -r file; do 19 | kks edit "$file" 20 | done 21 | -------------------------------------------------------------------------------- /scripts/kks-filetypes: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # pick filetype from Kakoune's runtime dir and set in current buffer 4 | # 5 | # requires: 6 | # - fzf (https://github.com/junegunn/fzf) 7 | 8 | ft_dir="$(kks get %val[runtime])/rc/filetype" 9 | 10 | find "$ft_dir"/*.kak -type f -exec basename -s .kak {} \; | 11 | fzf --height 100% --prompt 'filetypes> ' | 12 | xargs -I {} kks send 'set buffer filetype {}' 13 | -------------------------------------------------------------------------------- /scripts/kks-git-files: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # pick files from git ls-files 4 | # 5 | # requires: 6 | # - fzf (https://github.com/junegunn/fzf) 7 | # - bat (change to your liking) (https://github.com/sharkdp/bat) 8 | 9 | preview_cmd="bat --color=always --line-range=:500" 10 | history_file="$HOME/.cache/kks-files-history" 11 | 12 | [ -f "$history_file" ] || touch "$history_file" 13 | 14 | git ls-files --full-name "$(git rev-parse --show-toplevel)" "$@" | 15 | fzf --multi --height 100% --prompt 'files> ' \ 16 | --preview "$preview_cmd {}" --history="$history_file" | 17 | while read -r file; do 18 | kks edit "$file" 19 | done 20 | -------------------------------------------------------------------------------- /scripts/kks-grep: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # search for pattern in workdir 4 | # 5 | # requires: 6 | # - ripgrep (https://github.com/BurntSushi/ripgrep) 7 | # - fzf (https://github.com/junegunn/fzf) 8 | # - bat (change to your liking) (https://github.com/sharkdp/bat) 9 | 10 | history_file="$HOME/.cache/kks-grep-history" 11 | query="" 12 | 13 | [ -f "$history_file" ] || touch "$history_file" 14 | [ "$(kks get %val[selection_length])" -gt 1 ] && query="$(kks get %val[selection])" 15 | 16 | rg --vimgrep '.+' "$@" | 17 | SHELL=sh fzf --delimiter=":" --query="$query" --height="100%" --prompt="grep> " --history="$history_file" \ 18 | --preview='range="$(echo {2}-5 | bc | sed "s/^-.*/0/"):$(echo {2}+20 | bc)"; bat -r "$range" -n --color always -H {2} {1}' | 19 | awk -F':' '{print $1 " " "+" $2 ":" $3 }' | 20 | xargs -r kks edit 21 | -------------------------------------------------------------------------------- /scripts/kks-lf: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # open lf in single-pane view with current buffer selected 4 | # 5 | # requires: 6 | # - lf (https://github.com/gokcehan/lf) 7 | 8 | kks get '%val{buffile}' | 9 | xargs -I {} lf -command "set nopreview; set ratios 1" {} 10 | -------------------------------------------------------------------------------- /scripts/kks-lines: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # jump to line in buffer 4 | # 5 | # requires: 6 | # - fzf (https://github.com/junegunn/fzf) 7 | 8 | kks cat | 9 | nl -ba -w4 -s' │ ' | 10 | fzf --height 100% --prompt 'lines> ' | 11 | awk '{print $1}' | 12 | xargs -r -I {} kks send "execute-keys '{}gx'" 13 | -------------------------------------------------------------------------------- /scripts/kks-md-heading: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # jump to heading in markdown file 4 | # 5 | # requires: 6 | # - ripgrep (https://github.com/BurntSushi/ripgrep) 7 | # - fzf (https://github.com/junegunn/fzf) 8 | 9 | kks cat | 10 | rg -n '^#+' | 11 | column -t -s ':' | 12 | fzf --height 100% --prompt 'heading> ' | 13 | awk '{print $1}' | 14 | xargs -r -I {} kks send "execute-keys '{}gx'" 15 | -------------------------------------------------------------------------------- /scripts/kks-mru: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # pick recent file 4 | # 5 | # requires: 6 | # - fzf (https://github.com/junegunn/fzf) 7 | # - bat (change to your liking) (https://github.com/sharkdp/bat) 8 | # 9 | # for this to work, add the following in kakrc: 10 | # (requires sponge from moreutils: https://joeyh.name/code/moreutils/) 11 | # hook global BufCreate [^*].* %{ 12 | # nop %sh{ 13 | # mru=~/.cache/kak-mru 14 | # echo "$kak_buffile" | awk '!seen[$0]++' - "$mru" | sponge "$mru" 15 | # } 16 | # } 17 | 18 | preview_cmd="bat --color=always --line-range=:500" 19 | 20 | (fzf --height 100% --prompt 'mru> ' --preview "$preview_cmd {}" | 21 | xargs -r kks edit) < ~/.cache/kak-mru 22 | -------------------------------------------------------------------------------- /scripts/kks-select: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # select kak session and client to set environment 4 | # use as `eval $(kks-select)`, eg: `alias ks="eval $(kks-select)"` in shell config 5 | # 6 | # requires: 7 | # - fzf (https://github.com/junegunn/fzf) 8 | 9 | command kak -clear 10 | 11 | kks list | 12 | fzf -d '\t*: *' \ 13 | --header="[c-x] kill, [c-t] new, [c-r] reload" \ 14 | --bind="ctrl-x:execute-silent(kks kill -s {1})+reload(sleep 0.1; kks list)" \ 15 | --bind="ctrl-t:execute-silent(kks new {q})+reload(sleep 0.1; kks list)" \ 16 | --bind="ctrl-r:reload(kks list)" \ 17 | --preview-window=down:0% --preview="kks send -a info; kks send -s {1} -c {2} info -markup '{2}@{+b}[{1}]'" | 18 | awk -F '\t*: *' '{print "export KKS_SESSION=" $1 "; export KKS_CLIENT=" $2}' 19 | --------------------------------------------------------------------------------