Repository: tonymajestro/reddit-tui
Branch: main
Commit: 3a594f6dce40
Files: 55
Total size: 129.6 KB
Directory structure:
gitextract_v95nv04n/
├── .github/
│ └── workflows/
│ ├── build.yml
│ └── release.yml
├── .gitignore
├── .goreleaser.yaml
├── LICENSE.txt
├── README.md
├── client/
│ ├── cache/
│ │ ├── commentsCache.go
│ │ ├── comments_cache_test.go
│ │ ├── postsCache.go
│ │ └── posts_cache_test.go
│ ├── client.go
│ ├── comments/
│ │ ├── commentsClient.go
│ │ └── commentsParser.go
│ ├── common/
│ │ ├── errors.go
│ │ └── html.go
│ ├── posts/
│ │ ├── postsClient.go
│ │ └── postsParser.go
│ └── url.go
├── components/
│ ├── colors/
│ │ └── colors.go
│ ├── comments/
│ │ ├── commentsPage.go
│ │ ├── header.go
│ │ ├── keys.go
│ │ ├── pager.go
│ │ └── styles.go
│ ├── messages/
│ │ └── messages.go
│ ├── modal/
│ │ ├── error.go
│ │ ├── modal.go
│ │ ├── quit.go
│ │ ├── render.go
│ │ ├── search.go
│ │ ├── spinner.go
│ │ └── subredditList.go
│ ├── posts/
│ │ ├── header.go
│ │ ├── keys.go
│ │ ├── postsPage.go
│ │ └── styles.go
│ ├── styles/
│ │ └── style.go
│ └── tui.go
├── config/
│ ├── config.go
│ └── defaultConfig.go
├── go.mod
├── go.sum
├── install.sh
├── integ_test.go
├── justfile
├── main.go
├── model/
│ ├── commentsModel.go
│ └── postModel.go
├── uninstall.sh
└── utils/
├── browser.go
├── files.go
├── logger.go
├── timer.go
├── utils.go
└── utils_test.go
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/build.yml
================================================
name: Build reddittui
on:
push:
branches:
- main
pull_request:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23'
- name: Build reddittui
run: go build -v ./...
================================================
FILE: .github/workflows/release.yml
================================================
name: Release reddittui
on:
push:
tags:
- '*'
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v4
with:
go-version: stable
- name: GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GH_KEY }}
================================================
FILE: .gitignore
================================================
samples
build/
# Added by goreleaser init:
dist/
================================================
FILE: .goreleaser.yaml
================================================
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
# The lines below are called `modelines`. See `:help modeline`
# Feel free to remove those if you don't want/need to use them.
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
# you may remove this if you don't need go generate
- go generate ./...
builds:
- binary: reddittui
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
archives:
- formats:
- tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
formats:
- zip
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
release:
footer: >-
---
Released by [GoReleaser](https://github.com/goreleaser/goreleaser).
================================================
FILE: LICENSE.txt
================================================
MIT License
Copyright (c) 2025 Anthony Majestro
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# Reddittui
A lightweight terminal application for browsing Reddit from your command line. Powered by [bubbletea](https://github.com/charmbracelet/bubbletea)
## Features
- **Subreddit Browsing:** Navigate through your favorite subreddits.
- **Post Viewing:** Read text posts and comments.
- **Keyboard Navigation:** Scroll and select posts using vim/standard keyboard shortcuts.
- **Configurable**: Customize caching behavior and define subreddit filters using a configuration file
## Demo
https://github.com/user-attachments/assets/40d61ef3-3a95-4a26-8c49-bec616f6ae1c
## Installation
### Git
#### Prerequisites
- **Git**
- **Go:** Version 1.16 or newer
- **Terminal:** A Unix-like terminal (Linux, macOS, or similar).
- **POSIX Utilities:** The `install` command is used for installation, which is available on both Linux and macOS.
Clone the repository and run the install script:
```bash
git clone https://github.com/tonymajestro/reddit-tui.git reddittui
cd reddittui
./install.sh
```
To remove reddittui run the uninstall script:
```bash
./uninstall.sh
```
### Arch
Arch users can install reddittui from the AUR using yay or other AUR helpers.
[Pre-compiled](https://aur.archlinux.org/packages/reddit-tui-bin) and [source packages](https://aur.archlinux.org/packages/reddit-tui) are available.
```bash
yay -S reddit-tui-bin
```
```bash
yay -S reddit-tui
```
### Nix
Nix users can try it in a shell or add it to their system config like this.
```bash
nix-shell -p reddit-tui
```
```nix
environment.systemPackages = [
pkgs.reddit-tui
];
```
## Usage
Run the installed binary from your preferred terminal:
```bash
# Open reddittui, navigating to the home page
reddittui
# Open reddittui, navigating to a specific subreddit
reddittui --subreddit dogs
# Open reddittui, navigating to a specific post by its ID
reddittui --post 1iyuce4
```
## Keybindings
- Navigation
- **h, j, k, l:** Vim movement
- **left, right, up, down:** Normal movement
- **g**: Go to top of page
- **G**: Go to bottom of page
- **s**: Switch subreddits
- Posts page
- **L**: Load more posts
- Comments page
- **o**: Open post link in browser
- **c**: Collapse comments
- Misc
- **H:** Go to home page
- **backspace**: Go back
- **q, esc**: Exit reddittui
## Configuration files
After running the reddittui binary, the following files will be initialized:
- Configuration file:
- `~/.config/reddittui/reddittui.toml`
- Log file:
- `~/.local/state/reddittui.log`
- Cache
- `~/.cache/reddittui/`
Sample configuration:
```toml
# Core configuration
[core]
bypassCache = false
logLevel = "Warn"
# Filter out posts containing keywords or belonging to certain subreddits
[filter]
subreddits = ["news", "politics"]
keywords = ["pizza", "pineapple"]
# Configure client timeout and cache TTL. By default, subreddit posts and comments are cached for 1 hour.
[client]
timeoutSeconds = 10
cacheTtlSeconds = 3600
# Configure which reddit server to use. Default is old.reddit.com but redlib servers are also supported
[server]
domain = "old.reddit.com"
type = "old"
```
## Redlib
For enhanced privacy, private [Redlib backends](https://github.com/redlib-org/redlib) are supported. A list of Redlib servers can be found [here](https://github.com/redlib-org/redlib-instances/blob/main/instances.json). Use the following configuration to use a Redlib server instead of old.reddit.com:
```toml
[server]
domain = "safereddit.com"
type = "redlib"
```
## Acknowledgments
Reddittui is based on the [bubbletea](https://github.com/charmbracelet/bubbletea) framework. It also takes inspiration from [circumflex](https://github.com/bensadeh/circumflex), a hackernews terminal browser.
================================================
FILE: client/cache/commentsCache.go
================================================
package cache
import (
"encoding/json"
"fmt"
"io/fs"
"log/slog"
"net/url"
"os"
"path/filepath"
"reddittui/client/common"
"reddittui/model"
"strings"
"time"
)
type CommentsCache interface {
Get(path string) (model.Comments, error)
Put(comments model.Comments, path string) error
Clean()
}
type FileCommentsCache struct {
BaseUrl string
CacheBaseDir string
}
func NewFileCommentsCache(baseUrl, cacheDir string) FileCommentsCache {
return FileCommentsCache{
BaseUrl: baseUrl,
CacheBaseDir: cacheDir,
}
}
// Get comments stored in cached file.
// Returns comments if they are present and not expired
func (f FileCommentsCache) Get(filename string) (comments model.Comments, err error) {
subreddit := f.GetSubredditFromUrl(filename)
if len(subreddit) == 0 {
return comments, common.ErrNotFound
}
sanitizedFilename := url.QueryEscape(filename) + ".json"
cacheFilePath := filepath.Join(f.CacheBaseDir, subreddit, sanitizedFilename)
cacheFile, err := os.Open(cacheFilePath)
if os.IsNotExist(err) {
slog.Info("not found: " + cacheFilePath)
return comments, common.ErrNotFound
} else if err != nil {
slog.Warn("Could not open cache file.", "error", err)
return comments, common.ErrCannotOpenCacheFile
}
defer cacheFile.Close()
decoder := json.NewDecoder(cacheFile)
err = decoder.Decode(&comments)
if err != nil {
slog.Warn("Could not decode cached comments.", "error", err)
return comments, common.ErrCannotDecodeCacheFile
}
if time.Now().After(comments.Expiry) {
return comments, common.ErrCacheEntryExpired
}
return comments, nil
}
// Cache the comments, writing the contents to the given cache file
func (f FileCommentsCache) Put(comments model.Comments, filename string) error {
subreddit := f.GetSubredditFromUrl(filename)
cacheDir := filepath.Join(f.CacheBaseDir, subreddit)
if err := os.MkdirAll(cacheDir, 0755); err != nil {
slog.Warn("Could not create subreddit comments cache directory", "error", err)
return err
}
sanitizedFilename := url.QueryEscape(filename) + ".json"
cacheFilePath := filepath.Join(cacheDir, sanitizedFilename)
cacheFile, err := os.OpenFile(cacheFilePath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644)
if err != nil {
slog.Warn("Could not open cache file for encoding", "error", err)
return common.ErrCannotOpenCacheFile
}
defer cacheFile.Close()
commentsJson, err := json.MarshalIndent(comments, "", " ")
if err != nil {
slog.Warn("Could not encode comments for caching", "error", err)
return common.ErrCannotEncodeCacheFile
}
_, err = cacheFile.Write(commentsJson)
if err != nil {
slog.Warn("Could not encode comments for caching", "error", err)
return common.ErrCannotEncodeCacheFile
}
return nil
}
func (f FileCommentsCache) GetSubredditFromUrl(commentsUrl string) string {
part := fmt.Sprintf("%s/r/", f.BaseUrl)
if !strings.Contains(commentsUrl, part) {
return ""
}
subreddit := commentsUrl[len(part):]
if strings.Contains(subreddit, "/") {
subreddit = subreddit[:strings.Index(subreddit, "/")]
}
return subreddit
}
func (f FileCommentsCache) Clean() {
// First delete expired comments files in each subreddit directory
filepath.WalkDir(f.CacheBaseDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Ignore directories, only look at cache comment files
if d.IsDir() || filepath.Ext(path) != ".json" {
return nil
}
cacheFile, err := os.Open(path)
if err != nil {
slog.Debug("Could not open cache file.", "error", err)
return nil
}
defer cacheFile.Close()
var comments model.Comments
decoder := json.NewDecoder(cacheFile)
err = decoder.Decode(&comments)
if err != nil {
slog.Debug("Could not decode cached comments.", "error", err)
return nil
}
// Delete cached comments file if it is expired
if time.Now().After(comments.Expiry) {
err = os.Remove(path)
if err != nil {
slog.Warn("Could not delete expired cache file")
return nil
}
}
return nil
})
// Wait for OS to process deleted files
time.Sleep(500 * time.Millisecond)
// Delete subreddit directories that are empty
filepath.WalkDir(f.CacheBaseDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
slog.Error("cache error", "error", err)
return nil
}
if !d.IsDir() || path == f.CacheBaseDir {
return nil
}
dir, err := os.Open(path)
if err != nil {
slog.Warn("Could not open cache dir", "error", err)
return filepath.SkipDir
}
// Get list of filenames in subreddit directory
files, err := dir.Readdirnames(0)
if err != nil {
slog.Warn("Could not read contests of subreddit cache dir", "error", err)
return filepath.SkipDir
}
// Delete subreddit directory if it is empty
if len(files) == 0 {
err = os.Remove(path)
if err != nil {
slog.Warn("Could not delete empty cache dir")
return filepath.SkipDir
}
}
return nil
})
}
type NoOpCommentsCache struct{}
func NewNoOpCommentsCache() NoOpCommentsCache {
return NoOpCommentsCache{}
}
func (n NoOpCommentsCache) Get(cacheFilePath string) (comments model.Comments, err error) {
return comments, common.ErrNotFound
}
func (n NoOpCommentsCache) Put(comments model.Comments, cacheFilePath string) error {
return nil
}
func (n NoOpCommentsCache) Clean() {
}
================================================
FILE: client/cache/comments_cache_test.go
================================================
package cache
import (
"fmt"
"reddittui/client/common"
"reddittui/model"
"testing"
"time"
)
const (
testPostPoints = "5 points"
testPostText = "text"
testPostTimestamp = "5 mins ago"
testCommentAuthor = "author"
testCommentText = "comments"
testCommentPoints = "5 points"
testCommentTimestamp = "5 mins ago"
testBaseUrl = "old.reddit.com"
testCommentDepth = 0
)
func TestCommentsCacheHappyPath(t *testing.T) {
cache := NewFileCommentsCache(testBaseUrl, t.TempDir())
expiry := time.Now().Add(200 * time.Millisecond).Round(time.Millisecond)
expected := createTestComments(expiry)
commentsUrl := generateCommentsFileUrl(testSubreddit, "happy")
err := cache.Put(expected, commentsUrl)
if err != nil {
t.Fatalf("could not put comments in comments cache: %v", err)
}
got, err := cache.Get(commentsUrl)
if err != nil {
t.Fatalf("expected no errors getting comments from cache: %v", err)
}
assertComments(expected, got, t)
}
func TestCommentsCacheCacheNotFound(t *testing.T) {
cache := NewFileCommentsCache(testBaseUrl, t.TempDir())
if _, err := cache.Get("notfound.json"); err != common.ErrNotFound {
t.Fatalf("expected to not find comments in cache")
}
}
func assertComments(expected, got model.Comments, t *testing.T) {
assertVal("PostTitle", expected.PostTitle, got.PostTitle, t)
assertVal("PostAuthor", expected.PostAuthor, got.PostAuthor, t)
assertVal("Subreddit", expected.Subreddit, got.Subreddit, t)
assertVal("PostPoints", expected.PostPoints, got.PostPoints, t)
assertVal("PostText", expected.PostText, got.PostText, t)
assertVal("PostTimestamp", expected.PostTimestamp, got.PostTimestamp, t)
assertVal("Expiry", expected.Expiry, got.Expiry, t)
if len(expected.Comments) != len(got.Comments) {
t.Fatalf("expected %d comments but got %d:", len(expected.Comments), len(got.Comments))
}
for i, expectedComment := range expected.Comments {
gotComment := got.Comments[i]
assertComment(expectedComment, gotComment, t)
}
if t.Failed() {
t.FailNow()
}
}
func assertComment(expectedComment, gotComment model.Comment, t *testing.T) {
assertVal("Author", expectedComment.Author, gotComment.Author, t)
assertVal("Text", expectedComment.Text, gotComment.Text, t)
assertVal("Points", expectedComment.Points, gotComment.Points, t)
assertVal("Timestamp", expectedComment.Timestamp, gotComment.Timestamp, t)
assertVal("Depth", expectedComment.Depth, gotComment.Depth, t)
}
func createTestComment() model.Comment {
return model.Comment{
Author: testCommentAuthor,
Text: testCommentText,
Points: testCommentPoints,
Timestamp: testCommentTimestamp,
Depth: testCommentDepth,
}
}
func createTestComments(expiry time.Time) model.Comments {
return model.Comments{
PostTitle: testTitle,
PostAuthor: testAuthor,
Subreddit: testSubreddit,
PostPoints: testPostPoints,
PostText: testPostUrl,
PostTimestamp: testPostTimestamp,
Expiry: expiry,
Comments: []model.Comment{createTestComment()},
}
}
func generateCommentsFileUrl(subreddit, filename string) string {
return fmt.Sprintf("%s/r/%s/%s", testBaseUrl, subreddit, filename)
}
================================================
FILE: client/cache/postsCache.go
================================================
package cache
import (
"encoding/json"
"io/fs"
"log/slog"
"net/url"
"os"
"path/filepath"
"reddittui/client/common"
"reddittui/model"
"time"
)
type PostsCache interface {
Get(path string) (model.Posts, error)
Put(posts model.Posts, cacheFilePath string) error
Clean()
}
type FilePostsCache struct {
CacheBaseDir string
}
func NewFilePostsCache(cacheDir string) FilePostsCache {
return FilePostsCache{CacheBaseDir: cacheDir}
}
// Get posts stored in cached file.
// Returns posts if they are present and not expired
func (f FilePostsCache) Get(filename string) (posts model.Posts, err error) {
sanitizedFilename := url.QueryEscape(filename) + ".json"
cacheFilePath := filepath.Join(f.CacheBaseDir, sanitizedFilename)
cacheFile, err := os.Open(cacheFilePath)
if os.IsNotExist(err) {
return posts, common.ErrNotFound
} else if err != nil {
slog.Warn("Could not open cache file.", "error", err)
return posts, common.ErrCannotOpenCacheFile
}
defer cacheFile.Close()
decoder := json.NewDecoder(cacheFile)
err = decoder.Decode(&posts)
if err != nil {
slog.Warn("Could not decode cached posts.", "error", err)
return posts, common.ErrCannotDecodeCacheFile
}
if time.Now().After(posts.Expiry) {
return posts, common.ErrCacheEntryExpired
}
return posts, nil
}
// Cache the posts, writing the contents to the given cache file
func (f FilePostsCache) Put(posts model.Posts, filename string) error {
sanitizedFilename := url.QueryEscape(filename) + ".json"
cacheFilePath := filepath.Join(f.CacheBaseDir, sanitizedFilename)
cacheFile, err := os.OpenFile(cacheFilePath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644)
if err != nil {
slog.Warn("Could not open cache file for encoding", "error", err)
return common.ErrCannotOpenCacheFile
}
defer cacheFile.Close()
postsJson, err := json.MarshalIndent(posts, "", " ")
if err != nil {
slog.Warn("Could not encode posts for caching", "error", err)
return common.ErrCannotEncodeCacheFile
}
_, err = cacheFile.Write(postsJson)
if err != nil {
slog.Warn("Could not encode posts for caching", "error", err)
return common.ErrCannotEncodeCacheFile
}
return nil
}
func (f FilePostsCache) Clean() {
filepath.WalkDir(f.CacheBaseDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
slog.Error("cache error", "error", err)
return nil
}
// Only clean up posts which are stored in root cache directory, skip everything else.
if d.IsDir() {
if path == f.CacheBaseDir {
return nil
} else {
return filepath.SkipDir
}
} else if filepath.Ext(path) != ".json" {
return nil
}
cacheFile, err := os.Open(path)
if err != nil {
slog.Debug("Could not open cache file.", "error", err)
return nil
}
defer cacheFile.Close()
var posts model.Posts
decoder := json.NewDecoder(cacheFile)
err = decoder.Decode(&posts)
if err != nil {
slog.Debug("Could not decode cached posts.", "error", err)
return nil
}
// Delete cached posts file if it is expired
if time.Now().After(posts.Expiry) {
slog.Debug("Removing expired cache posts", "path", path)
err = os.Remove(path)
if err != nil {
slog.Debug("Could not delete expired cache file", "error", err)
return nil
}
}
return nil
})
}
type NoOpPostsCache struct{}
func NewNoOpPostsCache() NoOpPostsCache {
return NoOpPostsCache{}
}
func (n NoOpPostsCache) Get(cacheFilePath string) (posts model.Posts, err error) {
return posts, common.ErrNotFound
}
func (n NoOpPostsCache) Put(posts model.Posts, cacheFilePath string) error {
return nil
}
func (f NoOpPostsCache) Clean() {
}
================================================
FILE: client/cache/posts_cache_test.go
================================================
package cache
import (
"os"
"path/filepath"
"reddittui/client/common"
"reddittui/model"
"strings"
"testing"
"time"
)
const (
testTitle = "title"
testDescription = "description"
testAuthor = "author"
testSubreddit = "subreddit"
testFriendlyDate = "5 mins ago"
testPostUrl = "post.url"
testCommentsUrl = "comments.url"
testTotalComments = "5 comments"
testTotalLikes = "10 likes"
testIsHome = false
testAfter = "after"
)
func TestPostsCacheHappyPath(t *testing.T) {
cache := NewFilePostsCache(t.TempDir())
expiry := time.Now().Add(200 * time.Millisecond).Round(time.Millisecond)
expected := createTestPosts(expiry)
err := cache.Put(expected, "happy")
if err != nil {
t.Fatalf("could not put posts in posts cache: %v", err)
}
got, err := cache.Get("happy")
if err != nil {
t.Fatalf("expected no errors getting posts from cache: %v", err)
}
assertPosts(expected, got, t)
}
func TestPostsCacheNotFound(t *testing.T) {
cache := NewFilePostsCache(t.TempDir())
if _, err := cache.Get("notfound.json"); err != common.ErrNotFound {
t.Fatalf("expected to not find posts in cache")
}
}
func TestPostsCacheCannotDecodePosts(t *testing.T) {
cache := NewFilePostsCache(t.TempDir())
file, err := os.CreateTemp(cache.CacheBaseDir, "cannotdecode*.json")
if err != nil {
t.Fatalf("could not create test posts file")
}
defer file.Close()
file.WriteString("not valid json")
// Cache adds the .json extension when fetching the file from the cache
// Strip it here so we don't add it twice
filename := filepath.Base(file.Name())
cacheEntryName := strings.TrimSuffix(filename, filepath.Ext(filename))
if _, err = cache.Get(cacheEntryName); err != common.ErrCannotDecodeCacheFile {
t.Fatalf("expected cannot decode cache entry %s, got %v", filename, err)
}
}
func TestPostsCacheCacheExpired(t *testing.T) {
cache := NewFilePostsCache(t.TempDir())
expiry := time.Now().Round(time.Millisecond)
expected := createTestPosts(expiry)
err := cache.Put(expected, "expired")
if err != nil {
t.Fatalf("could not put posts in posts cache: %v", err)
}
// Posts should be already expired by time we fetch them
time.Sleep(100 * time.Millisecond)
_, err = cache.Get("happy")
if err == nil {
t.Fatalf("expected no errors getting posts from cache: %v", err)
}
}
func TestPostsCacheCleanCache(t *testing.T) {
cache := NewFilePostsCache(t.TempDir())
posts1 := createTestPosts(time.Now().Round(time.Millisecond))
posts2 := createTestPosts(time.Now().Add(200 * time.Millisecond).Round(time.Millisecond))
posts1.Subreddit = "subreddit1"
posts2.Subreddit = "subreddit2"
cache.Put(posts1, "subreddit1")
cache.Put(posts2, "subreddit2")
cache.Clean()
if _, err := cache.Get("subreddit1"); err != common.ErrNotFound {
t.Fatal("expected expired posts subreddit1 to be cleaned from cache")
}
gotPosts2, err := cache.Get("subreddit2")
if err != nil {
t.Fatalf("unexpected error fetching subreddit2 from cache: %v", err)
}
assertPosts(posts2, gotPosts2, t)
time.Sleep(200 * time.Millisecond)
cache.Clean()
if _, err := cache.Get("subreddit2"); err != common.ErrNotFound {
t.Fatal("expected expired posts subreddit1 to be cleaned from cache")
}
}
func assertPosts(expected, got model.Posts, t *testing.T) {
assertVal("After", expected.After, got.After, t)
assertVal("Description", expected.Description, got.Description, t)
assertVal("Subreddit", expected.Subreddit, got.Subreddit, t)
assertVal("IsHome", expected.IsHome, got.IsHome, t)
assertVal("Expiry", expected.Expiry, got.Expiry, t)
if len(expected.Posts) != len(got.Posts) {
t.Fatalf("expected %d posts but got %d:", len(expected.Posts), len(got.Posts))
}
for i, expectedPost := range expected.Posts {
gotPost := got.Posts[i]
assertPost(expectedPost, gotPost, t)
}
if t.Failed() {
t.FailNow()
}
}
func assertPost(expected, got model.Post, t *testing.T) {
assertVal("PostTitle", expected.PostTitle, got.PostTitle, t)
assertVal("Author", expected.Author, got.Author, t)
assertVal("Subreddit", expected.Author, got.Author, t)
assertVal("FriendlyDate", expected.FriendlyDate, got.FriendlyDate, t)
assertVal("Expiry", expected.Expiry, got.Expiry, t)
assertVal("PostUrl", expected.PostUrl, got.PostUrl, t)
assertVal("CommentsUrl", expected.CommentsUrl, got.CommentsUrl, t)
assertVal("TotalComments", expected.TotalComments, got.TotalComments, t)
assertVal("TotalLikes", expected.TotalLikes, got.TotalLikes, t)
}
func assertVal[K comparable](context string, expected, got K, t *testing.T) {
if expected != got {
t.Errorf("assertion failed %s: for expected %v but got %v", context, expected, got)
}
}
func createTestPost() model.Post {
return model.Post{
PostTitle: testTitle,
Author: testAuthor,
Subreddit: testSubreddit,
FriendlyDate: testFriendlyDate,
PostUrl: testPostUrl,
CommentsUrl: testCommentsUrl,
TotalComments: testTotalComments,
TotalLikes: testTotalLikes,
}
}
func createTestPosts(expiry time.Time) model.Posts {
post := createTestPost()
posts := []model.Post{post}
return model.Posts{
Description: testDescription,
Subreddit: testSubreddit,
IsHome: false,
Posts: posts,
After: testAfter,
Expiry: expiry,
}
}
================================================
FILE: client/client.go
================================================
package client
import (
"log"
"log/slog"
"net/http"
"os"
"path/filepath"
"reddittui/client/cache"
"reddittui/client/comments"
"reddittui/client/common"
"reddittui/client/posts"
"reddittui/config"
"reddittui/model"
"reddittui/utils"
"time"
)
type RedditClient struct {
BaseUrl string
postsClient posts.RedditPostsClient
commentsClient comments.RedditCommentsClient
}
func NewRedditClient(configuration config.Config) RedditClient {
baseUrl, err := NormalizeBaseUrl(configuration.Server.Domain)
if err != nil {
log.Fatalf("Could not parse reddit server url: %s", configuration.Server.Domain)
}
// Support legacy core.ClientTimeout configuration value, use the greater of the two
timeoutSeconds := max(configuration.Core.ClientTimeout, configuration.Client.TimeoutSeconds)
httpClient := &http.Client{
Timeout: time.Duration(timeoutSeconds) * time.Second,
}
postsCache, commentsCache := InitializeCaches(baseUrl, configuration.Core.BypassCache)
postsClient := posts.NewRedditPostsClient(baseUrl, httpClient, postsCache, configuration)
commentsClient := comments.NewRedditCommentsClient(baseUrl, configuration.Server.Type, httpClient, commentsCache)
return RedditClient{
baseUrl,
postsClient,
commentsClient,
}
}
func (r RedditClient) GetHomePosts(after string) (model.Posts, error) {
return r.postsClient.GetHomePosts(after)
}
func (r RedditClient) GetSubredditPosts(subreddit, after string) (model.Posts, error) {
return r.postsClient.GetSubredditPosts(subreddit, after)
}
func (r RedditClient) GetComments(url string) (model.Comments, error) {
return r.commentsClient.GetComments(url)
}
func (r RedditClient) CleanCache() {
r.postsClient.Cache.Clean()
r.commentsClient.Cache.Clean()
}
func InitializeCaches(baseUrl string, bypassCache bool) (cache.PostsCache, cache.CommentsCache) {
if bypassCache {
return cache.NewNoOpPostsCache(), cache.NewNoOpCommentsCache()
}
// read cache dir from env var
cacheDir, err := utils.GetCacheDir()
if err != nil {
slog.Warn("Cannot open cache dir, skipping cache")
return cache.NewNoOpPostsCache(), cache.NewNoOpCommentsCache()
}
// ensure root cache dir exists
err = os.MkdirAll(cacheDir, 0755)
if err != nil {
slog.Warn("Cannot create root cache dir, skipping cache")
return cache.NewNoOpPostsCache(), cache.NewNoOpCommentsCache()
}
// use root cache dir for posts
postsCache := cache.NewFilePostsCache(cacheDir)
// ensure comments cache dir exists
commentsCacheDir := filepath.Join(cacheDir, common.CommentsCacheDirName)
err = os.MkdirAll(commentsCacheDir, 0755)
if err != nil {
slog.Warn("Cannot create comments cache dir, skipping comments cache")
return postsCache, cache.NewNoOpCommentsCache()
}
commentsCache := cache.NewFileCommentsCache(baseUrl, commentsCacheDir)
return postsCache, commentsCache
}
================================================
FILE: client/comments/commentsClient.go
================================================
package comments
import (
"log/slog"
"net/http"
"reddittui/client/cache"
"reddittui/client/common"
"reddittui/model"
"reddittui/utils"
"regexp"
"time"
"golang.org/x/net/html"
)
const defaultTtl = 1 * time.Hour
var postTextTrimRegex = regexp.MustCompile("\n\n\n+")
type RedditCommentsClient struct {
BaseUrl string
Client *http.Client
Cache cache.CommentsCache
Parser CommentsParser
}
func NewRedditCommentsClient(baseUrl, serverType string, httpClient *http.Client, commentsCache cache.CommentsCache) RedditCommentsClient {
var parser CommentsParser
switch serverType {
case "old":
parser = OldRedditCommentsParser{}
case "redlib":
parser = RedlibCommentsParser{}
default:
panic("Unrecognized server type in configuration: " + serverType)
}
return RedditCommentsClient{
BaseUrl: baseUrl,
Client: httpClient,
Cache: commentsCache,
Parser: parser,
}
}
func (r RedditCommentsClient) GetComments(url string) (comments model.Comments, err error) {
totalTimer := utils.NewTimer("total time to retrieve comments")
defer totalTimer.StopAndLog()
timer := utils.NewTimer("fetching comments from cache")
comments, err = r.Cache.Get(url)
if err == nil {
// return cached data
timer.StopAndLog()
return comments, nil
}
timer.StopAndLog()
urlWithLimit := common.AddQueryParameter(url, common.LimitQueryParameter)
req, err := http.NewRequest("GET", urlWithLimit, nil)
if err != nil {
return comments, err
}
req.Header.Add(common.UserAgentHeaderKey, common.UserAgentHeaderValue)
timer = utils.NewTimer("fetching comments from server")
res, err := r.Client.Do(req)
timer.StopAndLog("url", url)
if err != nil {
return comments, err
}
if res.StatusCode != http.StatusOK {
slog.Error("Error fetching comments from server", "StatusCode", res.StatusCode)
return comments, common.ErrNotFound
}
defer res.Body.Close()
timer = utils.NewTimer("parsing comments html")
doc, err := html.Parse(res.Body)
timer.StopAndLog()
if err != nil {
return comments, err
}
timer = utils.NewTimer("converting comments html")
comments = r.Parser.ParseComments(common.HtmlNode{Node: doc}, url)
comments.Expiry = time.Now().Add(defaultTtl)
timer.StopAndLog()
timer = utils.NewTimer("putting comments in cache")
r.Cache.Put(comments, url)
timer.StopAndLog()
return comments, nil
}
================================================
FILE: client/comments/commentsParser.go
================================================
package comments
import (
"fmt"
"reddittui/client/common"
"reddittui/model"
"reddittui/utils"
"strings"
"golang.org/x/net/html"
)
type CommentsParser interface {
ParseComments(common.HtmlNode, string) model.Comments
}
type OldRedditCommentsParser struct{}
func (p OldRedditCommentsParser) ParseComments(root common.HtmlNode, url string) model.Comments {
var commentsData model.Comments
var commentsList []model.Comment
commentsData.PostTitle = p.getTitle(root)
commentsData.PostAuthor = p.getPostAuthor(root)
commentsData.PostTimestamp = p.getPostTimestamp(root)
commentsData.Subreddit = p.getSubreddit(root)
commentsData.PostPoints = p.getPostPoints(root)
commentsData.Comments = p.parseCommentsList(root, 0, commentsList)
postText, postUrl := p.getPostContent(root)
if postUrl == "" {
// Self post
postUrl = url
}
commentsData.PostText = postText
commentsData.PostUrl = postUrl
return commentsData
}
func (p OldRedditCommentsParser) parseCommentsList(node common.HtmlNode, depth int, comments []model.Comment) []model.Comment {
var commentsNode common.HtmlNode
commentsNode, ok := node.FindDescendant("div", "sitetable", "nestedlisting")
if !ok {
commentsNode, ok = node.FindDescendant("div", "sitetable", "listing")
if !ok {
return comments
}
}
for c := range commentsNode.FindChildren("div", "thing", "comment") {
if c.ClassContains("deleted") {
// Skip deleted comments and their children
// todo: figure out how to render these properly
continue
}
entryNode, ok := c.FindChild("div", "entry")
if !ok {
continue
}
comment := p.parseCommentNode(entryNode, depth)
comments = append(comments, comment)
if n, ok := c.FindChild("div", "child"); ok {
comments = p.parseCommentsList(n, depth+1, comments)
}
}
return comments
}
func (p OldRedditCommentsParser) parseCommentNode(node common.HtmlNode, depth int) model.Comment {
var comment model.Comment
comment.Depth = depth
if taglineNode, ok := node.FindChild("p", "tagline"); ok {
if authorNode, ok := taglineNode.FindChild("a", "author"); ok {
comment.Author = authorNode.Text()
}
// Default to 1 point if the comment is too new to show points
points := "1 point"
if likesNode, ok := taglineNode.FindChild("span", "score", "likes"); ok {
points = likesNode.Text()
}
comment.Points = points
if timestampNode, ok := taglineNode.FindChild("time", "live-timestamp"); ok {
comment.Timestamp = timestampNode.Text()
}
}
if usertextNode, ok := node.FindChild("form", "usertext"); ok {
comment.Text = strings.TrimSpace(renderHtmlNode(usertextNode))
}
return comment
}
func (p OldRedditCommentsParser) getTitle(root common.HtmlNode) string {
for n := range root.FindDescendants("meta") {
if n.GetAttr("property") == "og:title" {
return n.GetAttr("content")
}
}
return ""
}
func (p OldRedditCommentsParser) getPostContent(root common.HtmlNode) (content, url string) {
if linkListingNode, ok := root.FindDescendant("div", "sitetable", "linklisting"); ok {
// self post
if mdNode, ok := linkListingNode.FindDescendant("div", "md"); ok {
postText := renderHtmlNode(mdNode)
// skip alb.reddit.com urls
if strings.Contains(postText, "alb.reddit.com") {
return "", ""
}
content = postTextTrimRegex.ReplaceAllString(postText, "\n\n")
return content, ""
}
}
if entry, ok := root.FindDescendant("div", "entry", "unvoted"); ok {
// link post
if linkNode, ok := entry.FindDescendant("a", "title"); ok {
url = linkNode.GetAttr("href")
// skip alb.reddit.com urls
if strings.Contains(url, "alb.reddit.com") {
return "", ""
}
content := fmt.Sprintf("%s\n\n", common.HyperLinkStyle.Render(url))
return content, url
}
}
return "", ""
}
func (p OldRedditCommentsParser) getPostAuthor(root common.HtmlNode) string {
if linkListingNode, ok := root.FindDescendant("div", "sitetable", "linklisting"); ok {
if authorNode, ok := linkListingNode.FindDescendant("a", "author"); ok {
return authorNode.Text()
}
}
return ""
}
func (p OldRedditCommentsParser) getPostTimestamp(root common.HtmlNode) string {
if linkListingNode, ok := root.FindDescendant("div", "sitetable", "linklisting"); ok {
if timestampNode, ok := linkListingNode.FindDescendant("time", "live-timestamp"); ok {
return timestampNode.Text()
}
}
return ""
}
func (p OldRedditCommentsParser) getSubreddit(root common.HtmlNode) string {
if spanNode, ok := root.FindDescendant("span", "pagename", "redditname"); ok {
if subredditNode, ok := spanNode.FindDescendant("a"); ok {
return subredditNode.Text()
}
}
return ""
}
func (p OldRedditCommentsParser) getPostPoints(root common.HtmlNode) string {
if linkListingNode, ok := root.FindDescendant("div", "sitetable", "linklisting"); ok {
if likesNode, ok := linkListingNode.FindDescendant("div", "score", "likes"); ok {
return likesNode.Text()
}
if unvotedNode, ok := linkListingNode.FindDescendant("div", "score", "unvoted"); ok {
return unvotedNode.Text()
}
// Fallback to any score node
if pointsNode, ok := linkListingNode.FindDescendant("div", "score"); ok {
return pointsNode.Text()
}
}
return ""
}
type RedlibCommentsParser struct{}
func (p RedlibCommentsParser) ParseComments(root common.HtmlNode, url string) model.Comments {
var (
commentsData model.Comments
commentsList []model.Comment
)
mainNode, ok := root.FindDescendant("main")
if !ok {
return commentsData
}
if headerNode, ok := mainNode.FindDescendant("div", "post", "highlighted"); ok {
commentsData.PostAuthor = p.getPostAuthor(headerNode)
commentsData.PostTimestamp = p.getPostTimestamp(headerNode)
commentsData.Subreddit = p.getSubreddit(headerNode)
}
commentsData.PostTitle = p.getTitle(root)
commentsData.PostPoints = p.getPostPoints(mainNode)
commentsData.Comments = p.parseCommentsList(mainNode, 0, commentsList)
postText, postUrl := p.getPostContent(mainNode)
if postUrl == "" {
// Self post
postUrl = url
}
commentsData.PostText = postText
commentsData.PostUrl = postUrl
return commentsData
}
func (p RedlibCommentsParser) getTitle(root common.HtmlNode) string {
titleNode, ok := root.FindDescendant("title")
if !ok {
return ""
}
// Strip subreddit from title
title := titleNode.Text()
index := strings.Index(title, "- r/")
if index < 0 {
return title
}
return strings.TrimSpace(title[:index])
}
func (p RedlibCommentsParser) getPostAuthor(root common.HtmlNode) string {
authorNode, ok := root.FindDescendant("a", "post_author")
if !ok {
return ""
}
author := authorNode.Text()
if len(author) > 2 && author[:2] == "u/" {
author = author[2:]
}
return author
}
func (p RedlibCommentsParser) getPostTimestamp(root common.HtmlNode) string {
timestampNode, ok := root.FindDescendant("span", "created")
if !ok {
return ""
}
return timestampNode.Text()
}
func (p RedlibCommentsParser) getSubreddit(root common.HtmlNode) string {
subredditNode, ok := root.FindDescendant("a", "post_subreddit")
if !ok {
return ""
}
return subredditNode.Text()
}
func (p RedlibCommentsParser) getPostPoints(root common.HtmlNode) string {
pointsNode, ok := root.FindDescendant("div", "post_score")
if !ok {
return ""
}
return strings.TrimSpace(pointsNode.Text())
}
func (p RedlibCommentsParser) getPostContent(root common.HtmlNode) (content, url string) {
// self post
if postBodyNode, ok := root.FindDescendant("div", "post_body"); ok {
if mdNode, ok := postBodyNode.FindDescendant("div", "md"); ok {
postText := renderHtmlNode(mdNode)
content = postTextTrimRegex.ReplaceAllString(postText, "\n\n")
return content, ""
}
}
// link post
for linkNode := range root.FindChildren("a") {
if linkNode.GetAttr("id") == "post_url" {
url = linkNode.GetAttr("href")
content := fmt.Sprintf("%s\n\n", common.HyperLinkStyle.Render(url))
return content, url
}
}
return "", ""
}
func (p RedlibCommentsParser) parseCommentsList(root common.HtmlNode, depth int, comments []model.Comment) []model.Comment {
for threadNode := range root.FindDescendants("div", "thread") {
comments = p.parseThread(threadNode, depth, comments)
}
return comments
}
func (p RedlibCommentsParser) parseThread(root common.HtmlNode, depth int, comments []model.Comment) []model.Comment {
commentNode, ok := root.FindChild("div", "comment")
if !ok {
return comments
}
comment := p.parseCommentNode(commentNode, depth)
comments = append(comments, comment)
if n, ok := commentNode.FindDescendant("blockquote", "replies"); ok {
comments = p.parseThread(n, depth+1, comments)
}
return comments
}
func (p RedlibCommentsParser) parseCommentNode(node common.HtmlNode, depth int) model.Comment {
var comment model.Comment
comment.Depth = depth
if leftNode, ok := node.FindDescendant("div", "comment_left"); ok {
if scoreNode, ok := leftNode.FindChild("p", "comment_score"); ok {
points := "1 point"
if scoreNode.GetAttr("title") != "Hidden" {
points = utils.GetSingularPlural(strings.TrimSpace(scoreNode.Text()), "point", "points")
}
comment.Points = strings.TrimSpace(points)
}
}
if rightNode, ok := node.FindDescendant("details", "comment_right"); ok {
if authorNode, ok := rightNode.FindDescendant("a", "comment_author"); ok {
author := authorNode.Text()
if len(author) > 2 && author[:2] == "u/" {
author = author[2:]
}
comment.Author = author
}
if timestampNode, ok := rightNode.FindDescendant("a", "created"); ok {
comment.Timestamp = timestampNode.Text()
}
if commentBodyNode, ok := node.FindDescendant("div", "md"); ok {
commentText := strings.TrimSpace(renderHtmlNode(commentBodyNode))
comment.Text = postTextTrimRegex.ReplaceAllString(commentText, "\n\n")
}
}
return comment
}
func renderHtmlNode(node common.HtmlNode) string {
var content strings.Builder
for child := range node.ChildNodes() {
cNode := common.HtmlNode{Node: child}
var nodeResults strings.Builder
renderHtmlNodeHelper(cNode, &nodeResults)
content.WriteString(nodeResults.String())
content.WriteString("\n")
}
return content.String()
}
func renderHtmlNodeHelper(node common.HtmlNode, results *strings.Builder) {
if node.Type == html.TextNode {
results.WriteString(node.Data)
} else if node.Tag() == "a" {
results.WriteString(common.RenderAnchor(node))
return
} else if node.Tag() == "li" {
results.WriteString(node.Text())
return
}
for child := range node.ChildNodes() {
renderHtmlNodeHelper(common.HtmlNode{Node: child}, results)
}
}
================================================
FILE: client/common/errors.go
================================================
package common
import "errors"
var (
ErrCacheEntryExpired = errors.New("entry is expired")
ErrCannotLoadPosts = errors.New("cannot load posts")
ErrNotFound = errors.New("not found")
ErrCannotOpenCacheFile = errors.New("cannot open cache file")
ErrCannotEncodeCacheFile = errors.New("cannot encode cache file")
ErrCannotDecodeCacheFile = errors.New("cannot decode cache file")
)
================================================
FILE: client/common/html.go
================================================
package common
import (
"fmt"
"iter"
"reddittui/components/colors"
"slices"
"strings"
"github.com/charmbracelet/lipgloss"
"golang.org/x/net/html"
)
const (
UserAgentHeaderKey = "User-Agent"
UserAgentHeaderValue = "Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0"
CacheControlHeader = "Cache-Control"
CommentsCacheDirName = "comments"
)
var (
HyperLinkStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Blue)).Italic(true)
LinkPostTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(colors.AdaptiveColor(colors.Text))
)
const LimitQueryParameter = "limit=500"
type HtmlNode struct {
*html.Node
}
func (n HtmlNode) GetAttr(key string) string {
for _, attr := range n.Attr {
if attr.Key != key {
continue
}
return attr.Val
}
return ""
}
func (n HtmlNode) Classes() []string {
var classes []string
class := n.GetAttr("class")
for _, c := range strings.Fields(class) {
classes = append(classes, strings.TrimSpace(c))
}
return classes
}
func (n HtmlNode) Class() string {
return n.GetAttr("class")
}
func (n HtmlNode) Id() string {
return n.GetAttr("id")
}
func (n HtmlNode) ClassContains(classesToFind ...string) bool {
for _, c := range classesToFind {
if !slices.Contains(n.Classes(), strings.TrimSpace(c)) {
return false
}
}
return true
}
func (n HtmlNode) Text() string {
for c := range n.ChildNodes() {
if c.Type == html.TextNode {
return c.Data
}
}
return ""
}
func (n HtmlNode) Tag() string {
return n.Data
}
func (n HtmlNode) TagEquals(tag string) bool {
return n.Type == html.ElementNode && n.Data == tag
}
func (n HtmlNode) NodeEquals(tag string, classes ...string) bool {
return n.TagEquals(tag) && n.ClassContains(classes...)
}
func (n HtmlNode) NodeEqualsById(tag string, id string) bool {
return n.TagEquals(tag) && n.Id() == id
}
func (n HtmlNode) FindDescendant(tag string, classes ...string) (HtmlNode, bool) {
var descendant HtmlNode
for c := range n.Descendants() {
descendant = HtmlNode{c}
if len(classes) == 0 && descendant.TagEquals(tag) {
return descendant, true
} else if descendant.NodeEquals(tag, classes...) {
return descendant, true
}
}
return descendant, false
}
func (n HtmlNode) FindDescendantById(tag string, id string) (HtmlNode, bool) {
var descendant HtmlNode
for c := range n.Descendants() {
descendant = HtmlNode{c}
if id == "" && descendant.TagEquals(tag) {
return descendant, true
} else if descendant.NodeEqualsById(tag, id) {
return descendant, true
}
}
return descendant, false
}
func (n HtmlNode) FindDescendants(tag string, classes ...string) iter.Seq[HtmlNode] {
return func(yield func(HtmlNode) bool) {
for c := range n.Descendants() {
childNode := HtmlNode{c}
if len(classes) == 0 && childNode.TagEquals(tag) {
if !yield(childNode) {
return
}
} else if childNode.NodeEquals(tag, classes...) {
if !yield(childNode) {
return
}
}
}
}
}
func (n HtmlNode) FindChild(tag string, classes ...string) (HtmlNode, bool) {
var child HtmlNode
for c := range n.ChildNodes() {
child = HtmlNode{c}
if len(classes) == 0 && child.TagEquals(tag) {
return child, true
} else if child.NodeEquals(tag, classes...) {
return child, true
}
}
return child, false
}
func (n HtmlNode) FindChildren(tag string, classes ...string) iter.Seq[HtmlNode] {
return func(yield func(HtmlNode) bool) {
for c := range n.ChildNodes() {
childNode := HtmlNode{c}
if len(classes) == 0 && childNode.TagEquals(tag) {
if !yield(childNode) {
return
}
} else if childNode.NodeEquals(tag, classes...) {
if !yield(childNode) {
return
}
}
}
}
}
func RenderAnchor(node HtmlNode) string {
var (
url = node.GetAttr("href")
linkText = node.Text()
)
if !strings.HasPrefix(url, "http") && !strings.HasPrefix(url, "www") {
return HyperLinkStyle.Render(linkText)
} else if url == linkText {
return HyperLinkStyle.Render(linkText)
}
return fmt.Sprintf(
"%s %s",
linkText,
HyperLinkStyle.Render(url))
}
func AddQueryParameter(url, query string) string {
if strings.Contains(url, "?") {
return fmt.Sprintf("%s&%s", url, query)
}
return fmt.Sprintf("%s?%s", url, query)
}
================================================
FILE: client/posts/postsClient.go
================================================
package posts
import (
"fmt"
"log/slog"
"net/http"
"reddittui/client/cache"
"reddittui/client/common"
"reddittui/config"
"reddittui/model"
"reddittui/utils"
"strings"
"time"
"golang.org/x/net/html"
)
type RedditPostsClient struct {
BaseUrl string
CacheTtl time.Duration
Client *http.Client
Cache cache.PostsCache
Parser PostsParser
KeywordFilters []string
SubredditFilters []string
}
func NewRedditPostsClient(
baseUrl string,
httpClient *http.Client,
postsCache cache.PostsCache,
configuration config.Config,
) RedditPostsClient {
var parser PostsParser
switch strings.ToLower(configuration.Server.Type) {
case "old":
parser = OldRedditPostsParser{}
case "redlib":
parser = RedlibParser{baseUrl}
default:
panic("Unrecognized server type in configuration: " + configuration.Server.Type)
}
return RedditPostsClient{
BaseUrl: baseUrl,
CacheTtl: time.Duration(configuration.Client.CacheTtlSeconds) * time.Second,
Client: httpClient,
Cache: postsCache,
Parser: parser,
KeywordFilters: configuration.Filter.Keywords,
SubredditFilters: configuration.Filter.Subreddits,
}
}
func (r RedditPostsClient) GetHomePosts(after string) (model.Posts, error) {
timer := utils.NewTimer("total time to retrieve home posts")
defer timer.StopAndLog()
postsUrl := r.BuildPostsUrl("", after)
posts, err := r.tryGetCachedPosts(postsUrl)
posts.IsHome = true
return posts, err
}
func (r RedditPostsClient) GetSubredditPosts(subreddit string, after string) (model.Posts, error) {
timer := utils.NewTimer("total time to retrieve subreddit posts")
defer timer.StopAndLog()
postsUrl := r.BuildPostsUrl(subreddit, after)
posts, err := r.tryGetCachedPosts(postsUrl)
posts.Subreddit = subreddit
return posts, err
}
// Try to get posts from cache. If they are not present, fetch them and cache the results
func (r RedditPostsClient) tryGetCachedPosts(postsUrl string) (posts model.Posts, err error) {
timer := utils.NewTimer("fetching posts from cache")
posts, err = r.Cache.Get(postsUrl)
if err == nil {
// return cached data
timer.StopAndLog()
return r.filterPosts(posts), nil
}
timer.StopAndLog()
timer = utils.NewTimer("getting posts from server")
posts, err = r.getPosts(postsUrl)
if err != nil {
timer.StopAndLog()
return posts, err
}
timer.StopAndLog()
timer = utils.NewTimer("filtering posts")
posts = r.filterPosts(posts)
timer.StopAndLog()
timer = utils.NewTimer("putting posts in cache")
r.Cache.Put(posts, postsUrl)
timer.StopAndLog()
return posts, nil
}
func (r RedditPostsClient) getPosts(url string) (posts model.Posts, err error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return posts, err
}
req.Header.Add(common.UserAgentHeaderKey, common.UserAgentHeaderValue)
timer := utils.NewTimer("fetching posts from server")
res, err := r.Client.Do(req)
timer.StopAndLog("url", url)
if err != nil {
return posts, err
} else if res.StatusCode != http.StatusOK {
// Treat all non-200s as 404s
return posts, common.ErrCannotLoadPosts
}
defer res.Body.Close()
timer = utils.NewTimer("parsing posts html")
doc, err := html.Parse(res.Body)
timer.StopAndLog()
if err != nil {
return posts, err
}
timer = utils.NewTimer("converting posts html")
posts = r.Parser.ParsePosts(common.HtmlNode{Node: doc})
timer.StopAndLog()
if len(posts.Posts) == 0 {
// if there are no posts, assume 404.
// reddit redirect invalid subreddits requests to some search page instead of doing 404
slog.Warn("Subreddit not found")
return posts, common.ErrNotFound
}
posts.Expiry = time.Now().Add(r.CacheTtl)
return posts, nil
}
func (r RedditPostsClient) filterPosts(posts model.Posts) model.Posts {
var filteredPosts []model.Post
outer:
for _, post := range posts.Posts {
for _, keyword := range r.KeywordFilters {
if strings.Contains(strings.ToLower(post.PostTitle), strings.ToLower(keyword)) {
slog.Debug("filtering post", "title", post.PostTitle)
continue outer
}
}
for _, subreddit := range r.SubredditFilters {
subreddit = utils.NormalizeSubreddit(subreddit)
if strings.EqualFold(post.Subreddit, subreddit) {
slog.Debug("filtering post", "title", post.PostTitle)
continue outer
}
}
filteredPosts = append(filteredPosts, post)
}
posts.Posts = filteredPosts
return posts
}
func (r RedditPostsClient) BuildPostsUrl(subreddit, after string) string {
afterParam := ""
if len(after) > 0 {
afterParam = fmt.Sprintf("?after=%s", after)
}
if len(subreddit) > 0 {
return fmt.Sprintf("%s/r/%s%s", r.BaseUrl, subreddit, afterParam)
}
return fmt.Sprintf("%s%s", r.BaseUrl, afterParam)
}
================================================
FILE: client/posts/postsParser.go
================================================
package posts
import (
"log/slog"
"net/url"
"reddittui/client/common"
"reddittui/model"
"strings"
)
type PostsParser interface {
ParsePosts(common.HtmlNode) model.Posts
}
type OldRedditPostsParser struct{}
func (p OldRedditPostsParser) ParsePosts(root common.HtmlNode) model.Posts {
var (
posts []model.Post
description string
)
for d := range root.FindDescendants("div", "thing") {
if d.ClassContains("promoted", "promotedlink") {
// Skip ads and promotional content
continue
}
post := p.parsePost(d)
posts = append(posts, post)
}
// Parse description
for d := range root.FindDescendants("meta") {
if d.GetAttr("name") == "description" {
description = d.GetAttr("content")
}
}
// Parse url for next page of posts
after := ""
for d := range root.FindDescendants("div", "nav-buttons") {
for a := range d.FindDescendants("a") {
if strings.Contains(a.Text(), "next") {
if parsed, err := url.Parse(a.GetAttr("href")); err == nil {
after = parsed.Query().Get("after")
}
}
}
}
modelPosts := model.Posts{
Posts: posts,
Description: description,
After: after,
}
return modelPosts
}
func (p OldRedditPostsParser) parsePost(n common.HtmlNode) model.Post {
var post model.Post
for c := range n.Descendants() {
cNode := common.HtmlNode{Node: c}
if cNode.NodeEquals("a", "title") {
post.PostTitle = cNode.Text()
post.PostUrl = cNode.GetAttr("href")
} else if cNode.NodeEquals("a", "author") {
post.Author = cNode.Text()
} else if cNode.NodeEquals("a", "subreddit") {
post.Subreddit = cNode.Text()
} else if cNode.NodeEquals("time", "live-timestamp") {
post.FriendlyDate = cNode.Text()
} else if cNode.NodeEquals("a", "comments") {
post.CommentsUrl = cNode.GetAttr("href")
post.TotalComments = strings.Fields(cNode.Text())[0]
} else if cNode.NodeEquals("div", "likes") {
post.TotalLikes = cNode.Text()
}
}
return post
}
type RedlibParser struct {
BaseUrl string
}
func (p RedlibParser) ParsePosts(root common.HtmlNode) model.Posts {
var posts model.Posts
for d := range root.FindDescendants("div", "post") {
post := p.parsePost(d)
posts.Posts = append(posts.Posts, post)
}
if descriptionNode, ok := root.FindDescendantById("p", "sub_description"); ok {
posts.Description = descriptionNode.Text()
}
return posts
}
func (p RedlibParser) parsePost(n common.HtmlNode) model.Post {
var post model.Post
for c := range n.Descendants() {
cNode := common.HtmlNode{Node: c}
if cNode.NodeEquals("h2", "post_title") {
for postTitleSubNode := range cNode.FindChildren("a") {
post.PostTitle = postTitleSubNode.Text()
commentsUrl, err := p.buildUrl(postTitleSubNode.GetAttr("href"))
if err != nil {
slog.Debug("Error parsing comments url", "error", err)
continue
}
post.CommentsUrl = commentsUrl
}
} else if cNode.NodeEquals("a", "post_author") {
post.Author = cNode.Text()
} else if cNode.NodeEquals("a", "post_subreddit") {
post.Subreddit = cNode.Text()
} else if cNode.NodeEquals("span", "created") {
post.FriendlyDate = cNode.Text()
} else if cNode.NodeEquals("a", "post_comments") {
commentsUrl, err := p.buildUrl(cNode.GetAttr("href"))
if err != nil {
slog.Debug("Error parsing comments url", "error", err)
continue
}
post.CommentsUrl = commentsUrl
post.TotalComments = cNode.GetAttr("title")
} else if cNode.NodeEquals("div", "post_score") {
post.TotalLikes = strings.TrimSpace(cNode.Text())
}
}
return post
}
func (p RedlibParser) buildUrl(part string) (string, error) {
return url.JoinPath(p.BaseUrl, part)
}
================================================
FILE: client/url.go
================================================
package client
import (
"net/url"
)
func NormalizeBaseUrl(baseUrl string) (string, error) {
parsed, err := url.Parse(baseUrl)
if err != nil {
return "", err
}
if parsed.Scheme != "https" {
parsed.Scheme = "https"
}
url := parsed.String()
if url[len(url)-1] == '/' {
url = url[:len(url)-1]
}
return url, nil
}
func GetPostUrl(baseUrl, post string) (string, error) {
parsed, err := url.Parse(post)
if err != nil {
// User passed in post ID, build URL from ID
return url.JoinPath(baseUrl, post)
}
// User passed in url, use base URL instead of the one passed in
return url.JoinPath(baseUrl, parsed.Path)
}
================================================
FILE: components/colors/colors.go
================================================
package colors
import "github.com/charmbracelet/lipgloss"
// https://github.com/catppuccin/catppuccin
type Color int
const (
Red = iota
Maroon
Pink
Orange
Yellow
Green
Blue
Purple
Indigo
Lavender
Text
Subtext
Sand
White
)
type Palette struct {
Red string
Maroon string
Pink string
Orange string
Yellow string
Green string
Blue string
Purple string
Indigo string
Lavender string
Text string
Subtext string
Sand string
White string
}
func (p Palette) ToHex(color Color) string {
switch color {
case Red:
return p.Red
case Maroon:
return p.Maroon
case Pink:
return p.Pink
case Orange:
return p.Orange
case Yellow:
return p.Yellow
case Green:
return p.Green
case Blue:
return p.Blue
case Purple:
return p.Purple
case Indigo:
return p.Indigo
case Lavender:
return p.Lavender
case Text:
return p.Text
case Subtext:
return p.Subtext
case Sand:
return p.Sand
case White:
return p.White
default:
return p.Text
}
}
// catppuccin-macchiato
var Dark = Palette{
Red: "#ed8796",
Maroon: "#ee99a0",
Pink: "#f5bde6",
Orange: "#f5a97f",
Yellow: "#eed49f",
Green: "#a6da95",
Blue: "#8aadf4",
Purple: "#c6a0f6",
Indigo: "#5f5fd7 ",
Lavender: "#b7bdf8",
Text: "#cad3f5",
Subtext: "#b8c0e0",
Sand: "#dddddd",
White: "#ffffff",
}
// catppuccin-latte
var Light = Palette{
Red: "#d20f39",
Maroon: "#e64553",
Pink: "#ea76cb",
Orange: "#fe640b",
Yellow: "#df8e1d",
Green: "#40a02b",
Blue: "#1e66f5",
Purple: "#8839ef",
Indigo: "#5f5fd7 ",
Lavender: "#7287fd",
Text: "#4c4f69",
Subtext: "#5c5f77",
Sand: "#dddddd",
White: "#ffffff",
}
func AdaptiveColors(light, dark Color) lipgloss.AdaptiveColor {
return lipgloss.AdaptiveColor{
Light: Light.ToHex(light),
Dark: Dark.ToHex(dark),
}
}
func AdaptiveColor(color Color) lipgloss.AdaptiveColor {
return lipgloss.AdaptiveColor{
Light: Light.ToHex(color),
Dark: Dark.ToHex(color),
}
}
================================================
FILE: components/comments/commentsPage.go
================================================
package comments
import (
"log/slog"
"reddittui/client"
"reddittui/components/messages"
"reddittui/components/styles"
"reddittui/model"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var commentsErrorText = "Could not load comments. Please try again in a few moments."
type CommentsPage struct {
redditClient client.RedditClient
header CommentsHeader
pager CommentsViewport
containerStyle lipgloss.Style
postUrl string
focus bool
}
func NewCommentsPage(redditClient client.RedditClient) CommentsPage {
header := NewCommentsHeader()
vp := NewCommentsViewport()
return CommentsPage{
redditClient: redditClient,
header: header,
pager: vp,
containerStyle: styles.GlobalStyle,
}
}
func (c CommentsPage) Init() tea.Cmd {
return nil
}
func (c CommentsPage) Update(msg tea.Msg) (CommentsPage, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
if c.focus {
c, cmd = c.handleFocusedMessages(msg)
cmds = append(cmds, cmd)
}
c, cmd = c.handleGlobalMessages(msg)
cmds = append(cmds, cmd)
return c, tea.Batch(cmds...)
}
func (c CommentsPage) handleGlobalMessages(msg tea.Msg) (CommentsPage, tea.Cmd) {
switch msg := msg.(type) {
case messages.LoadCommentsMsg:
url := string(msg)
return c, c.loadComments(url)
case messages.UpdateCommentsMsg:
c.updateComments(model.Comments(msg))
return c, messages.LoadingComplete
}
return c, nil
}
func (c CommentsPage) handleFocusedMessages(msg tea.Msg) (CommentsPage, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch keypress := msg.String(); keypress {
case "H":
return c, messages.LoadHome
case "escape", "backspace", "left", "h":
return c, messages.GoBack
case "o", "O":
return c, messages.OpenUrl(c.postUrl)
}
}
var cmd tea.Cmd
c.pager, cmd = c.pager.Update(msg)
return c, cmd
}
func (c CommentsPage) View() string {
headerView := c.header.View()
pagerView := c.pager.View()
joined := lipgloss.JoinVertical(lipgloss.Center, headerView, pagerView)
return c.containerStyle.Render(joined)
}
func (c *CommentsPage) SetSize(w, h int) {
c.containerStyle = c.containerStyle.Width(w).Height(h)
c.resizeComponents()
}
func (c *CommentsPage) Focus() {
c.focus = true
}
func (c *CommentsPage) Blur() {
c.focus = false
}
func (c *CommentsPage) resizeComponents() {
var (
w = c.containerStyle.GetWidth() - c.containerStyle.GetHorizontalFrameSize()
h = c.containerStyle.GetHeight() - c.containerStyle.GetVerticalFrameSize()
headerHeight = lipgloss.Height(c.header.View())
pagerHeight = h - headerHeight
)
c.header.SetSize(w, h)
c.pager.SetSize(w, pagerHeight)
}
func (c *CommentsPage) loadComments(url string) tea.Cmd {
return func() tea.Msg {
comments, err := c.redditClient.GetComments(url)
if err != nil {
slog.Error(commentsErrorText, "error", err)
return messages.ShowErrorModalMsg{ErrorMsg: commentsErrorText}
}
return messages.UpdateCommentsMsg(comments)
}
}
func (c *CommentsPage) updateComments(comments model.Comments) {
c.header.SetContent(comments)
c.pager.SetContent(comments)
c.postUrl = comments.PostUrl
// Need to resize components when content loads so padding and margins are correct
c.resizeComponents()
}
================================================
FILE: components/comments/header.go
================================================
package comments
import (
"fmt"
"reddittui/components/colors"
"reddittui/model"
"reddittui/utils"
"strconv"
"github.com/charmbracelet/lipgloss"
)
var (
headerContainerStyle = lipgloss.NewStyle().MarginBottom(2)
titleStyle = lipgloss.NewStyle().
MarginBottom(1).
Padding(0, 2).
Height(1).
Background(colors.AdaptiveColors(colors.Blue, colors.Indigo)).
Foreground(colors.AdaptiveColors(colors.White, colors.Sand))
defaultDescriptionStyle = lipgloss.NewStyle().
Bold(true).
Foreground(colors.AdaptiveColor(colors.Text))
)
type CommentsHeader struct {
DescriptionStyle lipgloss.Style
Title string
Description string
Author string
Timestamp string
Points string
TotalComments int
W int
}
func NewCommentsHeader() CommentsHeader {
return CommentsHeader{DescriptionStyle: defaultDescriptionStyle}
}
func (h *CommentsHeader) SetSize(width, height int) {
h.W = width - headerContainerStyle.GetHorizontalFrameSize()
h.DescriptionStyle = h.DescriptionStyle.Width(h.W)
}
func (h CommentsHeader) View() string {
titleView := titleStyle.Render(utils.TruncateString(h.Title, h.W))
descriptionView := h.DescriptionStyle.Render(h.Description)
authorView := postAuthorStyle.Render(h.Author)
timestampView := postTimestampStyle.Render(fmt.Sprintf("submitted %s by", h.Timestamp))
authorTimestampView := fmt.Sprintf("%s %s", timestampView, authorView)
postPointsView := postPointsStyle.Render(utils.GetSingularPlural(h.Points, "point", "points"))
totalCommentsView := totalCommentsStyle.Render(utils.GetSingularPlural(strconv.Itoa(h.TotalComments), "comment", "comments"))
pointsAndCommentsView := fmt.Sprintf("%s • %s", postPointsView, totalCommentsView)
joinedView := lipgloss.JoinVertical(lipgloss.Left, titleView, descriptionView, authorTimestampView, pointsAndCommentsView)
return headerContainerStyle.Render(joinedView)
}
func (h *CommentsHeader) SetContent(comments model.Comments) {
h.Title = utils.NormalizeSubreddit(comments.Subreddit)
h.Description = comments.PostTitle
h.Author = comments.PostAuthor
h.TotalComments = len(comments.Comments)
h.Timestamp = comments.PostTimestamp
h.Points = comments.PostPoints
}
================================================
FILE: components/comments/keys.go
================================================
package comments
import "github.com/charmbracelet/bubbles/key"
type viewportKeyMap struct {
CursorUp key.Binding
CursorDown key.Binding
GoToStart key.Binding
GoToEnd key.Binding
OpenPost key.Binding
GoHome key.Binding
CollapseComments key.Binding
ShowFullHelp key.Binding
CloseFullHelp key.Binding
Quit key.Binding
ForceQuit key.Binding
}
var commentsKeys = viewportKeyMap{
CursorUp: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
CursorDown: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
GoToStart: key.NewBinding(
key.WithKeys("home", "g"),
key.WithHelp("g/home", "go to start"),
),
GoToEnd: key.NewBinding(
key.WithKeys("end", "G"),
key.WithHelp("G/end", "go to end"),
),
OpenPost: key.NewBinding(
key.WithKeys("o", "O"),
key.WithHelp("o", "open post"),
),
GoHome: key.NewBinding(
key.WithKeys("H"),
key.WithHelp("H", "go home"),
),
CollapseComments: key.NewBinding(
key.WithKeys("c"),
key.WithHelp("c", "collapse comments"),
),
ShowFullHelp: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "more"),
),
CloseFullHelp: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "close help"),
),
Quit: key.NewBinding(
key.WithKeys("q", "esc"),
key.WithHelp("q", "quit"),
),
ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")),
}
func (k viewportKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.CursorUp, k.CursorDown, k.OpenPost, k.GoHome, k.ShowFullHelp}
}
func (k viewportKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.CursorUp, k.CursorDown, k.GoToStart, k.GoToEnd, k.OpenPost},
{k.GoHome, k.CollapseComments, k.Quit, k.CloseFullHelp},
}
}
================================================
FILE: components/comments/pager.go
================================================
package comments
import (
"fmt"
"reddittui/model"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type CommentsViewport struct {
viewport viewport.Model
postText string
postUrl string
comments []model.Comment
keyMap viewportKeyMap
help help.Model
collapsed bool
viewportLines []string
w, h int
}
func NewCommentsViewport() CommentsViewport {
return CommentsViewport{
viewport: viewport.New(0, 0),
keyMap: commentsKeys,
help: help.New(),
collapsed: false,
}
}
func (c CommentsViewport) Update(msg tea.Msg) (CommentsViewport, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, c.keyMap.GoToStart):
c.viewport.GotoTop()
case key.Matches(msg, c.keyMap.GoToEnd):
c.viewport.GotoBottom()
case key.Matches(msg, c.keyMap.CollapseComments):
c.toggleCollapseComments()
case key.Matches(msg, c.keyMap.ShowFullHelp),
key.Matches(msg, c.keyMap.CloseFullHelp):
c.help.ShowAll = !c.help.ShowAll
}
}
var cmd tea.Cmd
c.viewport, cmd = c.viewport.Update(msg)
return c, cmd
}
func (c CommentsViewport) View() string {
viewportView := viewportStyle.Render(c.viewport.View())
helpView := c.help.View(c.keyMap)
return lipgloss.JoinVertical(lipgloss.Left, viewportView, helpView)
}
func (c *CommentsViewport) SetSize(w, h int) {
c.w = w - viewportStyle.GetHorizontalFrameSize()
c.h = h
c.ResizeComponents()
c.SetViewportContent()
}
func (c *CommentsViewport) SetContent(comments model.Comments) {
c.postText = comments.PostText
c.postUrl = comments.PostUrl
c.comments = comments.Comments
c.collapsed = false
c.viewport.SetYOffset(0)
c.ResizeComponents()
c.SetViewportContent()
}
func (c *CommentsViewport) ResizeComponents() {
helpHeight := lipgloss.Height(c.help.View(c.keyMap))
c.viewport.Width = c.w
c.viewport.Height = c.h - helpHeight - 1
}
func (c *CommentsViewport) GetViewportView() string {
var content strings.Builder
if len(c.postText) > 0 {
content.WriteString(c.postText)
content.WriteString("\n")
} else {
content.WriteString(c.postUrl)
content.WriteString("\n\n")
}
for i := range len(c.comments) {
comment := c.comments[i]
commentView := c.formatComment(comment, i)
if len(commentView) > 0 {
content.WriteString(commentView)
content.WriteString("\n\n")
}
}
return content.String()
}
func (c *CommentsViewport) SetViewportContent() {
content := c.GetViewportView()
c.viewport.SetContent(content)
c.viewportLines = strings.Split(content, "\n")
}
// Format comment, adding padding to the entry according to the comment's depth
func (c *CommentsViewport) formatComment(comment model.Comment, i int) string {
var (
authorAndDateView string
pointsView string
pointsAndCollapsedHintView string
paddingW = comment.Depth * 2
containerStyle = lipgloss.NewStyle().PaddingLeft(paddingW).Width(c.w - paddingW)
)
if c.collapsed && comment.Depth > 0 {
return ""
}
authorView := commentAuthorStyle.Render(comment.Author)
dateView := commentDateStyle.Render(comment.Timestamp)
authorAndDateView = fmt.Sprintf("%s • %s", authorView, dateView)
pointsView = renderPoints(comment.Points)
pointsAndCollapsedHintView = pointsView
if c.collapsed {
children := 0
for j := i + 1; j < len(c.comments); j++ {
nextComment := c.comments[j]
if nextComment.Depth == 0 {
break
}
children++
}
if children == 1 {
collapsedHintView := collapsedStyle.Render("(1 comment hidden)")
pointsAndCollapsedHintView = fmt.Sprintf("%s %s", pointsView, collapsedHintView)
} else if children > 1 {
collapsedView := collapsedStyle.Render(fmt.Sprintf("(%d comments hidden)", children))
pointsAndCollapsedHintView = fmt.Sprintf("%s %s", pointsView, collapsedView)
}
}
joined := lipgloss.JoinVertical(lipgloss.Left, authorAndDateView, comment.Text, pointsAndCollapsedHintView)
return containerStyle.Render(joined)
}
func renderPoints(pointsString string) string {
parts := strings.Fields(pointsString)
if len(parts) != 2 {
return defaultPointsStyle.Render(pointsString)
}
if strings.Contains(parts[0], "-") {
return negativePointsStyle.Render(pointsString)
} else if strings.Contains(parts[0], "k") {
return popularPointsStyle.Render(pointsString)
}
points, err := strconv.Atoi(parts[0])
if err != nil {
return defaultPointsStyle.Render(pointsString)
} else if points >= 1000 {
return popularPointsStyle.Render(pointsString)
}
return defaultPointsStyle.Render(pointsString)
}
func (c *CommentsViewport) toggleCollapseComments() {
pos, title, text := c.findAnchorComment()
if pos < 0 {
return
}
offset := pos - c.viewport.YOffset
c.collapsed = !c.collapsed
c.SetViewportContent()
newPos := c.findComment(title, text)
c.viewport.SetYOffset(newPos - offset)
}
// Find comment closest to the center of the screen to act as an anchor when toggling
// child comments.
func (c *CommentsViewport) findAnchorComment() (pos int, title string, text string) {
findAnchorHelper := func(start, offset int) int {
for i := start; i >= 0 && i < len(c.viewportLines); i += offset {
line := c.viewportLines[i]
if len(line) > 0 && line[0] == ' ' {
continue
}
split := strings.Split(line, "•")
if len(split) == 2 && strings.Contains(split[1], "ago") {
return i
}
}
return -1
}
// Don't use actual center of viewport since the header takes up some amount of space and
// users probably look closer to the top of the screen rather than the bottom
searchStart := c.viewport.YOffset + int(float64(c.viewport.Height)*0.4)
if searchStart >= len(c.viewportLines) {
searchStart = 0
}
// Look for the comment above and below the center of the screen. Calculate which comment is closer to
// the center of the screen
upPos := findAnchorHelper(searchStart, -1)
downPos := findAnchorHelper(searchStart, 1)
if upPos < 0 && downPos < 0 {
return -1, "", ""
} else if upPos >= 0 && downPos < 0 {
return upPos, c.viewportLines[upPos], c.viewportLines[upPos+1]
} else if upPos < 0 && downPos >= 0 {
return downPos, c.viewportLines[downPos], c.viewportLines[downPos+1]
}
upDiff, downDiff := searchStart-upPos, downPos-searchStart
if upDiff < downDiff {
return upPos, c.viewportLines[upPos], c.viewportLines[upPos+1]
}
return downPos, c.viewportLines[downPos], c.viewportLines[downPos+1]
}
func (c *CommentsViewport) findComment(title, text string) int {
for i := range len(c.viewportLines) - 1 {
currTitle := c.viewportLines[i]
currText := c.viewportLines[i+1]
if currTitle == title && currText == text {
return i
}
}
return -1
}
================================================
FILE: components/comments/styles.go
================================================
package comments
import (
"reddittui/components/colors"
"github.com/charmbracelet/lipgloss"
)
var viewportStyle = lipgloss.NewStyle().Margin(0, 2, 1, 2)
var (
commentAuthorStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Blue)).Bold(true)
commentDateStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Lavender)).Italic(true)
commentTextStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text))
popularPointsStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Purple))
defaultPointsStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Purple))
negativePointsStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Red))
collapsedStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Yellow))
)
var (
postAuthorStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Blue))
postPointsStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Purple))
totalCommentsStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Orange))
postTextStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Sand))
postTimestampStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)).Faint(true)
)
================================================
FILE: components/messages/messages.go
================================================
package messages
import (
"reddittui/model"
tea "github.com/charmbracelet/bubbletea"
)
type ErrorModalMsg struct {
ErrorMsg string
OnClose tea.Cmd
}
type (
CleanCacheMsg struct{}
GoBackMsg struct{}
LoadCommentsMsg string
LoadHomeMsg struct{}
LoadMorePostsMsg bool
LoadSubredditMsg string
UpdateCommentsMsg model.Comments
UpdatePostsMsg model.Posts
AddMorePostsMsg model.Posts
LoadingCompleteMsg struct{}
OpenModalMsg struct{}
ExitModalMsg struct{}
ShowSpinnerModalMsg string
ShowErrorModalMsg ErrorModalMsg
OpenUrlMsg string
)
func CleanCache() tea.Msg {
return CleanCacheMsg{}
}
func GoBack() tea.Msg {
return GoBackMsg{}
}
func LoadHome() tea.Msg {
return LoadHomeMsg{}
}
func LoadMorePosts(home bool) tea.Cmd {
return func() tea.Msg {
return LoadMorePostsMsg(home)
}
}
func LoadSubreddit(subreddit string) tea.Cmd {
return func() tea.Msg {
return LoadSubredditMsg(subreddit)
}
}
func LoadComments(url string) tea.Cmd {
return func() tea.Msg {
return LoadCommentsMsg(url)
}
}
func LoadingComplete() tea.Msg {
return LoadingCompleteMsg{}
}
func OpenModal() tea.Msg {
return OpenModalMsg{}
}
func ExitModal() tea.Msg {
return ExitModalMsg{}
}
func ShowSpinnerModal(loadingMsg string) tea.Cmd {
return func() tea.Msg {
return ShowSpinnerModalMsg(loadingMsg)
}
}
func ShowErrorModal(errorMsg string) tea.Cmd {
return func() tea.Msg {
return ShowErrorModalMsg{ErrorMsg: errorMsg}
}
}
func ShowErrorModalWithCallback(errorMsg string, callback tea.Cmd) tea.Cmd {
return func() tea.Msg {
return ShowErrorModalMsg{ErrorMsg: errorMsg, OnClose: callback}
}
}
func HideSpinnerModal() tea.Msg {
return ExitModalMsg{}
}
func OpenUrl(url string) tea.Cmd {
return func() tea.Msg {
return OpenUrlMsg(url)
}
}
================================================
FILE: components/modal/error.go
================================================
package modal
import (
"fmt"
"reddittui/components/colors"
"reddittui/components/messages"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
defaultErrorStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Red)).Bold(true)
errorMsgStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text))
)
type ErrorModal struct {
ErrorMsg string
}
func NewErrorModal() ErrorModal {
return ErrorModal{}
}
func (e ErrorModal) Init() tea.Cmd {
return nil
}
func (e ErrorModal) Update(msg tea.Msg) (ErrorModal, tea.Cmd) {
switch msg.(type) {
case tea.KeyMsg:
// Press any key to exit modal
return e, messages.ExitModal
}
return e, nil
}
func (e ErrorModal) View() string {
defaultErrorView := defaultErrorStyle.Render("Error:")
errorMsgView := errorMsgStyle.Render(e.ErrorMsg)
return fmt.Sprintf("%s %s", defaultErrorView, errorMsgView)
}
================================================
FILE: components/modal/modal.go
================================================
package modal
import (
"reddittui/components/colors"
"reddittui/components/messages"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type SessionState int
const (
defaultState SessionState = iota
loading
searching
quitting
showingError
)
var modalStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder(), true).
BorderForeground(colors.AdaptiveColor(colors.Blue)).
Padding(1, 2).
Margin(1, 1)
type ModalManager struct {
quit QuitModal
search SubredditSearchModal
spinner SpinnerModal
errorModal ErrorModal
state SessionState
style lipgloss.Style
onClose tea.Cmd
}
func NewModalManager() ModalManager {
return ModalManager{
quit: NewQuitModal(),
search: NewSubredditSearchModal(),
spinner: NewSpinnerModal(),
errorModal: NewErrorModal(),
style: modalStyle,
}
}
func (m ModalManager) Init() tea.Cmd {
return nil
}
func (m ModalManager) Update(msg tea.Msg) (ModalManager, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
if m.state != defaultState {
m, cmd = m.handleFocusedMessages(msg)
cmds = append(cmds, cmd)
}
m, cmd = m.handleGlobalMessages(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m ModalManager) handleGlobalMessages(msg tea.Msg) (ModalManager, tea.Cmd) {
switch msg := msg.(type) {
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case messages.ShowSpinnerModalMsg:
loadingMsg := string(msg)
return m, m.SetLoading(loadingMsg)
case messages.ShowErrorModalMsg:
return m, m.SetErrorWithCallback(msg.ErrorMsg, msg.OnClose)
case tea.KeyMsg:
switch msg.String() {
case "esc", "q":
if m.state == defaultState {
return m, m.SetQuitting()
}
case "s", "S":
return m, m.SetSearching()
}
}
return m, nil
}
func (m ModalManager) handleFocusedMessages(msg tea.Msg) (ModalManager, tea.Cmd) {
var cmd tea.Cmd
switch m.state {
case loading:
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case quitting:
m.quit, cmd = m.quit.Update(msg)
return m, cmd
case searching:
m.search, cmd = m.search.Update(msg)
return m, cmd
case showingError:
m.errorModal, cmd = m.errorModal.Update(msg)
return m, cmd
default:
return m, nil
}
}
func (m ModalManager) View(background Viewer) string {
switch m.state {
case loading:
return PlaceModal(m.spinner, background, lipgloss.Center, lipgloss.Center, m.style)
case quitting:
return PlaceModal(m.quit, background, lipgloss.Center, lipgloss.Center, m.style)
case searching:
return PlaceModal(m.search, background, lipgloss.Center, lipgloss.Center, m.style)
case showingError:
return PlaceModal(m.errorModal, background, lipgloss.Center, lipgloss.Center, m.style)
default:
// This sometimes happens when loading completes before the loading modal finishes rendering
return ""
}
}
func (m *ModalManager) SetSize(w, h int) {
m.search.SetSize(w, h)
modalSize := int((float64(w) * (2)) / 3.0)
m.style = m.style.MaxWidth(modalSize)
}
func (m *ModalManager) Blur() tea.Cmd {
m.state = defaultState
m.search.Blur()
onClose := m.onClose
m.onClose = nil
return onClose
}
func (m *ModalManager) SetLoading(message string) tea.Cmd {
m.state = loading
m.spinner.SetLoading(message)
return m.spinner.Tick
}
func (m *ModalManager) SetSearching() tea.Cmd {
m.state = searching
m.search.Focus()
return messages.OpenModal
}
func (m *ModalManager) SetQuitting() tea.Cmd {
m.state = quitting
return messages.OpenModal
}
func (m *ModalManager) SetError(errorMsg string) tea.Cmd {
m.state = showingError
m.errorModal.ErrorMsg = errorMsg
return messages.OpenModal
}
func (m *ModalManager) SetErrorWithCallback(errorMsg string, onClose tea.Cmd) tea.Cmd {
m.state = showingError
m.onClose = onClose
m.errorModal.ErrorMsg = errorMsg
return messages.OpenModal
}
================================================
FILE: components/modal/quit.go
================================================
package modal
import (
"fmt"
"reddittui/components/colors"
"reddittui/components/messages"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
quitMsg = "Are you sure you want to quit?"
yesNoMsg = "(y/n)"
)
var (
quitTitleStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)).Italic(true)
quitYesNoStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)).Bold(true)
)
type QuitModal struct{}
func NewQuitModal() QuitModal {
return QuitModal{}
}
func (q QuitModal) View() string {
titleView := quitTitleStyle.Render(quitMsg)
yesNoView := quitYesNoStyle.Render(yesNoMsg)
return fmt.Sprintf("%s %s", titleView, yesNoView)
}
func (q QuitModal) Update(msg tea.Msg) (QuitModal, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "y", "Y", "q", "Q", "esc":
return q, tea.Quit
default:
return q, messages.ExitModal
}
}
return q, nil
}
================================================
FILE: components/modal/render.go
================================================
package modal
import (
"bytes"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
"github.com/muesli/ansi"
"github.com/muesli/reflow/truncate"
"github.com/muesli/termenv"
)
const maxModalWidthPercentage = 0.66
// Most code in this file is taken from the following codebases (modals are not currently supported in charmbracelet)
//
// https://github.com/mrusme/neonmodem/blob/a237769cbfc526bec380d90e4a3b873fbb47e77c/ui/helpers/overlay.go#L37
// https://github.com/charmbracelet/lipgloss/pull/102
type Viewer interface {
View() string
}
// func Place(fg, bg string, xPos, yPos lipgloss.Position) string {
func PlaceModal(foreground, background Viewer, xPos, yPos lipgloss.Position, modalStyle lipgloss.Style) string {
var (
x int
y int
initFg = modalStyle.Render(foreground.View())
initFgWidth = lipgloss.Width(initFg)
fgWidth = min(initFgWidth, int(float64(modalStyle.GetMaxWidth())*maxModalWidthPercentage))
fg = modalStyle.Width(fgWidth).Render(foreground.View())
fgHeight = lipgloss.Height(fg)
bg = background.View()
bgWidth = lipgloss.Width(bg)
bgHeight = lipgloss.Height(bg)
)
switch xPos {
case lipgloss.Left:
x = 0
case lipgloss.Center:
x = (bgWidth / 2) - (fgWidth / 2) - 1
case lipgloss.Right:
x = bgWidth - fgWidth
}
switch yPos {
case lipgloss.Top:
y = 0
case lipgloss.Center:
// 45% looks more pleasing than 50% for center aligned modals
y = int((float64(bgHeight) * 0.45)) - (fgHeight / 2) - 1
case lipgloss.Bottom:
x = bgHeight - fgHeight
}
return Place(x, y, fg, bg, false)
}
// PlaceOverlay places fg on top of bg.
func Place(
x, y int,
fg, bg string,
shadow bool, opts ...WhitespaceOption,
) string {
fgLines, fgWidth := getLines(fg)
bgLines, bgWidth := getLines(bg)
bgHeight := len(bgLines)
fgHeight := len(fgLines)
if shadow {
var shadowbg string = ""
shadowchar := lipgloss.NewStyle().
Foreground(lipgloss.Color("#333333")).
Render("░")
for i := 0; i <= fgHeight; i++ {
if i == 0 {
shadowbg += " " + strings.Repeat(" ", fgWidth) + "\n"
} else {
shadowbg += " " + strings.Repeat(shadowchar, fgWidth) + "\n"
}
}
fg = Place(0, 0, fg, shadowbg, false, opts...)
fgLines, fgWidth = getLines(fg)
fgHeight = len(fgLines)
}
if fgWidth >= bgWidth && fgHeight >= bgHeight {
// FIXME: return fg or bg?
return fg
}
// TODO: allow placement outside of the bg box?
x = clamp(x, 0, bgWidth-fgWidth)
y = clamp(y, 0, bgHeight-fgHeight)
ws := &whitespace{}
for _, opt := range opts {
opt(ws)
}
var b strings.Builder
for i, bgLine := range bgLines {
if i > 0 {
b.WriteByte('\n')
}
if i < y || i >= y+fgHeight {
b.WriteString(bgLine)
continue
}
pos := 0
if x > 0 {
left := truncate.String(bgLine, uint(x))
pos = ansi.PrintableRuneWidth(left)
b.WriteString(left)
if pos < x {
b.WriteString(ws.render(x - pos))
pos = x
}
}
fgLine := fgLines[i-y]
b.WriteString(fgLine)
pos += ansi.PrintableRuneWidth(fgLine)
right := cutLeft(bgLine, pos)
bgWidth := ansi.PrintableRuneWidth(bgLine)
rightWidth := ansi.PrintableRuneWidth(right)
if rightWidth <= bgWidth-pos {
b.WriteString(ws.render(bgWidth - rightWidth - pos))
}
b.WriteString(right)
}
return b.String()
}
// Split a string into lines, additionally returning the size of the widest
// line.
func getLines(s string) (lines []string, widest int) {
lines = strings.Split(s, "\n")
for _, l := range lines {
w := ansi.PrintableRuneWidth(l)
if widest < w {
widest = w
}
}
return lines, widest
}
// cutLeft cuts printable characters from the left.
// This function is heavily based on muesli's ansi and truncate packages.
func cutLeft(s string, cutWidth int) string {
var (
pos int
isAnsi bool
ab bytes.Buffer
b bytes.Buffer
)
for _, c := range s {
var w int
if c == ansi.Marker || isAnsi {
isAnsi = true
ab.WriteRune(c)
if ansi.IsTerminator(c) {
isAnsi = false
if bytes.HasSuffix(ab.Bytes(), []byte("[0m")) {
ab.Reset()
}
}
} else {
w = runewidth.RuneWidth(c)
}
if pos >= cutWidth {
if b.Len() == 0 {
if ab.Len() > 0 {
b.Write(ab.Bytes())
}
if pos-cutWidth > 1 {
b.WriteByte(' ')
continue
}
}
b.WriteRune(c)
}
pos += w
}
return b.String()
}
func clamp(v, lower, upper int) int {
return min(max(v, lower), upper)
}
type whitespace struct {
style termenv.Style
chars string
}
// Render whitespaces.
func (w whitespace) render(width int) string {
if w.chars == "" {
w.chars = " "
}
r := []rune(w.chars)
j := 0
b := strings.Builder{}
// Cycle through runes and print them into the whitespace.
for i := 0; i < width; {
b.WriteRune(r[j])
j++
if j >= len(r) {
j = 0
}
i += ansi.PrintableRuneWidth(string(r[j]))
}
// Fill any extra gaps white spaces. This might be necessary if any runes
// are more than one cell wide, which could leave a one-rune gap.
short := width - ansi.PrintableRuneWidth(b.String())
if short > 0 {
b.WriteString(strings.Repeat(" ", short))
}
return w.style.Styled(b.String())
}
// WhitespaceOption sets a styling rule for rendering whitespace.
type WhitespaceOption func(*whitespace)
================================================
FILE: components/modal/search.go
================================================
package modal
import (
"reddittui/components/colors"
"reddittui/components/messages"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
searchHelpText = "Choose a subreddit:"
searchPlaceholder = "subreddit"
defaultSearchWidth = 35
)
var (
searchHelpStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)).Italic(true)
searchModelStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Purple))
)
type SubredditSearchModal struct {
textinput.Model
style lipgloss.Style
}
func NewSubredditSearchModal() SubredditSearchModal {
searchTextInput := textinput.New()
searchTextInput.Placeholder = searchPlaceholder
searchTextInput.ShowSuggestions = true
searchTextInput.SetSuggestions(subredditSuggestions)
searchTextInput.CharLimit = 30
return SubredditSearchModal{
Model: searchTextInput,
style: lipgloss.NewStyle(),
}
}
func (s SubredditSearchModal) Init() tea.Cmd {
return nil
}
func (s SubredditSearchModal) Update(msg tea.Msg) (SubredditSearchModal, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
return s, messages.LoadSubreddit(s.Value())
case "esc":
return s, messages.ExitModal
}
}
var cmd tea.Cmd
s.Model, cmd = s.Model.Update(msg)
return s, cmd
}
func (s SubredditSearchModal) View() string {
titleView := searchHelpStyle.Render(searchHelpText)
modelView := searchModelStyle.Render(s.Model.View())
joined := lipgloss.JoinVertical(lipgloss.Left, titleView, modelView)
return s.style.Render(joined)
}
func (s *SubredditSearchModal) SetSize(w, h int) {
searchW := min(w-s.style.GetHorizontalFrameSize(), defaultSearchWidth)
s.style = s.style.Width(searchW)
}
func (s *SubredditSearchModal) Blur() {
s.Model.Blur()
s.Reset()
}
================================================
FILE: components/modal/spinner.go
================================================
package modal
import (
"fmt"
"reddittui/components/colors"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
spinnerStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Purple))
spinnerTextStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)).Italic(true)
)
type SpinnerModal struct {
spinner.Model
LoadingMessage string
}
func NewSpinnerModal() SpinnerModal {
model := spinner.New()
model.Spinner = spinner.Dot
model.Style = spinnerStyle
return SpinnerModal{
Model: model,
}
}
func (s SpinnerModal) Init() tea.Cmd {
return s.Tick
}
func (s SpinnerModal) Update(msg tea.Msg) (SpinnerModal, tea.Cmd) {
var cmd tea.Cmd
s.Model, cmd = s.Model.Update(msg)
return s, cmd
}
func (s SpinnerModal) View() string {
loadingTextView := spinnerTextStyle.Render(s.LoadingMessage)
return fmt.Sprintf("%s %s", s.Model.View(), loadingTextView)
}
func (s *SpinnerModal) SetLoading(message string) {
model := spinner.New()
model.Spinner = spinner.Dot
model.Style = spinnerStyle
s.Model = model
s.LoadingMessage = message
}
================================================
FILE: components/modal/subredditList.go
================================================
package modal
var subredditSuggestions = []string{
"15minutefood",
"adviceanimals",
"all",
"animalsbeingbros",
"animalsbeingderps",
"animalsbeingjerks",
"anime",
"anime_irl",
"apple",
"art",
"askreddit",
"askscience",
"aww",
"awwducational",
"backpacking",
"baking",
"battlestations",
"beamazed",
"bestof",
"bikinibottomtwitter",
"biology",
"bitcoin",
"boardgames",
"bodyweightfitness",
"books",
"buildapc",
"camping",
"canada",
"careerguidance",
"cars",
"cats",
"cfb",
"changemyview",
"chatgpt",
"chemistry",
"comicbooks",
"compsci",
"contagiouslaughter",
"cooking",
"coolguides",
"cozyplaces",
"crappydesign",
"creepy",
"cryptocurrency",
"dadjokes",
"damnthatsinteresting",
"dataisbeautiful",
"dating",
"dating_advice",
"daytrading",
"design",
"destinythegame",
"digitalpainting",
"diwhy",
"diy",
"dnd",
"documentaries",
"drawing",
"dundermifflin",
"eatcheapandhealthy",
"economics",
"eldenring",
"entertainment",
"entrepreneur",
"ethereum",
"europe",
"expectationvsreality",
"explainlikeimfive",
"eyebleach",
"facepalm",
"fantasy",
"fantasyfootball",
"fauxmoi",
"femalefashionadvice",
"fitness",
"food",
"foodhacks",
"formula1",
"fortnitebr",
"frugal",
"funny",
"funnyanimals",
"futurology",
"gadgets",
"gameofthrones",
"games",
"gaming",
"gardening",
"genshin_impact",
"getmotivated",
"gifrecipes",
"gifs",
"google",
"hair",
"hardware",
"health",
"healthyfood",
"highqualitygifs",
"history",
"historymemes",
"holup",
"homeautomation",
"homeimprovement",
"homestead",
"howto",
"humansbeingbros",
"iama",
"idiotsincars",
"indieheads",
"interestingasfuck",
"internetisbeautiful",
"iphone",
"itookapicture",
"japantravel",
"jokes",
"keto",
"kpop",
"leagueoflegends",
"learnprogramming",
"lifehacks",
"lifeprotips",
"listentothis",
"loseit",
"mademesmile",
"makeupaddiction",
"malefashionadvice",
"maliciouscompliance",
"marvelmemes",
"marvelstudios",
"math",
"maybemaybemaybe",
"mealprepsunday",
"meditation",
"memes",
"mildlyinfuriating",
"mildlyinteresting",
"minecraft",
"minecraftmemes",
"mma",
"modernwarfareii",
"motorcycles",
"moviedetails",
"movies",
"music",
"mypeopleneedme",
"nails",
"nasa",
"natureisfuckinglit",
"nba",
"netflixbestof",
"nevertellmetheodds",
"news",
"nfl",
"nintendoswitch",
"nosleep",
"nostupidquestions",
"nottheonion",
"nutrition",
"oddlysatisfying",
"oddlyspecific",
"offmychest",
"oldschoolcool",
"onepiece",
"outdoors",
"outoftheloop",
"overwatch",
"painting",
"parenting",
"pcgaming",
"pcmasterrace",
"personalfinance",
"pettyrevenge",
"philosophy",
"photography",
"photoshopbattles",
"pics",
"podcasts",
"pokemon",
"pokemongo",
"politics",
"popculturechat",
"premierleague",
"prequelmemes",
"productivity",
"programmerhumor",
"programming",
"ps4",
"ps5",
"psychology",
"rarepuppers",
"reactiongifs",
"recipes",
"relationship_advice",
"relationshipmemes",
"roadtrip",
"running",
"science",
"sciencememes",
"scifi",
"shoestring",
"showerthoughts",
"singularity",
"skincareaddiction",
"slowcooking",
"sneakers",
"soccer",
"socialskills",
"solotravel",
"space",
"spacex",
"sports",
"standupshots",
"starterpacks",
"starwars",
"steam",
"stockmarket",
"streetwear",
"strength_training",
"survival",
"tattoos",
"taylorswift",
"technicallythetruth",
"technology",
"television",
"teslamotors",
"thriftstorehauls",
"tifu",
"tinder",
"todayilearned",
"travel",
"travelhacks",
"trippinthroughtime",
"unexpected",
"unitedkingdom",
"unpopularopinion",
"upliftingnews",
"videos",
"wallstreetbets",
"watchpeopledieinside",
"wearethemusicmakers",
"wholesomememes",
"woahdude",
"woodworking",
"worldnews",
"writingprompts",
"youshouldknow",
}
================================================
FILE: components/posts/header.go
================================================
package posts
import (
"reddittui/components/colors"
"reddittui/utils"
"github.com/charmbracelet/lipgloss"
)
var (
headerContainerStyle = lipgloss.NewStyle().MarginBottom(2)
titleStyle = lipgloss.NewStyle().
MarginBottom(1).
Padding(0, 2).
Height(1).
Background(colors.AdaptiveColors(colors.Blue, colors.Indigo)).
Foreground(colors.AdaptiveColors(colors.White, colors.Sand))
defaultDescriptionStyle = lipgloss.NewStyle().
Bold(true).
Foreground(colors.AdaptiveColor(colors.Text))
)
type PostsHeader struct {
DescriptionStyle lipgloss.Style
Title string
Description string
W int
}
func NewPostsHeader() PostsHeader {
return PostsHeader{DescriptionStyle: defaultDescriptionStyle}
}
func (h *PostsHeader) SetSize(width, height int) {
h.W = width - headerContainerStyle.GetHorizontalFrameSize()
h.DescriptionStyle = h.DescriptionStyle.Width(h.W)
}
func (h PostsHeader) View() string {
titleView := titleStyle.Render(utils.TruncateString(h.Title, h.W))
descriptionView := h.DescriptionStyle.Render(h.Description)
joinedView := lipgloss.JoinVertical(lipgloss.Left, titleView, descriptionView)
return headerContainerStyle.Render(joinedView)
}
func (h *PostsHeader) SetContent(title, desc string) {
h.Title = utils.NormalizeSubreddit(title)
h.Description = desc
}
================================================
FILE: components/posts/keys.go
================================================
package posts
import "github.com/charmbracelet/bubbles/key"
type postsKeyMap struct {
Home key.Binding
Search key.Binding
Back key.Binding
Load key.Binding
}
var postsKeys = postsKeyMap{
Home: key.NewBinding(
key.WithKeys("H"),
key.WithHelp("H", "home")),
Search: key.NewBinding(
key.WithKeys("s"),
key.WithHelp("s", "subreddit search")),
Back: key.NewBinding(
key.WithKeys("bs"),
key.WithHelp("bs", "back")),
Load: key.NewBinding(
key.WithKeys("L"),
key.WithHelp("L", "load more posts")),
}
func (k postsKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Home, k.Search, k.Load}
}
func (k postsKeyMap) FullHelp() []key.Binding {
return []key.Binding{k.Home, k.Search, k.Back, k.Load}
}
================================================
FILE: components/posts/postsPage.go
================================================
package posts
import (
"fmt"
"log/slog"
"reddittui/client"
"reddittui/client/common"
"reddittui/components/messages"
"reddittui/components/styles"
"reddittui/model"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
defaultHeaderTitle = "reddit.com"
defaultHeaderDescription = "The front page of the internet"
postsErrorText = "Could not load posts. Please try again in a few moments."
subredditNotFoundText = "Subreddit not found"
)
type PostsPage struct {
Subreddit string
posts model.Posts
redditClient client.RedditClient
header PostsHeader
list list.Model
focus bool
Home bool
containerStyle lipgloss.Style
}
func NewPostsPage(redditClient client.RedditClient, home bool) PostsPage {
items := list.New(nil, NewPostsDelegate(), 0, 0)
items.SetShowTitle(false)
items.SetShowStatusBar(false)
items.KeyMap.NextPage.SetEnabled(false)
items.KeyMap.PrevPage.SetEnabled(false)
items.SetFilteringEnabled(false)
items.AdditionalShortHelpKeys = postsKeys.ShortHelp
items.AdditionalFullHelpKeys = postsKeys.FullHelp
header := NewPostsHeader()
if home {
header.SetContent(defaultHeaderTitle, defaultHeaderDescription)
}
containerStyle := styles.GlobalStyle
return PostsPage{
list: items,
redditClient: redditClient,
header: header,
Home: home,
containerStyle: containerStyle,
}
}
func (p PostsPage) Init() tea.Cmd {
return nil
}
func (p PostsPage) Update(msg tea.Msg) (PostsPage, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
if p.focus {
p, cmd = p.handleFocusedMessages(msg)
cmds = append(cmds, cmd)
}
p, cmd = p.handleGlobalMessages(msg)
cmds = append(cmds, cmd)
return p, tea.Batch(cmds...)
}
func (p PostsPage) handleGlobalMessages(msg tea.Msg) (PostsPage, tea.Cmd) {
switch msg := msg.(type) {
case messages.LoadHomeMsg:
if p.Home {
return p, p.loadHome()
}
case messages.LoadSubredditMsg:
if !p.Home {
subreddit := string(msg)
return p, p.loadSubreddit(subreddit)
}
case messages.LoadMorePostsMsg:
isHome := bool(msg)
if p.Home == isHome {
return p, p.loadMorePosts()
}
case messages.UpdatePostsMsg:
posts := model.Posts(msg)
if posts.IsHome == p.Home {
p.updatePosts(posts)
return p, messages.LoadingComplete
}
case messages.AddMorePostsMsg:
posts := model.Posts(msg)
if posts.IsHome == p.Home {
p.addPosts(posts)
return p, messages.LoadingComplete
}
}
return p, nil
}
func (p PostsPage) handleFocusedMessages(msg tea.Msg) (PostsPage, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch keypress := msg.String(); keypress {
case "enter", "right", "l":
loadCommentsCmd := func() tea.Msg {
post := p.posts.Posts[p.list.Index()]
return messages.LoadCommentsMsg(post.CommentsUrl)
}
return p, loadCommentsCmd
case "q", "Q":
// Ignore q keystrokes to list.Modal. since it will default to sending a Quit message
// instead of showing the quit modal. Tui component will correctly handle quit mesages
return p, nil
case "L":
return p, messages.LoadMorePosts(p.Home)
case "H":
return p, messages.LoadHome
case "esc", "backspace", "left", "h":
return p, messages.GoBack
}
}
var cmd tea.Cmd
p.list, cmd = p.list.Update(msg)
return p, cmd
}
func (p PostsPage) View() string {
if len(p.posts.Posts) == 0 {
return p.containerStyle.Render("")
}
headerView := p.header.View()
listView := p.list.View()
joined := lipgloss.JoinVertical(lipgloss.Left, headerView, listView)
return p.containerStyle.Render(joined)
}
func (p *PostsPage) SetSize(w, h int) {
p.containerStyle = p.containerStyle.Width(w).Height(h)
p.resizeComponents()
}
func (p *PostsPage) Focus() {
p.focus = true
}
func (p *PostsPage) Blur() {
p.focus = false
}
func (p *PostsPage) resizeComponents() {
var (
w = p.containerStyle.GetWidth() - p.containerStyle.GetHorizontalFrameSize()
h = p.containerStyle.GetHeight() - p.containerStyle.GetVerticalFrameSize()
listWidth = w - postsListStyle.GetHorizontalFrameSize()
headerHeight = lipgloss.Height(p.header.View())
listHeight = h - headerHeight
)
p.header.SetSize(w, h)
p.list.SetSize(listWidth, listHeight)
}
func (p *PostsPage) loadHome() tea.Cmd {
return func() tea.Msg {
posts, err := p.redditClient.GetHomePosts("")
if err != nil {
slog.Error(postsErrorText, "error", err)
return messages.ShowErrorModalMsg{ErrorMsg: postsErrorText}
}
return messages.UpdatePostsMsg(posts)
}
}
func (p *PostsPage) loadMorePosts() tea.Cmd {
return func() tea.Msg {
var (
posts model.Posts
err error
)
if len(p.posts.After) == 0 {
slog.Error(postsErrorText, "error", err)
return messages.ShowErrorModalMsg{ErrorMsg: postsErrorText}
}
if p.posts.IsHome {
posts, err = p.redditClient.GetHomePosts(p.posts.After)
} else {
posts, err = p.redditClient.GetSubredditPosts(p.Subreddit, p.posts.After)
}
if err != nil {
slog.Error(postsErrorText, "error", err)
return messages.ShowErrorModalMsg{ErrorMsg: postsErrorText}
}
return messages.AddMorePostsMsg(posts)
}
}
func (p PostsPage) loadSubreddit(subreddit string) tea.Cmd {
return func() tea.Msg {
posts, err := p.redditClient.GetSubredditPosts(subreddit, "")
if err == common.ErrNotFound {
slog.Error(subredditNotFoundText, "error", err, "subreddit", subreddit)
return messages.ShowErrorModalMsg{ErrorMsg: fmt.Sprintf("%s: %s", subredditNotFoundText, subreddit)}
} else if err != nil {
slog.Error(postsErrorText, "error", err)
return messages.ShowErrorModalMsg{ErrorMsg: postsErrorText}
}
return messages.UpdatePostsMsg(posts)
}
}
func (p *PostsPage) updatePosts(posts model.Posts) {
p.posts = posts
if posts.IsHome {
p.header.SetContent(defaultHeaderTitle, defaultHeaderDescription)
} else {
p.header.SetContent(posts.Subreddit, posts.Description)
p.Subreddit = posts.Subreddit
}
p.list.ResetSelected()
var listItems []list.Item
for _, p := range posts.Posts {
listItems = append(listItems, p)
}
p.list.SetItems(listItems)
// Need to set size again when content loads so padding and margins are correct
p.resizeComponents()
}
func (p *PostsPage) addPosts(posts model.Posts) {
uniqueTitles := make(map[string]bool)
p.posts.Posts = append(p.posts.Posts, posts.Posts...)
p.posts.After = posts.After
// Merge existing posts with new posts, avoiding duplicates
var listItems []list.Item
for _, p := range p.posts.Posts {
if _, ok := uniqueTitles[p.PostTitle]; !ok {
listItems = append(listItems, p)
uniqueTitles[p.PostTitle] = true
}
}
for _, p := range posts.Posts {
if _, ok := uniqueTitles[p.PostTitle]; !ok {
listItems = append(listItems, p)
uniqueTitles[p.PostTitle] = true
}
}
p.list.SetItems(listItems)
// Need to set size again when content loads so padding and margins are correct
p.resizeComponents()
}
================================================
FILE: components/posts/styles.go
================================================
package posts
import (
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/lipgloss"
)
var postsListStyle = lipgloss.NewStyle().MarginRight(4)
func NewPostsDelegate() list.DefaultDelegate {
delegate := list.NewDefaultDelegate()
listStyle := delegate.Styles
listStyle.NormalTitle = listStyle.NormalTitle.Bold(false)
listStyle.SelectedTitle = listStyle.SelectedTitle.Bold(true)
delegate.Styles = listStyle
return delegate
}
================================================
FILE: components/styles/style.go
================================================
package styles
import "github.com/charmbracelet/lipgloss"
var GlobalStyle = lipgloss.NewStyle().Padding(1, 2)
================================================
FILE: components/tui.go
================================================
package components
import (
"fmt"
"log/slog"
"reddittui/client"
"reddittui/components/comments"
"reddittui/components/messages"
"reddittui/components/modal"
"reddittui/components/posts"
"reddittui/config"
"reddittui/utils"
tea "github.com/charmbracelet/bubbletea"
)
const defaultLoadingMessage = "loading reddit.com..."
type (
pageType int
)
const (
HomePage pageType = iota
SubredditPage
CommentsPage
)
type RedditTui struct {
redditClient client.RedditClient
homePage posts.PostsPage
subredditPage posts.PostsPage
commentsPage comments.CommentsPage
modalManager modal.ModalManager
popup bool
initializing bool
page pageType
prevPage pageType
loadingPage pageType
initCmd tea.Cmd
}
func NewRedditTui(configuration config.Config, subreddit, post string) RedditTui {
redditClient := client.NewRedditClient(configuration)
homePage := posts.NewPostsPage(redditClient, true)
subredditPage := posts.NewPostsPage(redditClient, false)
commentsPage := comments.NewCommentsPage(redditClient)
modalManager := modal.NewModalManager()
return RedditTui{
redditClient: redditClient,
homePage: homePage,
subredditPage: subredditPage,
commentsPage: commentsPage,
modalManager: modalManager,
initializing: true,
initCmd: getInitCmd(redditClient.BaseUrl, subreddit, post),
}
}
func getInitCmd(baseUrl, subreddit, post string) tea.Cmd {
if len(subreddit) != 0 {
return messages.LoadSubreddit(subreddit)
} else if len(post) != 0 {
url, err := client.GetPostUrl(baseUrl, post)
if err != nil {
panic(fmt.Sprintf("Could not load post %s: %v", post, err))
}
return messages.LoadComments(url)
} else {
return tea.Batch(messages.CleanCache, messages.LoadHome)
}
}
func (r RedditTui) Init() tea.Cmd {
return messages.LoadHome
}
func (r RedditTui) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var (
cmds []tea.Cmd
cmd tea.Cmd
)
switch msg := msg.(type) {
case messages.ShowErrorModalMsg:
if r.initializing && msg.OnClose == nil {
slog.Error("Error during initialization")
if r.loadingPage == HomePage {
errorMsg := "Could not initialize reddittui. Check the logfile for details."
return r, messages.ShowErrorModalWithCallback(errorMsg, tea.Quit)
}
var errorMsg string
if r.loadingPage == SubredditPage {
errorMsg = "Error loading subreddit. Returning to home page..."
} else {
errorMsg = "Error loading post. Returning to home page..."
}
return r, messages.ShowErrorModalWithCallback(errorMsg, messages.LoadHome)
}
case messages.CleanCacheMsg:
r.redditClient.CleanCache()
return r, nil
case messages.OpenModalMsg:
r.focusModal()
return r, nil
case messages.LoadingCompleteMsg:
cmd = r.completeLoading()
return r, cmd
case messages.ExitModalMsg:
r.popup = false
r.focusActivePage()
cmd = r.modalManager.Blur()
return r, cmd
case messages.GoBackMsg:
r.goBack()
return r, nil
case messages.LoadHomeMsg:
if r.page == HomePage && !r.initializing {
return r, r.modalManager.Blur()
}
r.focusModal()
r.loadingPage = HomePage
cmd = r.modalManager.SetLoading(defaultLoadingMessage)
cmds = append(cmds, cmd)
case messages.LoadSubredditMsg:
subreddit := string(msg)
r.focusModal()
r.loadingPage = SubredditPage
loadingMsg := fmt.Sprintf("loading %s...", utils.NormalizeSubreddit(subreddit))
cmd = r.modalManager.SetLoading(loadingMsg)
cmds = append(cmds, cmd)
case messages.LoadMorePostsMsg:
r.focusModal()
r.loadingPage = r.page
cmd = r.modalManager.SetLoading("loading posts...")
cmds = append(cmds, cmd)
case messages.LoadCommentsMsg:
r.focusModal()
r.loadingPage = CommentsPage
cmd = r.modalManager.SetLoading("loading comments...")
cmds = append(cmds, cmd)
case messages.OpenUrlMsg:
url := string(msg)
if err := utils.OpenUrl(url); err != nil {
slog.Error("Error opening url in browser", "url", url, "error", err.Error())
cmd = r.modalManager.SetError(fmt.Sprintf("Could not open url %s in browser", url))
cmds = append(cmds, cmd)
}
case tea.WindowSizeMsg:
r.homePage.SetSize(msg.Width, msg.Height)
r.subredditPage.SetSize(msg.Width, msg.Height)
r.commentsPage.SetSize(msg.Width, msg.Height)
r.modalManager.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return r, tea.Quit
}
}
r.modalManager, cmd = r.modalManager.Update(msg)
cmds = append(cmds, cmd)
r.homePage, cmd = r.homePage.Update(msg)
cmds = append(cmds, cmd)
r.subredditPage, cmd = r.subredditPage.Update(msg)
cmds = append(cmds, cmd)
r.commentsPage, cmd = r.commentsPage.Update(msg)
cmds = append(cmds, cmd)
return r, tea.Batch(cmds...)
}
func (r RedditTui) View() string {
if r.popup {
switch r.page {
case HomePage:
return r.modalManager.View(r.homePage)
case SubredditPage:
return r.modalManager.View(r.subredditPage)
case CommentsPage:
return r.modalManager.View(r.commentsPage)
}
}
switch r.page {
case HomePage:
return r.homePage.View()
case SubredditPage:
return r.subredditPage.View()
case CommentsPage:
return r.commentsPage.View()
}
return ""
}
func (r *RedditTui) goBack() {
switch r.page {
case CommentsPage:
if r.prevPage == HomePage {
r.setPage(HomePage)
} else {
r.setPage(SubredditPage)
}
default:
r.setPage(HomePage)
}
r.focusActivePage()
}
func (r *RedditTui) setPage(page pageType) {
r.page, r.prevPage = page, r.page
}
func (r *RedditTui) completeLoading() tea.Cmd {
initializing := r.initializing
r.initializing = false
r.popup = false
r.setPage(r.loadingPage)
r.focusActivePage()
if initializing {
r.initializing = false
return r.initCmd
}
return r.modalManager.Blur()
}
func (r *RedditTui) focusModal() {
r.popup = true
r.homePage.Blur()
r.subredditPage.Blur()
r.commentsPage.Blur()
}
func (r *RedditTui) focusActivePage() {
switch r.page {
case HomePage:
r.homePage.Focus()
r.subredditPage.Blur()
r.commentsPage.Blur()
case SubredditPage:
r.homePage.Blur()
r.subredditPage.Focus()
r.commentsPage.Blur()
case CommentsPage:
r.homePage.Blur()
r.subredditPage.Blur()
r.commentsPage.Focus()
}
}
================================================
FILE: config/config.go
================================================
package config
import (
"log/slog"
"os"
"path/filepath"
"reddittui/utils"
"github.com/BurntSushi/toml"
)
const (
configFilename = "reddittui.toml"
defaultDomainName = "old.reddit.com"
defaultServerType = "old"
)
type Config struct {
Core CoreConfig `toml:"core"`
Filter FilterConfig `toml:"filter"`
Client ClientConfig `toml:"client"`
Server ServerConfig `toml:"server"`
}
type CoreConfig struct {
BypassCache bool
LogLevel string
ClientTimeout int // Legacy
}
type FilterConfig struct {
Keywords []string
Subreddits []string
}
type ClientConfig struct {
TimeoutSeconds int
CacheTtlSeconds int
}
type ServerConfig struct {
Domain string
Type string
}
func NewConfig() Config {
return Config{
Core: CoreConfig{
BypassCache: false,
LogLevel: "Warn",
ClientTimeout: 10,
},
Server: ServerConfig{
Domain: defaultDomainName,
Type: defaultServerType,
},
}
}
func LoadConfig() (Config, error) {
defaultConfig := NewConfig()
configDir, err := utils.GetConfigDir()
if err != nil {
slog.Warn("Could not get config directory", "error", err)
return defaultConfig, err
}
err = os.MkdirAll(configDir, 0755)
if err != nil {
slog.Warn("Could not make config directory", "error", err)
return defaultConfig, err
}
configPath := filepath.Join(configDir, configFilename)
configFile, err := os.Open(configPath)
if os.IsNotExist(err) {
createConfigFile(configPath)
return defaultConfig, err
} else if err != nil {
slog.Warn("Could not open config file", "error", err)
return defaultConfig, err
}
defer configFile.Close()
var configFromFile Config
decoder := toml.NewDecoder(configFile)
meta, err := decoder.Decode(&configFromFile)
if err != nil {
slog.Warn("Could not decode config file", "error", err)
return defaultConfig, err
}
mergedConfig := mergeConfig(defaultConfig, configFromFile, meta)
return mergedConfig, err
}
// Merge right config into left
func mergeConfig(left, right Config, meta toml.MetaData) Config {
if meta.IsDefined("core", "bypassCache") {
left.Core.BypassCache = right.Core.BypassCache
}
if meta.IsDefined("core", "logLevel") {
left.Core.LogLevel = right.Core.LogLevel
}
if meta.IsDefined("core", "clientTimeout") {
left.Core.ClientTimeout = right.Core.ClientTimeout
}
if meta.IsDefined("filter", "keywords") {
left.Filter.Keywords = right.Filter.Keywords
}
if meta.IsDefined("filter", "subreddits") {
left.Filter.Subreddits = right.Filter.Subreddits
}
if meta.IsDefined("client", "timeoutSeconds") {
left.Client.TimeoutSeconds = right.Client.TimeoutSeconds
}
if meta.IsDefined("client", "cacheTtlSeconds") {
left.Client.CacheTtlSeconds = right.Client.CacheTtlSeconds
}
if meta.IsDefined("server", "domain") {
left.Server.Domain = right.Server.Domain
}
if meta.IsDefined("server", "type") {
left.Server.Type = right.Server.Type
}
return left
}
func createConfigFile(configFilePath string) error {
configFile, err := os.Create(configFilePath)
if err != nil {
return err
}
_, err = configFile.WriteString(defaultConfiguration)
return err
}
================================================
FILE: config/defaultConfig.go
================================================
package config
const defaultConfiguration = `
#
# Default configuration for reddittui.
# Uncomment to configure
#
#[core]
#bypassCache = false
#logLevel = "Warn"
#[filter]
#keywords = ["drama"]
#subreddits = ["news", "politics"]
#[client]
#timeoutSeconds = 10
#cacheTtlSeconds = 3600
#[server]
#domain = "old.reddit.com"
#type = "old"
`
================================================
FILE: go.mod
================================================
module reddittui
go 1.23.4
require (
github.com/charmbracelet/bubbletea v1.2.4
github.com/charmbracelet/x/exp/teatest v0.0.0-20250303111204-ce812b082f54
github.com/muesli/reflow v0.3.0
golang.org/x/net v0.39.0
)
require (
github.com/aymanbagabas/go-udiff v0.2.0 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b // indirect
)
require (
github.com/BurntSushi/toml v1.4.0
github.com/atotto/clipboard v0.1.4 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/lipgloss v1.0.0
github.com/charmbracelet/x/ansi v0.4.5 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.2
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
================================================
FILE: go.sum
================================================
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
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/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE=
github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM=
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM=
github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/teatest v0.0.0-20250303111204-ce812b082f54 h1:VjFUoe3r4PNKSIiKn45jl7KL+ZYSUBh5gr+JxgvFG94=
github.com/charmbracelet/x/exp/teatest v0.0.0-20250303111204-ce812b082f54/go.mod h1:ag+SpTUkiN/UuUGYPX3Ci4fR1oF3XX97PpGhiXK7i6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/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.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
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.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
================================================
FILE: install.sh
================================================
#!/bin/bash
set -e
APP_NAME="reddittui"
BUILD_DIR="build"
GO_MAIN_FILE="main.go"
INSTALL_DIR="/usr/local/bin"
# Build reddittui
echo "Building reddittui application..."
mkdir -p "$BUILD_DIR"
go build -o "$BUILD_DIR/$APP_NAME" "$GO_MAIN_FILE"
# Install reddittui
echo "Installing reddittui..."
echo "Copying binary to $INSTALL_DIR (may require sudo)..."
sudo install -m 0755 "$BUILD_DIR/$APP_NAME" "$INSTALL_DIR/$APP_NAME"
echo "Installation complete. You can now run $APP_NAME' from your terminal."
================================================
FILE: integ_test.go
================================================
package main
import (
"bytes"
"os"
"reddittui/components"
"reddittui/config"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/exp/teatest"
)
const (
testTimeout = 20 * time.Second
testDomain = "old.reddit.com"
testServerType = "old"
)
func TestStartup(t *testing.T) {
t.Logf("Testing startup...")
configuration := getTestConfig()
tui := components.NewRedditTui(configuration, "", "")
tm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100))
t.Logf("\tVerify the loading screen shows on startup...")
WaitFor(t, tm, "loading reddit.com...")
t.Logf("\tVerify the home page loads...")
WaitFor(t, tm, "The front page of the internet")
}
func TestSwitchSubreddit(t *testing.T) {
t.Logf("Testing switching subreddit...")
configuration := getTestConfig()
tui := components.NewRedditTui(configuration, "", "")
tm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100))
t.Logf("\tVerify home page loads...")
WaitFor(t, tm, "The front page of the internet")
t.Logf("\tVerify subreddit selection modal shows...")
WaitForWithInputs(t, tm, "s", "Choose a subreddit:")
t.Logf("\tVerify dogs subreddit loads...")
tm.Send(tea.KeyMsg{
Type: tea.KeyRunes,
Runes: []rune("dogs"),
})
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
WaitFor(t, tm, "r/dogs")
}
func TestReturnToHomePage(t *testing.T) {
t.Logf("Testing returning to the home page after switching subreddits...")
configuration := getTestConfig()
tui := components.NewRedditTui(configuration, "", "")
tm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100))
t.Logf("\tVerify home page loads...")
WaitFor(t, tm, "The front page of the internet")
t.Logf("\tVerify subreddit selection modal shows...")
WaitForWithInputs(t, tm, "s", "Choose a subreddit:")
t.Logf("\tVerify dogs subreddit loads...")
tm.Send(tea.KeyMsg{
Type: tea.KeyRunes,
Runes: []rune("dogs"),
})
tm.Send(tea.KeyMsg{
Type: tea.KeyEnter,
})
WaitFor(t, tm, "r/dogs ")
t.Logf("\tVerify home page loads")
WaitForWithInputs(t, tm, "H", "The front page of the internet")
}
func TestShowComments(t *testing.T) {
t.Logf("Testing show post comments...")
configuration := getTestConfig()
tui := components.NewRedditTui(configuration, "", "")
tm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100))
t.Logf("\tVerify home page loads...")
WaitFor(t, tm, "The front page of the internet")
t.Logf("\tVerify subreddit selection modal shows...")
WaitForWithInputs(t, tm, "s", "Choose a subreddit:")
t.Logf("\tVerify dogs subreddit loads...")
tm.Send(tea.KeyMsg{
Type: tea.KeyRunes,
Runes: []rune("dogs"),
})
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
WaitFor(t, tm, "r/dogs", "/r/dogs")
t.Logf("\tVerify comments header loads...")
time.Sleep(time.Second)
WaitForWithInputs(t, tm, "l", "submitted", "ago by", "point", "comment")
}
func TestLoadInitialPostFromId(t *testing.T) {
t.Logf("Testing loading initial post...")
configuration := getTestConfig()
postId := "1jgxswb"
tui := components.NewRedditTui(configuration, "", postId)
tm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100))
t.Logf("\tVerify comments header loads...")
time.Sleep(time.Second)
WaitForWithInputs(t, tm, "l", "submitted", "ago by", "point", "comment")
}
func TestLoadInitialPostFromUrl(t *testing.T) {
t.Logf("Testing loading initial post...")
configuration := getTestConfig()
postUrl := "https://old.reddit.com/r/dogs/comments/1jh0yne/dog_becoming_cuddlier_as_a_senior/"
tui := components.NewRedditTui(configuration, "", postUrl)
tm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100))
t.Logf("\tVerify comments header loads...")
time.Sleep(time.Second)
WaitForWithInputs(t, tm, "l", "submitted", "ago by", "point", "comment")
}
func TestLoadInitialSubredditAndCanGoBack(t *testing.T) {
t.Logf("Testing loading subreddit...")
configuration := getTestConfig()
tui := components.NewRedditTui(configuration, "dogs", "")
tm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100))
t.Logf("\tVerify dog subreddit loads...")
WaitFor(t, tm, "r/dogs", "/r/dogs")
time.Sleep(time.Second)
// Go back
t.Logf("\tVerify the home page loads...")
WaitForWithInputs(t, tm, "h", "The front page of the internet")
}
func WaitFor(t *testing.T, tm *teatest.TestModel, messages ...string) {
WaitForWithInputs(t, tm, "", messages...)
}
func WaitForWithInputs(t *testing.T, tm *teatest.TestModel, inputs string, messages ...string) {
if len(inputs) > 0 {
tm.Send(tea.KeyMsg{
Type: tea.KeyRunes,
Runes: []rune(inputs),
})
}
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
for _, message := range messages {
if !bytes.Contains(bts, []byte(message)) {
return false
}
}
return true
}, teatest.WithCheckInterval(time.Millisecond*50), teatest.WithDuration(testTimeout))
}
func getTestConfig() config.Config {
configuration := config.NewConfig()
configuration.Core.BypassCache = true
domain := os.Getenv("TEST_DOMAIN")
serverType := os.Getenv("TEST_SERVER_TYPE")
if len(domain) > 0 && len(serverType) > 0 {
configuration.Server.Domain = domain
configuration.Server.Type = serverType
}
return configuration
}
================================================
FILE: justfile
================================================
@default: run
@run:
go run .
test:
go test -v ./...
clean:
rm -rf build/
rm -rf ~/.cache/reddittui/*
rm -rf ~/.local/state/reddittui/*
build:
@echo "Building reddittui..."
@echo "Creating build directory at build/..."
mkdir -p build
@echo "Installing dependencies..."
go mod tidy
@echo "Building reddittui application..."
go build -o build/reddittui main.go
@echo "Build complete."
install: build
@echo "Installing reddittui..."
./install.sh
@echo "Installation complete."
uninstall: clean
@echo "Cleaning reddittui..."
sudo rm -f /usr/local/bin/reddittui
@echo "Clean complete"
================================================
FILE: main.go
================================================
package main
import (
"flag"
"fmt"
"log/slog"
"os"
"reddittui/components"
"reddittui/config"
"reddittui/utils"
tea "github.com/charmbracelet/bubbletea"
)
const version = "v0.3.9"
type CliArgs struct {
subreddit string
postId string
showVersion bool
}
func main() {
configuration, _ := config.LoadConfig()
logFile, err := utils.InitLogger(configuration.Core.LogLevel)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not open logfile: %v\n", err)
}
defer logFile.Close()
var args CliArgs
flag.StringVar(&args.postId, "post", "", "Post id")
flag.StringVar(&args.subreddit, "subreddit", "", "Subreddit")
flag.BoolVar(&args.showVersion, "version", false, "Version")
flag.Parse()
if args.showVersion {
fmt.Printf("reddittui version %s\n", version)
os.Exit(0)
}
reddit := components.NewRedditTui(configuration, args.subreddit, args.postId)
p := tea.NewProgram(reddit, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
slog.Error("Error running reddittui, see logfile for details", "error", err)
os.Exit(1)
}
}
================================================
FILE: model/commentsModel.go
================================================
package model
import (
"fmt"
"strings"
"time"
)
type Comment struct {
Author string `json:"author"`
Text string `json:"text"`
Points string `json:"points"`
Timestamp string `json:"timestamp"`
Depth int `json:"depth"`
}
type Comments struct {
PostTitle string `json:"title"`
PostAuthor string `json:"author"`
Subreddit string `json:"subreddit"`
PostPoints string `json:"points"`
PostText string `json:"text"`
PostUrl string `json:"url"`
PostTimestamp string `json:"timestamp"`
Expiry time.Time `json:"expiry"`
Comments []Comment `json:"comments"`
}
func (c Comment) Title() string {
return formatDepth(c.Text, c.Depth)
}
func (c Comment) Description() string {
desc := fmt.Sprintf("%s by %s %s", c.Points, c.Author, c.Timestamp)
return formatDepth(desc, c.Depth)
}
func (c Comment) FilterValue() string {
return c.Author
}
func formatDepth(s string, depth int) string {
var results strings.Builder
for range depth {
results.WriteString(" ")
}
results.WriteString(s)
return results.String()
}
================================================
FILE: model/postModel.go
================================================
package model
import (
"fmt"
"strings"
"time"
)
type Post struct {
PostTitle string `json:"title"`
Author string `json:"author"`
Subreddit string `json:"subreddit"`
FriendlyDate string `json:"friendlyDate"`
Expiry time.Time `json:"expiry"`
PostUrl string `json:"postUrl"`
CommentsUrl string `json:"commentsUrl"`
TotalComments string `json:"totalComments"`
TotalLikes string `json:"totalLikes"`
}
type Posts struct {
Description string
Subreddit string
IsHome bool
Posts []Post
After string
Expiry time.Time
}
func (p Post) Title() string {
return fmt.Sprintf(" %s %s", p.TotalLikes, p.PostTitle)
}
func (p Post) Description() string {
var sb strings.Builder
if strings.TrimSpace(p.Subreddit) != "" {
sb.WriteString(p.Subreddit)
sb.WriteString(" ")
}
if strings.TrimSpace(p.TotalComments) == "" {
fmt.Fprintf(&sb, "%d comments ", 0)
} else {
fmt.Fprintf(&sb, "%s comments ", p.TotalComments)
}
fmt.Fprintf(&sb, "submitted %s by %s", p.FriendlyDate, p.Author)
return sb.String()
}
func (p Post) FilterValue() string {
return p.PostTitle
}
================================================
FILE: uninstall.sh
================================================
#!/bin/bash
set -e
APP_NAME="reddittui"
INSTALL_DIR="/usr/local/bin"
BINARY_PATH="$INSTALL_DIR/$APP_NAME"
if [[ -f $BINARY_PATH ]]; then
echo "Uninstalling reddittui..."
echo "Removing binary from $INSTALL_DIR (may require sudo)..."
sudo rm "$BINARY_PATH"
echo "Uninstallation complete."
else
echo "reddittui is not installed. Nothing to do."
fi
================================================
FILE: utils/browser.go
================================================
package utils
import (
"fmt"
"os/exec"
"runtime"
)
func OpenUrl(url string) error {
switch runtime.GOOS {
case "linux":
return exec.Command("xdg-open", url).Start()
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
return exec.Command("open", url).Start()
default:
return fmt.Errorf("unsupported platform")
}
}
================================================
FILE: utils/files.go
================================================
package utils
import (
"os"
"path/filepath"
)
const (
appName = "reddittui"
defaultConfigDir = ".config"
defaultStateDir = ".local/state"
defaultCacheDir = ".cache"
logFileName = "reddittui.log"
)
func GetConfigDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(homeDir, defaultConfigDir, appName), nil
}
func GetStateDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(homeDir, defaultStateDir, appName), nil
}
func GetCacheDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(homeDir, defaultCacheDir, appName), nil
}
func OpenLogFile() (*os.File, error) {
stateDir, err := GetStateDir()
if err != nil {
return nil, err
}
err = os.Mkdir(stateDir, 0750)
if err != nil && !os.IsExist(err) {
return nil, err
}
logPath := filepath.Join(stateDir, logFileName)
return os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
}
func FileExists(path string) bool {
_, err := os.Open(path)
return os.IsNotExist(err)
}
================================================
FILE: utils/logger.go
================================================
package utils
import (
"log/slog"
"os"
"strings"
)
func InitLogger(logLevel string) (*os.File, error) {
var level slog.Level
logFile, err := OpenLogFile()
if err != nil {
return nil, err
}
switch l := strings.ToLower(logLevel); l {
case "debug":
level = slog.LevelDebug
case "info":
level = slog.LevelInfo
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}
options := slog.HandlerOptions{Level: level}
logger := slog.New(slog.NewJSONHandler(logFile, &options))
slog.SetDefault(logger)
return logFile, nil
}
================================================
FILE: utils/timer.go
================================================
package utils
import (
"fmt"
"log/slog"
"time"
)
type Timer struct {
Context string
Start time.Time
End time.Time
time.Duration
}
func NewTimer(context string) Timer {
return Timer{
Context: context,
Start: time.Now(),
}
}
func (t *Timer) Stop() {
t.End = time.Now()
t.Duration = t.End.Sub(t.Start)
}
func (t *Timer) StopAndLog(args ...any) {
t.Stop()
slogArgs := []any{
"duration",
fmt.Sprintf("%d ms", t.Milliseconds()),
}
for _, arg := range args {
slogArgs = append(slogArgs, any(arg))
}
slog.Debug(t.Context, slogArgs...)
}
================================================
FILE: utils/utils.go
================================================
package utils
import (
"fmt"
)
func NormalizeSubreddit(subreddit string) string {
if subreddit == "reddit.com" {
return subreddit
}
if len(subreddit) >= 2 && subreddit[:2] == "r/" {
return subreddit
}
return fmt.Sprintf("r/%s", subreddit)
}
func TruncateString(s string, w int) string {
if w <= 0 {
return s
} else if len(s) <= w || len(s) <= 3 {
return s
}
return fmt.Sprintf("%s...", s[:w-3])
}
func Clamp(min, max, val int) int {
if val < min {
return min
} else if val > max {
return max
}
return val
}
func GetSingularPlural(s, singular, plural string) string {
if s == "1" {
return fmt.Sprintf("%s %s", s, singular)
}
return fmt.Sprintf("%s %s", s, plural)
}
================================================
FILE: utils/utils_test.go
================================================
package utils
import "testing"
func TestNormalizeSubreddit(t *testing.T) {
tests := []struct {
subreddit string
want string
}{
{"neovim", "r/neovim"},
{"r/neovim", "r/neovim"},
}
for _, tt := range tests {
got := NormalizeSubreddit(tt.subreddit)
if got != tt.want {
t.Errorf("got %s, want %s", got, tt.want)
}
}
}
func TestTruncateString(t *testing.T) {
tests := []struct {
s string
width int
want string
}{
{"abc", 3, "abc"},
{"abcd", 4, "abcd"},
{"abcde", 4, "a..."},
{"abcdef", 5, "ab..."},
{"abcdefg", 6, "abc..."},
}
for _, tt := range tests {
got := TruncateString(tt.s, tt.width)
if got != tt.want {
t.Errorf("got %s, want %s with input %s", got, tt.want, tt.s)
}
}
}
func TestClamp(t *testing.T) {
tests := []struct {
min int
max int
val int
want int
}{
{0, 2, 0, 0},
{0, 2, 1, 1},
{0, 2, 2, 2},
{0, 2, 3, 2},
{0, 2, -1, 0},
{0, 10, 5, 5},
{0, 10, -5, 0},
{0, 10, 15, 10},
}
for _, tt := range tests {
got := Clamp(tt.min, tt.max, tt.val)
if got != tt.want {
t.Errorf("got %d, want %d with input: min %d, max %d, val %d", got, tt.want, tt.min, tt.max, tt.val)
}
}
}
func TestGetSingularPlural(t *testing.T) {
tests := []struct {
s string
singular string
plural string
want string
}{
{"0", "banana", "bananas", "0 bananas"},
{"1", "banana", "bananas", "1 banana"},
{"2", "banana", "bananas", "2 bananas"},
{"3", "banana", "bananas", "3 bananas"},
}
for _, tt := range tests {
got := GetSingularPlural(tt.s, tt.singular, tt.plural)
if got != tt.want {
t.Errorf("got %s, want %s with input: s %s, singular %s, plural %s", got, tt.want, tt.s, tt.singular, tt.plural)
}
}
}
gitextract_v95nv04n/
├── .github/
│ └── workflows/
│ ├── build.yml
│ └── release.yml
├── .gitignore
├── .goreleaser.yaml
├── LICENSE.txt
├── README.md
├── client/
│ ├── cache/
│ │ ├── commentsCache.go
│ │ ├── comments_cache_test.go
│ │ ├── postsCache.go
│ │ └── posts_cache_test.go
│ ├── client.go
│ ├── comments/
│ │ ├── commentsClient.go
│ │ └── commentsParser.go
│ ├── common/
│ │ ├── errors.go
│ │ └── html.go
│ ├── posts/
│ │ ├── postsClient.go
│ │ └── postsParser.go
│ └── url.go
├── components/
│ ├── colors/
│ │ └── colors.go
│ ├── comments/
│ │ ├── commentsPage.go
│ │ ├── header.go
│ │ ├── keys.go
│ │ ├── pager.go
│ │ └── styles.go
│ ├── messages/
│ │ └── messages.go
│ ├── modal/
│ │ ├── error.go
│ │ ├── modal.go
│ │ ├── quit.go
│ │ ├── render.go
│ │ ├── search.go
│ │ ├── spinner.go
│ │ └── subredditList.go
│ ├── posts/
│ │ ├── header.go
│ │ ├── keys.go
│ │ ├── postsPage.go
│ │ └── styles.go
│ ├── styles/
│ │ └── style.go
│ └── tui.go
├── config/
│ ├── config.go
│ └── defaultConfig.go
├── go.mod
├── go.sum
├── install.sh
├── integ_test.go
├── justfile
├── main.go
├── model/
│ ├── commentsModel.go
│ └── postModel.go
├── uninstall.sh
└── utils/
├── browser.go
├── files.go
├── logger.go
├── timer.go
├── utils.go
└── utils_test.go
SYMBOL INDEX (386 symbols across 40 files)
FILE: client/cache/commentsCache.go
type CommentsCache (line 17) | type CommentsCache interface
type FileCommentsCache (line 23) | type FileCommentsCache struct
method Get (line 37) | func (f FileCommentsCache) Get(filename string) (comments model.Commen...
method Put (line 72) | func (f FileCommentsCache) Put(comments model.Comments, filename strin...
method GetSubredditFromUrl (line 106) | func (f FileCommentsCache) GetSubredditFromUrl(commentsUrl string) str...
method Clean (line 120) | func (f FileCommentsCache) Clean() {
function NewFileCommentsCache (line 28) | func NewFileCommentsCache(baseUrl, cacheDir string) FileCommentsCache {
type NoOpCommentsCache (line 200) | type NoOpCommentsCache struct
method Get (line 206) | func (n NoOpCommentsCache) Get(cacheFilePath string) (comments model.C...
method Put (line 210) | func (n NoOpCommentsCache) Put(comments model.Comments, cacheFilePath ...
method Clean (line 214) | func (n NoOpCommentsCache) Clean() {
function NewNoOpCommentsCache (line 202) | func NewNoOpCommentsCache() NoOpCommentsCache {
FILE: client/cache/comments_cache_test.go
constant testPostPoints (line 12) | testPostPoints = "5 points"
constant testPostText (line 13) | testPostText = "text"
constant testPostTimestamp (line 14) | testPostTimestamp = "5 mins ago"
constant testCommentAuthor (line 15) | testCommentAuthor = "author"
constant testCommentText (line 16) | testCommentText = "comments"
constant testCommentPoints (line 17) | testCommentPoints = "5 points"
constant testCommentTimestamp (line 18) | testCommentTimestamp = "5 mins ago"
constant testBaseUrl (line 19) | testBaseUrl = "old.reddit.com"
constant testCommentDepth (line 20) | testCommentDepth = 0
function TestCommentsCacheHappyPath (line 23) | func TestCommentsCacheHappyPath(t *testing.T) {
function TestCommentsCacheCacheNotFound (line 43) | func TestCommentsCacheCacheNotFound(t *testing.T) {
function assertComments (line 51) | func assertComments(expected, got model.Comments, t *testing.T) {
function assertComment (line 74) | func assertComment(expectedComment, gotComment model.Comment, t *testing...
function createTestComment (line 82) | func createTestComment() model.Comment {
function createTestComments (line 92) | func createTestComments(expiry time.Time) model.Comments {
function generateCommentsFileUrl (line 105) | func generateCommentsFileUrl(subreddit, filename string) string {
FILE: client/cache/postsCache.go
type PostsCache (line 15) | type PostsCache interface
type FilePostsCache (line 21) | type FilePostsCache struct
method Get (line 31) | func (f FilePostsCache) Get(filename string) (posts model.Posts, err e...
method Put (line 60) | func (f FilePostsCache) Put(posts model.Posts, filename string) error {
method Clean (line 87) | func (f FilePostsCache) Clean() {
function NewFilePostsCache (line 25) | func NewFilePostsCache(cacheDir string) FilePostsCache {
type NoOpPostsCache (line 135) | type NoOpPostsCache struct
method Get (line 141) | func (n NoOpPostsCache) Get(cacheFilePath string) (posts model.Posts, ...
method Put (line 145) | func (n NoOpPostsCache) Put(posts model.Posts, cacheFilePath string) e...
method Clean (line 149) | func (f NoOpPostsCache) Clean() {
function NewNoOpPostsCache (line 137) | func NewNoOpPostsCache() NoOpPostsCache {
FILE: client/cache/posts_cache_test.go
constant testTitle (line 14) | testTitle = "title"
constant testDescription (line 15) | testDescription = "description"
constant testAuthor (line 16) | testAuthor = "author"
constant testSubreddit (line 17) | testSubreddit = "subreddit"
constant testFriendlyDate (line 18) | testFriendlyDate = "5 mins ago"
constant testPostUrl (line 19) | testPostUrl = "post.url"
constant testCommentsUrl (line 20) | testCommentsUrl = "comments.url"
constant testTotalComments (line 21) | testTotalComments = "5 comments"
constant testTotalLikes (line 22) | testTotalLikes = "10 likes"
constant testIsHome (line 23) | testIsHome = false
constant testAfter (line 24) | testAfter = "after"
function TestPostsCacheHappyPath (line 27) | func TestPostsCacheHappyPath(t *testing.T) {
function TestPostsCacheNotFound (line 45) | func TestPostsCacheNotFound(t *testing.T) {
function TestPostsCacheCannotDecodePosts (line 53) | func TestPostsCacheCannotDecodePosts(t *testing.T) {
function TestPostsCacheCacheExpired (line 74) | func TestPostsCacheCacheExpired(t *testing.T) {
function TestPostsCacheCleanCache (line 93) | func TestPostsCacheCleanCache(t *testing.T) {
function assertPosts (line 126) | func assertPosts(expected, got model.Posts, t *testing.T) {
function assertPost (line 147) | func assertPost(expected, got model.Post, t *testing.T) {
function assertVal (line 159) | func assertVal[K comparable](context string, expected, got K, t *testing...
function createTestPost (line 165) | func createTestPost() model.Post {
function createTestPosts (line 178) | func createTestPosts(expiry time.Time) model.Posts {
FILE: client/client.go
type RedditClient (line 19) | type RedditClient struct
method GetHomePosts (line 48) | func (r RedditClient) GetHomePosts(after string) (model.Posts, error) {
method GetSubredditPosts (line 52) | func (r RedditClient) GetSubredditPosts(subreddit, after string) (mode...
method GetComments (line 56) | func (r RedditClient) GetComments(url string) (model.Comments, error) {
method CleanCache (line 60) | func (r RedditClient) CleanCache() {
function NewRedditClient (line 25) | func NewRedditClient(configuration config.Config) RedditClient {
function InitializeCaches (line 65) | func InitializeCaches(baseUrl string, bypassCache bool) (cache.PostsCach...
FILE: client/comments/commentsClient.go
constant defaultTtl (line 16) | defaultTtl = 1 * time.Hour
type RedditCommentsClient (line 20) | type RedditCommentsClient struct
method GetComments (line 47) | func (r RedditCommentsClient) GetComments(url string) (comments model....
function NewRedditCommentsClient (line 27) | func NewRedditCommentsClient(baseUrl, serverType string, httpClient *htt...
FILE: client/comments/commentsParser.go
type CommentsParser (line 13) | type CommentsParser interface
type OldRedditCommentsParser (line 17) | type OldRedditCommentsParser struct
method ParseComments (line 19) | func (p OldRedditCommentsParser) ParseComments(root common.HtmlNode, u...
method parseCommentsList (line 41) | func (p OldRedditCommentsParser) parseCommentsList(node common.HtmlNod...
method parseCommentNode (line 75) | func (p OldRedditCommentsParser) parseCommentNode(node common.HtmlNode...
method getTitle (line 103) | func (p OldRedditCommentsParser) getTitle(root common.HtmlNode) string {
method getPostContent (line 113) | func (p OldRedditCommentsParser) getPostContent(root common.HtmlNode) ...
method getPostAuthor (line 148) | func (p OldRedditCommentsParser) getPostAuthor(root common.HtmlNode) s...
method getPostTimestamp (line 158) | func (p OldRedditCommentsParser) getPostTimestamp(root common.HtmlNode...
method getSubreddit (line 168) | func (p OldRedditCommentsParser) getSubreddit(root common.HtmlNode) st...
method getPostPoints (line 178) | func (p OldRedditCommentsParser) getPostPoints(root common.HtmlNode) s...
type RedlibCommentsParser (line 197) | type RedlibCommentsParser struct
method ParseComments (line 199) | func (p RedlibCommentsParser) ParseComments(root common.HtmlNode, url ...
method getTitle (line 231) | func (p RedlibCommentsParser) getTitle(root common.HtmlNode) string {
method getPostAuthor (line 246) | func (p RedlibCommentsParser) getPostAuthor(root common.HtmlNode) stri...
method getPostTimestamp (line 260) | func (p RedlibCommentsParser) getPostTimestamp(root common.HtmlNode) s...
method getSubreddit (line 269) | func (p RedlibCommentsParser) getSubreddit(root common.HtmlNode) string {
method getPostPoints (line 278) | func (p RedlibCommentsParser) getPostPoints(root common.HtmlNode) stri...
method getPostContent (line 287) | func (p RedlibCommentsParser) getPostContent(root common.HtmlNode) (co...
method parseCommentsList (line 309) | func (p RedlibCommentsParser) parseCommentsList(root common.HtmlNode, ...
method parseThread (line 317) | func (p RedlibCommentsParser) parseThread(root common.HtmlNode, depth ...
method parseCommentNode (line 333) | func (p RedlibCommentsParser) parseCommentNode(node common.HtmlNode, d...
function renderHtmlNode (line 369) | func renderHtmlNode(node common.HtmlNode) string {
function renderHtmlNodeHelper (line 383) | func renderHtmlNodeHelper(node common.HtmlNode, results *strings.Builder) {
FILE: client/common/html.go
constant UserAgentHeaderKey (line 15) | UserAgentHeaderKey = "User-Agent"
constant UserAgentHeaderValue (line 16) | UserAgentHeaderValue = "Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/...
constant CacheControlHeader (line 17) | CacheControlHeader = "Cache-Control"
constant CommentsCacheDirName (line 18) | CommentsCacheDirName = "comments"
constant LimitQueryParameter (line 26) | LimitQueryParameter = "limit=500"
type HtmlNode (line 28) | type HtmlNode struct
method GetAttr (line 32) | func (n HtmlNode) GetAttr(key string) string {
method Classes (line 44) | func (n HtmlNode) Classes() []string {
method Class (line 55) | func (n HtmlNode) Class() string {
method Id (line 59) | func (n HtmlNode) Id() string {
method ClassContains (line 63) | func (n HtmlNode) ClassContains(classesToFind ...string) bool {
method Text (line 73) | func (n HtmlNode) Text() string {
method Tag (line 83) | func (n HtmlNode) Tag() string {
method TagEquals (line 87) | func (n HtmlNode) TagEquals(tag string) bool {
method NodeEquals (line 91) | func (n HtmlNode) NodeEquals(tag string, classes ...string) bool {
method NodeEqualsById (line 95) | func (n HtmlNode) NodeEqualsById(tag string, id string) bool {
method FindDescendant (line 99) | func (n HtmlNode) FindDescendant(tag string, classes ...string) (HtmlN...
method FindDescendantById (line 114) | func (n HtmlNode) FindDescendantById(tag string, id string) (HtmlNode,...
method FindDescendants (line 129) | func (n HtmlNode) FindDescendants(tag string, classes ...string) iter....
method FindChild (line 147) | func (n HtmlNode) FindChild(tag string, classes ...string) (HtmlNode, ...
method FindChildren (line 162) | func (n HtmlNode) FindChildren(tag string, classes ...string) iter.Seq...
function RenderAnchor (line 180) | func RenderAnchor(node HtmlNode) string {
function AddQueryParameter (line 198) | func AddQueryParameter(url, query string) string {
FILE: client/posts/postsClient.go
type RedditPostsClient (line 18) | type RedditPostsClient struct
method GetHomePosts (line 56) | func (r RedditPostsClient) GetHomePosts(after string) (model.Posts, er...
method GetSubredditPosts (line 67) | func (r RedditPostsClient) GetSubredditPosts(subreddit string, after s...
method tryGetCachedPosts (line 79) | func (r RedditPostsClient) tryGetCachedPosts(postsUrl string) (posts m...
method getPosts (line 107) | func (r RedditPostsClient) getPosts(url string) (posts model.Posts, er...
method filterPosts (line 149) | func (r RedditPostsClient) filterPosts(posts model.Posts) model.Posts {
method BuildPostsUrl (line 176) | func (r RedditPostsClient) BuildPostsUrl(subreddit, after string) stri...
function NewRedditPostsClient (line 28) | func NewRedditPostsClient(
FILE: client/posts/postsParser.go
type PostsParser (line 11) | type PostsParser interface
type OldRedditPostsParser (line 15) | type OldRedditPostsParser struct
method ParsePosts (line 17) | func (p OldRedditPostsParser) ParsePosts(root common.HtmlNode) model.P...
method parsePost (line 61) | func (p OldRedditPostsParser) parsePost(n common.HtmlNode) model.Post {
type RedlibParser (line 86) | type RedlibParser struct
method ParsePosts (line 90) | func (p RedlibParser) ParsePosts(root common.HtmlNode) model.Posts {
method parsePost (line 105) | func (p RedlibParser) parsePost(n common.HtmlNode) model.Post {
method buildUrl (line 143) | func (p RedlibParser) buildUrl(part string) (string, error) {
FILE: client/url.go
function NormalizeBaseUrl (line 7) | func NormalizeBaseUrl(baseUrl string) (string, error) {
function GetPostUrl (line 25) | func GetPostUrl(baseUrl, post string) (string, error) {
FILE: components/colors/colors.go
type Color (line 7) | type Color
constant Red (line 10) | Red = iota
constant Maroon (line 11) | Maroon
constant Pink (line 12) | Pink
constant Orange (line 13) | Orange
constant Yellow (line 14) | Yellow
constant Green (line 15) | Green
constant Blue (line 16) | Blue
constant Purple (line 17) | Purple
constant Indigo (line 18) | Indigo
constant Lavender (line 19) | Lavender
constant Text (line 20) | Text
constant Subtext (line 21) | Subtext
constant Sand (line 22) | Sand
constant White (line 23) | White
type Palette (line 26) | type Palette struct
method ToHex (line 43) | func (p Palette) ToHex(color Color) string {
function AdaptiveColors (line 114) | func AdaptiveColors(light, dark Color) lipgloss.AdaptiveColor {
function AdaptiveColor (line 121) | func AdaptiveColor(color Color) lipgloss.AdaptiveColor {
FILE: components/comments/commentsPage.go
type CommentsPage (line 16) | type CommentsPage struct
method Init (line 37) | func (c CommentsPage) Init() tea.Cmd {
method Update (line 41) | func (c CommentsPage) Update(msg tea.Msg) (CommentsPage, tea.Cmd) {
method handleGlobalMessages (line 56) | func (c CommentsPage) handleGlobalMessages(msg tea.Msg) (CommentsPage,...
method handleFocusedMessages (line 69) | func (c CommentsPage) handleFocusedMessages(msg tea.Msg) (CommentsPage...
method View (line 89) | func (c CommentsPage) View() string {
method SetSize (line 96) | func (c *CommentsPage) SetSize(w, h int) {
method Focus (line 101) | func (c *CommentsPage) Focus() {
method Blur (line 105) | func (c *CommentsPage) Blur() {
method resizeComponents (line 109) | func (c *CommentsPage) resizeComponents() {
method loadComments (line 121) | func (c *CommentsPage) loadComments(url string) tea.Cmd {
method updateComments (line 133) | func (c *CommentsPage) updateComments(comments model.Comments) {
function NewCommentsPage (line 25) | func NewCommentsPage(redditClient client.RedditClient) CommentsPage {
FILE: components/comments/header.go
type CommentsHeader (line 27) | type CommentsHeader struct
method SetSize (line 42) | func (h *CommentsHeader) SetSize(width, height int) {
method View (line 47) | func (h CommentsHeader) View() string {
method SetContent (line 64) | func (h *CommentsHeader) SetContent(comments model.Comments) {
function NewCommentsHeader (line 38) | func NewCommentsHeader() CommentsHeader {
FILE: components/comments/keys.go
type viewportKeyMap (line 5) | type viewportKeyMap struct
method ShortHelp (line 63) | func (k viewportKeyMap) ShortHelp() []key.Binding {
method FullHelp (line 67) | func (k viewportKeyMap) FullHelp() [][]key.Binding {
FILE: components/comments/pager.go
type CommentsViewport (line 16) | type CommentsViewport struct
method Update (line 37) | func (c CommentsViewport) Update(msg tea.Msg) (CommentsViewport, tea.C...
method View (line 58) | func (c CommentsViewport) View() string {
method SetSize (line 64) | func (c *CommentsViewport) SetSize(w, h int) {
method SetContent (line 72) | func (c *CommentsViewport) SetContent(comments model.Comments) {
method ResizeComponents (line 83) | func (c *CommentsViewport) ResizeComponents() {
method GetViewportView (line 90) | func (c *CommentsViewport) GetViewportView() string {
method SetViewportContent (line 113) | func (c *CommentsViewport) SetViewportContent() {
method formatComment (line 120) | func (c *CommentsViewport) formatComment(comment model.Comment, i int)...
method toggleCollapseComments (line 184) | func (c *CommentsViewport) toggleCollapseComments() {
method findAnchorComment (line 201) | func (c *CommentsViewport) findAnchorComment() (pos int, title string,...
method findComment (line 246) | func (c *CommentsViewport) findComment(title, text string) int {
function NewCommentsViewport (line 28) | func NewCommentsViewport() CommentsViewport {
function renderPoints (line 162) | func renderPoints(pointsString string) string {
FILE: components/messages/messages.go
type ErrorModalMsg (line 9) | type ErrorModalMsg struct
type CleanCacheMsg (line 15) | type CleanCacheMsg struct
type GoBackMsg (line 16) | type GoBackMsg struct
type LoadCommentsMsg (line 17) | type LoadCommentsMsg
type LoadHomeMsg (line 18) | type LoadHomeMsg struct
type LoadMorePostsMsg (line 19) | type LoadMorePostsMsg
type LoadSubredditMsg (line 20) | type LoadSubredditMsg
type UpdateCommentsMsg (line 21) | type UpdateCommentsMsg
type UpdatePostsMsg (line 22) | type UpdatePostsMsg
type AddMorePostsMsg (line 23) | type AddMorePostsMsg
type LoadingCompleteMsg (line 24) | type LoadingCompleteMsg struct
type OpenModalMsg (line 26) | type OpenModalMsg struct
type ExitModalMsg (line 27) | type ExitModalMsg struct
type ShowSpinnerModalMsg (line 28) | type ShowSpinnerModalMsg
type ShowErrorModalMsg (line 30) | type ShowErrorModalMsg
type OpenUrlMsg (line 32) | type OpenUrlMsg
function CleanCache (line 35) | func CleanCache() tea.Msg {
function GoBack (line 39) | func GoBack() tea.Msg {
function LoadHome (line 43) | func LoadHome() tea.Msg {
function LoadMorePosts (line 47) | func LoadMorePosts(home bool) tea.Cmd {
function LoadSubreddit (line 53) | func LoadSubreddit(subreddit string) tea.Cmd {
function LoadComments (line 59) | func LoadComments(url string) tea.Cmd {
function LoadingComplete (line 65) | func LoadingComplete() tea.Msg {
function OpenModal (line 69) | func OpenModal() tea.Msg {
function ExitModal (line 73) | func ExitModal() tea.Msg {
function ShowSpinnerModal (line 77) | func ShowSpinnerModal(loadingMsg string) tea.Cmd {
function ShowErrorModal (line 83) | func ShowErrorModal(errorMsg string) tea.Cmd {
function ShowErrorModalWithCallback (line 89) | func ShowErrorModalWithCallback(errorMsg string, callback tea.Cmd) tea.C...
function HideSpinnerModal (line 95) | func HideSpinnerModal() tea.Msg {
function OpenUrl (line 99) | func OpenUrl(url string) tea.Cmd {
FILE: components/modal/error.go
type ErrorModal (line 17) | type ErrorModal struct
method Init (line 25) | func (e ErrorModal) Init() tea.Cmd {
method Update (line 29) | func (e ErrorModal) Update(msg tea.Msg) (ErrorModal, tea.Cmd) {
method View (line 39) | func (e ErrorModal) View() string {
function NewErrorModal (line 21) | func NewErrorModal() ErrorModal {
FILE: components/modal/modal.go
type SessionState (line 12) | type SessionState
constant defaultState (line 15) | defaultState SessionState = iota
constant loading (line 16) | loading
constant searching (line 17) | searching
constant quitting (line 18) | quitting
constant showingError (line 19) | showingError
type ModalManager (line 28) | type ModalManager struct
method Init (line 48) | func (m ModalManager) Init() tea.Cmd {
method Update (line 52) | func (m ModalManager) Update(msg tea.Msg) (ModalManager, tea.Cmd) {
method handleGlobalMessages (line 67) | func (m ModalManager) handleGlobalMessages(msg tea.Msg) (ModalManager,...
method handleFocusedMessages (line 95) | func (m ModalManager) handleFocusedMessages(msg tea.Msg) (ModalManager...
method View (line 116) | func (m ModalManager) View(background Viewer) string {
method SetSize (line 132) | func (m *ModalManager) SetSize(w, h int) {
method Blur (line 139) | func (m *ModalManager) Blur() tea.Cmd {
method SetLoading (line 148) | func (m *ModalManager) SetLoading(message string) tea.Cmd {
method SetSearching (line 154) | func (m *ModalManager) SetSearching() tea.Cmd {
method SetQuitting (line 160) | func (m *ModalManager) SetQuitting() tea.Cmd {
method SetError (line 165) | func (m *ModalManager) SetError(errorMsg string) tea.Cmd {
method SetErrorWithCallback (line 171) | func (m *ModalManager) SetErrorWithCallback(errorMsg string, onClose t...
function NewModalManager (line 38) | func NewModalManager() ModalManager {
FILE: components/modal/quit.go
constant quitMsg (line 13) | quitMsg = "Are you sure you want to quit?"
constant yesNoMsg (line 14) | yesNoMsg = "(y/n)"
type QuitModal (line 22) | type QuitModal struct
method View (line 28) | func (q QuitModal) View() string {
method Update (line 34) | func (q QuitModal) Update(msg tea.Msg) (QuitModal, tea.Cmd) {
function NewQuitModal (line 24) | func NewQuitModal() QuitModal {
FILE: components/modal/render.go
constant maxModalWidthPercentage (line 14) | maxModalWidthPercentage = 0.66
type Viewer (line 20) | type Viewer interface
function PlaceModal (line 25) | func PlaceModal(foreground, background Viewer, xPos, yPos lipgloss.Posit...
function Place (line 65) | func Place(
function getLines (line 146) | func getLines(s string) (lines []string, widest int) {
function cutLeft (line 161) | func cutLeft(s string, cutWidth int) string {
function clamp (line 200) | func clamp(v, lower, upper int) int {
type whitespace (line 204) | type whitespace struct
method render (line 210) | func (w whitespace) render(width int) string {
type WhitespaceOption (line 240) | type WhitespaceOption
FILE: components/modal/search.go
constant searchHelpText (line 13) | searchHelpText = "Choose a subreddit:"
constant searchPlaceholder (line 14) | searchPlaceholder = "subreddit"
constant defaultSearchWidth (line 15) | defaultSearchWidth = 35
type SubredditSearchModal (line 23) | type SubredditSearchModal struct
method Init (line 41) | func (s SubredditSearchModal) Init() tea.Cmd {
method Update (line 45) | func (s SubredditSearchModal) Update(msg tea.Msg) (SubredditSearchModa...
method View (line 61) | func (s SubredditSearchModal) View() string {
method SetSize (line 68) | func (s *SubredditSearchModal) SetSize(w, h int) {
method Blur (line 73) | func (s *SubredditSearchModal) Blur() {
function NewSubredditSearchModal (line 28) | func NewSubredditSearchModal() SubredditSearchModal {
FILE: components/modal/spinner.go
type SpinnerModal (line 17) | type SpinnerModal struct
method Init (line 32) | func (s SpinnerModal) Init() tea.Cmd {
method Update (line 36) | func (s SpinnerModal) Update(msg tea.Msg) (SpinnerModal, tea.Cmd) {
method View (line 42) | func (s SpinnerModal) View() string {
method SetLoading (line 47) | func (s *SpinnerModal) SetLoading(message string) {
function NewSpinnerModal (line 22) | func NewSpinnerModal() SpinnerModal {
FILE: components/posts/header.go
type PostsHeader (line 24) | type PostsHeader struct
method SetSize (line 35) | func (h *PostsHeader) SetSize(width, height int) {
method View (line 40) | func (h PostsHeader) View() string {
method SetContent (line 48) | func (h *PostsHeader) SetContent(title, desc string) {
function NewPostsHeader (line 31) | func NewPostsHeader() PostsHeader {
FILE: components/posts/keys.go
type postsKeyMap (line 5) | type postsKeyMap struct
method ShortHelp (line 27) | func (k postsKeyMap) ShortHelp() []key.Binding {
method FullHelp (line 31) | func (k postsKeyMap) FullHelp() []key.Binding {
FILE: components/posts/postsPage.go
constant defaultHeaderTitle (line 18) | defaultHeaderTitle = "reddit.com"
constant defaultHeaderDescription (line 19) | defaultHeaderDescription = "The front page of the internet"
constant postsErrorText (line 20) | postsErrorText = "Could not load posts. Please try again in a ...
constant subredditNotFoundText (line 21) | subredditNotFoundText = "Subreddit not found"
type PostsPage (line 24) | type PostsPage struct
method Init (line 61) | func (p PostsPage) Init() tea.Cmd {
method Update (line 65) | func (p PostsPage) Update(msg tea.Msg) (PostsPage, tea.Cmd) {
method handleGlobalMessages (line 80) | func (p PostsPage) handleGlobalMessages(msg tea.Msg) (PostsPage, tea.C...
method handleFocusedMessages (line 117) | func (p PostsPage) handleFocusedMessages(msg tea.Msg) (PostsPage, tea....
method View (line 150) | func (p PostsPage) View() string {
method SetSize (line 161) | func (p *PostsPage) SetSize(w, h int) {
method Focus (line 166) | func (p *PostsPage) Focus() {
method Blur (line 170) | func (p *PostsPage) Blur() {
method resizeComponents (line 174) | func (p *PostsPage) resizeComponents() {
method loadHome (line 187) | func (p *PostsPage) loadHome() tea.Cmd {
method loadMorePosts (line 199) | func (p *PostsPage) loadMorePosts() tea.Cmd {
method loadSubreddit (line 226) | func (p PostsPage) loadSubreddit(subreddit string) tea.Cmd {
method updatePosts (line 241) | func (p *PostsPage) updatePosts(posts model.Posts) {
method addPosts (line 263) | func (p *PostsPage) addPosts(posts model.Posts) {
function NewPostsPage (line 35) | func NewPostsPage(redditClient client.RedditClient, home bool) PostsPage {
FILE: components/posts/styles.go
function NewPostsDelegate (line 10) | func NewPostsDelegate() list.DefaultDelegate {
FILE: components/tui.go
constant defaultLoadingMessage (line 17) | defaultLoadingMessage = "loading reddit.com..."
type pageType (line 20) | type pageType
constant HomePage (line 24) | HomePage pageType = iota
constant SubredditPage (line 25) | SubredditPage
constant CommentsPage (line 26) | CommentsPage
type RedditTui (line 29) | type RedditTui struct
method Init (line 78) | func (r RedditTui) Init() tea.Cmd {
method Update (line 82) | func (r RedditTui) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
method View (line 199) | func (r RedditTui) View() string {
method goBack (line 223) | func (r *RedditTui) goBack() {
method setPage (line 238) | func (r *RedditTui) setPage(page pageType) {
method completeLoading (line 242) | func (r *RedditTui) completeLoading() tea.Cmd {
method focusModal (line 258) | func (r *RedditTui) focusModal() {
method focusActivePage (line 265) | func (r *RedditTui) focusActivePage() {
function NewRedditTui (line 43) | func NewRedditTui(configuration config.Config, subreddit, post string) R...
function getInitCmd (line 63) | func getInitCmd(baseUrl, subreddit, post string) tea.Cmd {
FILE: config/config.go
constant configFilename (line 13) | configFilename = "reddittui.toml"
constant defaultDomainName (line 14) | defaultDomainName = "old.reddit.com"
constant defaultServerType (line 15) | defaultServerType = "old"
type Config (line 18) | type Config struct
type CoreConfig (line 25) | type CoreConfig struct
type FilterConfig (line 31) | type FilterConfig struct
type ClientConfig (line 36) | type ClientConfig struct
type ServerConfig (line 41) | type ServerConfig struct
function NewConfig (line 46) | func NewConfig() Config {
function LoadConfig (line 60) | func LoadConfig() (Config, error) {
function mergeConfig (line 100) | func mergeConfig(left, right Config, meta toml.MetaData) Config {
function createConfigFile (line 140) | func createConfigFile(configFilePath string) error {
FILE: config/defaultConfig.go
constant defaultConfiguration (line 3) | defaultConfiguration = `
FILE: integ_test.go
constant testTimeout (line 16) | testTimeout = 20 * time.Second
constant testDomain (line 17) | testDomain = "old.reddit.com"
constant testServerType (line 18) | testServerType = "old"
function TestStartup (line 21) | func TestStartup(t *testing.T) {
function TestSwitchSubreddit (line 35) | func TestSwitchSubreddit(t *testing.T) {
function TestReturnToHomePage (line 57) | func TestReturnToHomePage(t *testing.T) {
function TestShowComments (line 84) | func TestShowComments(t *testing.T) {
function TestLoadInitialPostFromId (line 110) | func TestLoadInitialPostFromId(t *testing.T) {
function TestLoadInitialPostFromUrl (line 123) | func TestLoadInitialPostFromUrl(t *testing.T) {
function TestLoadInitialSubredditAndCanGoBack (line 136) | func TestLoadInitialSubredditAndCanGoBack(t *testing.T) {
function WaitFor (line 152) | func WaitFor(t *testing.T, tm *teatest.TestModel, messages ...string) {
function WaitForWithInputs (line 156) | func WaitForWithInputs(t *testing.T, tm *teatest.TestModel, inputs strin...
function getTestConfig (line 175) | func getTestConfig() config.Config {
FILE: main.go
constant version (line 15) | version = "v0.3.9"
type CliArgs (line 17) | type CliArgs struct
function main (line 23) | func main() {
FILE: model/commentsModel.go
type Comment (line 9) | type Comment struct
method Title (line 29) | func (c Comment) Title() string {
method Description (line 33) | func (c Comment) Description() string {
method FilterValue (line 38) | func (c Comment) FilterValue() string {
type Comments (line 17) | type Comments struct
function formatDepth (line 42) | func formatDepth(s string, depth int) string {
FILE: model/postModel.go
type Post (line 9) | type Post struct
method Title (line 30) | func (p Post) Title() string {
method Description (line 34) | func (p Post) Description() string {
method FilterValue (line 51) | func (p Post) FilterValue() string {
type Posts (line 21) | type Posts struct
FILE: utils/browser.go
function OpenUrl (line 9) | func OpenUrl(url string) error {
FILE: utils/files.go
constant appName (line 9) | appName = "reddittui"
constant defaultConfigDir (line 10) | defaultConfigDir = ".config"
constant defaultStateDir (line 11) | defaultStateDir = ".local/state"
constant defaultCacheDir (line 12) | defaultCacheDir = ".cache"
constant logFileName (line 13) | logFileName = "reddittui.log"
function GetConfigDir (line 16) | func GetConfigDir() (string, error) {
function GetStateDir (line 25) | func GetStateDir() (string, error) {
function GetCacheDir (line 34) | func GetCacheDir() (string, error) {
function OpenLogFile (line 43) | func OpenLogFile() (*os.File, error) {
function FileExists (line 58) | func FileExists(path string) bool {
FILE: utils/logger.go
function InitLogger (line 9) | func InitLogger(logLevel string) (*os.File, error) {
FILE: utils/timer.go
type Timer (line 9) | type Timer struct
method Stop (line 23) | func (t *Timer) Stop() {
method StopAndLog (line 28) | func (t *Timer) StopAndLog(args ...any) {
function NewTimer (line 16) | func NewTimer(context string) Timer {
FILE: utils/utils.go
function NormalizeSubreddit (line 7) | func NormalizeSubreddit(subreddit string) string {
function TruncateString (line 19) | func TruncateString(s string, w int) string {
function Clamp (line 29) | func Clamp(min, max, val int) int {
function GetSingularPlural (line 39) | func GetSingularPlural(s, singular, plural string) string {
FILE: utils/utils_test.go
function TestNormalizeSubreddit (line 5) | func TestNormalizeSubreddit(t *testing.T) {
function TestTruncateString (line 22) | func TestTruncateString(t *testing.T) {
function TestClamp (line 43) | func TestClamp(t *testing.T) {
function TestGetSingularPlural (line 68) | func TestGetSingularPlural(t *testing.T) {
Condensed preview — 55 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (149K chars).
[
{
"path": ".github/workflows/build.yml",
"chars": 405,
"preview": "name: Build reddittui\n\non:\n push:\n branches:\n - main\n pull_request:\n\njobs:\n deploy:\n runs-on: ubuntu-lates"
},
{
"path": ".github/workflows/release.yml",
"chars": 529,
"preview": "name: Release reddittui\n\non:\n push:\n tags:\n - '*'\n\njobs:\n goreleaser:\n runs-on: ubuntu-latest\n steps:\n "
},
{
"path": ".gitignore",
"chars": 49,
"preview": "samples\nbuild/\n# Added by goreleaser init:\ndist/\n"
},
{
"path": ".goreleaser.yaml",
"chars": 1314,
"preview": "# This is an example .goreleaser.yml file with some sensible defaults.\n# Make sure to check the documentation at https:/"
},
{
"path": "LICENSE.txt",
"chars": 1073,
"preview": "MIT License\n\nCopyright (c) 2025 Anthony Majestro\n\nPermission is hereby granted, free of charge, to any person obtaining "
},
{
"path": "README.md",
"chars": 3698,
"preview": "# Reddittui\nA lightweight terminal application for browsing Reddit from your command line. Powered by [bubbletea](https:"
},
{
"path": "client/cache/commentsCache.go",
"chars": 5325,
"preview": "package cache\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log/slog\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reddittui/clien"
},
{
"path": "client/cache/comments_cache_test.go",
"chars": 3198,
"preview": "package cache\n\nimport (\n\t\"fmt\"\n\t\"reddittui/client/common\"\n\t\"reddittui/model\"\n\t\"testing\"\n\t\"time\"\n)\n\nconst (\n\ttestPostPoin"
},
{
"path": "client/cache/postsCache.go",
"chars": 3623,
"preview": "package cache\n\nimport (\n\t\"encoding/json\"\n\t\"io/fs\"\n\t\"log/slog\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reddittui/client/commo"
},
{
"path": "client/cache/posts_cache_test.go",
"chars": 5305,
"preview": "package cache\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"reddittui/client/common\"\n\t\"reddittui/model\"\n\t\"strings\"\n\t\"testing\"\n\t\"tim"
},
{
"path": "client/client.go",
"chars": 2844,
"preview": "package client\n\nimport (\n\t\"log\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reddittui/client/cache\"\n\t\"reddittui/cli"
},
{
"path": "client/comments/commentsClient.go",
"chars": 2346,
"preview": "package comments\n\nimport (\n\t\"log/slog\"\n\t\"net/http\"\n\t\"reddittui/client/cache\"\n\t\"reddittui/client/common\"\n\t\"reddittui/mode"
},
{
"path": "client/comments/commentsParser.go",
"chars": 10571,
"preview": "package comments\n\nimport (\n\t\"fmt\"\n\t\"reddittui/client/common\"\n\t\"reddittui/model\"\n\t\"reddittui/utils\"\n\t\"strings\"\n\n\t\"golang."
},
{
"path": "client/common/errors.go",
"chars": 411,
"preview": "package common\n\nimport \"errors\"\n\nvar (\n\tErrCacheEntryExpired = errors.New(\"entry is expired\")\n\tErrCannotLoadPosts "
},
{
"path": "client/common/html.go",
"chars": 4271,
"preview": "package common\n\nimport (\n\t\"fmt\"\n\t\"iter\"\n\t\"reddittui/components/colors\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/"
},
{
"path": "client/posts/postsClient.go",
"chars": 4760,
"preview": "package posts\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"reddittui/client/cache\"\n\t\"reddittui/client/common\"\n\t\"reddittui/"
},
{
"path": "client/posts/postsParser.go",
"chars": 3653,
"preview": "package posts\n\nimport (\n\t\"log/slog\"\n\t\"net/url\"\n\t\"reddittui/client/common\"\n\t\"reddittui/model\"\n\t\"strings\"\n)\n\ntype PostsPar"
},
{
"path": "client/url.go",
"chars": 635,
"preview": "package client\n\nimport (\n\t\"net/url\"\n)\n\nfunc NormalizeBaseUrl(baseUrl string) (string, error) {\n\tparsed, err := url.Parse"
},
{
"path": "components/colors/colors.go",
"chars": 2043,
"preview": "package colors\n\nimport \"github.com/charmbracelet/lipgloss\"\n\n// https://github.com/catppuccin/catppuccin\n\ntype Color int\n"
},
{
"path": "components/comments/commentsPage.go",
"chars": 3299,
"preview": "package comments\n\nimport (\n\t\"log/slog\"\n\t\"reddittui/client\"\n\t\"reddittui/components/messages\"\n\t\"reddittui/components/style"
},
{
"path": "components/comments/header.go",
"chars": 2251,
"preview": "package comments\n\nimport (\n\t\"fmt\"\n\t\"reddittui/components/colors\"\n\t\"reddittui/model\"\n\t\"reddittui/utils\"\n\t\"strconv\"\n\n\t\"git"
},
{
"path": "components/comments/keys.go",
"chars": 1780,
"preview": "package comments\n\nimport \"github.com/charmbracelet/bubbles/key\"\n\ntype viewportKeyMap struct {\n\tCursorUp key.Bind"
},
{
"path": "components/comments/pager.go",
"chars": 6879,
"preview": "package comments\n\nimport (\n\t\"fmt\"\n\t\"reddittui/model\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/help\"\n\t\"g"
},
{
"path": "components/comments/styles.go",
"chars": 1288,
"preview": "package comments\n\nimport (\n\t\"reddittui/components/colors\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nvar viewportStyle = l"
},
{
"path": "components/messages/messages.go",
"chars": 1820,
"preview": "package messages\n\nimport (\n\t\"reddittui/model\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n)\n\ntype ErrorModalMsg struct {\n"
},
{
"path": "components/modal/error.go",
"chars": 920,
"preview": "package modal\n\nimport (\n\t\"fmt\"\n\t\"reddittui/components/colors\"\n\t\"reddittui/components/messages\"\n\n\ttea \"github.com/charmbr"
},
{
"path": "components/modal/modal.go",
"chars": 3930,
"preview": "package modal\n\nimport (\n\t\"reddittui/components/colors\"\n\t\"reddittui/components/messages\"\n\n\t\"github.com/charmbracelet/bubb"
},
{
"path": "components/modal/quit.go",
"chars": 973,
"preview": "package modal\n\nimport (\n\t\"fmt\"\n\t\"reddittui/components/colors\"\n\t\"reddittui/components/messages\"\n\n\ttea \"github.com/charmbr"
},
{
"path": "components/modal/render.go",
"chars": 5264,
"preview": "package modal\n\nimport (\n\t\"bytes\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/mattn/go-runewidth\"\n\t\"git"
},
{
"path": "components/modal/search.go",
"chars": 1852,
"preview": "package modal\n\nimport (\n\t\"reddittui/components/colors\"\n\t\"reddittui/components/messages\"\n\n\t\"github.com/charmbracelet/bubb"
},
{
"path": "components/modal/spinner.go",
"chars": 1158,
"preview": "package modal\n\nimport (\n\t\"fmt\"\n\t\"reddittui/components/colors\"\n\n\t\"github.com/charmbracelet/bubbles/spinner\"\n\ttea \"github."
},
{
"path": "components/modal/subredditList.go",
"chars": 3809,
"preview": "package modal\n\nvar subredditSuggestions = []string{\n\t\"15minutefood\",\n\t\"adviceanimals\",\n\t\"all\",\n\t\"animalsbeingbros\",\n\t\"an"
},
{
"path": "components/posts/header.go",
"chars": 1354,
"preview": "package posts\n\nimport (\n\t\"reddittui/components/colors\"\n\t\"reddittui/utils\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nvar ("
},
{
"path": "components/posts/keys.go",
"chars": 732,
"preview": "package posts\n\nimport \"github.com/charmbracelet/bubbles/key\"\n\ntype postsKeyMap struct {\n\tHome key.Binding\n\tSearch key."
},
{
"path": "components/posts/postsPage.go",
"chars": 7039,
"preview": "package posts\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"reddittui/client\"\n\t\"reddittui/client/common\"\n\t\"reddittui/components/message"
},
{
"path": "components/posts/styles.go",
"chars": 449,
"preview": "package posts\n\nimport (\n\t\"github.com/charmbracelet/bubbles/list\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nvar postsListSt"
},
{
"path": "components/styles/style.go",
"chars": 112,
"preview": "package styles\n\nimport \"github.com/charmbracelet/lipgloss\"\n\nvar GlobalStyle = lipgloss.NewStyle().Padding(1, 2)\n"
},
{
"path": "components/tui.go",
"chars": 6211,
"preview": "package components\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"reddittui/client\"\n\t\"reddittui/components/comments\"\n\t\"reddittui/compone"
},
{
"path": "config/config.go",
"chars": 3126,
"preview": "package config\n\nimport (\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reddittui/utils\"\n\n\t\"github.com/BurntSushi/toml\"\n)\n\nconst ("
},
{
"path": "config/defaultConfig.go",
"chars": 342,
"preview": "package config\n\nconst defaultConfiguration = `\n#\n# Default configuration for reddittui.\n# Uncomment to configure\n#\n\n#[co"
},
{
"path": "go.mod",
"chars": 1345,
"preview": "module reddittui\n\ngo 1.23.4\n\nrequire (\n\tgithub.com/charmbracelet/bubbletea v1.2.4\n\tgithub.com/charmbracelet/x/exp/teates"
},
{
"path": "go.sum",
"chars": 5398,
"preview": "github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=\ngithub.com/BurntSushi/toml v1.4.0/go.m"
},
{
"path": "install.sh",
"chars": 503,
"preview": "#!/bin/bash\nset -e\n\nAPP_NAME=\"reddittui\"\nBUILD_DIR=\"build\"\nGO_MAIN_FILE=\"main.go\"\nINSTALL_DIR=\"/usr/local/bin\"\n\n# Build "
},
{
"path": "integ_test.go",
"chars": 5291,
"preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"os\"\n\t\"reddittui/components\"\n\t\"reddittui/config\"\n\t\"testing\"\n\t\"time\"\n\n\ttea \"github.com/c"
},
{
"path": "justfile",
"chars": 627,
"preview": "@default: run\n\n@run:\n go run .\n\ntest:\n go test -v ./...\n\nclean:\n rm -rf build/\n rm -rf ~/.cache/reddittui/*\n rm -rf"
},
{
"path": "main.go",
"chars": 1062,
"preview": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\t\"reddittui/components\"\n\t\"reddittui/config\"\n\t\"reddittui/utils\"\n\n"
},
{
"path": "model/commentsModel.go",
"chars": 1106,
"preview": "package model\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype Comment struct {\n\tAuthor string `json:\"author\"`\n\tText "
},
{
"path": "model/postModel.go",
"chars": 1168,
"preview": "package model\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype Post struct {\n\tPostTitle string `json:\"title\"`\n\tAuthor "
},
{
"path": "uninstall.sh",
"chars": 368,
"preview": "#!/bin/bash\nset -e\n\nAPP_NAME=\"reddittui\"\nINSTALL_DIR=\"/usr/local/bin\"\nBINARY_PATH=\"$INSTALL_DIR/$APP_NAME\"\n\nif [[ -f $BI"
},
{
"path": "utils/browser.go",
"chars": 387,
"preview": "package utils\n\nimport (\n\t\"fmt\"\n\t\"os/exec\"\n\t\"runtime\"\n)\n\nfunc OpenUrl(url string) error {\n\tswitch runtime.GOOS {\n\tcase \"l"
},
{
"path": "utils/files.go",
"chars": 1162,
"preview": "package utils\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n)\n\nconst (\n\tappName = \"reddittui\"\n\tdefaultConfigDir = \".config\"\n"
},
{
"path": "utils/logger.go",
"chars": 599,
"preview": "package utils\n\nimport (\n\t\"log/slog\"\n\t\"os\"\n\t\"strings\"\n)\n\nfunc InitLogger(logLevel string) (*os.File, error) {\n\tvar level "
},
{
"path": "utils/timer.go",
"chars": 572,
"preview": "package utils\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"time\"\n)\n\ntype Timer struct {\n\tContext string\n\tStart time.Time\n\tEnd ti"
},
{
"path": "utils/utils.go",
"chars": 706,
"preview": "package utils\n\nimport (\n\t\"fmt\"\n)\n\nfunc NormalizeSubreddit(subreddit string) string {\n\tif subreddit == \"reddit.com\" {\n\t\tr"
},
{
"path": "utils/utils_test.go",
"chars": 1732,
"preview": "package utils\n\nimport \"testing\"\n\nfunc TestNormalizeSubreddit(t *testing.T) {\n\ttests := []struct {\n\t\tsubreddit string\n\t\tw"
}
]
About this extraction
This page contains the full source code of the tonymajestro/reddit-tui GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 55 files (129.6 KB), approximately 39.3k tokens, and a symbol index with 386 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.