├── .github └── workflows │ └── go.yml ├── LICENSE ├── README.md ├── cmd └── ghcv │ └── main.go ├── go.mod ├── go.sum ├── img ├── demo.tape ├── image.gif ├── pr-list-all-status.png ├── pr-list-all.png ├── pr-list.png ├── pr-owner.png ├── pr-repo.png ├── repo-lang.png ├── repo-sort.png └── repo.png └── internal ├── gen └── gen.go ├── gh ├── auth.go ├── client.go ├── client_test.go └── config.go ├── ghcv └── const.go └── ui ├── about.go ├── app.go ├── common.go ├── credits.go ├── credits_gen.go ├── help.go ├── menu.go ├── profile.go ├── prs.go ├── prs_list.go ├── prs_list_all.go ├── prs_list_all_delegate.go ├── prs_list_delegate.go ├── prs_owner.go ├── prs_repo.go ├── prs_repo_delegate.go ├── repositories.go ├── repository_delegate.go ├── user.go └── util.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: 1.21 19 | 20 | - name: Build 21 | run: go build -v ./... 22 | 23 | - name: Test 24 | run: go test -v ./... 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Kyosuke Fujimoto 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 | ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/lusingander/ghcv-cli) 2 | ![GitHub](https://img.shields.io/github/license/lusingander/ghcv-cli) 3 | 4 | # ghcv-cli 5 | 6 | ghcv-cli makes it easy to view the user-created issues, pull requests, and repositories in the terminal. 7 | 8 | 9 | 10 | ## About 11 | 12 | - Show a list of pull requests created by the user (to other people's repositories) 13 | - Show a list of (non-forked) public repositories created by the user 14 | 15 | ## Installation 16 | 17 | `$ go install github.com/lusingander/ghcv-cli/cmd/ghcv@latest` 18 | 19 | ## Setup 20 | 21 | You will need to authenticate with GitHub in order to use the application because GitHub GraphQL API [requires authentication](https://docs.github.com/en/graphql/guides/forming-calls-with-graphql#authenticating-with-graphql). 22 | 23 | Authentication must be granted according to the [Device flow](https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#device-flow). You will need to enter the code that will be displayed in your console when you start the application. 24 | 25 | > The application requires only minimal scope (access to public information). 26 | 27 | Or, you can set the [personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) as the environment variable. 28 | 29 | ```sh 30 | export GHCV_GITHUB_ACCESS_TOKEN= 31 | ``` 32 | 33 | > In this case as well, you don't need to specify anything in the scope (only public information will be accessed). 34 | 35 | ## Usage 36 | 37 | ### Pull Requests 38 | 39 | You can list all pull requests created by the user (to the user's own repository are not included). 40 | Pull requests are grouped and displayed by the target repository and its owner. 41 | 42 | You can also view all pull requests without grouping and filter by status. 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ### Repositories 51 | 52 | You can list all repositories created by the user. 53 | Only public and not forked repositories will be shown. 54 | 55 | By default, Repositories will be sorted by stars. 56 | You can sort by number of stars and last updated. 57 | 58 | You can also filter by language. 59 | 60 | 61 | 62 | 63 | 64 | ## License 65 | 66 | MIT 67 | -------------------------------------------------------------------------------- /cmd/ghcv/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/lusingander/ghcv-cli/internal/gh" 8 | "github.com/lusingander/ghcv-cli/internal/ui" 9 | ) 10 | 11 | func run(args []string) error { 12 | cfg, err := gh.LoadConfig() 13 | if err != nil { 14 | cfg, err = gh.Authorize() 15 | if err != nil { 16 | return err 17 | } 18 | if err := gh.SaveConfig(cfg); err != nil { 19 | return err 20 | } 21 | } 22 | client := gh.NewGitHubClient(cfg) 23 | return ui.Start(client) 24 | } 25 | 26 | func main() { 27 | if err := run(os.Args); err != nil { 28 | log.Fatal(err) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lusingander/ghcv-cli 2 | 3 | go 1.21.3 4 | 5 | require ( 6 | github.com/Songmu/gocredits v0.3.0 7 | github.com/charmbracelet/bubbles v0.18.0 8 | github.com/charmbracelet/bubbletea v0.25.0 9 | github.com/charmbracelet/lipgloss v0.10.0 10 | github.com/emirpasic/gods v1.18.1 11 | github.com/lusingander/kasane v0.0.0-20231207092011-d7af4a4cf7cf 12 | github.com/muesli/reflow v0.3.0 13 | github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc 14 | github.com/simonhege/timeago v1.0.0-rc5 15 | golang.org/x/oauth2 v0.19.0 16 | ) 17 | 18 | require ( 19 | github.com/atotto/clipboard v0.1.4 // indirect 20 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 21 | github.com/containerd/console v1.0.4 // indirect 22 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 23 | github.com/mattn/go-isatty v0.0.20 // indirect 24 | github.com/mattn/go-localereader v0.0.1 // indirect 25 | github.com/mattn/go-runewidth v0.0.15 // indirect 26 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 27 | github.com/muesli/cancelreader v0.2.2 // indirect 28 | github.com/muesli/termenv v0.15.2 // indirect 29 | github.com/rivo/uniseg v0.4.7 // indirect 30 | github.com/sahilm/fuzzy v0.1.1 // indirect 31 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect 32 | golang.org/x/sync v0.7.0 // indirect 33 | golang.org/x/sys v0.19.0 // indirect 34 | golang.org/x/term v0.19.0 // indirect 35 | golang.org/x/text v0.14.0 // indirect 36 | ) 37 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Songmu/gocredits v0.3.0 h1:BOredmhBQhrZjanpQpTWVl7aCuQW83Sea85kA0E9lOs= 2 | github.com/Songmu/gocredits v0.3.0/go.mod h1:GGUAT/3BmUVgvfHxm07agU6Zz+ZSeGg5gvqN6N/CxH0= 3 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 4 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 5 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 6 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 7 | github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= 8 | github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= 9 | github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= 10 | github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= 11 | github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= 12 | github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= 13 | github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= 14 | github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 15 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 16 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 17 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 18 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 20 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 21 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 22 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 23 | github.com/lusingander/kasane v0.0.0-20231207092011-d7af4a4cf7cf h1:LQvnYz+y/Aru93SP8//PXz9gFLWH7HyCRsW94lRoeO0= 24 | github.com/lusingander/kasane v0.0.0-20231207092011-d7af4a4cf7cf/go.mod h1:J4SEbIo6FftuXrGpeW/+zdy0r1BEHWwWJAF3XvbJkiM= 25 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 26 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 27 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 28 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 29 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 30 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 31 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 32 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 33 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 34 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 35 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 36 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 37 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 38 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 39 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 40 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 41 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 42 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 43 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 44 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 45 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 46 | github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc h1:vH0NQbIDk+mJLvBliNGfcQgUmhlniWBDXC79oRxfZA0= 47 | github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= 48 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= 49 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= 50 | github.com/simonhege/timeago v1.0.0-rc5 h1:Fx6M3eLoSdZDRX1fYf0ZKEHK6dlmvfLY+zHRwsnOGNU= 51 | github.com/simonhege/timeago v1.0.0-rc5/go.mod h1:PfcxupQPucgCUBC1uH6OI1vHaKuhDvXN9eo54vseEVc= 52 | golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg= 53 | golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8= 54 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 55 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 56 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= 59 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 60 | golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= 61 | golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= 62 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 63 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 64 | -------------------------------------------------------------------------------- /img/demo.tape: -------------------------------------------------------------------------------- 1 | Set Shell zsh 2 | 3 | Output ./img/image.gif 4 | 5 | Set FontSize 15 6 | Set Width 1000 7 | Set Height 600 8 | Set Padding 15 9 | Set WindowBar Colorful 10 | Set BorderRadius 20 11 | 12 | Hide 13 | 14 | Type@10ms "go run cmd/ghcv/main.go" 15 | Enter 16 | Sleep 1s 17 | 18 | Show 19 | 20 | Sleep 500ms 21 | 22 | Type@100ms "lusingander" 23 | Sleep 500ms 24 | Enter 25 | Sleep 2.5s 26 | 27 | Enter 28 | Sleep 1.5s 29 | 30 | Backspace 31 | Sleep 500ms 32 | Type "j" 33 | Sleep 500ms 34 | Enter 35 | Sleep 4s 36 | 37 | Screenshot ./img/pr-owner.png 38 | Type "j" 39 | Sleep 500ms 40 | Type "j" 41 | Sleep 500ms 42 | Type "j" 43 | Sleep 500ms 44 | Enter 45 | Sleep 500ms 46 | Screenshot ./img/pr-repo.png 47 | Enter 48 | Sleep 1s 49 | Screenshot ./img/pr-list.png 50 | 51 | Backspace 52 | Sleep 300ms 53 | Backspace 54 | Sleep 300ms 55 | 56 | Tab 57 | Screenshot ./img/pr-list-all.png 58 | Sleep 500ms 59 | Type "T" 60 | Sleep 1s 61 | Type "j" 62 | Sleep 500ms 63 | Type "j" 64 | Sleep 500ms 65 | Screenshot ./img/pr-list-all-status.png 66 | Type "T" 67 | Sleep 500ms 68 | Backspace 69 | Sleep 1s 70 | 71 | Type "j" 72 | Sleep 500ms 73 | Enter 74 | Sleep 4s 75 | 76 | Screenshot ./img/repo.png 77 | Type "j" 78 | Sleep 500ms 79 | Type "j" 80 | Sleep 500ms 81 | Type "l" 82 | Sleep 1s 83 | 84 | Type "L" 85 | Sleep 1s 86 | Type "j" 87 | Sleep 500ms 88 | Type "j" 89 | Screenshot ./img/repo-lang.png 90 | Sleep 1s 91 | Type "L" 92 | Sleep 1s 93 | Type "S" 94 | Sleep 1s 95 | Type "j" 96 | Sleep 500ms 97 | Type "j" 98 | Sleep 1s 99 | Screenshot ./img/repo-sort.png 100 | Type "S" 101 | Sleep 1s -------------------------------------------------------------------------------- /img/image.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusingander/ghcv-cli/e9ab39c7bd9712e9b8ed1b07eb073eeb9e964470/img/image.gif -------------------------------------------------------------------------------- /img/pr-list-all-status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusingander/ghcv-cli/e9ab39c7bd9712e9b8ed1b07eb073eeb9e964470/img/pr-list-all-status.png -------------------------------------------------------------------------------- /img/pr-list-all.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusingander/ghcv-cli/e9ab39c7bd9712e9b8ed1b07eb073eeb9e964470/img/pr-list-all.png -------------------------------------------------------------------------------- /img/pr-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusingander/ghcv-cli/e9ab39c7bd9712e9b8ed1b07eb073eeb9e964470/img/pr-list.png -------------------------------------------------------------------------------- /img/pr-owner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusingander/ghcv-cli/e9ab39c7bd9712e9b8ed1b07eb073eeb9e964470/img/pr-owner.png -------------------------------------------------------------------------------- /img/pr-repo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusingander/ghcv-cli/e9ab39c7bd9712e9b8ed1b07eb073eeb9e964470/img/pr-repo.png -------------------------------------------------------------------------------- /img/repo-lang.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusingander/ghcv-cli/e9ab39c7bd9712e9b8ed1b07eb073eeb9e964470/img/repo-lang.png -------------------------------------------------------------------------------- /img/repo-sort.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusingander/ghcv-cli/e9ab39c7bd9712e9b8ed1b07eb073eeb9e964470/img/repo-sort.png -------------------------------------------------------------------------------- /img/repo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lusingander/ghcv-cli/e9ab39c7bd9712e9b8ed1b07eb073eeb9e964470/img/repo.png -------------------------------------------------------------------------------- /internal/gen/gen.go: -------------------------------------------------------------------------------- 1 | //go:generate go run . -output ../ui/credits_gen.go 2 | package main 3 | 4 | // cf: https://github.com/lusingander/fyne-credits-generator 5 | 6 | import ( 7 | "bytes" 8 | "flag" 9 | "fmt" 10 | "os" 11 | "strings" 12 | 13 | "github.com/Songmu/gocredits" 14 | ) 15 | 16 | var ( 17 | output = flag.String("output", "./gen.go", "output filepath") 18 | ) 19 | 20 | const ( 21 | splitterLicense = "================================================================" 22 | splitterText = "----------------------------------------------------------------" 23 | ) 24 | 25 | type credit struct { 26 | name, url, text string 27 | } 28 | 29 | // [`] => [` + "`" + `] 30 | const replacedBackquote = "`" + ` + "` + "`" + `" + ` + "`" 31 | 32 | func (c *credit) formattedText() string { 33 | return strings.Replace(c.text, "`", replacedBackquote, -1) 34 | } 35 | 36 | func collect() ([]*credit, error) { 37 | buf, err := runGoCredits() 38 | if err != nil { 39 | return nil, err 40 | } 41 | licenses := strings.Split(buf.String(), splitterLicense) 42 | credits := make([]*credit, 0) 43 | for _, l := range licenses { 44 | c := newCredit(l) 45 | if c != nil { 46 | credits = append(credits, c) 47 | } 48 | } 49 | return credits, nil 50 | } 51 | 52 | func newCredit(text string) *credit { 53 | l := strings.Split(text, splitterText) 54 | if len(l) < 2 { 55 | return nil 56 | } 57 | s := strings.Split(strings.Trim(l[0], "\n"), "\n") 58 | return &credit{ 59 | name: s[0], 60 | url: s[1], 61 | text: l[1], 62 | } 63 | } 64 | 65 | func runGoCredits() (*bytes.Buffer, error) { 66 | buf := &bytes.Buffer{} 67 | err := gocredits.Run([]string{"-skip-missing", "../../"} /* from root */, buf, os.Stderr) 68 | if err != nil { 69 | return nil, err 70 | } 71 | return buf, nil 72 | } 73 | 74 | var template = `// Code generated by gen.go; DO NOT EDIT. 75 | 76 | package ui 77 | 78 | type credit struct { 79 | name, url, text string 80 | } 81 | 82 | var credits = []*credit{ 83 | %s} 84 | ` 85 | 86 | func run() error { 87 | flag.Parse() 88 | 89 | credits, err := collect() 90 | if err != nil { 91 | return err 92 | } 93 | 94 | vars := "" 95 | for _, c := range credits { 96 | vars += fmt.Sprintf(` { 97 | "%s", 98 | "%s", 99 | `+"`%s`"+`, 100 | }, 101 | `, c.name, c.url, c.formattedText()) 102 | } 103 | 104 | var buf bytes.Buffer 105 | buf.WriteString(fmt.Sprintf(template, vars)) 106 | return os.WriteFile(*output, buf.Bytes(), 0666) 107 | } 108 | 109 | func main() { 110 | if err := run(); err != nil { 111 | panic(err) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /internal/gh/auth.go: -------------------------------------------------------------------------------- 1 | package gh 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "os/exec" 11 | "runtime" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | const ( 17 | oauthClientId = "8f2a9bd8ba029f2a0e17" 18 | deviceCodeUrl = "https://github.com/login/device/code" 19 | accessTokenUrl = "https://github.com/login/oauth/access_token" 20 | scope = "" // read-only access to public information 21 | grantType = "urn:ietf:params:oauth:grant-type:device_code" 22 | ) 23 | 24 | func postLogin(url string, params url.Values) ([]byte, error) { 25 | req, err := http.NewRequest("POST", url, strings.NewReader(params.Encode())) 26 | if err != nil { 27 | return nil, err 28 | } 29 | req.Header.Set("Accept", "application/json") 30 | 31 | client := &http.Client{} 32 | resp, err := client.Do(req) 33 | if err != nil { 34 | return nil, err 35 | } 36 | defer resp.Body.Close() 37 | 38 | if resp.StatusCode != 200 { 39 | return nil, fmt.Errorf("unexpected status code: %s", resp.Status) 40 | } 41 | 42 | return io.ReadAll(resp.Body) 43 | } 44 | 45 | type deviceCodeResponse struct { 46 | DeviceCode string `json:"device_code"` 47 | ExpiresIn int `json:"expires_in"` 48 | Interval int `json:"interval"` 49 | UserCode string `json:"user_code"` 50 | VerificationURI string `json:"verification_uri"` 51 | } 52 | 53 | func (r *deviceCodeResponse) valid() bool { 54 | return r.DeviceCode != "" 55 | } 56 | 57 | func postDeviceCode() (*deviceCodeResponse, error) { 58 | values := url.Values{} 59 | values.Add("client_id", oauthClientId) 60 | values.Add("scope", scope) 61 | 62 | body, err := postLogin(deviceCodeUrl, values) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | res := &deviceCodeResponse{} 68 | err = json.Unmarshal(body, res) 69 | if err == nil && res.valid() { 70 | return res, nil 71 | } 72 | return nil, err 73 | } 74 | 75 | type accessTokenResponse struct { 76 | AccessToken string `json:"access_token"` 77 | TokenType string `json:"token_type"` 78 | Scope string `json:"scope"` 79 | } 80 | 81 | func (r *accessTokenResponse) valid() bool { 82 | return r.AccessToken != "" 83 | } 84 | 85 | type accessTokenErrorResponse struct { 86 | Error string `json:"error"` 87 | ErrorDescription string `json:"error_description"` 88 | ErrorUri string `json:"error_uri"` 89 | } 90 | 91 | func (r *accessTokenErrorResponse) valid() bool { 92 | return r.Error != "" 93 | } 94 | 95 | func (r *accessTokenErrorResponse) toError() error { 96 | return fmt.Errorf("%s %s %s", r.Error, r.ErrorDescription, r.ErrorUri) 97 | } 98 | 99 | func postAccessToken(deviceCode string) (*accessTokenResponse, *accessTokenErrorResponse, error) { 100 | values := url.Values{} 101 | values.Add("client_id", oauthClientId) 102 | values.Add("device_code", deviceCode) 103 | values.Add("grant_type", grantType) 104 | 105 | body, err := postLogin(accessTokenUrl, values) 106 | if err != nil { 107 | return nil, nil, err 108 | } 109 | 110 | res := &accessTokenResponse{} 111 | err = json.Unmarshal(body, res) 112 | if err == nil && res.valid() { 113 | return res, nil, nil 114 | } 115 | 116 | errRes := &accessTokenErrorResponse{} 117 | err = json.Unmarshal(body, errRes) 118 | if err == nil && errRes.valid() { 119 | return nil, errRes, nil 120 | } 121 | 122 | return nil, nil, err 123 | } 124 | 125 | func pollAccessToken(r *deviceCodeResponse) (*accessTokenResponse, error) { 126 | deviceCode := r.DeviceCode 127 | interval := time.Duration(r.Interval+1) * time.Second 128 | for { 129 | time.Sleep(interval) 130 | acResp, acErrResp, err := postAccessToken(deviceCode) 131 | if err != nil { 132 | return nil, err 133 | } 134 | if acErrResp != nil { 135 | switch acErrResp.Error { 136 | case "authorization_pending": 137 | continue 138 | case "slow_down": 139 | interval *= 2 140 | continue 141 | default: 142 | return nil, acErrResp.toError() 143 | } 144 | } 145 | return acResp, nil 146 | } 147 | } 148 | 149 | func openBrowser(url string) error { 150 | if runtime.GOOS != "darwin" { 151 | return errors.New("unsupported os") 152 | } 153 | cmd := exec.Command("open", url) 154 | return cmd.Start() 155 | } 156 | 157 | func promptInputUserCode(r *deviceCodeResponse) error { 158 | fmt.Println("Enter this code:", r.UserCode) 159 | fmt.Println(r.VerificationURI) 160 | return openBrowser(r.VerificationURI) 161 | } 162 | 163 | func authDeviceFlow() (string, error) { 164 | // https://docs.github.com/ja/developers/apps/building-oauth-apps/authorizing-oauth-apps#device-flow 165 | dcResp, err := postDeviceCode() 166 | if err != nil { 167 | return "", err 168 | } 169 | if err := promptInputUserCode(dcResp); err != nil { 170 | return "", err 171 | } 172 | atResp, err := pollAccessToken(dcResp) 173 | if err != nil { 174 | return "", err 175 | } 176 | return atResp.AccessToken, nil 177 | } 178 | 179 | func Authorize() (*GithubConfig, error) { 180 | token, err := authDeviceFlow() 181 | if err != nil { 182 | return nil, err 183 | } 184 | cfg := &GithubConfig{ 185 | AccessToken: token, 186 | } 187 | return cfg, nil 188 | } 189 | -------------------------------------------------------------------------------- /internal/gh/client.go: -------------------------------------------------------------------------------- 1 | package gh 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math" 7 | "time" 8 | 9 | "github.com/emirpasic/gods/maps/linkedhashmap" 10 | "github.com/shurcooL/githubv4" 11 | "golang.org/x/oauth2" 12 | ) 13 | 14 | type GitHubClient struct { 15 | client *githubv4.Client 16 | } 17 | 18 | func NewGitHubClient(cfg *GithubConfig) *GitHubClient { 19 | src := oauth2.StaticTokenSource( 20 | &oauth2.Token{AccessToken: cfg.AccessToken}, 21 | ) 22 | httpClient := oauth2.NewClient(context.Background(), src) 23 | client := githubv4.NewClient(httpClient) 24 | return &GitHubClient{ 25 | client: client, 26 | } 27 | } 28 | 29 | func (c *GitHubClient) ExistUser(id string) bool { 30 | var query struct { 31 | User struct { 32 | Login githubv4.String 33 | } `graphql:"user(login: $login)"` 34 | } 35 | variables := map[string]interface{}{ 36 | "login": githubv4.String(id), 37 | } 38 | err := c.client.Query(context.Background(), &query, variables) 39 | return err == nil 40 | } 41 | 42 | type UserProfile struct { 43 | Login string 44 | Name string 45 | Bio string 46 | Followers int 47 | Following int 48 | Location string 49 | Company string 50 | WebsiteUrl string 51 | AvatarUrl string 52 | Url string 53 | } 54 | 55 | type userProfileQuery struct { 56 | User struct { 57 | Login githubv4.String 58 | Name githubv4.String 59 | Bio githubv4.String 60 | Followers struct { 61 | TotalCount githubv4.Int 62 | } 63 | Following struct { 64 | TotalCount githubv4.Int 65 | } 66 | Location githubv4.String 67 | Company githubv4.String 68 | WebsiteUrl githubv4.String 69 | AvatarUrl githubv4.String 70 | Url githubv4.String 71 | } `graphql:"user(login: $login)"` 72 | } 73 | 74 | func (q *userProfileQuery) toUserProfile() *UserProfile { 75 | return &UserProfile{ 76 | Login: string(q.User.Login), 77 | Name: string(q.User.Name), 78 | Bio: string(q.User.Bio), 79 | Followers: int(q.User.Followers.TotalCount), 80 | Following: int(q.User.Following.TotalCount), 81 | Location: string(q.User.Location), 82 | Company: string(q.User.Company), 83 | WebsiteUrl: string(q.User.WebsiteUrl), 84 | AvatarUrl: string(q.User.AvatarUrl), 85 | Url: string(q.User.Url), 86 | } 87 | } 88 | 89 | func (c *GitHubClient) QueryUserProfile(id string) (*UserProfile, error) { 90 | var query userProfileQuery 91 | variables := map[string]interface{}{ 92 | "login": githubv4.String(id), 93 | } 94 | if err := c.client.Query(context.Background(), &query, variables); err != nil { 95 | return nil, err 96 | } 97 | return query.toUserProfile(), nil 98 | } 99 | 100 | type UserPullRequests struct { 101 | TotalCount int 102 | Owners []*UserPullRequestsOwner 103 | } 104 | 105 | func (p *UserPullRequests) Owner(owner string) *UserPullRequestsOwner { 106 | for _, o := range p.Owners { 107 | if o.Name == owner { 108 | return o 109 | } 110 | } 111 | return nil 112 | } 113 | 114 | type UserPullRequestsOwner struct { 115 | Name string 116 | Repositories []*UserPullRequestsRepository 117 | } 118 | 119 | type UserPullRequestsRepository struct { 120 | Name string 121 | Description string 122 | Url string 123 | Watchers int 124 | Stars int 125 | Forks int 126 | LangName string 127 | LangColor string 128 | PullRequests []*UserPullRequestsPullRequest 129 | } 130 | 131 | type UserPullRequestsPullRequest struct { 132 | Title string 133 | State string 134 | Number int 135 | Url string 136 | Additions int 137 | Deletions int 138 | Comments int 139 | CretaedAt time.Time 140 | ClosedAt time.Time 141 | } 142 | 143 | type userPullRequestsQuery struct { 144 | Search struct { 145 | IssueCount githubv4.Int 146 | Edges []userPullRequestsQueryEdge 147 | } `graphql:"search(query:$searchQuery,type:ISSUE,first:$first,after:$after)"` 148 | } 149 | 150 | type userPullRequestsQueryEdge struct { 151 | Cursor githubv4.String 152 | Node struct { 153 | PullRequest struct { 154 | Title githubv4.String 155 | State githubv4.String 156 | Number githubv4.Int 157 | Url githubv4.String 158 | Additions githubv4.Int 159 | Deletions githubv4.Int 160 | CreatedAt githubv4.DateTime 161 | ClosedAt githubv4.DateTime 162 | Repository userPullRequestsQueryRepository 163 | } `graphql:"... on PullRequest"` 164 | } 165 | } 166 | 167 | type userPullRequestsQueryRepository struct { 168 | Name githubv4.String 169 | Description githubv4.String 170 | Url githubv4.String 171 | Owner struct { 172 | Login githubv4.String 173 | } 174 | PrimaryLanguage struct { 175 | Name githubv4.String 176 | Color githubv4.String 177 | } 178 | Stargazers struct { 179 | TotalCount githubv4.Int 180 | } 181 | Watchers struct { 182 | TotalCount githubv4.Int 183 | } 184 | ForkCount githubv4.Int 185 | } 186 | 187 | func newEmptyUserPullRequestsQuery() *userPullRequestsQuery { 188 | return &userPullRequestsQuery{ 189 | Search: struct { 190 | IssueCount githubv4.Int 191 | Edges []userPullRequestsQueryEdge 192 | }{ 193 | IssueCount: 0, 194 | Edges: make([]userPullRequestsQueryEdge, 0), 195 | }, 196 | } 197 | } 198 | 199 | func (q *userPullRequestsQuery) merge(qq *userPullRequestsQuery) { 200 | q.Search.IssueCount = qq.Search.IssueCount 201 | q.Search.Edges = append(q.Search.Edges, qq.Search.Edges...) 202 | } 203 | 204 | type repoNodesMap struct { 205 | *linkedhashmap.Map 206 | } 207 | 208 | func newRepoNodesMap() *repoNodesMap { 209 | return &repoNodesMap{linkedhashmap.New()} 210 | } 211 | 212 | func (m *repoNodesMap) Exist(key string) bool { 213 | _, ok := m.Map.Get(key) 214 | return ok 215 | } 216 | 217 | func (m *repoNodesMap) Get(key string) userPullRequestsQueryRepository { 218 | node, _ := m.Map.Get(key) 219 | return node.(userPullRequestsQueryRepository) 220 | } 221 | 222 | func (m *repoNodesMap) Put(key string, value userPullRequestsQueryRepository) { 223 | m.Map.Put(key, value) 224 | } 225 | 226 | type ownerMap struct { 227 | *linkedhashmap.Map 228 | } 229 | 230 | func newOwnerMap() *ownerMap { 231 | return &ownerMap{linkedhashmap.New()} 232 | } 233 | 234 | func (m *ownerMap) Exist(key string) bool { 235 | _, ok := m.Map.Get(key) 236 | return ok 237 | } 238 | 239 | func (m *ownerMap) Get(key string) *repoMap { 240 | prs, _ := m.Map.Get(key) 241 | return prs.(*repoMap) 242 | } 243 | 244 | func (m *ownerMap) Put(key string, value *repoMap) { 245 | m.Map.Put(key, value) 246 | } 247 | 248 | func (m *ownerMap) Keys() []string { 249 | keys := m.Map.Keys() 250 | ret := make([]string, len(keys)) 251 | for i, key := range keys { 252 | ret[i] = key.(string) 253 | } 254 | return ret 255 | } 256 | 257 | type repoMap struct { 258 | *linkedhashmap.Map 259 | } 260 | 261 | func newRepoMap() *repoMap { 262 | return &repoMap{linkedhashmap.New()} 263 | } 264 | 265 | func (m *repoMap) Exist(key string) bool { 266 | _, ok := m.Map.Get(key) 267 | return ok 268 | } 269 | 270 | func (m *repoMap) Get(key string) []*UserPullRequestsPullRequest { 271 | prs, _ := m.Map.Get(key) 272 | return prs.([]*UserPullRequestsPullRequest) 273 | } 274 | 275 | func (m *repoMap) Put(key string, value []*UserPullRequestsPullRequest) { 276 | m.Map.Put(key, value) 277 | } 278 | 279 | func (m *repoMap) Keys() []string { 280 | keys := m.Map.Keys() 281 | ret := make([]string, len(keys)) 282 | for i, key := range keys { 283 | ret[i] = key.(string) 284 | } 285 | return ret 286 | } 287 | 288 | func (q *userPullRequestsQuery) toUserPullRequests() *UserPullRequests { 289 | rnMap := newRepoNodesMap() 290 | for _, edge := range q.Search.Edges { 291 | repo := edge.Node.PullRequest.Repository 292 | ownerName := string(repo.Owner.Login) 293 | repoName := string(repo.Name) 294 | key := fmt.Sprintf("%s/%s", ownerName, repoName) 295 | if !rnMap.Exist(key) { 296 | rnMap.Put(key, repo) 297 | } 298 | } 299 | 300 | ownerMap := newOwnerMap() 301 | for _, edge := range q.Search.Edges { 302 | pn := edge.Node.PullRequest 303 | ownerName := string(pn.Repository.Owner.Login) 304 | repoName := string(pn.Repository.Name) 305 | if !ownerMap.Exist(ownerName) { 306 | ownerMap.Put(ownerName, newRepoMap()) 307 | } 308 | repoMap := ownerMap.Get(ownerName) 309 | if !repoMap.Exist(repoName) { 310 | repoMap.Put(repoName, make([]*UserPullRequestsPullRequest, 0)) 311 | } 312 | pullRequests := repoMap.Get(repoName) 313 | pullRequest := &UserPullRequestsPullRequest{ 314 | Title: string(pn.Title), 315 | State: string(pn.State), 316 | Number: int(pn.Number), 317 | Url: string(pn.Url), 318 | Additions: int(pn.Additions), 319 | Deletions: int(pn.Deletions), 320 | CretaedAt: pn.CreatedAt.Time, 321 | ClosedAt: pn.ClosedAt.Time, 322 | } 323 | pullRequests = append(pullRequests, pullRequest) 324 | repoMap.Put(repoName, pullRequests) 325 | } 326 | 327 | owners := make([]*UserPullRequestsOwner, 0) 328 | for _, ownerName := range ownerMap.Keys() { 329 | repositories := make([]*UserPullRequestsRepository, 0) 330 | repoMap := ownerMap.Get(ownerName) 331 | for _, repoName := range repoMap.Keys() { 332 | key := fmt.Sprintf("%s/%s", ownerName, repoName) 333 | rn := rnMap.Get(key) 334 | prs := repoMap.Get(repoName) 335 | repository := &UserPullRequestsRepository{ 336 | Name: string(rn.Name), 337 | Description: string(rn.Description), 338 | Url: string(rn.Url), 339 | Watchers: int(rn.Watchers.TotalCount), 340 | Stars: int(rn.Stargazers.TotalCount), 341 | Forks: int(rn.ForkCount), 342 | LangName: string(rn.PrimaryLanguage.Name), 343 | LangColor: string(rn.PrimaryLanguage.Color), 344 | PullRequests: prs, 345 | } 346 | repositories = append(repositories, repository) 347 | } 348 | owner := &UserPullRequestsOwner{ 349 | Name: ownerName, 350 | Repositories: repositories, 351 | } 352 | owners = append(owners, owner) 353 | } 354 | ret := &UserPullRequests{ 355 | TotalCount: int(q.Search.IssueCount), 356 | Owners: owners, 357 | } 358 | return ret 359 | } 360 | 361 | func (c *GitHubClient) QueryUserPullRequests(id string) (*UserPullRequests, error) { 362 | q := newEmptyUserPullRequestsQuery() 363 | issueCount := math.MaxInt32 364 | total := 0 365 | cursor := "" 366 | for total < issueCount { 367 | qq, err := c.queryUserPullRequests(id, cursor) 368 | if err != nil { 369 | return nil, err 370 | } 371 | issueCount = int(qq.Search.IssueCount) 372 | if issueCount == 0 { 373 | break 374 | } 375 | edges := qq.Search.Edges 376 | cursor = string(edges[len(edges)-1].Cursor) 377 | total += len(edges) 378 | q.merge(qq) 379 | } 380 | return q.toUserPullRequests(), nil 381 | } 382 | 383 | func (c *GitHubClient) queryUserPullRequests(id, cursorAfter string) (*userPullRequestsQuery, error) { 384 | searchQuery := fmt.Sprintf("author:%s -user:%s is:pr sort:created-desc", id, id) 385 | var query userPullRequestsQuery 386 | variables := map[string]interface{}{ 387 | "searchQuery": githubv4.String(searchQuery), 388 | "first": githubv4.Int(50), 389 | } 390 | if cursorAfter == "" { 391 | variables["after"] = (*githubv4.String)(nil) 392 | } else { 393 | variables["after"] = githubv4.String(cursorAfter) 394 | } 395 | if err := c.client.Query(context.Background(), &query, variables); err != nil { 396 | return nil, err 397 | } 398 | return &query, nil 399 | } 400 | 401 | func (c *GitHubClient) QueryUserRepositories(id string) (*UserRepositories, error) { 402 | q, err := c.queryUserRepositories(id, "") 403 | if err != nil { 404 | return nil, err 405 | } 406 | hasNext := bool(q.User.Repositories.PageInfo.HasNextPage) 407 | cursor := string(q.User.Repositories.PageInfo.EndCursor) 408 | for hasNext { 409 | qq, err := c.queryUserRepositories(id, cursor) 410 | if err != nil { 411 | return nil, err 412 | } 413 | hasNext = bool(qq.User.Repositories.PageInfo.HasNextPage) 414 | cursor = string(qq.User.Repositories.PageInfo.EndCursor) 415 | q.merge(qq) 416 | } 417 | return q.toUserRepositories(), nil 418 | } 419 | 420 | func (c *GitHubClient) queryUserRepositories(id, cursorAfter string) (*userRepositoriesQuery, error) { 421 | var query userRepositoriesQuery 422 | variables := map[string]interface{}{ 423 | "login": githubv4.String(id), 424 | "first": githubv4.Int(50), 425 | } 426 | if cursorAfter == "" { 427 | variables["after"] = (*githubv4.String)(nil) 428 | } else { 429 | variables["after"] = githubv4.String(cursorAfter) 430 | } 431 | if err := c.client.Query(context.Background(), &query, variables); err != nil { 432 | return nil, err 433 | } 434 | return &query, nil 435 | } 436 | 437 | type UserRepositories struct { 438 | TotalCount int 439 | Repositories []*UserRepository 440 | } 441 | 442 | type UserRepository struct { 443 | Name string 444 | Description string 445 | Url string 446 | Watchers int 447 | Stars int 448 | Forks int 449 | LangName string 450 | LangColor string 451 | OpenedIssues int 452 | OpenedPullRequests int 453 | License string 454 | CreatedAt time.Time 455 | PushedAt time.Time 456 | } 457 | 458 | type userRepositoriesQuery struct { 459 | User struct { 460 | Repositories struct { 461 | TotalCount githubv4.Int 462 | PageInfo pageInfo 463 | Edges []userRepositoriesQueryEdge 464 | } `graphql:"repositories(orderBy:{direction:DESC,field:STARGAZERS},privacy:PUBLIC,isFork:false,first:$first,after:$after)"` 465 | } `graphql:"user(login:$login)"` 466 | } 467 | 468 | func (q *userRepositoriesQuery) merge(qq *userRepositoriesQuery) { 469 | q.User.Repositories.TotalCount = qq.User.Repositories.TotalCount 470 | q.User.Repositories.PageInfo = qq.User.Repositories.PageInfo 471 | q.User.Repositories.Edges = append(q.User.Repositories.Edges, qq.User.Repositories.Edges...) 472 | } 473 | 474 | type pageInfo struct { 475 | EndCursor githubv4.String 476 | HasNextPage githubv4.Boolean 477 | HasPreviousPage githubv4.Boolean 478 | StartCursor githubv4.String 479 | } 480 | 481 | type userRepositoriesQueryEdge struct { 482 | Cursor githubv4.String 483 | Node struct { 484 | Name githubv4.String 485 | Description githubv4.String 486 | Url githubv4.String 487 | PrimaryLanguage struct { 488 | Name githubv4.String 489 | Color githubv4.String 490 | } 491 | Stargazers struct { 492 | TotalCount githubv4.Int 493 | } 494 | Watchers struct { 495 | TotalCount githubv4.Int 496 | } 497 | Issues struct { 498 | TotalCount githubv4.Int 499 | } `graphql:"issues(states:OPEN)"` 500 | PullRequests struct { 501 | TotalCount githubv4.Int 502 | } `graphql:"pullRequests(states:OPEN)"` 503 | ForkCount githubv4.Int 504 | LicenseInfo struct { 505 | Name githubv4.String 506 | SpdxId githubv4.String // https://spdx.org/licenses 507 | } 508 | IsArchived githubv4.Boolean 509 | IsFork githubv4.Boolean 510 | IsPrivate githubv4.Boolean 511 | IsTemplate githubv4.Boolean 512 | PushedAt githubv4.DateTime 513 | CreatedAt githubv4.DateTime 514 | } 515 | } 516 | 517 | func (q *userRepositoriesQuery) toUserRepositories() *UserRepositories { 518 | repositories := make([]*UserRepository, 0) 519 | for _, edge := range q.User.Repositories.Edges { 520 | r := edge.Node 521 | repository := &UserRepository{ 522 | Name: string(r.Name), 523 | Description: string(r.Description), 524 | Url: string(r.Url), 525 | Watchers: int(r.Watchers.TotalCount), 526 | Stars: int(r.Stargazers.TotalCount), 527 | Forks: int(r.ForkCount), 528 | LangName: string(r.PrimaryLanguage.Name), 529 | LangColor: string(r.PrimaryLanguage.Color), 530 | OpenedIssues: int(r.Issues.TotalCount), 531 | OpenedPullRequests: int(r.PullRequests.TotalCount), 532 | License: string(r.LicenseInfo.SpdxId), 533 | CreatedAt: r.CreatedAt.Time, 534 | PushedAt: r.PushedAt.Time, 535 | } 536 | repositories = append(repositories, repository) 537 | } 538 | return &UserRepositories{ 539 | TotalCount: int(q.User.Repositories.TotalCount), 540 | Repositories: repositories, 541 | } 542 | } 543 | -------------------------------------------------------------------------------- /internal/gh/client_test.go: -------------------------------------------------------------------------------- 1 | package gh 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/shurcooL/githubv4" 8 | ) 9 | 10 | func equal(x, y interface{}) bool { 11 | return reflect.DeepEqual(x, y) 12 | } 13 | 14 | func notEqual(x, y interface{}) bool { 15 | return !equal(x, y) 16 | } 17 | 18 | func Test_userProfileQuery_toUserProfile(t *testing.T) { 19 | q := &userProfileQuery{ 20 | User: struct { 21 | Login githubv4.String 22 | Name githubv4.String 23 | Bio githubv4.String 24 | Followers struct { 25 | TotalCount githubv4.Int 26 | } 27 | Following struct { 28 | TotalCount githubv4.Int 29 | } 30 | Location githubv4.String 31 | Company githubv4.String 32 | WebsiteUrl githubv4.String 33 | AvatarUrl githubv4.String 34 | Url githubv4.String 35 | }{ 36 | Login: "foo", 37 | Name: "foo bar", 38 | Bio: "bar", 39 | Followers: struct { 40 | TotalCount githubv4.Int 41 | }{ 42 | TotalCount: 123, 43 | }, 44 | Following: struct { 45 | TotalCount githubv4.Int 46 | }{ 47 | TotalCount: 456, 48 | }, 49 | Location: "japan", 50 | Company: "baz", 51 | WebsiteUrl: "http://example.com/qux", 52 | AvatarUrl: "http://example.com/foo.png", 53 | Url: "http://example.com/foo", 54 | }, 55 | } 56 | want := &UserProfile{ 57 | Login: "foo", 58 | Name: "foo bar", 59 | Bio: "bar", 60 | Followers: 123, 61 | Following: 456, 62 | Location: "japan", 63 | Company: "baz", 64 | WebsiteUrl: "http://example.com/qux", 65 | AvatarUrl: "http://example.com/foo.png", 66 | Url: "http://example.com/foo", 67 | } 68 | got := q.toUserProfile() 69 | if notEqual(got, want) { 70 | t.Errorf("got: %v, want: %v", got, want) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /internal/gh/config.go: -------------------------------------------------------------------------------- 1 | package gh 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | const ( 10 | accessTokenEnvKey = "GHCV_GITHUB_ACCESS_TOKEN" 11 | ) 12 | 13 | type GithubConfig struct { 14 | AccessToken string `json:"access_token"` 15 | } 16 | 17 | func configFilePath() (string, error) { 18 | home, err := os.UserHomeDir() 19 | if err != nil { 20 | return "", err 21 | } 22 | path := filepath.Join(home, ".config", "ghcv-cli", "config.json") 23 | return path, nil 24 | } 25 | 26 | func LoadConfig() (*GithubConfig, error) { 27 | cfg := loadConfigFromEnv() 28 | if cfg != nil { 29 | return cfg, nil 30 | } 31 | return loadConfigFromFile() 32 | } 33 | 34 | func loadConfigFromEnv() *GithubConfig { 35 | token, exist := os.LookupEnv(accessTokenEnvKey) 36 | if !exist { 37 | return nil 38 | } 39 | return &GithubConfig{ 40 | AccessToken: token, 41 | } 42 | } 43 | 44 | func loadConfigFromFile() (*GithubConfig, error) { 45 | path, err := configFilePath() 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | f, err := os.Open(path) 51 | if err != nil { 52 | return nil, err 53 | } 54 | defer f.Close() 55 | 56 | var cfg GithubConfig 57 | err = json.NewDecoder(f).Decode(&cfg) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | return &cfg, nil 63 | } 64 | 65 | func SaveConfig(cfg *GithubConfig) error { 66 | path, err := configFilePath() 67 | if err != nil { 68 | return err 69 | } 70 | bytes, err := json.Marshal(cfg) 71 | if err != nil { 72 | return err 73 | } 74 | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { 75 | return err 76 | } 77 | return os.WriteFile(path, bytes, 0666) 78 | } 79 | -------------------------------------------------------------------------------- /internal/ghcv/const.go: -------------------------------------------------------------------------------- 1 | package ghcv 2 | 3 | const ( 4 | AppName = "ghcv" 5 | Version = "0.2.1" 6 | AppUrl = "https://github.com/lusingander/ghcv-cli" 7 | 8 | GitHubBaseUrl = "https://github.com/" 9 | ) 10 | -------------------------------------------------------------------------------- /internal/ui/about.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/bubbles/help" 7 | "github.com/charmbracelet/bubbles/key" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/lusingander/ghcv-cli/internal/ghcv" 11 | ) 12 | 13 | var ( 14 | aboutItemStyle = lipgloss.NewStyle(). 15 | Padding(1, 0, 1, 2) 16 | 17 | aboutItemAppNameStyle = profileItemStyle.Copy(). 18 | Bold(true) 19 | 20 | aboutItemUrlStyle = urlTextStyle.Copy(). 21 | Padding(1, 0, 1, 2) 22 | ) 23 | 24 | type aboutKeyMap struct { 25 | Open key.Binding 26 | Back key.Binding 27 | Quit key.Binding 28 | } 29 | 30 | func (k aboutKeyMap) ShortHelp() []key.Binding { 31 | return []key.Binding{ 32 | k.Open, 33 | k.Back, 34 | k.Quit, 35 | } 36 | } 37 | 38 | func (k aboutKeyMap) FullHelp() [][]key.Binding { 39 | return [][]key.Binding{ 40 | { 41 | k.Open, 42 | }, 43 | { 44 | k.Back, 45 | }, 46 | { 47 | k.Quit, 48 | }, 49 | } 50 | } 51 | 52 | type aboutModel struct { 53 | keys aboutKeyMap 54 | help help.Model 55 | 56 | width, height int 57 | } 58 | 59 | func newAboutModel() aboutModel { 60 | keys := aboutKeyMap{ 61 | Open: key.NewBinding( 62 | key.WithKeys("x"), 63 | key.WithHelp("x", "open in browser"), 64 | ), 65 | Back: key.NewBinding( 66 | key.WithKeys("backspace", "ctrl+h"), 67 | key.WithHelp("backspace", "back"), 68 | ), 69 | Quit: key.NewBinding( 70 | key.WithKeys("ctrl+c", "esc"), 71 | key.WithHelp("ctrl+c", "quit"), 72 | ), 73 | } 74 | return aboutModel{ 75 | keys: keys, 76 | help: help.New(), 77 | } 78 | } 79 | 80 | func (m *aboutModel) SetSize(width, height int) { 81 | m.width = width 82 | m.height = height 83 | m.help.Width = width 84 | } 85 | 86 | func (m aboutModel) Init() tea.Cmd { 87 | return nil 88 | } 89 | 90 | func (m aboutModel) openThisRepositoryPageInBrowser() tea.Cmd { 91 | return func() tea.Msg { 92 | if err := openBrowser(ghcv.AppUrl); err != nil { 93 | return profileErrorMsg{err, "failed to open browser"} 94 | } 95 | return nil 96 | } 97 | } 98 | 99 | func (m aboutModel) Update(msg tea.Msg) (aboutModel, tea.Cmd) { 100 | switch msg := msg.(type) { 101 | case tea.KeyMsg: 102 | switch { 103 | case key.Matches(msg, m.keys.Open): 104 | return m, m.openThisRepositoryPageInBrowser() 105 | case key.Matches(msg, m.keys.Back): 106 | return m, goBackHelpPage 107 | case key.Matches(msg, m.keys.Quit): 108 | return m, tea.Quit 109 | } 110 | } 111 | return m, nil 112 | } 113 | 114 | func (m aboutModel) View() string { 115 | if m.height <= 0 { 116 | return "" 117 | } 118 | 119 | ret := "" 120 | height := m.height - 1 121 | 122 | title := titleView(m.breadcrumb()) 123 | ret += title 124 | height -= cn(title) 125 | 126 | appName := aboutItemAppNameStyle.Render(ghcv.AppName) 127 | ret += appName 128 | height -= cn(appName) 129 | 130 | ver := aboutItemStyle.Render("Version " + ghcv.Version) 131 | ret += ver 132 | height -= cn(ver) 133 | 134 | appUrl := aboutItemUrlStyle.Render(ghcv.AppUrl) 135 | ret += appUrl 136 | height -= cn(appUrl) 137 | 138 | help := helpStyle.Render(m.help.View(m.keys)) 139 | height -= cn(help) 140 | 141 | ret += strings.Repeat("\n", height) 142 | ret += help 143 | 144 | return ret 145 | } 146 | 147 | func (m aboutModel) breadcrumb() []string { 148 | return []string{"Help", "About"} 149 | } 150 | -------------------------------------------------------------------------------- /internal/ui/app.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/spinner" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/lusingander/ghcv-cli/internal/gh" 8 | ) 9 | 10 | var ( 11 | baseStyle = lipgloss.NewStyle().Margin(1, 2) 12 | ) 13 | 14 | type page int 15 | 16 | const ( 17 | authPage page = iota 18 | userSelectPage 19 | menuPage 20 | profilePage 21 | pullRequrstsPage 22 | repositoriesPage 23 | helpPage 24 | aboutPage 25 | creditsPage 26 | ) 27 | 28 | type model struct { 29 | client *gh.GitHubClient 30 | currentPage page 31 | 32 | userSelect userSelectModel 33 | menu menuModel 34 | profile profileModel 35 | pullRequests pullRequestsModel 36 | repositories repositoriesModel 37 | help helpModel 38 | about aboutModel 39 | credits creditsModel 40 | 41 | spinner *spinner.Model 42 | } 43 | 44 | func newModel(client *gh.GitHubClient) model { 45 | s := spinner.New() 46 | s.Spinner = spinner.Moon 47 | return model{ 48 | client: client, 49 | currentPage: userSelectPage, 50 | userSelect: newUserSelectModel(client, &s), 51 | menu: newMenuModel(), 52 | profile: newProfileModel(client, &s), 53 | pullRequests: newPullRequestsModel(client, &s), 54 | repositories: newRepositoriesModel(client, &s), 55 | help: newHelpModel(), 56 | about: newAboutModel(), 57 | credits: newCreditsModel(), 58 | spinner: &s, 59 | } 60 | } 61 | 62 | func (m model) Init() tea.Cmd { 63 | return m.spinner.Tick 64 | } 65 | 66 | type userSelectMsg struct { 67 | id string 68 | } 69 | 70 | var _ tea.Msg = (*userSelectMsg)(nil) 71 | 72 | func userSelected(id string) tea.Cmd { 73 | return func() tea.Msg { return userSelectMsg{id} } 74 | } 75 | 76 | type selectRepositoriesPageMsg struct { 77 | id string 78 | } 79 | 80 | var _ tea.Msg = (*selectRepositoriesPageMsg)(nil) 81 | 82 | func selectRepositoriesPage(id string) tea.Cmd { 83 | return func() tea.Msg { return selectRepositoriesPageMsg{id} } 84 | } 85 | 86 | type selectProfilePageMsg struct { 87 | id string 88 | } 89 | 90 | var _ tea.Msg = (*selectProfilePageMsg)(nil) 91 | 92 | func selectProfilePage(id string) tea.Cmd { 93 | return func() tea.Msg { return selectProfilePageMsg{id} } 94 | } 95 | 96 | type goBackUserSelectPageMsg struct{} 97 | 98 | var _ tea.Msg = (*goBackUserSelectPageMsg)(nil) 99 | 100 | func goBackUserSelectPage() tea.Msg { 101 | return goBackUserSelectPageMsg{} 102 | } 103 | 104 | type selectPullRequestsPageMsg struct { 105 | id string 106 | } 107 | 108 | var _ tea.Msg = (*selectPullRequestsPageMsg)(nil) 109 | 110 | func selectPullRequestsPage(id string) tea.Cmd { 111 | return func() tea.Msg { return selectPullRequestsPageMsg{id} } 112 | } 113 | 114 | type selectHelpPageMsg struct{} 115 | 116 | var _ tea.Msg = (*selectHelpPageMsg)(nil) 117 | 118 | func selectHelpPage() tea.Msg { 119 | return selectHelpPageMsg{} 120 | } 121 | 122 | type selectAboutPageMsg struct{} 123 | 124 | var _ tea.Msg = (*selectAboutPageMsg)(nil) 125 | 126 | func selectAboutPage() tea.Msg { 127 | return selectAboutPageMsg{} 128 | } 129 | 130 | type selectCreditsPageMsg struct{} 131 | 132 | var _ tea.Msg = (*selectCreditsPageMsg)(nil) 133 | 134 | func selectCreditsPage() tea.Msg { 135 | return selectCreditsPageMsg{} 136 | } 137 | 138 | type goBackMenuPageMsg struct{} 139 | 140 | var _ tea.Msg = (*goBackMenuPageMsg)(nil) 141 | 142 | func goBackMenuPage() tea.Msg { 143 | return goBackMenuPageMsg{} 144 | } 145 | 146 | type goBackHelpPageMsg struct{} 147 | 148 | var _ tea.Msg = (*goBackHelpPageMsg)(nil) 149 | 150 | func goBackHelpPage() tea.Msg { 151 | return goBackHelpPageMsg{} 152 | } 153 | 154 | func (m *model) SetSize(width, height int) { 155 | m.userSelect.SetSize(width, height) 156 | m.menu.SetSize(width, height) 157 | m.profile.SetSize(width, height) 158 | m.pullRequests.SetSize(width, height) 159 | m.repositories.SetSize(width, height) 160 | m.help.SetSize(width, height) 161 | m.about.SetSize(width, height) 162 | m.credits.SetSize(width, height) 163 | } 164 | 165 | func (m *model) SetUser(id string) { 166 | m.menu.SetUser(id) 167 | m.profile.SetUser(id) 168 | m.repositories.SetUser(id) 169 | m.pullRequests.SetUser(id) 170 | } 171 | 172 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 173 | var cmd tea.Cmd 174 | cmds := make([]tea.Cmd, 0) 175 | 176 | switch msg := msg.(type) { 177 | case tea.KeyMsg: 178 | switch keypress := msg.String(); keypress { 179 | case "ctrl+c": 180 | return m, tea.Quit 181 | } 182 | case spinner.TickMsg: 183 | *m.spinner, cmd = m.spinner.Update(msg) 184 | return m, cmd 185 | case tea.WindowSizeMsg: 186 | top, right, bottom, left := baseStyle.GetMargin() 187 | m.SetSize(msg.Width-left-right, msg.Height-top-bottom) 188 | case userSelectMsg: 189 | m.SetUser(msg.id) 190 | m.currentPage = menuPage 191 | case selectProfilePageMsg: 192 | m.currentPage = profilePage 193 | case selectPullRequestsPageMsg: 194 | m.currentPage = pullRequrstsPage 195 | case selectRepositoriesPageMsg: 196 | m.currentPage = repositoriesPage 197 | case selectHelpPageMsg: 198 | m.currentPage = helpPage 199 | case selectAboutPageMsg: 200 | m.currentPage = aboutPage 201 | case selectCreditsPageMsg: 202 | m.currentPage = creditsPage 203 | case goBackUserSelectPageMsg: 204 | m.currentPage = userSelectPage 205 | case goBackMenuPageMsg: 206 | m.currentPage = menuPage 207 | case goBackHelpPageMsg: 208 | m.currentPage = helpPage 209 | } 210 | 211 | switch m.currentPage { 212 | case userSelectPage: 213 | m.userSelect, cmd = m.userSelect.Update(msg) 214 | cmds = append(cmds, cmd) 215 | case menuPage: 216 | m.menu, cmd = m.menu.Update(msg) 217 | cmds = append(cmds, cmd) 218 | case profilePage: 219 | m.profile, cmd = m.profile.Update(msg) 220 | cmds = append(cmds, cmd) 221 | case pullRequrstsPage: 222 | m.pullRequests, cmd = m.pullRequests.Update(msg) 223 | cmds = append(cmds, cmd) 224 | case repositoriesPage: 225 | m.repositories, cmd = m.repositories.Update(msg) 226 | cmds = append(cmds, cmd) 227 | case helpPage: 228 | m.help, cmd = m.help.Update(msg) 229 | cmds = append(cmds, cmd) 230 | case aboutPage: 231 | m.about, cmd = m.about.Update(msg) 232 | cmds = append(cmds, cmd) 233 | case creditsPage: 234 | m.credits, cmd = m.credits.Update(msg) 235 | cmds = append(cmds, cmd) 236 | default: 237 | return m, nil 238 | } 239 | 240 | return m, tea.Batch(cmds...) 241 | } 242 | 243 | func (m model) View() string { 244 | switch m.currentPage { 245 | case userSelectPage: 246 | return baseStyle.Render(m.userSelect.View()) 247 | case menuPage: 248 | return baseStyle.Render(m.menu.View()) 249 | case profilePage: 250 | return baseStyle.Render(m.profile.View()) 251 | case pullRequrstsPage: 252 | return baseStyle.Render(m.pullRequests.View()) 253 | case repositoriesPage: 254 | return baseStyle.Render(m.repositories.View()) 255 | case helpPage: 256 | return baseStyle.Render(m.help.View()) 257 | case aboutPage: 258 | return baseStyle.Render(m.about.View()) 259 | case creditsPage: 260 | return baseStyle.Render(m.credits.View()) 261 | } 262 | return baseStyle.Render("error... :(") 263 | } 264 | 265 | func Start(client *gh.GitHubClient) error { 266 | m := newModel(client) 267 | p := tea.NewProgram(m, tea.WithAltScreen()) 268 | _, err := p.Run() 269 | return err 270 | } 271 | -------------------------------------------------------------------------------- /internal/ui/common.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/bubbles/list" 7 | "github.com/charmbracelet/bubbles/spinner" 8 | "github.com/charmbracelet/lipgloss" 9 | ) 10 | 11 | const ( 12 | appTitle = "GHCV" 13 | ) 14 | 15 | var ( 16 | titleBarStyle = lipgloss.NewStyle(). 17 | Padding(0, 0, 1, 2) 18 | 19 | titleStyle = lipgloss.NewStyle(). 20 | Background(lipgloss.Color("97")). 21 | Foreground(lipgloss.Color("229")). 22 | Padding(0, 1) 23 | 24 | breadcrumbStyle = lipgloss.NewStyle(). 25 | Foreground(lipgloss.Color("240")). 26 | Padding(0, 0, 0, 2) 27 | 28 | listStyle = lipgloss.NewStyle(). 29 | MarginTop(1) 30 | 31 | spinnerStyle = lipgloss.NewStyle(). 32 | Padding(2, 0, 0, 2) 33 | 34 | helpStyle = lipgloss.NewStyle(). 35 | Padding(1, 0, 0, 2) 36 | 37 | urlTextStyle = lipgloss.NewStyle(). 38 | Foreground(lipgloss.Color("33")). 39 | Underline(true) 40 | ) 41 | 42 | var ( 43 | selectedColor1 = lipgloss.Color("142") 44 | selectedColor2 = lipgloss.Color("143") 45 | 46 | listNormalTitleColorStyle = lipgloss.NewStyle(). 47 | Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}) 48 | 49 | listNormalItemStyle = lipgloss.NewStyle(). 50 | Padding(0, 0, 0, 2) 51 | 52 | listNormalTitleStyle = listNormalTitleColorStyle.Copy(). 53 | Padding(0, 0, 0, 2) 54 | 55 | listNormalDescColorStyle = lipgloss.NewStyle(). 56 | Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}) 57 | 58 | listNormalDescStyle = listNormalDescColorStyle.Copy(). 59 | Padding(0, 0, 0, 2) 60 | 61 | listSelectedTitleColorStyle = lipgloss.NewStyle(). 62 | Foreground(selectedColor1) 63 | 64 | listSelectedItemStyle = lipgloss.NewStyle(). 65 | Border(lipgloss.NormalBorder(), false, false, false, true). 66 | BorderForeground(selectedColor2). 67 | Padding(0, 0, 0, 1) 68 | 69 | listSelectedTitleStyle = listSelectedItemStyle.Copy(). 70 | Foreground(selectedColor1) 71 | 72 | listSelectedDescColorStyle = listSelectedTitleColorStyle.Copy(). 73 | Foreground(selectedColor2) 74 | 75 | listSelectedDescStyle = listSelectedItemStyle.Copy(). 76 | Foreground(selectedColor2) 77 | ) 78 | 79 | func titleView(bcs []string) string { 80 | // bubbles/list/styles.go 81 | title := titleStyle.Render(appTitle) 82 | if bcs != nil { 83 | title += breadcrumbStyle.Render(strings.Join(bcs, " > ")) 84 | } 85 | return titleBarStyle.Render(title) 86 | } 87 | 88 | func listView(l list.Model) string { 89 | return listStyle.Render(l.View()) 90 | } 91 | 92 | func loadingView(s *spinner.Model, bc []string) string { 93 | ret := "" 94 | 95 | title := titleView(bc) 96 | ret += title 97 | 98 | sp := spinnerStyle.Render(s.View() + " Loading...") 99 | ret += sp 100 | 101 | return ret 102 | } 103 | 104 | func cn(view string) int { 105 | return strings.Count(view, "\n") 106 | } 107 | -------------------------------------------------------------------------------- /internal/ui/credits.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/bubbles/help" 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/viewport" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | ) 12 | 13 | var ( 14 | creditsViewportStyle = lipgloss.NewStyle(). 15 | Padding(1, 0, 1, 2) 16 | 17 | creditsRepositoryNameStyle = lipgloss.NewStyle(). 18 | Bold(true) 19 | 20 | creditsUrlStyle = urlTextStyle.Copy() 21 | 22 | creditsSeparatorStyle = lipgloss.NewStyle(). 23 | Foreground(lipgloss.Color("240")) 24 | 25 | creditsSeparator = "----------------------------------------" 26 | ) 27 | 28 | type creditsKeyMap struct { 29 | Back key.Binding 30 | Quit key.Binding 31 | } 32 | 33 | func (k creditsKeyMap) ShortHelp() []key.Binding { 34 | return []key.Binding{ 35 | k.Back, 36 | k.Quit, 37 | } 38 | } 39 | 40 | func (k creditsKeyMap) FullHelp() [][]key.Binding { 41 | return [][]key.Binding{ 42 | { 43 | k.Back, 44 | }, 45 | { 46 | k.Quit, 47 | }, 48 | } 49 | } 50 | 51 | type creditsModel struct { 52 | viewport viewport.Model 53 | 54 | keys creditsKeyMap 55 | help help.Model 56 | 57 | width, height int 58 | } 59 | 60 | func newCreditsModel() creditsModel { 61 | keys := creditsKeyMap{ 62 | Back: key.NewBinding( 63 | key.WithKeys("backspace", "ctrl+h"), 64 | key.WithHelp("backspace", "back"), 65 | ), 66 | Quit: key.NewBinding( 67 | key.WithKeys("ctrl+c", "esc"), 68 | key.WithHelp("ctrl+c", "quit"), 69 | ), 70 | } 71 | 72 | contents := "" 73 | for _, c := range credits { 74 | contents += creditsRepositoryNameStyle.Render(c.name) + "\n\n" 75 | contents += creditsUrlStyle.Render(c.url) + "\n\n" 76 | contents += c.text + "\n" 77 | contents += creditsSeparatorStyle.Render(creditsSeparator) + "\n\n" 78 | } 79 | 80 | v := viewport.New(0, 0) 81 | v.SetContent(contents) 82 | 83 | return creditsModel{ 84 | keys: keys, 85 | viewport: v, 86 | help: help.New(), 87 | } 88 | } 89 | 90 | func (m *creditsModel) SetSize(width, height int) { 91 | m.width = width 92 | m.height = height 93 | m.help.Width = width 94 | t, r, b, l := creditsViewportStyle.GetPadding() 95 | m.viewport.Width = width - r - l 96 | m.viewport.Height = height - 2 - t - b 97 | } 98 | 99 | func (m creditsModel) Init() tea.Cmd { 100 | return nil 101 | } 102 | 103 | func (m creditsModel) Update(msg tea.Msg) (creditsModel, tea.Cmd) { 104 | switch msg := msg.(type) { 105 | case tea.KeyMsg: 106 | switch { 107 | case key.Matches(msg, m.keys.Back): 108 | return m, goBackHelpPage 109 | case key.Matches(msg, m.keys.Quit): 110 | return m, tea.Quit 111 | } 112 | case selectCreditsPageMsg: 113 | m.viewport.GotoTop() 114 | } 115 | var cmd tea.Cmd 116 | m.viewport, cmd = m.viewport.Update(msg) 117 | return m, cmd 118 | } 119 | 120 | func (m creditsModel) View() string { 121 | if m.height <= 0 { 122 | return "" 123 | } 124 | 125 | ret := "" 126 | height := m.height - 1 127 | 128 | title := titleView(m.breadcrumb()) 129 | ret += title 130 | height -= cn(title) 131 | 132 | credits := creditsViewportStyle.Render(m.viewport.View()) 133 | ret += credits 134 | height -= cn(credits) 135 | 136 | help := helpStyle.Render(m.help.View(m.keys)) 137 | height -= cn(help) 138 | 139 | ret += strings.Repeat("\n", height) 140 | ret += help 141 | 142 | return ret 143 | } 144 | 145 | func (m creditsModel) breadcrumb() []string { 146 | return []string{"Help", "Credits"} 147 | } 148 | -------------------------------------------------------------------------------- /internal/ui/credits_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by gen.go; DO NOT EDIT. 2 | 3 | package ui 4 | 5 | type credit struct { 6 | name, url, text string 7 | } 8 | 9 | var credits = []*credit{ 10 | { 11 | "Go (the standard library)", 12 | "https://golang.org/", 13 | ` 14 | Copyright (c) 2009 The Go Authors. All rights reserved. 15 | 16 | Redistribution and use in source and binary forms, with or without 17 | modification, are permitted provided that the following conditions are 18 | met: 19 | 20 | * Redistributions of source code must retain the above copyright 21 | notice, this list of conditions and the following disclaimer. 22 | * Redistributions in binary form must reproduce the above 23 | copyright notice, this list of conditions and the following disclaimer 24 | in the documentation and/or other materials provided with the 25 | distribution. 26 | * Neither the name of Google Inc. nor the names of its 27 | contributors may be used to endorse or promote products derived from 28 | this software without specific prior written permission. 29 | 30 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 31 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 32 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 33 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 34 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 35 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 36 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 37 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 38 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 39 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 40 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 41 | 42 | `, 43 | }, 44 | { 45 | "github.com/Songmu/gocredits", 46 | "https://github.com/Songmu/gocredits", 47 | ` 48 | Copyright (c) 2019 Songmu 49 | 50 | MIT License 51 | 52 | Permission is hereby granted, free of charge, to any person obtaining 53 | a copy of this software and associated documentation files (the 54 | "Software"), to deal in the Software without restriction, including 55 | without limitation the rights to use, copy, modify, merge, publish, 56 | distribute, sublicense, and/or sell copies of the Software, and to 57 | permit persons to whom the Software is furnished to do so, subject to 58 | the following conditions: 59 | 60 | The above copyright notice and this permission notice shall be 61 | included in all copies or substantial portions of the Software. 62 | 63 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 64 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 65 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 66 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 67 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 68 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 69 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 70 | 71 | `, 72 | }, 73 | { 74 | "github.com/atotto/clipboard", 75 | "https://github.com/atotto/clipboard", 76 | ` 77 | Copyright (c) 2013 Ato Araki. All rights reserved. 78 | 79 | Redistribution and use in source and binary forms, with or without 80 | modification, are permitted provided that the following conditions are 81 | met: 82 | 83 | * Redistributions of source code must retain the above copyright 84 | notice, this list of conditions and the following disclaimer. 85 | * Redistributions in binary form must reproduce the above 86 | copyright notice, this list of conditions and the following disclaimer 87 | in the documentation and/or other materials provided with the 88 | distribution. 89 | * Neither the name of @atotto. nor the names of its 90 | contributors may be used to endorse or promote products derived from 91 | this software without specific prior written permission. 92 | 93 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 94 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 95 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 96 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 97 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 98 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 99 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 100 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 101 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 102 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 103 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 104 | 105 | `, 106 | }, 107 | { 108 | "github.com/aymanbagabas/go-osc52/v2", 109 | "https://github.com/aymanbagabas/go-osc52/v2", 110 | ` 111 | MIT License 112 | 113 | Copyright (c) 2022 Ayman Bagabas 114 | 115 | Permission is hereby granted, free of charge, to any person obtaining a copy 116 | of this software and associated documentation files (the "Software"), to deal 117 | in the Software without restriction, including without limitation the rights 118 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 119 | copies of the Software, and to permit persons to whom the Software is 120 | furnished to do so, subject to the following conditions: 121 | 122 | The above copyright notice and this permission notice shall be included in all 123 | copies or substantial portions of the Software. 124 | 125 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 126 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 127 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 128 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 129 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 130 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 131 | SOFTWARE. 132 | 133 | `, 134 | }, 135 | { 136 | "github.com/charmbracelet/bubbles", 137 | "https://github.com/charmbracelet/bubbles", 138 | ` 139 | MIT License 140 | 141 | Copyright (c) 2020-2023 Charmbracelet, Inc 142 | 143 | Permission is hereby granted, free of charge, to any person obtaining a copy 144 | of this software and associated documentation files (the "Software"), to deal 145 | in the Software without restriction, including without limitation the rights 146 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 147 | copies of the Software, and to permit persons to whom the Software is 148 | furnished to do so, subject to the following conditions: 149 | 150 | The above copyright notice and this permission notice shall be included in all 151 | copies or substantial portions of the Software. 152 | 153 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 154 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 155 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 156 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 157 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 158 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 159 | SOFTWARE. 160 | 161 | `, 162 | }, 163 | { 164 | "github.com/charmbracelet/bubbletea", 165 | "https://github.com/charmbracelet/bubbletea", 166 | ` 167 | MIT License 168 | 169 | Copyright (c) 2020-2023 Charmbracelet, Inc 170 | 171 | Permission is hereby granted, free of charge, to any person obtaining a copy 172 | of this software and associated documentation files (the "Software"), to deal 173 | in the Software without restriction, including without limitation the rights 174 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 175 | copies of the Software, and to permit persons to whom the Software is 176 | furnished to do so, subject to the following conditions: 177 | 178 | The above copyright notice and this permission notice shall be included in all 179 | copies or substantial portions of the Software. 180 | 181 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 182 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 183 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 184 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 185 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 186 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 187 | SOFTWARE. 188 | 189 | `, 190 | }, 191 | { 192 | "github.com/charmbracelet/lipgloss", 193 | "https://github.com/charmbracelet/lipgloss", 194 | ` 195 | MIT License 196 | 197 | Copyright (c) 2021-2023 Charmbracelet, Inc 198 | 199 | Permission is hereby granted, free of charge, to any person obtaining a copy 200 | of this software and associated documentation files (the "Software"), to deal 201 | in the Software without restriction, including without limitation the rights 202 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 203 | copies of the Software, and to permit persons to whom the Software is 204 | furnished to do so, subject to the following conditions: 205 | 206 | The above copyright notice and this permission notice shall be included in all 207 | copies or substantial portions of the Software. 208 | 209 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 210 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 211 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 212 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 213 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 214 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 215 | SOFTWARE. 216 | 217 | `, 218 | }, 219 | { 220 | "github.com/containerd/console", 221 | "https://github.com/containerd/console", 222 | ` 223 | 224 | Apache License 225 | Version 2.0, January 2004 226 | https://www.apache.org/licenses/ 227 | 228 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 229 | 230 | 1. Definitions. 231 | 232 | "License" shall mean the terms and conditions for use, reproduction, 233 | and distribution as defined by Sections 1 through 9 of this document. 234 | 235 | "Licensor" shall mean the copyright owner or entity authorized by 236 | the copyright owner that is granting the License. 237 | 238 | "Legal Entity" shall mean the union of the acting entity and all 239 | other entities that control, are controlled by, or are under common 240 | control with that entity. For the purposes of this definition, 241 | "control" means (i) the power, direct or indirect, to cause the 242 | direction or management of such entity, whether by contract or 243 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 244 | outstanding shares, or (iii) beneficial ownership of such entity. 245 | 246 | "You" (or "Your") shall mean an individual or Legal Entity 247 | exercising permissions granted by this License. 248 | 249 | "Source" form shall mean the preferred form for making modifications, 250 | including but not limited to software source code, documentation 251 | source, and configuration files. 252 | 253 | "Object" form shall mean any form resulting from mechanical 254 | transformation or translation of a Source form, including but 255 | not limited to compiled object code, generated documentation, 256 | and conversions to other media types. 257 | 258 | "Work" shall mean the work of authorship, whether in Source or 259 | Object form, made available under the License, as indicated by a 260 | copyright notice that is included in or attached to the work 261 | (an example is provided in the Appendix below). 262 | 263 | "Derivative Works" shall mean any work, whether in Source or Object 264 | form, that is based on (or derived from) the Work and for which the 265 | editorial revisions, annotations, elaborations, or other modifications 266 | represent, as a whole, an original work of authorship. For the purposes 267 | of this License, Derivative Works shall not include works that remain 268 | separable from, or merely link (or bind by name) to the interfaces of, 269 | the Work and Derivative Works thereof. 270 | 271 | "Contribution" shall mean any work of authorship, including 272 | the original version of the Work and any modifications or additions 273 | to that Work or Derivative Works thereof, that is intentionally 274 | submitted to Licensor for inclusion in the Work by the copyright owner 275 | or by an individual or Legal Entity authorized to submit on behalf of 276 | the copyright owner. For the purposes of this definition, "submitted" 277 | means any form of electronic, verbal, or written communication sent 278 | to the Licensor or its representatives, including but not limited to 279 | communication on electronic mailing lists, source code control systems, 280 | and issue tracking systems that are managed by, or on behalf of, the 281 | Licensor for the purpose of discussing and improving the Work, but 282 | excluding communication that is conspicuously marked or otherwise 283 | designated in writing by the copyright owner as "Not a Contribution." 284 | 285 | "Contributor" shall mean Licensor and any individual or Legal Entity 286 | on behalf of whom a Contribution has been received by Licensor and 287 | subsequently incorporated within the Work. 288 | 289 | 2. Grant of Copyright License. Subject to the terms and conditions of 290 | this License, each Contributor hereby grants to You a perpetual, 291 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 292 | copyright license to reproduce, prepare Derivative Works of, 293 | publicly display, publicly perform, sublicense, and distribute the 294 | Work and such Derivative Works in Source or Object form. 295 | 296 | 3. Grant of Patent License. Subject to the terms and conditions of 297 | this License, each Contributor hereby grants to You a perpetual, 298 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 299 | (except as stated in this section) patent license to make, have made, 300 | use, offer to sell, sell, import, and otherwise transfer the Work, 301 | where such license applies only to those patent claims licensable 302 | by such Contributor that are necessarily infringed by their 303 | Contribution(s) alone or by combination of their Contribution(s) 304 | with the Work to which such Contribution(s) was submitted. If You 305 | institute patent litigation against any entity (including a 306 | cross-claim or counterclaim in a lawsuit) alleging that the Work 307 | or a Contribution incorporated within the Work constitutes direct 308 | or contributory patent infringement, then any patent licenses 309 | granted to You under this License for that Work shall terminate 310 | as of the date such litigation is filed. 311 | 312 | 4. Redistribution. You may reproduce and distribute copies of the 313 | Work or Derivative Works thereof in any medium, with or without 314 | modifications, and in Source or Object form, provided that You 315 | meet the following conditions: 316 | 317 | (a) You must give any other recipients of the Work or 318 | Derivative Works a copy of this License; and 319 | 320 | (b) You must cause any modified files to carry prominent notices 321 | stating that You changed the files; and 322 | 323 | (c) You must retain, in the Source form of any Derivative Works 324 | that You distribute, all copyright, patent, trademark, and 325 | attribution notices from the Source form of the Work, 326 | excluding those notices that do not pertain to any part of 327 | the Derivative Works; and 328 | 329 | (d) If the Work includes a "NOTICE" text file as part of its 330 | distribution, then any Derivative Works that You distribute must 331 | include a readable copy of the attribution notices contained 332 | within such NOTICE file, excluding those notices that do not 333 | pertain to any part of the Derivative Works, in at least one 334 | of the following places: within a NOTICE text file distributed 335 | as part of the Derivative Works; within the Source form or 336 | documentation, if provided along with the Derivative Works; or, 337 | within a display generated by the Derivative Works, if and 338 | wherever such third-party notices normally appear. The contents 339 | of the NOTICE file are for informational purposes only and 340 | do not modify the License. You may add Your own attribution 341 | notices within Derivative Works that You distribute, alongside 342 | or as an addendum to the NOTICE text from the Work, provided 343 | that such additional attribution notices cannot be construed 344 | as modifying the License. 345 | 346 | You may add Your own copyright statement to Your modifications and 347 | may provide additional or different license terms and conditions 348 | for use, reproduction, or distribution of Your modifications, or 349 | for any such Derivative Works as a whole, provided Your use, 350 | reproduction, and distribution of the Work otherwise complies with 351 | the conditions stated in this License. 352 | 353 | 5. Submission of Contributions. Unless You explicitly state otherwise, 354 | any Contribution intentionally submitted for inclusion in the Work 355 | by You to the Licensor shall be under the terms and conditions of 356 | this License, without any additional terms or conditions. 357 | Notwithstanding the above, nothing herein shall supersede or modify 358 | the terms of any separate license agreement you may have executed 359 | with Licensor regarding such Contributions. 360 | 361 | 6. Trademarks. This License does not grant permission to use the trade 362 | names, trademarks, service marks, or product names of the Licensor, 363 | except as required for reasonable and customary use in describing the 364 | origin of the Work and reproducing the content of the NOTICE file. 365 | 366 | 7. Disclaimer of Warranty. Unless required by applicable law or 367 | agreed to in writing, Licensor provides the Work (and each 368 | Contributor provides its Contributions) on an "AS IS" BASIS, 369 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 370 | implied, including, without limitation, any warranties or conditions 371 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 372 | PARTICULAR PURPOSE. You are solely responsible for determining the 373 | appropriateness of using or redistributing the Work and assume any 374 | risks associated with Your exercise of permissions under this License. 375 | 376 | 8. Limitation of Liability. In no event and under no legal theory, 377 | whether in tort (including negligence), contract, or otherwise, 378 | unless required by applicable law (such as deliberate and grossly 379 | negligent acts) or agreed to in writing, shall any Contributor be 380 | liable to You for damages, including any direct, indirect, special, 381 | incidental, or consequential damages of any character arising as a 382 | result of this License or out of the use or inability to use the 383 | Work (including but not limited to damages for loss of goodwill, 384 | work stoppage, computer failure or malfunction, or any and all 385 | other commercial damages or losses), even if such Contributor 386 | has been advised of the possibility of such damages. 387 | 388 | 9. Accepting Warranty or Additional Liability. While redistributing 389 | the Work or Derivative Works thereof, You may choose to offer, 390 | and charge a fee for, acceptance of support, warranty, indemnity, 391 | or other liability obligations and/or rights consistent with this 392 | License. However, in accepting such obligations, You may act only 393 | on Your own behalf and on Your sole responsibility, not on behalf 394 | of any other Contributor, and only if You agree to indemnify, 395 | defend, and hold each Contributor harmless for any liability 396 | incurred by, or claims asserted against, such Contributor by reason 397 | of your accepting any such warranty or additional liability. 398 | 399 | END OF TERMS AND CONDITIONS 400 | 401 | Copyright The containerd Authors 402 | 403 | Licensed under the Apache License, Version 2.0 (the "License"); 404 | you may not use this file except in compliance with the License. 405 | You may obtain a copy of the License at 406 | 407 | https://www.apache.org/licenses/LICENSE-2.0 408 | 409 | Unless required by applicable law or agreed to in writing, software 410 | distributed under the License is distributed on an "AS IS" BASIS, 411 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 412 | See the License for the specific language governing permissions and 413 | limitations under the License. 414 | 415 | `, 416 | }, 417 | { 418 | "github.com/emirpasic/gods", 419 | "https://github.com/emirpasic/gods", 420 | ` 421 | Copyright (c) 2015, Emir Pasic 422 | All rights reserved. 423 | 424 | Redistribution and use in source and binary forms, with or without 425 | modification, are permitted provided that the following conditions are met: 426 | 427 | * Redistributions of source code must retain the above copyright notice, this 428 | list of conditions and the following disclaimer. 429 | 430 | * Redistributions in binary form must reproduce the above copyright notice, 431 | this list of conditions and the following disclaimer in the documentation 432 | and/or other materials provided with the distribution. 433 | 434 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 435 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 436 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 437 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 438 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 439 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 440 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 441 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 442 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 443 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 444 | 445 | `, 446 | }, 447 | { 448 | "github.com/google/go-cmp", 449 | "https://github.com/google/go-cmp", 450 | ` 451 | Copyright (c) 2017 The Go Authors. All rights reserved. 452 | 453 | Redistribution and use in source and binary forms, with or without 454 | modification, are permitted provided that the following conditions are 455 | met: 456 | 457 | * Redistributions of source code must retain the above copyright 458 | notice, this list of conditions and the following disclaimer. 459 | * Redistributions in binary form must reproduce the above 460 | copyright notice, this list of conditions and the following disclaimer 461 | in the documentation and/or other materials provided with the 462 | distribution. 463 | * Neither the name of Google Inc. nor the names of its 464 | contributors may be used to endorse or promote products derived from 465 | this software without specific prior written permission. 466 | 467 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 468 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 469 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 470 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 471 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 472 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 473 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 474 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 475 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 476 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 477 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 478 | 479 | `, 480 | }, 481 | { 482 | "github.com/kylelemons/godebug", 483 | "https://github.com/kylelemons/godebug", 484 | ` 485 | 486 | Apache License 487 | Version 2.0, January 2004 488 | http://www.apache.org/licenses/ 489 | 490 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 491 | 492 | 1. Definitions. 493 | 494 | "License" shall mean the terms and conditions for use, reproduction, 495 | and distribution as defined by Sections 1 through 9 of this document. 496 | 497 | "Licensor" shall mean the copyright owner or entity authorized by 498 | the copyright owner that is granting the License. 499 | 500 | "Legal Entity" shall mean the union of the acting entity and all 501 | other entities that control, are controlled by, or are under common 502 | control with that entity. For the purposes of this definition, 503 | "control" means (i) the power, direct or indirect, to cause the 504 | direction or management of such entity, whether by contract or 505 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 506 | outstanding shares, or (iii) beneficial ownership of such entity. 507 | 508 | "You" (or "Your") shall mean an individual or Legal Entity 509 | exercising permissions granted by this License. 510 | 511 | "Source" form shall mean the preferred form for making modifications, 512 | including but not limited to software source code, documentation 513 | source, and configuration files. 514 | 515 | "Object" form shall mean any form resulting from mechanical 516 | transformation or translation of a Source form, including but 517 | not limited to compiled object code, generated documentation, 518 | and conversions to other media types. 519 | 520 | "Work" shall mean the work of authorship, whether in Source or 521 | Object form, made available under the License, as indicated by a 522 | copyright notice that is included in or attached to the work 523 | (an example is provided in the Appendix below). 524 | 525 | "Derivative Works" shall mean any work, whether in Source or Object 526 | form, that is based on (or derived from) the Work and for which the 527 | editorial revisions, annotations, elaborations, or other modifications 528 | represent, as a whole, an original work of authorship. For the purposes 529 | of this License, Derivative Works shall not include works that remain 530 | separable from, or merely link (or bind by name) to the interfaces of, 531 | the Work and Derivative Works thereof. 532 | 533 | "Contribution" shall mean any work of authorship, including 534 | the original version of the Work and any modifications or additions 535 | to that Work or Derivative Works thereof, that is intentionally 536 | submitted to Licensor for inclusion in the Work by the copyright owner 537 | or by an individual or Legal Entity authorized to submit on behalf of 538 | the copyright owner. For the purposes of this definition, "submitted" 539 | means any form of electronic, verbal, or written communication sent 540 | to the Licensor or its representatives, including but not limited to 541 | communication on electronic mailing lists, source code control systems, 542 | and issue tracking systems that are managed by, or on behalf of, the 543 | Licensor for the purpose of discussing and improving the Work, but 544 | excluding communication that is conspicuously marked or otherwise 545 | designated in writing by the copyright owner as "Not a Contribution." 546 | 547 | "Contributor" shall mean Licensor and any individual or Legal Entity 548 | on behalf of whom a Contribution has been received by Licensor and 549 | subsequently incorporated within the Work. 550 | 551 | 2. Grant of Copyright License. Subject to the terms and conditions of 552 | this License, each Contributor hereby grants to You a perpetual, 553 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 554 | copyright license to reproduce, prepare Derivative Works of, 555 | publicly display, publicly perform, sublicense, and distribute the 556 | Work and such Derivative Works in Source or Object form. 557 | 558 | 3. Grant of Patent License. Subject to the terms and conditions of 559 | this License, each Contributor hereby grants to You a perpetual, 560 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 561 | (except as stated in this section) patent license to make, have made, 562 | use, offer to sell, sell, import, and otherwise transfer the Work, 563 | where such license applies only to those patent claims licensable 564 | by such Contributor that are necessarily infringed by their 565 | Contribution(s) alone or by combination of their Contribution(s) 566 | with the Work to which such Contribution(s) was submitted. If You 567 | institute patent litigation against any entity (including a 568 | cross-claim or counterclaim in a lawsuit) alleging that the Work 569 | or a Contribution incorporated within the Work constitutes direct 570 | or contributory patent infringement, then any patent licenses 571 | granted to You under this License for that Work shall terminate 572 | as of the date such litigation is filed. 573 | 574 | 4. Redistribution. You may reproduce and distribute copies of the 575 | Work or Derivative Works thereof in any medium, with or without 576 | modifications, and in Source or Object form, provided that You 577 | meet the following conditions: 578 | 579 | (a) You must give any other recipients of the Work or 580 | Derivative Works a copy of this License; and 581 | 582 | (b) You must cause any modified files to carry prominent notices 583 | stating that You changed the files; and 584 | 585 | (c) You must retain, in the Source form of any Derivative Works 586 | that You distribute, all copyright, patent, trademark, and 587 | attribution notices from the Source form of the Work, 588 | excluding those notices that do not pertain to any part of 589 | the Derivative Works; and 590 | 591 | (d) If the Work includes a "NOTICE" text file as part of its 592 | distribution, then any Derivative Works that You distribute must 593 | include a readable copy of the attribution notices contained 594 | within such NOTICE file, excluding those notices that do not 595 | pertain to any part of the Derivative Works, in at least one 596 | of the following places: within a NOTICE text file distributed 597 | as part of the Derivative Works; within the Source form or 598 | documentation, if provided along with the Derivative Works; or, 599 | within a display generated by the Derivative Works, if and 600 | wherever such third-party notices normally appear. The contents 601 | of the NOTICE file are for informational purposes only and 602 | do not modify the License. You may add Your own attribution 603 | notices within Derivative Works that You distribute, alongside 604 | or as an addendum to the NOTICE text from the Work, provided 605 | that such additional attribution notices cannot be construed 606 | as modifying the License. 607 | 608 | You may add Your own copyright statement to Your modifications and 609 | may provide additional or different license terms and conditions 610 | for use, reproduction, or distribution of Your modifications, or 611 | for any such Derivative Works as a whole, provided Your use, 612 | reproduction, and distribution of the Work otherwise complies with 613 | the conditions stated in this License. 614 | 615 | 5. Submission of Contributions. Unless You explicitly state otherwise, 616 | any Contribution intentionally submitted for inclusion in the Work 617 | by You to the Licensor shall be under the terms and conditions of 618 | this License, without any additional terms or conditions. 619 | Notwithstanding the above, nothing herein shall supersede or modify 620 | the terms of any separate license agreement you may have executed 621 | with Licensor regarding such Contributions. 622 | 623 | 6. Trademarks. This License does not grant permission to use the trade 624 | names, trademarks, service marks, or product names of the Licensor, 625 | except as required for reasonable and customary use in describing the 626 | origin of the Work and reproducing the content of the NOTICE file. 627 | 628 | 7. Disclaimer of Warranty. Unless required by applicable law or 629 | agreed to in writing, Licensor provides the Work (and each 630 | Contributor provides its Contributions) on an "AS IS" BASIS, 631 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 632 | implied, including, without limitation, any warranties or conditions 633 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 634 | PARTICULAR PURPOSE. You are solely responsible for determining the 635 | appropriateness of using or redistributing the Work and assume any 636 | risks associated with Your exercise of permissions under this License. 637 | 638 | 8. Limitation of Liability. In no event and under no legal theory, 639 | whether in tort (including negligence), contract, or otherwise, 640 | unless required by applicable law (such as deliberate and grossly 641 | negligent acts) or agreed to in writing, shall any Contributor be 642 | liable to You for damages, including any direct, indirect, special, 643 | incidental, or consequential damages of any character arising as a 644 | result of this License or out of the use or inability to use the 645 | Work (including but not limited to damages for loss of goodwill, 646 | work stoppage, computer failure or malfunction, or any and all 647 | other commercial damages or losses), even if such Contributor 648 | has been advised of the possibility of such damages. 649 | 650 | 9. Accepting Warranty or Additional Liability. While redistributing 651 | the Work or Derivative Works thereof, You may choose to offer, 652 | and charge a fee for, acceptance of support, warranty, indemnity, 653 | or other liability obligations and/or rights consistent with this 654 | License. However, in accepting such obligations, You may act only 655 | on Your own behalf and on Your sole responsibility, not on behalf 656 | of any other Contributor, and only if You agree to indemnify, 657 | defend, and hold each Contributor harmless for any liability 658 | incurred by, or claims asserted against, such Contributor by reason 659 | of your accepting any such warranty or additional liability. 660 | 661 | END OF TERMS AND CONDITIONS 662 | 663 | APPENDIX: How to apply the Apache License to your work. 664 | 665 | To apply the Apache License to your work, attach the following 666 | boilerplate notice, with the fields enclosed by brackets "[]" 667 | replaced with your own identifying information. (Don't include 668 | the brackets!) The text should be enclosed in the appropriate 669 | comment syntax for the file format. We also recommend that a 670 | file or class name and description of purpose be included on the 671 | same "printed page" as the copyright notice for easier 672 | identification within third-party archives. 673 | 674 | Copyright [yyyy] [name of copyright owner] 675 | 676 | Licensed under the Apache License, Version 2.0 (the "License"); 677 | you may not use this file except in compliance with the License. 678 | You may obtain a copy of the License at 679 | 680 | http://www.apache.org/licenses/LICENSE-2.0 681 | 682 | Unless required by applicable law or agreed to in writing, software 683 | distributed under the License is distributed on an "AS IS" BASIS, 684 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 685 | See the License for the specific language governing permissions and 686 | limitations under the License. 687 | 688 | `, 689 | }, 690 | { 691 | "github.com/lucasb-eyer/go-colorful", 692 | "https://github.com/lucasb-eyer/go-colorful", 693 | ` 694 | Copyright (c) 2013 Lucas Beyer 695 | 696 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 697 | 698 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 699 | 700 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 701 | 702 | `, 703 | }, 704 | { 705 | "github.com/lusingander/kasane", 706 | "https://github.com/lusingander/kasane", 707 | ` 708 | MIT License 709 | 710 | Copyright (c) 2023 Kyosuke Fujimoto 711 | 712 | Permission is hereby granted, free of charge, to any person obtaining a copy 713 | of this software and associated documentation files (the "Software"), to deal 714 | in the Software without restriction, including without limitation the rights 715 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 716 | copies of the Software, and to permit persons to whom the Software is 717 | furnished to do so, subject to the following conditions: 718 | 719 | The above copyright notice and this permission notice shall be included in all 720 | copies or substantial portions of the Software. 721 | 722 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 723 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 724 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 725 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 726 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 727 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 728 | SOFTWARE. 729 | `, 730 | }, 731 | { 732 | "github.com/mattn/go-isatty", 733 | "https://github.com/mattn/go-isatty", 734 | ` 735 | Copyright (c) Yasuhiro MATSUMOTO 736 | 737 | MIT License (Expat) 738 | 739 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 740 | 741 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 742 | 743 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 744 | 745 | `, 746 | }, 747 | { 748 | "github.com/mattn/go-runewidth", 749 | "https://github.com/mattn/go-runewidth", 750 | ` 751 | The MIT License (MIT) 752 | 753 | Copyright (c) 2016 Yasuhiro Matsumoto 754 | 755 | Permission is hereby granted, free of charge, to any person obtaining a copy 756 | of this software and associated documentation files (the "Software"), to deal 757 | in the Software without restriction, including without limitation the rights 758 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 759 | copies of the Software, and to permit persons to whom the Software is 760 | furnished to do so, subject to the following conditions: 761 | 762 | The above copyright notice and this permission notice shall be included in all 763 | copies or substantial portions of the Software. 764 | 765 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 766 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 767 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 768 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 769 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 770 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 771 | SOFTWARE. 772 | 773 | `, 774 | }, 775 | { 776 | "github.com/muesli/ansi", 777 | "https://github.com/muesli/ansi", 778 | ` 779 | MIT License 780 | 781 | Copyright (c) 2021 Christian Muehlhaeuser 782 | 783 | Permission is hereby granted, free of charge, to any person obtaining a copy 784 | of this software and associated documentation files (the "Software"), to deal 785 | in the Software without restriction, including without limitation the rights 786 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 787 | copies of the Software, and to permit persons to whom the Software is 788 | furnished to do so, subject to the following conditions: 789 | 790 | The above copyright notice and this permission notice shall be included in all 791 | copies or substantial portions of the Software. 792 | 793 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 794 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 795 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 796 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 797 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 798 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 799 | SOFTWARE. 800 | 801 | `, 802 | }, 803 | { 804 | "github.com/muesli/cancelreader", 805 | "https://github.com/muesli/cancelreader", 806 | ` 807 | MIT License 808 | 809 | Copyright (c) 2022 Erik Geiser and Christian Muehlhaeuser 810 | 811 | Permission is hereby granted, free of charge, to any person obtaining a copy 812 | of this software and associated documentation files (the "Software"), to deal 813 | in the Software without restriction, including without limitation the rights 814 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 815 | copies of the Software, and to permit persons to whom the Software is 816 | furnished to do so, subject to the following conditions: 817 | 818 | The above copyright notice and this permission notice shall be included in all 819 | copies or substantial portions of the Software. 820 | 821 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 822 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 823 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 824 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 825 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 826 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 827 | SOFTWARE. 828 | 829 | `, 830 | }, 831 | { 832 | "github.com/muesli/reflow", 833 | "https://github.com/muesli/reflow", 834 | ` 835 | MIT License 836 | 837 | Copyright (c) 2019 Christian Muehlhaeuser 838 | 839 | Permission is hereby granted, free of charge, to any person obtaining a copy 840 | of this software and associated documentation files (the "Software"), to deal 841 | in the Software without restriction, including without limitation the rights 842 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 843 | copies of the Software, and to permit persons to whom the Software is 844 | furnished to do so, subject to the following conditions: 845 | 846 | The above copyright notice and this permission notice shall be included in all 847 | copies or substantial portions of the Software. 848 | 849 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 850 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 851 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 852 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 853 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 854 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 855 | SOFTWARE. 856 | 857 | `, 858 | }, 859 | { 860 | "github.com/muesli/termenv", 861 | "https://github.com/muesli/termenv", 862 | ` 863 | MIT License 864 | 865 | Copyright (c) 2019 Christian Muehlhaeuser 866 | 867 | Permission is hereby granted, free of charge, to any person obtaining a copy 868 | of this software and associated documentation files (the "Software"), to deal 869 | in the Software without restriction, including without limitation the rights 870 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 871 | copies of the Software, and to permit persons to whom the Software is 872 | furnished to do so, subject to the following conditions: 873 | 874 | The above copyright notice and this permission notice shall be included in all 875 | copies or substantial portions of the Software. 876 | 877 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 878 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 879 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 880 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 881 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 882 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 883 | SOFTWARE. 884 | 885 | `, 886 | }, 887 | { 888 | "github.com/rivo/uniseg", 889 | "https://github.com/rivo/uniseg", 890 | ` 891 | MIT License 892 | 893 | Copyright (c) 2019 Oliver Kuederle 894 | 895 | Permission is hereby granted, free of charge, to any person obtaining a copy 896 | of this software and associated documentation files (the "Software"), to deal 897 | in the Software without restriction, including without limitation the rights 898 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 899 | copies of the Software, and to permit persons to whom the Software is 900 | furnished to do so, subject to the following conditions: 901 | 902 | The above copyright notice and this permission notice shall be included in all 903 | copies or substantial portions of the Software. 904 | 905 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 906 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 907 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 908 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 909 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 910 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 911 | SOFTWARE. 912 | 913 | `, 914 | }, 915 | { 916 | "github.com/sahilm/fuzzy", 917 | "https://github.com/sahilm/fuzzy", 918 | ` 919 | The MIT License (MIT) 920 | 921 | Copyright (c) 2017 Sahil Muthoo 922 | 923 | Permission is hereby granted, free of charge, to any person obtaining a copy 924 | of this software and associated documentation files (the "Software"), to deal 925 | in the Software without restriction, including without limitation the rights 926 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 927 | copies of the Software, and to permit persons to whom the Software is 928 | furnished to do so, subject to the following conditions: 929 | 930 | The above copyright notice and this permission notice shall be included in all 931 | copies or substantial portions of the Software. 932 | 933 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 934 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 935 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 936 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 937 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 938 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 939 | SOFTWARE. 940 | 941 | `, 942 | }, 943 | { 944 | "github.com/shurcooL/githubv4", 945 | "https://github.com/shurcooL/githubv4", 946 | ` 947 | MIT License 948 | 949 | Copyright (c) 2017 Dmitri Shuralyov 950 | 951 | Permission is hereby granted, free of charge, to any person obtaining a copy 952 | of this software and associated documentation files (the "Software"), to deal 953 | in the Software without restriction, including without limitation the rights 954 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 955 | copies of the Software, and to permit persons to whom the Software is 956 | furnished to do so, subject to the following conditions: 957 | 958 | The above copyright notice and this permission notice shall be included in all 959 | copies or substantial portions of the Software. 960 | 961 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 962 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 963 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 964 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 965 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 966 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 967 | SOFTWARE. 968 | 969 | `, 970 | }, 971 | { 972 | "github.com/shurcooL/graphql", 973 | "https://github.com/shurcooL/graphql", 974 | ` 975 | MIT License 976 | 977 | Copyright (c) 2017 Dmitri Shuralyov 978 | 979 | Permission is hereby granted, free of charge, to any person obtaining a copy 980 | of this software and associated documentation files (the "Software"), to deal 981 | in the Software without restriction, including without limitation the rights 982 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 983 | copies of the Software, and to permit persons to whom the Software is 984 | furnished to do so, subject to the following conditions: 985 | 986 | The above copyright notice and this permission notice shall be included in all 987 | copies or substantial portions of the Software. 988 | 989 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 990 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 991 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 992 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 993 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 994 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 995 | SOFTWARE. 996 | 997 | `, 998 | }, 999 | { 1000 | "github.com/simonhege/timeago", 1001 | "https://github.com/simonhege/timeago", 1002 | ` 1003 | The MIT License (MIT) 1004 | 1005 | Copyright (c) 2013 Simon HEGE 1006 | 1007 | Permission is hereby granted, free of charge, to any person obtaining a copy of 1008 | this software and associated documentation files (the "Software"), to deal in 1009 | the Software without restriction, including without limitation the rights to 1010 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 1011 | the Software, and to permit persons to whom the Software is furnished to do so, 1012 | subject to the following conditions: 1013 | 1014 | The above copyright notice and this permission notice shall be included in all 1015 | copies or substantial portions of the Software. 1016 | 1017 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 1018 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 1019 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 1020 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 1021 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 1022 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 1023 | 1024 | `, 1025 | }, 1026 | { 1027 | "golang.org/x/oauth2", 1028 | "https://golang.org/x/oauth2", 1029 | ` 1030 | Copyright (c) 2009 The Go Authors. All rights reserved. 1031 | 1032 | Redistribution and use in source and binary forms, with or without 1033 | modification, are permitted provided that the following conditions are 1034 | met: 1035 | 1036 | * Redistributions of source code must retain the above copyright 1037 | notice, this list of conditions and the following disclaimer. 1038 | * Redistributions in binary form must reproduce the above 1039 | copyright notice, this list of conditions and the following disclaimer 1040 | in the documentation and/or other materials provided with the 1041 | distribution. 1042 | * Neither the name of Google Inc. nor the names of its 1043 | contributors may be used to endorse or promote products derived from 1044 | this software without specific prior written permission. 1045 | 1046 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 1047 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 1048 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 1049 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 1050 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 1051 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 1052 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 1053 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 1054 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 1055 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 1056 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 1057 | 1058 | `, 1059 | }, 1060 | { 1061 | "golang.org/x/sync", 1062 | "https://golang.org/x/sync", 1063 | ` 1064 | Copyright (c) 2009 The Go Authors. All rights reserved. 1065 | 1066 | Redistribution and use in source and binary forms, with or without 1067 | modification, are permitted provided that the following conditions are 1068 | met: 1069 | 1070 | * Redistributions of source code must retain the above copyright 1071 | notice, this list of conditions and the following disclaimer. 1072 | * Redistributions in binary form must reproduce the above 1073 | copyright notice, this list of conditions and the following disclaimer 1074 | in the documentation and/or other materials provided with the 1075 | distribution. 1076 | * Neither the name of Google Inc. nor the names of its 1077 | contributors may be used to endorse or promote products derived from 1078 | this software without specific prior written permission. 1079 | 1080 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 1081 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 1082 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 1083 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 1084 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 1085 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 1086 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 1087 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 1088 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 1089 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 1090 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 1091 | 1092 | `, 1093 | }, 1094 | { 1095 | "golang.org/x/sys", 1096 | "https://golang.org/x/sys", 1097 | ` 1098 | Copyright (c) 2009 The Go Authors. All rights reserved. 1099 | 1100 | Redistribution and use in source and binary forms, with or without 1101 | modification, are permitted provided that the following conditions are 1102 | met: 1103 | 1104 | * Redistributions of source code must retain the above copyright 1105 | notice, this list of conditions and the following disclaimer. 1106 | * Redistributions in binary form must reproduce the above 1107 | copyright notice, this list of conditions and the following disclaimer 1108 | in the documentation and/or other materials provided with the 1109 | distribution. 1110 | * Neither the name of Google Inc. nor the names of its 1111 | contributors may be used to endorse or promote products derived from 1112 | this software without specific prior written permission. 1113 | 1114 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 1115 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 1116 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 1117 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 1118 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 1119 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 1120 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 1121 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 1122 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 1123 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 1124 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 1125 | 1126 | `, 1127 | }, 1128 | { 1129 | "golang.org/x/term", 1130 | "https://golang.org/x/term", 1131 | ` 1132 | Copyright (c) 2009 The Go Authors. All rights reserved. 1133 | 1134 | Redistribution and use in source and binary forms, with or without 1135 | modification, are permitted provided that the following conditions are 1136 | met: 1137 | 1138 | * Redistributions of source code must retain the above copyright 1139 | notice, this list of conditions and the following disclaimer. 1140 | * Redistributions in binary form must reproduce the above 1141 | copyright notice, this list of conditions and the following disclaimer 1142 | in the documentation and/or other materials provided with the 1143 | distribution. 1144 | * Neither the name of Google Inc. nor the names of its 1145 | contributors may be used to endorse or promote products derived from 1146 | this software without specific prior written permission. 1147 | 1148 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 1149 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 1150 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 1151 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 1152 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 1153 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 1154 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 1155 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 1156 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 1157 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 1158 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 1159 | 1160 | `, 1161 | }, 1162 | { 1163 | "golang.org/x/text", 1164 | "https://golang.org/x/text", 1165 | ` 1166 | Copyright (c) 2009 The Go Authors. All rights reserved. 1167 | 1168 | Redistribution and use in source and binary forms, with or without 1169 | modification, are permitted provided that the following conditions are 1170 | met: 1171 | 1172 | * Redistributions of source code must retain the above copyright 1173 | notice, this list of conditions and the following disclaimer. 1174 | * Redistributions in binary form must reproduce the above 1175 | copyright notice, this list of conditions and the following disclaimer 1176 | in the documentation and/or other materials provided with the 1177 | distribution. 1178 | * Neither the name of Google Inc. nor the names of its 1179 | contributors may be used to endorse or promote products derived from 1180 | this software without specific prior written permission. 1181 | 1182 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 1183 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 1184 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 1185 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 1186 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 1187 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 1188 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 1189 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 1190 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 1191 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 1192 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 1193 | 1194 | `, 1195 | }, 1196 | } 1197 | -------------------------------------------------------------------------------- /internal/ui/help.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/list" 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | const ( 10 | helpTitleAbout = "About" 11 | helpTitleCredits = "Credits" 12 | ) 13 | 14 | type helpModel struct { 15 | list list.Model 16 | 17 | delegateKeys helpDelegateKeyMap 18 | 19 | width, height int 20 | } 21 | 22 | type helpItem struct { 23 | title string 24 | desc string 25 | } 26 | 27 | var _ list.DefaultItem = (*helpItem)(nil) 28 | 29 | func (i helpItem) Title() string { 30 | return i.title 31 | } 32 | 33 | func (i helpItem) Description() string { 34 | return i.desc 35 | } 36 | 37 | func (i helpItem) FilterValue() string { 38 | return i.title 39 | } 40 | 41 | func newHelpModel() helpModel { 42 | items := []list.Item{ 43 | helpItem{ 44 | title: helpTitleAbout, 45 | desc: "Show about this application", 46 | }, 47 | helpItem{ 48 | title: helpTitleCredits, 49 | desc: "Show license information for this application", 50 | }, 51 | } 52 | 53 | delegate := list.NewDefaultDelegate() 54 | 55 | delegateKeys := newHelpDelegateKeyMap() 56 | delegate.ShortHelpFunc = func() []key.Binding { 57 | return []key.Binding{delegateKeys.sel, delegateKeys.back} 58 | } 59 | delegate.FullHelpFunc = func() [][]key.Binding { 60 | return [][]key.Binding{{delegateKeys.sel, delegateKeys.back}} 61 | } 62 | 63 | // bubbles/list/defaultitem.go 64 | delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Copy().Foreground(selectedColor1).BorderForeground(selectedColor2) 65 | delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Copy().Foreground(selectedColor2).BorderForeground(selectedColor2) 66 | l := list.New(items, delegate, 0, 0) 67 | l.KeyMap.Quit = key.NewBinding( 68 | key.WithKeys("ctrl+c", "esc"), 69 | key.WithHelp("ctrl+c", "quit"), 70 | ) 71 | l.SetShowTitle(false) 72 | l.SetFilteringEnabled(false) 73 | l.SetShowStatusBar(false) 74 | return helpModel{ 75 | list: l, 76 | delegateKeys: delegateKeys, 77 | } 78 | } 79 | 80 | type helpDelegateKeyMap struct { 81 | back key.Binding 82 | sel key.Binding 83 | } 84 | 85 | func newHelpDelegateKeyMap() helpDelegateKeyMap { 86 | return helpDelegateKeyMap{ 87 | back: key.NewBinding( 88 | key.WithKeys("backspace", "ctrl+h"), 89 | key.WithHelp("backspace", "back"), 90 | ), 91 | sel: key.NewBinding( 92 | key.WithKeys("enter"), 93 | key.WithHelp("enter", "select"), 94 | ), 95 | } 96 | } 97 | 98 | func (m *helpModel) SetSize(width, height int) { 99 | m.width = width 100 | m.height = height 101 | m.list.SetSize(width, height-2) 102 | } 103 | 104 | func (m helpModel) Init() tea.Cmd { 105 | return nil 106 | } 107 | 108 | func (m helpModel) Update(msg tea.Msg) (helpModel, tea.Cmd) { 109 | cmds := make([]tea.Cmd, 0) 110 | 111 | switch msg := msg.(type) { 112 | case tea.KeyMsg: 113 | switch { 114 | case key.Matches(msg, m.delegateKeys.sel): 115 | switch m.list.SelectedItem().(helpItem).Title() { 116 | case helpTitleAbout: 117 | return m, selectAboutPage 118 | case helpTitleCredits: 119 | return m, selectCreditsPage 120 | } 121 | case key.Matches(msg, m.delegateKeys.back): 122 | return m, goBackMenuPage 123 | } 124 | case selectHelpPageMsg: 125 | m.list.ResetSelected() 126 | } 127 | 128 | list, cmd := m.list.Update(msg) 129 | m.list = list 130 | cmds = append(cmds, cmd) 131 | 132 | return m, tea.Batch(cmds...) 133 | } 134 | 135 | func (m helpModel) View() string { 136 | return titleView(m.breadcrumb()) + listView(m.list) 137 | } 138 | 139 | func (m helpModel) breadcrumb() []string { 140 | return []string{"Help"} 141 | } 142 | -------------------------------------------------------------------------------- /internal/ui/menu.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/list" 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | const ( 10 | menuTitleProfile = "Profile" 11 | menuTitlePullRequests = "Pull Requests" 12 | menuTitleRepositories = "Repositories" 13 | menuTitleHelp = "Help" 14 | ) 15 | 16 | type menuModel struct { 17 | list list.Model 18 | 19 | delegateKeys menuDelegateKeyMap 20 | 21 | selectedUser string 22 | width, height int 23 | } 24 | 25 | func newMenuModel() menuModel { 26 | items := []list.Item{ 27 | menuItem{ 28 | title: menuTitleProfile, 29 | description: "Show the user's profile", 30 | }, 31 | menuItem{ 32 | title: menuTitlePullRequests, 33 | description: "Show Pull Requests created by the user", 34 | }, 35 | menuItem{ 36 | title: menuTitleRepositories, 37 | description: "Show Repositories created by the user", 38 | }, 39 | menuItem{ 40 | title: menuTitleHelp, 41 | description: "Show help menus", 42 | }, 43 | } 44 | 45 | delegate := list.NewDefaultDelegate() 46 | 47 | delegateKeys := newMenuDelegateKeyMap() 48 | delegate.ShortHelpFunc = func() []key.Binding { 49 | return []key.Binding{delegateKeys.sel, delegateKeys.back} 50 | } 51 | delegate.FullHelpFunc = func() [][]key.Binding { 52 | return [][]key.Binding{{delegateKeys.sel, delegateKeys.back}} 53 | } 54 | 55 | // bubbles/list/defaultitem.go 56 | delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Copy().Foreground(selectedColor1).BorderForeground(selectedColor2) 57 | delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Copy().Foreground(selectedColor2).BorderForeground(selectedColor2) 58 | l := list.New(items, delegate, 0, 0) 59 | l.KeyMap.Quit = key.NewBinding( 60 | key.WithKeys("ctrl+c", "esc"), 61 | key.WithHelp("ctrl+c", "quit"), 62 | ) 63 | l.SetShowTitle(false) 64 | l.SetFilteringEnabled(false) 65 | l.SetShowStatusBar(false) 66 | return menuModel{ 67 | list: l, 68 | delegateKeys: delegateKeys, 69 | } 70 | } 71 | 72 | type menuItem struct { 73 | title string 74 | description string 75 | } 76 | 77 | var _ list.DefaultItem = (*menuItem)(nil) 78 | 79 | func (i menuItem) Title() string { 80 | return i.title 81 | } 82 | 83 | func (i menuItem) Description() string { 84 | return i.description 85 | } 86 | 87 | func (i menuItem) FilterValue() string { 88 | return i.title 89 | } 90 | 91 | type menuDelegateKeyMap struct { 92 | back key.Binding 93 | sel key.Binding 94 | } 95 | 96 | func newMenuDelegateKeyMap() menuDelegateKeyMap { 97 | return menuDelegateKeyMap{ 98 | back: key.NewBinding( 99 | key.WithKeys("backspace", "ctrl+h"), 100 | key.WithHelp("backspace", "back"), 101 | ), 102 | sel: key.NewBinding( 103 | key.WithKeys("enter"), 104 | key.WithHelp("enter", "select"), 105 | ), 106 | } 107 | } 108 | 109 | func (m *menuModel) SetSize(width, height int) { 110 | m.width = width 111 | m.height = height 112 | m.list.SetSize(width, height-2) 113 | } 114 | 115 | func (m *menuModel) SetUser(id string) { 116 | m.selectedUser = id 117 | } 118 | 119 | func (m menuModel) Init() tea.Cmd { 120 | return nil 121 | } 122 | 123 | func (m menuModel) Update(msg tea.Msg) (menuModel, tea.Cmd) { 124 | cmds := make([]tea.Cmd, 0) 125 | switch msg := msg.(type) { 126 | case tea.KeyMsg: 127 | switch { 128 | case key.Matches(msg, m.delegateKeys.sel): 129 | switch m.list.SelectedItem().(menuItem).Title() { 130 | case menuTitleProfile: 131 | return m, selectProfilePage(m.selectedUser) 132 | case menuTitleRepositories: 133 | return m, selectRepositoriesPage(m.selectedUser) 134 | case menuTitlePullRequests: 135 | return m, selectPullRequestsPage(m.selectedUser) 136 | case menuTitleHelp: 137 | return m, selectHelpPage 138 | } 139 | case key.Matches(msg, m.delegateKeys.back): 140 | return m, goBackUserSelectPage 141 | } 142 | case userSelectMsg: 143 | m.list.ResetSelected() 144 | } 145 | 146 | list, cmd := m.list.Update(msg) 147 | m.list = list 148 | cmds = append(cmds, cmd) 149 | 150 | return m, tea.Batch(cmds...) 151 | } 152 | 153 | func (m menuModel) View() string { 154 | return titleView(m.breadcrumb()) + listView(m.list) 155 | } 156 | 157 | func (m menuModel) breadcrumb() []string { 158 | return []string{m.selectedUser} 159 | } 160 | -------------------------------------------------------------------------------- /internal/ui/profile.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/bubbles/help" 8 | "github.com/charmbracelet/bubbles/key" 9 | "github.com/charmbracelet/bubbles/spinner" 10 | "github.com/charmbracelet/bubbles/viewport" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | "github.com/lusingander/ghcv-cli/internal/gh" 14 | ) 15 | 16 | var ( 17 | profileErrorStyle = lipgloss.NewStyle(). 18 | Padding(2, 0, 0, 2). 19 | Foreground(lipgloss.Color("161")) 20 | 21 | profileItemStyle = lipgloss.NewStyle(). 22 | Padding(1, 0, 1, 2) 23 | 24 | profileItemNameStyle = profileItemStyle.Copy(). 25 | Bold(true) 26 | 27 | profileViewportStyle = lipgloss.NewStyle(). 28 | Padding(1, 0, 0, 0) 29 | 30 | profileSelectedItemColorStyle = lipgloss.NewStyle(). 31 | Background(lipgloss.Color("250")). 32 | Foreground(lipgloss.Color("56")) 33 | ) 34 | 35 | type profileSelectableItem int 36 | 37 | const ( 38 | profileNotSelectedItem profileSelectableItem = iota 39 | profileAccountItem 40 | profileCompanyItem 41 | profileWebsiteItem 42 | numberOfItems // not item 43 | ) 44 | 45 | type profileKeyMap struct { 46 | Tab key.Binding 47 | ShiftTab key.Binding 48 | Open key.Binding 49 | Back key.Binding 50 | Quit key.Binding 51 | } 52 | 53 | func (k profileKeyMap) ShortHelp() []key.Binding { 54 | return []key.Binding{ 55 | k.Tab, 56 | k.Open, 57 | k.Back, 58 | k.Quit, 59 | } 60 | } 61 | 62 | func (k profileKeyMap) FullHelp() [][]key.Binding { 63 | return [][]key.Binding{ 64 | { 65 | k.Tab, 66 | }, 67 | { 68 | k.Open, 69 | }, 70 | { 71 | k.Back, 72 | }, 73 | { 74 | k.Quit, 75 | }, 76 | } 77 | } 78 | 79 | type profileModel struct { 80 | client *gh.GitHubClient 81 | 82 | keys *profileKeyMap 83 | viewport viewport.Model 84 | help help.Model 85 | profile *gh.UserProfile 86 | spinner *spinner.Model 87 | selectedItem profileSelectableItem 88 | 89 | errorMsg *profileErrorMsg 90 | loading bool 91 | selectedUser string 92 | width, height int 93 | } 94 | 95 | func newProfileModel(client *gh.GitHubClient, s *spinner.Model) profileModel { 96 | profileKeys := &profileKeyMap{ 97 | Tab: key.NewBinding( 98 | key.WithKeys("tab"), 99 | key.WithHelp("tab", "select item"), 100 | ), 101 | ShiftTab: key.NewBinding( 102 | key.WithKeys("shift+tab"), 103 | key.WithHelp("shift+tab", "select item (reverse)"), 104 | ), 105 | Open: key.NewBinding( 106 | key.WithKeys("x"), 107 | key.WithHelp("x", "open in browser"), 108 | ), 109 | Back: key.NewBinding( 110 | key.WithKeys("backspace", "ctrl+h"), 111 | key.WithHelp("backspace", "back"), 112 | ), 113 | Quit: key.NewBinding( 114 | key.WithKeys("ctrl+c", "esc"), 115 | key.WithHelp("ctrl+c", "quit"), 116 | ), 117 | } 118 | return profileModel{ 119 | client: client, 120 | keys: profileKeys, 121 | viewport: viewport.New(0, 0), 122 | help: help.New(), 123 | spinner: s, 124 | selectedItem: profileNotSelectedItem, 125 | } 126 | } 127 | 128 | func (m *profileModel) SetSize(width, height int) { 129 | m.width = width 130 | m.height = height 131 | m.help.Width = width 132 | m.viewport.Width = width 133 | m.viewport.Height = height - 4 134 | } 135 | 136 | func (m *profileModel) SetUser(id string) { 137 | m.selectedUser = id 138 | } 139 | 140 | func (m *profileModel) updateProfile(profile *gh.UserProfile) { 141 | m.profile = profile 142 | m.updateContent() 143 | } 144 | 145 | func (m *profileModel) updateContent() { 146 | m.viewport.SetContent(m.profieContentsView()) 147 | } 148 | 149 | func (m *profileModel) selectItem(reverse bool) { 150 | m.keys.Open.SetEnabled(true) 151 | if reverse { 152 | m.selectedItem = ((m.selectedItem-1)%numberOfItems + numberOfItems) % numberOfItems 153 | } else { 154 | m.selectedItem = (m.selectedItem + 1) % numberOfItems 155 | } 156 | switch m.selectedItem { 157 | case profileAccountItem: 158 | // do nothing 159 | case profileCompanyItem: 160 | if !isOrganizationLogin(m.profile.Company) { 161 | m.selectItem(reverse) 162 | } 163 | case profileWebsiteItem: 164 | if !isUrl(m.profile.WebsiteUrl) { 165 | m.selectItem(reverse) 166 | } 167 | default: 168 | m.selectedItem = profileNotSelectedItem 169 | m.keys.Open.SetEnabled(false) 170 | } 171 | } 172 | 173 | func (m profileModel) Init() tea.Cmd { 174 | return nil 175 | } 176 | 177 | type profileSuccessMsg struct { 178 | profile *gh.UserProfile 179 | } 180 | 181 | var _ tea.Msg = (*profileSuccessMsg)(nil) 182 | 183 | type profileErrorMsg struct { 184 | e error 185 | summary string 186 | } 187 | 188 | var _ tea.Msg = (*profileErrorMsg)(nil) 189 | 190 | func (m profileModel) loadProfile(id string) tea.Cmd { 191 | return func() tea.Msg { 192 | profile, err := m.client.QueryUserProfile(id) 193 | if err != nil { 194 | return profileErrorMsg{err, "failed to fetch profile"} 195 | } 196 | return profileSuccessMsg{profile} 197 | } 198 | } 199 | 200 | func (m profileModel) openInBrowser() tea.Cmd { 201 | return func() tea.Msg { 202 | var url string 203 | switch m.selectedItem { 204 | case profileAccountItem: 205 | url = m.profile.Url 206 | case profileCompanyItem: 207 | url = organigzationUrlFrom(m.profile.Company) 208 | case profileWebsiteItem: 209 | url = m.profile.WebsiteUrl 210 | default: 211 | return nil 212 | } 213 | if err := openBrowser(url); err != nil { 214 | return profileErrorMsg{err, "failed to open browser"} 215 | } 216 | return nil 217 | } 218 | } 219 | 220 | func (m profileModel) Update(msg tea.Msg) (profileModel, tea.Cmd) { 221 | switch msg := msg.(type) { 222 | case tea.KeyMsg: 223 | switch { 224 | case key.Matches(msg, m.keys.Tab): 225 | m.selectItem(false) 226 | m.updateContent() 227 | return m, nil 228 | case key.Matches(msg, m.keys.ShiftTab): 229 | m.selectItem(true) 230 | m.updateContent() 231 | return m, nil 232 | case key.Matches(msg, m.keys.Open): 233 | return m, m.openInBrowser() 234 | case key.Matches(msg, m.keys.Back): 235 | return m, goBackMenuPage 236 | case key.Matches(msg, m.keys.Quit): 237 | return m, tea.Quit 238 | } 239 | case selectProfilePageMsg: 240 | m.loading = true 241 | return m, m.loadProfile(msg.id) 242 | case profileSuccessMsg: 243 | m.errorMsg = nil 244 | m.loading = false 245 | m.selectedItem = profileNotSelectedItem 246 | m.keys.Open.SetEnabled(false) 247 | m.updateProfile(msg.profile) 248 | return m, nil 249 | case profileErrorMsg: 250 | m.errorMsg = &msg 251 | m.loading = false 252 | return m, nil 253 | } 254 | 255 | var cmd tea.Cmd 256 | m.viewport, cmd = m.viewport.Update(msg) 257 | return m, cmd 258 | } 259 | 260 | func (m profileModel) View() string { 261 | if m.loading { 262 | return loadingView(m.spinner, m.breadcrumb()) 263 | } 264 | if m.errorMsg != nil { 265 | return m.errorView() 266 | } 267 | return m.profieView() 268 | } 269 | 270 | func (m profileModel) profieView() string { 271 | if m.height <= 0 { 272 | return "" 273 | } 274 | 275 | ret := "" 276 | height := m.height - 1 277 | 278 | title := titleView(m.breadcrumb()) 279 | ret += title 280 | height -= cn(title) 281 | 282 | vp := profileViewportStyle.Render(m.viewport.View()) 283 | ret += vp 284 | height -= cn(vp) 285 | 286 | help := helpStyle.Render(m.help.View(m.keys)) 287 | height -= cn(help) 288 | 289 | ret += strings.Repeat("\n", height) 290 | ret += help 291 | 292 | return ret 293 | } 294 | 295 | func (m profileModel) profieContentsView() string { 296 | ret := "" 297 | ret += profileItemNameStyle.Render(m.profile.Name) 298 | login := "@" + m.profile.Login 299 | if m.selectedItem == profileAccountItem { 300 | login = profileSelectedItemColorStyle.Render(login) 301 | } 302 | ret += profileItemStyle.Render(login) 303 | ret += profileItemStyle.Render(m.profile.Bio) 304 | ret += "\n" 305 | ret += profileItemStyle.Render(fmt.Sprintf("%d followers - %d following", m.profile.Followers, m.profile.Following)) 306 | company := m.profile.Company 307 | if m.selectedItem == profileCompanyItem { 308 | company = profileSelectedItemColorStyle.Render(company) 309 | } 310 | ret += profileItemStyle.Render("🏢 " + company) 311 | ret += profileItemStyle.Render("🌐 " + m.profile.Location) 312 | websiteUrl := m.profile.WebsiteUrl 313 | if m.selectedItem == profileWebsiteItem { 314 | websiteUrl = profileSelectedItemColorStyle.Render(websiteUrl) 315 | } 316 | ret += profileItemStyle.Render("🔗 " + websiteUrl) 317 | return ret 318 | } 319 | 320 | func (m profileModel) errorView() string { 321 | if m.height <= 0 { 322 | return "" 323 | } 324 | 325 | ret := "" 326 | height := m.height - 1 327 | 328 | title := titleView(m.breadcrumb()) 329 | ret += title 330 | height -= cn(title) 331 | 332 | errorText := profileErrorStyle.Render("ERROR: " + m.errorMsg.summary) 333 | ret += errorText 334 | height -= cn(errorText) 335 | 336 | help := helpStyle.Render(m.help.View(m.keys)) 337 | height -= cn(help) 338 | 339 | ret += strings.Repeat("\n", height) 340 | ret += help 341 | 342 | return ret 343 | } 344 | 345 | func (m profileModel) breadcrumb() []string { 346 | return []string{m.selectedUser, "Profile"} 347 | } 348 | -------------------------------------------------------------------------------- /internal/ui/prs.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/spinner" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/lusingander/ghcv-cli/internal/gh" 8 | ) 9 | 10 | var ( 11 | pullRequestsErrorStyle = lipgloss.NewStyle(). 12 | Padding(2, 0, 0, 2). 13 | Foreground(lipgloss.Color("161")) 14 | ) 15 | 16 | type pullRequestsInnerPage int 17 | 18 | const ( 19 | pullRequestsOwnerPage pullRequestsInnerPage = iota 20 | pullRequestsRepositoryPage 21 | pullRequestsListPage 22 | pullRequestsListAllPage 23 | ) 24 | 25 | type pullRequestsModel struct { 26 | client *gh.GitHubClient 27 | currentPage pullRequestsInnerPage 28 | 29 | prs *gh.UserPullRequests 30 | 31 | owner *pullRequestsOwnerModel 32 | repo *pullRequestsRepositoryModel 33 | list *pullRequestsListModel 34 | listAll *pullRequestsListAllModel 35 | spinner *spinner.Model 36 | 37 | errorMsg *pullRequestsErrorMsg 38 | loading bool 39 | selectedUser string 40 | width, height int 41 | } 42 | 43 | func newPullRequestsModel(client *gh.GitHubClient, s *spinner.Model) pullRequestsModel { 44 | return pullRequestsModel{ 45 | client: client, 46 | owner: newPullRequestsOwnerModel(), 47 | repo: newPullRequestsRepositoryModel(), 48 | list: newPullRequestsListModel(), 49 | listAll: newPullRequestsListAllModel(), 50 | spinner: s, 51 | } 52 | } 53 | 54 | func (m *pullRequestsModel) SetSize(width, height int) { 55 | m.width = width 56 | m.height = height 57 | m.owner.SetSize(width, height) 58 | m.repo.SetSize(width, height) 59 | m.list.SetSize(width, height) 60 | m.listAll.SetSize(width, height) 61 | } 62 | 63 | func (m *pullRequestsModel) SetUser(id string) { 64 | m.selectedUser = id 65 | m.owner.SetUser(id) 66 | m.repo.SetUser(id) 67 | m.list.SetUser(id) 68 | m.listAll.SetUser(id) 69 | } 70 | 71 | func (m pullRequestsModel) Init() tea.Cmd { 72 | return nil 73 | } 74 | 75 | type pullRequestsSuccessMsg struct { 76 | prs *gh.UserPullRequests 77 | } 78 | 79 | var _ tea.Msg = (*pullRequestsSuccessMsg)(nil) 80 | 81 | type pullRequestsErrorMsg struct { 82 | e error 83 | summary string 84 | } 85 | 86 | var _ tea.Msg = (*pullRequestsErrorMsg)(nil) 87 | 88 | func (m pullRequestsModel) loadPullRequests(id string) tea.Cmd { 89 | return func() tea.Msg { 90 | prs, err := m.client.QueryUserPullRequests(id) 91 | if err != nil { 92 | return pullRequestsErrorMsg{err, "failed to fetch pull requests"} 93 | } 94 | return pullRequestsSuccessMsg{prs} 95 | } 96 | } 97 | 98 | type selectPullRequestsOwnerMsg struct { 99 | owner *gh.UserPullRequestsOwner 100 | } 101 | 102 | var _ tea.Msg = (*selectPullRequestsOwnerMsg)(nil) 103 | 104 | type selectPullRequestsRepositoryMsg struct { 105 | repo *gh.UserPullRequestsRepository 106 | owner string 107 | } 108 | 109 | var _ tea.Msg = (*selectPullRequestsRepositoryMsg)(nil) 110 | 111 | type togglePullRequestsListMsg struct{} 112 | 113 | var _ tea.Msg = (*togglePullRequestsListMsg)(nil) 114 | 115 | func togglePullRequestsList() tea.Msg { 116 | return togglePullRequestsListMsg{} 117 | } 118 | 119 | type togglePullRequestsListAllMsg struct { 120 | prs *gh.UserPullRequests 121 | } 122 | 123 | var _ tea.Msg = (*togglePullRequestsListAllMsg)(nil) 124 | 125 | func togglePullRequestsListAll(prs *gh.UserPullRequests) tea.Cmd { 126 | return func() tea.Msg { 127 | return togglePullRequestsListAllMsg{prs} 128 | } 129 | } 130 | 131 | type goBackPullRequestsOwnerPageMsg struct{} 132 | 133 | var _ tea.Msg = (*goBackPullRequestsOwnerPageMsg)(nil) 134 | 135 | func goBackPullRequestsOwnerPage() tea.Msg { 136 | return goBackPullRequestsOwnerPageMsg{} 137 | } 138 | 139 | type goBackPullRequestsRepositoryPageMsg struct{} 140 | 141 | var _ tea.Msg = (*goBackPullRequestsRepositoryPageMsg)(nil) 142 | 143 | func goBackPullRequestsRepositoryPage() tea.Msg { 144 | return goBackPullRequestsRepositoryPageMsg{} 145 | } 146 | 147 | func (m pullRequestsModel) Update(msg tea.Msg) (pullRequestsModel, tea.Cmd) { 148 | var cmd tea.Cmd 149 | cmds := make([]tea.Cmd, 0) 150 | switch msg := msg.(type) { 151 | case tea.KeyMsg: 152 | if m.loading { 153 | return m, nil 154 | } 155 | case selectPullRequestsPageMsg: 156 | m.loading = true 157 | return m, m.loadPullRequests(msg.id) 158 | case selectPullRequestsOwnerMsg: 159 | m.currentPage = pullRequestsRepositoryPage 160 | case selectPullRequestsRepositoryMsg: 161 | m.currentPage = pullRequestsListPage 162 | case togglePullRequestsListMsg: 163 | m.currentPage = pullRequestsOwnerPage 164 | case togglePullRequestsListAllMsg: 165 | m.currentPage = pullRequestsListAllPage 166 | case goBackPullRequestsOwnerPageMsg: 167 | m.currentPage = pullRequestsOwnerPage 168 | case goBackPullRequestsRepositoryPageMsg: 169 | m.currentPage = pullRequestsRepositoryPage 170 | case pullRequestsSuccessMsg: 171 | m.errorMsg = nil 172 | m.loading = false 173 | m.prs = msg.prs 174 | m.currentPage = pullRequestsOwnerPage 175 | case pullRequestsErrorMsg: 176 | m.errorMsg = &msg 177 | m.loading = false 178 | return m, nil 179 | } 180 | 181 | switch m.currentPage { 182 | case pullRequestsOwnerPage: 183 | *m.owner, cmd = m.owner.Update(msg) 184 | cmds = append(cmds, cmd) 185 | case pullRequestsRepositoryPage: 186 | *m.repo, cmd = m.repo.Update(msg) 187 | cmds = append(cmds, cmd) 188 | case pullRequestsListPage: 189 | *m.list, cmd = m.list.Update(msg) 190 | cmds = append(cmds, cmd) 191 | case pullRequestsListAllPage: 192 | *m.listAll, cmd = m.listAll.Update(msg) 193 | cmds = append(cmds, cmd) 194 | default: 195 | return m, nil 196 | } 197 | 198 | return m, tea.Batch(cmds...) 199 | } 200 | 201 | func (m pullRequestsModel) View() string { 202 | if m.loading { 203 | return loadingView(m.spinner, m.breadcrumb()) 204 | } 205 | if m.errorMsg != nil { 206 | return m.errorView() 207 | } 208 | 209 | switch m.currentPage { 210 | case pullRequestsOwnerPage: 211 | return m.owner.View() 212 | case pullRequestsRepositoryPage: 213 | return m.repo.View() 214 | case pullRequestsListPage: 215 | return m.list.View() 216 | case pullRequestsListAllPage: 217 | return m.listAll.View() 218 | default: 219 | return baseStyle.Render("error... :(") 220 | } 221 | } 222 | 223 | func (m pullRequestsModel) errorView() string { 224 | if m.height <= 0 { 225 | return "" 226 | } 227 | 228 | ret := "" 229 | height := m.height - 1 230 | 231 | title := titleView(m.breadcrumb()) 232 | ret += title 233 | height -= cn(title) 234 | 235 | errorText := pullRequestsErrorStyle.Render("ERROR: " + m.errorMsg.summary) 236 | ret += errorText 237 | height -= cn(errorText) 238 | 239 | return ret 240 | } 241 | 242 | func (m pullRequestsModel) breadcrumb() []string { 243 | return []string{m.selectedUser, "PRs"} 244 | } 245 | -------------------------------------------------------------------------------- /internal/ui/prs_list.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/list" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/lusingander/ghcv-cli/internal/gh" 8 | ) 9 | 10 | type pullRequestsListModel struct { 11 | prs []*gh.UserPullRequestsPullRequest 12 | 13 | list list.Model 14 | delegateKeys pullRequestsListDelegateKeyMap 15 | 16 | selectedUser string 17 | selectedOwner string 18 | selectedRepository string 19 | width, height int 20 | } 21 | 22 | type pullRequestsListDelegateKeyMap struct { 23 | open key.Binding 24 | back key.Binding 25 | quit key.Binding 26 | } 27 | 28 | func newPullRequestsListDelegateKeyMap() pullRequestsListDelegateKeyMap { 29 | return pullRequestsListDelegateKeyMap{ 30 | open: key.NewBinding( 31 | key.WithKeys("x"), 32 | key.WithHelp("x", "open in browser"), 33 | ), 34 | back: key.NewBinding( 35 | key.WithKeys("backspace", "ctrl+h"), 36 | key.WithHelp("backspace", "back"), 37 | ), 38 | quit: key.NewBinding( 39 | key.WithKeys("ctrl+c", "esc"), 40 | key.WithHelp("ctrl+c", "quit"), 41 | ), 42 | } 43 | } 44 | 45 | func newPullRequestsListModel() *pullRequestsListModel { 46 | delegateKeys := newPullRequestsListDelegateKeyMap() 47 | delegate := newPullRequestsListDelegate(delegateKeys) 48 | 49 | l := list.New(nil, delegate, 0, 0) 50 | l.KeyMap.Quit = delegateKeys.quit 51 | l.SetShowTitle(false) 52 | l.SetFilteringEnabled(false) 53 | l.SetShowStatusBar(false) 54 | 55 | return &pullRequestsListModel{ 56 | list: l, 57 | delegateKeys: delegateKeys, 58 | } 59 | } 60 | 61 | func (m *pullRequestsListModel) SetSize(width, height int) { 62 | m.width = width 63 | m.height = height 64 | m.list.SetSize(width, height-2) 65 | } 66 | 67 | func (m *pullRequestsListModel) SetUser(id string) { 68 | m.selectedUser = id 69 | } 70 | 71 | func (m *pullRequestsListModel) setOwner(name string) { 72 | m.selectedOwner = name 73 | } 74 | 75 | func (m *pullRequestsListModel) setRepository(name string) { 76 | m.selectedRepository = name 77 | } 78 | 79 | func (m *pullRequestsListModel) updateList(prs []*gh.UserPullRequestsPullRequest) { 80 | m.prs = prs 81 | items := make([]list.Item, len(m.prs)) 82 | for i, pr := range m.prs { 83 | created := formatDuration(pr.CretaedAt) 84 | closed := formatDuration(pr.ClosedAt) 85 | item := pullRequestsListItem{ 86 | title: pr.Title, 87 | status: pr.State, 88 | number: pr.Number, 89 | additions: pr.Additions, 90 | deletions: pr.Deletions, 91 | comments: pr.Comments, 92 | created: created, 93 | closed: closed, 94 | url: pr.Url, 95 | } 96 | items[i] = item 97 | } 98 | m.list.SetItems(items) 99 | } 100 | 101 | func (m pullRequestsListModel) Init() tea.Cmd { 102 | return nil 103 | } 104 | 105 | func (m pullRequestsListModel) openPullRequestPageInBrowser(item pullRequestsListItem) tea.Cmd { 106 | return func() tea.Msg { 107 | if err := openBrowser(item.url); err != nil { 108 | return profileErrorMsg{err, "failed to open browser"} 109 | } 110 | return nil 111 | } 112 | } 113 | 114 | func (m pullRequestsListModel) Update(msg tea.Msg) (pullRequestsListModel, tea.Cmd) { 115 | var cmd tea.Cmd 116 | switch msg := msg.(type) { 117 | case tea.KeyMsg: 118 | switch { 119 | case key.Matches(msg, m.delegateKeys.open): 120 | item := m.list.SelectedItem().(pullRequestsListItem) 121 | return m, m.openPullRequestPageInBrowser(item) 122 | case key.Matches(msg, m.delegateKeys.back): 123 | if m.list.FilterState() != list.Filtering { 124 | return m, goBackPullRequestsRepositoryPage 125 | } 126 | } 127 | case selectPullRequestsRepositoryMsg: 128 | m.list.ResetSelected() 129 | m.updateList(msg.repo.PullRequests) 130 | m.setRepository(msg.repo.Name) 131 | m.setOwner(msg.owner) 132 | return m, nil 133 | } 134 | m.list, cmd = m.list.Update(msg) 135 | return m, cmd 136 | } 137 | 138 | func (m pullRequestsListModel) View() string { 139 | return titleView(m.breadcrumb()) + listView(m.list) 140 | } 141 | 142 | func (m pullRequestsListModel) breadcrumb() []string { 143 | return []string{m.selectedUser, "PRs", m.selectedOwner, m.selectedRepository} 144 | } 145 | -------------------------------------------------------------------------------- /internal/ui/prs_list_all.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/key" 9 | "github.com/charmbracelet/bubbles/list" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/lusingander/ghcv-cli/internal/gh" 13 | "github.com/lusingander/kasane" 14 | ) 15 | 16 | var ( 17 | pullRequestsListAllDialogBodyStyle = lipgloss.NewStyle(). 18 | Padding(0, 2) 19 | 20 | pullRequestsListAllDialogStyle = lipgloss.NewStyle(). 21 | BorderStyle(lipgloss.RoundedBorder()) 22 | 23 | pullRequestsListAllDialogSelectedStyle = lipgloss.NewStyle(). 24 | Foreground(selectedColor1) 25 | 26 | pullRequestsListAllDialogNotSelectedStyle = lipgloss.NewStyle() 27 | ) 28 | 29 | type pullRequestListAllSortType int 30 | 31 | const ( 32 | pullRequestListAllSortByCreatedAtDesc pullRequestListAllSortType = iota 33 | pullRequestListAllSortByCreatedAtAsc 34 | ) 35 | 36 | type pullRequestStatus struct { 37 | name string 38 | count int 39 | } 40 | 41 | type pullRequestsListAllModel struct { 42 | prs *gh.UserPullRequests 43 | 44 | list list.Model 45 | originalItems []list.Item 46 | delegateKeys pullRequestsListAllDelegateKeyMap 47 | filterStatusDialogDelegateKeys pullRequestsListAllFilterStatusDialogDelegateKeyMap 48 | 49 | selectedUser string 50 | width, height int 51 | 52 | pullRequestListAllSortType 53 | 54 | statuses []*pullRequestStatus 55 | statusIdx int 56 | statusDialogOpened bool 57 | } 58 | 59 | type pullRequestsListAllDelegateKeyMap struct { 60 | stat key.Binding 61 | open key.Binding 62 | back key.Binding 63 | tog key.Binding 64 | quit key.Binding 65 | } 66 | 67 | func newPullRequestsListAllDelegateKeyMap() pullRequestsListAllDelegateKeyMap { 68 | return pullRequestsListAllDelegateKeyMap{ 69 | stat: key.NewBinding( 70 | key.WithKeys("T"), 71 | key.WithHelp("T", "filter by status"), 72 | ), 73 | open: key.NewBinding( 74 | key.WithKeys("x"), 75 | key.WithHelp("x", "open in browser"), 76 | ), 77 | back: key.NewBinding( 78 | key.WithKeys("backspace", "ctrl+h"), 79 | key.WithHelp("backspace", "back"), 80 | ), 81 | tog: key.NewBinding( 82 | key.WithKeys("tab"), 83 | key.WithHelp("tab", "toggle"), 84 | ), 85 | quit: key.NewBinding( 86 | key.WithKeys("ctrl+c", "esc"), 87 | key.WithHelp("ctrl+c", "quit"), 88 | ), 89 | } 90 | } 91 | 92 | type pullRequestsListAllFilterStatusDialogDelegateKeyMap struct { 93 | next key.Binding 94 | prev key.Binding 95 | close key.Binding 96 | } 97 | 98 | func newPullRequestsListAllFilterStatusDialogDelegateKeyMap() pullRequestsListAllFilterStatusDialogDelegateKeyMap { 99 | return pullRequestsListAllFilterStatusDialogDelegateKeyMap{ 100 | next: key.NewBinding( 101 | key.WithKeys("j"), 102 | key.WithHelp("j", "select next"), 103 | ), 104 | prev: key.NewBinding( 105 | key.WithKeys("k"), 106 | key.WithHelp("k", "select prev"), 107 | ), 108 | close: key.NewBinding( 109 | key.WithKeys("T", "esc", "enter"), 110 | key.WithHelp("T", "close dialog"), 111 | ), 112 | } 113 | } 114 | 115 | func newPullRequestsListAllModel() *pullRequestsListAllModel { 116 | delegateKeys := newPullRequestsListAllDelegateKeyMap() 117 | delegate := newPullRequestsListAllDelegate(delegateKeys) 118 | filterStatusDialogDelegateKeys := newPullRequestsListAllFilterStatusDialogDelegateKeyMap() 119 | 120 | l := list.New(nil, delegate, 0, 0) 121 | l.KeyMap.Quit = delegateKeys.quit 122 | l.SetShowTitle(false) 123 | l.SetFilteringEnabled(false) 124 | l.SetShowStatusBar(false) 125 | 126 | return &pullRequestsListAllModel{ 127 | list: l, 128 | delegateKeys: delegateKeys, 129 | filterStatusDialogDelegateKeys: filterStatusDialogDelegateKeys, 130 | } 131 | } 132 | 133 | func (m *pullRequestsListAllModel) SetSize(width, height int) { 134 | m.width = width 135 | m.height = height 136 | m.list.SetSize(width, height-2) 137 | } 138 | 139 | func (m *pullRequestsListAllModel) SetUser(id string) { 140 | m.selectedUser = id 141 | } 142 | 143 | func (m *pullRequestsListAllModel) updatePrs(prs *gh.UserPullRequests) { 144 | m.prs = prs 145 | 146 | items := make([]list.Item, 0) 147 | statusesMap := make(map[string]int) 148 | for _, owner := range m.prs.Owners { 149 | for _, repo := range owner.Repositories { 150 | for _, pr := range repo.PullRequests { 151 | created := formatDuration(pr.CretaedAt) 152 | closed := formatDuration(pr.ClosedAt) 153 | item := pullRequestsListAllItem{ 154 | owner: owner.Name, 155 | repository: repo.Name, 156 | createdAt: pr.CretaedAt, 157 | closedAt: pr.ClosedAt, 158 | pullRequestsListItem: pullRequestsListItem{ 159 | title: pr.Title, 160 | status: pr.State, 161 | number: pr.Number, 162 | additions: pr.Additions, 163 | deletions: pr.Deletions, 164 | comments: pr.Comments, 165 | created: created, 166 | closed: closed, 167 | url: pr.Url, 168 | }, 169 | } 170 | items = append(items, item) 171 | statusesMap[pr.State] += 1 172 | } 173 | } 174 | } 175 | m.list.SetItems(items) 176 | m.originalItems = items 177 | m.sortItems() 178 | 179 | m.statuses = []*pullRequestStatus{ 180 | {name: "All", count: len(items)}, 181 | {name: "OPEN", count: statusesMap["OPEN"]}, 182 | {name: "MERGED", count: statusesMap["MERGED"]}, 183 | {name: "CLOSED", count: statusesMap["CLOSED"]}, 184 | } 185 | m.statusIdx = 0 186 | } 187 | 188 | func (m *pullRequestsListAllModel) sortItems() { 189 | items := m.list.Items() 190 | switch m.pullRequestListAllSortType { 191 | case pullRequestListAllSortByCreatedAtDesc: 192 | sort.Slice(items, func(i, j int) bool { 193 | return items[i].(pullRequestsListAllItem).createdAt.After(items[j].(pullRequestsListAllItem).createdAt) 194 | }) 195 | case pullRequestListAllSortByCreatedAtAsc: 196 | sort.Slice(items, func(i, j int) bool { 197 | return items[i].(pullRequestsListAllItem).createdAt.Before(items[j].(pullRequestsListAllItem).createdAt) 198 | }) 199 | } 200 | m.list.SetItems(items) 201 | } 202 | 203 | func (m *pullRequestsListAllModel) updateStatusIdx(reverse bool) { 204 | n := len(m.statuses) 205 | if reverse { 206 | m.statusIdx = ((m.statusIdx-1)%n + n) % n 207 | } else { 208 | m.statusIdx = (m.statusIdx + 1) % n 209 | } 210 | } 211 | 212 | func (m *pullRequestsListAllModel) filterItems() { 213 | if m.statuses[m.statusIdx].name == "All" { 214 | m.list.SetItems(m.originalItems) 215 | m.sortItems() 216 | return 217 | } 218 | items := make([]list.Item, 0) 219 | for _, i := range m.originalItems { 220 | if i.(pullRequestsListAllItem).status == m.statuses[m.statusIdx].name { 221 | items = append(items, i) 222 | } 223 | } 224 | m.list.SetItems(items) 225 | m.sortItems() 226 | } 227 | 228 | func (m pullRequestsListAllModel) Init() tea.Cmd { 229 | return nil 230 | } 231 | 232 | func (m pullRequestsListAllModel) openPullRequestPageInBrowser(item pullRequestsListAllItem) tea.Cmd { 233 | return func() tea.Msg { 234 | if err := openBrowser(item.url); err != nil { 235 | return profileErrorMsg{err, "failed to open browser"} 236 | } 237 | return nil 238 | } 239 | } 240 | 241 | func (m pullRequestsListAllModel) Update(msg tea.Msg) (pullRequestsListAllModel, tea.Cmd) { 242 | var cmd tea.Cmd 243 | switch msg := msg.(type) { 244 | case tea.KeyMsg: 245 | if m.statusDialogOpened { 246 | switch { 247 | case key.Matches(msg, m.filterStatusDialogDelegateKeys.close): 248 | m.statusDialogOpened = false 249 | case key.Matches(msg, m.filterStatusDialogDelegateKeys.next): 250 | m.list.ResetSelected() 251 | m.updateStatusIdx(false) 252 | m.filterItems() 253 | case key.Matches(msg, m.filterStatusDialogDelegateKeys.prev): 254 | m.list.ResetSelected() 255 | m.updateStatusIdx(true) 256 | m.filterItems() 257 | } 258 | return m, nil 259 | } 260 | switch { 261 | case key.Matches(msg, m.delegateKeys.stat): 262 | m.statusDialogOpened = true 263 | return m, nil 264 | case key.Matches(msg, m.delegateKeys.open): 265 | item := m.list.SelectedItem().(pullRequestsListAllItem) 266 | return m, m.openPullRequestPageInBrowser(item) 267 | case key.Matches(msg, m.delegateKeys.back): 268 | if m.list.FilterState() != list.Filtering { 269 | return m, goBackMenuPage 270 | } 271 | case key.Matches(msg, m.delegateKeys.tog): 272 | return m, togglePullRequestsList 273 | } 274 | case togglePullRequestsListAllMsg: 275 | m.list.ResetSelected() 276 | m.updatePrs(msg.prs) 277 | return m, nil 278 | } 279 | m.list, cmd = m.list.Update(msg) 280 | return m, cmd 281 | } 282 | 283 | func (m pullRequestsListAllModel) View() string { 284 | ret := titleView(m.breadcrumb()) + listView(m.list) 285 | if m.statusDialogOpened { 286 | return m.withStatusDialogView(ret) 287 | } 288 | return ret 289 | } 290 | 291 | func (m pullRequestsListAllModel) withStatusDialogView(base string) string { 292 | title := repositoriesDialogTitleStyle.Render("Status") 293 | 294 | ivs := make([]string, len(m.statuses)) 295 | for i, s := range m.statuses { 296 | ivs[i] = m.statusKeySelectItemView(s) 297 | } 298 | body := strings.Join(ivs, "\n") 299 | body = pullRequestsListAllDialogBodyStyle.Render(body) 300 | 301 | dialog := pullRequestsListAllDialogStyle.Render(lipgloss.JoinVertical(lipgloss.Left, title, body)) 302 | 303 | dw, dh := lipgloss.Size(dialog) 304 | top := (m.height / 2) - (dh / 2) 305 | left := (m.width / 2) - (dw / 2) 306 | return kasane.OverlayString(base, dialog, top, left, kasane.WithPadding(m.width)) 307 | } 308 | 309 | func (m pullRequestsListAllModel) statusKeySelectItemView(status *pullRequestStatus) string { 310 | if m.statuses[m.statusIdx].name == status.name { 311 | return pullRequestsListAllDialogSelectedStyle.Render(fmt.Sprintf("> %s (%d)", status.name, status.count)) 312 | } else { 313 | return pullRequestsListAllDialogNotSelectedStyle.Render(fmt.Sprintf(" %s (%d)", status.name, status.count)) 314 | } 315 | } 316 | 317 | func (m pullRequestsListAllModel) breadcrumb() []string { 318 | return []string{m.selectedUser, "PRs (ALL)"} 319 | } 320 | -------------------------------------------------------------------------------- /internal/ui/prs_list_all_delegate.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "time" 7 | 8 | "github.com/charmbracelet/bubbles/key" 9 | "github.com/charmbracelet/bubbles/list" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/muesli/reflow/truncate" 12 | ) 13 | 14 | type pullRequestsListAllItem struct { 15 | owner string 16 | repository string 17 | createdAt time.Time 18 | closedAt time.Time 19 | pullRequestsListItem 20 | } 21 | 22 | func (i pullRequestsListAllItem) styledRepo(selected bool) string { 23 | name := fmt.Sprintf("%s/%s", i.owner, i.repository) 24 | if selected { 25 | name = listSelectedTitleColorStyle.Render(name) 26 | } else { 27 | name = listNormalTitleColorStyle.Render(name) 28 | } 29 | return name 30 | } 31 | 32 | var _ list.Item = (*pullRequestsListAllItem)(nil) 33 | 34 | func (i pullRequestsListAllItem) FilterValue() string { 35 | return i.title 36 | } 37 | 38 | type pullRequestsListAllDelegate struct { 39 | shortHelpFunc func() []key.Binding 40 | fullHelpFunc func() [][]key.Binding 41 | } 42 | 43 | var _ list.ItemDelegate = (*pullRequestsListAllDelegate)(nil) 44 | 45 | func newPullRequestsListAllDelegate(delegateKeys pullRequestsListAllDelegateKeyMap) pullRequestsListAllDelegate { 46 | shortHelpFunc := func() []key.Binding { 47 | return []key.Binding{delegateKeys.back, delegateKeys.tog} 48 | } 49 | fullHelpFunc := func() [][]key.Binding { 50 | return [][]key.Binding{{delegateKeys.stat, delegateKeys.open, delegateKeys.back, delegateKeys.tog}} 51 | } 52 | return pullRequestsListAllDelegate{ 53 | shortHelpFunc: shortHelpFunc, 54 | fullHelpFunc: fullHelpFunc, 55 | } 56 | } 57 | 58 | func (d pullRequestsListAllDelegate) Height() int { 59 | return 3 60 | } 61 | 62 | func (d pullRequestsListAllDelegate) Spacing() int { 63 | return 1 64 | } 65 | 66 | func (d pullRequestsListAllDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { 67 | return nil 68 | } 69 | 70 | func (d pullRequestsListAllDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { 71 | selected := index == m.Index() 72 | 73 | i := item.(pullRequestsListAllItem) 74 | repo := i.styledRepo(selected) 75 | title := i.styledTitle(selected) 76 | desc := i.styledDesc(selected) 77 | 78 | if m.Width() > 0 { 79 | textwidth := uint(m.Width() - listNormalTitleStyle.GetPaddingLeft() - listNormalTitleStyle.GetPaddingRight()) 80 | title = truncate.StringWithTail(title, textwidth, ellipsis) 81 | // todo: considering max width 82 | } 83 | 84 | if selected { 85 | repo = listSelectedItemStyle.Render(repo) 86 | title = listSelectedItemStyle.Render(title) 87 | desc = listSelectedItemStyle.Render(desc) 88 | } else { 89 | repo = listNormalItemStyle.Render(repo) 90 | title = listNormalItemStyle.Render(title) 91 | desc = listNormalItemStyle.Render(desc) 92 | } 93 | 94 | fmt.Fprintf(w, "%s\n%s\n%s", repo, title, desc) 95 | } 96 | 97 | func (d pullRequestsListAllDelegate) ShortHelp() []key.Binding { 98 | return d.shortHelpFunc() 99 | } 100 | 101 | func (d pullRequestsListAllDelegate) FullHelp() [][]key.Binding { 102 | return d.fullHelpFunc() 103 | } 104 | -------------------------------------------------------------------------------- /internal/ui/prs_list_delegate.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/list" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | "github.com/muesli/reflow/truncate" 12 | ) 13 | 14 | var ( 15 | statusStyleBase = lipgloss.NewStyle(). 16 | Underline(true) 17 | 18 | statusOpenStyle = statusStyleBase.Copy(). 19 | Bold(true). 20 | Foreground(lipgloss.Color("34")) 21 | 22 | statusMergedStyle = statusStyleBase.Copy(). 23 | Bold(true). 24 | Foreground(lipgloss.Color("98")) 25 | 26 | statusClosedStyle = statusStyleBase.Copy(). 27 | Bold(true). 28 | Foreground(lipgloss.Color("203")) 29 | 30 | additionsStyle = lipgloss.NewStyle(). 31 | Foreground(lipgloss.Color("34")) 32 | 33 | deletionsStyle = lipgloss.NewStyle(). 34 | Foreground(lipgloss.Color("203")) 35 | ) 36 | 37 | type pullRequestsListItem struct { 38 | title string 39 | status string 40 | number int 41 | additions int 42 | deletions int 43 | comments int 44 | created string 45 | closed string 46 | url string 47 | } 48 | 49 | func (i pullRequestsListItem) styledTitle(selected bool) string { 50 | var title, status string 51 | if selected { 52 | title = listSelectedTitleColorStyle.Render(i.title) 53 | } else { 54 | title = listNormalTitleColorStyle.Render(i.title) 55 | } 56 | switch i.status { 57 | case "OPEN": 58 | status = statusOpenStyle.Render(i.status) 59 | case "MERGED": 60 | status = statusMergedStyle.Render(i.status) 61 | case "CLOSED": 62 | status = statusClosedStyle.Render(i.status) 63 | } 64 | return fmt.Sprintf("%s %s", status, title) 65 | } 66 | 67 | func (i pullRequestsListItem) styledDesc(selected bool) string { 68 | num := i.styledNumber(selected) 69 | upd := i.styledUpdate(selected) 70 | mods := i.styledModifications() 71 | return fmt.Sprintf("%s %s %s", num, upd, mods) 72 | } 73 | 74 | func (i pullRequestsListItem) styledNumber(selected bool) string { 75 | s := fmt.Sprintf("#%d", i.number) 76 | if selected { 77 | return listSelectedDescColorStyle.Render(s) 78 | } 79 | return listNormalDescColorStyle.Render(s) 80 | } 81 | 82 | func (i pullRequestsListItem) styledUpdate(selected bool) string { 83 | var upd, st string 84 | switch i.status { 85 | case "OPEN": 86 | upd = i.created 87 | st = "opened" 88 | case "MERGED": 89 | upd = i.closed 90 | st = "merged" 91 | case "CLOSED": 92 | upd = i.closed 93 | st = "closed" 94 | } 95 | s := fmt.Sprintf("%s %s", st, upd) 96 | if selected { 97 | return listSelectedDescColorStyle.Render(s) 98 | } 99 | return listNormalDescColorStyle.Render(s) 100 | } 101 | 102 | func (i pullRequestsListItem) styledModifications() string { 103 | s := "" 104 | if i.additions > 0 { 105 | s += additionsStyle.Render(fmt.Sprintf("+%d", i.additions)) 106 | } 107 | if i.deletions > 0 { 108 | s += deletionsStyle.Render(fmt.Sprintf("-%d", i.deletions)) 109 | } 110 | return s 111 | } 112 | 113 | var _ list.Item = (*pullRequestsListItem)(nil) 114 | 115 | func (i pullRequestsListItem) FilterValue() string { 116 | return i.title 117 | } 118 | 119 | type pullRequestsListDelegate struct { 120 | shortHelpFunc func() []key.Binding 121 | fullHelpFunc func() [][]key.Binding 122 | } 123 | 124 | var _ list.ItemDelegate = (*pullRequestsListDelegate)(nil) 125 | 126 | func newPullRequestsListDelegate(delegateKeys pullRequestsListDelegateKeyMap) pullRequestsListDelegate { 127 | shortHelpFunc := func() []key.Binding { 128 | return []key.Binding{delegateKeys.back} 129 | } 130 | fullHelpFunc := func() [][]key.Binding { 131 | return [][]key.Binding{{delegateKeys.open, delegateKeys.back}} 132 | } 133 | return pullRequestsListDelegate{ 134 | shortHelpFunc: shortHelpFunc, 135 | fullHelpFunc: fullHelpFunc, 136 | } 137 | } 138 | 139 | func (d pullRequestsListDelegate) Height() int { 140 | return 2 141 | } 142 | 143 | func (d pullRequestsListDelegate) Spacing() int { 144 | return 1 145 | } 146 | 147 | func (d pullRequestsListDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { 148 | return nil 149 | } 150 | 151 | func (d pullRequestsListDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { 152 | selected := index == m.Index() 153 | 154 | i := item.(pullRequestsListItem) 155 | title := i.styledTitle(selected) 156 | desc := i.styledDesc(selected) 157 | 158 | if m.Width() > 0 { 159 | textwidth := uint(m.Width() - listNormalTitleStyle.GetPaddingLeft() - listNormalTitleStyle.GetPaddingRight()) 160 | title = truncate.StringWithTail(title, textwidth, ellipsis) 161 | // desc = truncate.StringWithTail(desc, textwidth, ellipsis) 162 | // todo: considering max width 163 | } 164 | 165 | if selected { 166 | title = listSelectedItemStyle.Render(title) 167 | desc = listSelectedItemStyle.Render(desc) 168 | } else { 169 | title = listNormalItemStyle.Render(title) 170 | desc = listNormalItemStyle.Render(desc) 171 | } 172 | 173 | fmt.Fprintf(w, "%s\n%s", title, desc) 174 | } 175 | 176 | func (d pullRequestsListDelegate) ShortHelp() []key.Binding { 177 | return d.shortHelpFunc() 178 | } 179 | 180 | func (d pullRequestsListDelegate) FullHelp() [][]key.Binding { 181 | return d.fullHelpFunc() 182 | } 183 | -------------------------------------------------------------------------------- /internal/ui/prs_owner.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/key" 7 | "github.com/charmbracelet/bubbles/list" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/lusingander/ghcv-cli/internal/gh" 10 | ) 11 | 12 | type pullRequestsOwnerModel struct { 13 | prs *gh.UserPullRequests 14 | 15 | list list.Model 16 | delegateKeys pullRequestsOwnerDelegateKeyMap 17 | 18 | selectedUser string 19 | width, height int 20 | } 21 | 22 | type pullRequestsOwnerItem struct { 23 | name string 24 | reposCount int 25 | prsCount int 26 | } 27 | 28 | var _ list.DefaultItem = (*pullRequestsOwnerItem)(nil) 29 | 30 | func (i pullRequestsOwnerItem) Title() string { 31 | return i.name 32 | } 33 | 34 | func (i pullRequestsOwnerItem) Description() string { 35 | var p, r string 36 | if i.prsCount > 1 { 37 | p = fmt.Sprintf("%d pull requests", i.prsCount) 38 | } else { 39 | p = "1 pull request" 40 | } 41 | if i.reposCount > 1 { 42 | r = fmt.Sprintf("%d repositories", i.reposCount) 43 | } else { 44 | r = "1 repository" 45 | } 46 | return fmt.Sprintf("Total %s in %s", p, r) 47 | } 48 | 49 | func (i pullRequestsOwnerItem) FilterValue() string { 50 | return i.name 51 | } 52 | 53 | type pullRequestsOwnerDelegateKeyMap struct { 54 | sel key.Binding 55 | back key.Binding 56 | tog key.Binding 57 | quit key.Binding 58 | } 59 | 60 | func newPullRequestsOwnerDelegateKeyMap() pullRequestsOwnerDelegateKeyMap { 61 | return pullRequestsOwnerDelegateKeyMap{ 62 | sel: key.NewBinding( 63 | key.WithKeys("enter"), 64 | key.WithHelp("enter", "select"), 65 | ), 66 | back: key.NewBinding( 67 | key.WithKeys("backspace", "ctrl+h"), 68 | key.WithHelp("backspace", "back"), 69 | ), 70 | tog: key.NewBinding( 71 | key.WithKeys("tab"), 72 | key.WithHelp("tab", "toggle"), 73 | ), 74 | quit: key.NewBinding( 75 | key.WithKeys("ctrl+c", "esc"), 76 | key.WithHelp("ctrl+c", "quit"), 77 | ), 78 | } 79 | } 80 | 81 | func newPullRequestsOwnerModel() *pullRequestsOwnerModel { 82 | var items []list.Item 83 | delegate := list.NewDefaultDelegate() 84 | 85 | delegateKeys := newPullRequestsOwnerDelegateKeyMap() 86 | delegate.ShortHelpFunc = func() []key.Binding { 87 | return []key.Binding{delegateKeys.sel, delegateKeys.back, delegateKeys.tog} 88 | } 89 | delegate.FullHelpFunc = func() [][]key.Binding { 90 | return [][]key.Binding{{delegateKeys.sel, delegateKeys.back, delegateKeys.tog}} 91 | } 92 | 93 | delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Copy().Foreground(selectedColor1).BorderForeground(selectedColor2) 94 | delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Copy().Foreground(selectedColor2).BorderForeground(selectedColor2) 95 | l := list.New(items, delegate, 0, 0) 96 | l.Title = appTitle 97 | l.SetShowTitle(false) 98 | l.SetFilteringEnabled(false) 99 | l.SetShowStatusBar(false) 100 | 101 | return &pullRequestsOwnerModel{ 102 | list: l, 103 | delegateKeys: delegateKeys, 104 | } 105 | } 106 | 107 | func (m *pullRequestsOwnerModel) SetSize(width, height int) { 108 | m.width = width 109 | m.height = height 110 | m.list.SetSize(width, height-2) 111 | } 112 | 113 | func (m *pullRequestsOwnerModel) SetUser(id string) { 114 | m.selectedUser = id 115 | } 116 | 117 | func (m *pullRequestsOwnerModel) updatePrs(prs *gh.UserPullRequests) { 118 | m.prs = prs 119 | items := make([]list.Item, len(m.prs.Owners)) 120 | for i, owner := range m.prs.Owners { 121 | repos := owner.Repositories 122 | prsCount := 0 123 | for _, repo := range repos { 124 | prsCount += len(repo.PullRequests) 125 | } 126 | item := pullRequestsOwnerItem{ 127 | name: owner.Name, 128 | reposCount: len(repos), 129 | prsCount: prsCount, 130 | } 131 | items[i] = item 132 | } 133 | m.list.SetItems(items) 134 | } 135 | 136 | func (m pullRequestsOwnerModel) Init() tea.Cmd { 137 | return nil 138 | } 139 | 140 | func (m pullRequestsOwnerModel) selectPullRequestsOwner(name string) tea.Cmd { 141 | return func() tea.Msg { 142 | for _, owner := range m.prs.Owners { 143 | if owner.Name == name { 144 | return selectPullRequestsOwnerMsg{owner} 145 | } 146 | } 147 | return pullRequestsErrorMsg{nil, "failed to get owner"} 148 | } 149 | } 150 | 151 | func (m pullRequestsOwnerModel) Update(msg tea.Msg) (pullRequestsOwnerModel, tea.Cmd) { 152 | var cmd tea.Cmd 153 | switch msg := msg.(type) { 154 | case tea.KeyMsg: 155 | switch { 156 | case key.Matches(msg, m.delegateKeys.sel): 157 | item := m.list.SelectedItem().(pullRequestsOwnerItem) 158 | return m, m.selectPullRequestsOwner(item.name) 159 | case key.Matches(msg, m.delegateKeys.back): 160 | if m.list.FilterState() != list.Filtering { 161 | return m, goBackMenuPage 162 | } 163 | case key.Matches(msg, m.delegateKeys.tog): 164 | return m, togglePullRequestsListAll(m.prs) 165 | } 166 | case pullRequestsSuccessMsg: 167 | m.list.ResetSelected() 168 | m.updatePrs(msg.prs) 169 | return m, nil 170 | } 171 | m.list, cmd = m.list.Update(msg) 172 | return m, cmd 173 | } 174 | 175 | func (m pullRequestsOwnerModel) View() string { 176 | return titleView(m.breadcrumb()) + listView(m.list) 177 | } 178 | 179 | func (m pullRequestsOwnerModel) breadcrumb() []string { 180 | return []string{m.selectedUser, "PRs"} 181 | } 182 | -------------------------------------------------------------------------------- /internal/ui/prs_repo.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/list" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/lusingander/ghcv-cli/internal/gh" 8 | ) 9 | 10 | type pullRequestsRepositoryModel struct { 11 | repos []*gh.UserPullRequestsRepository 12 | 13 | list list.Model 14 | delegateKeys pullRequestsRepositoryDelegateKeyMap 15 | 16 | selectedUser string 17 | selectedOwner string 18 | width, height int 19 | } 20 | 21 | type pullRequestsRepositoryDelegateKeyMap struct { 22 | open key.Binding 23 | sel key.Binding 24 | back key.Binding 25 | quit key.Binding 26 | } 27 | 28 | func newPullRequestsRepositoryDelegateKeyMap() pullRequestsRepositoryDelegateKeyMap { 29 | return pullRequestsRepositoryDelegateKeyMap{ 30 | open: key.NewBinding( 31 | key.WithKeys("x"), 32 | key.WithHelp("x", "open in browser"), 33 | ), 34 | sel: key.NewBinding( 35 | key.WithKeys("enter"), 36 | key.WithHelp("enter", "select"), 37 | ), 38 | back: key.NewBinding( 39 | key.WithKeys("backspace", "ctrl+h"), 40 | key.WithHelp("backspace", "back"), 41 | ), 42 | quit: key.NewBinding( 43 | key.WithKeys("ctrl+c", "esc"), 44 | key.WithHelp("ctrl+c", "quit"), 45 | ), 46 | } 47 | } 48 | 49 | func newPullRequestsRepositoryModel() *pullRequestsRepositoryModel { 50 | delegateKeys := newPullRequestsRepositoryDelegateKeyMap() 51 | delegate := newPullRequestsRepositoryDelegate(delegateKeys) 52 | 53 | l := list.New(nil, delegate, 0, 0) 54 | l.KeyMap.Quit = delegateKeys.quit 55 | l.SetShowTitle(false) 56 | l.SetFilteringEnabled(false) 57 | l.SetShowStatusBar(false) 58 | 59 | return &pullRequestsRepositoryModel{ 60 | list: l, 61 | delegateKeys: delegateKeys, 62 | } 63 | } 64 | 65 | func (m *pullRequestsRepositoryModel) SetSize(width, height int) { 66 | m.width = width 67 | m.height = height 68 | m.list.SetSize(width, height-2) 69 | } 70 | 71 | func (m *pullRequestsRepositoryModel) SetUser(id string) { 72 | m.selectedUser = id 73 | } 74 | 75 | func (m *pullRequestsRepositoryModel) setOwner(name string) { 76 | m.selectedOwner = name 77 | } 78 | 79 | func (m *pullRequestsRepositoryModel) updateRepos(repos []*gh.UserPullRequestsRepository) { 80 | m.repos = repos 81 | items := make([]list.Item, len(m.repos)) 82 | for i, repo := range m.repos { 83 | item := &pullRequestsRepositoryItem{ 84 | name: repo.Name, 85 | description: repo.Description, 86 | langName: repo.LangName, 87 | langColor: repo.LangColor, 88 | prsCount: len(repo.PullRequests), 89 | url: repo.Url, 90 | } 91 | items[i] = item 92 | } 93 | m.list.SetItems(items) 94 | } 95 | 96 | func (m pullRequestsRepositoryModel) Init() tea.Cmd { 97 | return nil 98 | } 99 | 100 | func (m pullRequestsRepositoryModel) selectPullRequestsRepository(name string) tea.Cmd { 101 | return func() tea.Msg { 102 | for _, repo := range m.repos { 103 | if repo.Name == name { 104 | return selectPullRequestsRepositoryMsg{repo, m.selectedOwner} 105 | } 106 | } 107 | return pullRequestsErrorMsg{nil, "failed to get repository"} 108 | } 109 | } 110 | 111 | func (m pullRequestsRepositoryModel) openRepositoryPageInBrowser(item *pullRequestsRepositoryItem) tea.Cmd { 112 | return func() tea.Msg { 113 | if err := openBrowser(item.url); err != nil { 114 | return profileErrorMsg{err, "failed to open browser"} 115 | } 116 | return nil 117 | } 118 | } 119 | 120 | func (m pullRequestsRepositoryModel) Update(msg tea.Msg) (pullRequestsRepositoryModel, tea.Cmd) { 121 | var cmd tea.Cmd 122 | switch msg := msg.(type) { 123 | case tea.KeyMsg: 124 | switch { 125 | case key.Matches(msg, m.delegateKeys.open): 126 | item := m.list.SelectedItem().(*pullRequestsRepositoryItem) 127 | return m, m.openRepositoryPageInBrowser(item) 128 | case key.Matches(msg, m.delegateKeys.sel): 129 | item := m.list.SelectedItem().(*pullRequestsRepositoryItem) 130 | return m, m.selectPullRequestsRepository(item.name) 131 | case key.Matches(msg, m.delegateKeys.back): 132 | if m.list.FilterState() != list.Filtering { 133 | return m, goBackPullRequestsOwnerPage 134 | } 135 | } 136 | case selectPullRequestsOwnerMsg: 137 | m.list.ResetSelected() 138 | m.updateRepos(msg.owner.Repositories) 139 | m.setOwner(msg.owner.Name) 140 | return m, nil 141 | } 142 | m.list, cmd = m.list.Update(msg) 143 | return m, cmd 144 | } 145 | 146 | func (m pullRequestsRepositoryModel) View() string { 147 | return titleView(m.breadcrumb()) + listView(m.list) 148 | } 149 | 150 | func (m pullRequestsRepositoryModel) breadcrumb() []string { 151 | return []string{m.selectedUser, "PRs", m.selectedOwner} 152 | } 153 | -------------------------------------------------------------------------------- /internal/ui/prs_repo_delegate.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/list" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | "github.com/muesli/reflow/truncate" 12 | ) 13 | 14 | type pullRequestsRepositoryItem struct { 15 | name string 16 | description string 17 | langName string 18 | langColor string 19 | prsCount int 20 | url string 21 | } 22 | 23 | var _ list.Item = (*pullRequestsRepositoryItem)(nil) 24 | 25 | func (i pullRequestsRepositoryItem) FilterValue() string { 26 | return i.name 27 | } 28 | 29 | type pullRequestsRepositoryDelegate struct { 30 | styles list.DefaultItemStyles 31 | shortHelpFunc func() []key.Binding 32 | fullHelpFunc func() [][]key.Binding 33 | 34 | normalDescWithoutPadding lipgloss.Style 35 | normalDescOnlyPadding lipgloss.Style 36 | selectedDescWithoutPadding lipgloss.Style 37 | selectedDescOnlyPadding lipgloss.Style 38 | dimmedDescWithoutPadding lipgloss.Style 39 | dimmedDescOnlyPadding lipgloss.Style 40 | } 41 | 42 | var _ list.ItemDelegate = (*pullRequestsRepositoryDelegate)(nil) 43 | 44 | func newPullRequestsRepositoryDelegate(delegateKeys pullRequestsRepositoryDelegateKeyMap) pullRequestsRepositoryDelegate { 45 | styles := list.NewDefaultItemStyles() 46 | styles.SelectedTitle = styles.SelectedTitle.Copy().Foreground(selectedColor1).BorderForeground(selectedColor2) 47 | styles.SelectedDesc = styles.SelectedDesc.Copy().Foreground(selectedColor2).BorderForeground(selectedColor2) 48 | 49 | shortHelpFunc := func() []key.Binding { 50 | return []key.Binding{delegateKeys.sel, delegateKeys.back} 51 | } 52 | fullHelpFunc := func() [][]key.Binding { 53 | return [][]key.Binding{{delegateKeys.open, delegateKeys.sel, delegateKeys.back}} 54 | } 55 | 56 | normalDescWithoutPadding := styles.NormalDesc.Copy().UnsetPadding() 57 | normalDescOnlyPadding := lipgloss.NewStyle().Padding(styles.NormalDesc.GetPadding()) 58 | selectedDescWithoutPadding := styles.SelectedDesc.Copy().UnsetPadding().UnsetBorderStyle() 59 | selectedDescOnlyPadding := lipgloss.NewStyle().Padding(styles.SelectedDesc.GetPadding()).Border(styles.SelectedDesc.GetBorder()).BorderForeground(styles.SelectedDesc.GetBorderLeftForeground()) 60 | dimmedDescWithoutPadding := styles.DimmedDesc.Copy().UnsetPadding() 61 | dimmedDescOnlyPadding := lipgloss.NewStyle().Padding(styles.DimmedDesc.GetPadding()) 62 | 63 | return pullRequestsRepositoryDelegate{ 64 | styles: styles, 65 | shortHelpFunc: shortHelpFunc, 66 | fullHelpFunc: fullHelpFunc, 67 | normalDescWithoutPadding: normalDescWithoutPadding, 68 | normalDescOnlyPadding: normalDescOnlyPadding, 69 | selectedDescWithoutPadding: selectedDescWithoutPadding, 70 | selectedDescOnlyPadding: selectedDescOnlyPadding, 71 | dimmedDescWithoutPadding: dimmedDescWithoutPadding, 72 | dimmedDescOnlyPadding: dimmedDescOnlyPadding, 73 | } 74 | } 75 | 76 | func (d pullRequestsRepositoryDelegate) Height() int { 77 | return 4 78 | } 79 | 80 | func (d pullRequestsRepositoryDelegate) Spacing() int { 81 | return 1 82 | } 83 | 84 | func (d pullRequestsRepositoryDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { 85 | return nil 86 | } 87 | 88 | func (d pullRequestsRepositoryDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { 89 | matchedRunes := []int{} 90 | s := &d.styles 91 | 92 | i := item.(*pullRequestsRepositoryItem) 93 | name := i.name 94 | desc := i.description 95 | if desc == "" { 96 | desc = "-" 97 | } 98 | 99 | prs := fmt.Sprintf("%d pull request", i.prsCount) 100 | if i.prsCount > 1 { 101 | prs += "s" 102 | } 103 | 104 | // U+25CD 105 | // U+26AB will be displayed as emoji 106 | // U+2B24 is too large 107 | detailsLangColor := "◍ " 108 | detailsLangColor = lipgloss.NewStyle().Foreground(lipgloss.Color(i.langColor)).Render(detailsLangColor) 109 | details := fmt.Sprintf("%s %s", i.langName, prs) 110 | 111 | if m.Width() > 0 { 112 | textwidth := uint(m.Width() - s.NormalTitle.GetPaddingLeft() - s.NormalTitle.GetPaddingRight()) 113 | name = truncate.StringWithTail(name, textwidth, ellipsis) 114 | desc = truncate.StringWithTail(desc, textwidth, ellipsis) 115 | // todo: considering max width 116 | } 117 | 118 | var ( 119 | isSelected = index == m.Index() 120 | emptyFilter = m.FilterState() == list.Filtering && m.FilterValue() == "" 121 | isFiltered = m.FilterState() == list.Filtering || m.FilterState() == list.FilterApplied 122 | ) 123 | 124 | if isFiltered && index < len(m.VisibleItems()) { 125 | matchedRunes = m.MatchesForItem(index) 126 | } 127 | 128 | if emptyFilter { 129 | name = s.DimmedTitle.Render(name) 130 | desc = s.DimmedDesc.Render(desc) 131 | details = d.dimmedDescWithoutPadding.Render(details) 132 | details = d.dimmedDescOnlyPadding.Render(detailsLangColor + details) 133 | } else if isSelected && m.FilterState() != list.Filtering { 134 | if isFiltered { 135 | unmatched := s.SelectedTitle.Inline(true) 136 | matched := unmatched.Copy().Inherit(s.FilterMatch) 137 | name = lipgloss.StyleRunes(name, matchedRunes, matched, unmatched) 138 | } 139 | name = s.SelectedTitle.Render(name) 140 | desc = s.SelectedDesc.Render(desc) 141 | details = d.selectedDescWithoutPadding.Render(details) 142 | details = d.selectedDescOnlyPadding.Render(detailsLangColor + details) 143 | } else { 144 | if isFiltered { 145 | unmatched := s.NormalTitle.Inline(true) 146 | matched := unmatched.Copy().Inherit(s.FilterMatch) 147 | name = lipgloss.StyleRunes(name, matchedRunes, matched, unmatched) 148 | } 149 | name = s.NormalTitle.Render(name) 150 | desc = s.NormalDesc.Render(desc) 151 | details = d.normalDescWithoutPadding.Render(details) 152 | details = d.normalDescOnlyPadding.Render(detailsLangColor + details) 153 | } 154 | 155 | fmt.Fprintf(w, "%s\n%s\n%s", name, desc, details) 156 | } 157 | 158 | func (d pullRequestsRepositoryDelegate) ShortHelp() []key.Binding { 159 | return d.shortHelpFunc() 160 | } 161 | 162 | func (d pullRequestsRepositoryDelegate) FullHelp() [][]key.Binding { 163 | return d.fullHelpFunc() 164 | } 165 | -------------------------------------------------------------------------------- /internal/ui/repositories.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/key" 9 | "github.com/charmbracelet/bubbles/list" 10 | "github.com/charmbracelet/bubbles/spinner" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | "github.com/lusingander/ghcv-cli/internal/gh" 14 | "github.com/lusingander/kasane" 15 | ) 16 | 17 | var ( 18 | repositoriesErrorStyle = lipgloss.NewStyle(). 19 | Padding(2, 0, 0, 2). 20 | Foreground(lipgloss.Color("161")) 21 | 22 | dialogTitleStyle = lipgloss.NewStyle(). 23 | Align(lipgloss.Center). 24 | Border(lipgloss.NormalBorder(), false, false, true, false). 25 | BorderForeground(lipgloss.Color("240")) 26 | 27 | repositoriesDialogTitleStyle = dialogTitleStyle.Copy(). 28 | Width(30) 29 | 30 | repositoriesDialogBodyStyle = lipgloss.NewStyle(). 31 | Padding(0, 2) 32 | 33 | reposirotiesDialogStyle = lipgloss.NewStyle(). 34 | BorderStyle(lipgloss.RoundedBorder()) 35 | 36 | repositoriesDialogSelectedStyle = lipgloss.NewStyle(). 37 | Foreground(selectedColor1) 38 | 39 | repositoriesDialogNotSelectedStyle = lipgloss.NewStyle() 40 | ) 41 | 42 | type sortType int 43 | 44 | const ( 45 | sortByStarDesc sortType = iota 46 | sortByStarAsc 47 | sortByUpdatedDesc 48 | sortByUpdatedAsc 49 | ) 50 | 51 | type repositoeisLang struct { 52 | name string 53 | count int 54 | } 55 | 56 | type repositoriesModel struct { 57 | client *gh.GitHubClient 58 | 59 | list list.Model 60 | originalItems []list.Item 61 | spinner *spinner.Model 62 | 63 | delegateKeys repositoriesDelegateKeyMap 64 | sortDialogDelegateKeys repositoriesSortDialogDelegateKeyMap 65 | langDialogDelegateKeys repositoriesLangDialogDelegateKeyMap 66 | 67 | errorMsg *repositoriesErrorMsg 68 | loading bool 69 | selectedUser string 70 | width, height int 71 | 72 | sortType 73 | sortDialogOpened bool 74 | 75 | langs []*repositoeisLang 76 | langIdx int 77 | langDialogOpened bool 78 | } 79 | 80 | type repositoriesDelegateKeyMap struct { 81 | sort key.Binding 82 | lang key.Binding 83 | open key.Binding 84 | back key.Binding 85 | quit key.Binding 86 | } 87 | 88 | func newRepositoriesDelegateKeyMap() repositoriesDelegateKeyMap { 89 | return repositoriesDelegateKeyMap{ 90 | sort: key.NewBinding( 91 | key.WithKeys("S"), 92 | key.WithHelp("S", "sort"), 93 | ), 94 | lang: key.NewBinding( 95 | key.WithKeys("L"), 96 | key.WithHelp("L", "filter by language"), 97 | ), 98 | open: key.NewBinding( 99 | key.WithKeys("x"), 100 | key.WithHelp("x", "open in browser"), 101 | ), 102 | back: key.NewBinding( 103 | key.WithKeys("backspace", "ctrl+h"), 104 | key.WithHelp("backspace", "back"), 105 | ), 106 | quit: key.NewBinding( 107 | key.WithKeys("ctrl+c", "esc"), 108 | key.WithHelp("ctrl+c", "quit"), 109 | ), 110 | } 111 | } 112 | 113 | type repositoriesSortDialogDelegateKeyMap struct { 114 | next key.Binding 115 | prev key.Binding 116 | close key.Binding 117 | } 118 | 119 | func newRepositoriesSortDialogDelegateKeyMap() repositoriesSortDialogDelegateKeyMap { 120 | return repositoriesSortDialogDelegateKeyMap{ 121 | next: key.NewBinding( 122 | key.WithKeys("j"), 123 | key.WithHelp("j", "select next"), 124 | ), 125 | prev: key.NewBinding( 126 | key.WithKeys("k"), 127 | key.WithHelp("k", "select prev"), 128 | ), 129 | close: key.NewBinding( 130 | key.WithKeys("S", "esc", "enter"), 131 | key.WithHelp("S", "close dialog"), 132 | ), 133 | } 134 | } 135 | 136 | type repositoriesLangDialogDelegateKeyMap struct { 137 | next key.Binding 138 | prev key.Binding 139 | close key.Binding 140 | } 141 | 142 | func newRepositoriesLangDialogDelegateKeyMap() repositoriesLangDialogDelegateKeyMap { 143 | return repositoriesLangDialogDelegateKeyMap{ 144 | next: key.NewBinding( 145 | key.WithKeys("j"), 146 | key.WithHelp("j", "select next"), 147 | ), 148 | prev: key.NewBinding( 149 | key.WithKeys("k"), 150 | key.WithHelp("k", "select prev"), 151 | ), 152 | close: key.NewBinding( 153 | key.WithKeys("L", "esc", "enter"), 154 | key.WithHelp("L", "close dialog"), 155 | ), 156 | } 157 | } 158 | 159 | func newRepositoriesModel(client *gh.GitHubClient, s *spinner.Model) repositoriesModel { 160 | delegateKeys := newRepositoriesDelegateKeyMap() 161 | delegate := NewRepositoryDelegate(delegateKeys) 162 | sortDialogDelegateKeys := newRepositoriesSortDialogDelegateKeyMap() 163 | langDialogDelegateKeys := newRepositoriesLangDialogDelegateKeyMap() 164 | 165 | l := list.New(nil, delegate, 0, 0) 166 | l.KeyMap.Quit = delegateKeys.quit 167 | l.SetShowTitle(false) 168 | l.SetFilteringEnabled(false) 169 | l.SetShowStatusBar(false) 170 | 171 | return repositoriesModel{ 172 | client: client, 173 | list: l, 174 | spinner: s, 175 | delegateKeys: delegateKeys, 176 | sortDialogDelegateKeys: sortDialogDelegateKeys, 177 | langDialogDelegateKeys: langDialogDelegateKeys, 178 | } 179 | } 180 | 181 | func (m *repositoriesModel) SetSize(width, height int) { 182 | m.width = width 183 | m.height = height 184 | m.list.SetSize(width, height-2) 185 | } 186 | 187 | func (m *repositoriesModel) SetUser(id string) { 188 | m.selectedUser = id 189 | } 190 | 191 | func (m *repositoriesModel) updateItems(repos *gh.UserRepositories) { 192 | items := make([]list.Item, len(repos.Repositories)) 193 | langMap := make(map[string]int) 194 | for i, repo := range repos.Repositories { 195 | updated := formatDuration(repo.PushedAt) 196 | item := &repositoryItem{ 197 | title: repo.Name, 198 | description: repo.Description, 199 | langName: repo.LangName, 200 | langColor: repo.LangColor, 201 | license: repo.License, 202 | updated: updated, 203 | stars: repo.Stars, 204 | forks: repo.Forks, 205 | watchers: repo.Watchers, 206 | url: repo.Url, 207 | pushedAt: repo.PushedAt, 208 | } 209 | items[i] = item 210 | langMap[repo.LangName] += 1 211 | } 212 | 213 | m.list.SetItems(items) 214 | m.originalItems = items 215 | 216 | m.sortType = sortByStarDesc 217 | 218 | langs := make([]*repositoeisLang, 0, len(langMap)+1) 219 | langs = append(langs, &repositoeisLang{name: "All", count: len(items)}) 220 | for k, v := range langMap { 221 | langs = append(langs, &repositoeisLang{name: k, count: v}) 222 | } 223 | sort.Slice(langs, func(i, j int) bool { 224 | if langs[i].count == langs[j].count { 225 | return langs[i].name < langs[j].name 226 | } 227 | return langs[i].count > langs[j].count 228 | }) 229 | m.langs = langs 230 | m.langIdx = 0 231 | } 232 | 233 | func (m *repositoriesModel) updateSortType(reverse bool) { 234 | if reverse { 235 | switch m.sortType { 236 | case sortByStarDesc: 237 | m.sortType = sortByUpdatedAsc 238 | case sortByStarAsc: 239 | m.sortType = sortByStarDesc 240 | case sortByUpdatedDesc: 241 | m.sortType = sortByStarAsc 242 | case sortByUpdatedAsc: 243 | m.sortType = sortByUpdatedDesc 244 | } 245 | } else { 246 | switch m.sortType { 247 | case sortByStarDesc: 248 | m.sortType = sortByStarAsc 249 | case sortByStarAsc: 250 | m.sortType = sortByUpdatedDesc 251 | case sortByUpdatedDesc: 252 | m.sortType = sortByUpdatedAsc 253 | case sortByUpdatedAsc: 254 | m.sortType = sortByStarDesc 255 | } 256 | } 257 | } 258 | 259 | func (m *repositoriesModel) sortItems() { 260 | items := m.list.Items() 261 | switch m.sortType { 262 | case sortByStarDesc: 263 | sort.Slice(items, func(i, j int) bool { 264 | return items[i].(*repositoryItem).stars > items[j].(*repositoryItem).stars 265 | }) 266 | case sortByStarAsc: 267 | sort.Slice(items, func(i, j int) bool { 268 | return items[i].(*repositoryItem).stars < items[j].(*repositoryItem).stars 269 | }) 270 | case sortByUpdatedDesc: 271 | sort.Slice(items, func(i, j int) bool { 272 | return items[i].(*repositoryItem).pushedAt.After(items[j].(*repositoryItem).pushedAt) 273 | }) 274 | case sortByUpdatedAsc: 275 | sort.Slice(items, func(i, j int) bool { 276 | return items[i].(*repositoryItem).pushedAt.Before(items[j].(*repositoryItem).pushedAt) 277 | }) 278 | } 279 | m.list.SetItems(items) 280 | } 281 | 282 | func (m *repositoriesModel) updateLangIdx(reverse bool) { 283 | n := len(m.langs) 284 | if reverse { 285 | m.langIdx = ((m.langIdx-1)%n + n) % n 286 | } else { 287 | m.langIdx = (m.langIdx + 1) % n 288 | } 289 | } 290 | 291 | func (m *repositoriesModel) filterItems() { 292 | if m.langs[m.langIdx].name == "All" { 293 | m.list.SetItems(m.originalItems) 294 | return 295 | } 296 | items := make([]list.Item, 0) 297 | for _, i := range m.originalItems { 298 | if i.(*repositoryItem).langName == m.langs[m.langIdx].name { 299 | items = append(items, i) 300 | } 301 | } 302 | m.list.SetItems(items) 303 | } 304 | 305 | func (m repositoriesModel) Init() tea.Cmd { 306 | return nil 307 | } 308 | 309 | type repositoriesSuccessMsg struct { 310 | repos *gh.UserRepositories 311 | } 312 | 313 | var _ tea.Msg = (*repositoriesSuccessMsg)(nil) 314 | 315 | type repositoriesErrorMsg struct { 316 | e error 317 | summary string 318 | } 319 | 320 | var _ tea.Msg = (*repositoriesErrorMsg)(nil) 321 | 322 | type loadRepositoriesMsg struct{} 323 | 324 | var _ tea.Msg = (*loadRepositoriesMsg)(nil) 325 | 326 | func (m repositoriesModel) loadRepositores(id string) tea.Cmd { 327 | return func() tea.Msg { 328 | repos, err := m.client.QueryUserRepositories(id) 329 | if err != nil { 330 | return repositoriesErrorMsg{err, "failed to fetch repositories"} 331 | } 332 | return repositoriesSuccessMsg{repos} 333 | } 334 | } 335 | 336 | func (m repositoriesModel) openRepositoryPageInBrowser(item *repositoryItem) tea.Cmd { 337 | return func() tea.Msg { 338 | if err := openBrowser(item.url); err != nil { 339 | return profileErrorMsg{err, "failed to open browser"} 340 | } 341 | return nil 342 | } 343 | } 344 | 345 | func (m repositoriesModel) Update(msg tea.Msg) (repositoriesModel, tea.Cmd) { 346 | cmds := make([]tea.Cmd, 0) 347 | switch msg := msg.(type) { 348 | case tea.KeyMsg: 349 | if m.loading { 350 | return m, nil 351 | } 352 | if m.sortDialogOpened { 353 | switch { 354 | case key.Matches(msg, m.sortDialogDelegateKeys.close): 355 | m.sortDialogOpened = false 356 | case key.Matches(msg, m.sortDialogDelegateKeys.next): 357 | m.list.ResetSelected() 358 | m.updateSortType(false) 359 | m.sortItems() 360 | case key.Matches(msg, m.sortDialogDelegateKeys.prev): 361 | m.list.ResetSelected() 362 | m.updateSortType(true) 363 | m.sortItems() 364 | } 365 | return m, nil 366 | } 367 | if m.langDialogOpened { 368 | switch { 369 | case key.Matches(msg, m.langDialogDelegateKeys.close): 370 | m.langDialogOpened = false 371 | case key.Matches(msg, m.langDialogDelegateKeys.next): 372 | m.list.ResetSelected() 373 | m.updateLangIdx(false) 374 | m.filterItems() 375 | case key.Matches(msg, m.langDialogDelegateKeys.prev): 376 | m.list.ResetSelected() 377 | m.updateLangIdx(true) 378 | m.filterItems() 379 | } 380 | return m, nil 381 | } 382 | switch { 383 | case key.Matches(msg, m.delegateKeys.sort): 384 | m.sortDialogOpened = true 385 | return m, nil 386 | case key.Matches(msg, m.delegateKeys.lang): 387 | m.langDialogOpened = true 388 | return m, nil 389 | case key.Matches(msg, m.delegateKeys.open): 390 | item := m.list.SelectedItem().(*repositoryItem) 391 | return m, m.openRepositoryPageInBrowser(item) 392 | case key.Matches(msg, m.delegateKeys.back): 393 | if m.list.FilterState() != list.Filtering { 394 | return m, goBackMenuPage 395 | } 396 | } 397 | case selectRepositoriesPageMsg: 398 | m.loading = true 399 | return m, m.loadRepositores(msg.id) 400 | case repositoriesSuccessMsg: 401 | m.errorMsg = nil 402 | m.loading = false 403 | m.list.ResetSelected() 404 | m.updateItems(msg.repos) 405 | return m, nil 406 | case repositoriesErrorMsg: 407 | m.errorMsg = &msg 408 | m.loading = false 409 | return m, nil 410 | } 411 | 412 | list, lCmd := m.list.Update(msg) 413 | m.list = list 414 | cmds = append(cmds, lCmd) 415 | 416 | return m, tea.Batch(cmds...) 417 | } 418 | 419 | func (m repositoriesModel) View() string { 420 | if m.loading { 421 | return loadingView(m.spinner, m.breadcrumb()) 422 | } 423 | if m.errorMsg != nil { 424 | return m.errorView() 425 | } 426 | ret := titleView(m.breadcrumb()) + listView(m.list) 427 | if m.sortDialogOpened { 428 | return m.withSortDialogView(ret) 429 | } 430 | if m.langDialogOpened { 431 | return m.withLangDialogView(ret) 432 | } 433 | return ret 434 | } 435 | 436 | func (m repositoriesModel) withSortDialogView(base string) string { 437 | title := repositoriesDialogTitleStyle.Render("Sort") 438 | 439 | body := strings.Join([]string{ 440 | m.sortKeySelectItemView("Stars (Desc)", sortByStarDesc), 441 | m.sortKeySelectItemView("Stars (Asc)", sortByStarAsc), 442 | m.sortKeySelectItemView("Last Updated (Desc)", sortByUpdatedDesc), 443 | m.sortKeySelectItemView("Last Updated (Asc)", sortByUpdatedAsc), 444 | }, "\n") 445 | body = repositoriesDialogBodyStyle.Render(body) 446 | 447 | dialog := reposirotiesDialogStyle.Render(lipgloss.JoinVertical(lipgloss.Left, title, body)) 448 | 449 | dw, dh := lipgloss.Size(dialog) 450 | top := (m.height / 2) - (dh / 2) 451 | left := (m.width / 2) - (dw / 2) 452 | return kasane.OverlayString(base, dialog, top, left, kasane.WithPadding(m.width)) 453 | } 454 | 455 | func (m repositoriesModel) sortKeySelectItemView(s string, st sortType) string { 456 | if m.sortType == st { 457 | return repositoriesDialogSelectedStyle.Render("> " + s) 458 | } else { 459 | return repositoriesDialogNotSelectedStyle.Render(" " + s) 460 | } 461 | } 462 | 463 | func (m repositoriesModel) withLangDialogView(base string) string { 464 | title := repositoriesDialogTitleStyle.Render("Language") 465 | 466 | ivs := make([]string, len(m.langs)) 467 | for i, l := range m.langs { 468 | ivs[i] = m.langKeySelectItemView(l) 469 | } 470 | body := strings.Join(ivs, "\n") 471 | body = repositoriesDialogBodyStyle.Render(body) 472 | 473 | dialog := reposirotiesDialogStyle.Render(lipgloss.JoinVertical(lipgloss.Left, title, body)) 474 | 475 | dw, dh := lipgloss.Size(dialog) 476 | top := (m.height / 2) - (dh / 2) 477 | left := (m.width / 2) - (dw / 2) 478 | return kasane.OverlayString(base, dialog, top, left, kasane.WithPadding(m.width)) 479 | } 480 | 481 | func (m repositoriesModel) langKeySelectItemView(lang *repositoeisLang) string { 482 | if m.langs[m.langIdx].name == lang.name { 483 | return repositoriesDialogSelectedStyle.Render(fmt.Sprintf("> %s (%d)", lang.name, lang.count)) 484 | } else { 485 | return repositoriesDialogNotSelectedStyle.Render(fmt.Sprintf(" %s (%d)", lang.name, lang.count)) 486 | } 487 | } 488 | 489 | func (m repositoriesModel) errorView() string { 490 | if m.height <= 0 { 491 | return "" 492 | } 493 | 494 | ret := "" 495 | height := m.height - 1 496 | 497 | title := titleView(m.breadcrumb()) 498 | ret += title 499 | height -= cn(title) 500 | 501 | errorText := repositoriesErrorStyle.Render("ERROR: " + m.errorMsg.summary) 502 | ret += errorText 503 | height -= cn(errorText) 504 | 505 | return ret 506 | } 507 | 508 | func (m repositoriesModel) breadcrumb() []string { 509 | return []string{m.selectedUser, "Repositories"} 510 | } 511 | -------------------------------------------------------------------------------- /internal/ui/repository_delegate.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "time" 7 | 8 | "github.com/charmbracelet/bubbles/key" 9 | "github.com/charmbracelet/bubbles/list" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/muesli/reflow/truncate" 13 | ) 14 | 15 | const ( 16 | ellipsis = "…" 17 | ) 18 | 19 | type repositoryItem struct { 20 | title string 21 | description string 22 | langName string 23 | langColor string 24 | license string 25 | updated string 26 | stars int 27 | forks int 28 | watchers int 29 | url string 30 | pushedAt time.Time 31 | } 32 | 33 | func (i repositoryItem) titleStr() string { 34 | return i.title 35 | } 36 | 37 | func (i repositoryItem) descStr() string { 38 | if i.description == "" { 39 | return "-" 40 | } 41 | return i.description 42 | } 43 | 44 | func (i repositoryItem) styledLangColor() string { 45 | // U+25CD 46 | // U+26AB will be displayed as emoji 47 | // U+2B24 is too large 48 | s := "◍ " 49 | style := lipgloss.NewStyle().Foreground(lipgloss.Color(i.langColor)) 50 | return style.Render(s) 51 | } 52 | 53 | func (i repositoryItem) detailsStr() string { 54 | license := i.license 55 | if license == "" { 56 | license = "-" 57 | } 58 | return fmt.Sprintf("%s ⚖ %s Updated %s", i.langName, license, i.updated) 59 | } 60 | 61 | func (i repositoryItem) countsStr() string { 62 | return fmt.Sprintf("Star: %d / Fork: %d / Watch: %d", i.stars, i.forks, i.watchers) 63 | } 64 | 65 | var _ list.Item = (*repositoryItem)(nil) 66 | 67 | func (i repositoryItem) FilterValue() string { 68 | return i.title 69 | } 70 | 71 | type repositoryDelegate struct { 72 | shortHelpFunc func() []key.Binding 73 | fullHelpFunc func() [][]key.Binding 74 | } 75 | 76 | var _ list.ItemDelegate = (*repositoryDelegate)(nil) 77 | 78 | func NewRepositoryDelegate(delegateKeys repositoriesDelegateKeyMap) repositoryDelegate { 79 | 80 | shortHelpFunc := func() []key.Binding { 81 | return []key.Binding{delegateKeys.open, delegateKeys.back} 82 | } 83 | fullHelpFunc := func() [][]key.Binding { 84 | return [][]key.Binding{{delegateKeys.sort, delegateKeys.lang, delegateKeys.open, delegateKeys.back}} 85 | } 86 | 87 | return repositoryDelegate{ 88 | shortHelpFunc: shortHelpFunc, 89 | fullHelpFunc: fullHelpFunc, 90 | } 91 | } 92 | 93 | func (d repositoryDelegate) Height() int { 94 | return 4 95 | } 96 | 97 | func (d repositoryDelegate) Spacing() int { 98 | return 1 99 | } 100 | 101 | func (d repositoryDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { 102 | return nil 103 | } 104 | 105 | func (d repositoryDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { 106 | i := item.(*repositoryItem) 107 | title := i.titleStr() 108 | desc := i.descStr() 109 | detailsLangColor := i.styledLangColor() 110 | details := i.detailsStr() 111 | counts := i.countsStr() 112 | 113 | if m.Width() > 0 { 114 | textwidth := uint(m.Width() - listNormalTitleStyle.GetPaddingLeft() - listNormalTitleStyle.GetPaddingRight()) 115 | title = truncate.StringWithTail(title, textwidth, ellipsis) 116 | desc = truncate.StringWithTail(desc, textwidth, ellipsis) 117 | // todo: considering max width 118 | } 119 | 120 | if index == m.Index() { 121 | title = listSelectedTitleStyle.Render(title) 122 | desc = listSelectedDescStyle.Render(desc) 123 | counts = listSelectedDescStyle.Render(counts) 124 | details = listSelectedDescColorStyle.Render(details) 125 | details = listSelectedItemStyle.Render(detailsLangColor + details) 126 | } else { 127 | title = listNormalTitleStyle.Render(title) 128 | desc = listNormalDescStyle.Render(desc) 129 | counts = listNormalDescStyle.Render(counts) 130 | details = listNormalDescColorStyle.Render(details) 131 | details = listNormalItemStyle.Render(detailsLangColor + details) 132 | } 133 | 134 | fmt.Fprintf(w, "%s\n%s\n%s\n%s", title, desc, counts, details) 135 | } 136 | 137 | func (d repositoryDelegate) ShortHelp() []key.Binding { 138 | return d.shortHelpFunc() 139 | } 140 | 141 | func (d repositoryDelegate) FullHelp() [][]key.Binding { 142 | return d.fullHelpFunc() 143 | } 144 | -------------------------------------------------------------------------------- /internal/ui/user.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/bubbles/help" 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/spinner" 9 | "github.com/charmbracelet/bubbles/textinput" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/lusingander/ghcv-cli/internal/gh" 13 | ) 14 | 15 | var ( 16 | inputLabelStyle = lipgloss.NewStyle(). 17 | Padding(1, 0, 1, 2) 18 | 19 | inputUserStyle = lipgloss.NewStyle(). 20 | Padding(1, 0, 0, 2) 21 | 22 | inputSpinnerStyle = lipgloss.NewStyle(). 23 | Padding(2, 0, 0, 2) 24 | 25 | inputErrorStyle = lipgloss.NewStyle(). 26 | Padding(2, 0, 0, 2). 27 | Foreground(lipgloss.Color("161")) 28 | ) 29 | 30 | type userSelectModel struct { 31 | client *gh.GitHubClient 32 | 33 | keys userSelectKeyMap 34 | input textinput.Model 35 | help help.Model 36 | spinner *spinner.Model 37 | 38 | errorMsg *userSelectErrorMsg 39 | loading bool 40 | width, height int 41 | } 42 | 43 | type userSelectKeyMap struct { 44 | Enter key.Binding 45 | Quit key.Binding 46 | } 47 | 48 | func (k userSelectKeyMap) ShortHelp() []key.Binding { 49 | return []key.Binding{ 50 | k.Enter, 51 | k.Quit, 52 | } 53 | } 54 | 55 | func (k userSelectKeyMap) FullHelp() [][]key.Binding { 56 | return [][]key.Binding{ 57 | { 58 | k.Enter, 59 | }, 60 | { 61 | k.Quit, 62 | }, 63 | } 64 | } 65 | 66 | func newUserSelectModel(client *gh.GitHubClient, s *spinner.Model) userSelectModel { 67 | userSelectKeys := userSelectKeyMap{ 68 | Enter: key.NewBinding( 69 | key.WithKeys("enter"), 70 | key.WithHelp("enter", "confirm"), 71 | ), 72 | Quit: key.NewBinding( 73 | key.WithKeys("ctrl+c", "esc"), 74 | key.WithHelp("ctrl+c", "quit"), 75 | ), 76 | } 77 | 78 | inputModel := textinput.New() 79 | inputModel.Placeholder = "GitHub ID" 80 | inputModel.Focus() 81 | 82 | return userSelectModel{ 83 | client: client, 84 | keys: userSelectKeys, 85 | input: inputModel, 86 | help: help.New(), 87 | spinner: s, 88 | } 89 | } 90 | 91 | func (m *userSelectModel) SetSize(width, height int) { 92 | m.width = width 93 | m.height = height 94 | m.help.Width = width 95 | } 96 | 97 | func (m *userSelectModel) Reset() { 98 | m.input.Reset() 99 | m.input.Focus() 100 | } 101 | 102 | func (m userSelectModel) Init() tea.Cmd { 103 | return nil 104 | } 105 | 106 | type userSelectSuccessMsg struct { 107 | id string 108 | } 109 | 110 | var _ tea.Msg = (*userSelectSuccessMsg)(nil) 111 | 112 | type userSelectErrorMsg struct { 113 | e error 114 | summary string 115 | } 116 | 117 | var _ tea.Msg = (*userSelectErrorMsg)(nil) 118 | 119 | func (m userSelectModel) checkUser() tea.Cmd { 120 | id := strings.TrimSpace(m.input.Value()) 121 | if id == "" { 122 | return nil 123 | } 124 | return func() tea.Msg { 125 | if m.client.ExistUser(id) { 126 | return userSelectSuccessMsg{id} 127 | } 128 | return userSelectErrorMsg{nil, "user not found"} 129 | } 130 | } 131 | 132 | func (m userSelectModel) Update(msg tea.Msg) (userSelectModel, tea.Cmd) { 133 | cmds := make([]tea.Cmd, 0) 134 | switch msg := msg.(type) { 135 | case tea.KeyMsg: 136 | switch { 137 | case key.Matches(msg, m.keys.Enter): 138 | cmd := m.checkUser() 139 | if cmd == nil { 140 | return m, nil 141 | } 142 | m.input.Blur() 143 | m.errorMsg = nil 144 | m.loading = true 145 | return m, cmd 146 | case key.Matches(msg, m.keys.Quit): 147 | return m, tea.Quit 148 | default: 149 | m.errorMsg = nil 150 | } 151 | case goBackUserSelectPageMsg: 152 | m.Reset() 153 | return m, nil 154 | case userSelectSuccessMsg: 155 | m.errorMsg = nil 156 | m.loading = false 157 | return m, userSelected(msg.id) 158 | case userSelectErrorMsg: 159 | m.errorMsg = &msg 160 | m.loading = false 161 | m.input.Focus() 162 | return m, nil 163 | } 164 | 165 | input, iCmd := m.input.Update(msg) 166 | m.input = input 167 | cmds = append(cmds, iCmd) 168 | 169 | return m, tea.Batch(cmds...) 170 | } 171 | 172 | func (m userSelectModel) View() string { 173 | if m.height <= 0 { 174 | return "" 175 | } 176 | 177 | ret := "" 178 | height := m.height - 1 179 | 180 | title := titleView(nil) 181 | ret += title 182 | height -= cn(title) 183 | 184 | label := inputLabelStyle.Render("Enter GitHub User ID") 185 | ret += label 186 | height -= cn(label) 187 | 188 | input := inputUserStyle.Render(m.input.View()) 189 | ret += input 190 | height -= cn(input) 191 | 192 | if m.loading { 193 | sp := inputSpinnerStyle.Render(m.spinner.View() + " Loading...") 194 | ret += sp 195 | height -= cn(sp) 196 | } 197 | 198 | if m.errorMsg != nil { 199 | errorText := inputErrorStyle.Render("ERROR: " + m.errorMsg.summary) 200 | ret += errorText 201 | height -= cn(errorText) 202 | } 203 | 204 | help := helpStyle.Render(m.help.View(m.keys)) 205 | height -= cn(help) 206 | 207 | ret += strings.Repeat("\n", height) 208 | ret += help 209 | 210 | return ret 211 | } 212 | -------------------------------------------------------------------------------- /internal/ui/util.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | "os/exec" 7 | "runtime" 8 | "strings" 9 | "time" 10 | 11 | "github.com/lusingander/ghcv-cli/internal/ghcv" 12 | "github.com/simonhege/timeago" 13 | ) 14 | 15 | func openBrowser(url string) error { 16 | if runtime.GOOS != "darwin" { 17 | return errors.New("unsupported os :(") 18 | } 19 | cmd := exec.Command("open", url) 20 | return cmd.Start() 21 | } 22 | 23 | func formatDuration(t time.Time) string { 24 | if t.IsZero() { 25 | return "" 26 | } 27 | now := time.Now() 28 | return timeago.English.FormatRelativeDuration(now.Sub(t)) 29 | } 30 | 31 | func isOrganizationLogin(s string) bool { 32 | return strings.HasPrefix(s, "@") 33 | } 34 | 35 | func organigzationUrlFrom(s string) string { 36 | login := strings.TrimSpace(strings.TrimLeft(s, "@")) 37 | return ghcv.GitHubBaseUrl + login 38 | } 39 | 40 | func isUrl(s string) bool { 41 | _, err := url.ParseRequestURI(s) 42 | return err == nil 43 | } 44 | --------------------------------------------------------------------------------