Full Code of charmbracelet/glow for AI

master 752de97c5a33 cached
46 files
119.4 KB
40.0k tokens
178 symbols
1 requests
Download .txt
Repository: charmbracelet/glow
Branch: master
Commit: 752de97c5a33
Files: 46
Total size: 119.4 KB

Directory structure:
gitextract_ljxy3y3l/

├── .editorconfig
├── .github/
│   ├── CODEOWNERS
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   ├── config.yml
│   │   └── feature_request.md
│   ├── dependabot.yml
│   └── workflows/
│       ├── build.yml
│       ├── coverage.yml
│       ├── dependabot-sync.yml
│       ├── goreleaser.yml
│       ├── lint-sync.yml
│       └── lint.yml
├── .gitignore
├── .golangci.yml
├── .goreleaser.yml
├── Dockerfile
├── LICENSE
├── README.md
├── Taskfile.yaml
├── config_cmd.go
├── console_windows.go
├── github.go
├── gitlab.go
├── glow_test.go
├── go.mod
├── go.sum
├── log.go
├── main.go
├── man_cmd.go
├── style.go
├── ui/
│   ├── config.go
│   ├── editor.go
│   ├── ignore_darwin.go
│   ├── ignore_general.go
│   ├── keys.go
│   ├── markdown.go
│   ├── pager.go
│   ├── sort.go
│   ├── stash.go
│   ├── stashhelp.go
│   ├── stashitem.go
│   ├── styles.go
│   └── ui.go
├── url.go
├── url_test.go
└── utils/
    └── utils.go

================================================
FILE CONTENTS
================================================

================================================
FILE: .editorconfig
================================================
# https://editorconfig.org/

root = true

[*]
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2

[*.go]
indent_style = tab
indent_size = 8

[*.golden]
insert_final_newline = false
trim_trailing_whitespace = false


================================================
FILE: .github/CODEOWNERS
================================================
* @charmbracelet/everyone


================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''

---

**Describe the bug**
A clear and concise description of what the bug is.

**Setup**
Please complete the following information along with version numbers, if applicable.
 - OS [e.g. Ubuntu, macOS]
 - Shell [e.g. zsh, fish]
 - Terminal Emulator [e.g. kitty, iterm]
 - Terminal Multiplexer [e.g. tmux]
 - Locale [e.g. en_US.UTF-8, zh_CN.UTF-8, etc.]

**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error

**Source Code**
Please include source code if needed to reproduce the behavior. 

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
Add screenshots to help explain your problem.

**Additional context**
Add any other context about the problem here.


================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: true
contact_links:
- name: Discord
  url: https://charm.sh/discord
  about: Chat on our Discord.


================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''

---

**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

**Describe the solution you'd like**
A clear and concise description of what you want to happen.

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

**Additional context**
Add any other context or screenshots about the feature request here.


================================================
FILE: .github/dependabot.yml
================================================
version: 2

updates:
  - package-ecosystem: "gomod"
    directory: "/"
    schedule:
      interval: "weekly"
      day: "monday"
      time: "05:00"
      timezone: "America/New_York"
    labels:
      - "dependencies"
    commit-message:
      prefix: "chore"
      include: "scope"
    groups:
      all:
        patterns:
          - "*"
    ignore:
      - dependency-name: github.com/charmbracelet/bubbletea/v2
        versions:
          - v2.0.0-beta1

  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
      day: "monday"
      time: "05:00"
      timezone: "America/New_York"
    labels:
      - "dependencies"
    commit-message:
      prefix: "chore"
      include: "scope"
    groups:
      all:
        patterns:
          - "*"

  - package-ecosystem: "docker"
    directory: "/"
    schedule:
      interval: "weekly"
      day: "monday"
      time: "05:00"
      timezone: "America/New_York"
    labels:
      - "dependencies"
    commit-message:
      prefix: "chore"
      include: "scope"
    groups:
      all:
        patterns:
          - "*"


================================================
FILE: .github/workflows/build.yml
================================================
name: build

on: [push, pull_request]

jobs:
  build:
    uses: charmbracelet/meta/.github/workflows/build.yml@main

  snapshot:
    uses: charmbracelet/meta/.github/workflows/snapshot.yml@main
    secrets:
      goreleaser_key: ${{ secrets.GORELEASER_KEY }}

  govulncheck:
    uses: charmbracelet/meta/.github/workflows/govulncheck.yml@main
    with:
      go-version: stable

  semgrep:
    uses: charmbracelet/meta/.github/workflows/semgrep.yml@main

  ruleguard:
    uses: charmbracelet/meta/.github/workflows/ruleguard.yml@main
    with:
      go-version: stable


================================================
FILE: .github/workflows/coverage.yml
================================================
name: coverage
on: [push, pull_request]

jobs:
  coverage:
    runs-on: ubuntu-latest
    env:
      GO111MODULE: "on"
    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Install Go
        uses: actions/setup-go@v6
        with:
          go-version: stable

      - name: Coverage
        env:
          COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          go test -race -covermode atomic -coverprofile=profile.cov ./...
          go install github.com/mattn/goveralls@latest
          goveralls -coverprofile=profile.cov -service=github


================================================
FILE: .github/workflows/dependabot-sync.yml
================================================
name: dependabot-sync
on:
  schedule:
    - cron: "0 0 * * 0" # every Sunday at midnight
  workflow_dispatch: # allows manual triggering

permissions:
  contents: write
  pull-requests: write

jobs:
  dependabot-sync:
    uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main
    with:
      repo_name: ${{ github.event.repository.name }}
    secrets:
      gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}


================================================
FILE: .github/workflows/goreleaser.yml
================================================
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json

name: goreleaser

on:
  push:
    tags:
      - v*.*.*

concurrency:
  group: goreleaser
  cancel-in-progress: true

jobs:
  goreleaser:
    uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main
    secrets:
      docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
      docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
      gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
      goreleaser_key: ${{ secrets.GORELEASER_KEY }}
      fury_token: ${{ secrets.FURY_TOKEN }}
      nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }}
      nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }}
      snapcraft_token: ${{ secrets.SNAPCRAFT_TOKEN }}


================================================
FILE: .github/workflows/lint-sync.yml
================================================
name: lint-sync
on:
  schedule:
    - cron: "0 0 * * 0" # every sunday at midnight
  workflow_dispatch:

permissions:
  contents: write
  pull-requests: write

jobs:
  lint:
    uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main


================================================
FILE: .github/workflows/lint.yml
================================================
name: lint
on:
  push:
  pull_request:

jobs:
  golangci:
    name: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: golangci-lint
        uses: golangci/golangci-lint-action@v9.2.0
        with:
          # Optional: golangci-lint command line arguments.
          args: --issues-exit-code=0
          # Optional: show only new issues if it's a pull request. The default value is `false`.
          only-new-issues: true


================================================
FILE: .gitignore
================================================
glow
dist/
.envrc
completions/
manpages/


================================================
FILE: .golangci.yml
================================================
version: "2"
run:
  tests: false
linters:
  enable:
    - bodyclose
    - exhaustive
    - goconst
    - godot
    - gomoddirectives
    - goprintffuncname
    - gosec
    - misspell
    - nakedret
    - nestif
    - nilerr
    - noctx
    - nolintlint
    - prealloc
    - revive
    - rowserrcheck
    - sqlclosecheck
    - tparallel
    - unconvert
    - unparam
    - whitespace
    - wrapcheck
  exclusions:
    rules:
      - text: '(slog|log)\.\w+'
        linters:
          - noctx
    generated: lax
    presets:
      - common-false-positives
issues:
  max-issues-per-linter: 0
  max-same-issues: 0
formatters:
  enable:
    - gofumpt
    - goimports
  exclusions:
    generated: lax


================================================
FILE: .goreleaser.yml
================================================
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json

version: 2

includes:
  - from_url:
      url: charmbracelet/meta/main/goreleaser-glow.yaml

variables:
  description: "Render markdown on the CLI, with pizzazz!"
  github_url: "https://github.com/charmbracelet/glow"
  maintainer: "Christian Muehlhaeuser <muesli@charm.sh>"
  brew_commit_author_name: "Christian Muehlhaeuser"
  brew_commit_author_email: "muesli@charm.sh"


================================================
FILE: Dockerfile
================================================
FROM gcr.io/distroless/static
COPY glow /usr/local/bin/glow
ENTRYPOINT [ "/usr/local/bin/glow" ]


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2019-2024 Charmbracelet, Inc

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
================================================
# Glow

Render markdown on the CLI, with _pizzazz_!

<p align="center">
    <img src="https://stuff.charm.sh/glow/glow-banner-github.gif" alt="Glow Logo">
    <a href="https://github.com/charmbracelet/glow/releases"><img src="https://img.shields.io/github/release/charmbracelet/glow.svg" alt="Latest Release"></a>
    <a href="https://pkg.go.dev/github.com/charmbracelet/glow?tab=doc"><img src="https://godoc.org/github.com/golang/gddo?status.svg" alt="GoDoc"></a>
    <a href="https://github.com/charmbracelet/glow/actions"><img src="https://github.com/charmbracelet/glow/workflows/build/badge.svg" alt="Build Status"></a>
    <a href="https://goreportcard.com/report/github.com/charmbracelet/glow"><img src="https://goreportcard.com/badge/charmbracelet/glow" alt="Go ReportCard"></a>
</p>

<p align="center">
    <img src="https://github.com/user-attachments/assets/c2246366-f84b-4847-b431-32a61ca07b74" width="800" alt="Glow UI Demo">
</p>

## What is it?

Glow is a terminal based markdown reader designed from the ground up to bring
out the beauty—and power—of the CLI.

Use it to discover markdown files, read documentation directly on the command
line. Glow will find local markdown files in subdirectories or a local
Git repository.

## Installation

### Package Manager

```bash
# macOS or Linux
brew install glow
```

```bash
# macOS (with MacPorts)
sudo port install glow
```

```bash
# Arch Linux (btw)
pacman -S glow
```

```bash
# Void Linux
xbps-install -S glow
```

```bash
# Nix shell
nix-shell -p glow --command glow
```

```bash
# FreeBSD
pkg install glow
```

```bash
# Solus
eopkg install glow
```

```bash
# Windows (with Chocolatey, Scoop, or Winget)
choco install glow
scoop install glow
winget install charmbracelet.glow
```

```bash
# Android (with termux)
pkg install glow
```

```bash
# Ubuntu (Snapcraft)
sudo snap install glow
```

```bash
# Debian/Ubuntu
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list
sudo apt update && sudo apt install glow
```

```bash
# Fedora/RHEL
echo '[charm]
name=Charm
baseurl=https://repo.charm.sh/yum/
enabled=1
gpgcheck=1
gpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo
sudo yum install glow
```

Or download a binary from the [releases][releases] page. MacOS, Linux, Windows,
FreeBSD and OpenBSD binaries are available, as well as Debian, RPM, and Alpine
packages. ARM builds are also available for macOS, Linux, FreeBSD and OpenBSD.

### Go

Or just install it with `go`:

```bash
go install github.com/charmbracelet/glow/v2@latest
```

### Build (requires Go 1.21+)

```bash
git clone https://github.com/charmbracelet/glow.git
cd glow
go build
```

[releases]: https://github.com/charmbracelet/glow/releases

## The TUI

Simply run `glow` without arguments to start the textual user interface and
browse local. Glow will find local markdown files in the
current directory and below or, if you’re in a Git repository, Glow will search
the repo.

Markdown files can be read with Glow's high-performance pager. Most of the
keystrokes you know from `less` are the same, but you can press `?` to list
the hotkeys.

## The CLI

In addition to a TUI, Glow has a CLI for working with Markdown. To format a
document use a markdown source as the primary argument:

```bash
# Read from file
glow README.md

# Read from stdin
echo "[Glow](https://github.com/charmbracelet/glow)" | glow -

# Fetch README from GitHub / GitLab
glow github.com/charmbracelet/glow

# Fetch markdown from HTTP
glow https://host.tld/file.md
```

### Word Wrapping

The `-w` flag lets you set a maximum width at which the output will be wrapped:

```bash
glow -w 60
```

### Paging

CLI output can be displayed in your preferred pager with the `-p` flag. This defaults
to the ANSI-aware `less -r` if `$PAGER` is not explicitly set.

### Styles

You can choose a style with the `-s` flag. When no flag is provided `glow` tries
to detect your terminal's current background color and automatically picks
either the `dark` or the `light` style for you.

```bash
glow -s [dark|light]
```

Alternatively you can also supply a custom JSON stylesheet:

```bash
glow -s mystyle.json
```

For additional usage details see:

```bash
glow --help
```

Check out the [Glamour Style Section](https://github.com/charmbracelet/glamour/blob/master/styles/gallery/README.md)
to find more styles. Or [make your own](https://github.com/charmbracelet/glamour/tree/master/styles)!

## The Config File

If you find yourself supplying the same flags to `glow` all the time, it's
probably a good idea to create a config file. Run `glow config`, which will open
it in your favorite $EDITOR. Alternatively you can manually put a file named
`glow.yml` in the default config path of you platform. If you're not sure where
that is, please refer to `glow --help`.

Here's an example config:

```yaml
# style name or JSON path (default "auto")
style: "light"
# mouse wheel support (TUI-mode only)
mouse: true
# use pager to display markdown
pager: true
# at which column should we word wrap?
width: 80
# show all files, including hidden and ignored.
all: false
# show line numbers (TUI-mode only)
showLineNumbers: false
# preserve newlines in the output
preserveNewLines: false
```

## Contributing

See [contributing][contribute].

[contribute]: https://github.com/charmbracelet/glow/contribute

## Feedback

We’d love to hear your thoughts on this project. Feel free to drop us a note!

- [Twitter](https://twitter.com/charmcli)
- [The Fediverse](https://mastodon.social/@charmcli)
- [Discord](https://charm.sh/chat)

## License

[MIT](https://github.com/charmbracelet/glow/raw/master/LICENSE)

---

Part of [Charm](https://charm.sh).

<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>

Charm热爱开源 • Charm loves open source


================================================
FILE: Taskfile.yaml
================================================
# https://taskfile.dev

version: '3'

tasks:
  lint:
    desc: Run base linters
    cmds:
      - golangci-lint run

  test:
    desc: Run tests
    cmds:
      - go test ./... {{.CLI_ARGS}}

  log:
    desc: Watch for glow logs
    aliases: [tail]
    cmds:
      - cmd: tail -f ~/Library/Caches/glow/glow.log
        platforms: [darwin]
      - cmd: tail -f ~/.cache/glow/glow.log
        platforms: [linux, windows]


================================================
FILE: config_cmd.go
================================================
package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path"
	"path/filepath"

	"github.com/charmbracelet/x/editor"
	"github.com/spf13/cobra"
	"github.com/spf13/viper"
)

const defaultConfig = `# style name or JSON path (default "auto")
style: "auto"
# mouse support (TUI-mode only)
mouse: false
# use pager to display markdown
pager: false
# word-wrap at width
width: 80
# show all files, including hidden and ignored.
all: false
`

var configCmd = &cobra.Command{
	Use:     "config",
	Hidden:  false,
	Short:   "Edit the glow config file",
	Long:    paragraph(fmt.Sprintf("\n%s the glow config file. We’ll use EDITOR to determine which editor to use. If the config file doesn't exist, it will be created.", keyword("Edit"))),
	Example: paragraph("glow config\nglow config --config path/to/config.yml"),
	Args:    cobra.NoArgs,
	RunE: func(*cobra.Command, []string) error {
		if err := ensureConfigFile(); err != nil {
			return err
		}

		c, err := editor.Cmd("Glow", configFile)
		if err != nil {
			return fmt.Errorf("unable to set config file: %w", err)
		}
		c.Stdin = os.Stdin
		c.Stdout = os.Stdout
		c.Stderr = os.Stderr
		if err := c.Run(); err != nil {
			return fmt.Errorf("unable to run command: %w", err)
		}

		fmt.Println("Wrote config file to:", configFile)
		return nil
	},
}

func ensureConfigFile() error {
	if configFile == "" {
		configFile = viper.GetViper().ConfigFileUsed()
		if err := os.MkdirAll(filepath.Dir(configFile), 0o755); err != nil { //nolint:gosec
			return fmt.Errorf("could not write configuration file: %w", err)
		}
	}

	if ext := path.Ext(configFile); ext != ".yaml" && ext != ".yml" {
		return fmt.Errorf("'%s' is not a supported configuration type: use '%s' or '%s'", ext, ".yaml", ".yml")
	}

	if _, err := os.Stat(configFile); errors.Is(err, fs.ErrNotExist) {
		// File doesn't exist yet, create all necessary directories and
		// write the default config file
		if err := os.MkdirAll(filepath.Dir(configFile), 0o700); err != nil {
			return fmt.Errorf("unable create directory: %w", err)
		}

		f, err := os.Create(configFile)
		if err != nil {
			return fmt.Errorf("unable to create config file: %w", err)
		}
		defer func() { _ = f.Close() }()

		if _, err := f.WriteString(defaultConfig); err != nil {
			return fmt.Errorf("unable to write config file: %w", err)
		}
	} else if err != nil { // some other error occurred
		return fmt.Errorf("unable to stat config file: %w", err)
	}
	return nil
}


================================================
FILE: console_windows.go
================================================
// +build windows

package main

import (
	"os"

	"golang.org/x/sys/windows"
)

// enableAnsiColors enables support for ANSI color sequences in Windows
// default console. Note that this only works with Windows 10.
func enableAnsiColors() {
	stdout := windows.Handle(os.Stdout.Fd())
	var originalMode uint32

	windows.GetConsoleMode(stdout, &originalMode)
	windows.SetConsoleMode(stdout, originalMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING)
}

func init() {
	enableAnsiColors()
}


================================================
FILE: github.go
================================================
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
)

// findGitHubREADME tries to find the correct README filename in a repository using GitHub API.
func findGitHubREADME(u *url.URL) (*source, error) {
	owner, repo, ok := strings.Cut(strings.TrimPrefix(u.Path, "/"), "/")
	if !ok {
		return nil, fmt.Errorf("invalid url: %s", u.String())
	}

	type readme struct {
		DownloadURL string `json:"download_url"`
	}

	apiURL := fmt.Sprintf("https://api.%s/repos/%s/%s/readme", u.Hostname(), owner, repo)

	//nolint:bodyclose
	// it is closed on the caller
	res, err := http.Get(apiURL) //nolint: gosec,noctx
	if err != nil {
		return nil, fmt.Errorf("unable to get url: %w", err)
	}

	body, err := io.ReadAll(res.Body)
	if err != nil {
		return nil, fmt.Errorf("unable to read http response body: %w", err)
	}

	var result readme
	if err := json.Unmarshal(body, &result); err != nil {
		return nil, fmt.Errorf("unable to parse json: %w", err)
	}

	if res.StatusCode == http.StatusOK {
		//nolint:bodyclose
		// it is closed on the caller
		resp, err := http.Get(result.DownloadURL) //nolint: noctx
		if err != nil {
			return nil, fmt.Errorf("unable to get url: %w", err)
		}

		if resp.StatusCode == http.StatusOK {
			return &source{resp.Body, result.DownloadURL}, nil
		}
	}

	return nil, errors.New("can't find README in GitHub repository")
}


================================================
FILE: gitlab.go
================================================
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
)

// findGitLabREADME tries to find the correct README filename in a repository using GitLab API.
func findGitLabREADME(u *url.URL) (*source, error) {
	owner, repo, ok := strings.Cut(strings.TrimPrefix(u.Path, "/"), "/")
	if !ok {
		return nil, fmt.Errorf("invalid url: %s", u.String())
	}

	projectPath := url.QueryEscape(owner + "/" + repo)

	type readme struct {
		ReadmeURL string `json:"readme_url"`
	}

	apiURL := fmt.Sprintf("https://%s/api/v4/projects/%s", u.Hostname(), projectPath)

	//nolint:bodyclose
	// it is closed on the caller
	res, err := http.Get(apiURL) //nolint: gosec,noctx
	if err != nil {
		return nil, fmt.Errorf("unable to get url: %w", err)
	}

	body, err := io.ReadAll(res.Body)
	if err != nil {
		return nil, fmt.Errorf("unable to read http response body: %w", err)
	}

	var result readme
	if err := json.Unmarshal(body, &result); err != nil {
		return nil, fmt.Errorf("unable to parse json: %w", err)
	}

	readmeRawURL := strings.ReplaceAll(result.ReadmeURL, "blob", "raw")

	if res.StatusCode == http.StatusOK {
		//nolint:bodyclose
		// it is closed on the caller
		resp, err := http.Get(readmeRawURL) //nolint: gosec,noctx
		if err != nil {
			return nil, fmt.Errorf("unable to get url: %w", err)
		}

		if resp.StatusCode == http.StatusOK {
			return &source{resp.Body, readmeRawURL}, nil
		}
	}

	return nil, errors.New("can't find README in GitLab repository")
}


================================================
FILE: glow_test.go
================================================
package main

import (
	"testing"
)

func TestGlowFlags(t *testing.T) {
	tt := []struct {
		args  []string
		check func() bool
	}{
		{
			args: []string{"-p"},
			check: func() bool {
				return pager
			},
		},
		{
			args: []string{"-s", "light"},
			check: func() bool {
				return style == "light"
			},
		},
		{
			args: []string{"-w", "40"},
			check: func() bool {
				return width == 40
			},
		},
	}

	for _, v := range tt {
		err := rootCmd.ParseFlags(v.args)
		if err != nil {
			t.Fatal(err)
		}
		if !v.check() {
			t.Errorf("Parsing flag failed: %s", v.args)
		}
	}
}


================================================
FILE: go.mod
================================================
module github.com/charmbracelet/glow/v2

go 1.24.0

toolchain go1.24.1

require (
	github.com/atotto/clipboard v0.1.4
	github.com/caarlos0/env/v11 v11.3.1
	github.com/charmbracelet/bubbles v0.21.0
	github.com/charmbracelet/bubbletea v1.3.10
	github.com/charmbracelet/glamour v0.10.0
	github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
	github.com/charmbracelet/log v0.4.2
	github.com/charmbracelet/x/editor v0.1.0
	github.com/dustin/go-humanize v1.0.1
	github.com/fsnotify/fsnotify v1.9.0
	github.com/mattn/go-runewidth v0.0.19
	github.com/mitchellh/go-homedir v1.1.0
	github.com/muesli/gitcha v0.3.0
	github.com/muesli/go-app-paths v0.2.2
	github.com/muesli/mango-cobra v1.3.0
	github.com/muesli/reflow v0.3.0
	github.com/muesli/roff v0.1.0
	github.com/muesli/termenv v0.16.0
	github.com/sahilm/fuzzy v0.1.1
	github.com/spf13/cobra v1.10.2
	github.com/spf13/viper v1.21.0
	golang.org/x/sys v0.39.0
	golang.org/x/term v0.38.0
	golang.org/x/text v0.32.0
)

require (
	github.com/alecthomas/chroma/v2 v2.14.0 // indirect
	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
	github.com/aymerick/douceur v0.2.0 // indirect
	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
	github.com/charmbracelet/x/ansi v0.10.1 // indirect
	github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
	github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
	github.com/charmbracelet/x/term v0.2.1 // indirect
	github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
	github.com/dlclark/regexp2 v1.11.0 // indirect
	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
	github.com/go-logfmt/logfmt v0.6.0 // indirect
	github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
	github.com/gorilla/css v1.0.1 // indirect
	github.com/inconshreveable/mousetrap v1.1.0 // 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/microcosm-cc/bluemonday v1.0.27 // indirect
	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
	github.com/muesli/cancelreader v0.2.2 // indirect
	github.com/muesli/mango v0.2.0 // indirect
	github.com/muesli/mango-pflag v0.1.0 // indirect
	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
	github.com/rivo/uniseg v0.4.7 // indirect
	github.com/rogpeppe/go-internal v1.12.0 // indirect
	github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 // indirect
	github.com/sagikazarmark/locafero v0.11.0 // indirect
	github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
	github.com/spf13/afero v1.15.0 // indirect
	github.com/spf13/cast v1.10.0 // indirect
	github.com/spf13/pflag v1.0.10 // indirect
	github.com/subosito/gotenv v1.6.0 // indirect
	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
	github.com/yuin/goldmark v1.7.8 // indirect
	github.com/yuin/goldmark-emoji v1.0.5 // indirect
	go.yaml.in/yaml/v3 v3.0.4 // indirect
	golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect
	golang.org/x/net v0.40.0 // indirect
	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)


================================================
FILE: go.sum
================================================
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/editor v0.1.0 h1:p69/dpvlwRTs9uYiPeAWruwsHqTFzHhTvQOd/WVSX98=
github.com/charmbracelet/x/editor v0.1.0/go.mod h1:oivrEbcP/AYt/Hpvk5pwDXXrQ933gQS6UzL6fxqAGSA=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
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/gitcha v0.3.0 h1:+PJkVKrDXVB0VgRn/yVx2CqSVSDGMSepzvohsCrPYtQ=
github.com/muesli/gitcha v0.3.0/go.mod h1:vX3jFL+XcEUq1uY74RCjLSZfAV+ZuvLg70/NGPdXn84=
github.com/muesli/go-app-paths v0.2.2 h1:NqG4EEZwNIhBq/pREgfBmgDmt3h1Smr1MjZiXbpZUnI=
github.com/muesli/go-app-paths v0.2.2/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho=
github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=
github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec=
github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E=
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
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/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 h1:G04eS0JkAIVZfaJLjla9dNxkJCPiKIGZlw9AfOhzOD0=
github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94/go.mod h1:b18R55ulyQ/h3RaWyloPyER7fWQVZvimKKhnI5OfrJQ=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM=
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=


================================================
FILE: log.go
================================================
package main

import (
	"fmt"
	"io"
	"os"
	"path/filepath"

	"github.com/charmbracelet/log"
	gap "github.com/muesli/go-app-paths"
)

func getLogFilePath() (string, error) {
	dir, err := gap.NewScope(gap.User, "glow").CacheDir()
	if err != nil {
		return "", fmt.Errorf("unable to get cache dir: %w", err)
	}
	return filepath.Join(dir, "glow.log"), nil
}

func setupLog() (func() error, error) {
	log.SetOutput(io.Discard)
	// Log to file, if set
	logFile, err := getLogFilePath()
	if err != nil {
		return nil, err
	}
	if err := os.MkdirAll(filepath.Dir(logFile), 0o755); err != nil { //nolint:gosec
		// log disabled
		return func() error { return nil }, nil //nolint:nilerr
	}
	f, err := os.OpenFile(logFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644) //nolint:gosec
	if err != nil {
		// log disabled
		return func() error { return nil }, nil //nolint:nilerr
	}
	log.SetOutput(f)
	log.SetLevel(log.DebugLevel)
	return f.Close, nil
}


================================================
FILE: main.go
================================================
// Package main provides the entry point for the Glow CLI application.
package main

import (
	"errors"
	"fmt"
	"io"
	"io/fs"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strings"

	"github.com/caarlos0/env/v11"
	"github.com/charmbracelet/glamour"
	"github.com/charmbracelet/glamour/styles"
	"github.com/charmbracelet/glow/v2/ui"
	"github.com/charmbracelet/glow/v2/utils"
	"github.com/charmbracelet/lipgloss"
	"github.com/charmbracelet/log"
	gap "github.com/muesli/go-app-paths"
	"github.com/spf13/cobra"
	"github.com/spf13/viper"
	"golang.org/x/term"
)

var (
	// Version as provided by goreleaser.
	Version = ""
	// CommitSHA as provided by goreleaser.
	CommitSHA = ""

	readmeNames      = []string{"README.md", "README", "Readme.md", "Readme", "readme.md", "readme"}
	configFile       string
	pager            bool
	tui              bool
	style            string
	width            uint
	showAllFiles     bool
	showLineNumbers  bool
	preserveNewLines bool
	mouse            bool

	rootCmd = &cobra.Command{
		Use:   "glow [SOURCE|DIR]",
		Short: "Render markdown on the CLI, with pizzazz!",
		Long: paragraph(
			fmt.Sprintf("\nRender markdown on the CLI, %s!", keyword("with pizzazz")),
		),
		SilenceErrors:    false,
		SilenceUsage:     true,
		TraverseChildren: true,
		Args:             cobra.MaximumNArgs(1),
		ValidArgsFunction: func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
			return nil, cobra.ShellCompDirectiveDefault
		},
		PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
			return validateOptions(cmd)
		},
		RunE: execute,
	}
)

// source provides a readable markdown source.
type source struct {
	reader io.ReadCloser
	URL    string
}

// sourceFromArg parses an argument and creates a readable source for it.
func sourceFromArg(arg string) (*source, error) {
	// from stdin
	if arg == "-" {
		return &source{reader: os.Stdin}, nil
	}

	// a GitHub or GitLab URL (even without the protocol):
	src, err := readmeURL(arg)
	if src != nil && err == nil {
		// if there's an error, try next methods...
		return src, nil
	}

	// HTTP(S) URLs:
	if u, err := url.ParseRequestURI(arg); err == nil && strings.Contains(arg, "://") { //nolint:nestif
		if u.Scheme != "" {
			if u.Scheme != "http" && u.Scheme != "https" {
				return nil, fmt.Errorf("%s is not a supported protocol", u.Scheme)
			}
			// consumer of the source is responsible for closing the ReadCloser.
			resp, err := http.Get(u.String()) //nolint: noctx,bodyclose
			if err != nil {
				return nil, fmt.Errorf("unable to get url: %w", err)
			}
			if resp.StatusCode != http.StatusOK {
				return nil, fmt.Errorf("HTTP status %d", resp.StatusCode)
			}
			return &source{resp.Body, u.String()}, nil
		}
	}

	// a directory:
	if len(arg) == 0 {
		// use the current working dir if no argument was supplied
		arg = "."
	}
	st, err := os.Stat(arg)
	if err == nil && st.IsDir() { //nolint:nestif
		var src *source
		_ = filepath.Walk(arg, func(path string, _ os.FileInfo, err error) error {
			if err != nil {
				return err
			}
			for _, v := range readmeNames {
				if strings.EqualFold(filepath.Base(path), v) {
					r, err := os.Open(path)
					if err != nil {
						continue
					}

					u, _ := filepath.Abs(path)
					src = &source{r, u}

					// abort filepath.Walk
					return errors.New("source found")
				}
			}
			return nil
		})

		if src != nil {
			return src, nil
		}

		return nil, errors.New("missing markdown source")
	}

	r, err := os.Open(arg)
	if err != nil {
		return nil, fmt.Errorf("unable to open file: %w", err)
	}
	u, err := filepath.Abs(arg)
	if err != nil {
		return nil, fmt.Errorf("unable to get absolute path: %w", err)
	}
	return &source{r, u}, nil
}

// validateStyle checks if the style is a default style, if not, checks that
// the custom style exists.
func validateStyle(style string) error {
	if style != "auto" && styles.DefaultStyles[style] == nil {
		style = utils.ExpandPath(style)
		if _, err := os.Stat(style); errors.Is(err, fs.ErrNotExist) {
			return fmt.Errorf("specified style does not exist: %s", style)
		} else if err != nil {
			return fmt.Errorf("unable to stat file: %w", err)
		}
	}
	return nil
}

func validateOptions(cmd *cobra.Command) error {
	// grab config values from Viper
	width = viper.GetUint("width")
	mouse = viper.GetBool("mouse")
	pager = viper.GetBool("pager")
	tui = viper.GetBool("tui")
	showAllFiles = viper.GetBool("all")
	preserveNewLines = viper.GetBool("preserveNewLines")
	showLineNumbers = viper.GetBool("showLineNumbers")

	if pager && tui {
		return errors.New("cannot use both pager and tui")
	}

	// validate the glamour style
	style = viper.GetString("style")
	if err := validateStyle(style); err != nil {
		return err
	}

	isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
	// We want to use a special no-TTY style, when stdout is not a terminal
	// and there was no specific style passed by arg
	if !isTerminal && !cmd.Flags().Changed("style") {
		style = "notty"
	}

	// Detect terminal width
	if !cmd.Flags().Changed("width") { //nolint:nestif
		if isTerminal && width == 0 {
			w, _, err := term.GetSize(int(os.Stdout.Fd()))
			if err == nil {
				width = uint(w) //nolint:gosec
			}

			if width > 120 {
				width = 120
			}
		}
		if width == 0 {
			width = 80
		}
	}
	return nil
}

func stdinIsPipe() (bool, error) {
	stat, err := os.Stdin.Stat()
	if err != nil {
		return false, fmt.Errorf("unable to open file: %w", err)
	}
	if stat.Mode()&os.ModeCharDevice == 0 || stat.Size() > 0 {
		return true, nil
	}
	return false, nil
}

func execute(cmd *cobra.Command, args []string) error {
	// if stdin is a pipe then use stdin for input. note that you can also
	// explicitly use a - to read from stdin.
	if yes, err := stdinIsPipe(); err != nil {
		return err
	} else if yes {
		src := &source{reader: os.Stdin}
		defer src.reader.Close() //nolint:errcheck
		return executeCLI(cmd, src, os.Stdout)
	}

	switch len(args) {
	// TUI running on cwd
	case 0:
		return runTUI("", "")

	// TUI with possible dir argument
	case 1:
		// Validate that the argument is a directory. If it's not treat it as
		// an argument to the non-TUI version of Glow (via fallthrough).
		info, err := os.Stat(args[0])
		if err == nil && info.IsDir() {
			p, err := filepath.Abs(args[0])
			if err == nil {
				return runTUI(p, "")
			}
		}
		fallthrough

	// CLI
	default:
		for _, arg := range args {
			if err := executeArg(cmd, arg, os.Stdout); err != nil {
				return err
			}
		}
	}

	return nil
}

func executeArg(cmd *cobra.Command, arg string, w io.Writer) error {
	// create an io.Reader from the markdown source in cli-args
	src, err := sourceFromArg(arg)
	if err != nil {
		return err
	}
	defer src.reader.Close() //nolint:errcheck
	return executeCLI(cmd, src, w)
}

func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error {
	b, err := io.ReadAll(src.reader)
	if err != nil {
		return fmt.Errorf("unable to read from reader: %w", err)
	}

	b = utils.RemoveFrontmatter(b)

	// render
	var baseURL string
	u, err := url.ParseRequestURI(src.URL)
	if err == nil {
		u.Path = filepath.Dir(u.Path)
		baseURL = u.String() + "/"
	}

	isCode := !utils.IsMarkdownFile(src.URL)

	// initialize glamour
	r, err := glamour.NewTermRenderer(
		glamour.WithColorProfile(lipgloss.ColorProfile()),
		utils.GlamourStyle(style, isCode),
		glamour.WithWordWrap(int(width)), //nolint:gosec
		glamour.WithBaseURL(baseURL),
		glamour.WithPreservedNewLines(),
	)
	if err != nil {
		return fmt.Errorf("unable to create renderer: %w", err)
	}

	content := string(b)
	ext := filepath.Ext(src.URL)
	if isCode {
		content = utils.WrapCodeBlock(string(b), ext)
	}

	out, err := r.Render(content)
	if err != nil {
		return fmt.Errorf("unable to render markdown: %w", err)
	}

	// display
	switch {
	case pager || cmd.Flags().Changed("pager"):
		pagerCmd := os.Getenv("PAGER")
		if pagerCmd == "" {
			pagerCmd = "less -r"
		}

		pa := strings.Split(pagerCmd, " ")
		c := exec.Command(pa[0], pa[1:]...) //nolint:gosec
		c.Stdin = strings.NewReader(out)
		c.Stdout = os.Stdout
		if err := c.Run(); err != nil {
			return fmt.Errorf("unable to run command: %w", err)
		}
		return nil
	case tui || cmd.Flags().Changed("tui"):
		path := ""
		if !isURL(src.URL) {
			path = src.URL
		}
		return runTUI(path, content)
	default:
		if _, err = fmt.Fprint(w, out); err != nil {
			return fmt.Errorf("unable to write to writer: %w", err)
		}
		return nil
	}
}

func runTUI(path string, content string) error {
	// Read environment to get debugging stuff
	cfg, err := env.ParseAs[ui.Config]()
	if err != nil {
		return fmt.Errorf("error parsing config: %v", err)
	}

	// use style set in env, or auto if unset
	if err := validateStyle(cfg.GlamourStyle); err != nil {
		cfg.GlamourStyle = style
	}

	cfg.Path = path
	cfg.ShowAllFiles = showAllFiles
	cfg.ShowLineNumbers = showLineNumbers
	cfg.GlamourMaxWidth = width
	cfg.EnableMouse = mouse
	cfg.PreserveNewLines = preserveNewLines

	// Run Bubble Tea program
	if _, err := ui.NewProgram(cfg, content).Run(); err != nil {
		return fmt.Errorf("unable to run tui program: %w", err)
	}

	return nil
}

func main() {
	closer, err := setupLog()
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	if err := rootCmd.Execute(); err != nil {
		_ = closer()
		os.Exit(1)
	}
	_ = closer()
}

func init() {
	tryLoadConfigFromDefaultPlaces()
	if len(CommitSHA) >= 7 {
		vt := rootCmd.VersionTemplate()
		rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n")
	}
	if Version == "" {
		Version = "unknown (built from source)"
	}
	rootCmd.Version = Version
	rootCmd.InitDefaultCompletionCmd()

	// "Glow Classic" cli arguments
	rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default %s)", viper.GetViper().ConfigFileUsed()))
	rootCmd.Flags().BoolVarP(&pager, "pager", "p", false, "display with pager")
	rootCmd.Flags().BoolVarP(&tui, "tui", "t", false, "display with tui")
	rootCmd.Flags().StringVarP(&style, "style", "s", styles.AutoStyle, "style name or JSON path")
	rootCmd.Flags().UintVarP(&width, "width", "w", 0, "word-wrap at width (set to 0 to disable)")
	rootCmd.Flags().BoolVarP(&showAllFiles, "all", "a", false, "show system files and directories (TUI-mode only)")
	rootCmd.Flags().BoolVarP(&showLineNumbers, "line-numbers", "l", false, "show line numbers (TUI-mode only)")
	rootCmd.Flags().BoolVarP(&preserveNewLines, "preserve-new-lines", "n", false, "preserve newlines in the output")
	rootCmd.Flags().BoolVarP(&mouse, "mouse", "m", false, "enable mouse wheel (TUI-mode only)")
	_ = rootCmd.Flags().MarkHidden("mouse")

	// Config bindings
	_ = viper.BindPFlag("pager", rootCmd.Flags().Lookup("pager"))
	_ = viper.BindPFlag("tui", rootCmd.Flags().Lookup("tui"))
	_ = viper.BindPFlag("style", rootCmd.Flags().Lookup("style"))
	_ = viper.BindPFlag("width", rootCmd.Flags().Lookup("width"))
	_ = viper.BindPFlag("debug", rootCmd.Flags().Lookup("debug"))
	_ = viper.BindPFlag("mouse", rootCmd.Flags().Lookup("mouse"))
	_ = viper.BindPFlag("preserveNewLines", rootCmd.Flags().Lookup("preserve-new-lines"))
	_ = viper.BindPFlag("showLineNumbers", rootCmd.Flags().Lookup("line-numbers"))
	_ = viper.BindPFlag("all", rootCmd.Flags().Lookup("all"))

	viper.SetDefault("style", styles.AutoStyle)
	viper.SetDefault("width", 0)
	viper.SetDefault("all", true)

	rootCmd.AddCommand(configCmd, manCmd)
}

func tryLoadConfigFromDefaultPlaces() {
	scope := gap.NewScope(gap.User, "glow")
	dirs, err := scope.ConfigDirs()
	if err != nil {
		fmt.Println("Could not load find configuration directory.")
		os.Exit(1)
	}

	if c := os.Getenv("XDG_CONFIG_HOME"); c != "" {
		dirs = append([]string{filepath.Join(c, "glow")}, dirs...)
	}

	if c := os.Getenv("GLOW_CONFIG_HOME"); c != "" {
		dirs = append([]string{c}, dirs...)
	}

	for _, v := range dirs {
		viper.AddConfigPath(v)
	}

	viper.SetConfigName("glow")
	viper.SetConfigType("yaml")
	viper.SetEnvPrefix("glow")
	viper.AutomaticEnv()

	if err := viper.ReadInConfig(); err != nil {
		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
			log.Warn("Could not parse configuration file", "err", err)
		}
	}

	if used := viper.ConfigFileUsed(); used != "" {
		log.Debug("Using configuration file", "path", viper.ConfigFileUsed())
		return
	}

	if viper.ConfigFileUsed() == "" {
		configFile = filepath.Join(dirs[0], "glow.yml")
	}
	if err := ensureConfigFile(); err != nil {
		log.Error("Could not create default configuration", "error", err)
	}
}


================================================
FILE: man_cmd.go
================================================
package main

import (
	"fmt"
	"os"

	mcobra "github.com/muesli/mango-cobra"
	"github.com/muesli/roff"
	"github.com/spf13/cobra"
)

var manCmd = &cobra.Command{
	Use:                   "man",
	Short:                 "Generates manpages",
	SilenceUsage:          true,
	DisableFlagsInUseLine: true,
	Hidden:                true,
	Args:                  cobra.NoArgs,
	RunE: func(*cobra.Command, []string) error {
		manPage, err := mcobra.NewManPage(1, rootCmd)
		if err != nil {
			return fmt.Errorf("unable to instantiate man page: %w", err)
		}
		if _, err := fmt.Fprint(os.Stdout, manPage.Build(roff.NewDocument())); err != nil {
			return fmt.Errorf("unable to build man page: %w", err)
		}
		return nil
	},
}


================================================
FILE: style.go
================================================
package main

import "github.com/charmbracelet/lipgloss"

var (
	keyword = lipgloss.NewStyle().
		Foreground(lipgloss.Color("#04B575")).
		Render

	paragraph = lipgloss.NewStyle().
			Width(78).
			Padding(0, 0, 0, 2).
			Render
)


================================================
FILE: ui/config.go
================================================
package ui

// Config contains TUI-specific configuration.
type Config struct {
	ShowAllFiles     bool
	ShowLineNumbers  bool
	Gopath           string `env:"GOPATH"`
	HomeDir          string `env:"HOME"`
	GlamourMaxWidth  uint
	GlamourStyle     string `env:"GLAMOUR_STYLE"`
	EnableMouse      bool
	PreserveNewLines bool

	// Working directory or file path
	Path string

	// For debugging the UI
	HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" envDefault:"true"`
	GlamourEnabled       bool `env:"GLOW_ENABLE_GLAMOUR"         envDefault:"true"`
}


================================================
FILE: ui/editor.go
================================================
package ui

import (
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/x/editor"
)

type editorFinishedMsg struct{ err error }

func openEditor(path string, lineno int) tea.Cmd {
	cb := func(err error) tea.Msg {
		return editorFinishedMsg{err}
	}
	cmd, err := editor.Cmd("Glow", path, editor.LineNumber(uint(lineno))) //nolint:gosec
	if err != nil {
		return func() tea.Msg { return cb(err) }
	}
	return tea.ExecProcess(cmd, cb)
}


================================================
FILE: ui/ignore_darwin.go
================================================
//go:build darwin
// +build darwin

package ui

import "path/filepath"

func ignorePatterns(m commonModel) []string {
	return []string{
		filepath.Join(m.cfg.HomeDir, "Library"),
		m.cfg.Gopath,
		"node_modules",
		".*",
	}
}


================================================
FILE: ui/ignore_general.go
================================================
//go:build !darwin
// +build !darwin

package ui

func ignorePatterns(m commonModel) []string {
	return []string{
		m.cfg.Gopath,
		"node_modules",
		".*",
	}
}


================================================
FILE: ui/keys.go
================================================
package ui

const (
	keyEnter = "enter"
	keyEsc   = "esc"
)


================================================
FILE: ui/markdown.go
================================================
package ui

import (
	"fmt"
	"math"
	"time"
	"unicode"

	"github.com/charmbracelet/log"
	"github.com/dustin/go-humanize"
	"golang.org/x/text/runes"
	"golang.org/x/text/transform"
	"golang.org/x/text/unicode/norm"
)

type markdown struct {
	// Full path of a local markdown file. Only relevant to local documents and
	// those that have been stashed in this session.
	localPath string

	// Value we filter against. This exists so that we can maintain positions
	// of filtered items if notes are edited while a filter is active. This
	// field is ephemeral, and should only be referenced during filtering.
	filterValue string

	Body    string
	Note    string
	Modtime time.Time
}

// Generate the value we're doing to filter against.
func (m *markdown) buildFilterValue() {
	note, err := normalize(m.Note)
	if err != nil {
		log.Error("error normalizing", "note", m.Note, "error", err)
		m.filterValue = m.Note
	}

	m.filterValue = note
}

func (m markdown) relativeTime() string {
	return relativeTime(m.Modtime)
}

// Normalize text to aid in the filtering process. In particular, we remove
// diacritics, "ö" becomes "o". Note that Mn is the unicode key for nonspacing
// marks.
func normalize(in string) (string, error) {
	t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
	out, _, err := transform.String(t, in)
	if err != nil {
		return "", fmt.Errorf("error normalizing: %w", err)
	}
	return out, nil
}

// Return the time in a human-readable format relative to the current time.
func relativeTime(then time.Time) string {
	now := time.Now()
	if ago := now.Sub(then); ago < time.Minute {
		return "just now"
	} else if ago < humanize.Week {
		return humanize.CustomRelTime(then, now, "ago", "from now", magnitudes)
	}
	return then.Format("02 Jan 2006 15:04 MST")
}

// Magnitudes for relative time.
var magnitudes = []humanize.RelTimeMagnitude{
	{D: time.Second, Format: "now", DivBy: time.Second},
	{D: 2 * time.Second, Format: "1 second %s", DivBy: 1},
	{D: time.Minute, Format: "%d seconds %s", DivBy: time.Second},
	{D: 2 * time.Minute, Format: "1 minute %s", DivBy: 1},
	{D: time.Hour, Format: "%d minutes %s", DivBy: time.Minute},
	{D: 2 * time.Hour, Format: "1 hour %s", DivBy: 1},
	{D: humanize.Day, Format: "%d hours %s", DivBy: time.Hour},
	{D: 2 * humanize.Day, Format: "1 day %s", DivBy: 1},
	{D: humanize.Week, Format: "%d days %s", DivBy: humanize.Day},
	{D: 2 * humanize.Week, Format: "1 week %s", DivBy: 1},
	{D: humanize.Month, Format: "%d weeks %s", DivBy: humanize.Week},
	{D: 2 * humanize.Month, Format: "1 month %s", DivBy: 1},
	{D: humanize.Year, Format: "%d months %s", DivBy: humanize.Month},
	{D: 18 * humanize.Month, Format: "1 year %s", DivBy: 1},
	{D: 2 * humanize.Year, Format: "2 years %s", DivBy: 1},
	{D: humanize.LongTime, Format: "%d years %s", DivBy: humanize.Year},
	{D: math.MaxInt64, Format: "a long while %s", DivBy: 1},
}


================================================
FILE: ui/pager.go
================================================
package ui

import (
	"fmt"
	"math"
	"path/filepath"
	"strings"
	"time"

	"github.com/atotto/clipboard"
	"github.com/charmbracelet/bubbles/viewport"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/glamour"
	"github.com/charmbracelet/glow/v2/utils"
	"github.com/charmbracelet/lipgloss"
	"github.com/charmbracelet/log"
	"github.com/fsnotify/fsnotify"
	runewidth "github.com/mattn/go-runewidth"
	"github.com/muesli/reflow/ansi"
	"github.com/muesli/reflow/truncate"
	"github.com/muesli/termenv"
)

const (
	statusBarHeight = 1
	lineNumberWidth = 4
)

var (
	pagerHelpHeight int

	mintGreen = lipgloss.AdaptiveColor{Light: "#89F0CB", Dark: "#89F0CB"}
	darkGreen = lipgloss.AdaptiveColor{Light: "#1C8760", Dark: "#1C8760"}

	lineNumberFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}

	statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
	statusBarBg     = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"}

	statusBarScrollPosStyle = lipgloss.NewStyle().
				Foreground(lipgloss.AdaptiveColor{Light: "#949494", Dark: "#5A5A5A"}).
				Background(statusBarBg).
				Render

	statusBarNoteStyle = lipgloss.NewStyle().
				Foreground(statusBarNoteFg).
				Background(statusBarBg).
				Render

	statusBarHelpStyle = lipgloss.NewStyle().
				Foreground(statusBarNoteFg).
				Background(lipgloss.AdaptiveColor{Light: "#DCDCDC", Dark: "#323232"}).
				Render

	statusBarMessageStyle = lipgloss.NewStyle().
				Foreground(mintGreen).
				Background(darkGreen).
				Render

	statusBarMessageScrollPosStyle = lipgloss.NewStyle().
					Foreground(mintGreen).
					Background(darkGreen).
					Render

	statusBarMessageHelpStyle = lipgloss.NewStyle().
					Foreground(lipgloss.Color("#B6FFE4")).
					Background(green).
					Render

	helpViewStyle = lipgloss.NewStyle().
			Foreground(statusBarNoteFg).
			Background(lipgloss.AdaptiveColor{Light: "#f2f2f2", Dark: "#1B1B1B"}).
			Render

	lineNumberStyle = lipgloss.NewStyle().
			Foreground(lineNumberFg).
			Render
)

type (
	contentRenderedMsg string
	reloadMsg          struct{}
)

type pagerState int

const (
	pagerStateBrowse pagerState = iota
	pagerStateStatusMessage
)

type pagerModel struct {
	common   *commonModel
	viewport viewport.Model
	state    pagerState
	showHelp bool

	statusMessage      string
	statusMessageTimer *time.Timer

	// Current document being rendered, sans-glamour rendering. We cache
	// it here so we can re-render it on resize.
	currentDocument markdown

	watcher *fsnotify.Watcher
}

func newPagerModel(common *commonModel) pagerModel {
	// Init viewport
	vp := viewport.New(0, 0)
	vp.YPosition = 0
	vp.HighPerformanceRendering = config.HighPerformancePager

	m := pagerModel{
		common:   common,
		state:    pagerStateBrowse,
		viewport: vp,
	}
	m.initWatcher()
	return m
}

func (m *pagerModel) setSize(w, h int) {
	m.viewport.Width = w
	m.viewport.Height = h - statusBarHeight

	if m.showHelp {
		if pagerHelpHeight == 0 {
			pagerHelpHeight = strings.Count(m.helpView(), "\n")
		}
		m.viewport.Height -= (statusBarHeight + pagerHelpHeight)
	}
}

func (m *pagerModel) setContent(s string) {
	m.viewport.SetContent(s)
}

func (m *pagerModel) toggleHelp() {
	m.showHelp = !m.showHelp
	m.setSize(m.common.width, m.common.height)
	if m.viewport.PastBottom() {
		m.viewport.GotoBottom()
	}
}

type pagerStatusMessage struct {
	message string
	isError bool
}

// Perform stuff that needs to happen after a successful markdown stash. Note
// that the returned command should be sent back the through the pager
// update function.
func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd {
	// Show a success message to the user
	m.state = pagerStateStatusMessage
	m.statusMessage = msg.message
	if m.statusMessageTimer != nil {
		m.statusMessageTimer.Stop()
	}
	m.statusMessageTimer = time.NewTimer(statusMessageTimeout)

	return waitForStatusMessageTimeout(pagerContext, m.statusMessageTimer)
}

func (m *pagerModel) unload() {
	log.Debug("unload")
	if m.showHelp {
		m.toggleHelp()
	}
	if m.statusMessageTimer != nil {
		m.statusMessageTimer.Stop()
	}
	m.state = pagerStateBrowse
	m.viewport.SetContent("")
	m.viewport.YOffset = 0
	m.unwatchFile()
}

func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
	var (
		cmd  tea.Cmd
		cmds []tea.Cmd
	)

	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.String() {
		case "q", keyEsc:
			if m.state != pagerStateBrowse {
				m.state = pagerStateBrowse
				return m, nil
			}
		case "home", "g":
			m.viewport.GotoTop()
			if m.viewport.HighPerformanceRendering {
				cmds = append(cmds, viewport.Sync(m.viewport))
			}
		case "end", "G":
			m.viewport.GotoBottom()
			if m.viewport.HighPerformanceRendering {
				cmds = append(cmds, viewport.Sync(m.viewport))
			}

		case "d":
			m.viewport.HalfViewDown()
			if m.viewport.HighPerformanceRendering {
				cmds = append(cmds, viewport.Sync(m.viewport))
			}

		case "u":
			m.viewport.HalfViewUp()
			if m.viewport.HighPerformanceRendering {
				cmds = append(cmds, viewport.Sync(m.viewport))
			}

		case "e":
			lineno := int(math.RoundToEven(float64(m.viewport.TotalLineCount()) * m.viewport.ScrollPercent()))
			if m.viewport.AtTop() {
				lineno = 0
			}
			log.Info(
				"opening editor",
				"file", m.currentDocument.localPath,
				"line", fmt.Sprintf("%d/%d", lineno, m.viewport.TotalLineCount()),
			)
			return m, openEditor(m.currentDocument.localPath, lineno)

		case "c":
			// Copy using OSC 52
			termenv.Copy(m.currentDocument.Body)
			// Copy using native system clipboard
			_ = clipboard.WriteAll(m.currentDocument.Body)
			cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Copied contents", false}))

		case "r":
			return m, loadLocalMarkdown(&m.currentDocument)

		case "?":
			m.toggleHelp()
			if m.viewport.HighPerformanceRendering {
				cmds = append(cmds, viewport.Sync(m.viewport))
			}
		}

	// Glow has rendered the content
	case contentRenderedMsg:
		log.Info("content rendered", "state", m.state)

		m.setContent(string(msg))
		if m.viewport.HighPerformanceRendering {
			cmds = append(cmds, viewport.Sync(m.viewport))
		}
		cmds = append(cmds, m.watchFile)

	// The file was changed on disk and we're reloading it
	case reloadMsg:
		return m, loadLocalMarkdown(&m.currentDocument)

	// We've finished editing the document, potentially making changes. Let's
	// retrieve the latest version of the document so that we display
	// up-to-date contents.
	case editorFinishedMsg:
		return m, loadLocalMarkdown(&m.currentDocument)

	// We've received terminal dimensions, either for the first time or
	// after a resize
	case tea.WindowSizeMsg:
		return m, renderWithGlamour(m, m.currentDocument.Body)

	case statusMessageTimeoutMsg:
		m.state = pagerStateBrowse
	}

	m.viewport, cmd = m.viewport.Update(msg)
	cmds = append(cmds, cmd)

	return m, tea.Batch(cmds...)
}

func (m pagerModel) View() string {
	var b strings.Builder
	fmt.Fprint(&b, m.viewport.View()+"\n")

	// Footer
	m.statusBarView(&b)

	if m.showHelp {
		fmt.Fprint(&b, "\n"+m.helpView())
	}

	return b.String()
}

func (m pagerModel) statusBarView(b *strings.Builder) {
	const (
		minPercent               float64 = 0.0
		maxPercent               float64 = 1.0
		percentToStringMagnitude float64 = 100.0
	)

	showStatusMessage := m.state == pagerStateStatusMessage

	// Logo
	logo := glowLogoView()

	// Scroll percent
	percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent()))
	scrollPercent := fmt.Sprintf(" %3.f%% ", percent*percentToStringMagnitude)
	if showStatusMessage {
		scrollPercent = statusBarMessageScrollPosStyle(scrollPercent)
	} else {
		scrollPercent = statusBarScrollPosStyle(scrollPercent)
	}

	// "Help" note
	var helpNote string
	if showStatusMessage {
		helpNote = statusBarMessageHelpStyle(" ? Help ")
	} else {
		helpNote = statusBarHelpStyle(" ? Help ")
	}

	// Note
	var note string
	if showStatusMessage {
		note = m.statusMessage
	} else {
		note = m.currentDocument.Note
	}
	note = truncate.StringWithTail(" "+note+" ", uint(max(0, //nolint:gosec
		m.common.width-
			ansi.PrintableRuneWidth(logo)-
			ansi.PrintableRuneWidth(scrollPercent)-
			ansi.PrintableRuneWidth(helpNote),
	)), ellipsis)
	if showStatusMessage {
		note = statusBarMessageStyle(note)
	} else {
		note = statusBarNoteStyle(note)
	}

	// Empty space
	padding := max(0,
		m.common.width-
			ansi.PrintableRuneWidth(logo)-
			ansi.PrintableRuneWidth(note)-
			ansi.PrintableRuneWidth(scrollPercent)-
			ansi.PrintableRuneWidth(helpNote),
	)
	emptySpace := strings.Repeat(" ", padding)
	if showStatusMessage {
		emptySpace = statusBarMessageStyle(emptySpace)
	} else {
		emptySpace = statusBarNoteStyle(emptySpace)
	}

	fmt.Fprintf(b, "%s%s%s%s%s",
		logo,
		note,
		emptySpace,
		scrollPercent,
		helpNote,
	)
}

func (m pagerModel) helpView() (s string) {
	col1 := []string{
		"g/home  go to top",
		"G/end   go to bottom",
		"c       copy contents",
		"e       edit this document",
		"r       reload this document",
		"esc     back to files",
		"q       quit",
	}

	s += "\n"
	s += "k/↑      up                  " + col1[0] + "\n"
	s += "j/↓      down                " + col1[1] + "\n"
	s += "b/pgup   page up             " + col1[2] + "\n"
	s += "f/pgdn   page down           " + col1[3] + "\n"
	s += "u        ½ page up           " + col1[4] + "\n"
	s += "d        ½ page down         "

	if len(col1) > 5 {
		s += col1[5]
	}

	s = indent(s, 2)

	// Fill up empty cells with spaces for background coloring
	if m.common.width > 0 {
		lines := strings.Split(s, "\n")
		for i := 0; i < len(lines); i++ {
			l := runewidth.StringWidth(lines[i])
			n := max(m.common.width-l, 0)
			lines[i] += strings.Repeat(" ", n)
		}

		s = strings.Join(lines, "\n")
	}

	return helpViewStyle(s)
}

// COMMANDS

func renderWithGlamour(m pagerModel, md string) tea.Cmd {
	return func() tea.Msg {
		s, err := glamourRender(m, md)
		if err != nil {
			log.Error("error rendering with Glamour", "error", err)
			return errMsg{err}
		}
		return contentRenderedMsg(s)
	}
}

// This is where the magic happens.
func glamourRender(m pagerModel, markdown string) (string, error) {
	trunc := lipgloss.NewStyle().MaxWidth(m.viewport.Width - lineNumberWidth).Render

	if !config.GlamourEnabled {
		return markdown, nil
	}

	isCode := !utils.IsMarkdownFile(m.currentDocument.Note)
	width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width)) //nolint:gosec
	if isCode {
		width = 0
	}

	options := []glamour.TermRendererOption{
		utils.GlamourStyle(m.common.cfg.GlamourStyle, isCode),
		glamour.WithWordWrap(width),
	}

	if m.common.cfg.PreserveNewLines {
		options = append(options, glamour.WithPreservedNewLines())
	}
	r, err := glamour.NewTermRenderer(options...)
	if err != nil {
		return "", fmt.Errorf("error creating glamour renderer: %w", err)
	}

	if isCode {
		markdown = utils.WrapCodeBlock(markdown, filepath.Ext(m.currentDocument.Note))
	}

	out, err := r.Render(markdown)
	if err != nil {
		return "", fmt.Errorf("error rendering markdown: %w", err)
	}

	if isCode {
		out = strings.TrimSpace(out)
	}

	// trim lines
	lines := strings.Split(out, "\n")

	var content strings.Builder
	for i, s := range lines {
		if isCode || m.common.cfg.ShowLineNumbers {
			content.WriteString(lineNumberStyle(fmt.Sprintf("%"+fmt.Sprint(lineNumberWidth)+"d", i+1)))
			content.WriteString(trunc(s))
		} else {
			content.WriteString(s)
		}

		// don't add an artificial newline after the last split
		if i+1 < len(lines) {
			content.WriteRune('\n')
		}
	}

	return content.String(), nil
}

func (m *pagerModel) initWatcher() {
	var err error
	m.watcher, err = fsnotify.NewWatcher()
	if err != nil {
		log.Error("error creating fsnotify watcher", "error", err)
	}
}

func (m *pagerModel) watchFile() tea.Msg {
	dir := m.localDir()

	if err := m.watcher.Add(dir); err != nil {
		log.Error("error adding dir to fsnotify watcher", "error", err)
		return nil
	}

	log.Info("fsnotify watching dir", "dir", dir)

	for {
		select {
		case event, ok := <-m.watcher.Events:
			if !ok || event.Name != m.currentDocument.localPath {
				continue
			}

			if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) {
				continue
			}

			log.Debug("fsnotify event", "file", event.Name, "event", event.Op)
			return reloadMsg{}
		case err, ok := <-m.watcher.Errors:
			if !ok {
				continue
			}
			log.Debug("fsnotify error", "dir", dir, "error", err)
		}
	}
}

func (m *pagerModel) unwatchFile() {
	dir := m.localDir()

	err := m.watcher.Remove(dir)
	if err == nil {
		log.Debug("fsnotify dir unwatched", "dir", dir)
	} else {
		log.Error("fsnotify fail to unwatch dir", "dir", dir, "error", err)
	}
}

func (m *pagerModel) localDir() string {
	return filepath.Dir(m.currentDocument.localPath)
}


================================================
FILE: ui/sort.go
================================================
package ui

import (
	"cmp"
	"slices"
)

func sortMarkdowns(mds []*markdown) {
	slices.SortStableFunc(mds, func(a, b *markdown) int {
		return cmp.Compare(a.Note, b.Note)
	})
}


================================================
FILE: ui/stash.go
================================================
package ui

import (
	"errors"
	"fmt"
	"os"
	"sort"
	"strings"
	"time"

	"github.com/charmbracelet/bubbles/paginator"
	"github.com/charmbracelet/bubbles/spinner"
	"github.com/charmbracelet/bubbles/textinput"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
	"github.com/charmbracelet/log"
	"github.com/muesli/reflow/ansi"
	"github.com/muesli/reflow/truncate"
	"github.com/sahilm/fuzzy"
)

const (
	stashIndent                = 1
	stashViewItemHeight        = 3 // height of stash entry, including gap
	stashViewTopPadding        = 5 // logo, status bar, gaps
	stashViewBottomPadding     = 3 // pagination and gaps, but not help
	stashViewHorizontalPadding = 6
)

var stashingStatusMessage = statusMessage{normalStatusMessage, "Stashing..."}

var (
	dividerDot = darkGrayFg.SetString(" • ")
	dividerBar = darkGrayFg.SetString(" │ ")

	logoStyle = lipgloss.NewStyle().
			Foreground(lipgloss.Color("#ECFD65")).
			Background(fuchsia).
			Bold(true)

	stashSpinnerStyle = lipgloss.NewStyle().
				Foreground(gray)
	stashInputPromptStyle = lipgloss.NewStyle().
				Foreground(yellowGreen).
				MarginRight(1)
	stashInputCursorStyle = lipgloss.NewStyle().
				Foreground(fuchsia).
				MarginRight(1)
)

// MSG

type (
	filteredMarkdownMsg []*markdown
	fetchedMarkdownMsg  *markdown
)

// MODEL

// stashViewState is the high-level state of the file listing.
type stashViewState int

const (
	stashStateReady stashViewState = iota
	stashStateLoadingDocument
	stashStateShowingError
)

// The types of documents we are currently showing to the user.
type sectionKey int

const (
	documentsSection = iota
	filterSection
)

// section contains definitions and state information for displaying a tab and
// its contents in the file listing view.
type section struct {
	key       sectionKey
	paginator paginator.Model
	cursor    int
}

// map sections to their associated types.
var sections = map[sectionKey]section{}

// filterState is the current filtering state in the file listing.
type filterState int

const (
	unfiltered    filterState = iota // no filter set
	filtering                        // user is actively setting a filter
	filterApplied                    // a filter is applied and user is not editing filter
)

// statusMessageType adds some context to the status message being sent.
type statusMessageType int

// Types of status messages.
const (
	normalStatusMessage statusMessageType = iota
	subtleStatusMessage
	errorStatusMessage
)

// statusMessage is an ephemeral note displayed in the UI.
type statusMessage struct {
	status  statusMessageType
	message string
}

func initSections() {
	sections = map[sectionKey]section{
		documentsSection: {
			key:       documentsSection,
			paginator: newStashPaginator(),
		},
		filterSection: {
			key:       filterSection,
			paginator: newStashPaginator(),
		},
	}
}

// String returns a styled version of the status message appropriate for the
// given context.
func (s statusMessage) String() string {
	switch s.status { //nolint:exhaustive
	case subtleStatusMessage:
		return dimGreenFg(s.message)
	case errorStatusMessage:
		return redFg(s.message)
	default:
		return greenFg(s.message)
	}
}

type stashModel struct {
	common             *commonModel
	err                error
	spinner            spinner.Model
	filterInput        textinput.Model
	viewState          stashViewState
	filterState        filterState
	showFullHelp       bool
	showStatusMessage  bool
	statusMessage      statusMessage
	statusMessageTimer *time.Timer

	// Available document sections we can cycle through. We use a slice, rather
	// than a map, because order is important.
	sections []section

	// Index of the section we're currently looking at
	sectionIndex int

	// Tracks if docs were loaded
	loaded bool

	// The master set of markdown documents we're working with.
	markdowns []*markdown

	// Markdown documents we're currently displaying. Filtering, toggles and so
	// on will alter this slice so we can show what is relevant. For that
	// reason, this field should be considered ephemeral.
	filteredMarkdowns []*markdown

	// Page we're fetching stash items from on the server, which is different
	// from the local pagination. Generally, the server will return more items
	// than we can display at a time so we can paginate locally without having
	// to fetch every time.
	serverPage int64
}

func (m stashModel) loadingDone() bool {
	return m.loaded
}

func (m stashModel) currentSection() *section {
	return &m.sections[m.sectionIndex]
}

func (m stashModel) paginator() *paginator.Model {
	return &m.currentSection().paginator
}

func (m *stashModel) setPaginator(p paginator.Model) {
	m.currentSection().paginator = p
}

func (m stashModel) cursor() int {
	return m.currentSection().cursor
}

func (m *stashModel) setCursor(i int) {
	m.currentSection().cursor = i
}

// Whether or not the spinner should be spinning.
func (m stashModel) shouldSpin() bool {
	loading := !m.loadingDone()
	openingDocument := m.viewState == stashStateLoadingDocument
	return loading || openingDocument
}

func (m *stashModel) setSize(width, height int) {
	m.common.width = width
	m.common.height = height

	m.filterInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth(
		m.filterInput.Prompt,
	)

	m.updatePagination()
}

func (m *stashModel) resetFiltering() {
	m.filterState = unfiltered
	m.filterInput.Reset()
	m.filteredMarkdowns = nil

	sortMarkdowns(m.markdowns)

	// If the filtered section is present (it's always at the end) slice it out
	// of the sections slice to remove it from the UI.
	if m.sections[len(m.sections)-1].key == filterSection {
		m.sections = m.sections[:len(m.sections)-1]
	}

	// If the current section is out of bounds (it would be if we cut down the
	// slice above) then return to the first section.
	if m.sectionIndex > len(m.sections)-1 {
		m.sectionIndex = 0
	}

	// Update pagination after we've switched sections.
	m.updatePagination()
}

// Is a filter currently being applied?
func (m stashModel) filterApplied() bool {
	return m.filterState != unfiltered
}

// Should we be updating the filter?
func (m stashModel) shouldUpdateFilter() bool {
	// If we're in the middle of setting a note don't update the filter so that
	// the focus won't jump around.
	return m.filterApplied()
}

// Update pagination according to the amount of markdowns for the current
// state.
func (m *stashModel) updatePagination() {
	_, helpHeight := m.helpView()

	availableHeight := m.common.height -
		stashViewTopPadding -
		helpHeight -
		stashViewBottomPadding

	m.paginator().PerPage = max(1, availableHeight/stashViewItemHeight)

	if pages := len(m.getVisibleMarkdowns()); pages < 1 {
		m.paginator().SetTotalPages(1)
	} else {
		m.paginator().SetTotalPages(pages)
	}

	// Make sure the page stays in bounds
	if m.paginator().Page >= m.paginator().TotalPages-1 {
		m.paginator().Page = max(0, m.paginator().TotalPages-1)
	}
}

// MarkdownIndex returns the index of the currently selected markdown item.
func (m stashModel) markdownIndex() int {
	return m.paginator().Page*m.paginator().PerPage + m.cursor()
}

// Return the current selected markdown in the stash.
func (m stashModel) selectedMarkdown() *markdown {
	i := m.markdownIndex()

	mds := m.getVisibleMarkdowns()
	if i < 0 || len(mds) == 0 || len(mds) <= i {
		return nil
	}

	return mds[i]
}

// Adds markdown documents to the model.
func (m *stashModel) addMarkdowns(mds ...*markdown) {
	if len(mds) == 0 {
		return
	}

	m.markdowns = append(m.markdowns, mds...)
	if !m.filterApplied() {
		sortMarkdowns(m.markdowns)
	}

	m.updatePagination()
}

// Returns the markdowns that should be currently shown.
func (m stashModel) getVisibleMarkdowns() []*markdown {
	if m.filterState == filtering || m.currentSection().key == filterSection {
		return m.filteredMarkdowns
	}

	return m.markdowns
}

// Command for opening a markdown document in the pager. Note that this also
// alters the model.
func (m *stashModel) openMarkdown(md *markdown) tea.Cmd {
	m.viewState = stashStateLoadingDocument
	cmd := loadLocalMarkdown(md)
	return tea.Batch(cmd, m.spinner.Tick)
}

func (m *stashModel) hideStatusMessage() {
	m.showStatusMessage = false
	m.statusMessage = statusMessage{}
	if m.statusMessageTimer != nil {
		m.statusMessageTimer.Stop()
	}
}

func (m *stashModel) moveCursorUp() {
	m.setCursor(m.cursor() - 1)
	if m.cursor() < 0 && m.paginator().Page == 0 {
		// Stop
		m.setCursor(0)
		return
	}

	if m.cursor() >= 0 {
		return
	}
	// Go to previous page
	m.paginator().PrevPage()

	m.setCursor(m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns())) - 1)
}

func (m *stashModel) moveCursorDown() {
	itemsOnPage := m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns()))

	m.setCursor(m.cursor() + 1)
	if m.cursor() < itemsOnPage {
		return
	}

	if !m.paginator().OnLastPage() {
		m.paginator().NextPage()
		m.setCursor(0)
		return
	}

	// During filtering the cursor position can exceed the number of
	// itemsOnPage. It's more intuitive to start the cursor at the
	// topmost position when moving it down in this scenario.
	if m.cursor() > itemsOnPage {
		m.setCursor(0)
		return
	}
	m.setCursor(itemsOnPage - 1)
}

// INIT

func newStashModel(common *commonModel) stashModel {
	sp := spinner.New()
	sp.Spinner = spinner.Line
	sp.Style = stashSpinnerStyle

	si := textinput.New()
	si.Prompt = "Find:"
	si.PromptStyle = stashInputPromptStyle
	si.Cursor.Style = stashInputCursorStyle
	si.Focus()

	s := []section{
		sections[documentsSection],
	}

	m := stashModel{
		common:      common,
		spinner:     sp,
		filterInput: si,
		serverPage:  1,
		sections:    s,
	}

	return m
}

func newStashPaginator() paginator.Model {
	p := paginator.New()
	p.Type = paginator.Dots
	p.ActiveDot = brightGrayFg("•")
	p.InactiveDot = darkGrayFg.Render("•")
	return p
}

// UPDATE

func (m stashModel) update(msg tea.Msg) (stashModel, tea.Cmd) {
	var cmds []tea.Cmd

	switch msg := msg.(type) {
	case errMsg:
		m.err = msg

	case localFileSearchFinished:
		// We're finished searching for local files
		m.loaded = true

	case filteredMarkdownMsg:
		m.filteredMarkdowns = msg
		m.setCursor(0)
		return m, nil

	case spinner.TickMsg:
		if m.shouldSpin() {
			var cmd tea.Cmd
			m.spinner, cmd = m.spinner.Update(msg)
			cmds = append(cmds, cmd)
		}

	case statusMessageTimeoutMsg:
		if applicationContext(msg) == stashContext {
			m.hideStatusMessage()
		}
	}

	if m.filterState == filtering {
		cmds = append(cmds, m.handleFiltering(msg))
		return m, tea.Batch(cmds...)
	}

	// Updates per the current state
	switch m.viewState { //nolint:exhaustive
	case stashStateReady:
		cmds = append(cmds, m.handleDocumentBrowsing(msg))
	case stashStateShowingError:
		// Any key exists the error view
		if _, ok := msg.(tea.KeyMsg); ok {
			m.viewState = stashStateReady
		}
	}

	return m, tea.Batch(cmds...)
}

// Updates for when a user is browsing the markdown listing.
func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd {
	var cmds []tea.Cmd

	numDocs := len(m.getVisibleMarkdowns())

	switch msg := msg.(type) {
	// Handle keys
	case tea.KeyMsg:
		switch msg.String() {
		case "k", "ctrl+k", "up":
			m.moveCursorUp()

		case "j", "ctrl+j", "down":
			m.moveCursorDown()

		// Go to the very start
		case "home", "g":
			m.paginator().Page = 0
			m.setCursor(0)

		// Go to the very end
		case "end", "G":
			m.paginator().Page = m.paginator().TotalPages - 1
			m.setCursor(m.paginator().ItemsOnPage(numDocs) - 1)

		// Clear filter (if applicable)
		case keyEsc:
			if m.filterApplied() {
				m.resetFiltering()
			}

		// Next section
		case "tab", "L":
			if len(m.sections) == 0 || m.filterState == filtering {
				break
			}
			m.sectionIndex++
			if m.sectionIndex >= len(m.sections) {
				m.sectionIndex = 0
			}
			m.updatePagination()

		// Previous section
		case "shift+tab", "H":
			if len(m.sections) == 0 || m.filterState == filtering {
				break
			}
			m.sectionIndex--
			if m.sectionIndex < 0 {
				m.sectionIndex = len(m.sections) - 1
			}
			m.updatePagination()

		case "F":
			m.loaded = false
			return findLocalFiles(*m.common)

		// Edit document in EDITOR
		case "e":
			md := m.selectedMarkdown()

			// In case no file is available
			if md == nil {
				return nil
			}

			return openEditor(md.localPath, 0)

		// Open document
		case keyEnter:
			m.hideStatusMessage()

			if numDocs == 0 {
				break
			}

			// Load the document from the server. We'll handle the message
			// that comes back in the main update function.
			md := m.selectedMarkdown()
			cmds = append(cmds, m.openMarkdown(md))

		// Filter your notes
		case "/":
			m.hideStatusMessage()

			// Build values we'll filter against
			for _, md := range m.markdowns {
				md.buildFilterValue()
			}

			m.filteredMarkdowns = m.markdowns

			m.paginator().Page = 0
			m.setCursor(0)
			m.filterState = filtering
			m.filterInput.CursorEnd()
			m.filterInput.Focus()
			return textinput.Blink

		// Toggle full help
		case "?":
			m.showFullHelp = !m.showFullHelp
			m.updatePagination()

		// Show errors
		case "!":
			if m.err != nil && m.viewState == stashStateReady {
				m.viewState = stashStateShowingError
				return nil
			}
		}
	}

	// Update paginator. Pagination key handling is done here, but it could
	// also be moved up to this level, in which case we'd use model methods
	// like model.PageUp().
	newPaginatorModel, cmd := m.paginator().Update(msg)
	m.setPaginator(newPaginatorModel)
	cmds = append(cmds, cmd)

	// Extra paginator keystrokes
	if key, ok := msg.(tea.KeyMsg); ok {
		switch key.String() {
		case "b", "u":
			m.paginator().PrevPage()
		case "f", "d":
			m.paginator().NextPage()
		}
	}

	// Keep the index in bounds when paginating
	itemsOnPage := m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns()))
	if m.cursor() > itemsOnPage-1 {
		m.setCursor(max(0, itemsOnPage-1))
	}

	return tea.Batch(cmds...)
}

// Updates for when a user is in the filter editing interface.
func (m *stashModel) handleFiltering(msg tea.Msg) tea.Cmd {
	var cmds []tea.Cmd

	// Handle keys
	if msg, ok := msg.(tea.KeyMsg); ok { //nolint:nestif
		switch msg.String() {
		case keyEsc:
			// Cancel filtering
			m.resetFiltering()
		case keyEnter, "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down":
			m.hideStatusMessage()

			if len(m.markdowns) == 0 {
				break
			}

			h := m.getVisibleMarkdowns()

			// If we've filtered down to nothing, clear the filter
			if len(h) == 0 {
				m.viewState = stashStateReady
				m.resetFiltering()
				break
			}

			// When there's only one filtered markdown left we can just
			// "open" it directly
			if len(h) == 1 {
				m.viewState = stashStateReady
				m.resetFiltering()
				cmds = append(cmds, m.openMarkdown(h[0]))
				break
			}

			// Add new section if it's not present
			if m.sections[len(m.sections)-1].key != filterSection {
				m.sections = append(m.sections, sections[filterSection])
			}
			m.sectionIndex = len(m.sections) - 1

			m.filterInput.Blur()

			m.filterState = filterApplied
			if m.filterInput.Value() == "" {
				m.resetFiltering()
			}
		}
	}

	// Update the filter text input component
	newFilterInputModel, inputCmd := m.filterInput.Update(msg)
	currentFilterVal := m.filterInput.Value()
	newFilterVal := newFilterInputModel.Value()
	m.filterInput = newFilterInputModel
	cmds = append(cmds, inputCmd)

	// If the filtering input has changed, request updated filtering
	if newFilterVal != currentFilterVal {
		cmds = append(cmds, filterMarkdowns(*m))
	}

	// Update pagination
	m.updatePagination()

	return tea.Batch(cmds...)
}

// VIEW

func (m stashModel) view() string {
	var s string
	switch m.viewState {
	case stashStateShowingError:
		return errorView(m.err, false)
	case stashStateLoadingDocument:
		s += " " + m.spinner.View() + " Loading document..."
	case stashStateReady:
		loadingIndicator := " "
		if m.shouldSpin() {
			loadingIndicator = m.spinner.View()
		}

		// Only draw the normal header if we're not using the header area for
		// something else (like a note or delete prompt).
		header := m.headerView()

		// Rules for the logo, filter and status message.
		logoOrFilter := " "
		if m.showStatusMessage && m.filterState == filtering {
			logoOrFilter += m.statusMessage.String()
		} else if m.filterState == filtering {
			logoOrFilter += m.filterInput.View()
		} else {
			logoOrFilter += glowLogoView()
			if m.showStatusMessage {
				logoOrFilter += "  " + m.statusMessage.String()
			}
		}
		logoOrFilter = truncate.StringWithTail(logoOrFilter, uint(m.common.width-1), ellipsis) //nolint:gosec

		help, helpHeight := m.helpView()

		populatedView := m.populatedView()
		populatedViewHeight := strings.Count(populatedView, "\n") + 2

		// We need to fill any empty height with newlines so the footer reaches
		// the bottom.
		availHeight := m.common.height -
			stashViewTopPadding -
			populatedViewHeight -
			helpHeight -
			stashViewBottomPadding
		blankLines := strings.Repeat("\n", max(0, availHeight))

		var pagination string
		if m.paginator().TotalPages > 1 {
			pagination = m.paginator().View()

			// If the dot pagination is wider than the width of the window
			// use the arabic paginator.
			if ansi.PrintableRuneWidth(pagination) > m.common.width-stashViewHorizontalPadding {
				// Copy the paginator since m.paginator() returns a pointer to
				// the active paginator and we don't want to mutate it. In
				// normal cases, where the paginator is not a pointer, we could
				// safely change the model parameters for rendering here as the
				// current model is discarded after reuturning from a View().
				// One could argue, in fact, that using pointers in
				// a functional framework is an antipattern and our use of
				// pointers in our model should be refactored away.
				p := *(m.paginator())
				p.Type = paginator.Arabic
				pagination = paginationStyle.Render(p.View())
			}
		}

		s += fmt.Sprintf(
			"%s%s\n\n  %s\n\n%s\n\n%s  %s\n\n%s",
			loadingIndicator,
			logoOrFilter,
			header,
			populatedView,
			blankLines,
			pagination,
			help,
		)
	}
	return "\n" + indent(s, stashIndent)
}

func glowLogoView() string {
	return logoStyle.Render(" Glow ")
}

func (m stashModel) headerView() string {
	localCount := len(m.markdowns)

	var sections []string //nolint:prealloc

	// Filter results
	if m.filterState == filtering {
		if localCount == 0 {
			return grayFg("Nothing found.")
		}
		if localCount > 0 {
			sections = append(sections, fmt.Sprintf("%d local", localCount))
		}

		for i := range sections {
			sections[i] = grayFg(sections[i])
		}

		return strings.Join(sections, dividerDot.String())
	}

	// Tabs
	for i, v := range m.sections {
		var s string

		switch v.key {
		case documentsSection:
			s = fmt.Sprintf("%d documents", localCount)

		case filterSection:
			s = fmt.Sprintf("%d “%s”", len(m.filteredMarkdowns), m.filterInput.Value())
		}

		if m.sectionIndex == i && len(m.sections) > 1 {
			s = selectedTabStyle.Render(s)
		} else {
			s = tabStyle.Render(s)
		}
		sections = append(sections, s)
	}

	return strings.Join(sections, dividerBar.String())
}

func (m stashModel) populatedView() string {
	mds := m.getVisibleMarkdowns()

	var b strings.Builder

	// Empty states
	if len(mds) == 0 {
		f := func(s string) {
			b.WriteString("  " + grayFg(s))
		}

		switch m.sections[m.sectionIndex].key {
		case documentsSection:
			if m.loadingDone() {
				f("No files found.")
			} else {
				f("Looking for local files...")
			}
		case filterSection:
			return ""
		}
	}

	if len(mds) > 0 {
		start, end := m.paginator().GetSliceBounds(len(mds))
		docs := mds[start:end]

		for i, md := range docs {
			stashItemView(&b, m, i, md)
			if i != len(docs)-1 {
				fmt.Fprintf(&b, "\n\n")
			}
		}
	}

	// If there aren't enough items to fill up this page (always the last page)
	// then we need to add some newlines to fill up the space where stash items
	// would have been.
	itemsOnPage := m.paginator().ItemsOnPage(len(mds))
	if itemsOnPage < m.paginator().PerPage {
		n := (m.paginator().PerPage - itemsOnPage) * stashViewItemHeight
		if len(mds) == 0 {
			n -= stashViewItemHeight - 1
		}
		for i := 0; i < n; i++ {
			fmt.Fprint(&b, "\n")
		}
	}

	return b.String()
}

// COMMANDS

func loadLocalMarkdown(md *markdown) tea.Cmd {
	return func() tea.Msg {
		if md.localPath == "" {
			return errMsg{errors.New("could not load file: missing path")}
		}

		data, err := os.ReadFile(md.localPath)
		if err != nil {
			log.Debug("error reading local file", "error", err)
			return errMsg{err}
		}
		md.Body = string(data)
		return fetchedMarkdownMsg(md)
	}
}

func filterMarkdowns(m stashModel) tea.Cmd {
	return func() tea.Msg {
		if m.filterInput.Value() == "" || !m.filterApplied() {
			return filteredMarkdownMsg(m.markdowns) // return everything
		}

		targets := []string{}
		mds := m.markdowns

		for _, t := range mds {
			targets = append(targets, t.filterValue)
		}

		ranks := fuzzy.Find(m.filterInput.Value(), targets)
		sort.Stable(ranks)

		filtered := []*markdown{}
		for _, r := range ranks {
			filtered = append(filtered, mds[r.Index])
		}

		return filteredMarkdownMsg(filtered)
	}
}


================================================
FILE: ui/stashhelp.go
================================================
package ui

import (
	"fmt"
	"strings"

	"github.com/muesli/reflow/ansi"
)

// helpEntry is a entry in a help menu containing values for a keystroke and
// it's associated action.
type helpEntry struct{ key, val string }

// helpColumn is a group of helpEntries which will be rendered into a column.
type helpColumn []helpEntry

// newHelpColumn creates a help column from pairs of string arguments
// representing keys and values. If the arguments are not even (and therein
// not every key has a matching value) the function will panic.
func newHelpColumn(pairs ...string) (h helpColumn) {
	if len(pairs)%2 != 0 {
		panic("help text group must have an even number of items")
	}

	for i := 0; i < len(pairs); i = i + 2 {
		h = append(h, helpEntry{key: pairs[i], val: pairs[i+1]})
	}

	return
}

// render returns styled and formatted rows from keys and values.
func (h helpColumn) render(height int) (rows []string) {
	keyWidth, valWidth := h.maxWidths()

	for i := 0; i < height; i++ {
		var (
			b    = strings.Builder{}
			k, v string
		)
		if i < len(h) {
			k = h[i].key
			v = h[i].val

			switch k {
			case "s":
				k = greenFg(k)
				v = semiDimGreenFg(v)
			default:
				k = grayFg(k)
				v = midGrayFg(v)
			}
		}
		b.WriteString(k)
		b.WriteString(strings.Repeat(" ", keyWidth-ansi.PrintableRuneWidth(k))) // pad keys
		b.WriteString("  ")                                                     // gap
		b.WriteString(v)
		b.WriteString(strings.Repeat(" ", valWidth-ansi.PrintableRuneWidth(v))) // pad vals
		rows = append(rows, b.String())
	}

	return
}

// maxWidths returns the widest key and values in the column, respectively.
func (h helpColumn) maxWidths() (maxKey int, maxVal int) {
	for _, v := range h {
		kw := ansi.PrintableRuneWidth(v.key)
		vw := ansi.PrintableRuneWidth(v.val)
		if kw > maxKey {
			maxKey = kw
		}
		if vw > maxVal {
			maxVal = vw
		}
	}

	return
}

// helpView returns either the mini or full help view depending on the state of
// the model, as well as the total height of the help view.
func (m stashModel) helpView() (string, int) {
	numDocs := len(m.getVisibleMarkdowns())

	// Help for when we're filtering
	if m.filterState == filtering {
		var h []string

		switch numDocs {
		case 0:
			h = []string{"enter/esc", "cancel"}
		case 1:
			h = []string{"enter", "open", "esc", "cancel"}
		default:
			h = []string{"enter", "confirm", "esc", "cancel", "ctrl+j/ctrl+k ↑/↓", "choose"}
		}

		return m.renderHelp(h)
	}

	var (
		navHelp       []string
		filterHelp    []string
		selectionHelp []string
		editHelp      []string
		sectionHelp   []string
		appHelp       []string
	)

	if numDocs > 0 && m.showFullHelp {
		navHelp = []string{"enter", "open", "j/k ↑/↓", "choose"}
	}

	if len(m.sections) > 1 {
		if m.showFullHelp {
			navHelp = append(navHelp, "tab/shift+tab", "section")
		} else {
			navHelp = append(navHelp, "tab", "section")
		}
	}

	if m.paginator().TotalPages > 1 {
		navHelp = append(navHelp, "h/l ←/→", "page")
	}

	// If we're browsing a filtered set
	if m.filterApplied() {
		filterHelp = []string{"/", "edit search", "esc", "clear filter"}
	} else {
		filterHelp = []string{"/", "find"}
	}

	// If there are errors
	if m.err != nil {
		appHelp = append(appHelp, "!", "errors")
	}

	appHelp = append(appHelp, "r", "refresh")

	if numDocs > 0 {
		appHelp = append(appHelp, "e", "edit")
	}

	appHelp = append(appHelp, "q", "quit")

	// Detailed help
	if m.showFullHelp {
		if m.filterState != filtering {
			appHelp = append(appHelp, "?", "close help")
		}
		return m.renderHelp(navHelp, filterHelp, append(selectionHelp, editHelp...), sectionHelp, appHelp)
	}

	// Mini help
	if m.filterState != filtering {
		appHelp = append(appHelp, "?", "more")
	}
	return m.renderHelp(navHelp, filterHelp, selectionHelp, editHelp, sectionHelp, appHelp)
}

const minHelpViewHeight = 5

// renderHelp returns the rendered help view and associated line height for
// the given groups of help items.
func (m stashModel) renderHelp(groups ...[]string) (string, int) {
	if m.showFullHelp {
		str := m.fullHelpView(groups...)
		numLines := strings.Count(str, "\n") + 1
		return str, max(numLines, minHelpViewHeight)
	}
	return m.miniHelpView(concatStringSlices(groups...)...), 1
}

// Builds the help view from various sections pieces, truncating it if the view
// would otherwise wrap to two lines. Help view entries should come in as pairs,
// with the first being the key and the second being the help text.
func (m stashModel) miniHelpView(entries ...string) string {
	if len(entries) == 0 {
		return ""
	}

	var (
		truncationChar  = subtleStyle.Render("…")
		truncationWidth = ansi.PrintableRuneWidth(truncationChar)
	)

	var (
		next       string
		leftGutter = "  "
		maxWidth   = m.common.width -
			stashViewHorizontalPadding -
			truncationWidth -
			ansi.PrintableRuneWidth(leftGutter)
		s = leftGutter
	)

	for i := 0; i < len(entries); i = i + 2 {
		k := entries[i]
		v := entries[i+1]

		k = grayFg(k)
		v = midGrayFg(v)

		next = fmt.Sprintf("%s %s", k, v)

		if i < len(entries)-2 {
			next += dividerDot.String()
		}

		// Only this (and the following) help text items if we have the
		// horizontal space
		if ansi.PrintableRuneWidth(s)+ansi.PrintableRuneWidth(next) >= maxWidth {
			s += truncationChar
			break
		}

		s += next
	}
	return s
}

func (m stashModel) fullHelpView(groups ...[]string) string {
	var tallestCol int
	columns := make([]helpColumn, 0, len(groups))
	renderedCols := make([][]string, 0, len(groups)) // final rows grouped by column

	// Get key/value pairs
	for _, g := range groups {
		if len(g) == 0 {
			continue // ignore empty columns
		}

		columns = append(columns, newHelpColumn(g...))
	}

	// Find the tallest column
	for _, c := range columns {
		if len(c) > tallestCol {
			tallestCol = len(c)
		}
	}

	// Build columns
	for _, c := range columns {
		renderedCols = append(renderedCols, c.render(tallestCol))
	}

	// Merge columns
	return mergeColumns(renderedCols...)
}

// Merge columns together to build the help view.
func mergeColumns(cols ...[]string) string {
	const minimumHeight = 3

	// Find the tallest column
	var tallestCol int
	for _, v := range cols {
		n := len(v)
		if n > tallestCol {
			tallestCol = n
		}
	}

	// Make sure the tallest column meets the minimum height
	if tallestCol < minimumHeight {
		tallestCol = minimumHeight
	}

	b := strings.Builder{}
	for i := 0; i < tallestCol; i++ {
		for j, col := range cols {
			if i >= len(col) {
				continue // skip if we're past the length of this column
			}
			if j == 0 {
				b.WriteString("  ") // gutter
			} else if j > 0 {
				b.WriteString("    ") // gap
			}
			b.WriteString(col[i])
		}
		if i < tallestCol-1 {
			b.WriteRune('\n')
		}
	}

	return b.String()
}

func concatStringSlices(s ...[]string) (agg []string) {
	for _, v := range s {
		agg = append(agg, v...)
	}
	return
}


================================================
FILE: ui/stashitem.go
================================================
package ui

import (
	"fmt"
	"strings"

	"github.com/charmbracelet/lipgloss"
	"github.com/charmbracelet/log"
	"github.com/muesli/reflow/truncate"
	"github.com/sahilm/fuzzy"
)

const (
	verticalLine         = "│"
	fileListingStashIcon = "• "
)

func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) {
	var (
		truncateTo  = uint(m.common.width - stashViewHorizontalPadding*2) //nolint:gosec
		gutter      string
		title       = truncate.StringWithTail(md.Note, truncateTo, ellipsis)
		date        = md.relativeTime()
		editedBy    = ""
		hasEditedBy = false
		icon        = ""
		separator   = ""
	)

	isSelected := index == m.cursor()
	isFiltering := m.filterState == filtering
	singleFilteredItem := isFiltering && len(m.getVisibleMarkdowns()) == 1

	// If there are multiple items being filtered don't highlight a selected
	// item in the results. If we've filtered down to one item, however,
	// highlight that first item since pressing return will open it.
	if isSelected && !isFiltering || singleFilteredItem { //nolint:nestif
		// Selected item
		if m.statusMessage == stashingStatusMessage {
			gutter = greenFg(verticalLine)
			icon = dimGreenFg(icon)
			title = greenFg(title)
			date = semiDimGreenFg(date)
			editedBy = semiDimGreenFg(editedBy)
			separator = semiDimGreenFg(separator)
		} else {
			gutter = dullFuchsiaFg(verticalLine)
			if m.currentSection().key == filterSection &&
				m.filterState == filterApplied || singleFilteredItem {
				s := lipgloss.NewStyle().Foreground(fuchsia)
				title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true))
			} else {
				title = fuchsiaFg(title)
				icon = fuchsiaFg(icon)
			}
			date = dimFuchsiaFg(date)
			editedBy = dimDullFuchsiaFg(editedBy)
			separator = dullFuchsiaFg(separator)
		}
	} else {
		gutter = " "
		if m.statusMessage == stashingStatusMessage {
			icon = dimGreenFg(icon)
			title = greenFg(title)
			date = semiDimGreenFg(date)
			editedBy = semiDimGreenFg(editedBy)
			separator = semiDimGreenFg(separator)
		} else if isFiltering && m.filterInput.Value() == "" {
			icon = dimGreenFg(icon)
			title = dimNormalFg(title)
			date = dimBrightGrayFg(date)
			editedBy = dimBrightGrayFg(editedBy)
			separator = dimBrightGrayFg(separator)
		} else {
			icon = greenFg(icon)

			s := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"})
			title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true))
			date = grayFg(date)
			editedBy = midGrayFg(editedBy)
			separator = brightGrayFg(separator)
		}
	}

	fmt.Fprintf(b, "%s %s%s%s%s\n", gutter, icon, separator, separator, title)
	fmt.Fprintf(b, "%s %s", gutter, date)
	if hasEditedBy {
		fmt.Fprintf(b, " %s", editedBy)
	}
}

func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle lipgloss.Style) string {
	b := strings.Builder{}

	normalizedHay, err := normalize(haystack)
	if err != nil {
		log.Error("error normalizing", "haystack", haystack, "error", err)
	}

	matches := fuzzy.Find(needles, []string{normalizedHay})
	if len(matches) == 0 {
		return defaultStyle.Render(haystack)
	}

	m := matches[0] // only one match exists
	for i, rune := range []rune(haystack) {
		styled := false
		for _, mi := range m.MatchedIndexes {
			if i == mi {
				b.WriteString(matchedStyle.Render(string(rune)))
				styled = true
			}
		}
		if !styled {
			b.WriteString(defaultStyle.Render(string(rune)))
		}
	}

	return b.String()
}


================================================
FILE: ui/styles.go
================================================
package ui

import "github.com/charmbracelet/lipgloss"

// Colors.
var (
	normalDim      = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}
	gray           = lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"}
	midGray        = lipgloss.AdaptiveColor{Light: "#B2B2B2", Dark: "#4A4A4A"}
	darkGray       = lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}
	brightGray     = lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}
	dimBrightGray  = lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"}
	cream          = lipgloss.AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"}
	yellowGreen    = lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}
	fuchsia        = lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}
	dimFuchsia     = lipgloss.AdaptiveColor{Light: "#F1A8FF", Dark: "#99519E"}
	dullFuchsia    = lipgloss.AdaptiveColor{Dark: "#AD58B4", Light: "#F793FF"}
	dimDullFuchsia = lipgloss.AdaptiveColor{Light: "#F6C9FF", Dark: "#7B4380"}
	green          = lipgloss.Color("#04B575")
	red            = lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"}
	semiDimGreen   = lipgloss.AdaptiveColor{Light: "#35D79C", Dark: "#036B46"}
	dimGreen       = lipgloss.AdaptiveColor{Light: "#72D2B0", Dark: "#0B5137"}
)

// Ulimately, we'll transition to named styles.
var (
	dimNormalFg      = lipgloss.NewStyle().Foreground(normalDim).Render
	brightGrayFg     = lipgloss.NewStyle().Foreground(brightGray).Render
	dimBrightGrayFg  = lipgloss.NewStyle().Foreground(dimBrightGray).Render
	grayFg           = lipgloss.NewStyle().Foreground(gray).Render
	midGrayFg        = lipgloss.NewStyle().Foreground(midGray).Render
	darkGrayFg       = lipgloss.NewStyle().Foreground(darkGray)
	greenFg          = lipgloss.NewStyle().Foreground(green).Render
	semiDimGreenFg   = lipgloss.NewStyle().Foreground(semiDimGreen).Render
	dimGreenFg       = lipgloss.NewStyle().Foreground(dimGreen).Render
	fuchsiaFg        = lipgloss.NewStyle().Foreground(fuchsia).Render
	dimFuchsiaFg     = lipgloss.NewStyle().Foreground(dimFuchsia).Render
	dullFuchsiaFg    = lipgloss.NewStyle().Foreground(dullFuchsia).Render
	dimDullFuchsiaFg = lipgloss.NewStyle().Foreground(dimDullFuchsia).Render
	redFg            = lipgloss.NewStyle().Foreground(red).Render
	tabStyle         = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"})
	selectedTabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#333333", Dark: "#979797"})
	errorTitleStyle  = lipgloss.NewStyle().Foreground(cream).Background(red).Padding(0, 1)
	subtleStyle      = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"})
	paginationStyle  = subtleStyle
)


================================================
FILE: ui/ui.go
================================================
// Package ui provides the main UI for the glow application.
package ui

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/glamour/styles"
	"github.com/charmbracelet/glow/v2/utils"
	"github.com/charmbracelet/log"
	"github.com/muesli/gitcha"
	te "github.com/muesli/termenv"
)

const (
	statusMessageTimeout = time.Second * 3 // how long to show status messages like "stashed!"
	ellipsis             = "…"
)

var (
	config Config

	markdownExtensions = []string{
		"*.md", "*.mdown", "*.mkdn", "*.mkd", "*.markdown",
	}
)

// NewProgram returns a new Tea program.
func NewProgram(cfg Config, content string) *tea.Program {
	log.Debug(
		"Starting glow",
		"high_perf_pager",
		cfg.HighPerformancePager,
		"glamour",
		cfg.GlamourEnabled,
	)

	config = cfg
	opts := []tea.ProgramOption{tea.WithAltScreen()}
	if cfg.EnableMouse {
		opts = append(opts, tea.WithMouseCellMotion())
	}
	m := newModel(cfg, content)
	return tea.NewProgram(m, opts...)
}

type errMsg struct{ err error }

func (e errMsg) Error() string { return e.err.Error() }

type (
	initLocalFileSearchMsg struct {
		cwd string
		ch  chan gitcha.SearchResult
	}
)

type (
	foundLocalFileMsg       gitcha.SearchResult
	localFileSearchFinished struct{}
	statusMessageTimeoutMsg applicationContext
)

// applicationContext indicates the area of the application something applies
// to. Occasionally used as an argument to commands and messages.
type applicationContext int

const (
	stashContext applicationContext = iota
	pagerContext
)

// state is the top-level application state.
type state int

const (
	stateShowStash state = iota
	stateShowDocument
)

func (s state) String() string {
	return map[state]string{
		stateShowStash:    "showing file listing",
		stateShowDocument: "showing document",
	}[s]
}

// Common stuff we'll need to access in all models.
type commonModel struct {
	cfg    Config
	cwd    string
	width  int
	height int
}

type model struct {
	common   *commonModel
	state    state
	fatalErr error

	// Sub-models
	stash stashModel
	pager pagerModel

	// Channel that receives paths to local markdown files
	// (via the github.com/muesli/gitcha package)
	localFileFinder chan gitcha.SearchResult
}

// unloadDocument unloads a document from the pager. Note that while this
// method alters the model we also need to send along any commands returned.
func (m *model) unloadDocument() []tea.Cmd {
	m.state = stateShowStash
	m.stash.viewState = stashStateReady
	m.pager.unload()
	m.pager.showHelp = false

	var batch []tea.Cmd
	if m.pager.viewport.HighPerformanceRendering {
		batch = append(batch, tea.ClearScrollArea) //nolint:staticcheck
	}

	if !m.stash.shouldSpin() {
		batch = append(batch, m.stash.spinner.Tick)
	}
	return batch
}

func newModel(cfg Config, content string) tea.Model {
	initSections()

	if cfg.GlamourStyle == styles.AutoStyle {
		if te.HasDarkBackground() {
			cfg.GlamourStyle = styles.DarkStyle
		} else {
			cfg.GlamourStyle = styles.LightStyle
		}
	}

	common := commonModel{
		cfg: cfg,
	}

	m := model{
		common: &common,
		state:  stateShowStash,
		pager:  newPagerModel(&common),
		stash:  newStashModel(&common),
	}

	path := cfg.Path
	if path == "" && content != "" {
		m.state = stateShowDocument
		m.pager.currentDocument = markdown{Body: content}
		return m
	}

	if path == "" {
		path = "."
	}
	info, err := os.Stat(path)
	if err != nil {
		log.Error("unable to stat file", "file", path, "error", err)
		m.fatalErr = err
		return m
	}
	if info.IsDir() {
		m.state = stateShowStash
	} else {
		cwd, _ := os.Getwd()
		m.state = stateShowDocument
		m.pager.currentDocument = markdown{
			localPath: path,
			Note:      stripAbsolutePath(path, cwd),
			Modtime:   info.ModTime(),
		}
	}

	return m
}

func (m model) Init() tea.Cmd {
	cmds := []tea.Cmd{m.stash.spinner.Tick}

	switch m.state {
	case stateShowStash:
		cmds = append(cmds, findLocalFiles(*m.common))
	case stateShowDocument:
		content, err := os.ReadFile(m.common.cfg.Path)
		if err != nil {
			log.Error("unable to read file", "file", m.common.cfg.Path, "error", err)
			return func() tea.Msg { return errMsg{err} }
		}
		body := string(utils.RemoveFrontmatter(content))
		cmds = append(cmds, renderWithGlamour(m.pager, body))
	}

	return tea.Batch(cmds...)
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	// If there's been an error, any key exits
	if m.fatalErr != nil {
		if _, ok := msg.(tea.KeyMsg); ok {
			return m, tea.Quit
		}
	}

	var cmds []tea.Cmd

	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.String() {
		case "esc":
			if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument {
				batch := m.unloadDocument()
				return m, tea.Batch(batch...)
			}
		case "r":
			var cmd tea.Cmd
			if m.state == stateShowStash {
				// pass through all keys if we're editing the filter
				if m.stash.filterState == filtering {
					m.stash, cmd = m.stash.update(msg)
					return m, cmd
				}
				m.stash.markdowns = nil
				return m, m.Init()
			}

		case "q":
			var cmd tea.Cmd

			switch m.state { //nolint:exhaustive
			case stateShowStash:
				// pass through all keys if we're editing the filter
				if m.stash.filterState == filtering {
					m.stash, cmd = m.stash.update(msg)
					return m, cmd
				}
			}

			return m, tea.Quit

		case "left", "h", "delete":
			if m.state == stateShowDocument {
				cmds = append(cmds, m.unloadDocument()...)
				return m, tea.Batch(cmds...)
			}

		case "ctrl+z":
			return m, tea.Suspend

		// Ctrl+C always quits no matter where in the application you are.
		case "ctrl+c":
			return m, tea.Quit
		}

	// Window size is received when starting up and on every resize
	case tea.WindowSizeMsg:
		m.common.width = msg.Width
		m.common.height = msg.Height
		m.stash.setSize(msg.Width, msg.Height)
		m.pager.setSize(msg.Width, msg.Height)

	case initLocalFileSearchMsg:
		m.localFileFinder = msg.ch
		m.common.cwd = msg.cwd
		cmds = append(cmds, findNextLocalFile(m))

	case fetchedMarkdownMsg:
		// We've loaded a markdown file's contents for rendering
		m.pager.currentDocument = *msg
		body := string(utils.RemoveFrontmatter([]byte(msg.Body)))
		cmds = append(cmds, renderWithGlamour(m.pager, body))

	case contentRenderedMsg:
		m.state = stateShowDocument

	case localFileSearchFinished:
		// Always pass these messages to the stash so we can keep it updated
		// about network activity, even if the user isn't currently viewing
		// the stash.
		stashModel, cmd := m.stash.update(msg)
		m.stash = stashModel
		return m, cmd

	case foundLocalFileMsg:
		newMd := localFileToMarkdown(m.common.cwd, gitcha.SearchResult(msg))
		m.stash.addMarkdowns(newMd)
		if m.stash.filterApplied() {
			newMd.buildFilterValue()
		}
		if m.stash.shouldUpdateFilter() {
			cmds = append(cmds, filterMarkdowns(m.stash))
		}
		cmds = append(cmds, findNextLocalFile(m))

	case filteredMarkdownMsg:
		if m.state == stateShowDocument {
			newStashModel, cmd := m.stash.update(msg)
			m.stash = newStashModel
			cmds = append(cmds, cmd)
		}
	}

	// Process children
	switch m.state {
	case stateShowStash:
		newStashModel, cmd := m.stash.update(msg)
		m.stash = newStashModel
		cmds = append(cmds, cmd)

	case stateShowDocument:
		newPagerModel, cmd := m.pager.update(msg)
		m.pager = newPagerModel
		cmds = append(cmds, cmd)
	}

	return m, tea.Batch(cmds...)
}

func (m model) View() string {
	if m.fatalErr != nil {
		return errorView(m.fatalErr, true)
	}

	switch m.state { //nolint:exhaustive
	case stateShowDocument:
		return m.pager.View()
	default:
		return m.stash.view()
	}
}

func errorView(err error, fatal bool) string {
	exitMsg := "press any key to "
	if fatal {
		exitMsg += "exit"
	} else {
		exitMsg += "return"
	}
	s := fmt.Sprintf("%s\n\n%v\n\n%s",
		errorTitleStyle.Render("ERROR"),
		err,
		subtleStyle.Render(exitMsg),
	)
	return "\n" + indent(s, 3)
}

// COMMANDS

func findLocalFiles(m commonModel) tea.Cmd {
	return func() tea.Msg {
		log.Info("findLocalFiles")
		var (
			cwd = m.cfg.Path
			err error
		)

		if cwd == "" {
			cwd, err = os.Getwd()
		} else {
			var info os.FileInfo
			info, err = os.Stat(cwd)
			if err == nil && info.IsDir() {
				cwd, err = filepath.Abs(cwd)
			}
		}

		// Note that this is one error check for both cases above
		if err != nil {
			log.Error("error finding local files", "error", err)
			return errMsg{err}
		}

		log.Debug("local directory is", "cwd", cwd)

		// Switch between FindFiles and FindAllFiles to bypass .gitignore rules
		var ch chan gitcha.SearchResult
		if m.cfg.ShowAllFiles {
			ch, err = gitcha.FindAllFilesExcept(cwd, markdownExtensions, nil)
		} else {
			ch, err = gitcha.FindFilesExcept(cwd, markdownExtensions, ignorePatterns(m))
		}

		if err != nil {
			log.Error("error finding local files", "error", err)
			return errMsg{err}
		}

		return initLocalFileSearchMsg{ch: ch, cwd: cwd}
	}
}

func findNextLocalFile(m model) tea.Cmd {
	return func() tea.Msg {
		res, ok := <-m.localFileFinder

		if ok {
			// Okay now find the next one
			return foundLocalFileMsg(res)
		}
		// We're done
		log.Debug("local file search finished")
		return localFileSearchFinished{}
	}
}

func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.Cmd {
	return func() tea.Msg {
		<-t.C
		return statusMessageTimeoutMsg(appCtx)
	}
}

// ETC

// Convert a Gitcha result to an internal representation of a markdown
// document. Note that we could be doing things like checking if the file is
// a directory, but we trust that gitcha has already done that.
func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown {
	return &markdown{
		localPath: res.Path,
		Note:      stripAbsolutePath(res.Path, cwd),
		Modtime:   res.Info.ModTime(),
	}
}

func stripAbsolutePath(fullPath, cwd string) string {
	fp, _ := filepath.EvalSymlinks(fullPath)
	cp, _ := filepath.EvalSymlinks(cwd)
	return strings.ReplaceAll(fp, cp+string(os.PathSeparator), "")
}

// Lightweight version of reflow's indent function.
func indent(s string, n int) string {
	if n <= 0 || s == "" {
		return s
	}
	l := strings.Split(s, "\n")
	b := strings.Builder{}
	i := strings.Repeat(" ", n)
	for _, v := range l {
		fmt.Fprintf(&b, "%s%s\n", i, v)
	}
	return b.String()
}


================================================
FILE: url.go
================================================
package main

import (
	"fmt"
	"net/url"
	"strings"
	"sync"
)

const (
	protoGithub = "github://"
	protoGitlab = "gitlab://"
	protoHTTPS  = "https://"
)

var (
	githubURL *url.URL
	gitlabURL *url.URL
	urlsOnce  sync.Once
)

func init() {
	urlsOnce.Do(func() {
		githubURL, _ = url.Parse("https://github.com")
		gitlabURL, _ = url.Parse("https://gitlab.com")
	})
}

func readmeURL(path string) (*source, error) {
	switch {
	case strings.HasPrefix(path, protoGithub):
		if u := githubReadmeURL(path); u != nil {
			return readmeURL(u.String())
		}
		return nil, nil
	case strings.HasPrefix(path, protoGitlab):
		if u := gitlabReadmeURL(path); u != nil {
			return readmeURL(u.String())
		}
		return nil, nil
	}

	if !strings.HasPrefix(path, protoHTTPS) {
		path = protoHTTPS + path
	}
	u, err := url.Parse(path)
	if err != nil {
		return nil, fmt.Errorf("unable to parse url: %w", err)
	}

	switch {
	case u.Hostname() == githubURL.Hostname():
		return findGitHubREADME(u)
	case u.Hostname() == gitlabURL.Hostname():
		return findGitLabREADME(u)
	}

	return nil, nil
}

func githubReadmeURL(path string) *url.URL {
	path = strings.TrimPrefix(path, protoGithub)
	parts := strings.Split(path, "/")
	if len(parts) != 2 {
		// custom hostnames are not supported yet
		return nil
	}
	u, _ := url.Parse(githubURL.String())
	return u.JoinPath(path)
}

func gitlabReadmeURL(path string) *url.URL {
	path = strings.TrimPrefix(path, protoGitlab)
	parts := strings.Split(path, "/")
	if len(parts) != 2 {
		// custom hostnames are not supported yet
		return nil
	}
	u, _ := url.Parse(gitlabURL.String())
	return u.JoinPath(path)
}

func isURL(path string) bool {
	_, err := url.ParseRequestURI(path)
	return err == nil && strings.Contains(path, "://")
}


================================================
FILE: url_test.go
================================================
package main

import "testing"

func TestURLParser(t *testing.T) {
	for path, url := range map[string]string{
		"github.com/charmbracelet/glow":             "https://raw.githubusercontent.com/charmbracelet/glow/master/README.md",
		"github://charmbracelet/glow":               "https://raw.githubusercontent.com/charmbracelet/glow/master/README.md",
		"github://caarlos0/dotfiles.fish":           "https://raw.githubusercontent.com/caarlos0/dotfiles.fish/main/README.md",
		"github://tj/git-extras":                    "https://raw.githubusercontent.com/tj/git-extras/main/Readme.md",
		"https://github.com/goreleaser/nfpm":        "https://raw.githubusercontent.com/goreleaser/nfpm/main/README.md",
		"gitlab.com/caarlos0/test":                  "https://gitlab.com/caarlos0/test/-/raw/master/README.md",
		"gitlab://caarlos0/test":                    "https://gitlab.com/caarlos0/test/-/raw/master/README.md",
		"https://gitlab.com/terrakok/gitlab-client": "https://gitlab.com/terrakok/gitlab-client/-/raw/develop/Readme.md",
	} {
		t.Run(path, func(t *testing.T) {
			t.Skip("test uses network, sometimes fails for no reason")
			got, err := readmeURL(path)
			if err != nil {
				t.Fatalf("expected no error, got %v", err)
			}
			if got == nil {
				t.Fatalf("should not be nil")
			}
			if url != got.URL {
				t.Errorf("expected url for %s to be %s, was %s", path, url, got.URL)
			}
		})
	}
}


================================================
FILE: utils/utils.go
================================================
// Package utils provides utility functions.
package utils

import (
	"os"
	"path/filepath"
	"regexp"
	"strings"

	"github.com/charmbracelet/glamour"
	"github.com/charmbracelet/glamour/ansi"
	"github.com/charmbracelet/glamour/styles"
	"github.com/charmbracelet/lipgloss"
	"github.com/mitchellh/go-homedir"
)

// RemoveFrontmatter removes the front matter header of a markdown file.
func RemoveFrontmatter(content []byte) []byte {
	if frontmatterBoundaries := detectFrontmatter(content); frontmatterBoundaries[0] == 0 {
		return content[frontmatterBoundaries[1]:]
	}
	return content
}

var yamlPattern = regexp.MustCompile(`(?m)^---\r?\n(\s*\r?\n)?`)

func detectFrontmatter(c []byte) []int {
	if matches := yamlPattern.FindAllIndex(c, 2); len(matches) > 1 {
		return []int{matches[0][0], matches[1][1]}
	}
	return []int{-1, -1}
}

// ExpandPath expands tilde and all environment variables from the given path.
func ExpandPath(path string) string {
	s, err := homedir.Expand(path)
	if err == nil {
		return os.ExpandEnv(s)
	}
	return os.ExpandEnv(path)
}

// WrapCodeBlock wraps a string in a code block with the given language.
func WrapCodeBlock(s, language string) string {
	return "```" + language + "\n" + s + "```"
}

var markdownExtensions = []string{
	".md", ".mdown", ".mkdn", ".mkd", ".markdown",
}

// IsMarkdownFile returns whether the filename has a markdown extension.
func IsMarkdownFile(filename string) bool {
	ext := filepath.Ext(filename)

	if ext == "" {
		// By default, assume it's a markdown file.
		return true
	}

	for _, v := range markdownExtensions {
		if strings.EqualFold(ext, v) {
			return true
		}
	}

	// Has an extension but not markdown
	// so assume this is a code file.
	return false
}

// GlamourStyle returns a glamour.TermRendererOption based on the given style.
func GlamourStyle(style string, isCode bool) glamour.TermRendererOption {
	if !isCode {
		if style == styles.AutoStyle {
			return glamour.WithAutoStyle()
		}
		return glamour.WithStylePath(style)
	}

	// If we are rendering a pure code block, we need to modify the style to
	// remove the indentation.

	var styleConfig ansi.StyleConfig

	switch style {
	case styles.AutoStyle:
		if lipgloss.HasDarkBackground() {
			styleConfig = styles.DarkStyleConfig
		} else {
			styleConfig = styles.LightStyleConfig
		}
	case styles.DarkStyle:
		styleConfig = styles.DarkStyleConfig
	case styles.LightStyle:
		styleConfig = styles.LightStyleConfig
	case styles.PinkStyle:
		styleConfig = styles.PinkStyleConfig
	case styles.NoTTYStyle:
		styleConfig = styles.NoTTYStyleConfig
	case styles.DraculaStyle:
		styleConfig = styles.DraculaStyleConfig
	case styles.TokyoNightStyle:
		styleConfig = styles.DraculaStyleConfig
	default:
		return glamour.WithStylesFromJSONFile(style)
	}

	var margin uint
	styleConfig.CodeBlock.Margin = &margin

	return glamour.WithStyles(styleConfig)
}
Download .txt
gitextract_ljxy3y3l/

├── .editorconfig
├── .github/
│   ├── CODEOWNERS
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   ├── config.yml
│   │   └── feature_request.md
│   ├── dependabot.yml
│   └── workflows/
│       ├── build.yml
│       ├── coverage.yml
│       ├── dependabot-sync.yml
│       ├── goreleaser.yml
│       ├── lint-sync.yml
│       └── lint.yml
├── .gitignore
├── .golangci.yml
├── .goreleaser.yml
├── Dockerfile
├── LICENSE
├── README.md
├── Taskfile.yaml
├── config_cmd.go
├── console_windows.go
├── github.go
├── gitlab.go
├── glow_test.go
├── go.mod
├── go.sum
├── log.go
├── main.go
├── man_cmd.go
├── style.go
├── ui/
│   ├── config.go
│   ├── editor.go
│   ├── ignore_darwin.go
│   ├── ignore_general.go
│   ├── keys.go
│   ├── markdown.go
│   ├── pager.go
│   ├── sort.go
│   ├── stash.go
│   ├── stashhelp.go
│   ├── stashitem.go
│   ├── styles.go
│   └── ui.go
├── url.go
├── url_test.go
└── utils/
    └── utils.go
Download .txt
SYMBOL INDEX (178 symbols across 22 files)

FILE: config_cmd.go
  constant defaultConfig (line 16) | defaultConfig = `# style name or JSON path (default "auto")
  function ensureConfigFile (line 56) | func ensureConfigFile() error {

FILE: console_windows.go
  function enableAnsiColors (line 13) | func enableAnsiColors() {
  function init (line 21) | func init() {

FILE: github.go
  function findGitHubREADME (line 14) | func findGitHubREADME(u *url.URL) (*source, error) {

FILE: gitlab.go
  function findGitLabREADME (line 14) | func findGitLabREADME(u *url.URL) (*source, error) {

FILE: glow_test.go
  function TestGlowFlags (line 7) | func TestGlowFlags(t *testing.T) {

FILE: log.go
  function getLogFilePath (line 13) | func getLogFilePath() (string, error) {
  function setupLog (line 21) | func setupLog() (func() error, error) {

FILE: main.go
  type source (line 67) | type source struct
  function sourceFromArg (line 73) | func sourceFromArg(arg string) (*source, error) {
  function validateStyle (line 153) | func validateStyle(style string) error {
  function validateOptions (line 165) | func validateOptions(cmd *cobra.Command) error {
  function stdinIsPipe (line 211) | func stdinIsPipe() (bool, error) {
  function execute (line 222) | func execute(cmd *cobra.Command, args []string) error {
  function executeArg (line 263) | func executeArg(cmd *cobra.Command, arg string, w io.Writer) error {
  function executeCLI (line 273) | func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error {
  function runTUI (line 344) | func runTUI(path string, content string) error {
  function main (line 371) | func main() {
  function init (line 384) | func init() {
  function tryLoadConfigFromDefaultPlaces (line 426) | func tryLoadConfigFromDefaultPlaces() {

FILE: ui/config.go
  type Config (line 4) | type Config struct

FILE: ui/editor.go
  type editorFinishedMsg (line 8) | type editorFinishedMsg struct
  function openEditor (line 10) | func openEditor(path string, lineno int) tea.Cmd {

FILE: ui/ignore_darwin.go
  function ignorePatterns (line 8) | func ignorePatterns(m commonModel) []string {

FILE: ui/ignore_general.go
  function ignorePatterns (line 6) | func ignorePatterns(m commonModel) []string {

FILE: ui/keys.go
  constant keyEnter (line 4) | keyEnter = "enter"
  constant keyEsc (line 5) | keyEsc   = "esc"

FILE: ui/markdown.go
  type markdown (line 16) | type markdown struct
    method buildFilterValue (line 32) | func (m *markdown) buildFilterValue() {
    method relativeTime (line 42) | func (m markdown) relativeTime() string {
  function normalize (line 49) | func normalize(in string) (string, error) {
  function relativeTime (line 59) | func relativeTime(then time.Time) string {

FILE: ui/pager.go
  constant statusBarHeight (line 25) | statusBarHeight = 1
  constant lineNumberWidth (line 26) | lineNumberWidth = 4
  type contentRenderedMsg (line 81) | type contentRenderedMsg
  type reloadMsg (line 82) | type reloadMsg struct
  type pagerState (line 85) | type pagerState
  constant pagerStateBrowse (line 88) | pagerStateBrowse pagerState = iota
  constant pagerStateStatusMessage (line 89) | pagerStateStatusMessage
  type pagerModel (line 92) | type pagerModel struct
    method setSize (line 123) | func (m *pagerModel) setSize(w, h int) {
    method setContent (line 135) | func (m *pagerModel) setContent(s string) {
    method toggleHelp (line 139) | func (m *pagerModel) toggleHelp() {
    method showStatusMessage (line 155) | func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd {
    method unload (line 167) | func (m *pagerModel) unload() {
    method update (line 181) | func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
    method View (line 282) | func (m pagerModel) View() string {
    method statusBarView (line 296) | func (m pagerModel) statusBarView(b *strings.Builder) {
    method helpView (line 368) | func (m pagerModel) helpView() (s string) {
    method initWatcher (line 482) | func (m *pagerModel) initWatcher() {
    method watchFile (line 490) | func (m *pagerModel) watchFile() tea.Msg {
    method unwatchFile (line 522) | func (m *pagerModel) unwatchFile() {
    method localDir (line 533) | func (m *pagerModel) localDir() string {
  function newPagerModel (line 108) | func newPagerModel(common *commonModel) pagerModel {
  type pagerStatusMessage (line 147) | type pagerStatusMessage struct
  function renderWithGlamour (line 410) | func renderWithGlamour(m pagerModel, md string) tea.Cmd {
  function glamourRender (line 422) | func glamourRender(m pagerModel, markdown string) (string, error) {

FILE: ui/sort.go
  function sortMarkdowns (line 8) | func sortMarkdowns(mds []*markdown) {

FILE: ui/stash.go
  constant stashIndent (line 23) | stashIndent                = 1
  constant stashViewItemHeight (line 24) | stashViewItemHeight        = 3
  constant stashViewTopPadding (line 25) | stashViewTopPadding        = 5
  constant stashViewBottomPadding (line 26) | stashViewBottomPadding     = 3
  constant stashViewHorizontalPadding (line 27) | stashViewHorizontalPadding = 6
  type filteredMarkdownMsg (line 54) | type filteredMarkdownMsg
  type fetchedMarkdownMsg (line 55) | type fetchedMarkdownMsg
  type stashViewState (line 61) | type stashViewState
  constant stashStateReady (line 64) | stashStateReady stashViewState = iota
  constant stashStateLoadingDocument (line 65) | stashStateLoadingDocument
  constant stashStateShowingError (line 66) | stashStateShowingError
  type sectionKey (line 70) | type sectionKey
  constant documentsSection (line 73) | documentsSection = iota
  constant filterSection (line 74) | filterSection
  type section (line 79) | type section struct
  type filterState (line 89) | type filterState
  constant unfiltered (line 92) | unfiltered    filterState = iota
  constant filtering (line 93) | filtering
  constant filterApplied (line 94) | filterApplied
  type statusMessageType (line 98) | type statusMessageType
  constant normalStatusMessage (line 102) | normalStatusMessage statusMessageType = iota
  constant subtleStatusMessage (line 103) | subtleStatusMessage
  constant errorStatusMessage (line 104) | errorStatusMessage
  type statusMessage (line 108) | type statusMessage struct
    method String (line 128) | func (s statusMessage) String() string {
  function initSections (line 113) | func initSections() {
  type stashModel (line 139) | type stashModel struct
    method loadingDone (line 176) | func (m stashModel) loadingDone() bool {
    method currentSection (line 180) | func (m stashModel) currentSection() *section {
    method paginator (line 184) | func (m stashModel) paginator() *paginator.Model {
    method setPaginator (line 188) | func (m *stashModel) setPaginator(p paginator.Model) {
    method cursor (line 192) | func (m stashModel) cursor() int {
    method setCursor (line 196) | func (m *stashModel) setCursor(i int) {
    method shouldSpin (line 201) | func (m stashModel) shouldSpin() bool {
    method setSize (line 207) | func (m *stashModel) setSize(width, height int) {
    method resetFiltering (line 218) | func (m *stashModel) resetFiltering() {
    method filterApplied (line 242) | func (m stashModel) filterApplied() bool {
    method shouldUpdateFilter (line 247) | func (m stashModel) shouldUpdateFilter() bool {
    method updatePagination (line 255) | func (m *stashModel) updatePagination() {
    method markdownIndex (line 278) | func (m stashModel) markdownIndex() int {
    method selectedMarkdown (line 283) | func (m stashModel) selectedMarkdown() *markdown {
    method addMarkdowns (line 295) | func (m *stashModel) addMarkdowns(mds ...*markdown) {
    method getVisibleMarkdowns (line 309) | func (m stashModel) getVisibleMarkdowns() []*markdown {
    method openMarkdown (line 319) | func (m *stashModel) openMarkdown(md *markdown) tea.Cmd {
    method hideStatusMessage (line 325) | func (m *stashModel) hideStatusMessage() {
    method moveCursorUp (line 333) | func (m *stashModel) moveCursorUp() {
    method moveCursorDown (line 350) | func (m *stashModel) moveCursorDown() {
    method update (line 412) | func (m stashModel) update(msg tea.Msg) (stashModel, tea.Cmd) {
    method handleDocumentBrowsing (line 461) | func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd {
    method handleFiltering (line 601) | func (m *stashModel) handleFiltering(msg tea.Msg) tea.Cmd {
    method view (line 670) | func (m stashModel) view() string {
    method headerView (line 754) | func (m stashModel) headerView() string {
    method populatedView (line 798) | func (m stashModel) populatedView() string {
  function newStashModel (line 376) | func newStashModel(common *commonModel) stashModel {
  function newStashPaginator (line 402) | func newStashPaginator() paginator.Model {
  function glowLogoView (line 750) | func glowLogoView() string {
  function loadLocalMarkdown (line 852) | func loadLocalMarkdown(md *markdown) tea.Cmd {
  function filterMarkdowns (line 868) | func filterMarkdowns(m stashModel) tea.Cmd {

FILE: ui/stashhelp.go
  type helpEntry (line 12) | type helpEntry struct
  type helpColumn (line 15) | type helpColumn
    method render (line 33) | func (h helpColumn) render(height int) (rows []string) {
    method maxWidths (line 66) | func (h helpColumn) maxWidths() (maxKey int, maxVal int) {
  function newHelpColumn (line 20) | func newHelpColumn(pairs ...string) (h helpColumn) {
  method helpView (line 83) | func (m stashModel) helpView() (string, int) {
  constant minHelpViewHeight (line 162) | minHelpViewHeight = 5
  method renderHelp (line 166) | func (m stashModel) renderHelp(groups ...[]string) (string, int) {
  method miniHelpView (line 178) | func (m stashModel) miniHelpView(entries ...string) string {
  method fullHelpView (line 223) | func (m stashModel) fullHelpView(groups ...[]string) string {
  function mergeColumns (line 254) | func mergeColumns(cols ...[]string) string {
  function concatStringSlices (line 292) | func concatStringSlices(s ...[]string) (agg []string) {

FILE: ui/stashitem.go
  constant verticalLine (line 14) | verticalLine         = "│"
  constant fileListingStashIcon (line 15) | fileListingStashIcon = "• "
  function stashItemView (line 18) | func stashItemView(b *strings.Builder, m stashModel, index int, md *mark...
  function styleFilteredText (line 92) | func styleFilteredText(haystack, needles string, defaultStyle, matchedSt...

FILE: ui/ui.go
  constant statusMessageTimeout (line 20) | statusMessageTimeout = time.Second * 3
  constant ellipsis (line 21) | ellipsis             = "…"
  function NewProgram (line 33) | func NewProgram(cfg Config, content string) *tea.Program {
  type errMsg (line 51) | type errMsg struct
    method Error (line 53) | func (e errMsg) Error() string { return e.err.Error() }
  type initLocalFileSearchMsg (line 56) | type initLocalFileSearchMsg struct
  type foundLocalFileMsg (line 63) | type foundLocalFileMsg
  type localFileSearchFinished (line 64) | type localFileSearchFinished struct
  type statusMessageTimeoutMsg (line 65) | type statusMessageTimeoutMsg
  type applicationContext (line 70) | type applicationContext
  constant stashContext (line 73) | stashContext applicationContext = iota
  constant pagerContext (line 74) | pagerContext
  type state (line 78) | type state
    method String (line 85) | func (s state) String() string {
  constant stateShowStash (line 81) | stateShowStash state = iota
  constant stateShowDocument (line 82) | stateShowDocument
  type commonModel (line 93) | type commonModel struct
  type model (line 100) | type model struct
    method unloadDocument (line 116) | func (m *model) unloadDocument() []tea.Cmd {
    method Init (line 186) | func (m model) Init() tea.Cmd {
    method Update (line 205) | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    method View (line 327) | func (m model) View() string {
  function newModel (line 133) | func newModel(cfg Config, content string) tea.Model {
  function errorView (line 340) | func errorView(err error, fatal bool) string {
  function findLocalFiles (line 357) | func findLocalFiles(m commonModel) tea.Cmd {
  function findNextLocalFile (line 400) | func findNextLocalFile(m model) tea.Cmd {
  function waitForStatusMessageTimeout (line 414) | func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Time...
  function localFileToMarkdown (line 426) | func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown {
  function stripAbsolutePath (line 434) | func stripAbsolutePath(fullPath, cwd string) string {
  function indent (line 441) | func indent(s string, n int) string {

FILE: url.go
  constant protoGithub (line 11) | protoGithub = "github://"
  constant protoGitlab (line 12) | protoGitlab = "gitlab://"
  constant protoHTTPS (line 13) | protoHTTPS  = "https://"
  function init (line 22) | func init() {
  function readmeURL (line 29) | func readmeURL(path string) (*source, error) {
  function githubReadmeURL (line 61) | func githubReadmeURL(path string) *url.URL {
  function gitlabReadmeURL (line 72) | func gitlabReadmeURL(path string) *url.URL {
  function isURL (line 83) | func isURL(path string) bool {

FILE: url_test.go
  function TestURLParser (line 5) | func TestURLParser(t *testing.T) {

FILE: utils/utils.go
  function RemoveFrontmatter (line 18) | func RemoveFrontmatter(content []byte) []byte {
  function detectFrontmatter (line 27) | func detectFrontmatter(c []byte) []int {
  function ExpandPath (line 35) | func ExpandPath(path string) string {
  function WrapCodeBlock (line 44) | func WrapCodeBlock(s, language string) string {
  function IsMarkdownFile (line 53) | func IsMarkdownFile(filename string) bool {
  function GlamourStyle (line 73) | func GlamourStyle(style string, isCode bool) glamour.TermRendererOption {
Condensed preview — 46 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (135K chars).
[
  {
    "path": ".editorconfig",
    "chars": 276,
    "preview": "# https://editorconfig.org/\n\nroot = true\n\n[*]\ncharset = utf-8\ninsert_final_newline = true\ntrim_trailing_whitespace = tru"
  },
  {
    "path": ".github/CODEOWNERS",
    "chars": 26,
    "preview": "* @charmbracelet/everyone\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "chars": 876,
    "preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the b"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "chars": 120,
    "preview": "blank_issues_enabled: true\ncontact_links:\n- name: Discord\n  url: https://charm.sh/discord\n  about: Chat on our Discord.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "chars": 604,
    "preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: enhancement\nassignees: ''\n\n---\n\n**Is"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 1114,
    "preview": "version: 2\n\nupdates:\n  - package-ecosystem: \"gomod\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n      day:"
  },
  {
    "path": ".github/workflows/build.yml",
    "chars": 569,
    "preview": "name: build\n\non: [push, pull_request]\n\njobs:\n  build:\n    uses: charmbracelet/meta/.github/workflows/build.yml@main\n\n  s"
  },
  {
    "path": ".github/workflows/coverage.yml",
    "chars": 593,
    "preview": "name: coverage\non: [push, pull_request]\n\njobs:\n  coverage:\n    runs-on: ubuntu-latest\n    env:\n      GO111MODULE: \"on\"\n "
  },
  {
    "path": ".github/workflows/dependabot-sync.yml",
    "chars": 419,
    "preview": "name: dependabot-sync\non:\n  schedule:\n    - cron: \"0 0 * * 0\" # every Sunday at midnight\n  workflow_dispatch: # allows m"
  },
  {
    "path": ".github/workflows/goreleaser.yml",
    "chars": 711,
    "preview": "# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json\n\nname: goreleaser\n\non:\n  push:\n    tag"
  },
  {
    "path": ".github/workflows/lint-sync.yml",
    "chars": 240,
    "preview": "name: lint-sync\non:\n  schedule:\n    - cron: \"0 0 * * 0\" # every sunday at midnight\n  workflow_dispatch:\n\npermissions:\n  "
  },
  {
    "path": ".github/workflows/lint.yml",
    "chars": 464,
    "preview": "name: lint\non:\n  push:\n  pull_request:\n\njobs:\n  golangci:\n    name: lint\n    runs-on: ubuntu-latest\n    steps:\n      - u"
  },
  {
    "path": ".gitignore",
    "chars": 41,
    "preview": "glow\ndist/\n.envrc\ncompletions/\nmanpages/\n"
  },
  {
    "path": ".golangci.yml",
    "chars": 695,
    "preview": "version: \"2\"\nrun:\n  tests: false\nlinters:\n  enable:\n    - bodyclose\n    - exhaustive\n    - goconst\n    - godot\n    - gom"
  },
  {
    "path": ".goreleaser.yml",
    "chars": 451,
    "preview": "# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json\n\nversion: 2\n\nincludes:\n  - from_url:\n     "
  },
  {
    "path": "Dockerfile",
    "chars": 97,
    "preview": "FROM gcr.io/distroless/static\nCOPY glow /usr/local/bin/glow\nENTRYPOINT [ \"/usr/local/bin/glow\" ]\n"
  },
  {
    "path": "LICENSE",
    "chars": 1080,
    "preview": "MIT License\n\nCopyright (c) 2019-2024 Charmbracelet, Inc\n\nPermission is hereby granted, free of charge, to any person obt"
  },
  {
    "path": "README.md",
    "chars": 6028,
    "preview": "# Glow\n\nRender markdown on the CLI, with _pizzazz_!\n\n<p align=\"center\">\n    <img src=\"https://stuff.charm.sh/glow/glow-b"
  },
  {
    "path": "Taskfile.yaml",
    "chars": 419,
    "preview": "# https://taskfile.dev\n\nversion: '3'\n\ntasks:\n  lint:\n    desc: Run base linters\n    cmds:\n      - golangci-lint run\n\n  t"
  },
  {
    "path": "config_cmd.go",
    "chars": 2448,
    "preview": "package main\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\n\t\"github.com/charmbracelet/x/editor\"\n\t\""
  },
  {
    "path": "console_windows.go",
    "chars": 484,
    "preview": "// +build windows\n\npackage main\n\nimport (\n\t\"os\"\n\n\t\"golang.org/x/sys/windows\"\n)\n\n// enableAnsiColors enables support for "
  },
  {
    "path": "github.go",
    "chars": 1388,
    "preview": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n)\n\n// findGitHubREADME "
  },
  {
    "path": "gitlab.go",
    "chars": 1497,
    "preview": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n)\n\n// findGitLabREADME "
  },
  {
    "path": "glow_test.go",
    "chars": 582,
    "preview": "package main\n\nimport (\n\t\"testing\"\n)\n\nfunc TestGlowFlags(t *testing.T) {\n\ttt := []struct {\n\t\targs  []string\n\t\tcheck func("
  },
  {
    "path": "go.mod",
    "chars": 3206,
    "preview": "module github.com/charmbracelet/glow/v2\n\ngo 1.24.0\n\ntoolchain go1.24.1\n\nrequire (\n\tgithub.com/atotto/clipboard v0.1.4\n\tg"
  },
  {
    "path": "go.sum",
    "chars": 15730,
    "preview": "github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=\ngithub.com/alecthomas/assert/v2 v"
  },
  {
    "path": "log.go",
    "chars": 937,
    "preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/charmbracelet/log\"\n\tgap \"github.com/muesli/go-a"
  },
  {
    "path": "main.go",
    "chars": 12477,
    "preview": "// Package main provides the entry point for the Glow CLI application.\npackage main\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"i"
  },
  {
    "path": "man_cmd.go",
    "chars": 713,
    "preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\tmcobra \"github.com/muesli/mango-cobra\"\n\t\"github.com/muesli/roff\"\n\t\"github.com/spf1"
  },
  {
    "path": "style.go",
    "chars": 231,
    "preview": "package main\n\nimport \"github.com/charmbracelet/lipgloss\"\n\nvar (\n\tkeyword = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Co"
  },
  {
    "path": "ui/config.go",
    "chars": 559,
    "preview": "package ui\n\n// Config contains TUI-specific configuration.\ntype Config struct {\n\tShowAllFiles     bool\n\tShowLineNumbers "
  },
  {
    "path": "ui/editor.go",
    "chars": 450,
    "preview": "package ui\n\nimport (\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/x/editor\"\n)\n\ntype editorFinish"
  },
  {
    "path": "ui/ignore_darwin.go",
    "chars": 226,
    "preview": "//go:build darwin\n// +build darwin\n\npackage ui\n\nimport \"path/filepath\"\n\nfunc ignorePatterns(m commonModel) []string {\n\tr"
  },
  {
    "path": "ui/ignore_general.go",
    "chars": 161,
    "preview": "//go:build !darwin\n// +build !darwin\n\npackage ui\n\nfunc ignorePatterns(m commonModel) []string {\n\treturn []string{\n\t\tm.cf"
  },
  {
    "path": "ui/keys.go",
    "chars": 60,
    "preview": "package ui\n\nconst (\n\tkeyEnter = \"enter\"\n\tkeyEsc   = \"esc\"\n)\n"
  },
  {
    "path": "ui/markdown.go",
    "chars": 2895,
    "preview": "package ui\n\nimport (\n\t\"fmt\"\n\t\"math\"\n\t\"time\"\n\t\"unicode\"\n\n\t\"github.com/charmbracelet/log\"\n\t\"github.com/dustin/go-humanize\""
  },
  {
    "path": "ui/pager.go",
    "chars": 12757,
    "preview": "package ui\n\nimport (\n\t\"fmt\"\n\t\"math\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/atotto/clipboard\"\n\t\"github.com/cha"
  },
  {
    "path": "ui/sort.go",
    "chars": 177,
    "preview": "package ui\n\nimport (\n\t\"cmp\"\n\t\"slices\"\n)\n\nfunc sortMarkdowns(mds []*markdown) {\n\tslices.SortStableFunc(mds, func(a, b *ma"
  },
  {
    "path": "ui/stash.go",
    "chars": 21137,
    "preview": "package ui\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/bubbles/paginator\"\n\t\""
  },
  {
    "path": "ui/stashhelp.go",
    "chars": 6862,
    "preview": "package ui\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/muesli/reflow/ansi\"\n)\n\n// helpEntry is a entry in a help menu conta"
  },
  {
    "path": "ui/stashitem.go",
    "chars": 3470,
    "preview": "package ui\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/charmbracelet/log\"\n\t\"github.co"
  },
  {
    "path": "ui/styles.go",
    "chars": 2720,
    "preview": "package ui\n\nimport \"github.com/charmbracelet/lipgloss\"\n\n// Colors.\nvar (\n\tnormalDim      = lipgloss.AdaptiveColor{Light:"
  },
  {
    "path": "ui/ui.go",
    "chars": 10268,
    "preview": "// Package ui provides the main UI for the glow application.\npackage ui\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"string"
  },
  {
    "path": "url.go",
    "chars": 1740,
    "preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"strings\"\n\t\"sync\"\n)\n\nconst (\n\tprotoGithub = \"github://\"\n\tprotoGitlab = \"gitlab"
  },
  {
    "path": "url_test.go",
    "chars": 1401,
    "preview": "package main\n\nimport \"testing\"\n\nfunc TestURLParser(t *testing.T) {\n\tfor path, url := range map[string]string{\n\t\t\"github."
  },
  {
    "path": "utils/utils.go",
    "chars": 2872,
    "preview": "// Package utils provides utility functions.\npackage utils\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"gith"
  }
]

About this extraction

This page contains the full source code of the charmbracelet/glow GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 46 files (119.4 KB), approximately 40.0k tokens, and a symbol index with 178 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!