├── .gitignore ├── CLIPBOARD_LICENSE ├── LICENSE ├── Makefile ├── README.md ├── nw.go └── nw_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | nw 2 | -------------------------------------------------------------------------------- /CLIPBOARD_LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Ato Araki. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of @atotto. nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Robbie Vanbrabant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: get clean 2 | 3 | nw: nw.go 4 | go build -o nw 5 | 6 | get: 7 | go get -v ./... 8 | 9 | clean: 10 | rm -f nw 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## What 2 | Numbers any valid file path in stdin (one per line). Copies chosen file names to the clipboard. 3 | 4 | ## Why 5 | For example you want to undo some changes you made to a git project: 6 | ```sh 7 | $ git st 8 | ## master 9 | M README.md 10 | M somwhere/in/a/deeply/nested/directory/program.go 11 | ``` 12 | 13 | You can't be bothered reaching for the mouse to copy this file name so you pipe the output to numberwang: 14 | 15 | ```sh 16 | $ git st | nw 17 | ## master 18 | {1} M {README.md} 19 | {2} M {somwhere/in/a/deeply/nested/directory/program.go} 20 | 21 | to clipboard: 22 | ``` 23 | You now got prompted to choose file names to copy to the clipboard. You choose "2" (you can select multiple files by typing multiple numbers separated by a space). 24 | 25 | ```sh 26 | $ git st | nw 27 | ## master 28 | {1} M {README.md} 29 | {2} M {somwhere/in/a/deeply/nested/directory/program.go} 30 | 31 | to clipboard: 2 32 | nw: wrote "somwhere/in/a/deeply/nested/directory/program.go " to clipboard 33 | ``` 34 | 35 | Now you can simply paste the file name(s) you selected when performing a checkout: 36 | 37 | ```sh 38 | $ git checkout somwhere/in/a/deeply/nested/directory/program.go 39 | ``` 40 | 41 | ## Usage with git 42 | I recommend a git alias that preserves colored output, for example: 43 | 44 | ```st = -c color.status=always status -sb``` 45 | 46 | Or if you want to go all-in and always call numberwang: 47 | 48 | ```snw = ! git -c color.status=always status -sb | nw``` 49 | 50 | Other commands might have similar options to preserve color. 51 | -------------------------------------------------------------------------------- /nw.go: -------------------------------------------------------------------------------- 1 | // vim: tabstop=4 softtabstop=4 shiftwidth=4 noexpandtab tw=72 2 | package main 3 | 4 | import ( 5 | "bufio" 6 | "bytes" 7 | "errors" 8 | "flag" 9 | "fmt" 10 | "github.com/atotto/clipboard" 11 | "os" 12 | "strconv" 13 | "strings" 14 | ) 15 | 16 | type existsFunc func(string) bool 17 | 18 | func osStatExists(file string) bool { 19 | _, err := os.Stat(file) 20 | return err == nil 21 | } 22 | 23 | var ignoreList = [...]string{"/", ".", "./", "..", "../"} 24 | 25 | //var rootListing, _ = ioutil.ReadDir("/") 26 | //var pwdListing, _ = ioutil.ReadDir(".") 27 | 28 | func ignored(file string) bool { 29 | for _, val := range ignoreList { 30 | if file == val { 31 | return true 32 | } 33 | } 34 | return false 35 | } 36 | 37 | func longestFileEndIndex(line []rune, exists existsFunc) int { 38 | // Possible optimisations: 39 | // 1. this should start at the end - the longest substring first 40 | // 2. it could be a good strategy to list files and try to 41 | // find a common prefix - if not just stop right there and then 42 | // from / if it starts with / and if not from `pwd` 43 | // do file listing from / and `pwd` only once 44 | // need to consider relative dirs though which is annoying 45 | 46 | maxIndex := 0 47 | for i, _ := range line { 48 | slice := line[0 : i+1] 49 | file := string(slice) 50 | if !ignored(file) { 51 | if exists(file) { 52 | // TODO if this is not a dir, stop here 53 | maxIndex = i 54 | } 55 | } 56 | } 57 | return maxIndex 58 | } 59 | 60 | func longestFileInLine(line string, exists existsFunc) (firstCharIndex int, lastCharIndex int) { 61 | for searchStartIndex, _ := range line { 62 | searchSpace := []rune(line[searchStartIndex:len(line)]) 63 | lastCharIndexInSlice := longestFileEndIndex(searchSpace, exists) 64 | lastCharIndexInLine := lastCharIndexInSlice + searchStartIndex 65 | if lastCharIndexInSlice > 0 && lastCharIndexInLine > lastCharIndex { 66 | lastCharIndex = lastCharIndexInLine 67 | firstCharIndex = searchStartIndex 68 | } 69 | } 70 | 71 | return firstCharIndex, lastCharIndex 72 | } 73 | 74 | func askUser(question string) (requestedNumbers []string, err error) { 75 | fmt.Println() 76 | fmt.Print(question) 77 | ttyFile, err := os.Open("/dev/tty") 78 | if err != nil { 79 | return nil, err 80 | } 81 | defer ttyFile.Close() 82 | ttyReader := bufio.NewReader(ttyFile) 83 | s, err := ttyReader.ReadString('\n') 84 | if err != nil { 85 | return nil, err 86 | } 87 | return strings.Fields(s), nil 88 | } 89 | 90 | type PrintFunction func(string, string, int, int) 91 | 92 | type Processor interface { 93 | processFile(file string) error 94 | processEnd() error 95 | } 96 | 97 | type NumbersGiven struct { 98 | clip *bytes.Buffer 99 | fileCount *int 100 | numbers []string 101 | invert bool 102 | } 103 | 104 | type AskForNumbers struct { 105 | clip *bytes.Buffer 106 | files []string 107 | invert bool 108 | } 109 | 110 | func printShortFormat(fileCount *int) PrintFunction { 111 | return func(file string, line string, firstCharIndex int, lastCharIndex int) { 112 | fmt.Println(strconv.Itoa(*fileCount), file) 113 | } 114 | } 115 | 116 | func printLongFormat(fileCount *int) PrintFunction { 117 | return func(file string, line string, firstCharIndex int, lastCharIndex int) { 118 | fmt.Print("{") 119 | fmt.Print(strconv.Itoa(*fileCount)) 120 | fmt.Print("} ") 121 | fmt.Print(line[:firstCharIndex]) 122 | fmt.Print("{") 123 | fmt.Print(file) 124 | fmt.Print("}") 125 | fmt.Print(line[lastCharIndex+1:]) 126 | } 127 | } 128 | 129 | func (ng *NumbersGiven) processEnd() error { 130 | writeToClipboard(ng.clip) 131 | return nil 132 | } 133 | 134 | func (ng *NumbersGiven) processFile(file string) error { 135 | for _, v := range ng.numbers { 136 | n, err := strconv.Atoi(v) 137 | if err != nil { 138 | fmt.Fprintf(os.Stderr, "nw: %s is not a number\n", v) 139 | return err 140 | } 141 | found := (n == *ng.fileCount) 142 | if found { 143 | if found != ng.invert { 144 | ng.clip.WriteString(file) 145 | ng.clip.WriteString(" ") 146 | } 147 | return nil 148 | } 149 | } 150 | 151 | // not found in given numbers, so always a match if inverted 152 | if ng.invert { 153 | ng.clip.WriteString(file) 154 | ng.clip.WriteString(" ") 155 | } 156 | return nil 157 | } 158 | 159 | func (afn *AskForNumbers) processEnd() error { 160 | if len(afn.files) == 0 { 161 | fmt.Println("nw: no file names found, NUMBERWANG!") 162 | return nil 163 | } 164 | question := "to clipboard: " 165 | if afn.invert { 166 | question = "NOT " + question 167 | } 168 | requestedNumbers, err := askUser(question) 169 | if err != nil { 170 | fmt.Fprintf(os.Stderr, "nw: failed to read input: %s\n", err) 171 | return err 172 | } 173 | 174 | reqIndexes := make(map[int]struct{}) 175 | for _, n := range requestedNumbers { 176 | i, err := strconv.Atoi(n) 177 | if err != nil { 178 | fmt.Fprintf(os.Stderr, "nw: %s is not a number\n", n) 179 | return err 180 | } 181 | if i <= 0 || i > len(afn.files) { 182 | fmt.Fprintf(os.Stderr, "nw: %s is not a valid choice\n", n) 183 | return errors.New("invalid choice") 184 | } 185 | reqIndexes[i-1] = struct{}{} 186 | } 187 | 188 | for i, file := range afn.files { 189 | if _, exists := reqIndexes[i]; exists != afn.invert { 190 | afn.clip.WriteString(file) 191 | afn.clip.WriteString(" ") 192 | } 193 | } 194 | 195 | writeToClipboard(afn.clip) 196 | return nil 197 | } 198 | 199 | func (afn *AskForNumbers) processFile(file string) error { 200 | afn.files = append(afn.files, file) 201 | return nil 202 | } 203 | 204 | func writeToClipboard(buffer *bytes.Buffer) { 205 | clipboardOutput := buffer.String() 206 | if clipboardOutput != "" { 207 | clipboard.WriteAll(clipboardOutput) 208 | fmt.Printf("nw: wrote \"%s\" to clipboard\n", clipboardOutput) 209 | } 210 | } 211 | 212 | func main() { 213 | var fileCount int 214 | var clip bytes.Buffer 215 | 216 | short := flag.Bool("s", false, "short format, only display file names") 217 | invert := flag.Bool("i", false, "copy the inverse of the selection to the clipboard") 218 | flag.Parse() 219 | 220 | extraArgs := flag.Args() 221 | 222 | var processor Processor 223 | if len(extraArgs) == 0 { 224 | processor = &AskForNumbers{clip: &clip, invert: *invert} 225 | } else { 226 | processor = &NumbersGiven{ 227 | clip: &clip, 228 | fileCount: &fileCount, 229 | numbers: extraArgs, 230 | invert: *invert, 231 | } 232 | } 233 | var printer PrintFunction 234 | if *short { 235 | printer = printShortFormat(&fileCount) 236 | } else { 237 | printer = printLongFormat(&fileCount) 238 | } 239 | 240 | reader := bufio.NewReader(os.Stdin) 241 | 242 | for { 243 | line, err := reader.ReadString('\n') 244 | if err != nil { 245 | break 246 | } 247 | 248 | firstCharIndex, lastCharIndex := longestFileInLine(line, osStatExists) 249 | 250 | if lastCharIndex > 0 { 251 | fileCount++ 252 | file := line[firstCharIndex : lastCharIndex+1] 253 | 254 | printer(file, line, firstCharIndex, lastCharIndex) 255 | err := processor.processFile(file) 256 | if err != nil { 257 | os.Exit(1) 258 | } 259 | } else if !*short { 260 | fmt.Print(line) 261 | } 262 | } 263 | 264 | err := processor.processEnd() 265 | if err != nil { 266 | os.Exit(1) 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /nw_test.go: -------------------------------------------------------------------------------- 1 | // vim: tabstop=4 softtabstop=4 shiftwidth=4 noexpandtab tw=72 2 | package main 3 | 4 | import ( 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestLastCharacterGetsConsidered(t *testing.T) { 10 | line := "blah/.gitkeep" 11 | start, end := longestFileInLine(line, func(file string) bool { 12 | return file == ".git" || file == line 13 | }) 14 | result := line[start : end+1] 15 | 16 | if result != line { 17 | t.Errorf("Expected: '%s', found: '%s'", line, result) 18 | } 19 | } 20 | 21 | func BenchmarkLongestFileInLine(b *testing.B) { 22 | line := strings.Repeat("blah/.gitkeep", 100) 23 | for i := 0; i < b.N; i++ { 24 | start, end := longestFileInLine(line, osStatExists) 25 | var _ = line[start : end+1] 26 | } 27 | } 28 | --------------------------------------------------------------------------------