├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE │ ├── BUG_REPORT.md │ ├── FEATURE_REQUEST.md │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── api └── github │ ├── github.go │ ├── mutations │ └── mutations.go │ └── queries │ └── queries.go ├── go.mod ├── go.sum ├── main.go ├── ui ├── commands │ └── commands.go ├── components │ ├── graph │ │ └── graph.go │ ├── help │ │ └── help.go │ ├── markdown │ │ └── markdown.go │ ├── message │ │ └── message.go │ ├── repo │ │ └── repo.go │ ├── search │ │ └── search.go │ ├── spinner │ │ └── spinner.go │ └── user │ │ └── user.go ├── context │ └── context.go ├── models │ └── models.go ├── styles │ └── styles.go └── ui.go └── utils ├── keys.go ├── utils.go └── utils_test.go /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | 4 | 5 | 6 | - [Our Pledge](#our-pledge) 7 | - [Our Standards](#our-standards) 8 | - [Our Responsibilities](#our-responsibilities) 9 | - [Scope](#scope) 10 | - [Enforcement](#enforcement) 11 | - [Attributions](#attributions) 12 | 13 | 14 | 15 | ## Our Pledge 16 | 17 | In the interest of fostering an open and welcoming environment, we as 18 | contributors and maintainers pledge to making participation in our project and 19 | our community a harassment-free experience for everyone, regardless of age, body 20 | size, disability, ethnicity, gender identity and expression, level of 21 | experience, nationality, personal appearance, race, religion, or sexual identity 22 | and orientation. 23 | 24 | ## Our Standards 25 | 26 | Examples of behavior that contributes to creating a positive environment 27 | include: 28 | 29 | - Using welcoming and inclusive language 30 | - Being respectful of differing viewpoints and experiences 31 | - Gracefully accepting constructive criticism 32 | - Focusing on what is best for the community 33 | - Showing empathy towards other community members 34 | 35 | Examples of unacceptable behavior by participants include: 36 | 37 | - The use of sexualized language or imagery and unwelcome sexual attention or 38 | advances 39 | - Trolling, insulting/derogatory comments, and personal or political attacks 40 | - Public or private harassment 41 | - Publishing others' private information, such as a physical or electronic 42 | address, without explicit permission 43 | - Other conduct which could reasonably be considered inappropriate in a 44 | professional setting 45 | 46 | ## Our Responsibilities 47 | 48 | Project maintainers are responsible for clarifying the standards of acceptable 49 | behavior and are expected to take appropriate and fair corrective action in 50 | response to any instances of unacceptable behavior. 51 | 52 | Project maintainers have the right and responsibility to remove, edit, or reject 53 | comments, commits, code, wiki edits, issues, and other contributions that are 54 | not aligned to this Code of Conduct, or to ban temporarily or permanently any 55 | contributor for other behaviors that they deem inappropriate, threatening, 56 | offensive, or harmful. 57 | 58 | ## Scope 59 | 60 | This Code of Conduct applies both within project spaces and in public spaces 61 | when an individual is representing the project or its community. Examples of 62 | representing a project or community include using an official project e-mail 63 | address, posting via an official social media account, or acting as an appointed 64 | representative at an online or offline event. Representation of a project may be 65 | further defined and clarified by project maintainers. 66 | 67 | ## Enforcement 68 | 69 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 70 | reported by contacting me directly at colby@jrnxf.co. All complaints 71 | will be reviewed and investigated and will result in a response that is deemed 72 | necessary and appropriate to the circumstances. The project team is obligated 73 | to maintain confidentiality with regard to the reporter of an incident. Further 74 | details of specific enforcement policies may be posted separately. 75 | 76 | Project maintainers who do not follow or enforce the Code of Conduct in good 77 | faith may face temporary or permanent repercussions as determined by other 78 | members of the project's leadership. 79 | 80 | ## Attributions 81 | 82 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 83 | version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 84 | 85 | [homepage]: http://contributor-covenant.org 86 | [version]: http://contributor-covenant.org/version/1/4/ 87 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create an issue about a bug you encountered 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | 12 | 13 | ## Description 14 | 15 | 18 | 19 | ## To Reproduce 20 | 21 | 25 | 26 | ## Expected behavior 27 | 28 | 31 | 32 | ## Screenshots 33 | 34 | 37 | 38 | ## Environment 39 | 40 | 43 | 44 | - OS: 45 | - Terminal Emulator: 46 | - Font: 47 | - `gh-eco` version: 48 | 49 | ## Additional context 50 | 51 | 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | ## Problem 10 | 11 | 14 | 15 | ## Solution 16 | 17 | 23 | 24 | ## Alternatives 25 | 26 | 29 | 30 | ## Additional context 31 | 32 | 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | **What**: 11 | 12 | 13 | 14 | **Why**: 15 | 16 | 17 | 18 | **How**: 19 | 20 | 21 | 22 | **Checklist**: 23 | 24 | 25 | 26 | 27 | 28 | - [ ] `Allow edits from maintainers` option checked 29 | - [ ] Branch name is prefixed with `[your_username]/` (ex. `jrnxf/feature_xyz`) 30 | - [ ] Documentation added 31 | - [ ] Tests added 32 | - [ ] No failing actions 33 | - [ ] Merge ready 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: setup 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 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: cli/gh-extension-precompile@v1.1.2 17 | with: 18 | go_version: "1.21" 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | gh-eco 3 | dist 4 | coverage.out 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) Copyright (c) 2022 Colby Thomas 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gh-eco 2 | 3 | 🦎 gh extension to explore the ecosystem 4 | 5 | [![GitHub Go 6 | Workflow](https://github.com/jrnxf/gh-eco/actions/workflows/ci.yml/badge.svg)](https://github.com/jrnxf/gh-eco/actions/workflows/ci.yml) 7 | [![License](https://img.shields.io/badge/License-MIT-default.svg)](./LICENSE.md) [![Github 8 | Stars](https://img.shields.io/github/stars/jrnxf/gh-eco)](https://github.com/jrnxf/gh-eco/stargazers) 9 | 10 | ![demo](https://github.com/jrnxf/assets/raw/main/gh-eco/demo.gif) 11 | 12 | ## Installation 13 | 14 | 1. Install the `gh` cli - see the [installation](https://github.com/cli/cli#installation) 15 | 16 | _Installation requires a minimum version (2.0.0) of the the GitHub CLI that supports extensions._ 17 | 18 | 2. Install this extension: 19 | 20 | ```sh 21 | gh extension install jrnxf/gh-eco 22 | ``` 23 | 24 |
25 | Manual Installation 26 | 27 | > If you want to install this extension manually, follow these steps: 28 | 29 | 1. clone the repo 30 | 31 | ```sh 32 | # git 33 | git clone https://github.com/jrnxf/gh-eco 34 | 35 | # GitHub CLI 36 | gh repo clone jrnxf/gh-eco 37 | ``` 38 | 39 | 2. `cd` into it 40 | 41 | ```sh 42 | cd gh-eco 43 | ``` 44 | 45 | 3. add dependencies and build it 46 | 47 | ```sh 48 | go get && go build 49 | ``` 50 | 51 | 4. install it locally 52 | ```sh 53 | gh extension install . 54 | ``` 55 |
56 | 57 | ## Usage 58 | 59 | to run: 60 | 61 | ```sh 62 | gh eco 63 | ``` 64 | 65 | to upgrade: 66 | 67 | ```sh 68 | gh extension upgrade eco 69 | ``` 70 | 71 | ## Roadmap 72 | 73 | **🎨 Custom Configurations** 74 | 75 | Allowing users to customize the colors of all displayed elements is definitely a priority. Right now 76 | the colors used should be adaptive between standard light/dark themed terminal profiles, however 77 | it's entirely possible for the colors to still clash! Beyond colors, having the ability to select 78 | what elements are displayed, their ordering, custom keymaps, etc. would be awesome! 79 | 80 | **⚡️ Interactions** 81 | 82 | I'm intentionally releasing `gh-eco` while I still have ideas bouncing around in my head for what 83 | could come next. There are some immediate limitations that I need to sort out. One of which is 84 | dealing with gh permissions. Right now all permissions are inherited from the `gh` cli, which only 85 | requests a small subset of available scopes. One interaction I have built out is to follow/unfollow 86 | users (see `feature/follow-users` branch), but the interaction is blocked due to missing scopes. I 87 | haven't found any documentation or examples showcasing what extending permissions for a `gh` 88 | extension might look like, so I may need to contact support. I'd rather not make it a full blown 89 | standalone app that requires it's own auth, as I think that might complicate things? If you have 90 | opinions please don't hesitate to reach out! 91 | 92 | Other interactions I think would be nice include: 93 | 94 | - searching for users and seeing results (i.e. perfect match not needed) 95 | - searching for repos as well (possibly using `u/{username}` and `r/{repo}` to differentiate 96 | searches) 97 | - "see more" option to view more than just pinned repos 98 | - viewing and/or filtering through a list of the followed/following users to jump to their profile 99 | - repo contributors and related stats 100 | - creative ways to show more user info while still keeping things clean (e.g. company, isHireable, 101 | status, etc). These are easy to display but I'm intentionally keeping things lightweight for now 102 | - trending users, repos, etc. 103 | 104 | **🧪 Tests** 105 | 106 | To date only a small amount of tests have been written. I feel comfortable writing tests for smaller 107 | utility functions, but I'd love to do more integration type testing on the underlying [Bubble 108 | Tea](https://github.com/charmbracelet/bubbletea) implementation. If you have any experience with 109 | this please feel free to open an issue / pull request and start the discussion! 110 | 111 | ## Limitations 112 | 113 | - Misalignment can occur when emojis are present. Since emoji widths vary, I don't believe there is 114 | any way to deal with this. Definitely reach out if you have any ideas. 115 | 116 | - Smaller terminal screens will almost definitely cause rendering issues. For `gh-eco` to work 117 | properly make sure to give your terminal as much screen real estate as possible! 118 | 119 | ## Contributing 120 | 121 | All contributions are greatly appreciated! 122 | 123 | If you have a suggestion that would make `gh-eco` better, please fork the repo and create a [pull 124 | request](https://github.com/jrnxf/gh-eco/pulls) or open an issue. 125 | 126 | See the [open issues](https://github.com/jrnxf/gh-eco/issues) for a full list of proposed 127 | features (and known bugs). 128 | 129 | ## License 130 | 131 | Distributed under the MIT License. See [LICENSE.md](./LICENSE.md) for more information. 132 | 133 | ## Acknowledgments 134 | 135 | Check out these amazing projects that inspired `gh-eco`! 136 | 137 | - anything and everything by [charm.sh](https://charm.sh/) 138 | - [gh-dash](https://github.com/dlvhdr/gh-dash) 139 | 140 | ## Follow 141 | 142 | [![github](https://img.shields.io/github/followers/jrnxf?style=social)](https://github.com/jrnxf) 143 | [![twitter](https://img.shields.io/twitter/follow/_jrnxf?color=white&style=social)](https://twitter.com/_jrnxf) 144 | [![youtube](https://img.shields.io/youtube/channel/subscribers/UCEDfokz6igeN4bX7Whq49-g?style=social)](https://www.youtube.com/@jrnxf) 145 | -------------------------------------------------------------------------------- /api/github/github.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "sync" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | gh "github.com/cli/go-gh/v2" 12 | "github.com/jrnxf/gh-eco/api/github/mutations" 13 | "github.com/jrnxf/gh-eco/api/github/queries" 14 | "github.com/jrnxf/gh-eco/ui/commands" 15 | "github.com/jrnxf/gh-eco/utils" 16 | ghv4 "github.com/shurcooL/githubv4" 17 | "golang.org/x/oauth2" 18 | ) 19 | 20 | const GH_ECO_REPO_ID string = "R_kgDOHVAImQ" 21 | 22 | var ( 23 | clientInstance *ghv4.Client 24 | once sync.Once 25 | ) 26 | 27 | // GetClient initializes a GitHub GraphQL client instance with a token obtained from GitHub CLI. 28 | func GetClient() *ghv4.Client { 29 | once.Do(func() { 30 | output, _, err := gh.Exec("auth", "token") 31 | if err != nil { 32 | fmt.Println("Unable to retrieve access token") 33 | } 34 | 35 | token := strings.TrimSpace(output.String()) 36 | src := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) 37 | httpClient := oauth2.NewClient(context.Background(), src) 38 | clientInstance = ghv4.NewClient(httpClient) 39 | }) 40 | 41 | return clientInstance 42 | } 43 | 44 | func GetUser(login string) tea.Cmd { 45 | return func() tea.Msg { 46 | client := GetClient() 47 | 48 | var query queries.GetUserQuery 49 | 50 | variables := map[string]interface{}{ 51 | "login": ghv4.String(login), 52 | "first": ghv4.Int(6), 53 | } 54 | 55 | err := client.Query(context.Background(), &query, variables) 56 | if err != nil { 57 | fmt.Println(err.Error()) 58 | return commands.GetUserResponse{Err: err} 59 | } 60 | 61 | return commands.GetUserResponse{User: utils.MapGetUserQueryToDisplayUser(query)} 62 | } 63 | } 64 | 65 | func GetReadme(name string, owner string) tea.Cmd { 66 | return func() tea.Msg { 67 | client := GetClient() 68 | 69 | var query queries.GetReadmeQuery 70 | 71 | variables := map[string]interface{}{ 72 | "name": ghv4.String(name), 73 | "owner": ghv4.String(owner), 74 | "expression": ghv4.String("HEAD:README.md"), 75 | } 76 | 77 | err := client.Query(context.Background(), &query, variables) 78 | if err != nil { 79 | log.Println(err) 80 | return commands.GetReadmeResponse{Err: err} 81 | } 82 | 83 | return commands.GetReadmeResponse{Readme: query.Repository.Object.Blob} 84 | } 85 | } 86 | 87 | func StarStarrable(starrableId string) tea.Cmd { 88 | return func() tea.Msg { 89 | client := GetClient() 90 | 91 | var mutation mutations.AddStarMutation 92 | 93 | input := ghv4.AddStarInput{ 94 | StarrableID: ghv4.ID(starrableId), 95 | } 96 | 97 | err := client.Mutate(context.Background(), &mutation, input, nil) 98 | if err != nil { 99 | log.Println(err) 100 | return commands.StarStarrableResponse{Err: err} 101 | } 102 | 103 | return commands.StarStarrableResponse{Starrable: mutation.AddStar.Starrable} 104 | } 105 | } 106 | 107 | func RemoveStarStarrable(starrableId string) tea.Cmd { 108 | return func() tea.Msg { 109 | client := GetClient() 110 | 111 | var mutation mutations.RemoveStarMutation 112 | 113 | input := ghv4.RemoveStarInput{ 114 | StarrableID: ghv4.ID(starrableId), 115 | } 116 | 117 | err := client.Mutate(context.Background(), &mutation, input, nil) 118 | if err != nil { 119 | log.Println(err) 120 | return commands.RemoveStarStarrableResponse{Err: err} 121 | } 122 | 123 | return commands.RemoveStarStarrableResponse{Starrable: mutation.RemoveStar.Starrable} 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /api/github/mutations/mutations.go: -------------------------------------------------------------------------------- 1 | package mutations 2 | 3 | type AddStarMutation struct { 4 | AddStar struct { 5 | Starrable struct { 6 | Id string 7 | StargazerCount int 8 | ViewerHasStarred bool 9 | } 10 | } `graphql:"addStar(input: $input)"` 11 | } 12 | 13 | type RemoveStarMutation struct { 14 | RemoveStar struct { 15 | Starrable struct { 16 | Id string 17 | StargazerCount int 18 | ViewerHasStarred bool 19 | } 20 | } `graphql:"removeStar(input: $input)"` 21 | } 22 | -------------------------------------------------------------------------------- /api/github/queries/queries.go: -------------------------------------------------------------------------------- 1 | package queries 2 | 3 | type GetUserQuery struct { 4 | User struct { 5 | Id string 6 | Login string 7 | Name string 8 | Location string 9 | Url string 10 | Bio string 11 | TwitterUsername string 12 | IsViewer bool 13 | IsFollowingViewer bool 14 | ViewerIsFollowing bool 15 | WebsiteUrl string 16 | Followers struct { 17 | TotalCount int 18 | } 19 | Following struct { 20 | TotalCount int 21 | } 22 | PinnedItems struct { 23 | Nodes []struct { 24 | Repository struct { 25 | Id string 26 | Name string 27 | Description string 28 | StargazerCount int 29 | ViewerHasStarred bool 30 | Url string 31 | Owner struct { 32 | Login string 33 | } 34 | PrimaryLanguage struct { 35 | Name string 36 | Color string 37 | } 38 | } `graphql:"... on Repository"` 39 | } 40 | } `graphql:"pinnedItems(first: $first)"` 41 | ContributionsCollection struct { 42 | ContributionCalendar struct { 43 | TotalContributions int 44 | Weeks []struct { 45 | ContributionDays []struct { 46 | ContributionLevel string 47 | } 48 | } 49 | } 50 | } 51 | } `graphql:"user(login: $login)"` 52 | } 53 | 54 | type GetReadmeQuery struct { 55 | Repository struct { 56 | Object struct { 57 | Blob struct { 58 | Text string 59 | } `graphql:"... on Blob"` 60 | } `graphql:"object(expression: $expression)"` 61 | } `graphql:"repository(name: $name, owner: $owner)"` 62 | } 63 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jrnxf/gh-eco 2 | 3 | go 1.21 4 | 5 | toolchain go1.22.2 6 | 7 | require ( 8 | github.com/charmbracelet/bubbles v0.19.0 9 | github.com/charmbracelet/bubbletea v0.27.0 10 | github.com/charmbracelet/glamour v0.7.0 11 | github.com/charmbracelet/lipgloss v0.12.1 12 | github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 13 | golang.org/x/oauth2 v0.22.0 14 | golang.org/x/term v0.13.0 15 | ) 16 | 17 | require ( 18 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect 19 | github.com/alecthomas/chroma v0.10.0 // indirect 20 | github.com/alecthomas/chroma/v2 v2.8.0 // indirect 21 | github.com/atotto/clipboard v0.1.4 // indirect 22 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 23 | github.com/aymerick/douceur v0.2.0 // indirect 24 | github.com/charmbracelet/x/ansi v0.1.4 // indirect 25 | github.com/charmbracelet/x/input v0.1.0 // indirect 26 | github.com/charmbracelet/x/term v0.1.1 // indirect 27 | github.com/charmbracelet/x/windows v0.1.0 // indirect 28 | github.com/cli/go-gh v1.2.1 // indirect 29 | github.com/cli/go-gh/v2 v2.9.0 30 | github.com/cli/safeexec v1.0.0 // indirect 31 | github.com/cli/shurcooL-graphql v0.0.4 // indirect 32 | github.com/containerd/console v1.0.3 // indirect 33 | github.com/dlclark/regexp2 v1.4.0 // indirect 34 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 35 | github.com/gorilla/css v1.0.0 // indirect 36 | github.com/henvic/httpretty v0.0.6 // indirect 37 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 38 | github.com/mattn/go-isatty v0.0.20 // indirect 39 | github.com/mattn/go-localereader v0.0.1 // indirect 40 | github.com/mattn/go-runewidth v0.0.16 // indirect 41 | github.com/microcosm-cc/bluemonday v1.0.26 // indirect 42 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 43 | github.com/muesli/cancelreader v0.2.2 // indirect 44 | github.com/muesli/reflow v0.3.0 // indirect 45 | github.com/muesli/termenv v0.15.2 // indirect 46 | github.com/olekukonko/tablewriter v0.0.5 // indirect 47 | github.com/rivo/uniseg v0.4.7 // indirect 48 | github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect 49 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 50 | github.com/yuin/goldmark v1.5.4 // indirect 51 | github.com/yuin/goldmark-emoji v1.0.2 // indirect 52 | golang.org/x/net v0.17.0 // indirect 53 | golang.org/x/sync v0.8.0 // indirect 54 | golang.org/x/sys v0.24.0 // indirect 55 | golang.org/x/text v0.13.0 // indirect 56 | gopkg.in/yaml.v3 v3.0.1 // indirect 57 | ) 58 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 2 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= 3 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= 4 | github.com/alecthomas/chroma/v2 v2.8.0 h1:w9WJUjFFmHHB2e8mRpL9jjy3alYDlU0QLDezj1xE264= 5 | github.com/alecthomas/chroma/v2 v2.8.0/go.mod h1:yrkMI9807G1ROx13fhe1v6PN2DDeaR73L3d+1nmYQtw= 6 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 7 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 9 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 10 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 11 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 12 | github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho= 13 | github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA= 14 | github.com/charmbracelet/bubbles v0.19.0 h1:gKZkKXPP6GlDk6EcfujDK19PCQqRjaJZQ7QRERx1UF0= 15 | github.com/charmbracelet/bubbles v0.19.0/go.mod h1:WILteEqZ+krG5c3ntGEMeG99nCupcuIk7V0/zOP0tOA= 16 | github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= 17 | github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc= 18 | github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= 19 | github.com/charmbracelet/bubbletea v0.27.0 h1:Mznj+vvYuYagD9Pn2mY7fuelGvP0HAXtZYGgRBCbHvU= 20 | github.com/charmbracelet/bubbletea v0.27.0/go.mod h1:5MdP9XH6MbQkgGhnlxUqCNmBXf9I74KRQ8HIidRxV1Y= 21 | github.com/charmbracelet/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g= 22 | github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc= 23 | github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da h1:FGz53GWQRiKQ/5xUsoCCkewSQIC7u81Scaxx2nUy3nM= 24 | github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da/go.mod h1:HXz79SMFnF9arKxqeoHWxmo1BhplAH7wehlRhKQIL94= 25 | github.com/charmbracelet/glamour v0.7.0 h1:2BtKGZ4iVJCDfMF229EzbeR1QRKLWztO9dMtjmqZSng= 26 | github.com/charmbracelet/glamour v0.7.0/go.mod h1:jUMh5MeihljJPQbJ/wf4ldw2+yBP59+ctV36jASy7ps= 27 | github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 28 | github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= 29 | github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= 30 | github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= 31 | github.com/charmbracelet/lipgloss v0.10.1-0.20240413172830-d0be07ea6b9c/go.mod h1:EPP2QJ0ectp3zo6gx9f8oJGq8keirqPJ3XpYEI8wrrs= 32 | github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs= 33 | github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8= 34 | github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= 35 | github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= 36 | github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ= 37 | github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28= 38 | github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= 39 | github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= 40 | github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= 41 | github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= 42 | github.com/cli/go-gh v0.0.3 h1:GcVgUa7q0SeauIRbch3VSUXVij6+c49jtAHv7WuWj5c= 43 | github.com/cli/go-gh v0.0.3/go.mod h1:J1eNgrPJYAUy7TwPKj7GW1ibqI+WCiMndtyzrCyZIiQ= 44 | github.com/cli/go-gh v1.2.1 h1:xFrjejSsgPiwXFP6VYynKWwxLQcNJy3Twbu82ZDlR/o= 45 | github.com/cli/go-gh v1.2.1/go.mod h1:Jxk8X+TCO4Ui/GarwY9tByWm/8zp4jJktzVZNlTW5VM= 46 | github.com/cli/go-gh/v2 v2.9.0 h1:D3lTjEneMYl54M+WjZ+kRPrR5CEJ5BHS05isBPOV3LI= 47 | github.com/cli/go-gh/v2 v2.9.0/go.mod h1:MeRoKzXff3ygHu7zP+NVTT+imcHW6p3tpuxHAzRM2xE= 48 | github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= 49 | github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= 50 | github.com/cli/shurcooL-graphql v0.0.1 h1:/9J3t9O6p1B8zdBBtQighq5g7DQRItBwuwGh3SocsKM= 51 | github.com/cli/shurcooL-graphql v0.0.1/go.mod h1:U7gCSuMZP/Qy7kbqkk5PrqXEeDgtfG5K+W+u8weorps= 52 | github.com/cli/shurcooL-graphql v0.0.2 h1:rwP5/qQQ2fM0TzkUTwtt6E2LbIYf6R+39cUXTa04NYk= 53 | github.com/cli/shurcooL-graphql v0.0.2/go.mod h1:tlrLmw/n5Q/+4qSvosT+9/W5zc8ZMjnJeYBxSdb4nWA= 54 | github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY= 55 | github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= 56 | github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= 57 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 58 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 59 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 60 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 61 | github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= 62 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 63 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 64 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 65 | github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= 66 | github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= 67 | github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= 68 | github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo= 69 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 70 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 71 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 72 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 73 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 74 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 75 | github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 76 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 77 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 78 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 79 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 80 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 81 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 82 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 83 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 84 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 85 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 86 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 87 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 88 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 89 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 90 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 91 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 92 | github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= 93 | github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE= 94 | github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg= 95 | github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= 96 | github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= 97 | github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= 98 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 99 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 100 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 101 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 102 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 103 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 104 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 105 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 106 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 107 | github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= 108 | github.com/muesli/termenv v0.11.0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 109 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 110 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= 111 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 112 | github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc= 113 | github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= 114 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 115 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 116 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 117 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 118 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 119 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 120 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 121 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 122 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 123 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 124 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 125 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 126 | github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M= 127 | github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= 128 | github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a h1:KikTa6HtAK8cS1qjvUvvq4QO21QnwC+EfvB+OAuZ/ZU= 129 | github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= 130 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= 131 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= 132 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 133 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 134 | github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= 135 | github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= 136 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 137 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 138 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 139 | github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 140 | github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs= 141 | github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg= 142 | github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= 143 | github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 144 | github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= 145 | github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= 146 | github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s= 147 | github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY= 148 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 149 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 150 | golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= 151 | golang.org/x/net v0.0.0-20221002022538-bcab6841153b h1:6e93nYa3hNqAvLr0pD4PN1fFS+gKzp2zAXqrnTCstqU= 152 | golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= 153 | golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= 154 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 155 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= 156 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 157 | golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= 158 | golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 159 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 160 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 161 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 162 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 163 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 164 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 165 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 166 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 167 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 168 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 169 | golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 170 | golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 171 | golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg= 172 | golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 173 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 174 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= 175 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 176 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 177 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 178 | golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= 179 | golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 180 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 181 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 182 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= 183 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 184 | golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= 185 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 186 | golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= 187 | golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= 188 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 189 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 190 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 191 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 192 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 193 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 194 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 195 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 196 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 197 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 198 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 199 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 200 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/jrnxf/gh-eco/ui" 9 | ) 10 | 11 | func main() { 12 | if len(os.Getenv("DEBUG")) > 0 { 13 | f, _ := tea.LogToFile("debug.log", "") 14 | defer f.Close() 15 | } 16 | 17 | p := tea.NewProgram(ui.New(), tea.WithAltScreen(), tea.WithMouseAllMotion()) 18 | 19 | if _, err := p.Run(); err != nil { 20 | log.Fatal(err) 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /ui/commands/commands.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import "github.com/jrnxf/gh-eco/ui/models" 4 | 5 | type FocusChange struct{} 6 | 7 | type LayoutChange struct{} 8 | 9 | type SetMessage struct { 10 | Content string 11 | SecondsDisplayed int 12 | } 13 | 14 | type ProgramInitMsg struct { 15 | Ready bool 16 | } 17 | 18 | type GetUserResponse struct { 19 | Err error 20 | User models.User 21 | } 22 | 23 | type GetReadmeResponse struct { 24 | Err error 25 | Readme models.Blob 26 | } 27 | 28 | type StarStarrableResponse struct { 29 | Err error 30 | Starrable models.Starrable 31 | } 32 | 33 | type RemoveStarStarrableResponse struct { 34 | Err error 35 | Starrable models.Starrable 36 | } 37 | -------------------------------------------------------------------------------- /ui/components/graph/graph.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/jrnxf/gh-eco/ui/models" 8 | "github.com/jrnxf/gh-eco/utils" 9 | ) 10 | 11 | var ( 12 | GH_GRAPH_CELL = "■" 13 | GH_GRAPH_CELL_NONE = lipgloss.NewStyle().PaddingRight(1).Foreground(lipgloss.AdaptiveColor{Light: "#EBEDF0", Dark: "#2D333B"}).Render(GH_GRAPH_CELL) 14 | GH_GRAPH_CELL_FIRST_QUARTILE = lipgloss.NewStyle().PaddingRight(1).Foreground(lipgloss.AdaptiveColor{Light: "#9BE9A8", Dark: "#0E4429"}).Render(GH_GRAPH_CELL) 15 | GH_GRAPH_CELL_SECOND_QUARTILE = lipgloss.NewStyle().PaddingRight(1).Foreground(lipgloss.AdaptiveColor{Light: "#40C463", Dark: "#006D32"}).Render(GH_GRAPH_CELL) 16 | GH_GRAPH_CELL_THIRD_QUARTILE = lipgloss.NewStyle().PaddingRight(1).Foreground(lipgloss.AdaptiveColor{Light: "#30A14E", Dark: "#26A641"}).Render(GH_GRAPH_CELL) 17 | GH_GRAPH_CELL_FOURTH_QUARTILE = lipgloss.NewStyle().PaddingRight(1).Foreground(lipgloss.AdaptiveColor{Light: "#216E39", Dark: "#39D353"}).Render(GH_GRAPH_CELL) 18 | ) 19 | 20 | func BuildGraphDisplay(weeklyContributions []models.WeeklyContribution) string { 21 | // prep the finished matrix 22 | 23 | result := make([][]string, len(weeklyContributions)) 24 | for i := range result { 25 | result[i] = make([]string, len(weeklyContributions[0].ContributionDays)) 26 | } 27 | 28 | for i, weeklyContribution := range weeklyContributions { 29 | for j, contributionDay := range weeklyContribution.ContributionDays { 30 | result[i][j] = contributionDay.ContributionLevel 31 | } 32 | } 33 | 34 | result = transposeSlice(result) 35 | 36 | foo := generateContributionGraph(result) 37 | 38 | return foo 39 | } 40 | 41 | func transposeSlice(slice [][]string) [][]string { 42 | xLen := len(slice[0]) 43 | yLen := len(slice) 44 | 45 | // prep the finished matrix 46 | result := make([][]string, xLen) // num empty rows to create (outer slice) 47 | for i := range result { 48 | result[i] = make([]string, yLen) // num empty columns to create in each row (inner slice) 49 | } 50 | 51 | for i := 0; i < xLen; i++ { 52 | for j := 0; j < yLen; j++ { 53 | result[i][j] = slice[j][i] 54 | } 55 | } 56 | return result 57 | } 58 | 59 | func generateContributionGraph(slice [][]string) string { 60 | var b strings.Builder 61 | w := b.WriteString 62 | 63 | for _, row := range slice { 64 | for _, cell := range row { 65 | switch cell { 66 | case "NONE": 67 | w(GH_GRAPH_CELL_NONE) 68 | case "FIRST_QUARTILE": 69 | w(GH_GRAPH_CELL_FIRST_QUARTILE) 70 | case "SECOND_QUARTILE": 71 | w(GH_GRAPH_CELL_SECOND_QUARTILE) 72 | case "THIRD_QUARTILE": 73 | w(GH_GRAPH_CELL_THIRD_QUARTILE) 74 | case "FOURTH_QUARTILE": 75 | w(GH_GRAPH_CELL_FOURTH_QUARTILE) 76 | } 77 | } 78 | 79 | w(utils.GetNewLines(1)) 80 | } 81 | 82 | return b.String() 83 | } 84 | -------------------------------------------------------------------------------- /ui/components/help/help.go: -------------------------------------------------------------------------------- 1 | package help 2 | 3 | import ( 4 | baseHelp "github.com/charmbracelet/bubbles/help" 5 | "github.com/charmbracelet/bubbles/key" 6 | tea "github.com/charmbracelet/bubbletea" 7 | 8 | "github.com/jrnxf/gh-eco/ui/context" 9 | "github.com/jrnxf/gh-eco/utils" 10 | ) 11 | 12 | type Model struct { 13 | keys utils.KeyMap 14 | help baseHelp.Model 15 | ctx *context.ProgramContext 16 | } 17 | 18 | func NewModel() Model { 19 | return Model{ 20 | keys: utils.Keys, 21 | help: baseHelp.NewModel(), 22 | } 23 | } 24 | 25 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 26 | switch msg := msg.(type) { 27 | case tea.WindowSizeMsg: 28 | m.help.Width = msg.Width 29 | } 30 | 31 | return m, nil 32 | } 33 | 34 | func (m Model) View() string { 35 | return m.help.ShortHelpView(m.collectHelpBindings()) 36 | } 37 | 38 | func (m Model) collectHelpBindings() []key.Binding { 39 | k := m.keys 40 | bindings := []key.Binding{} 41 | switch m.ctx.Mode { 42 | case context.InsertMode: 43 | bindings = append(bindings, k.Search) 44 | case context.NormalMode: 45 | if m.ctx.User.Login == "" { 46 | // user has not yet loaded 47 | bindings = append(bindings, k.Search) 48 | } else { 49 | if m.ctx.CurrentView == context.UserView { 50 | fw := m.ctx.CurrentFocus.FocusedWidget 51 | bindings = append(bindings, k.FocusInput, k.FocusNext, k.FocusPrev, k.ToggleReadme, k.OpenGithub) 52 | if fw.Type == context.RepoWidget { 53 | bindings = append(bindings, k.StarRepo) 54 | } 55 | } else if m.ctx.CurrentView == context.ReadmeView { 56 | bindings = append(bindings, k.FocusNext, k.FocusPrev, k.PreviewPageDown, k.PreviewPageUp, k.ToggleReadme, k.StarRepo, k.OpenGithub) 57 | } 58 | bindings = append(bindings, k.StarGhEco) 59 | } 60 | } 61 | bindings = append(bindings, k.Quit) 62 | 63 | return bindings 64 | 65 | } 66 | 67 | func (m *Model) UpdateProgramContext(ctx *context.ProgramContext) { 68 | if ctx == nil { 69 | return 70 | } 71 | m.ctx = ctx 72 | } 73 | -------------------------------------------------------------------------------- /ui/components/markdown/markdown.go: -------------------------------------------------------------------------------- 1 | package markdown 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/jrnxf/gh-eco/ui/commands" 8 | "github.com/jrnxf/gh-eco/ui/context" 9 | "github.com/jrnxf/gh-eco/ui/styles" 10 | "github.com/jrnxf/gh-eco/utils" 11 | 12 | "github.com/charmbracelet/bubbles/viewport" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/glamour" 15 | "github.com/charmbracelet/lipgloss" 16 | ) 17 | 18 | type Model struct { 19 | Viewport viewport.Model 20 | ctx *context.ProgramContext 21 | mdRenderer *glamour.TermRenderer 22 | } 23 | 24 | func NewModel() Model { 25 | 26 | markdownRenderer, _ := glamour.NewTermRenderer( 27 | glamour.WithAutoStyle(), 28 | glamour.WithWordWrap(100), 29 | ) 30 | 31 | return Model{ 32 | Viewport: viewport.Model{ 33 | Width: 0, 34 | Height: 0, 35 | }, 36 | mdRenderer: markdownRenderer, 37 | } 38 | } 39 | 40 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 41 | var ( 42 | cmd tea.Cmd 43 | cmds []tea.Cmd 44 | ) 45 | 46 | switch msg := msg.(type) { 47 | 48 | case tea.KeyMsg: 49 | if m.ctx.Mode == context.NormalMode { 50 | // only listen for keyboard events if in normal mode 51 | m.Viewport, cmd = m.Viewport.Update(msg) 52 | } 53 | 54 | case commands.GetReadmeResponse: 55 | out, _ := m.mdRenderer.Render(msg.Readme.Text) 56 | m.Viewport.SetYOffset(0) // scroll to top 57 | m.Viewport.SetContent(out) 58 | 59 | case commands.LayoutChange: 60 | m.calculateViewportDimensions() 61 | 62 | case tea.WindowSizeMsg: 63 | m.calculateViewportDimensions() 64 | 65 | default: 66 | // Handle other events (like mouse events) 67 | m.Viewport, cmd = m.Viewport.Update(msg) 68 | } 69 | 70 | cmds = append(cmds, cmd) 71 | return m, tea.Batch(cmds...) 72 | } 73 | 74 | func (m *Model) calculateViewportDimensions() { 75 | m.Viewport.Width = m.ctx.Layout.ContentWidth 76 | m.Viewport.Height = m.ctx.Layout.ContentHeight - lipgloss.Height(m.footerView()) 77 | } 78 | 79 | func (m Model) View() string { 80 | return fmt.Sprintf("%s%s%s", m.Viewport.View(), utils.GetNewLines(1), m.footerView()) 81 | } 82 | 83 | func (m Model) footerView() string { 84 | scrollPercentage := fmt.Sprintf(" %.f%%", m.Viewport.ScrollPercent()*100) 85 | line := strings.Repeat("─", utils.MaxInt(0, m.Viewport.Width-lipgloss.Width(scrollPercentage))) 86 | 87 | return styles.FaintBold.Render(lipgloss.JoinHorizontal(lipgloss.Center, line, scrollPercentage)) 88 | } 89 | 90 | func (m *Model) UpdateProgramContext(ctx *context.ProgramContext) { 91 | if ctx == nil { 92 | return 93 | } 94 | m.ctx = ctx 95 | 96 | } 97 | -------------------------------------------------------------------------------- /ui/components/message/message.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/bubbles/timer" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | 10 | "github.com/jrnxf/gh-eco/ui/commands" 11 | "github.com/jrnxf/gh-eco/ui/context" 12 | "github.com/jrnxf/gh-eco/utils" 13 | ) 14 | 15 | type Model struct { 16 | keys utils.KeyMap 17 | Content string 18 | timer timer.Model 19 | ctx *context.ProgramContext 20 | } 21 | 22 | func NewModel() Model { 23 | return Model{ 24 | keys: utils.Keys, 25 | } 26 | } 27 | 28 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 29 | 30 | var ( 31 | cmd tea.Cmd 32 | cmds []tea.Cmd 33 | ) 34 | switch msg := msg.(type) { 35 | 36 | case commands.SetMessage: 37 | m.ctx.CurrentView = context.MessageView 38 | m.Content = msg.Content 39 | m.timer = timer.NewWithInterval(time.Second*time.Duration(msg.SecondsDisplayed), time.Second) 40 | cmd = m.timer.Init() 41 | 42 | case timer.TickMsg: 43 | var cmd tea.Cmd 44 | m.timer, cmd = m.timer.Update(msg) 45 | return m, cmd 46 | 47 | case timer.StartStopMsg: 48 | var cmd tea.Cmd 49 | m.timer, cmd = m.timer.Update(msg) 50 | return m, cmd 51 | 52 | case timer.TimeoutMsg: 53 | m.ctx.CurrentView = m.ctx.LastView 54 | m.ctx.LastView = context.VoidView 55 | m.Content = "" 56 | } 57 | 58 | cmds = append(cmds, cmd) 59 | return m, tea.Batch(cmds...) 60 | } 61 | 62 | func (m Model) View() string { 63 | 64 | text := lipgloss.NewStyle().Width(m.ctx.Layout.ScreenWidth).PaddingTop(m.ctx.Layout.ScreenHeight / 2).Align(lipgloss.Center).Render( 65 | // fmt.Sprintf("%s %s", m.Content, m.timer.View()), 66 | m.Content, 67 | ) 68 | return text 69 | } 70 | 71 | func (m *Model) UpdateProgramContext(ctx *context.ProgramContext) { 72 | if ctx == nil { 73 | return 74 | } 75 | m.ctx = ctx 76 | } 77 | 78 | func (m Model) TriggerMessage(content string, secondsDisplayed int) tea.Cmd { 79 | return func() tea.Msg { 80 | return commands.SetMessage{ 81 | Content: content, 82 | SecondsDisplayed: secondsDisplayed, 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /ui/components/repo/repo.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/jrnxf/gh-eco/ui/context" 9 | "github.com/jrnxf/gh-eco/ui/models" 10 | "github.com/jrnxf/gh-eco/ui/styles" 11 | "github.com/jrnxf/gh-eco/utils" 12 | ) 13 | 14 | func buildRepoDisplay(repo models.Repo, width int, isFocused bool, viewerHasStarred bool) string { 15 | // prep the finished matrix 16 | var b strings.Builder 17 | w := b.WriteString 18 | 19 | if isFocused { 20 | w(styles.FocusedBold.Render(repo.Name)) 21 | } else { 22 | w(styles.Bold.Render(repo.Name)) 23 | } 24 | w(utils.GetNewLines(1)) 25 | 26 | w(fmt.Sprintf("%s %s", utils.TruncateText(repo.Description, 60), strings.Repeat(" ", width))) 27 | w(utils.GetNewLines(1)) 28 | 29 | coloredCircle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: repo.PrimaryLanguage.Color, Dark: repo.PrimaryLanguage.Color}).Render("●") 30 | 31 | star := "⭑" 32 | if viewerHasStarred { 33 | star = lipgloss.NewStyle().Foreground(lipgloss.Color("#DAAA3F")).Render("⭑") 34 | } 35 | w(fmt.Sprintf("%s %s %s %v", coloredCircle, repo.PrimaryLanguage.Name, star, repo.StarsCount)) 36 | 37 | return lipgloss.NewStyle(). 38 | Align(lipgloss.Left).Render(styles.Frame.Render(b.String())) 39 | 40 | } 41 | 42 | func BuildPinnedRepoDisplay(repos []models.Repo, ctx *context.ProgramContext) string { 43 | var lc strings.Builder // left col 44 | var rc strings.Builder // right col 45 | 46 | maxRepoDescLength := 0 47 | for _, r := range repos { 48 | currRepoDescLength := len(utils.TruncateText(r.Description, 60)) 49 | if currRepoDescLength > maxRepoDescLength { 50 | maxRepoDescLength = currRepoDescLength 51 | } 52 | } 53 | 54 | for i, r := range repos { 55 | currRepoDescLength := len(utils.TruncateText(r.Description, 60)) 56 | 57 | widgetName := fmt.Sprintf("PinnedRepo%v", i+1) 58 | ctx.FocusableWidgets = append(ctx.FocusableWidgets, context.FocusableWidget{Type: context.RepoWidget, Repo: r, Descriptor: widgetName}) 59 | fw := ctx.CurrentFocus.FocusedWidget 60 | display := buildRepoDisplay(r, maxRepoDescLength-currRepoDescLength, fw.Descriptor == widgetName, r.ViewerHasStarred) + utils.GetNewLines(1) 61 | if i%2 == 0 { 62 | lc.WriteString(display) 63 | } else { 64 | rc.WriteString(display) 65 | } 66 | } 67 | 68 | return lipgloss.JoinHorizontal(lipgloss.Top, lc.String(), rc.String()) 69 | } 70 | -------------------------------------------------------------------------------- /ui/components/search/search.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/textinput" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | "github.com/jrnxf/gh-eco/api/github" 12 | "github.com/jrnxf/gh-eco/ui/commands" 13 | "github.com/jrnxf/gh-eco/ui/components/spinner" 14 | "github.com/jrnxf/gh-eco/ui/context" 15 | "github.com/jrnxf/gh-eco/utils" 16 | ) 17 | 18 | type Model struct { 19 | keys utils.KeyMap 20 | textInput textinput.Model 21 | spinner spinner.Model 22 | fetching bool 23 | err error 24 | ctx *context.ProgramContext 25 | } 26 | 27 | func NewModel() Model { 28 | ti := textinput.NewModel() 29 | ti.Focus() 30 | ti.Placeholder = "search by username" 31 | 32 | return Model{ 33 | keys: utils.Keys, 34 | textInput: ti, 35 | err: nil, 36 | fetching: false, 37 | spinner: spinner.NewModel(), 38 | } 39 | } 40 | 41 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 42 | var ( 43 | cmd tea.Cmd 44 | spinnerCmd tea.Cmd 45 | textInputCmd tea.Cmd 46 | getUserCmd tea.Cmd 47 | 48 | cmds []tea.Cmd 49 | ) 50 | 51 | switch msg := msg.(type) { 52 | 53 | case tea.KeyMsg: 54 | if key.Matches(msg, m.keys.Quit) { 55 | cmd = tea.Quit 56 | } 57 | 58 | switch m.ctx.Mode { 59 | case context.InsertMode: 60 | cmds = append(cmds, textinput.Blink) 61 | 62 | if key.Matches(msg, m.keys.Search) { 63 | m.ctx.Mode = context.NormalMode 64 | m.textInput.SetCursorMode(textinput.CursorHide) 65 | getUserCmd = github.GetUser(m.textInput.Value()) 66 | m.fetching = true 67 | cmds = append(cmds, m.spinner.Tick, getUserCmd) 68 | } 69 | 70 | case context.NormalMode: 71 | if key.Matches(msg, m.keys.FocusInput) { 72 | if m.ctx.CurrentView == context.UserView { 73 | m.textInput.Reset() 74 | m.ctx.Mode = context.InsertMode 75 | m.textInput.SetCursorMode(textinput.CursorBlink) 76 | return m, textinput.Blink 77 | } 78 | } 79 | } 80 | 81 | case commands.GetUserResponse: 82 | m.fetching = false 83 | } 84 | 85 | if m.ctx.Mode == context.InsertMode { 86 | m.textInput, textInputCmd = m.textInput.Update(msg) 87 | } 88 | 89 | if m.fetching { 90 | // by not sending updates to it i effectively stop the spinner and cut useless 91 | // re-renders in the top level update (in ui.go) 92 | m.spinner, spinnerCmd = m.spinner.Update(msg) 93 | } 94 | 95 | cmds = append(cmds, cmd, spinnerCmd, textInputCmd) 96 | return m, tea.Batch(cmds...) 97 | } 98 | 99 | func (m Model) View() string { 100 | var b strings.Builder 101 | w := b.WriteString 102 | 103 | w(utils.GetNewLines(1)) 104 | 105 | if m.fetching { 106 | w(fmt.Sprintf("%s%s", m.textInput.View(), m.spinner.View())) 107 | } else { 108 | w(m.textInput.View()) 109 | } 110 | 111 | return lipgloss.NewStyle().Render(b.String()) 112 | } 113 | 114 | func (m *Model) UpdateProgramContext(ctx *context.ProgramContext) { 115 | if ctx == nil { 116 | return 117 | } 118 | m.ctx = ctx 119 | } 120 | -------------------------------------------------------------------------------- /ui/components/spinner/spinner.go: -------------------------------------------------------------------------------- 1 | package spinner 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/spinner" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | type Model struct { 10 | spinner spinner.Model 11 | Tick tea.Cmd 12 | } 13 | 14 | func NewModel() Model { 15 | return Model{ 16 | spinner: spinner.Model{ 17 | Spinner: spinner.Dot, 18 | Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#5E81AC")), 19 | }, 20 | Tick: spinner.Tick, 21 | } 22 | } 23 | 24 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 25 | var cmd tea.Cmd 26 | m.spinner, cmd = m.spinner.Update(msg) 27 | return m, cmd 28 | } 29 | 30 | func (m Model) View() string { 31 | return m.spinner.View() 32 | } 33 | -------------------------------------------------------------------------------- /ui/components/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/jrnxf/gh-eco/ui/commands" 11 | "github.com/jrnxf/gh-eco/ui/components/graph" 12 | "github.com/jrnxf/gh-eco/ui/components/repo" 13 | "github.com/jrnxf/gh-eco/ui/context" 14 | "github.com/jrnxf/gh-eco/ui/models" 15 | "github.com/jrnxf/gh-eco/ui/styles" 16 | "github.com/jrnxf/gh-eco/utils" 17 | "golang.org/x/term" 18 | ) 19 | 20 | type Model struct { 21 | User models.User 22 | display string 23 | err error 24 | ctx *context.ProgramContext 25 | } 26 | 27 | func NewModel() Model { 28 | return Model{ 29 | User: models.User{}, 30 | err: nil, 31 | } 32 | } 33 | 34 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 35 | var ( 36 | cmd tea.Cmd 37 | cmds []tea.Cmd 38 | ) 39 | 40 | switch msg := msg.(type) { 41 | case commands.GetUserResponse: 42 | if msg.Err != nil { 43 | m.err = msg.Err 44 | } else { 45 | m.User = msg.User 46 | m.ctx.User = msg.User 47 | m.err = nil 48 | } 49 | m.buildDisplay() 50 | case commands.FocusChange: 51 | m.buildDisplay() 52 | 53 | case tea.WindowSizeMsg: 54 | m.buildDisplay() 55 | case commands.StarStarrableResponse: 56 | for i, r := range m.ctx.User.PinnedRepos { 57 | if r.Id == msg.Starrable.Id { 58 | m.ctx.User.PinnedRepos[i].ViewerHasStarred = msg.Starrable.ViewerHasStarred 59 | m.ctx.User.PinnedRepos[i].StarsCount = msg.Starrable.StargazerCount 60 | m.ctx.CurrentFocus.FocusedWidget.Repo.ViewerHasStarred = msg.Starrable.ViewerHasStarred 61 | m.ctx.CurrentFocus.FocusedWidget.Repo.StarsCount = msg.Starrable.StargazerCount 62 | } 63 | } 64 | m.buildDisplay() 65 | 66 | case commands.RemoveStarStarrableResponse: 67 | for i, r := range m.ctx.User.PinnedRepos { 68 | if r.Id == msg.Starrable.Id { 69 | m.ctx.User.PinnedRepos[i].ViewerHasStarred = msg.Starrable.ViewerHasStarred 70 | m.ctx.User.PinnedRepos[i].StarsCount = msg.Starrable.StargazerCount 71 | m.ctx.CurrentFocus.FocusedWidget.Repo.ViewerHasStarred = msg.Starrable.ViewerHasStarred 72 | m.ctx.CurrentFocus.FocusedWidget.Repo.StarsCount = msg.Starrable.StargazerCount 73 | } 74 | } 75 | m.buildDisplay() 76 | } 77 | 78 | cmds = append(cmds, cmd) 79 | return m, tea.Batch(cmds...) 80 | } 81 | 82 | func (m Model) buildUserDisplay() string { 83 | u := m.User 84 | 85 | var b strings.Builder 86 | w := b.WriteString 87 | 88 | if u.Name == "" && u.Login != "" { 89 | // user hasn't specified their name on github 90 | if m.ctx.CurrentFocus.FocusedWidget.Descriptor == "UserDisplay" { 91 | w(styles.FocusedBold.Render(u.Login)) 92 | } else { 93 | w(styles.Bold.Render(u.Login)) 94 | 95 | } 96 | w(utils.GetNewLines(2)) 97 | 98 | } 99 | if u.Name != "" { 100 | if m.ctx.CurrentFocus.FocusedWidget.Descriptor == "UserDisplay" { 101 | w(styles.FocusedBold.Render(u.Name)) 102 | } else { 103 | w(styles.Bold.Render(u.Name)) 104 | } 105 | w(utils.GetNewLines(2)) 106 | 107 | } 108 | 109 | if u.Bio != "" { 110 | w(lipgloss.NewStyle().Faint(true).Align(lipgloss.Center).Render(u.Bio)) 111 | w(utils.GetNewLines(2)) 112 | } 113 | 114 | var ( 115 | viewerIsFollowingStr string 116 | isFollowingViewerStr string 117 | ) 118 | 119 | if u.ViewerIsFollowing { 120 | viewerIsFollowingStr = lipgloss.NewStyle().Italic(true).Render(" (you follow)") 121 | } 122 | 123 | if u.IsFollowingViewer { 124 | isFollowingViewerStr = lipgloss.NewStyle().Italic(true).Render(" (follows you)") 125 | } 126 | 127 | w(fmt.Sprintf("%v %s%s / %v %s%s", u.FollowersCount, "followers", viewerIsFollowingStr, u.FollowingCount, "following", isFollowingViewerStr)) 128 | w(utils.GetNewLines(1)) 129 | 130 | if (u.Location != "") || (u.WebsiteUrl != "") || (u.TwitterUsername != "") { 131 | line := []string{} 132 | if u.Location != "" { 133 | line = append(line, u.Location) 134 | } 135 | if u.WebsiteUrl != "" { 136 | line = append(line, u.WebsiteUrl) 137 | } 138 | if u.TwitterUsername != "" { 139 | line = append(line, fmt.Sprintf("@%s", u.TwitterUsername)) 140 | } 141 | 142 | w(utils.GetNewLines(1)) 143 | w(strings.Join(line, " / ")) 144 | w(utils.GetNewLines(1)) 145 | } 146 | 147 | m.ctx.FocusableWidgets = append(m.ctx.FocusableWidgets, context.FocusableWidget{Descriptor: "UserDisplay", Type: context.UserWidget, User: m.User}) 148 | 149 | return b.String() 150 | } 151 | 152 | func (m *Model) buildDisplay() { 153 | physicalWidth, _, _ := term.GetSize(int(os.Stdout.Fd())) 154 | 155 | var b strings.Builder 156 | w := b.WriteString 157 | 158 | u := m.User 159 | if m.err != nil { 160 | w("no results") 161 | } else if m.User.Login != "" { 162 | w(m.buildUserDisplay()) 163 | 164 | w(utils.GetNewLines(2)) 165 | 166 | w(fmt.Sprintf("%v contributions", u.ActivityGraph.ContributionsCount)) 167 | 168 | w(utils.GetNewLines(1)) 169 | 170 | w(lipgloss.NewStyle(). 171 | Align(lipgloss.Left). 172 | Render(graph.BuildGraphDisplay(u.ActivityGraph.Weeks))) 173 | 174 | w(utils.GetNewLines(2)) 175 | 176 | w(lipgloss.NewStyle(). 177 | Align(lipgloss.Center).Render(repo.BuildPinnedRepoDisplay(u.PinnedRepos, m.ctx))) 178 | 179 | } 180 | 181 | m.display = lipgloss.NewStyle(). 182 | Align(lipgloss.Center). 183 | Width(physicalWidth).Render(b.String()) 184 | 185 | } 186 | 187 | func (m Model) View() string { 188 | return lipgloss.NewStyle().Height(m.ctx.Layout.ContentHeight).Render(m.display) 189 | } 190 | 191 | func (m *Model) UpdateProgramContext(ctx *context.ProgramContext) { 192 | if ctx == nil { 193 | return 194 | } 195 | m.ctx = ctx 196 | } 197 | -------------------------------------------------------------------------------- /ui/context/context.go: -------------------------------------------------------------------------------- 1 | package context 2 | 3 | import "github.com/jrnxf/gh-eco/ui/models" 4 | 5 | type Mode int 6 | 7 | const ( 8 | InsertMode Mode = iota 9 | NormalMode 10 | ) 11 | 12 | type View int 13 | 14 | const ( 15 | VoidView View = iota 16 | UserView 17 | ReadmeView 18 | MessageView 19 | ) 20 | 21 | type ProgramContext struct { 22 | User models.User 23 | CurrentView View 24 | LastView View 25 | Mode Mode 26 | CurrentFocus CurrentFocus 27 | FocusableWidgets []FocusableWidget 28 | Layout struct { 29 | ScreenHeight int 30 | ScreenWidth int 31 | ContentHeight int 32 | ContentWidth int 33 | } 34 | } 35 | 36 | type FocusedWidgetType int 37 | 38 | const ( 39 | NoWidget FocusedWidgetType = iota 40 | UserWidget 41 | RepoWidget 42 | ) 43 | 44 | type FocusableWidget struct { 45 | Descriptor string 46 | Type FocusedWidgetType 47 | Repo models.Repo 48 | User models.User 49 | } 50 | 51 | type CurrentFocus struct { 52 | FocusIdx int 53 | FocusedWidget FocusableWidget 54 | } 55 | -------------------------------------------------------------------------------- /ui/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type User struct { 4 | Id string 5 | Login string 6 | Name string 7 | Location string 8 | Url string 9 | Bio string 10 | TwitterUsername string 11 | WebsiteUrl string 12 | FollowersCount int 13 | FollowingCount int 14 | IsViewer bool 15 | IsFollowingViewer bool 16 | ViewerIsFollowing bool 17 | PinnedRepos []Repo 18 | ActivityGraph struct { 19 | ContributionsCount int 20 | Weeks []WeeklyContribution 21 | } 22 | } 23 | 24 | type WeeklyContribution struct { 25 | ContributionDays []struct { 26 | ContributionLevel string 27 | } 28 | } 29 | 30 | type Repo struct { 31 | Id string 32 | Name string 33 | Description string 34 | StarsCount int 35 | ViewerHasStarred bool 36 | Url string 37 | Owner struct { 38 | Login string 39 | } 40 | Readme Blob 41 | PrimaryLanguage struct { 42 | Name string 43 | Color string 44 | } 45 | } 46 | 47 | type Blob struct { 48 | Text string 49 | } 50 | 51 | type Starrable struct { 52 | Id string 53 | StargazerCount int 54 | ViewerHasStarred bool 55 | } 56 | -------------------------------------------------------------------------------- /ui/styles/styles.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | var ( 6 | Bold = lipgloss.NewStyle().Bold(true) 7 | FocusedBold = Bold.Copy().Background(lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}) 8 | HighlightedBold = lipgloss.NewStyle().Background(lipgloss.AdaptiveColor{Light: "#5E81AC", Dark: "#5E81AC"}) 9 | 10 | Faint = lipgloss.NewStyle().Faint(true) 11 | FaintBold = Faint.Copy().Bold(true) 12 | 13 | roundedBorder = lipgloss.Border{ 14 | Top: "─", 15 | Bottom: "─", 16 | Left: "│", 17 | Right: "│", 18 | TopLeft: "╭", 19 | TopRight: "╮", 20 | BottomLeft: "╰", 21 | BottomRight: "╯", 22 | } 23 | 24 | Frame = lipgloss.NewStyle().Border(roundedBorder, true).Padding(0, 1).Margin(0, 1) 25 | ) 26 | -------------------------------------------------------------------------------- /ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/textinput" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | 9 | "github.com/jrnxf/gh-eco/api/github" 10 | "github.com/jrnxf/gh-eco/ui/commands" 11 | "github.com/jrnxf/gh-eco/ui/components/help" 12 | "github.com/jrnxf/gh-eco/ui/components/markdown" 13 | "github.com/jrnxf/gh-eco/ui/components/message" 14 | "github.com/jrnxf/gh-eco/ui/components/search" 15 | "github.com/jrnxf/gh-eco/ui/components/user" 16 | "github.com/jrnxf/gh-eco/ui/context" 17 | "github.com/jrnxf/gh-eco/utils" 18 | ) 19 | 20 | type Model struct { 21 | keys utils.KeyMap 22 | search search.Model 23 | user user.Model 24 | markdown markdown.Model 25 | help help.Model 26 | message message.Model 27 | ctx context.ProgramContext 28 | } 29 | 30 | func New() Model { 31 | m := Model{ 32 | keys: utils.Keys, 33 | search: search.NewModel(), 34 | user: user.NewModel(), 35 | markdown: markdown.NewModel(), 36 | help: help.NewModel(), 37 | message: message.NewModel(), 38 | ctx: context.ProgramContext{ 39 | Mode: context.InsertMode, 40 | CurrentView: context.UserView, 41 | LastView: context.VoidView, 42 | }, 43 | } 44 | 45 | m.resetWidgets() 46 | m.resetCurrentFocus() 47 | 48 | m.syncProgramContext() 49 | 50 | return m 51 | } 52 | 53 | func (m Model) Init() tea.Cmd { 54 | var ( 55 | cmds []tea.Cmd 56 | ) 57 | 58 | cmds = append(cmds, textinput.Blink) 59 | return tea.Batch( 60 | cmds..., 61 | ) 62 | } 63 | 64 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 65 | var ( 66 | cmd tea.Cmd 67 | spinnerCmd tea.Cmd 68 | searchCmd tea.Cmd 69 | userCmd tea.Cmd 70 | helpCmd tea.Cmd 71 | messageCmd tea.Cmd 72 | markdownCmd tea.Cmd 73 | getReadmeCmd tea.Cmd 74 | starRepoCmd tea.Cmd 75 | unstarRepoCmd tea.Cmd 76 | focusChangeCmd tea.Cmd 77 | layoutChangeCmd tea.Cmd 78 | cmds []tea.Cmd 79 | ) 80 | fw := &m.ctx.CurrentFocus.FocusedWidget 81 | 82 | switch msg := msg.(type) { 83 | 84 | case tea.KeyMsg: 85 | 86 | if key.Matches(msg, m.keys.Quit) { 87 | cmd = tea.Quit 88 | } 89 | 90 | if m.ctx.Mode == context.NormalMode && m.ctx.CurrentView != context.MessageView { 91 | 92 | switch fw.Type { 93 | case context.UserWidget: 94 | 95 | switch { 96 | case key.Matches(msg, m.keys.OpenGithub): 97 | utils.BrowserOpen(fw.User.Url) 98 | } 99 | 100 | case context.RepoWidget: 101 | 102 | switch { 103 | case key.Matches(msg, m.keys.OpenGithub): 104 | utils.BrowserOpen(fw.Repo.Url) 105 | 106 | case key.Matches(msg, m.keys.StarRepo): 107 | if fw.Repo.ViewerHasStarred { 108 | unstarRepoCmd = github.RemoveStarStarrable(fw.Repo.Id) 109 | cmds = append(cmds, unstarRepoCmd) 110 | } else { 111 | starRepoCmd = github.StarStarrable(fw.Repo.Id) 112 | cmds = append(cmds, starRepoCmd) 113 | } 114 | } 115 | } 116 | 117 | if m.ctx.CurrentView == context.UserView { 118 | 119 | switch { 120 | case key.Matches(msg, m.keys.FocusNext): 121 | m.focusNextWidget() 122 | focusChangeCmd = m.notifyFocusChange() 123 | 124 | case key.Matches(msg, m.keys.FocusPrev): 125 | m.focusPrevWidget() 126 | focusChangeCmd = m.notifyFocusChange() 127 | 128 | case key.Matches(msg, m.keys.FocusInput): 129 | if m.ctx.CurrentView == context.UserView { 130 | m.resetCurrentFocus() 131 | focusChangeCmd = m.notifyFocusChange() 132 | } 133 | } 134 | } 135 | 136 | if key.Matches(msg, m.keys.ToggleReadme) && (fw.Type == context.UserWidget || fw.Type == context.RepoWidget) { 137 | 138 | switch m.ctx.CurrentView { 139 | case context.UserView: 140 | switch fw.Type { 141 | case context.UserWidget: 142 | // get the focused users personal readme 143 | getReadmeCmd = github.GetReadme(fw.User.Login, fw.User.Login) 144 | 145 | case context.RepoWidget: 146 | // get the focused repos readme 147 | getReadmeCmd = github.GetReadme(fw.Repo.Name, fw.Repo.Owner.Login) 148 | } 149 | 150 | case context.ReadmeView: 151 | m.ctx.CurrentView = context.UserView 152 | m.onLayoutChange() 153 | layoutChangeCmd = m.notifyLayoutChange() 154 | } 155 | } 156 | 157 | if key.Matches(msg, m.keys.StarGhEco) { 158 | starRepoCmd = github.StarStarrable(github.GH_ECO_REPO_ID) 159 | messageCmd = m.message.TriggerMessage("tysm 🥹", 2) 160 | m.ctx.LastView = m.ctx.CurrentView 161 | cmds = append(cmds, messageCmd, starRepoCmd) 162 | } 163 | } 164 | 165 | case commands.GetReadmeResponse: 166 | m.ctx.CurrentView = context.ReadmeView 167 | m.onLayoutChange() 168 | layoutChangeCmd = m.notifyLayoutChange() 169 | 170 | case commands.GetUserResponse: 171 | m.resetWidgets() 172 | 173 | case commands.FocusChange: 174 | m.resetWidgets() 175 | 176 | case tea.WindowSizeMsg: 177 | m.onWindowSizeChanged(msg) 178 | m.syncProgramContext() 179 | } 180 | 181 | m.syncProgramContext() 182 | m.search, searchCmd = m.search.Update(msg) 183 | m.markdown, markdownCmd = m.markdown.Update(msg) 184 | m.user, userCmd = m.user.Update(msg) 185 | m.help, helpCmd = m.help.Update(msg) 186 | m.message, messageCmd = m.message.Update(msg) 187 | cmds = append(cmds, cmd, spinnerCmd, searchCmd, userCmd, helpCmd, messageCmd, markdownCmd, getReadmeCmd, focusChangeCmd, layoutChangeCmd) 188 | return m, tea.Batch(cmds...) 189 | } 190 | 191 | func (m Model) View() string { 192 | 193 | if m.message.Content != "" { 194 | return m.message.View() 195 | } 196 | 197 | switch m.ctx.CurrentView { 198 | case context.ReadmeView: 199 | return lipgloss.JoinVertical(lipgloss.Left, 200 | m.markdown.View(), 201 | m.help.View(), 202 | ) 203 | 204 | case context.UserView: 205 | return lipgloss.JoinVertical(lipgloss.Left, 206 | lipgloss.NewStyle().Render(m.search.View()), 207 | m.user.View(), 208 | m.help.View(), 209 | ) 210 | 211 | default: 212 | return "" 213 | } 214 | } 215 | 216 | func (m *Model) focusNextWidget() { 217 | cf := &m.ctx.CurrentFocus 218 | 219 | numWidgets := len(m.ctx.FocusableWidgets) 220 | cf.FocusIdx = (cf.FocusIdx + 1) % numWidgets 221 | cf.FocusedWidget = m.ctx.FocusableWidgets[cf.FocusIdx] 222 | } 223 | 224 | func (m *Model) focusPrevWidget() { 225 | cf := &m.ctx.CurrentFocus 226 | 227 | numWidgets := len(m.ctx.FocusableWidgets) 228 | cf.FocusIdx = (cf.FocusIdx - 1 + numWidgets) % numWidgets 229 | cf.FocusedWidget = m.ctx.FocusableWidgets[cf.FocusIdx] 230 | } 231 | 232 | func (m Model) notifyFocusChange() tea.Cmd { 233 | return func() tea.Msg { 234 | return commands.FocusChange{} 235 | } 236 | } 237 | func (m Model) notifyLayoutChange() tea.Cmd { 238 | return func() tea.Msg { 239 | return commands.LayoutChange{} 240 | } 241 | } 242 | 243 | func (m *Model) onWindowSizeChanged(msg tea.WindowSizeMsg) { 244 | m.ctx.Layout.ScreenHeight = msg.Height 245 | m.ctx.Layout.ScreenWidth = msg.Width 246 | 247 | m.ctx.Layout.ContentHeight = msg.Height - lipgloss.Height(m.search.View()) - lipgloss.Height(m.help.View()) 248 | m.ctx.Layout.ContentWidth = msg.Width 249 | } 250 | 251 | func (m *Model) onLayoutChange() { 252 | contentHeight := m.ctx.Layout.ScreenHeight - lipgloss.Height(m.help.View()) 253 | 254 | if m.ctx.CurrentView == context.UserView { 255 | contentHeight -= lipgloss.Height(m.search.View()) 256 | } 257 | 258 | m.ctx.Layout.ContentHeight = contentHeight 259 | m.syncProgramContext() 260 | } 261 | 262 | func (m *Model) resetWidgets() { 263 | m.ctx.FocusableWidgets = []context.FocusableWidget{ 264 | { 265 | Descriptor: "NoFocus", 266 | }, 267 | } 268 | } 269 | 270 | func (m *Model) resetCurrentFocus() { 271 | m.ctx.CurrentFocus = context.CurrentFocus{ 272 | FocusIdx: 0, 273 | FocusedWidget: context.FocusableWidget{ 274 | Descriptor: "NoFocus", 275 | Type: context.NoWidget, 276 | }, 277 | } 278 | } 279 | 280 | func (m *Model) syncProgramContext() { 281 | m.markdown.UpdateProgramContext(&m.ctx) 282 | m.search.UpdateProgramContext(&m.ctx) 283 | m.user.UpdateProgramContext(&m.ctx) 284 | m.help.UpdateProgramContext(&m.ctx) 285 | m.message.UpdateProgramContext(&m.ctx) 286 | } 287 | -------------------------------------------------------------------------------- /utils/keys.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | ) 6 | 7 | type KeyMap struct { 8 | FocusNext key.Binding 9 | FocusPrev key.Binding 10 | PreviewPageDown key.Binding 11 | PreviewPageUp key.Binding 12 | ToggleReadme key.Binding 13 | OpenGithub key.Binding 14 | StarRepo key.Binding 15 | StarGhEco key.Binding 16 | FocusInput key.Binding 17 | Search key.Binding 18 | Quit key.Binding 19 | } 20 | 21 | var Keys = KeyMap{ 22 | FocusPrev: key.NewBinding( 23 | key.WithKeys("k", "up"), 24 | key.WithHelp("k", "move up"), 25 | ), 26 | FocusNext: key.NewBinding( 27 | key.WithKeys("j", "down"), 28 | key.WithHelp("j", "move down"), 29 | ), 30 | PreviewPageUp: key.NewBinding( 31 | key.WithKeys("ctrl+u"), 32 | key.WithHelp("ctrl+u", "page up"), 33 | ), 34 | PreviewPageDown: key.NewBinding( 35 | key.WithKeys("ctrl+d"), 36 | key.WithHelp("ctrl+d", "page down"), 37 | ), 38 | ToggleReadme: key.NewBinding( 39 | key.WithKeys("r"), 40 | key.WithHelp("r", "toggle readme"), 41 | ), 42 | OpenGithub: key.NewBinding( 43 | key.WithKeys("o"), 44 | key.WithHelp("o", "open in github"), 45 | ), 46 | StarRepo: key.NewBinding( 47 | key.WithKeys("s"), 48 | key.WithHelp("s", "star repo"), 49 | ), 50 | StarGhEco: key.NewBinding( 51 | key.WithKeys("!"), 52 | key.WithHelp("!", "star gh-eco"), 53 | ), 54 | Quit: key.NewBinding( 55 | key.WithKeys("esc", "ctrl+c"), 56 | key.WithHelp("esc", "quit"), 57 | ), 58 | Search: key.NewBinding( 59 | key.WithKeys("enter"), 60 | key.WithHelp("enter", "search"), 61 | ), 62 | FocusInput: key.NewBinding( 63 | key.WithKeys("/"), 64 | key.WithHelp("/", "focus input"), 65 | ), 66 | } 67 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os/exec" 7 | "runtime" 8 | "strings" 9 | "unicode" 10 | 11 | "github.com/jrnxf/gh-eco/api/github/queries" 12 | "github.com/jrnxf/gh-eco/ui/models" 13 | ) 14 | 15 | func TruncateText(str string, max int) string { 16 | if max <= 0 { 17 | return "" 18 | } 19 | 20 | lastSpaceIdx := -1 21 | len := 0 22 | for i, r := range str { 23 | if unicode.IsSpace(r) { 24 | lastSpaceIdx = i 25 | } 26 | len++ 27 | if len > max { 28 | if lastSpaceIdx != -1 { 29 | return str[:lastSpaceIdx] + "..." 30 | } 31 | // string is longer than max but has no spaces 32 | } 33 | } 34 | // string is shorter than max 35 | return str 36 | } 37 | 38 | func BrowserOpen(url string) { 39 | var err error 40 | 41 | switch runtime.GOOS { 42 | case "linux": 43 | err = exec.Command("xdg-open", url).Start() 44 | case "windows": 45 | err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() 46 | case "darwin": 47 | err = exec.Command("open", url).Start() 48 | default: 49 | err = fmt.Errorf("unsupported platform") 50 | } 51 | if err != nil { 52 | log.Println(err) 53 | } 54 | } 55 | 56 | func GetNewLines(n int) string { 57 | if n <= 0 { 58 | return "" 59 | } 60 | return strings.Repeat("\n", n) 61 | } 62 | 63 | func MapGetUserQueryToDisplayUser(query queries.GetUserQuery) models.User { 64 | qu := query.User 65 | du := models.User{ 66 | Login: qu.Login, 67 | Name: qu.Name, 68 | Location: qu.Location, 69 | Url: qu.Url, 70 | Bio: qu.Bio, 71 | TwitterUsername: qu.TwitterUsername, 72 | WebsiteUrl: qu.WebsiteUrl, 73 | FollowersCount: qu.Followers.TotalCount, 74 | FollowingCount: qu.Following.TotalCount, 75 | IsViewer: qu.IsViewer, 76 | IsFollowingViewer: qu.IsFollowingViewer, 77 | ViewerIsFollowing: qu.ViewerIsFollowing, 78 | } 79 | 80 | du.ActivityGraph.ContributionsCount = qu.ContributionsCollection.ContributionCalendar.TotalContributions 81 | 82 | for _, week := range qu.ContributionsCollection.ContributionCalendar.Weeks { 83 | du.ActivityGraph.Weeks = append(du.ActivityGraph.Weeks, week) 84 | } 85 | 86 | for _, node := range qu.PinnedItems.Nodes { 87 | r := node.Repository 88 | du.PinnedRepos = append(du.PinnedRepos, models.Repo{ 89 | Id: r.Id, 90 | Name: r.Name, 91 | Description: r.Description, 92 | StarsCount: r.StargazerCount, 93 | ViewerHasStarred: r.ViewerHasStarred, 94 | Owner: struct{ Login string }{ 95 | Login: r.Owner.Login, 96 | }, 97 | Url: r.Url, 98 | PrimaryLanguage: r.PrimaryLanguage, 99 | }) 100 | } 101 | 102 | return du 103 | } 104 | 105 | func MaxInt(a, b int) int { 106 | if a >= b { 107 | return a 108 | } 109 | return b 110 | } 111 | -------------------------------------------------------------------------------- /utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "testing" 4 | 5 | type getNewLinesTest struct { 6 | arg1 int 7 | expected string 8 | } 9 | 10 | func Test_GetNewLines(t *testing.T) { 11 | var tests = []getNewLinesTest{ 12 | {4, "\n\n\n\n"}, 13 | {0, ""}, 14 | {-14, ""}, 15 | } 16 | 17 | for _, test := range tests { 18 | if output := GetNewLines(test.arg1); output != test.expected { 19 | t.Errorf("Output %q not equal to expected %q", output, test.expected) 20 | } 21 | } 22 | } 23 | 24 | type truncateTextTest struct { 25 | arg1 string 26 | arg2 int 27 | expected string 28 | } 29 | 30 | func Test_TruncateText(t *testing.T) { 31 | var tests = []truncateTextTest{ 32 | {"the quick brown fox jumped over the lazy dog", 15, "the quick brown..."}, 33 | {"the quick brown fox jumped over the lazy dog", 14, "the quick..."}, 34 | {"the quick brown fox jumped over the lazy dog", -14, ""}, 35 | {"the quick brown fox jumped over the lazy dog", 0, ""}, 36 | {"the quick brown fox jumped over the lazy dog", 1, "the..."}, 37 | } 38 | 39 | for _, test := range tests { 40 | if output := TruncateText(test.arg1, test.arg2); output != test.expected { 41 | t.Errorf("Output %q not equal to expected %q", output, test.expected) 42 | } 43 | } 44 | } 45 | 46 | type maxIntTest struct { 47 | arg1 int 48 | arg2 int 49 | expected int 50 | } 51 | 52 | func Test_MaxInt(t *testing.T) { 53 | var tests = []maxIntTest{ 54 | {1, 3, 3}, 55 | {-1, 99, 99}, 56 | {54, 54, 54}, 57 | {312, 4, 312}, 58 | } 59 | 60 | for _, test := range tests { 61 | if output := MaxInt(test.arg1, test.arg2); output != test.expected { 62 | t.Errorf("Output %q not equal to expected %q", output, test.expected) 63 | } 64 | } 65 | } 66 | --------------------------------------------------------------------------------