├── .github └── workflows │ └── goreleaser.yml ├── .goreleaser.yml ├── LICENSE ├── README.md ├── cmd └── ilse.go ├── config.go ├── file.go ├── filter ├── fuzzy.go ├── ripgrep.go └── search.go ├── frame.go ├── go.mod ├── go.sum ├── ilse.go ├── layout.go ├── list.go ├── pages.go ├── preview.go ├── renovate.json ├── search_bar.go ├── state.go ├── tree.go └── util ├── logger.go └── util.go /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - 'v.*' 9 | 10 | jobs: 11 | goreleaser: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - 15 | name: Checkout 16 | uses: actions/checkout@v2 17 | with: 18 | fetch-depth: 0 19 | - 20 | name: Set up Go 21 | uses: actions/setup-go@v2 22 | with: 23 | go-version: 1.16 24 | - 25 | name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v2 27 | with: 28 | version: latest 29 | args: release --rm-dist 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | before: 4 | hooks: 5 | # You may remove this if you don't use go modules. 6 | - go mod download 7 | # you may remove this if you don't need go generate 8 | - go generate ./... 9 | builds: 10 | - env: 11 | - CGO_ENABLED=0 12 | goos: 13 | - linux 14 | - windows 15 | - darwin 16 | goarch: 17 | - 386 18 | - amd64 19 | - arm 20 | - arm64 21 | main: ./cmd/ilse.go 22 | archives: 23 | - replacements: 24 | darwin: Darwin 25 | linux: Linux 26 | windows: Windows 27 | 386: i386 28 | amd64: x86_64 29 | checksum: 30 | name_template: 'checksums.txt' 31 | snapshot: 32 | name_template: "{{ .Tag }}-next" 33 | changelog: 34 | sort: asc 35 | filters: 36 | exclude: 37 | - '^docs:' 38 | - '^test:' 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ilse 2 | TUI grep tool respect for IntelliJ 3 | 4 | ## Requirements 5 | - [ripgrep](https://github.com/BurntSushi/ripgrep) 6 | - for fast grep 7 | - [bat](https://github.com/sharkdp/bat) 8 | - for beautiful preview 9 | 10 | ## Features 11 | - support HeadMatch(FirstMatch), WordMatch, Regex, FuzzySearch 12 | - preview surrounding code 13 | - auto resize 14 | - You can open the hit in the editor 15 | 16 | ## How to Work 17 | ![ilse](https://user-images.githubusercontent.com/31027514/107879359-b7992800-6f1b-11eb-9408-bea84deedafa.gif) 18 | 19 | ## How to Use 20 | ```command 21 | switch to WordMatch 22 | switch to HeadMatch 23 | switch to Regex 24 | switch to ripgrep 25 | switch to fuzzy search 26 | Toggle case sensitive 27 | specify search target directory visually 28 | clear search target directory 29 | clear your input 30 | ``` 31 | 32 | ## Flag 33 | ```bash 34 | ilse - ilse is TUI grep tool like IntelliJ 35 | 36 | Flags: 37 | --version Displays the program version string. 38 | -h --help Displays help with available flag, subcommand, and positional value parameters. 39 | -m --max-search-results Max number of search results (default: 100) 40 | -f --filter select filter ('rg', 'fuzzy') (default: rg) 41 | -fm --filter-mode select filter mode ('head', 'word', 'regex') (default: head) 42 | -t --preview-theme select bat theme for preview (default: OneHalfDark) 43 | -c --case case sensitive 44 | ``` 45 | 46 | ## Caution 47 | I intendedly ignore if only one letter. Because, it takes a lot of time, but it's of little value. 48 | -------------------------------------------------------------------------------- /cmd/ilse.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/integrii/flaggy" 7 | "github.com/tjmtmmnk/ilse" 8 | ) 9 | 10 | func initFlags(config *ilse.Config) { 11 | const ( 12 | name = "ilse" 13 | description = "ilse is TUI grep tool like IntelliJ" 14 | version = "0.1" 15 | ) 16 | flaggy.SetVersion(version) 17 | flaggy.SetName(name) 18 | flaggy.SetDescription(description) 19 | 20 | flaggy.Int(&config.MaxSearchResults, "m", "max-search-results", "Max number of search results") 21 | flaggy.String(&config.SearchCommand, "f", "filter", "select filter ('rg', 'fuzzy')") 22 | flaggy.String(&config.SearchMode, "fm", "filter-mode", "select filter mode ('head', 'word', 'regex')") 23 | flaggy.String(&config.Theme, "t", "preview-theme", "select bat theme for preview") 24 | flaggy.Bool(&config.CaseSensitive, "c", "case", "case sensitive") 25 | 26 | flaggy.Parse() 27 | 28 | } 29 | 30 | func main() { 31 | config, err := ilse.NewConfig() 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | initFlags(config) 36 | if err := ilse.Init(config); err != nil { 37 | log.Fatal(err) 38 | } 39 | if err := ilse.Run(); err != nil { 40 | log.Fatal(err) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package ilse 2 | 3 | import ( 4 | "github.com/tjmtmmnk/ilse/util" 5 | ) 6 | 7 | type Config struct { 8 | Theme string 9 | userWorkDir string 10 | homeDir string 11 | MaxSearchResults int 12 | SearchCommand string 13 | SearchMode string 14 | CaseSensitive bool 15 | } 16 | 17 | func NewConfig() (*Config, error) { 18 | userWorkDir, err := util.GetUserWorkDir() 19 | if err != nil { 20 | return nil, err 21 | } 22 | homeDir := util.GetHomeDir() 23 | 24 | return &Config{ 25 | Theme: "OneHalfDark", 26 | userWorkDir: userWorkDir, 27 | homeDir: homeDir, 28 | MaxSearchResults: 100, 29 | SearchCommand: "rg", 30 | SearchMode: "head", 31 | CaseSensitive: false, 32 | }, nil 33 | } 34 | -------------------------------------------------------------------------------- /file.go: -------------------------------------------------------------------------------- 1 | package ilse 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | 9 | "github.com/tjmtmmnk/ilse/util" 10 | ) 11 | 12 | func getEditorLineFlag() string { 13 | editor := os.Getenv("EDITORLINEFLAG") 14 | if len(editor) == 0 { 15 | editor = "+" 16 | } 17 | return editor 18 | } 19 | 20 | func getEditor() string { 21 | editor := os.Getenv("EDITOR") 22 | if len(editor) == 0 { 23 | editor = "vim" 24 | } 25 | return editor 26 | } 27 | 28 | func openFile(fileName string, lineNum int) { 29 | editor := getEditor() 30 | 31 | path := filepath.Join(conf.userWorkDir, fileName) 32 | if filepath.IsAbs(fileName) { 33 | path = fileName 34 | } 35 | lineFlag := fmt.Sprintf("%s%d", getEditorLineFlag(), lineNum) 36 | 37 | var cmd *exec.Cmd 38 | _, file := filepath.Split(editor) 39 | switch file { 40 | case "emacs", "emacsclient": 41 | // emacs expects the line before the file 42 | cmd = exec.Command(editor, lineFlag, path) 43 | default: 44 | // default to adding the line after the file 45 | cmd = exec.Command(editor, path, lineFlag) 46 | } 47 | 48 | cmd.Stdin = os.Stdin 49 | cmd.Stdout = os.Stdout 50 | cmd.Stderr = os.Stderr 51 | 52 | if err := cmd.Run(); err != nil { 53 | util.Logger.Error("can't open file") 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /filter/fuzzy.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io/fs" 7 | "io/ioutil" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "sync" 13 | 14 | "github.com/monochromegane/go-gitignore" 15 | "github.com/sahilm/fuzzy" 16 | "github.com/tjmtmmnk/ilse/util" 17 | ) 18 | 19 | func newFuzzySearch(option *SearchOption) *fuzzySearch { 20 | fuzzy := &fuzzySearch{} 21 | 22 | gitIgnorePath := filepath.Join(option.TargetDir, ".gitignore") 23 | 24 | if _, err := os.Stat(gitIgnorePath); !os.IsNotExist(err) { 25 | gitIgnore, err := gitignore.NewGitIgnore(gitIgnorePath) 26 | if err == nil { 27 | fuzzy.lookGitIgnore = true 28 | fuzzy.gitIgnore = gitIgnore 29 | } 30 | } 31 | return fuzzy 32 | } 33 | 34 | type fuzzySearch struct { 35 | cachedFile map[string]string 36 | gitIgnore gitignore.IgnoreMatcher 37 | lookGitIgnore bool 38 | } 39 | 40 | type file struct { 41 | name string 42 | text string 43 | } 44 | 45 | type fileSystem struct { 46 | fs.ReadDirFS 47 | } 48 | 49 | func (fs *fileSystem) Open(name string) (fs.File, error) { 50 | return os.Open(name) 51 | } 52 | 53 | func (fs *fileSystem) ReadDir(name string) ([]fs.DirEntry, error) { 54 | return os.ReadDir(name) 55 | } 56 | 57 | func (f *fuzzySearch) Search(q string, option *SearchOption) ([]SearchResult, error) { 58 | f.cachedFile = make(map[string]string, option.Limit) 59 | texts := make([]string, 0, option.Limit) 60 | results := make([]SearchResult, 0, option.Limit) 61 | isDuplicateLine := make(map[string]bool, option.Limit) 62 | 63 | if option.TargetDir == "" { 64 | option.TargetDir = "." 65 | } 66 | 67 | files, err := f.getFiles(option.TargetDir, option.Limit) 68 | if err != nil { 69 | util.Logger.Warn("get file error : ", err) 70 | return nil, err 71 | } 72 | 73 | for _, file := range files { 74 | texts = append(texts, file.text) 75 | } 76 | 77 | matches := fuzzy.Find(q, texts) 78 | for _, m := range matches { 79 | for _, idx := range f.reIndex(m.MatchedIndexes, len(q)) { 80 | if len(files) <= m.Index { 81 | continue 82 | } 83 | fileName := files[m.Index].name 84 | lineNum, text := f.getLine(files[m.Index].name, idx+len(q)) 85 | if text == "" { 86 | continue 87 | } 88 | key := fmt.Sprintf("%s%d", fileName, lineNum) 89 | if isDuplicateLine[key] { 90 | continue 91 | } 92 | isDuplicateLine[key] = true 93 | results = append(results, SearchResult{fileName, lineNum, text}) 94 | } 95 | } 96 | 97 | return results, nil 98 | } 99 | 100 | func (f *fuzzySearch) getLine(fileName string, pos int) (int, string) { 101 | var text string 102 | if f.cachedFile[fileName] != "" { 103 | text = f.cachedFile[fileName] 104 | } else { 105 | content, _ := ioutil.ReadFile(fileName) 106 | text = string(content) 107 | f.cachedFile[fileName] = text 108 | } 109 | to := pos 110 | if to > len(text) { 111 | to = len(text) - 1 112 | } 113 | lineNum := 1 114 | 115 | var sb strings.Builder 116 | sb.Grow(to) 117 | for _, c := range text[:to] { 118 | sb.WriteRune(c) 119 | 120 | if c == '\n' { 121 | lineNum++ 122 | sb.Reset() 123 | } 124 | } 125 | return lineNum, sb.String() 126 | } 127 | 128 | func (f *fuzzySearch) getFiles(dir string, limit int) ([]file, error) { 129 | var ( 130 | wg sync.WaitGroup 131 | mu sync.Mutex 132 | ) 133 | files := make([]file, 0, limit*2) 134 | 135 | fileNames, err := f.getFileNames(dir, limit) 136 | if err != nil { 137 | util.Logger.Warn("get file name error : ", err) 138 | return nil, err 139 | } 140 | 141 | for _, name := range fileNames { 142 | wg.Add(1) 143 | go func(name string) { 144 | defer wg.Done() 145 | var sb strings.Builder 146 | sb.Grow(100) 147 | fp, err := os.Open(name) 148 | if err != nil { 149 | util.Logger.Warn("file open error : ", err) 150 | return 151 | } 152 | defer fp.Close() 153 | 154 | scanner := bufio.NewScanner(fp) 155 | 156 | for scanner.Scan() { 157 | if scanner.Err() != nil { 158 | util.Logger.Warn("scan error : ", err) 159 | return 160 | } 161 | if scanner.Text() != "" { 162 | sb.WriteString(scanner.Text()) 163 | } 164 | } 165 | text := sb.String() 166 | mimeType := http.DetectContentType([]byte(text)) 167 | isBinary := !strings.HasPrefix(mimeType, "text/plain") 168 | if isBinary { 169 | return 170 | } 171 | 172 | mu.Lock() 173 | defer mu.Unlock() 174 | files = append(files, file{name, text}) 175 | }(name) 176 | } 177 | wg.Wait() 178 | return files, nil 179 | } 180 | 181 | func (f *fuzzySearch) reIndex(indexes []int, queryLength int) []int { 182 | if len(indexes) == 0 { 183 | return []int{} 184 | } 185 | baseIdx := indexes[0] 186 | reIndexes := []int{baseIdx} 187 | for _, idx := range indexes { 188 | isSameWord := idx <= baseIdx+queryLength 189 | if isSameWord { 190 | continue 191 | } 192 | reIndexes = append(reIndexes, idx) 193 | baseIdx = idx 194 | } 195 | return reIndexes 196 | } 197 | 198 | func (f *fuzzySearch) getFileNames(root string, limit int) ([]string, error) { 199 | fsm := &fileSystem{} 200 | fileNames := make([]string, 0, limit*2) 201 | err := fs.WalkDir(fsm, root, func(path string, d fs.DirEntry, err error) error { 202 | util.Logger.Debug(path) 203 | if f.lookGitIgnore && f.gitIgnore.Match(path, d.IsDir()) { 204 | if d.IsDir() { 205 | return fs.SkipDir 206 | } 207 | return nil 208 | } 209 | 210 | // skip hidden directory or file 211 | if strings.HasPrefix(d.Name(), ".") { 212 | if d.IsDir() { 213 | return fs.SkipDir 214 | } 215 | return nil 216 | } 217 | 218 | if d.IsDir() { 219 | return nil 220 | } 221 | 222 | info, err := d.Info() 223 | if err != nil { 224 | util.Logger.Warn("file info error : ", err) 225 | return nil 226 | } 227 | 228 | // skip over 1MB file 229 | if info.Size() > 1000000 { 230 | return nil 231 | } 232 | 233 | fileNames = append(fileNames, path) 234 | return nil 235 | }) 236 | if err != nil { 237 | util.Logger.Warn("walk error : ", err) 238 | return []string{}, err 239 | } 240 | return fileNames, nil 241 | } 242 | -------------------------------------------------------------------------------- /filter/ripgrep.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "errors" 7 | "os/exec" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/tjmtmmnk/ilse/util" 13 | ) 14 | 15 | const ( 16 | timeout = 150 * time.Millisecond 17 | ) 18 | 19 | type rg struct{} 20 | 21 | func newRg() *rg { 22 | return &rg{} 23 | } 24 | 25 | func isValidQuery(q string) bool { 26 | return q != "" 27 | } 28 | 29 | func isValidRegex(q string) bool { 30 | return true 31 | } 32 | 33 | func convert(str string, option *SearchOption) (*SearchResult, error) { 34 | // first remove reset flag included in path, line 35 | str = strings.Replace(str, "\x1b[0m", "", 4) 36 | splitted := strings.Split(str, ":") 37 | if len(splitted) < 3 { 38 | return nil, nil 39 | } 40 | 41 | fileName := splitted[0] 42 | lineNum, err := strconv.Atoi(splitted[1]) 43 | if err != nil { 44 | return nil, errors.New("line number wrong format") 45 | } 46 | // change reset flag included in text to black foreground 47 | text := strings.ReplaceAll(splitted[2], "\x1b[0m", "\x1b[39;40m") 48 | return &SearchResult{fileName, lineNum, text}, nil 49 | } 50 | 51 | func (r *rg) Search(q string, option *SearchOption) ([]SearchResult, error) { 52 | if !isValidQuery(q) { 53 | return []SearchResult{}, nil 54 | } 55 | 56 | cmd := []string{ 57 | "rg", "--color=always", "--line-number", "--with-filename", 58 | "--colors=path:none", "--colors=line:none", 59 | } 60 | 61 | switch option.Mode { 62 | case Regex: 63 | if isValidRegex(q) { 64 | cmd = append(cmd, "-e") 65 | } else { 66 | return []SearchResult{}, nil 67 | } 68 | case HeadMatch: 69 | case WordMatch: 70 | cmd = append(cmd, "-w") 71 | } 72 | if option.Mode != Regex && !option.Case { 73 | cmd = append(cmd, "-i") 74 | } 75 | 76 | cmd = append(cmd, q) 77 | 78 | if option.TargetDir != "" { 79 | cmd = append(cmd, option.TargetDir) 80 | } 81 | 82 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 83 | defer cancel() 84 | ecmd := exec.CommandContext(ctx, cmd[0], cmd[1:]...) 85 | stdout, err := ecmd.StdoutPipe() 86 | 87 | if err != nil { 88 | util.Logger.Warn("command exec stdout pipe error : ", err) 89 | return []SearchResult{}, err 90 | } 91 | 92 | if err := ecmd.Start(); err != nil { 93 | util.Logger.Warn("command exec start error : ", err) 94 | return []SearchResult{}, err 95 | } 96 | 97 | results := make([]SearchResult, 0, option.Limit) 98 | 99 | scanner := bufio.NewScanner(stdout) 100 | for scanner.Scan() { 101 | if len(results) > option.Limit { 102 | break 103 | } 104 | result, err := convert(scanner.Text(), option) 105 | if err != nil { 106 | continue 107 | } 108 | if result != nil { 109 | results = append(results, *result) 110 | } 111 | } 112 | if err := ecmd.Wait(); err != nil { 113 | util.Logger.Warn("command exec wait error : ", err) 114 | } 115 | 116 | if ctx.Err() == context.DeadlineExceeded { 117 | util.Logger.Warn("Timeout") 118 | } 119 | 120 | return results, nil 121 | } 122 | -------------------------------------------------------------------------------- /filter/search.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import "errors" 4 | 5 | type SearchResult struct { 6 | FileName string 7 | LineNum int 8 | Text string 9 | } 10 | 11 | type SearchOption struct { 12 | Command SearchCommand 13 | Mode SearchMode 14 | Case bool 15 | TargetDir string 16 | Limit int 17 | } 18 | 19 | type SearchMode int 20 | type SearchCommand int 21 | 22 | type Filter interface { 23 | Search(string, *SearchOption) ([]SearchResult, error) 24 | } 25 | 26 | const ( 27 | Regex SearchMode = iota 28 | HeadMatch 29 | WordMatch 30 | NoneMode 31 | ) 32 | 33 | const ( 34 | RipGrep SearchCommand = iota 35 | FuzzySearch 36 | NoneCommand 37 | ) 38 | 39 | func NewFilter(option *SearchOption) Filter { 40 | switch option.Command { 41 | case RipGrep: 42 | return newRg() 43 | case FuzzySearch: 44 | return newFuzzySearch(option) 45 | default: 46 | return newRg() 47 | } 48 | } 49 | 50 | func CommandByName(v string) (SearchCommand, error) { 51 | switch v { 52 | case "rg": 53 | return RipGrep, nil 54 | case "fuzzy": 55 | return FuzzySearch, nil 56 | default: 57 | return NoneCommand, errors.New("seach command name unmatch") 58 | } 59 | } 60 | 61 | func ModeByName(v string) (SearchMode, error) { 62 | switch v { 63 | case "head": 64 | return HeadMatch, nil 65 | case "word": 66 | return WordMatch, nil 67 | case "regex": 68 | return Regex, nil 69 | default: 70 | return NoneMode, errors.New("search mode name unmatch") 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /frame.go: -------------------------------------------------------------------------------- 1 | package ilse 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | "github.com/rivo/tview" 6 | "github.com/tjmtmmnk/ilse/filter" 7 | ) 8 | 9 | var ( 10 | frame *tview.Application 11 | ) 12 | 13 | func initFrame() error { 14 | if err := initPages(); err != nil { 15 | return err 16 | } 17 | frame = tview.NewApplication().SetScreen(app.screen) 18 | frame.SetRoot(pages, true).EnableMouse(true) 19 | 20 | frame.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 21 | currentPage, _ := pages.GetFrontPage() 22 | if currentPage != mainPage { 23 | return event 24 | } 25 | switch key := event.Key(); { 26 | case key == tcell.KeyRune || key == tcell.KeyBackspace || key == tcell.KeyBackspace2: 27 | frame.SetFocus(searchBar) 28 | case key == tcell.KeyDown && searchBar.HasFocus(): 29 | frame.SetFocus(list) 30 | case key == tcell.KeyCtrlW: 31 | app.searchOption.Mode = filter.WordMatch 32 | updateSearchBarHeader() 33 | case key == tcell.KeyCtrlE: 34 | app.searchOption.Mode = filter.HeadMatch 35 | updateSearchBarHeader() 36 | case key == tcell.KeyCtrlT: 37 | app.searchOption.Case = !app.searchOption.Case 38 | updateSearchBarHeader() 39 | case key == tcell.KeyCtrlR: 40 | app.searchOption.Mode = filter.Regex 41 | updateSearchBarHeader() 42 | case key == tcell.KeyCtrlG: 43 | app.searchOption.Command = filter.RipGrep 44 | updateSearchBarHeader() 45 | case key == tcell.KeyCtrlF: 46 | app.searchOption.Command = filter.FuzzySearch 47 | updateSearchBarHeader() 48 | case key == tcell.KeyCtrlD: 49 | pages.SwitchToPage("tree") 50 | case key == tcell.KeyCtrlN: 51 | app.searchOption.TargetDir = "" 52 | case key == tcell.KeyCtrlB: 53 | clearAll() 54 | } 55 | return event 56 | }) 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tjmtmmnk/ilse 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/gdamore/tcell/v2 v2.2.0 7 | github.com/integrii/flaggy v1.4.4 8 | github.com/kylelemons/godebug v1.1.0 // indirect 9 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 10 | github.com/rivo/tview v0.0.0-20210215180505-b1efc6d8c1b8 11 | github.com/sahilm/fuzzy v0.1.0 12 | ) 13 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= 2 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 3 | github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU= 4 | github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0= 5 | github.com/gdamore/tcell/v2 v2.0.1-0.20201017141208-acf90d56d591/go.mod h1:vSVL/GV5mCSlPC6thFP5kfOFdM9MGZcalipmpTxTgQA= 6 | github.com/gdamore/tcell/v2 v2.2.0 h1:vSyEgKwraXPSOkvCk7IwOSyX+Pv3V2cV9CikJMXg4U4= 7 | github.com/gdamore/tcell/v2 v2.2.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU= 8 | github.com/integrii/flaggy v1.4.4 h1:8fGyiC14o0kxhTqm2VBoN19fDKPZsKipP7yggreTMDc= 9 | github.com/integrii/flaggy v1.4.4/go.mod h1:tnTxHeTJbah0gQ6/K0RW0J7fMUBk9MCF5blhm43LNpI= 10 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 11 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 12 | github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= 13 | github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 14 | github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 15 | github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg= 16 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 17 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= 18 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= 19 | github.com/rivo/tview v0.0.0-20210215180505-b1efc6d8c1b8 h1:bEm0+lS2TTbv6N32lOq6nPROteRzVjYph0pZhhHo7zk= 20 | github.com/rivo/tview v0.0.0-20210215180505-b1efc6d8c1b8/go.mod h1:1QW7hX7RQzOqyGgx8O64bRPQBrFtPflioPPX5gFPV3A= 21 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 22 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 23 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 24 | github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= 25 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 26 | golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 27 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 28 | golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78 h1:nVuTkr9L6Bq62qpUqKo/RnZCFfzDBL0bYo6w9OJUqZY= 29 | golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 30 | golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= 31 | golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 32 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 33 | golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ= 34 | golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 35 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 36 | -------------------------------------------------------------------------------- /ilse.go: -------------------------------------------------------------------------------- 1 | package ilse 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/gdamore/tcell/v2" 7 | "github.com/tjmtmmnk/ilse/filter" 8 | ) 9 | 10 | type ilse struct { 11 | state *state 12 | searchOption *filter.SearchOption 13 | screen tcell.Screen 14 | } 15 | 16 | var ( 17 | app *ilse 18 | conf *Config 19 | ) 20 | 21 | func initApp() error { 22 | screen, err := tcell.NewScreen() 23 | if err != nil { 24 | return err 25 | } 26 | if err := screen.Init(); err != nil { 27 | return err 28 | } 29 | 30 | state := newState() 31 | 32 | os.MkdirAll(conf.homeDir, 0766) 33 | 34 | command, err := filter.CommandByName(conf.SearchCommand) 35 | if err != nil { 36 | return err 37 | } 38 | mode, err := filter.ModeByName(conf.SearchMode) 39 | if err != nil { 40 | return err 41 | } 42 | searchOption := &filter.SearchOption{ 43 | Command: command, 44 | Mode: mode, 45 | Case: conf.CaseSensitive, 46 | Limit: conf.MaxSearchResults, 47 | TargetDir: conf.userWorkDir, 48 | } 49 | 50 | app = &ilse{ 51 | screen: screen, 52 | state: state, 53 | searchOption: searchOption, 54 | } 55 | return nil 56 | } 57 | 58 | func Init(cfg *Config) error { 59 | conf = cfg 60 | 61 | if err := initApp(); err != nil { 62 | return err 63 | } 64 | if err := initFrame(); err != nil { 65 | return err 66 | } 67 | 68 | return nil 69 | } 70 | 71 | func Run() error { 72 | return frame.Run() 73 | } 74 | -------------------------------------------------------------------------------- /layout.go: -------------------------------------------------------------------------------- 1 | package ilse 2 | 3 | import ( 4 | "github.com/rivo/tview" 5 | ) 6 | 7 | var ( 8 | mainLayout *tview.Flex 9 | ) 10 | 11 | func initLayout() { 12 | initSearchBar() 13 | initList() 14 | mainLayout = tview.NewFlex().SetDirection(tview.FlexRow). 15 | AddItem(searchBar, 0, 3, true).SetDirection(tview.FlexRow). 16 | AddItem(tview.NewFlex().SetDirection(tview.FlexColumn). 17 | AddItem(list, 0, 1, false). 18 | AddItem(preview, 0, 1, false), 0, 30, false) 19 | } 20 | -------------------------------------------------------------------------------- /list.go: -------------------------------------------------------------------------------- 1 | package ilse 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gdamore/tcell/v2" 7 | "github.com/rivo/tview" 8 | "github.com/tjmtmmnk/ilse/filter" 9 | "github.com/tjmtmmnk/ilse/util" 10 | ) 11 | 12 | var ( 13 | list *tview.List 14 | ) 15 | 16 | func initList() { 17 | initPreview() 18 | list = tview.NewList().ShowSecondaryText(false) 19 | 20 | list.SetBackgroundColor(tcell.ColorBlack) 21 | 22 | list.SetChangedFunc(func(index int, mainText string, secondaryText string, shortcut rune) { 23 | if index >= len(app.state.matched) { 24 | return 25 | } 26 | item := app.state.matched[index] 27 | 28 | text, err := getPreviewContent(item) 29 | if err != nil { 30 | util.Logger.Error("fail to fetch preview content : %v", err) 31 | } 32 | preview.SetText(text) 33 | }) 34 | 35 | list.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) { 36 | item := app.state.matched[index] 37 | if err := app.screen.Suspend(); err != nil { 38 | util.Logger.Error("failed to suspend: " + err.Error()) 39 | } 40 | openFile(item.FileName, item.LineNum) 41 | if err := app.screen.Resume(); err != nil { 42 | util.Logger.Error("failed to resume: " + err.Error()) 43 | } 44 | }) 45 | 46 | list.SetDoneFunc(func() { 47 | frame.SetFocus(searchBar) 48 | }) 49 | } 50 | 51 | func convertToListItems(results []filter.SearchResult) []string { 52 | var items []string 53 | for _, r := range results { 54 | item := fmt.Sprintf("[purple:black:-]%s[-]:[green]%d[-] %s[-:black]", util.ShortFileName(r.FileName), r.LineNum, r.Text) 55 | items = append(items, item) 56 | } 57 | return items 58 | } 59 | 60 | func updateList(items []string) { 61 | list.Clear() 62 | for _, item := range items { 63 | text := tview.TranslateANSI(item) 64 | list.AddItem(text, "", 0, nil) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /pages.go: -------------------------------------------------------------------------------- 1 | package ilse 2 | 3 | import ( 4 | "github.com/rivo/tview" 5 | ) 6 | 7 | var ( 8 | pages *tview.Pages 9 | mainPage = "main" 10 | treePage = "tree" 11 | ) 12 | 13 | func initPages() error { 14 | initLayout() 15 | if err := initTree(); err != nil { 16 | return err 17 | } 18 | pages = tview.NewPages(). 19 | AddPage(mainPage, mainLayout, true, true). 20 | AddPage(treePage, tree, true, false) 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /preview.go: -------------------------------------------------------------------------------- 1 | package ilse 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | "github.com/tjmtmmnk/ilse/filter" 12 | ) 13 | 14 | var ( 15 | preview *tview.TextView 16 | ) 17 | 18 | func initPreview() { 19 | preview = tview.NewTextView(). 20 | SetDynamicColors(true). 21 | SetScrollable(true) 22 | 23 | preview.SetBackgroundColor(tcell.ColorBlack) 24 | } 25 | 26 | func getPreviewContent(item filter.SearchResult) (string, error) { 27 | _, h := app.screen.Size() 28 | from := item.LineNum - (h/2 - 1) 29 | if from < 0 { 30 | from = 0 31 | } 32 | to := item.LineNum + (h/2 - 1) 33 | lineRange := fmt.Sprintf("%d:%d", from, to) 34 | cmd := []string{"bat", "--line-range", lineRange, "--highlight-line", strconv.Itoa(item.LineNum), "--color=always", "--theme", conf.Theme, "--style=numbers,changes", item.FileName} 35 | 36 | out, err := exec.Command(cmd[0], cmd[1:]...).Output() 37 | text := string(out) 38 | text = strings.ReplaceAll(text, "\x1b[0m", "\x1b[39;40m") 39 | return tview.TranslateANSI(text), err 40 | } 41 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /search_bar.go: -------------------------------------------------------------------------------- 1 | package ilse 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/rivo/tview" 9 | "github.com/tjmtmmnk/ilse/filter" 10 | "github.com/tjmtmmnk/ilse/util" 11 | ) 12 | 13 | var ( 14 | searchBar *tview.InputField 15 | ) 16 | 17 | func searchBarHeader() string { 18 | var sb strings.Builder 19 | sb.Grow(5) 20 | 21 | switch app.searchOption.Command { 22 | case filter.RipGrep: 23 | sb.WriteString("Rg") 24 | case filter.FuzzySearch: 25 | sb.WriteString("Fs") 26 | } 27 | 28 | if app.searchOption.Command == filter.RipGrep { 29 | sb.WriteString("|") 30 | switch opt := app.searchOption; { 31 | case opt.Mode == filter.HeadMatch: 32 | sb.WriteString("HM") 33 | case opt.Mode == filter.WordMatch: 34 | sb.WriteString("WM") 35 | case opt.Mode == filter.Regex: 36 | sb.WriteString("Re") 37 | } 38 | 39 | if app.searchOption.Mode != filter.Regex && app.searchOption.Case { 40 | sb.WriteString(",C") 41 | } 42 | } 43 | 44 | isOverMax := len(app.state.matched) > conf.MaxSearchResults 45 | 46 | if isOverMax { 47 | sb.WriteString(" " + fmt.Sprintf("(%d+)", conf.MaxSearchResults)) 48 | } 49 | header := fmt.Sprintf("(%s) >>> ", sb.String()) 50 | 51 | return header 52 | } 53 | 54 | func updateSearchBarHeader() { 55 | searchBar.SetLabel(searchBarHeader()) 56 | } 57 | 58 | func clearResult() { 59 | list.Clear() 60 | preview.Clear() 61 | app.state.mutex.Lock() 62 | app.state.matched = []filter.SearchResult{} 63 | app.state.mutex.Unlock() 64 | updateSearchBarHeader() 65 | } 66 | 67 | func clearAll() { 68 | searchBar.SetText("") 69 | clearResult() 70 | } 71 | 72 | func initSearchBar() { 73 | searchBar = tview.NewInputField(). 74 | SetLabel(searchBarHeader()). 75 | SetFieldBackgroundColor(tcell.ColorBlack) 76 | 77 | searchBar.SetBackgroundColor(tcell.ColorBlack) 78 | 79 | searchBar.SetChangedFunc(func(text string) { 80 | if len(text) < 2 { 81 | clearResult() 82 | return 83 | } 84 | ftr := filter.NewFilter(app.searchOption) 85 | results, err := ftr.Search(text, app.searchOption) 86 | if err != nil { 87 | util.Logger.Error("search error : %v", err) 88 | } 89 | app.state.mutex.Lock() 90 | app.state.matched = results 91 | app.state.mutex.Unlock() 92 | updateSearchBarHeader() 93 | 94 | if len(results) > 0 { 95 | last := len(results) 96 | if len(results) > conf.MaxSearchResults { 97 | last = conf.MaxSearchResults 98 | } 99 | items := convertToListItems(results[0:last]) 100 | updateList(items) 101 | } else { 102 | clearResult() 103 | } 104 | }) 105 | 106 | searchBar.SetDoneFunc(func(key tcell.Key) { 107 | switch key { 108 | case tcell.KeyEnter: 109 | frame.SetFocus(list) 110 | case tcell.KeyEsc: 111 | frame.Stop() 112 | } 113 | }) 114 | } 115 | -------------------------------------------------------------------------------- /state.go: -------------------------------------------------------------------------------- 1 | package ilse 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/tjmtmmnk/ilse/filter" 7 | ) 8 | 9 | type state struct { 10 | mutex sync.RWMutex 11 | currentPage string 12 | matched []filter.SearchResult 13 | fileCache map[string][]string 14 | } 15 | 16 | func newState() *state { 17 | return &state{} 18 | } 19 | -------------------------------------------------------------------------------- /tree.go: -------------------------------------------------------------------------------- 1 | package ilse 2 | 3 | import ( 4 | "io/ioutil" 5 | "path/filepath" 6 | 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/rivo/tview" 9 | "github.com/tjmtmmnk/ilse/util" 10 | ) 11 | 12 | var ( 13 | tree *tview.TreeView 14 | ) 15 | 16 | func initTree() error { 17 | rootDir, err := util.GetUserWorkDir() 18 | if err != nil { 19 | return err 20 | } 21 | 22 | root := tview.NewTreeNode(rootDir). 23 | SetColor(tcell.ColorRed) 24 | 25 | addNode(root, rootDir) 26 | 27 | tree = tview.NewTreeView(). 28 | SetRoot(root). 29 | SetCurrentNode(root) 30 | 31 | tree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 32 | currentPage, _ := pages.GetFrontPage() 33 | if currentPage != treePage { 34 | return event 35 | } 36 | if event.Key() == tcell.KeyRight || event.Rune() == 'l' { 37 | if err := expand(tree.GetCurrentNode()); err != nil { 38 | util.Logger.Error("expand error : %v", err) 39 | } 40 | } 41 | return event 42 | }) 43 | 44 | tree.SetSelectedFunc(func(node *tview.TreeNode) { 45 | reference := node.GetReference() 46 | path := rootDir 47 | if reference != nil { 48 | path = reference.(string) 49 | } 50 | app.searchOption.TargetDir = path 51 | pages.SwitchToPage(mainPage) 52 | }) 53 | 54 | tree.SetDoneFunc(func(key tcell.Key) { 55 | pages.SwitchToPage(mainPage) 56 | }) 57 | 58 | return nil 59 | } 60 | 61 | func addNode(target *tview.TreeNode, path string) error { 62 | files, err := ioutil.ReadDir(path) 63 | if err != nil { 64 | return err 65 | } 66 | for _, file := range files { 67 | node := tview.NewTreeNode(file.Name()). 68 | SetReference(filepath.Join(path, file.Name())). 69 | SetSelectable(file.IsDir()) 70 | if file.IsDir() { 71 | node.SetColor(tcell.ColorGreen) 72 | } 73 | target.AddChild(node) 74 | } 75 | return nil 76 | } 77 | 78 | func expand(node *tview.TreeNode) error { 79 | reference := node.GetReference() 80 | if reference == nil { 81 | return nil 82 | } 83 | children := node.GetChildren() 84 | if len(children) == 0 { 85 | path := reference.(string) 86 | if err := addNode(node, path); err != nil { 87 | return err 88 | } 89 | } else { 90 | node.SetExpanded(!node.IsExpanded()) 91 | } 92 | return nil 93 | } 94 | -------------------------------------------------------------------------------- /util/logger.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | ) 8 | 9 | const ( 10 | PREFIX_INFO = "\033[39;49m\033[32mINFO\t" 11 | PREFIX_WARN = "\033[39;49m\033[33mWARN\t" 12 | PREFIX_ERROR = "\033[39;49m\033[31mERROR\t" 13 | PREFIX_DEBUG = "\033[39;49m\033[36mDEBUG\t" 14 | ) 15 | 16 | type PrintLevel int 17 | 18 | const ( 19 | Info PrintLevel = iota 20 | Warn 21 | Error 22 | Debug 23 | ) 24 | 25 | type logger struct { 26 | *log.Logger 27 | } 28 | 29 | var Logger = newLogger() 30 | 31 | func newLogger() *logger { 32 | path := fmt.Sprintf("%s/log.txt", GetHomeDir()) 33 | file, _ := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0664) 34 | l := log.New(file, "", log.Ldate|log.Ltime|log.Lshortfile) 35 | 36 | return &logger{ 37 | l, 38 | } 39 | } 40 | 41 | func (l *logger) Info(v ...interface{}) { 42 | l.Print(PREFIX_INFO, v, "\033[39;49m\n") 43 | } 44 | 45 | func (l *logger) Warn(v ...interface{}) { 46 | l.Print(PREFIX_WARN, v, "\033[39;49m\n") 47 | } 48 | 49 | func (l *logger) Error(v ...interface{}) { 50 | l.Print(PREFIX_ERROR, v, "\033[39;49m\n") 51 | os.Exit(0) 52 | } 53 | 54 | func (l *logger) Debug(v ...interface{}) { 55 | l.Print(PREFIX_DEBUG, v, "\033[39;49m\n") 56 | } 57 | -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | ) 9 | 10 | func ShortFileName(fileName string) string { 11 | sp := strings.Split(fileName, "/") 12 | if len(sp) == 1 { 13 | return sp[0] 14 | } else if len(sp) == 2 { 15 | return fmt.Sprintf("%c/%s", sp[0][0], sp[1]) 16 | } else { 17 | last := len(sp) - 1 18 | return fmt.Sprintf("%c/%c/%s", sp[last-2][0], sp[last-1][0], sp[last]) 19 | } 20 | } 21 | 22 | func GetHomeDir() string { 23 | userHomeDir, _ := os.UserHomeDir() 24 | homeDir := fmt.Sprintf("%s/.ilse", userHomeDir) 25 | return homeDir 26 | } 27 | 28 | // if use git, return repository 29 | // else return current directory 30 | func GetUserWorkDir() (string, error) { 31 | isManaged, err := isManagedByGit() 32 | if err != nil { 33 | return ".", nil 34 | } 35 | if isManaged { 36 | repo, err := getGitRepository() 37 | if err != nil { 38 | return ".", err 39 | } 40 | if repo != "" { 41 | return repo, nil 42 | } 43 | } 44 | return ".", nil 45 | } 46 | 47 | func getGitRepository() (string, error) { 48 | cmd := []string{"git", "rev-parse", "--show-toplevel"} 49 | out, err := exec.Command(cmd[0], cmd[1:]...).Output() 50 | if err != nil { 51 | return "", err 52 | } 53 | repo := string(out) 54 | return strings.TrimRight(repo, "\r\n"), nil 55 | } 56 | 57 | func isManagedByGit() (bool, error) { 58 | cmd := []string{"git", "rev-parse", "--all"} 59 | out, err := exec.Command(cmd[0], cmd[1:]...).Output() 60 | if err != nil { 61 | return false, err 62 | } 63 | isManaged := len(out) != 0 64 | return isManaged, nil 65 | } 66 | --------------------------------------------------------------------------------