├── .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 | 
2 | 
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 |
--------------------------------------------------------------------------------