├── .github ├── dependabot.yml └── workflows │ └── release.yml ├── .gitignore ├── .goreleaser ├── darwin.yaml ├── linux.yaml ├── release.yaml └── windows.yaml ├── LICENSE.md ├── README.md ├── assets └── demo.gif ├── go.mod ├── go.sum ├── jq └── jq.go ├── main.go ├── message ├── fatalerror.go ├── parsedfile.go └── queryresult.go ├── model ├── init.go ├── keys.go ├── model.go ├── update.go └── view.go └── util ├── clipboard.go ├── math.go ├── ptr.go └── util.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "gomod" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | 10 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build-darwin: 13 | runs-on: macos-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - uses: actions/setup-go@v5 19 | with: 20 | go-version: 1.21.5 21 | - name: Build 22 | uses: goreleaser/goreleaser-action@v5 23 | with: 24 | args: release --skip=announce,publish --config .goreleaser/darwin.yaml 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} 27 | - name: Upload artifacts 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: jqless-darwin 31 | path: dist/jqless* 32 | 33 | build-linux: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | with: 38 | fetch-depth: 0 39 | - uses: actions/setup-go@v5 40 | with: 41 | go-version: 1.21.5 42 | - name: Install cross-compiler libraries 43 | run: sudo apt-get install -y gcc-aarch64-linux-gnu 44 | - name: Build 45 | uses: goreleaser/goreleaser-action@v5 46 | with: 47 | args: release --skip=announce,publish --config .goreleaser/linux.yaml 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} 50 | - name: Upload artifacts 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: jqless-linux 54 | path: dist/jqless* 55 | 56 | build-windows: 57 | runs-on: windows-latest 58 | steps: 59 | - uses: actions/checkout@v4 60 | with: 61 | fetch-depth: 0 62 | - uses: actions/setup-go@v5 63 | with: 64 | go-version: 1.21.5 65 | - name: Build 66 | uses: goreleaser/goreleaser-action@v5 67 | with: 68 | args: release --skip=announce,publish --config .goreleaser/windows.yaml 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} 71 | - name: Upload artifacts 72 | uses: actions/upload-artifact@v4 73 | with: 74 | name: jqless-windows 75 | path: dist/jqless* 76 | 77 | release: 78 | needs: [build-darwin, build-linux, build-windows] 79 | runs-on: ubuntu-latest 80 | steps: 81 | - uses: actions/checkout@v4 82 | with: 83 | fetch-depth: 0 84 | - uses: actions/setup-go@v5 85 | with: 86 | go-version: 1.21.5 87 | - name: Make directory 88 | run: | 89 | mkdir -p ./jqless-build/darwin 90 | mkdir -p ./jqless-build/linux 91 | mkdir -p ./jqless-build/windows 92 | - name: Download darwin binaries 93 | uses: actions/download-artifact@v4 94 | with: 95 | name: jqless-darwin 96 | path: ./jqless-build/darwin 97 | - name: Download linux binaries 98 | uses: actions/download-artifact@v4 99 | with: 100 | name: jqless-linux 101 | path: ./jqless-build/linux 102 | - name: Download windows binaries 103 | uses: actions/download-artifact@v4 104 | with: 105 | name: jqless-windows 106 | path: ./jqless-build/windows 107 | - name: Merge checksum files 108 | run: | 109 | cd ./jqless-build 110 | cat ./darwin/jqless*checksums.txt >> checksums.txt 111 | cat ./linux/jqless*checksums.txt >> checksums.txt 112 | cat ./windows/jqless*checksums.txt >> checksums.txt 113 | rm ./darwin/jqless*checksums.txt 114 | rm ./linux/jqless*checksums.txt 115 | rm ./windows/jqless*checksums.txt 116 | - name: Release 117 | uses: goreleaser/goreleaser-action@v5 118 | with: 119 | args: release --config .goreleaser/release.yaml 120 | env: 121 | GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} 122 | - name: Upload checksum 123 | uses: actions/upload-artifact@v4 124 | with: 125 | name: release-checksums 126 | path: jqless-build/checksums.txt 127 | 128 | push: 129 | needs: [release] 130 | runs-on: ubuntu-latest 131 | steps: 132 | - uses: actions/checkout@v4 133 | with: 134 | repository: samsullivan/homebrew-samsullivan 135 | ref: 'main' 136 | token: ${{ secrets.RELEASE_GITHUB_TOKEN }} 137 | - name: Setup git config 138 | run: | 139 | git config user.name "GitHub Actions Bot (jqless)" 140 | git config user.email "<>" 141 | - name: Download release checksums 142 | uses: actions/download-artifact@v4 143 | with: 144 | name: release-checksums 145 | path: ./checksums.txt 146 | - name: Make changes to jqless homebrew file 147 | run: | 148 | cp jqless.rb.template jqless.rb 149 | echo ${{ github.event.release.tag_name }} | sed 's/v//' |\ 150 | xargs -I{} sed -i -e 's/%VERSION%/{}/g' jqless.rb 151 | grep Darwin_all checksums.txt | awk '{print $1}' |\ 152 | xargs -I{} sed -i -e 's/%DARWIN_SHA%/{}/g' jqless.rb 153 | grep Linux_arm checksums.txt | awk '{print $1}' |\ 154 | xargs -I{} sed -i -e 's/%LINUX_ARM_SHA%/{}/g' jqless.rb 155 | grep Linux_x86_64 checksums.txt | awk '{print $1}' |\ 156 | xargs -I{} sed -i -e 's/%LINUX_INTEL_SHA%/{}/g' jqless.rb 157 | rm checksums.txt 158 | git add jqless.rb 159 | git commit -m "" 160 | - run: git push origin main -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Go workspace file 15 | go.work 16 | 17 | # goreleaser 18 | dist/ 19 | jqless-build/ -------------------------------------------------------------------------------- /.goreleaser/darwin.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - env: 9 | - CGO_ENABLED=1 10 | goos: 11 | - darwin 12 | 13 | universal_binaries: 14 | - replace: true 15 | 16 | archives: 17 | - format: tar.gz 18 | # this name template makes the OS and Arch compatible with the results of `uname`. 19 | name_template: >- 20 | {{ .ProjectName }}_ 21 | {{- title .Os }}_ 22 | {{- if eq .Arch "amd64" }}x86_64 23 | {{- else if eq .Arch "386" }}i386 24 | {{- else }}{{ .Arch }}{{ end }} 25 | {{- if .Arm }}v{{ .Arm }}{{ end }} -------------------------------------------------------------------------------- /.goreleaser/linux.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - env: 9 | - CGO_ENABLED=1 10 | goos: 11 | - linux 12 | goarch: # TODO: ideally remove this and support cross-compiling all architectures 13 | - arm64 14 | - amd64 15 | overrides: 16 | - goos: linux 17 | goarch: arm64 18 | env: 19 | - CC=aarch64-linux-gnu-gcc 20 | - goos: linux 21 | goarch: amd64 22 | env: 23 | - CC=gcc 24 | 25 | archives: 26 | - format: tar.gz 27 | # this name template makes the OS and Arch compatible with the results of `uname`. 28 | name_template: >- 29 | {{ .ProjectName }}_ 30 | {{- title .Os }}_ 31 | {{- if eq .Arch "amd64" }}x86_64 32 | {{- else if eq .Arch "386" }}i386 33 | {{- else }}{{ .Arch }}{{ end }} 34 | {{- if .Arm }}v{{ .Arm }}{{ end }} -------------------------------------------------------------------------------- /.goreleaser/release.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | builds: 4 | - skip: true 5 | 6 | changelog: 7 | sort: asc 8 | filters: 9 | exclude: 10 | - "^chore:" 11 | - "^docs:" 12 | - "^test:" 13 | 14 | release: 15 | prerelease: auto 16 | extra_files: 17 | - glob: ./jqless-build/**/* 18 | - glob: ./jqless-build/checksums.txt -------------------------------------------------------------------------------- /.goreleaser/windows.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - env: 9 | goos: 10 | - windows 11 | 12 | archives: 13 | - format: zip 14 | # this name template makes the OS and Arch compatible with the results of `uname`. 15 | name_template: >- 16 | {{ .ProjectName }}_ 17 | {{- title .Os }}_ 18 | {{- if eq .Arch "amd64" }}x86_64 19 | {{- else if eq .Arch "386" }}i386 20 | {{- else }}{{ .Arch }}{{ end }} 21 | {{- if .Arm }}v{{ .Arm }}{{ end }} -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sam Sullivan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jqless 2 | 3 | `jqless` is a combination between `jq` and `less`, enabling users to filter and extract data from JSON in real-time -- useful when first learning the syntax or for power users trying to extract multiple pieces of data from a single JSON blob. 4 | 5 | ![demo](https://github.com/samsullivan/jqless/blob/main/assets/demo.gif?raw=true) 6 | 7 | ### Installation 8 | 9 | Build from source with Go, see [the release binaries](https://github.com/samsullivan/jqless/releases), or use Homebrew if on Mac: 10 | 11 | ``` 12 | brew tap samsullivan/samsullivan 13 | brew install jqless 14 | ``` 15 | 16 | ### Usage 17 | 18 | To use, start `jqless` in your favorite terminal by either piping JSON data to the process or including a file path as the first argument. 19 | 20 | ``` 21 | jqless [path/to/file.json] 22 | cat [path/to/file.json] | jqless 23 | ``` 24 | 25 | Once loaded, type your `jq` query as expected and see the results filter. To extract results to your clipboard, use `ctrl+x` as shown in help text. 26 | 27 | More options to come in future versions! 28 | 29 | ### Acknowledgements 30 | 31 | It is written in Golang using the [Bubble Tea framework](https://github.com/charmbracelet/bubbletea) and [`gojq`](https://github.com/itchyny/gojq). Inspiration h/t to [`jq-live`](https://github.com/TheDahv/jq-live). -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samsullivan/jqless/5c09b4953d6b11a8686c0e4267b3aba69d9c9078/assets/demo.gif -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/samsullivan/jqless 2 | 3 | go 1.21.5 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.17.1 7 | github.com/charmbracelet/bubbletea v0.25.0 8 | github.com/charmbracelet/lipgloss v0.9.1 9 | github.com/itchyny/gojq v0.12.14 10 | github.com/urfave/cli/v3 v3.0.0-alpha8 11 | golang.design/x/clipboard v0.7.0 12 | golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 13 | ) 14 | 15 | require ( 16 | github.com/atotto/clipboard v0.1.4 // indirect 17 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 18 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 19 | github.com/itchyny/timefmt-go v0.1.5 // indirect 20 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 21 | github.com/mattn/go-isatty v0.0.20 // indirect 22 | github.com/mattn/go-localereader v0.0.1 // indirect 23 | github.com/mattn/go-runewidth v0.0.15 // indirect 24 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 25 | github.com/muesli/cancelreader v0.2.2 // indirect 26 | github.com/muesli/reflow v0.3.0 // indirect 27 | github.com/muesli/termenv v0.15.2 // indirect 28 | github.com/rivo/uniseg v0.4.4 // indirect 29 | github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect 30 | golang.org/x/exp/shiny v0.0.0-20240112132812-db7319d0e0e3 // indirect 31 | golang.org/x/image v0.15.0 // indirect 32 | golang.org/x/mobile v0.0.0-20240112133503-c713f31d574b // indirect 33 | golang.org/x/sync v0.6.0 // indirect 34 | golang.org/x/sys v0.16.0 // indirect 35 | golang.org/x/term v0.6.0 // indirect 36 | golang.org/x/text v0.14.0 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbles v0.17.1 h1:0SIyjOnkrsfDo88YvPgAWvZMwXe26TP6drRvmkjyUu4= 6 | github.com/charmbracelet/bubbles v0.17.1/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o= 7 | github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= 8 | github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= 9 | github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= 10 | github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= 11 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= 12 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/itchyny/gojq v0.12.14 h1:6k8vVtsrhQSYgSGg827AD+PVVaB1NLXEdX+dda2oZCc= 16 | github.com/itchyny/gojq v0.12.14/go.mod h1:y1G7oO7XkcR1LPZO59KyoCRy08T3j9vDYRV0GgYSS+s= 17 | github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE= 18 | github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8= 19 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 20 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 21 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 22 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 23 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 24 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 25 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 26 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 27 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 28 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 29 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 30 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 31 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 32 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 33 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 34 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 35 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 36 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 37 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 38 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 39 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 40 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 41 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 42 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 43 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 44 | github.com/urfave/cli/v3 v3.0.0-alpha8 h1:H+qxFPoCkGzdF8KUMs2fEOZl5io/1QySgUiGfar8occ= 45 | github.com/urfave/cli/v3 v3.0.0-alpha8/go.mod h1:0kK/RUFHyh+yIKSfWxwheGndfnrvYSmYFVeKCh03ZUc= 46 | github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI= 47 | github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 48 | golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo= 49 | golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E= 50 | golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o= 51 | golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= 52 | golang.org/x/exp/shiny v0.0.0-20240112132812-db7319d0e0e3 h1:NezsOJwoBjJ5AXH5QQCdxe+WsqLw+f/t8eo1Tacfhqs= 53 | golang.org/x/exp/shiny v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o= 54 | golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= 55 | golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= 56 | golang.org/x/mobile v0.0.0-20240112133503-c713f31d574b h1:kfWLZgb8iUBHdE9WydD5V5dHIS/F6HjlBZNyJfn2bs4= 57 | golang.org/x/mobile v0.0.0-20240112133503-c713f31d574b/go.mod h1:4efzQnuA1nICq6h4kmZRMGzbPiP06lZvgADUu1VpJCE= 58 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 59 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 60 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 61 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 63 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 64 | golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= 65 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 66 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 67 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 68 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 69 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 70 | -------------------------------------------------------------------------------- /jq/jq.go: -------------------------------------------------------------------------------- 1 | package jq 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/itchyny/gojq" 7 | 8 | "github.com/samsullivan/jqless/message" 9 | ) 10 | 11 | const DefaultQuery = "." 12 | 13 | type Options struct { 14 | Compact bool 15 | Raw bool 16 | } 17 | 18 | // Query takes unmarshalled JSON data and an input query. 19 | // On success, returns a slice of strings from gojq result. 20 | func Query(data interface{}, input string, opts *Options) message.QueryResult { 21 | query, err := gojq.Parse(input) 22 | if err != nil { 23 | return message.NewQueryError(err) 24 | } 25 | 26 | var compactOutput bool 27 | if opts != nil { 28 | compactOutput = opts.Compact 29 | } 30 | 31 | var rawOutput bool 32 | if opts != nil { 33 | rawOutput = opts.Raw 34 | } 35 | 36 | var result []string 37 | 38 | iter := query.Run(data) 39 | for { 40 | v, ok := iter.Next() 41 | if !ok { 42 | break 43 | } 44 | if err, ok := v.(error); ok { 45 | // TODO: handle more than one error 46 | return message.NewQueryError(err) 47 | } 48 | 49 | if rawOutput { 50 | if str, ok := v.(string); ok { 51 | result = append(result, str) 52 | continue 53 | } 54 | } 55 | 56 | var b []byte 57 | if compactOutput { 58 | b, err = json.Marshal(v) 59 | if err != nil { 60 | return message.NewQueryError(err) 61 | } 62 | } else { 63 | b, err = json.MarshalIndent(v, "", " ") 64 | if err != nil { 65 | return message.NewQueryError(err) 66 | } 67 | } 68 | 69 | result = append(result, string(b)) 70 | } 71 | 72 | return message.NewQueryResult(result) 73 | } 74 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/urfave/cli/v3" 11 | 12 | "github.com/samsullivan/jqless/model" 13 | ) 14 | 15 | var version = "0.0.0" 16 | 17 | // main registers cli flags and, if unused, starts the bubbletea program. 18 | func main() { 19 | cmd := &cli.Command{ 20 | Name: "jqless", 21 | Usage: "combining jq and less for real-time JSON parsing", 22 | Version: version, 23 | UsageText: strings.Join([]string{ 24 | "jqless [path/to/file.json]", 25 | "cat [path/to/file.json] | jqless", 26 | }, "\n"), 27 | HideHelpCommand: true, 28 | Action: func(context.Context, *cli.Command) error { 29 | file, err := getFile() 30 | if err != nil { 31 | return err 32 | } 33 | 34 | m, err := model.New(file) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | if _, err := tea.NewProgram(m).Run(); err != nil { 40 | return err 41 | } 42 | 43 | return nil 44 | }, 45 | } 46 | 47 | if err := cmd.Run(context.Background(), os.Args); err != nil { 48 | log.Fatal(err) 49 | } 50 | } 51 | 52 | // getFile returns a file descriptor, expected to contain JSON data. 53 | func getFile() (file *os.File, err error) { 54 | if len(os.Args) > 1 { 55 | // if command line arguments included, attempt to open local file 56 | file, err = os.Open(os.Args[1]) 57 | if err != nil { 58 | return nil, err 59 | } 60 | } else { 61 | // otherwise, piped data is expected 62 | stat, err := os.Stdin.Stat() 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | if stat.Mode()&os.ModeNamedPipe != 0 { 68 | file = os.Stdin 69 | } 70 | } 71 | 72 | return file, nil 73 | } 74 | -------------------------------------------------------------------------------- /message/fatalerror.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | type FatalError struct { 4 | err error 5 | } 6 | 7 | func NewFatalError(err error) FatalError { 8 | return FatalError{err: err} 9 | } 10 | 11 | func (msg FatalError) Error() error { 12 | return msg.err 13 | } 14 | -------------------------------------------------------------------------------- /message/parsedfile.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | type ParsedFile struct { 4 | data interface{} 5 | } 6 | 7 | func NewParsedFile(data interface{}) ParsedFile { 8 | return ParsedFile{data: data} 9 | } 10 | 11 | func (msg ParsedFile) Data() interface{} { 12 | return msg.data 13 | } 14 | -------------------------------------------------------------------------------- /message/queryresult.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | type QueryResult struct { 4 | results []string 5 | err error 6 | } 7 | 8 | func NewQueryResult(results []string) QueryResult { 9 | return QueryResult{results: results} 10 | } 11 | 12 | func NewQueryError(err error) QueryResult { 13 | return QueryResult{err: err} 14 | } 15 | 16 | func (msg QueryResult) Results() []string { 17 | return msg.results 18 | } 19 | 20 | func (msg QueryResult) Error() error { 21 | return msg.err 22 | } 23 | 24 | func (msg QueryResult) Failed() bool { 25 | return msg.err != nil 26 | } 27 | -------------------------------------------------------------------------------- /model/init.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/textinput" 5 | tea "github.com/charmbracelet/bubbletea" 6 | ) 7 | 8 | func (m model) Init() tea.Cmd { 9 | return tea.Batch( 10 | m.spinner.Tick, 11 | textinput.Blink, 12 | m.parseFile(), 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /model/keys.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/bubbles/help" 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/textinput" 9 | tea "github.com/charmbracelet/bubbletea" 10 | 11 | "github.com/samsullivan/jqless/message" 12 | "github.com/samsullivan/jqless/util" 13 | ) 14 | 15 | // keyBindings defines the available key bindings. 16 | type keyBindings struct { 17 | SwitchFocus key.Binding 18 | Extract key.Binding 19 | ViewportNavigation key.Binding 20 | Compact key.Binding 21 | Raw key.Binding 22 | Quit key.Binding 23 | } 24 | 25 | // Validate that keyBindings satisfies help.KeyMap interface. 26 | // TODO: refactor away from help bubbles for more control 27 | var _ help.KeyMap = (*keyBindings)(nil) 28 | 29 | // inputKeys contains the key binding & help text when input is focused. 30 | var inputKeys = keyBindings{ 31 | SwitchFocus: getSwitchFocusKeyBinding(nil), 32 | Extract: getExtractKeyBinding(true), 33 | ViewportNavigation: getViewportNavigationKeyBinding(nil), 34 | Quit: getQuitKeyBinding(), 35 | } 36 | 37 | // inputKeys contains the key binding & help text when viewport is focused. 38 | var viewportKeys = keyBindings{ 39 | SwitchFocus: getSwitchFocusKeyBinding(util.Ptr("edit query")), 40 | Extract: getExtractKeyBinding(false), 41 | ViewportNavigation: getViewportNavigationKeyBinding([][]string{ 42 | {"j", "k"}, 43 | {"f", "b"}, 44 | {"d", "u"}, 45 | }), 46 | Compact: key.NewBinding( 47 | key.WithKeys("c"), 48 | key.WithHelp("c", "compact output"), 49 | ), 50 | Raw: key.NewBinding( 51 | key.WithKeys("r"), 52 | key.WithHelp("r", "raw output"), 53 | ), 54 | Quit: getQuitKeyBinding(), 55 | } 56 | 57 | // getSwitchFocusKeyBinding allows overriding the help text. 58 | func getSwitchFocusKeyBinding(customHelpText *string) key.Binding { 59 | helpText := "more options" 60 | if customHelpText != nil { 61 | helpText = *customHelpText 62 | } 63 | return key.NewBinding( 64 | key.WithKeys("tab"), 65 | key.WithHelp("⇥", helpText), 66 | ) 67 | } 68 | 69 | // getExtractKeyBinding allows optional enforcing of ctrl keypress. 70 | func getExtractKeyBinding(requiresCtrl bool) key.Binding { 71 | keys := []string{"ctrl+x"} 72 | if !requiresCtrl { 73 | keys = append(keys, "x") 74 | } 75 | return key.NewBinding( 76 | key.WithKeys(keys...), 77 | key.WithHelp(keys[len(keys)-1], "extract (to clipboard)"), 78 | ) 79 | } 80 | 81 | // getViewportNavigationKeyBinding shows extra vim scrollable shortcuts. 82 | func getViewportNavigationKeyBinding(extraHelpKeySets [][]string) key.Binding { 83 | keys := make([]string, 0, (len(extraHelpKeySets)*2)+1) 84 | keys = append(keys, "down", "up") 85 | 86 | helpKeys := make([]string, 0, len(extraHelpKeySets)+1) 87 | helpKeys = append(helpKeys, "↓/↑") 88 | 89 | for _, extraHelpKeySet := range extraHelpKeySets { 90 | keys = append(keys, extraHelpKeySet...) 91 | helpKeys = append(helpKeys, strings.Join(extraHelpKeySet, "/")) 92 | } 93 | 94 | return key.NewBinding( 95 | key.WithKeys(keys...), 96 | key.WithHelp(strings.Join(helpKeys, "·"), "scroll output"), 97 | ) 98 | } 99 | 100 | // getQuitKeyBinding has no options. 101 | func getQuitKeyBinding() key.Binding { 102 | return key.NewBinding( 103 | key.WithKeys("ctrl+c"), 104 | key.WithHelp("ctrl+c", "quit"), 105 | ) 106 | } 107 | 108 | // ShortHelp returns keybindings to be shown in the mini help view. 109 | func (k keyBindings) ShortHelp() []key.Binding { 110 | return []key.Binding{ 111 | k.SwitchFocus, 112 | k.Extract, 113 | k.ViewportNavigation, 114 | k.Quit} 115 | } 116 | 117 | // FullHelp returns keybindings for the expanded help view, used when viewport focused. 118 | func (k keyBindings) FullHelp() [][]key.Binding { 119 | return [][]key.Binding{ 120 | {k.SwitchFocus}, 121 | {k.Extract}, 122 | {k.ViewportNavigation}, 123 | {k.Compact}, 124 | {k.Raw}, 125 | {k.Quit}, 126 | } 127 | } 128 | 129 | // handleKeyMsg is used by Update() when a KeyMsg is received. 130 | // Any non-nil command returned should be expected to be immediately 131 | // passed to bubbletea, without being processed further by Update(). 132 | func (m model) handleKeyMsg(msg tea.KeyMsg) (model, tea.Cmd) { 133 | var cmd tea.Cmd 134 | 135 | switch { 136 | case key.Matches(msg, inputKeys.SwitchFocus, viewportKeys.SwitchFocus): 137 | switch m.currentFocus { 138 | case focusInput: 139 | m.currentFocus = focusViewport 140 | m.help.ShowAll = true 141 | m.textinput.Cursor.Blink = true 142 | case focusViewport: 143 | m.currentFocus = focusInput 144 | m.help.ShowAll = false 145 | m.textinput.Cursor.Blink = false 146 | cmd = textinput.Blink 147 | } 148 | return m, cmd 149 | case key.Matches(msg, inputKeys.Extract, viewportKeys.Extract): 150 | cmd = func() tea.Msg { 151 | if err := util.WriteClipboard([]byte(m.viewportContents())); err != nil { 152 | return message.NewFatalError(err) 153 | } 154 | 155 | // TODO: success indication (flash help text green?) 156 | return nil 157 | } 158 | return m, cmd 159 | case key.Matches(msg, viewportKeys.Compact): 160 | m.lastQuery = "" // trigger jq render 161 | m.compactOutput = !m.compactOutput 162 | return m, cmd 163 | case key.Matches(msg, viewportKeys.Raw): 164 | m.lastQuery = "" // trigger jq render 165 | m.rawOutput = !m.rawOutput 166 | return m, cmd 167 | case key.Matches(msg, inputKeys.ViewportNavigation, viewportKeys.ViewportNavigation): 168 | m.viewport, cmd = m.viewport.Update(msg) 169 | return m, cmd 170 | case key.Matches(msg, inputKeys.Quit, viewportKeys.Quit): 171 | cmd = tea.Quit 172 | return m, cmd 173 | } 174 | 175 | return m, cmd 176 | } 177 | -------------------------------------------------------------------------------- /model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "os" 8 | 9 | "github.com/charmbracelet/bubbles/help" 10 | "github.com/charmbracelet/bubbles/spinner" 11 | "github.com/charmbracelet/bubbles/textinput" 12 | "github.com/charmbracelet/bubbles/viewport" 13 | tea "github.com/charmbracelet/bubbletea" 14 | 15 | "github.com/samsullivan/jqless/jq" 16 | "github.com/samsullivan/jqless/message" 17 | ) 18 | 19 | type focus int 20 | 21 | const ( 22 | focusInput focus = iota 23 | focusViewport 24 | ) 25 | 26 | type model struct { 27 | viewportReady bool 28 | 29 | // related to JSON user input 30 | file *os.File 31 | data interface{} 32 | 33 | // bubbletea components 34 | viewport viewport.Model 35 | help help.Model 36 | textinput textinput.Model 37 | spinner spinner.Model 38 | 39 | // state of jq querying 40 | isLoading bool 41 | lastError error 42 | lastQuery string 43 | lastResults []string 44 | 45 | // various settings 46 | currentFocus focus 47 | compactOutput bool 48 | rawOutput bool 49 | } 50 | 51 | // New takes an open file and returns a model for use by bubbletea. 52 | // In order to show the spinner immediately, for larger JSON payloads, 53 | // The file stream isn't consumed or unmarshalled into JSON yet. 54 | func New(file *os.File) (*model, error) { 55 | m := model{ 56 | currentFocus: focusInput, 57 | file: file, 58 | isLoading: true, 59 | } 60 | 61 | // configure help 62 | m.help = help.New() 63 | m.help.FullSeparator = m.help.ShortSeparator 64 | 65 | // configure text input 66 | m.textinput = textinput.New() 67 | m.textinput.Placeholder = jq.DefaultQuery 68 | m.textinput.Focus() 69 | 70 | // configure loading spinner 71 | m.spinner = spinner.New() 72 | m.spinner.Spinner = spinner.MiniDot 73 | 74 | return &m, nil 75 | } 76 | 77 | // parseFile returns a command for reading the input file into unmarshalled JSON data 78 | func (m *model) parseFile() tea.Cmd { 79 | return func() tea.Msg { 80 | var data interface{} 81 | 82 | // verify file exists 83 | if m.file == nil { 84 | return message.NewFatalError( 85 | errors.New("no data passed to jqless"), 86 | ) 87 | } 88 | 89 | // close file when done reading 90 | defer m.file.Close() 91 | 92 | // read entire file 93 | b, err := io.ReadAll(m.file) 94 | if err != nil { 95 | return message.NewFatalError(err) 96 | } 97 | 98 | // unmarshal to data interface 99 | err = json.Unmarshal(b, &data) 100 | if err != nil { 101 | return message.NewFatalError(err) 102 | } 103 | 104 | return message.NewParsedFile(data) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /model/update.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/spinner" 7 | "github.com/charmbracelet/bubbles/viewport" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | 11 | "github.com/samsullivan/jqless/jq" 12 | "github.com/samsullivan/jqless/message" 13 | "github.com/samsullivan/jqless/util" 14 | ) 15 | 16 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 17 | var ( 18 | // some messages need to return a single command immediately 19 | cmd tea.Cmd 20 | // if not, append to list of cmds to be returned as a batch at the end 21 | cmds []tea.Cmd 22 | ) 23 | 24 | switch msg := msg.(type) { 25 | // listen for errors 26 | case message.FatalError: 27 | // TODO: better error output 28 | fmt.Printf("FatalError: %s\n\n", msg.Error()) 29 | 30 | cmd = tea.Quit 31 | return m, cmd 32 | // listen to keypresses 33 | case tea.KeyMsg: 34 | m, cmd = m.handleKeyMsg(msg) 35 | if cmd != nil { 36 | return m, cmd 37 | } 38 | // listen for spinner tick 39 | case spinner.TickMsg: 40 | if !m.isLoading { 41 | // stop spinner if no longer loading 42 | return m, cmd 43 | } 44 | m.spinner, cmd = m.spinner.Update(msg) 45 | return m, cmd 46 | // listen for window resizing 47 | case tea.WindowSizeMsg: 48 | // see https://github.com/charmbracelet/bubbletea/blob/master/examples/pager/main.go 49 | headerHeight := lipgloss.Height(m.headerView()) 50 | footerHeight := lipgloss.Height(m.footerView()) 51 | verticalMarginHeight := headerHeight + footerHeight 52 | 53 | if !m.viewportReady { 54 | // Since this program is using the full size of the viewport we 55 | // need to wait until we've received the window dimensions before 56 | // we can initialize the viewport. The initial dimensions come in 57 | // quickly, though asynchronously, which is why we wait for them 58 | // here. 59 | m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) 60 | m.viewport.YPosition = headerHeight 61 | m.viewport.SetContent(m.viewportContents()) 62 | 63 | m.viewportReady = true 64 | } else { 65 | m.viewport.Width = msg.Width 66 | m.viewport.Height = msg.Height - verticalMarginHeight 67 | } 68 | // listen for parsed JSON file 69 | case message.ParsedFile: 70 | m.data = msg.Data() 71 | // listen for updated jq results 72 | case message.QueryResult: 73 | m.isLoading = false 74 | if msg.Failed() { 75 | m.lastError = msg.Error() 76 | } else { 77 | m.lastError = nil 78 | m.lastResults = msg.Results() 79 | } 80 | } 81 | 82 | // handle viewport changes 83 | m.viewport.SetContent(m.viewportContents()) 84 | if m.currentFocus == focusViewport { 85 | m.viewport, cmd = m.viewport.Update(msg) 86 | cmds = append(cmds, cmd) 87 | } 88 | 89 | // handle text input changes 90 | if m.currentFocus == focusInput { 91 | m.textinput, cmd = m.textinput.Update(msg) 92 | cmds = append(cmds, cmd) 93 | } 94 | 95 | // skip jq-related processing if file not processed into data yet 96 | if m.data == nil { 97 | // TODO: timeout 98 | } else { 99 | // if query changed, trigger new parsing of jq 100 | query := util.SanitizeQuery(m.textinput.Value(), m.textinput.Placeholder) 101 | if query != m.lastQuery { 102 | m.lastQuery = query 103 | m.isLoading = true 104 | 105 | // restart spinner in addition to triggering jq 106 | cmds = append(cmds, m.spinner.Tick) 107 | cmds = append(cmds, func() tea.Msg { 108 | return jq.Query(m.data, query, &jq.Options{ 109 | Compact: m.compactOutput, 110 | Raw: m.rawOutput, 111 | }) 112 | }) 113 | } 114 | } 115 | 116 | return m, tea.Batch(cmds...) 117 | } 118 | -------------------------------------------------------------------------------- /model/view.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "strings" 7 | 8 | "github.com/charmbracelet/lipgloss" 9 | 10 | "github.com/samsullivan/jqless/util" 11 | ) 12 | 13 | var ( 14 | leftBoxStyle = func() lipgloss.Style { 15 | b := lipgloss.RoundedBorder() 16 | b.Right = "├" 17 | return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) 18 | } 19 | 20 | rightBoxStyleStyle = func() lipgloss.Style { 21 | b := lipgloss.RoundedBorder() 22 | b.Left = "┤" 23 | return leftBoxStyle().Copy().BorderStyle(b) 24 | } 25 | ) 26 | 27 | func (m model) View() string { 28 | if m.file == nil { 29 | // FatalError will be returned by parseFile() command triggered by Init(). 30 | return "" 31 | } 32 | 33 | if !m.viewportReady || m.data == nil { 34 | return m.footerView() 35 | } 36 | 37 | return strings.Join([]string{ 38 | m.headerView(), 39 | m.viewport.View(), 40 | m.footerView(), 41 | }, "\n") 42 | } 43 | 44 | func (m model) headerView() string { 45 | titleStyle := leftBoxStyle() 46 | 47 | var borderColor lipgloss.TerminalColor = lipgloss.NoColor{} 48 | if m.lastError != nil { 49 | borderColor = lipgloss.Color("#CF2222") 50 | } 51 | titleStyle.BorderForeground(borderColor) 52 | 53 | title := titleStyle.Render(m.textinput.View()) 54 | line := strings.Repeat("─", util.Max(0, m.viewport.Width-lipgloss.Width(title))) 55 | return lipgloss.JoinHorizontal(lipgloss.Center, title, line) 56 | } 57 | 58 | func (m model) viewportContents() string { 59 | return strings.Join(m.lastResults, "\n") 60 | } 61 | 62 | func (m model) footerView() string { 63 | var k keyBindings 64 | switch m.currentFocus { 65 | case focusInput: 66 | k = inputKeys 67 | case focusViewport: 68 | k = viewportKeys 69 | } 70 | help := leftBoxStyle().Render(m.help.View(k)) 71 | 72 | infoItems := make([]string, 1, 2) 73 | infoItems[0] = m.spinner.View() 74 | if m.viewport.TotalLineCount() > m.viewport.VisibleLineCount() { 75 | infoItems = append(infoItems, fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) 76 | } 77 | 78 | slices.Reverse(infoItems) 79 | info := rightBoxStyleStyle().Render(strings.Join(infoItems, " ")) 80 | 81 | contentWidth := lipgloss.Width(help) + lipgloss.Width(info) 82 | line := strings.Repeat("─", util.Max(0, m.viewport.Width-contentWidth)) 83 | return lipgloss.JoinHorizontal(lipgloss.Center, help, line, info) 84 | } 85 | -------------------------------------------------------------------------------- /util/clipboard.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "golang.design/x/clipboard" 4 | 5 | var isClipboardInitialized bool 6 | 7 | func InitClipboard() error { 8 | if isClipboardInitialized { 9 | return nil 10 | } 11 | 12 | return clipboard.Init() 13 | } 14 | 15 | func WriteClipboard(data []byte) error { 16 | if err := InitClipboard(); err != nil { 17 | return err 18 | } 19 | 20 | clipboard.Write(clipboard.FmtText, data) 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /util/math.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "golang.org/x/exp/constraints" 4 | 5 | // Max returns the largest argument provided. 6 | func Max[T constraints.Ordered](args ...T) T { 7 | if len(args) == 0 { 8 | var zero T 9 | return zero 10 | } 11 | 12 | max := args[0] 13 | for _, arg := range args[1:] { 14 | if arg > max { 15 | max = arg 16 | } 17 | } 18 | return max 19 | } 20 | 21 | // Min returns the smallest argument provided. 22 | func Min[T constraints.Ordered](args ...T) T { 23 | if len(args) == 0 { 24 | var zero T 25 | return zero 26 | } 27 | 28 | min := args[0] 29 | for _, arg := range args[1:] { 30 | if arg < min { 31 | min = arg 32 | } 33 | } 34 | return min 35 | } 36 | -------------------------------------------------------------------------------- /util/ptr.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func Ptr[T any](x T) *T { 4 | return &x 5 | } 6 | -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "strings" 4 | 5 | // SanitizeQuery removes leading and trailing spaces from input string. 6 | // If string is empty, uses default value. 7 | func SanitizeQuery(input string, defaultValue string) string { 8 | output := strings.TrimSpace(input) 9 | if output == "" { 10 | output = defaultValue 11 | } 12 | return output 13 | } 14 | --------------------------------------------------------------------------------