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)
}
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
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.