Full Code of tonymajestro/reddit-tui for AI

main 3a594f6dce40 cached
55 files
129.6 KB
39.3k tokens
386 symbols
1 requests
Download .txt
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)
		}
	}
}
Download .txt
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
Download .txt
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.

Copied to clipboard!