diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8d14bbc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: Bug report +about: File a bug/issue +title: '[Bug]: ' +labels: bug +assignees: '' +--- + +Your issue may already be reported! +Please search on the [issue tracker](../) before creating one. + +### Prerequisites + +* [ ] Are you running the latest version? +* [ ] Did you check other issues? +* [ ] Can you reproduce the problem? +* [ ] Provide a minimal code snippet example that reproduces the bug. + + +### Description + +[Description of the bug] + +### Steps to Reproduce + +1. [First Step] +2. [Second Step] +3. [and so on...] + +**Expected behavior:** [What you expected to happen] + +**Actual behavior:** [What actually happened] + +### Versions + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..63626c5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature request +about: "Suggest an idea for this project" +title: '[Proposal]: ' +labels: proposal +assignees: '' +--- + +Your issue may already be reported! +Please search on the [issue tracker](../) before creating one. + +## Feature Request + +### Description + +[A clear and concise description of what the problem is. Ex. I have an issue when] + +### Solution + +[A clear and concise description of what you want to happen. Add any considered drawbacks.] + +### Alternative solutions / Recommendations / Workarounds + +[A clear and concise description of any alternative solutions or features you've considered.] + +### Documentation, Adoption, Migration Strategy +[If you can, explain how users will be able to use this.] \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f0b9262 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..b70e510 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,20 @@ +### Description +Describe your work here. + +### Scope +> What is affected by this pull request? + +- [ ] Bug Fix +- [ ] New Feature +- [ ] Documentation +- [ ] Other + +### Related Issue +Fixes # + + +### To-Do Checklist +- [ ] I tested my changes +- [ ] I have commented every method that I created/changed +- [ ] I updated the examples to fit with my changes +- [ ] I have added tests for my newly created methods \ No newline at end of file diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 0000000..82fa406 --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,19 @@ +_extends: .github + +repository: + description: 📰 Hackertea a TUI for Hackernews. + homepage: https://github.com/KarolosLykos/hackerteae + topics: go, cli, tui, golang, hackernews, bubbletea, lipgloss, bubble, elm-architecture, terminal + private: true + +branches: + - name: main + protection: + required_pull_request_reviews: + required_approving_review_count: 1 + dismiss_stale_reviews: true + require_code_owner_reviews: true + required_status_checks: + strict: true + contexts: [] + required_linear_history: true diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..b70f622 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,36 @@ +name: Go + +on: [push, pull_request] + +jobs: + build: + name: Build + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-latest, windows-latest, macos-latest ] + steps: + + - name: Set up Go 1.x + uses: actions/setup-go@v4 + with: + go-version: ^1 + id: go + + - name: Check out code into the Go module directory + uses: actions/checkout@v3 + + - name: Get dependencies + run: go get -v -t -d ./... + + - name: Build + run: go build -v . + + - name: Test + run: go test -coverprofile="coverage.txt" -covermode=atomic -p 1 ./... + + - name: Upload coverage to Codecov + if: success() && matrix.os == 'ubuntu-latest' + uses: codecov/codecov-action@v3.1.4 + with: + token: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml new file mode 100644 index 0000000..f1d2c86 --- /dev/null +++ b/.github/workflows/release-test.yml @@ -0,0 +1,23 @@ +name: release-test + +on: [ push, pull_request ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: 1.19 + - name: Run GoReleaser Tests + uses: goreleaser/goreleaser-action@v4 + with: + version: latest + args: --snapshot --skip-publish --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7d01d14 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + id-token: write + packages: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-go@v4 + with: + go-version: 1.19 + - uses: goreleaser/goreleaser-action@v4 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/vhs.yml b/.github/workflows/vhs.yml new file mode 100644 index 0000000..4a01595 --- /dev/null +++ b/.github/workflows/vhs.yml @@ -0,0 +1,25 @@ +name: vhs +on: + push: + paths: + - vhs.tape +jobs: + vhs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: charmbracelet/vhs-action@v2.0.0 + with: + path: 'vhs.tape' + env: + TERM: xterm-256color + - uses: stefanzweifel/git-auto-commit-action@v4 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + commit_message: Update generated VHS GIF + branch: main + commit_user_name: vhs-action 📼 + commit_user_email: actions@github.com + commit_author: vhs-action 📼 + file_pattern: '*.gif' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bded1ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +coverage.html + +# Dependency directories (remove the comment below to include it) +#vendor/ + +# Output of GoReleaser +dist/ + +# MacOS DS_Store files +.DS_Store + +# Visual Studio Code files +.vscode/ + +# Local History for Visual Studio Code +.history/ + +# Goland and Intellij IDEA files +.idea/ + +# env files that usually contain secrets or local config +.env +.envrc \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..56844e1 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,43 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com +before: + hooks: + - go mod tidy + +gomod: + proxy: true + +builds: + - env: ["CGO_ENABLED=0"] + mod_timestamp: "{{ .CommitTimestamp }}" + flags: ["-trimpath"] + targets: ["go_first_class"] + +changelog: + sort: asc + use: github + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore" + - Merge pull request + - Merge remote-tracking branch + - Merge branch + - go mod tidy + groups: + - title: "New Features" + regexp: "^.*feat[(\\w)]*:+.*$" + order: 0 + - title: "Bug fixes" + regexp: "^.*fix[(\\w)]*:+.*$" + order: 10 + - title: Other work + order: 999 + +release: + footer: | + + --- + + _Released with [GoReleaser](https://goreleaser.com)!_ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..42900c3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing + +Thank you for considering contributing to Hackertea + +You can contribute in the following ways: + +- Finding and reporting bugs +- Contributing code to Hackertea by fixing bugs or implementing features +- Improving the documentation + +## Bug reports + +Bug reports and feature suggestions must use descriptive and concise titles and be submitted to [GitHub Issues](https://github.com/KarolosLykos/hackertea/issues). Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected. + +## Pull requests + +**Please use clean, concise titles for your pull requests.** Unless the pull request is about refactoring code, updating dependencies or other internal tasks, assume that the person reading the pull request title is not a programmer, and **try to describe your change or fix**. The final commit in the main branch will carry the title of the pull request. + +## Documentation + +The [Hackertea documentation](https://github.com/KarolosLykos/hackertea/blob/main/README.md). diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef85b7a --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +

HackerTea

+ + +Go version + + + +Downloads + + +A command-line interface (CLI) tool that allows users to browse the Top, New, and Best stories on Hacker News. The tool includes a minimalist text-based user interface (TUI) that is developed using Bubble Tea, Lip Gloss, and Bubble libraries. + +Welcome to Hachertea + +## Features + + +- Read Top, New and Best stories. +- Fetch stories concurrently. (You can set the number of workers in the config file) +- In-memory thread-safe cache for caching news. +- A shiny UI to gaze your eyes upon. + - Tabs + - Separate pagination for each tab + - Fetch next pages + - Vim-like movements + +## Libraries used + + +* [Bubbletea](https://github.com/charmbracelet/bubbles): The fun, functional and stateful way to build terminal apps. +* [Bubbles](https://github.com/charmbracelet/bubbles): Common Bubble Tea components such as text inputs, viewports, spinners and so on +* [Lip Gloss](https://github.com/charmbracelet/lipgloss): Style, format and layout tools for terminal applications + + +## Styling + + +The default theme is already loaded by default, but the good news is that you have the option to add any theme of your choice! +Simply take a look at the "config-example.yaml" file to see the available options. + +Welcome to Hachertea + +## Roadmap + + +- [ ] Add more screens + - [ ] Add Comments screen + - [ ] Add User profile screen + - [ ] Add Ask HN screen + - [ ] Add Jobs screen +- [ ] Add Changelog +- [ ] Add additional styling options w/ Examples +- [ ] Multi-language Support + +## Contributing + + +Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. + +You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository. To get started, take a look at [CONTRIBUTING.md](CONTRIBUTING.md) + diff --git a/config-example.yaml b/config-example.yaml new file mode 100644 index 0000000..d55d79f --- /dev/null +++ b/config-example.yaml @@ -0,0 +1,44 @@ +style: + listItem: + normalTitle: + dark: + light: + normalDesc: + dark: + light: + selectedTitle: + borderforeground: + dark: + light: + foreground: + dark: + light: + selectedDesc: + dark: + light: + dimmedTitle: + dark: + light: + dimmedDesc: + dark: + light: + filterMatch: + borderforeground: + dark: + light: + foreground: + dark: + light: + visited: + dark: + light: + window: + border: normal + color: + dark: + light: + tab: + color: + dark: + light: +workers: diff --git a/demo.gif b/demo.gif new file mode 100644 index 0000000..b0fa82c Binary files /dev/null and b/demo.gif differ diff --git a/examples/demo.gif b/examples/demo.gif new file mode 100644 index 0000000..0d61349 Binary files /dev/null and b/examples/demo.gif differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ec2c3d4 --- /dev/null +++ b/go.mod @@ -0,0 +1,37 @@ +module github.com/KarolosLykos/hackertea + +go 1.19 + +require ( + github.com/adrg/xdg v0.4.0 + github.com/charmbracelet/bubbles v0.16.1 + github.com/charmbracelet/bubbletea v0.24.2 + github.com/charmbracelet/lipgloss v0.7.1 + github.com/golang/mock v1.6.0 + github.com/stretchr/testify v1.8.4 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + github.com/sahilm/fuzzy v0.1.0 // indirect + golang.org/x/sync v0.2.0 // indirect + golang.org/x/sys v0.8.0 // indirect + golang.org/x/term v0.8.0 // indirect + golang.org/x/text v0.9.0 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..edeb00d --- /dev/null +++ b/go.sum @@ -0,0 +1,100 @@ +github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= +github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= +github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= +github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= +github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= +github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= +github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= +github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= +golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..bcddc50 --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,47 @@ +// Package cache provides an interface for caching data. +package cache + +import ( + "sync" + + "github.com/KarolosLykos/hackertea/internal/item" +) + +// Cache is an interface for a cache. +type Cache interface { + Get(key int) (*item.Item, bool) + Set(key int, value *item.Item) +} + +// MemCache is an implementation of the Cache interface that stores data in memory. +type MemCache struct { + lock sync.Mutex + items map[int]*item.Item +} + +// New returns a new MemCache. +func New() *MemCache { + return &MemCache{items: make(map[int]*item.Item)} +} + +// Get retrieves an item from the cache with the given key. +// Returns the item and a bool indicating whether the item was found. +func (m *MemCache) Get(key int) (*item.Item, bool) { + m.lock.Lock() + defer m.lock.Unlock() + + v, ok := m.items[key] + if !ok { + return nil, false + } + + return v, true +} + +// Set sets an item in the cache with the given key and value. +func (m *MemCache) Set(key int, value *item.Item) { + m.lock.Lock() + defer m.lock.Unlock() + + m.items[key] = value +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..46a12ae --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,38 @@ +package cache + +import ( + "testing" + + "github.com/KarolosLykos/hackertea/internal/item" +) + +func TestCache_Get(t *testing.T) { + c := New() + + c.Set(1, &item.Item{ID: 1}) + + v, ok := c.Get(1) + if !ok { + t.Errorf("should find the key") + } + + if v.ID != 1 { + t.Errorf("the value should be 1") + } +} + +func TestCache_Set(t *testing.T) { + c := New() + + c.Set(1, &item.Item{ID: 1}) + + _, ok := c.Get(1) + if !ok { + t.Errorf("should be present") + } + + _, ok = c.Get(2) + if ok { + t.Errorf("should not be present") + } +} diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..e8151a4 --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,59 @@ +package client + +import ( + "context" + "fmt" + "io" + "net/http" +) + +// HttpClient interface that specifies the behavior of an HTTP client. +type HttpClient interface { + Get(ctx context.Context, suffix string) ([]byte, error) +} + +// Client struct that implements the HttpClient interface. +type Client struct { + baseURL string + c *http.Client +} + +// New returns a new Client instance with the given base URL and HTTP client +func New(baseURL string, c *http.Client) *Client { + return &Client{ + baseURL: baseURL, + c: c, + } +} + +// Get makes a GET request to the API with the given suffix and returns the response body as a []byte +func (c *Client) Get(ctx context.Context, suffix string) ([]byte, error) { + // Construct the full URL for the API endpoint. + uri := fmt.Sprintf("%s/%s", c.baseURL, suffix) + + // Create a new HTTP request with the given context, method, URL, and body. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err // If an error occurred while creating the request, return it + } + + // Enable HTTP keep-alive to improve performance. + req.Close = true + + res, err := c.c.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s", res.Status) + } + + resp, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + return resp, nil +} diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..3c3fcf1 --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,189 @@ +package client + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_Get(t *testing.T) { + t.Run("successful request", func(t *testing.T) { + body := "test response" + baseURL := "http://test.com" + path := "/test" + ctx := context.Background() + + // Create mock transport that returns a successful response with the test body. + transport := &mockTransport{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(body))), + }, + } + + c := New(baseURL, &http.Client{Transport: transport}) + + resp, err := c.Get(ctx, baseURL+path) + + assert.NoError(t, err) + assert.Equal(t, []byte(body), resp) + }) + + t.Run("nil context error", func(t *testing.T) { + baseURL := "http://test.com" + path := "/notfound" + + // Create mock transport that returns a not found error. + transport := &mockTransport{ + response: &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewReader(nil)), + }, + } + + c := New(baseURL, &http.Client{Transport: transport}) + + _, err := c.Get(nil, baseURL+path) + + assert.Error(t, err) + assert.ErrorContains(t, err, "nil Context") + }) + + t.Run("request with error", func(t *testing.T) { + baseURL := "http://test.com" + path := "/notfound" + ctx := context.Background() + + // Create mock transport that returns a not found error. + transport := &mockTransport{ + response: &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewReader(nil)), + }, + } + + c := New(baseURL, &http.Client{Transport: transport}) + + _, err := c.Get(ctx, baseURL+path) + + assert.Error(t, err) + }) + + t.Run("unsuccessful response", func(t *testing.T) { + baseURL := "http://test.com" + path := "/test" + ctx := context.Background() + + // Create mock transport that returns a internal server error. + transport := &mockTransport{ + response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(bytes.NewReader(nil)), + }, + } + + c := New(baseURL, &http.Client{Transport: transport}) + + _, err := c.Get(ctx, baseURL+path) + + assert.Error(t, err) + }) + + t.Run("timeout", func(t *testing.T) { + // Create a custom HTTP server with a delay handler that waits for longer than the timeout period. + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(2 * time.Second) + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + // Create an HTTP client with custom transport that uses the custom server. + tr := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + } + client := &http.Client{ + Timeout: 1 * time.Second, + Transport: tr, + } + + // Create a new client with the custom HTTP client. + c := New(ts.URL, client) + + // Call the Get method and expect an error due to the request timing out. + ctx := context.Background() + _, err := c.Get(ctx, "test") + + assert.Error(t, err) + urlError, ok := err.(*url.Error) + require.True(t, ok) + assert.ErrorContains(t, urlError, context.DeadlineExceeded.Error()) + }) + + t.Run("body read error", func(t *testing.T) { + // Create a custom HTTP server with a delay handler that waits for longer than the timeout period. + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(2 * time.Second) + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + // Create a mock response with an unreadable body. + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: errorReader{}, + } + + // Create an HTTP client with custom transport that uses the custom server. + tr := &mockTransport{ + response: resp, + } + client := &http.Client{ + Timeout: 1 * time.Second, + Transport: tr, + } + + // Create a new client with the custom HTTP client. + c := New(ts.URL, client) + + // Call the Get method and expect an error due to the request timing out. + ctx := context.Background() + _, err := c.Get(ctx, "test") + + assert.Error(t, err) + assert.ErrorContains(t, err, "readall error") + }) +} + +// errorReader is a custom io.Reader that always returns an error when Read is called. +type errorReader struct{} + +func (e errorReader) Read(p []byte) (n int, err error) { + return 0, fmt.Errorf("readall error") +} + +func (e errorReader) Close() error { + return nil +} + +// mockTransport is a mock http.RoundTripper that delays the response and returns a specified response. +type mockTransport struct { + delay time.Duration + response *http.Response +} + +// RoundTrip delays the response for the specified duration and returns the specified response. +func (t *mockTransport) RoundTrip(_ *http.Request) (*http.Response, error) { + time.Sleep(t.delay) + return t.response, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0a995f5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,169 @@ +package config + +import ( + "os" + + "github.com/adrg/xdg" + "gopkg.in/yaml.v3" +) + +const ( + defaultConfig = "hackertea/config.yaml" +) + +type Config struct { + Style Style `yaml:"style"` + Workers int `yaml:"workers"` +} + +type Style struct { + ListItem ListItem `yaml:"listItem"` + Visited AdaptiveColor `yaml:"visited"` + Window Window `yaml:"window"` + Tab Tab `yaml:"tab"` +} + +type ListItem struct { + NormalTitle AdaptiveColor `yaml:"normalTitle"` + NormalDesc AdaptiveColor `yaml:"normalDesc"` + SelectedTitle ListItemStyle `yaml:"selectedTitle"` + SelectedDesc AdaptiveColor `yaml:"selectedDesc"` + DimmedTitle AdaptiveColor `yaml:"dimmedTitle"` + DimmedDesc AdaptiveColor `yaml:"dimmedDesc"` + FilterMatch ListItemStyle `yaml:"filterMatch"` +} + +type ListItemStyle struct { + BorderForeground AdaptiveColor + Foreground AdaptiveColor +} + +type Window struct { + Border string `yaml:"border"` + Color AdaptiveColor `yaml:"color"` +} + +type Tab struct { + Color AdaptiveColor `yaml:"color"` +} + +type AdaptiveColor struct { + Dark string `yaml:"dark"` + Light string `yaml:"light"` +} + +// LoadConfig loads the configuration file. +// It first searches for the configuration file using the XDG Base Directory Specification. +// If the configuration file is found, it is loaded and parsed using the getConfig function. +// If the configuration file is not found, a default configuration is written to a new file +// using the initConfig function, and the default configuration is returned. +func LoadConfig() (*Config, error) { + configFilePath, err := xdg.SearchConfigFile(defaultConfig) + if err == nil { + return getConfig(configFilePath) + } + + return initConfig(defaultConfig) +} + +// getConfig reads a configuration file from a specified path and decodes +// it into a Config. +func getConfig(configPath string) (*Config, error) { + cFile, err := os.Open(configPath) + if err != nil { + return nil, err + } + + defer cFile.Close() + + cfg := &Config{} + if err = yaml.NewDecoder(cFile).Decode(cfg); err != nil { + return nil, err + } + + return cfg, nil +} + +// initConfig creates a new configuration file at the specified path and returns a Config struct with default values. +// The default values are set in the basicConfig function. +func initConfig(configPath string) (*Config, error) { + configFilePath, err := xdg.ConfigFile(configPath) + if err != nil { + return nil, err + } + + confB, _ := yaml.Marshal(basicConfig()) + if err = os.WriteFile(configFilePath, confB, 0o755); err != nil { + return nil, err + } + + return basicConfig(), nil +} + +// basicConfig returns the default configuration. +func basicConfig() *Config { + return &Config{ + Style: Style{ + ListItem: ListItem{ + NormalTitle: AdaptiveColor{ + Dark: "#E6EBE9", + Light: "#7D56F4", + }, + NormalDesc: AdaptiveColor{ + Dark: "#4f4f4f", + Light: "#7D56F4", + }, + SelectedTitle: ListItemStyle{ + BorderForeground: AdaptiveColor{ + Dark: "#2A8f69", + Light: "#7D56F4", + }, + Foreground: AdaptiveColor{ + Dark: "#2A8f69", + Light: "#7D56F4", + }, + }, + SelectedDesc: AdaptiveColor{ + Dark: "#4f4f4f", + Light: "#7D56F4", + }, + DimmedTitle: AdaptiveColor{ + Dark: "#874BFD", + Light: "#7D56F4", + }, + DimmedDesc: AdaptiveColor{ + Dark: "#874BFD", + Light: "#7D56F4", + }, + FilterMatch: ListItemStyle{ + BorderForeground: AdaptiveColor{ + Dark: "#2A8f69", + Light: "#7D56F4", + }, + Foreground: AdaptiveColor{ + Dark: "#2A8f69", + Light: "#7D56F4", + }, + }, + }, + Visited: AdaptiveColor{ + Dark: "#777777", + Light: "#777777", + }, + Window: Window{ + Border: "normal", + Color: AdaptiveColor{ + Dark: "#2A8f69", + Light: "#7D56F4", + }, + }, + Tab: Tab{ + Color: AdaptiveColor{ + Dark: "#2A8f69", + Light: "#7D56F4", + }, + }, + }, + Workers: 10, + } +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go new file mode 100644 index 0000000..bb09354 --- /dev/null +++ b/internal/constants/constants.go @@ -0,0 +1,47 @@ +package constants + +import ( + "strings" + "time" +) + +var CurrentTime = time.Now() + +type ItemType string + +func (i ItemType) Title() string { + return strings.ToTitle(string(i)) +} + +const ( + BaseURL = "https://hacker-news.firebaseio.com/v0" + NewSuffix = "newstories.json" + TopSuffix = "topstories.json" + BestSuffix = "beststories.json" + SingleSuffix = "item/%s.json" + + TabTop = "Top" + TabNew = "New" + TabBest = "Best" + Linux = "linux" + Windows = "windows" + Darwing = "darwing" +) + +var Items = struct { + NewItems ItemType + TopItems ItemType + BestItems ItemType + SingleItem ItemType +}{ + NewItems: "new", + TopItems: "top", + BestItems: "best", + SingleItem: "item", +} + +const ( + RoundedBorder = "rounded" + ThickBorder = "thick" + DoubleBorder = "double" +) diff --git a/internal/constants/constants_test.go b/internal/constants/constants_test.go new file mode 100644 index 0000000..2ca6d76 --- /dev/null +++ b/internal/constants/constants_test.go @@ -0,0 +1,25 @@ +package constants + +import ( + "testing" +) + +func TestItemType_Title(t *testing.T) { + items := Items + + if items.TopItems.Title() != "TOP" { + t.Errorf("wanted Top got %v", items.TopItems.Title()) + } + + if items.BestItems.Title() != "BEST" { + t.Errorf("wanted Best got %v", items.BestItems.Title()) + } + + if items.NewItems.Title() != "NEW" { + t.Errorf("wanted New got %v", items.NewItems.Title()) + } + + if items.SingleItem.Title() != "ITEM" { + t.Errorf("wanted Item got %v", items.SingleItem.Title()) + } +} diff --git a/internal/hn/hn.go b/internal/hn/hn.go new file mode 100644 index 0000000..02bf0f3 --- /dev/null +++ b/internal/hn/hn.go @@ -0,0 +1,89 @@ +package hn + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + + "github.com/KarolosLykos/hackertea/internal/cache" + "github.com/KarolosLykos/hackertea/internal/client" + "github.com/KarolosLykos/hackertea/internal/constants" + "github.com/KarolosLykos/hackertea/internal/item" +) + +var ErrInvalidItemType = errors.New("invalid item type") + +type Service interface { + GetItems(ctx context.Context, item constants.ItemType) ([]int, error) + GetItem(ctx context.Context, id int) (*item.Item, error) +} + +type HN struct { + c client.HttpClient + cache cache.Cache +} + +func New(c client.HttpClient, cache cache.Cache) *HN { + return &HN{c: c, cache: cache} +} + +func (h *HN) GetItems(ctx context.Context, item constants.ItemType) ([]int, error) { + suffix, err := getSuffix(item) + if err != nil { + return nil, err + } + + resp, err := h.c.Get(ctx, suffix) + if err != nil { + return nil, err + } + + items := make([]int, 0) + if err = json.Unmarshal(resp, &items); err != nil { + return nil, err + } + + return items, nil +} + +func (h *HN) GetItem(ctx context.Context, id int) (*item.Item, error) { + v, ok := h.cache.Get(id) + if ok { + return v, nil + } + + suffix, _ := getSuffix(constants.Items.SingleItem) + + uri := fmt.Sprintf(suffix, strconv.Itoa(id)) + + resp, err := h.c.Get(ctx, uri) + if err != nil { + return nil, err + } + + i := &item.Item{} + if err = json.Unmarshal(resp, i); err != nil { + return nil, err + } + + h.cache.Set(id, i) + + return i, nil +} + +func getSuffix(item constants.ItemType) (string, error) { + switch item { + case constants.Items.NewItems: + return constants.NewSuffix, nil + case constants.Items.TopItems: + return constants.TopSuffix, nil + case constants.Items.BestItems: + return constants.BestSuffix, nil + case constants.Items.SingleItem: + return constants.SingleSuffix, nil + default: + return "", ErrInvalidItemType + } +} diff --git a/internal/hn/hn_test.go b/internal/hn/hn_test.go new file mode 100644 index 0000000..c6e2ce3 --- /dev/null +++ b/internal/hn/hn_test.go @@ -0,0 +1,175 @@ +package hn + +import ( + "context" + "errors" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/KarolosLykos/hackertea/internal/constants" + "github.com/KarolosLykos/hackertea/internal/item" + "github.com/KarolosLykos/hackertea/internal/mock/cache" + "github.com/KarolosLykos/hackertea/internal/mock/client" +) + +func TestGetSuffix(t *testing.T) { + + tt := []struct { + name string + value constants.ItemType + suffix string + err error + }{ + {name: "New items", value: constants.Items.NewItems, suffix: constants.NewSuffix}, + {name: "Top items", value: constants.Items.TopItems, suffix: constants.TopSuffix}, + {name: "Best items", value: constants.Items.BestItems, suffix: constants.BestSuffix}, + {name: "Single item", value: constants.Items.SingleItem, suffix: constants.SingleSuffix}, + {name: "Error", value: "", err: ErrInvalidItemType}, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + suffix, err := getSuffix(tc.value) + if err != nil && tc.err != nil { + assert.ErrorIs(t, err, ErrInvalidItemType) + } else { + require.NoError(t, err) + assert.Equal(t, tc.suffix, suffix) + } + }) + } +} + +func TestGetItems(t *testing.T) { + tt := []struct { + name string + itemType constants.ItemType + clientStub func(client *mock_client.MockHttpClient) + expectedItems []int + expectedError error + }{ + {name: "wrong item type", itemType: "", clientStub: func(client *mock_client.MockHttpClient) {}, expectedItems: nil, expectedError: ErrInvalidItemType}, + {name: "success case", itemType: constants.Items.TopItems, clientStub: func(client *mock_client.MockHttpClient) { + client.EXPECT().Get(gomock.Any(), gomock.Any()).Times(1).Return([]byte(`[123, 456, 789]`), nil) + }, expectedItems: []int{123, 456, 789}, expectedError: nil}, + {name: "invalid json", itemType: constants.Items.TopItems, clientStub: func(client *mock_client.MockHttpClient) { + client.EXPECT().Get(gomock.Any(), gomock.Any()).Times(1).Return([]byte(`{invalid json}`), nil) + }, expectedItems: nil, expectedError: errors.New("invalid character")}, + {name: "client error response", itemType: constants.Items.TopItems, clientStub: func(client *mock_client.MockHttpClient) { + client.EXPECT().Get(gomock.Any(), gomock.Any()).Times(1).Return(nil, errors.New("client error")) + }, expectedItems: nil, expectedError: errors.New("client error")}, + } + + for _, tc := range tt { + // call the GetItems method and check the result + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // Set up mock client response + mockClient := mock_client.NewMockHttpClient(ctrl) + tc.clientStub(mockClient) + + hn := New(mockClient, nil) + items, err := hn.GetItems(context.Background(), tc.itemType) + if err != nil && tc.expectedError != nil { + assert.ErrorContains(t, err, tc.expectedError.Error()) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expectedItems, items) + } + }) + } +} + +func TestHN_GetItem(t *testing.T) { + testCases := []struct { + name string + id int + clientStub func(client *mock_client.MockHttpClient) + cacheStub func(cache *mock_cache.MockCache) + response []byte + respErr error + expected *item.Item + expectErr bool + }{ + { + name: "cache hit", + id: 123, + clientStub: func(client *mock_client.MockHttpClient) { + client.EXPECT().Get(gomock.Any(), gomock.Any()).Times(0) + }, + cacheStub: func(cache *mock_cache.MockCache) { + cache.EXPECT().Get(gomock.Any()).Times(1).Return(&item.Item{ID: 123}, true) + }, + response: nil, + expected: &item.Item{ID: 123}, + expectErr: false, + }, + { + name: "cache miss", + id: 123, + clientStub: func(client *mock_client.MockHttpClient) { + client.EXPECT().Get(gomock.Any(), gomock.Any()).Times(1).Return([]byte(`{"id":123}`), nil) + }, + cacheStub: func(cache *mock_cache.MockCache) { + cache.EXPECT().Get(gomock.Any()).Times(1).Return(nil, false) + cache.EXPECT().Set(gomock.Any(), gomock.Any()).Times(1) + }, + expected: &item.Item{ID: 123}, + expectErr: false, + }, + { + name: "invalid response", + id: 123, + clientStub: func(client *mock_client.MockHttpClient) { + client.EXPECT().Get(gomock.Any(), gomock.Any()).Times(1).Return([]byte(`{"invalid"`), nil) + }, + cacheStub: func(cache *mock_cache.MockCache) { cache.EXPECT().Get(gomock.Any()).Times(1).Return(nil, false) }, + expected: nil, + expectErr: true, + }, + { + name: "GET error response", + id: 123, + clientStub: func(client *mock_client.MockHttpClient) { + client.EXPECT().Get(gomock.Any(), gomock.Any()).Times(1).Return(nil, errors.New("get error")) + }, + cacheStub: func(cache *mock_cache.MockCache) { cache.EXPECT().Get(gomock.Any()).Times(1).Return(nil, false) }, + expected: nil, + expectErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // Set up mock client response + mockCache := mock_cache.NewMockCache(ctrl) + mockClient := mock_client.NewMockHttpClient(ctrl) + + tc.cacheStub(mockCache) + tc.clientStub(mockClient) + + h := &HN{c: mockClient, cache: mockCache} + + // Call GetItem + i, err := h.GetItem(context.Background(), tc.id) + + // Check error + if tc.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + // Check item + require.Equal(t, tc.expected, i) + }) + } +} diff --git a/internal/item/item.go b/internal/item/item.go new file mode 100644 index 0000000..920f6bd --- /dev/null +++ b/internal/item/item.go @@ -0,0 +1,70 @@ +package item + +import ( + "fmt" + "time" + + "github.com/charmbracelet/lipgloss" + + "github.com/KarolosLykos/hackertea/internal/constants" + "github.com/KarolosLykos/hackertea/internal/tui/theme" +) + +type Item struct { + ID int `json:"id"` + Parent int `json:"parent"` + Kids []int `json:"kids"` + Descendants int `json:"descendants"` + Parts []int `json:"parts"` + Score int `json:"score"` + Timestamp int `json:"time"` + By string `json:"by"` + Type string `json:"type"` + Titl string `json:"title"` + Text string `json:"text"` + URL string `json:"url"` + Dead bool `json:"dead"` + Deleted bool `json:"deleted"` + Visited bool +} + +func (i *Item) Time() time.Time { + return time.Unix(int64(i.Timestamp), 0) +} + +func (i *Item) Title() string { + text := fmt.Sprintf("%s", i.Titl) + if i.URL != "" { + text = fmt.Sprintf("%s (%s)", i.Titl, i.URL) + } + + if i.Visited { + return visitedStyle().Render(text) + } + + return text +} + +func (i *Item) Description() string { + desc := fmt.Sprintf( + "%d points by %s %s ago %d comments", + i.Score, + i.By, + constants.CurrentTime.Sub(i.Time()).Round(time.Second).String(), + i.Descendants, + ) + + if i.Visited { + return visitedStyle().Render(desc) + } + + return desc +} + +func (i *Item) FilterValue() string { return i.Titl } + +func visitedStyle() lipgloss.Style { + t, _ := theme.GetTheme() + + return t.Visited +} diff --git a/internal/item/item_test.go b/internal/item/item_test.go new file mode 100644 index 0000000..24d526b --- /dev/null +++ b/internal/item/item_test.go @@ -0,0 +1,52 @@ +package item + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/KarolosLykos/hackertea/internal/constants" +) + +func TestItem_Time(t *testing.T) { + // Test successful conversion + item := Item{ + Timestamp: int(time.Date(2021, 5, 25, 0, 0, 0, 0, time.Local).Unix()), + } // May 25, 2021 12:00:00 AM UTC + expected := time.Date(2021, 5, 25, 0, 0, 0, 0, time.Local) + actual := item.Time() + assert.Equal(t, expected, actual) +} + +func TestItem_Title(t *testing.T) { + item := Item{Titl: "Test Title", URL: "https://example.com"} + expected := "Test Title (https://example.com)" + assert.Equal(t, expected, item.Title()) + + item.Visited = true + expected = visitedStyle().Render("Test Title (https://example.com)") + assert.Equal(t, expected, item.Title()) +} + +func TestItem_Description(t *testing.T) { + item := Item{ + Score: 42, + By: "testuser", + Timestamp: int(time.Date(2023, 4, 30, 0, 0, 0, 0, time.Local).Unix()), + Descendants: 3, + } + elapsed := constants.CurrentTime.Sub(item.Time()).Round(time.Second).String() + expected := fmt.Sprintf("42 points by testuser %s ago 3 comments", elapsed) + assert.Equal(t, expected, item.Description()) + + item.Visited = true + expected = visitedStyle().Render(fmt.Sprintf("42 points by testuser %s ago 3 comments", elapsed)) + assert.Equal(t, expected, item.Description()) +} + +func TestItem_FilterValue(t *testing.T) { + item := Item{Titl: "Test Title"} + assert.Equal(t, "Test Title", item.FilterValue()) +} diff --git a/internal/mock/cache/mock_cache.go b/internal/mock/cache/mock_cache.go new file mode 100644 index 0000000..c5aea7f --- /dev/null +++ b/internal/mock/cache/mock_cache.go @@ -0,0 +1,62 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: internal/cache/cache.go + +// Package mock_cache is a generated GoMock package. +package mock_cache + +import ( + reflect "reflect" + + item "github.com/KarolosLykos/hackertea/internal/item" + gomock "github.com/golang/mock/gomock" +) + +// MockCache is a mock of Cache interface. +type MockCache struct { + ctrl *gomock.Controller + recorder *MockCacheMockRecorder +} + +// MockCacheMockRecorder is the mock recorder for MockCache. +type MockCacheMockRecorder struct { + mock *MockCache +} + +// NewMockCache creates a new mock instance. +func NewMockCache(ctrl *gomock.Controller) *MockCache { + mock := &MockCache{ctrl: ctrl} + mock.recorder = &MockCacheMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCache) EXPECT() *MockCacheMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockCache) Get(key int) (*item.Item, bool) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", key) + ret0, _ := ret[0].(*item.Item) + ret1, _ := ret[1].(bool) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockCacheMockRecorder) Get(key interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockCache)(nil).Get), key) +} + +// Set mocks base method. +func (m *MockCache) Set(key int, value *item.Item) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Set", key, value) +} + +// Set indicates an expected call of Set. +func (mr *MockCacheMockRecorder) Set(key, value interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockCache)(nil).Set), key, value) +} diff --git a/internal/mock/client/mock_client.go b/internal/mock/client/mock_client.go new file mode 100644 index 0000000..e935376 --- /dev/null +++ b/internal/mock/client/mock_client.go @@ -0,0 +1,50 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: internal/client/client.go + +// Package mock_client is a generated GoMock package. +package mock_client + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockHttpClient is a mock of HttpClient interface. +type MockHttpClient struct { + ctrl *gomock.Controller + recorder *MockHttpClientMockRecorder +} + +// MockHttpClientMockRecorder is the mock recorder for MockHttpClient. +type MockHttpClientMockRecorder struct { + mock *MockHttpClient +} + +// NewMockHttpClient creates a new mock instance. +func NewMockHttpClient(ctrl *gomock.Controller) *MockHttpClient { + mock := &MockHttpClient{ctrl: ctrl} + mock.recorder = &MockHttpClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockHttpClient) EXPECT() *MockHttpClientMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockHttpClient) Get(ctx context.Context, suffix string) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ctx, suffix) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockHttpClientMockRecorder) Get(ctx, suffix interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockHttpClient)(nil).Get), ctx, suffix) +} diff --git a/internal/mock/hn/mock_hn.go b/internal/mock/hn/mock_hn.go new file mode 100644 index 0000000..1706a6b --- /dev/null +++ b/internal/mock/hn/mock_hn.go @@ -0,0 +1,67 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: internal/hn/hn.go + +// Package mock_hn is a generated GoMock package. +package mock_hn + +import ( + context "context" + reflect "reflect" + + constants "github.com/KarolosLykos/hackertea/internal/constants" + item "github.com/KarolosLykos/hackertea/internal/item" + gomock "github.com/golang/mock/gomock" +) + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// GetItem mocks base method. +func (m *MockService) GetItem(ctx context.Context, id int) (*item.Item, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetItem", ctx, id) + ret0, _ := ret[0].(*item.Item) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetItem indicates an expected call of GetItem. +func (mr *MockServiceMockRecorder) GetItem(ctx, id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetItem", reflect.TypeOf((*MockService)(nil).GetItem), ctx, id) +} + +// GetItems mocks base method. +func (m *MockService) GetItems(ctx context.Context, item constants.ItemType) ([]int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetItems", ctx, item) + ret0, _ := ret[0].([]int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetItems indicates an expected call of GetItems. +func (mr *MockServiceMockRecorder) GetItems(ctx, item interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetItems", reflect.TypeOf((*MockService)(nil).GetItems), ctx, item) +} diff --git a/internal/tui/keys/keys.go b/internal/tui/keys/keys.go new file mode 100644 index 0000000..c59c9e4 --- /dev/null +++ b/internal/tui/keys/keys.go @@ -0,0 +1,50 @@ +package keys + +import ( + "github.com/charmbracelet/bubbles/key" +) + +type listKeyMap struct { + nextPage key.Binding + previousPage key.Binding + nextTab key.Binding + previousTab key.Binding + fetchNextPage key.Binding +} + +func NewListKeyMap() *listKeyMap { + return &listKeyMap{ + previousPage: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "left"), + ), + nextPage: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "right"), + ), + nextTab: key.NewBinding( + key.WithKeys("t", "tab"), + key.WithHelp("t/Tab", "next tab"), + ), + previousTab: key.NewBinding( + key.WithKeys("T", "shift+tab"), + key.WithHelp("T/Shift+Tab", "previous tab"), + ), + fetchNextPage: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "next page"), + ), + } +} + +func (l *listKeyMap) KeyBindings() func() []key.Binding { + return func() []key.Binding { + return []key.Binding{ + l.nextPage, + l.previousPage, + l.nextTab, + l.previousTab, + l.fetchNextPage, + } + } +} diff --git a/internal/tui/keys/keys_test.go b/internal/tui/keys/keys_test.go new file mode 100644 index 0000000..beb0592 --- /dev/null +++ b/internal/tui/keys/keys_test.go @@ -0,0 +1,27 @@ +package keys + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestListKeyMap(t *testing.T) { + listKeys := NewListKeyMap() + + // Test NewListKeyMap + assert.NotNil(t, listKeys.nextPage) + assert.NotNil(t, listKeys.previousPage) + assert.NotNil(t, listKeys.nextTab) + assert.NotNil(t, listKeys.previousTab) + assert.NotNil(t, listKeys.fetchNextPage) + + // Test KeyBindings + bindings := listKeys.KeyBindings() + assert.Equal(t, 5, len(bindings())) + assert.Contains(t, bindings(), listKeys.nextPage) + assert.Contains(t, bindings(), listKeys.previousPage) + assert.Contains(t, bindings(), listKeys.nextTab) + assert.Contains(t, bindings(), listKeys.previousTab) + assert.Contains(t, bindings(), listKeys.fetchNextPage) +} diff --git a/internal/tui/model/commands.go b/internal/tui/model/commands.go new file mode 100644 index 0000000..e1adea3 --- /dev/null +++ b/internal/tui/model/commands.go @@ -0,0 +1,36 @@ +package model + +import ( + tea "github.com/charmbracelet/bubbletea" + + "github.com/KarolosLykos/hackertea/internal/utils" +) + +func (m model) initCmd() tea.Cmd { + return func() tea.Msg { + docH, docV := m.theme.Doc.GetFrameSize() + winH, _ := m.theme.Window.GetFrameSize() + contH, contV := m.theme.ListContent.GetFrameSize() + for i := range m.tabs { + m.TabContent[i].SetSize( + m.width-docH-winH-contH, + m.height-docV-contV, + ) + m.visited[i] = map[int]bool{} + m.TabContent[i].SetItems( + utils.FetchStories(m.ctx, m.client, m.ids, m.cfg.Workers, i, 0, m.TabContent[i].Paginator.PerPage), + ) + } + + return initMsg{} + } +} + +func (m model) next(tabID, perPage, page int) tea.Cmd { + return func() tea.Msg { + n := next{} + n.items = utils.FetchStories(m.ctx, m.client, m.ids, m.cfg.Workers, tabID, perPage*page, perPage*page+perPage) + + return n + } +} diff --git a/internal/tui/model/messages.go b/internal/tui/model/messages.go new file mode 100644 index 0000000..9d70f8e --- /dev/null +++ b/internal/tui/model/messages.go @@ -0,0 +1,11 @@ +package model + +import ( + "github.com/charmbracelet/bubbles/list" +) + +type initMsg struct{} + +type next struct { + items []list.Item +} diff --git a/internal/tui/model/model.go b/internal/tui/model/model.go new file mode 100644 index 0000000..62e6898 --- /dev/null +++ b/internal/tui/model/model.go @@ -0,0 +1,236 @@ +package model + +import ( + "context" + "runtime" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/KarolosLykos/hackertea/internal/config" + "github.com/KarolosLykos/hackertea/internal/constants" + "github.com/KarolosLykos/hackertea/internal/hn" + "github.com/KarolosLykos/hackertea/internal/item" + "github.com/KarolosLykos/hackertea/internal/tui/keys" + "github.com/KarolosLykos/hackertea/internal/tui/theme" + "github.com/KarolosLykos/hackertea/internal/utils" +) + +type model struct { + ctx context.Context + cancel context.CancelFunc + cfg *config.Config + theme *theme.Theme + tabs []string + TabContent []list.Model + activeTab int + loading bool + client *hn.HN + spinner spinner.Model + ids [][]int + visited []map[int]bool + width, height int +} + +func New(ctx context.Context, client *hn.HN) (*model, error) { + newCtx, cancel := context.WithCancel(ctx) + + cfg, err := config.LoadConfig() + if err != nil { + cancel() + return nil, err + } + + th, err := theme.NewTheme() + if err != nil { + cancel() + return nil, err + } + + s := spinner.New() + s.Spinner = spinner.Points + + topStories, err := client.GetItems(newCtx, constants.Items.TopItems) + if err != nil { + cancel() + return nil, err + } + + bestStories, err := client.GetItems(newCtx, constants.Items.BestItems) + if err != nil { + cancel() + return nil, err + } + + newStories, err := client.GetItems(newCtx, constants.Items.NewItems) + if err != nil { + cancel() + return nil, err + } + + m := &model{ + cfg: cfg, + ctx: newCtx, + cancel: cancel, + theme: th, + ids: [][]int{topStories, newStories, bestStories}, + client: client, + spinner: s, + visited: make([]map[int]bool, 3), + tabs: []string{constants.TabTop, constants.TabNew, constants.TabBest}, + } + + m.TabContent = m.createTabContent(3) + + listKeys := keys.NewListKeyMap() + + for i := 0; i < len(m.TabContent); i++ { + m.TabContent[i].AdditionalShortHelpKeys = listKeys.KeyBindings() + m.TabContent[i].AdditionalFullHelpKeys = listKeys.KeyBindings() + } + + return m, nil +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case next: + m.loading = false + items := append(m.TabContent[m.activeTab].Items(), msg.items...) + m.TabContent[m.activeTab].SetItems(items) + m.visited[m.activeTab][m.TabContent[m.activeTab].Paginator.Page] = true + m.TabContent[m.activeTab].Paginator.NextPage() + case tea.KeyMsg: + // Don't match any of the keys below if we're actively filtering. + if m.TabContent[m.activeTab].FilterState() == list.Filtering { + break + } + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case tea.KeyEnter.String(): + if v, ok := m.TabContent[m.activeTab].SelectedItem().(*item.Item); ok { + if err := utils.Open(v.URL, runtime.GOOS); err != nil { + return m, nil + } + v.Visited = true + } + case "n": + if !m.visited[m.activeTab][m.TabContent[m.activeTab].Paginator.Page] { + m.loading = true + return m, m.next(m.activeTab, m.TabContent[m.activeTab].Paginator.PerPage, m.TabContent[m.activeTab].Paginator.Page+1) + } + case "t", "tab": + m.activeTab = utils.Min(m.activeTab+1, len(m.tabs)-1) + case "T", "shift+tab": + m.activeTab = utils.Max(m.activeTab-1, 0) + } + case initMsg: + m.loading = false + + case spinner.TickMsg: + if !m.loading { + return m, nil + } + + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.loading = true + return m, tea.Batch( + m.spinner.Tick, + m.initCmd(), + ) + } + + m.TabContent[m.activeTab], cmd = m.TabContent[m.activeTab].Update(msg) + + return m, cmd +} + +func (m model) View() string { + doc := strings.Builder{} + doc.Reset() + + windowFrameSize := m.theme.Window.GetHorizontalFrameSize() + docFrameSize := m.theme.Doc.GetHorizontalFrameSize() + var renderedTabs []string + + renderedTabs = append(renderedTabs, m.theme.TitleTab.Render("HackerTea")) + + for i, t := range m.tabs { + var style lipgloss.Style + isFirst, isLast, isActive := i == 0, i == len(m.tabs)-1, i == m.activeTab + if isActive { + style = m.theme.ActiveTab.Copy() + } else { + style = m.theme.InActiveTab.Copy() + } + border, _, _, _, _ := style.GetBorder() + if isFirst && isActive { + border.BottomLeft = "┘" + } else if isFirst && !isActive { + border.BottomLeft = "┴" + } else if isLast && isActive { + border.BottomRight = "└" + } else if isLast && !isActive { + border.BottomRight = "┴" + } + + style = style.Border(border) + renderedTabs = append(renderedTabs, style.Render(t)) + } + + row := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) + gap := m.theme.GapTab.Render( + strings.Repeat(" ", utils.Max(0, m.width-windowFrameSize-docFrameSize-lipgloss.Width(row))), + ) + + row = lipgloss.JoinHorizontal(lipgloss.Bottom, row, gap) + doc.WriteString(row) + doc.WriteString("\n") + + m.theme.Window.Width(m.width - windowFrameSize - docFrameSize - 1) + + if m.loading { + doc.WriteString(m.theme.Window.Render(m.spinner.View())) + } else { + doc.WriteString(m.theme.Window.Render(m.TabContent[m.activeTab].View())) + } + + return m.theme.Doc.Render(doc.String()) +} + +func (m model) createTabContent(tabs int) []list.Model { + tabContent := make([]list.Model, tabs) + + delegate := list.NewDefaultDelegate() + delegate.Styles = list.DefaultItemStyles{ + NormalTitle: m.theme.NormalTitle, + NormalDesc: m.theme.NormalDesc, + SelectedTitle: m.theme.SelectedTitle, + SelectedDesc: m.theme.SelectedDesc, + DimmedTitle: m.theme.DimmedTitle, + DimmedDesc: m.theme.DimmedDesc, + FilterMatch: m.theme.FilterMatch, + } + + for i := 0; i < tabs; i++ { + l := list.New(make([]list.Item, 0), delegate, 0, 0) + l.SetShowTitle(false) + l.SetShowStatusBar(false) + + tabContent[i] = l + } + + return tabContent +} diff --git a/internal/tui/style/style.go b/internal/tui/style/style.go new file mode 100644 index 0000000..d609a27 --- /dev/null +++ b/internal/tui/style/style.go @@ -0,0 +1,181 @@ +package style + +import ( + "github.com/charmbracelet/lipgloss" + + "github.com/KarolosLykos/hackertea/internal/constants" +) + +func DocStyle() lipgloss.Style { + + return lipgloss.NewStyle(). + Padding(1, 2, 1, 2) +} + +func WindowStyle(light, dark, border string) lipgloss.Style { + highlightColor := lipgloss.AdaptiveColor{ + Light: light, + Dark: dark, + } + + borderStyle := lipgloss.NormalBorder() + + switch border { + case constants.RoundedBorder: + borderStyle = lipgloss.RoundedBorder() + case constants.ThickBorder: + borderStyle = lipgloss.ThickBorder() + case constants.DoubleBorder: + borderStyle = lipgloss.DoubleBorder() + } + + return lipgloss.NewStyle(). + BorderForeground(highlightColor). + Padding(2, 2). + Border(borderStyle).UnsetBorderTop() +} + +func TabGapStyle(light, dark, border string) lipgloss.Style { + highlightColor := lipgloss.AdaptiveColor{ + Light: light, + Dark: dark, + } + tabGapBorder := tabGapBorderWithBottom("┴", border) + + return lipgloss.NewStyle().Border(tabGapBorder, true). + BorderForeground(highlightColor). + Padding(0, 0). + BorderTop(false). + BorderLeft(false) +} + +func TitleTabStyle(light, dark, border string) lipgloss.Style { + activeTabBorder := tabBorderWithBottom("├", "─", "┴", border) + + highlightColor := lipgloss.AdaptiveColor{ + Light: light, + Dark: dark, + } + + return lipgloss.NewStyle(). + Border(activeTabBorder, true). + BorderForeground(highlightColor). + Foreground(highlightColor). + Padding(0, 1) +} + +func ActiveTabStyle(light, dark, border string) lipgloss.Style { + activeTabBorder := tabBorderWithBottom("┘", " ", "└", border) + highlightColor := lipgloss.AdaptiveColor{ + Light: light, + Dark: dark, + } + + return lipgloss.NewStyle(). + Border(activeTabBorder, true). + BorderForeground(highlightColor). + Padding(0, 1) +} + +func InActiveTabStyle(light, dark, border string) lipgloss.Style { + inActiveTabBorder := tabBorderWithBottom("┴", "─", "┴", border) + highlightColor := lipgloss.AdaptiveColor{ + Light: light, + Dark: dark, + } + + return lipgloss.NewStyle(). + Border(inActiveTabBorder, true). + BorderForeground(highlightColor). + Padding(0, 1) +} + +func ItemNormalTitleStyle(light, dark string) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: light, Dark: dark}). + Padding(0, 0, 0, 2) +} + +func ItemNormalDescStyle(light, dark string) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: light, Dark: dark}). + Padding(0, 0, 0, 2) +} + +func ItemSelectedTitleStyle(bLight, bDark, light, dark string) lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(lipgloss.AdaptiveColor{Light: bLight, Dark: bDark}). + Foreground(lipgloss.AdaptiveColor{Light: light, Dark: dark}). + Padding(0, 0, 0, 1) +} + +func ItemSelectedDescStyle(bLight, bDark, light, dark string) lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(lipgloss.AdaptiveColor{Light: bLight, Dark: bDark}). + Foreground(lipgloss.AdaptiveColor{Light: light, Dark: dark}). + Padding(0, 0, 0, 1) +} + +func ItemDimmedTitleStyle(light, dark string) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: light, Dark: dark}). + Padding(0, 0, 0, 2) +} + +func ItemDimmedDescStyle(light, dark string) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: light, Dark: dark}). + Padding(0, 0, 0, 2) +} + +func VisitedStyle(light, dark string) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: light, Dark: dark}) +} + +func FilterMatchedStyle(bLight, bDark, light, dark string) lipgloss.Style { + return lipgloss.NewStyle(). + Background(lipgloss.AdaptiveColor{Light: bLight, Dark: bDark}). + Foreground(lipgloss.AdaptiveColor{Light: light, Dark: dark}). + Underline(true) +} + +func tabBorderWithBottom(left, middle, right, border string) lipgloss.Border { + borderStyle := lipgloss.RoundedBorder() + + switch border { + case constants.RoundedBorder: + borderStyle = lipgloss.RoundedBorder() + case constants.ThickBorder: + borderStyle = lipgloss.ThickBorder() + case constants.DoubleBorder: + borderStyle = lipgloss.DoubleBorder() + } + + borderStyle.BottomLeft = left + borderStyle.Bottom = middle + borderStyle.BottomRight = right + + return borderStyle +} + +func tabGapBorderWithBottom(left, border string) lipgloss.Border { + borderStyle := lipgloss.RoundedBorder() + + switch border { + case constants.RoundedBorder: + borderStyle = lipgloss.RoundedBorder() + case constants.ThickBorder: + borderStyle = lipgloss.ThickBorder() + case constants.DoubleBorder: + borderStyle = lipgloss.DoubleBorder() + } + + borderStyle.BottomLeft = left + borderStyle.BottomRight = borderStyle.TopRight + borderStyle.Right = "" + + return borderStyle +} diff --git a/internal/tui/style/style_test.go b/internal/tui/style/style_test.go new file mode 100644 index 0000000..506ea07 --- /dev/null +++ b/internal/tui/style/style_test.go @@ -0,0 +1,431 @@ +package style + +import ( + "testing" + + "github.com/KarolosLykos/hackertea/internal/constants" + "github.com/charmbracelet/lipgloss" + "github.com/stretchr/testify/assert" +) + +func TestDocStyle(t *testing.T) { + docStyle := DocStyle() + assert.Equal(t, lipgloss.NewStyle().Padding(1, 2, 1, 2), docStyle) +} + +func TestTabGapStyle(t *testing.T) { + + tt := []struct { + name string + light, dark, border string + expectedBorder lipgloss.Border + }{ + { + name: "default border", + light: "#FFFFFF", + dark: "#000000", + border: "", + expectedBorder: lipgloss.RoundedBorder(), + }, + { + name: "double border", + light: "#FFFFFF", + dark: "#000000", + border: constants.DoubleBorder, + expectedBorder: lipgloss.DoubleBorder(), + }, + { + name: "rounded border", + light: "#FFFFFF", + dark: "#000000", + border: constants.RoundedBorder, + expectedBorder: lipgloss.RoundedBorder(), + }, + { + name: "thick border", + light: "#FFFFFF", + dark: "#000000", + border: constants.ThickBorder, + expectedBorder: lipgloss.ThickBorder(), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + tabGapStyle := TabGapStyle(tc.light, tc.dark, tc.border) + + assert.Equal(t, + lipgloss.AdaptiveColor{Light: tc.light, Dark: tc.dark}, + tabGapStyle.GetBorderRightForeground()) + + assert.Equal(t, tc.expectedBorder.Top, tabGapStyle.GetBorderStyle().Top) + }) + } +} + +func TestActiveTabStyle(t *testing.T) { + + tt := []struct { + name string + light, dark, border string + expectedBorder lipgloss.Border + }{ + { + name: "default border", + light: "#FFFFFF", + dark: "#000000", + border: "", + expectedBorder: lipgloss.RoundedBorder(), + }, + { + name: "double border", + light: "#FFFFFF", + dark: "#000000", + border: constants.DoubleBorder, + expectedBorder: lipgloss.DoubleBorder(), + }, + { + name: "rounded border", + light: "#FFFFFF", + dark: "#000000", + border: constants.RoundedBorder, + expectedBorder: lipgloss.RoundedBorder(), + }, + { + name: "thick border", + light: "#FFFFFF", + dark: "#000000", + border: constants.ThickBorder, + expectedBorder: lipgloss.ThickBorder(), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + activeTabStyle := ActiveTabStyle(tc.light, tc.dark, tc.border) + + assert.Equal(t, + lipgloss.AdaptiveColor{Light: tc.light, Dark: tc.dark}, + activeTabStyle.GetBorderRightForeground()) + + assert.Equal(t, tc.expectedBorder.Top, activeTabStyle.GetBorderStyle().Top) + }) + } +} + +func TestTabBorderWithBottom(t *testing.T) { + + tt := []struct { + name string + left, middle, right, border string + expectedBorder lipgloss.Border + }{ + { + name: "default border", + left: "┴", + middle: "─", + right: "┐", + border: "", + expectedBorder: lipgloss.RoundedBorder(), + }, + { + name: "double border", + left: "┴", + middle: "─", + right: "┐", + border: constants.DoubleBorder, + expectedBorder: lipgloss.DoubleBorder(), + }, + { + name: "rounded border", + left: "┴", + middle: "─", + right: "┐", + border: constants.RoundedBorder, + expectedBorder: lipgloss.RoundedBorder(), + }, + { + name: "thick border", + left: "┴", + middle: "─", + right: "┐", + border: constants.ThickBorder, + expectedBorder: lipgloss.ThickBorder(), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + tabStyle := tabBorderWithBottom(tc.left, tc.middle, tc.right, tc.border) + + assert.Equal(t, tc.left, tabStyle.BottomLeft) + assert.Equal(t, tc.middle, tabStyle.Bottom) + assert.Equal(t, tc.right, tabStyle.BottomRight) + assert.Equal(t, tc.expectedBorder.Top, tabStyle.Top) + }) + } +} + +func TestWindowStyle(t *testing.T) { + + tt := []struct { + name string + light, dark, border string + expectedBorder lipgloss.Border + }{ + { + name: "default border", + light: "#FFFFFF", + dark: "#000000", + border: "", + expectedBorder: lipgloss.RoundedBorder(), + }, + { + name: "double border", + light: "#FFFFFF", + dark: "#000000", + border: constants.DoubleBorder, + expectedBorder: lipgloss.DoubleBorder(), + }, + { + name: "rounded border", + light: "#FFFFFF", + dark: "#000000", + border: constants.RoundedBorder, + expectedBorder: lipgloss.RoundedBorder(), + }, + { + name: "thick border", + light: "#FFFFFF", + dark: "#000000", + border: constants.ThickBorder, + expectedBorder: lipgloss.ThickBorder(), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + windowStyle := WindowStyle(tc.light, tc.dark, tc.border) + + assert.Equal(t, + lipgloss.AdaptiveColor{Light: tc.light, Dark: tc.dark}, + windowStyle.GetBorderRightForeground()) + + assert.Equal(t, tc.expectedBorder.Top, windowStyle.GetBorderStyle().Top) + }) + } +} + +func TestTitleTabStyle(t *testing.T) { + + tt := []struct { + name string + light, dark, border string + expectedBorder lipgloss.Border + }{ + { + name: "default border", + light: "#FFFFFF", + dark: "#000000", + border: "", + expectedBorder: lipgloss.RoundedBorder(), + }, + { + name: "double border", + light: "#FFFFFF", + dark: "#000000", + border: constants.DoubleBorder, + expectedBorder: lipgloss.DoubleBorder(), + }, + { + name: "rounded border", + light: "#FFFFFF", + dark: "#000000", + border: constants.RoundedBorder, + expectedBorder: lipgloss.RoundedBorder(), + }, + { + name: "thick border", + light: "#FFFFFF", + dark: "#000000", + border: constants.ThickBorder, + expectedBorder: lipgloss.ThickBorder(), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + titleTabStyle := TitleTabStyle(tc.light, tc.dark, tc.border) + + assert.Equal(t, + lipgloss.AdaptiveColor{Light: tc.light, Dark: tc.dark}, + titleTabStyle.GetBorderRightForeground()) + + assert.Equal(t, tc.expectedBorder.Top, titleTabStyle.GetBorderStyle().Top) + }) + } +} + +func TestInActiveTabStyle(t *testing.T) { + + tt := []struct { + name string + light, dark, border string + expectedBorder lipgloss.Border + }{ + { + name: "default border", + light: "#FFFFFF", + dark: "#000000", + border: "", + expectedBorder: lipgloss.RoundedBorder(), + }, + { + name: "double border", + light: "#FFFFFF", + dark: "#000000", + border: constants.DoubleBorder, + expectedBorder: lipgloss.DoubleBorder(), + }, + { + name: "rounded border", + light: "#FFFFFF", + dark: "#000000", + border: constants.RoundedBorder, + expectedBorder: lipgloss.RoundedBorder(), + }, + { + name: "thick border", + light: "#FFFFFF", + dark: "#000000", + border: constants.ThickBorder, + expectedBorder: lipgloss.ThickBorder(), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + inActiveStyle := InActiveTabStyle(tc.light, tc.dark, tc.border) + + assert.Equal(t, + lipgloss.AdaptiveColor{Light: tc.light, Dark: tc.dark}, + inActiveStyle.GetBorderRightForeground()) + + assert.Equal(t, tc.expectedBorder.Top, inActiveStyle.GetBorderStyle().Top) + }) + } +} + +func TestItemNormalTitleStyle(t *testing.T) { + expected := lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). + Padding(0, 0, 0, 2) + + assert.Equal(t, expected, ItemNormalTitleStyle("#FFFFFF", "#000000")) +} + +func TestItemNormalDescStyle(t *testing.T) { + expected := lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). + Padding(0, 0, 0, 2) + + assert.Equal(t, expected, ItemNormalDescStyle("#FFFFFF", "#000000")) +} + +func TestItemSelectedTitleStyle(t *testing.T) { + s := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). + Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). + Padding(0, 0, 0, 1) + + assert.Equal(t, s, ItemSelectedTitleStyle( + "#FFFFFF", "#000000", "#FFFFFF", "#000000")) +} + +func TestItemSelectedDescStyle(t *testing.T) { + s := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). + Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). + Padding(0, 0, 0, 1) + + assert.Equal(t, s, ItemSelectedDescStyle( + "#FFFFFF", "#000000", "#FFFFFF", "#000000")) +} + +func TestItemDimmedTitleStyle(t *testing.T) { + s := lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). + Padding(0, 0, 0, 2) + + assert.Equal(t, s, ItemDimmedTitleStyle("#FFFFFF", "#000000")) +} + +func TestItemDimmedDescStyle(t *testing.T) { + s := lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). + Padding(0, 0, 0, 2) + + assert.Equal(t, s, ItemDimmedDescStyle("#FFFFFF", "#000000")) +} + +func TestVisitedStyle(t *testing.T) { + s := lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}) + + assert.Equal(t, s, VisitedStyle("#FFFFFF", "#000000")) +} + +func TestFilterMatchedStyle(t *testing.T) { + s := lipgloss.NewStyle(). + Background(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). + Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). + Underline(true) + + assert.Equal(t, s, FilterMatchedStyle("#FFFFFF", "#000000", "#FFFFFF", "#000000")) +} + +func TestTabGapBorderWithBottom(t *testing.T) { + + tt := []struct { + name string + left, border string + expectedBorder lipgloss.Border + }{ + { + name: "default border", + left: "┴", + border: "", + expectedBorder: lipgloss.RoundedBorder(), + }, + { + name: "double border", + left: "┴", + border: constants.DoubleBorder, + expectedBorder: lipgloss.DoubleBorder(), + }, + { + name: "rounded border", + left: "┴", + border: constants.RoundedBorder, + expectedBorder: lipgloss.RoundedBorder(), + }, + { + name: "thick border", + left: "┴", + border: constants.ThickBorder, + expectedBorder: lipgloss.ThickBorder(), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + tabStyle := tabGapBorderWithBottom(tc.left, tc.border) + + assert.Equal(t, tc.left, tabStyle.BottomLeft) + assert.Equal(t, tc.expectedBorder.TopRight, tabStyle.BottomRight) + assert.Equal(t, "", tabStyle.Right) + }) + } +} diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go new file mode 100644 index 0000000..ffb6535 --- /dev/null +++ b/internal/tui/theme/theme.go @@ -0,0 +1,78 @@ +package theme + +import ( + "github.com/charmbracelet/lipgloss" + + "github.com/KarolosLykos/hackertea/internal/config" + "github.com/KarolosLykos/hackertea/internal/tui/style" +) + +var instance *Theme + +type Theme struct { + TitleTab lipgloss.Style + NormalTitle lipgloss.Style + NormalDesc lipgloss.Style + SelectedTitle lipgloss.Style + SelectedDesc lipgloss.Style + DimmedTitle lipgloss.Style + DimmedDesc lipgloss.Style + FilterMatch lipgloss.Style + Visited lipgloss.Style + ActiveTab lipgloss.Style + InActiveTab lipgloss.Style + GapTab lipgloss.Style + Window lipgloss.Style + Doc lipgloss.Style + ListContent lipgloss.Style +} + +func GetTheme() (*Theme, error) { + if instance != nil { + return instance, nil + } + + instance, _ = NewTheme() + + return instance, nil +} + +func NewTheme() (*Theme, error) { + cfg, _ := config.LoadConfig() + + return &Theme{ + TitleTab: style.TitleTabStyle(cfg.Style.Tab.Color.Light, cfg.Style.Tab.Color.Dark, cfg.Style.Window.Border), + NormalTitle: style.ItemNormalTitleStyle( + cfg.Style.ListItem.NormalTitle.Light, + cfg.Style.ListItem.NormalTitle.Dark, + ), + NormalDesc: style.ItemNormalDescStyle(cfg.Style.ListItem.NormalDesc.Light, cfg.Style.ListItem.NormalDesc.Dark), + SelectedTitle: style.ItemSelectedTitleStyle( + cfg.Style.ListItem.SelectedTitle.BorderForeground.Light, + cfg.Style.ListItem.SelectedTitle.BorderForeground.Dark, + cfg.Style.ListItem.SelectedTitle.Foreground.Light, + cfg.Style.ListItem.SelectedTitle.Foreground.Dark, + ), + SelectedDesc: style.ItemSelectedDescStyle( + cfg.Style.ListItem.SelectedTitle.BorderForeground.Light, + cfg.Style.ListItem.SelectedTitle.BorderForeground.Dark, + cfg.Style.ListItem.SelectedDesc.Light, + cfg.Style.ListItem.SelectedDesc.Dark, + ), + DimmedTitle: style.ItemDimmedTitleStyle(cfg.Style.ListItem.DimmedTitle.Light, cfg.Style.ListItem.DimmedTitle.Dark), + DimmedDesc: style.ItemDimmedDescStyle(cfg.Style.ListItem.DimmedDesc.Light, cfg.Style.ListItem.DimmedDesc.Dark), + Visited: style.VisitedStyle(cfg.Style.Visited.Light, cfg.Style.Visited.Dark), + FilterMatch: style.FilterMatchedStyle( + cfg.Style.ListItem.FilterMatch.BorderForeground.Light, + cfg.Style.ListItem.FilterMatch.BorderForeground.Dark, + cfg.Style.ListItem.FilterMatch.Foreground.Light, + cfg.Style.ListItem.FilterMatch.Foreground.Dark, + ), + ActiveTab: style.ActiveTabStyle(cfg.Style.Tab.Color.Light, cfg.Style.Tab.Color.Dark, cfg.Style.Window.Border), + InActiveTab: style.InActiveTabStyle(cfg.Style.Tab.Color.Light, cfg.Style.Tab.Color.Dark, cfg.Style.Window.Border), + GapTab: style.TabGapStyle(cfg.Style.Tab.Color.Light, cfg.Style.Tab.Color.Dark, cfg.Style.Window.Border), + Window: style.WindowStyle(cfg.Style.Window.Color.Light, cfg.Style.Window.Color.Dark, cfg.Style.Window.Border), + Doc: style.DocStyle(), + ListContent: lipgloss.NewStyle().Padding(5), + }, nil +} diff --git a/internal/tui/theme/theme_test.go b/internal/tui/theme/theme_test.go new file mode 100644 index 0000000..5403af0 --- /dev/null +++ b/internal/tui/theme/theme_test.go @@ -0,0 +1,31 @@ +package theme + +import ( + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetTheme(t *testing.T) { + // Create new instance first. + theme, err := GetTheme() + require.NoError(t, err) + require.NotNil(t, theme) + + // Get instance after the first time. + theme1, err1 := GetTheme() + require.NoError(t, err1) + require.NotNil(t, theme1) + + assert.EqualValues(t, theme, theme1) +} + +func TestNewTheme(t *testing.T) { + theme, err := NewTheme() + assert.NotNil(t, theme) + assert.NoError(t, err) + + assert.Equal(t, lipgloss.NewStyle().Padding(1, 2, 1, 2), theme.Doc) +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..0bb2d6c --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,131 @@ +package utils + +import ( + "context" + "fmt" + "os/exec" + "sync" + + "github.com/charmbracelet/bubbles/list" + + "github.com/KarolosLykos/hackertea/internal/constants" + "github.com/KarolosLykos/hackertea/internal/hn" + "github.com/KarolosLykos/hackertea/internal/item" +) + +// Max returns the maximum of two integers. +func Max(a, b int) int { + if a > b { + return a + } + return b +} + +// Min returns the minimum of two integers. +func Min(a, b int) int { + if a < b { + return a + } + return b +} + +// Open opens a URL in the default web browser for the user's platform. +// The supported platforms are Linux, Windows, and macOS. +func Open(url string, runtimeOS string) error { + if url == "" { + return nil + } + + var err error + + switch runtimeOS { + case constants.Linux: + err = exec.Command("xdg-open", url).Start() + case constants.Windows: + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case constants.Darwing: + err = exec.Command("open", url).Start() + default: + err = fmt.Errorf("unsupported platform") + } + + return err +} + +// FetchStories fetches the given stories asynchronously from the Hacker News API, +// using a pool of workers. +// It returns a slice of list.Items that can be used to display the stories in a list. +// The function takes the following parameters: +// - ctx: The context to use for the API requests. +// - client: The Hacker News client to use for the API requests. +// - ids: A 2D slice containing the IDs of the stories to fetch for each tab. +// - workers: The number of workers to use for fetching the stories. +// - tabID: The index of the tab containing the IDs of the stories to fetch. +// - start: The index of the first story to fetch. +// - end: The index of the last story to fetch. +func FetchStories( + ctx context.Context, + client hn.Service, + ids [][]int, + workers, tabID, start, end int, +) []list.Item { + if tabID > len(ids)-1 { + return make([]list.Item, 0) + } + + if end-start > len(ids[tabID]) || end > len(ids[tabID]) { + return make([]list.Item, 0) + } + + workers = Min(workers, end-start) + + type workReq struct { + id int + number int + } + type workResp struct { + item *item.Item + number int + } + + work := make(chan workReq) + msg := make(chan workResp) + + wg := sync.WaitGroup{} + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for s := range work { + it, err := client.GetItem(ctx, s.id) + if err != nil { + msg <- workResp{ + item: &item.Item{Titl: fmt.Sprintf("Could not get item (%s)", err.Error())}, + number: s.number, + } + } else { + msg <- workResp{item: it, number: s.number} + } + } + }() + } + + go func() { + for n, s := range ids[tabID][start:end] { + work <- workReq{id: s, number: start + n} + } + + close(work) + + wg.Wait() + close(msg) + }() + + items := make([]list.Item, end-start) + + for result := range msg { + items[result.number-start] = result.item + } + + return items +} diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go new file mode 100644 index 0000000..12f7466 --- /dev/null +++ b/internal/utils/utils_test.go @@ -0,0 +1,175 @@ +package utils + +import ( + "context" + "errors" + "runtime" + "testing" + + "github.com/charmbracelet/bubbles/list" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + + "github.com/KarolosLykos/hackertea/internal/item" + mock_hn "github.com/KarolosLykos/hackertea/internal/mock/hn" +) + +func TestUtils_Max(t *testing.T) { + assert.Equal(t, 5, Max(2, 5)) + assert.Equal(t, 5, Max(5, 2)) + assert.Equal(t, 0, Max(0, 0)) +} + +func TestUtils_Min(t *testing.T) { + assert.Equal(t, 2, Min(2, 5)) + assert.Equal(t, 2, Min(5, 2)) + assert.Equal(t, 0, Min(0, 0)) +} + +func TestUtils_Open(t *testing.T) { + testCases := []struct { + name string + url string + runtimeOS string + expectedError error + }{ + { + name: "Empty URL", + url: "", + runtimeOS: runtime.GOOS, + expectedError: nil, + }, + { + name: "Unsupported platform", + url: "https://example.com", + runtimeOS: "unknown", + expectedError: errors.New("unsupported platform"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := Open(tc.url, tc.runtimeOS) + + if (err == nil && tc.expectedError != nil) || + (err != nil && tc.expectedError == nil) || + (err != nil && err.Error() != tc.expectedError.Error()) { + t.Errorf("Expected error '%v', but got '%v'", tc.expectedError, err) + } + }) + } +} + +func TestUtils_FetchStories(t *testing.T) { + tt := []struct { + name string + workers int + ids [][]int + tabID int + start, end int + hnStub func(hn *mock_hn.MockService) + expectedItems []list.Item + expectedLen int + }{ + { + name: "fetch stories", + workers: 3, + ids: [][]int{{1, 2, 3}}, + tabID: 0, start: 0, end: 3, + hnStub: func(hn *mock_hn.MockService) { + hn.EXPECT().GetItem(gomock.Any(), gomock.Any()).Return(&item.Item{ID: 1}, nil) + hn.EXPECT().GetItem(gomock.Any(), gomock.Any()).Return(&item.Item{ID: 2}, nil) + hn.EXPECT().GetItem(gomock.Any(), gomock.Any()).Return(&item.Item{ID: 3}, nil) + }, + expectedItems: []list.Item{&item.Item{ID: 1}, &item.Item{ID: 2}, &item.Item{ID: 3}}, + expectedLen: 3, + }, + { + name: "fetch stories with less workers", + workers: 1, + ids: [][]int{{1, 2, 3}}, + tabID: 0, start: 0, end: 3, + hnStub: func(hn *mock_hn.MockService) { + hn.EXPECT().GetItem(gomock.Any(), gomock.Any()).Return(&item.Item{ID: 1}, nil) + hn.EXPECT().GetItem(gomock.Any(), gomock.Any()).Return(&item.Item{ID: 2}, nil) + hn.EXPECT().GetItem(gomock.Any(), gomock.Any()).Return(&item.Item{ID: 3}, nil) + }, + expectedItems: []list.Item{&item.Item{ID: 1}, &item.Item{ID: 2}, &item.Item{ID: 3}}, + expectedLen: 3, + }, + { + name: "fetch stories with unnecessary workers", + workers: 3, + ids: [][]int{{1}}, + tabID: 0, start: 0, end: 1, + hnStub: func(hn *mock_hn.MockService) { + hn.EXPECT().GetItem(gomock.Any(), gomock.Any()).Return(&item.Item{ID: 1}, nil) + }, + expectedItems: []list.Item{&item.Item{ID: 1}}, + expectedLen: 1, + }, + { + name: "fetch stories with error while getting item", + workers: 3, + ids: [][]int{{1, 2, 3}}, + tabID: 0, start: 0, end: 3, + hnStub: func(hn *mock_hn.MockService) { + hn.EXPECT().GetItem(gomock.Any(), gomock.Any()).Return(&item.Item{ID: 1}, nil) + hn.EXPECT().GetItem(gomock.Any(), gomock.Any()).Return(nil, errors.New("error getting item")) + hn.EXPECT().GetItem(gomock.Any(), gomock.Any()).Return(&item.Item{ID: 3}, nil) + }, + expectedItems: []list.Item{ + &item.Item{ID: 1}, + &item.Item{Titl: "Could not get item (error getting item)"}, + &item.Item{ID: 3}, + }, + expectedLen: 3, + }, + { + name: "wrong tabID", + workers: 3, + ids: [][]int{{1}}, + tabID: 1, start: 0, end: 1, + hnStub: func(hn *mock_hn.MockService) { + hn.EXPECT().GetItem(gomock.Any(), gomock.Any()).Times(0) + }, + expectedItems: []list.Item{}, + expectedLen: 0, + }, + { + name: "wrong start - end", + workers: 3, + ids: [][]int{{1}}, + tabID: 0, start: 1, end: 2, + hnStub: func(hn *mock_hn.MockService) { + hn.EXPECT().GetItem(gomock.Any(), gomock.Any()).Times(0) + }, + expectedItems: []list.Item{}, + expectedLen: 0, + }, + } + + for _, tc := range tt { + // call the GetItems method and check the result + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // Set up mock client response + mockHN := mock_hn.NewMockService(ctrl) + tc.hnStub(mockHN) + + items := FetchStories( + context.Background(), + mockHN, + tc.ids, + tc.workers, + tc.tabID, + tc.start, tc.end, + ) + + assert.ElementsMatch(t, items, tc.expectedItems) + assert.Len(t, items, tc.expectedLen) + }) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9b3f443 --- /dev/null +++ b/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "time" + + "github.com/KarolosLykos/hackertea/internal/cache" + "github.com/KarolosLykos/hackertea/internal/client" + "github.com/KarolosLykos/hackertea/internal/constants" + "github.com/KarolosLykos/hackertea/internal/hn" + "github.com/KarolosLykos/hackertea/internal/tui/model" + tea "github.com/charmbracelet/bubbletea" +) + +func main() { + ctx := context.Background() + + c := client.New(constants.BaseURL, &http.Client{Timeout: 10 * time.Second}) + memCache := cache.New() + hnClient := hn.New(c, memCache) + + m, err := model.New(ctx, hnClient) + if err != nil { + fmt.Println("Error creating model: ", err) + os.Exit(1) + } + + p := tea.NewProgram(m, tea.WithAltScreen()) + + if _, err = p.Run(); err != nil { + fmt.Println("Error running program: ", err) + os.Exit(1) + } +} diff --git a/vhs.tape b/vhs.tape new file mode 100644 index 0000000..df70dd7 --- /dev/null +++ b/vhs.tape @@ -0,0 +1,59 @@ +Output demo.gif + +Set FontSize 12 +Set Framerate 30 +Set Height 1080 + +Set WindowBar Colorful +Set WindowBarSize 40 + +Set PlaybackSpeed 2.0 + +Hide +Type "go build -o hackertea ." +Enter +Sleep 28s + +Type "clear" +Enter +Show + + +Type "./hackertea" +Enter +Sleep 10s + +Down@500ms 5 + +Type "n" + +Sleep 3s + +Down@500ms 5 +Up@500ms 5 + +Type "t" +Sleep 2s + +Type "t" +Sleep 2s + +Type "T" +Sleep 2s + +Type "T" +Sleep 2s + +Type "?" + +Sleep 5s + +Type "?" + +# Admire the output for a bit. +Sleep 3s + +Hide +Ctrl+C +Type "rm hackertea" +Enter