Compare commits

..

No commits in common. "master" and "v1.4.1" have entirely different histories.

51 changed files with 3016 additions and 1904 deletions

View file

@ -1,18 +0,0 @@
# 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

View file

@ -1,38 +0,0 @@
---
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.

View file

@ -1,5 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: Discord
url: https://charm.sh/discord
about: Chat on our Discord.

View file

@ -1,20 +0,0 @@
---
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.

View file

@ -1,57 +0,0 @@
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:
- "*"

View file

@ -1,25 +1,28 @@
name: build
on: [push, pull_request]
jobs:
build:
uses: charmbracelet/meta/.github/workflows/build.yml@main
test:
strategy:
matrix:
go-version: [~1.13, ^1]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
env:
GO111MODULE: "on"
steps:
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
snapshot:
uses: charmbracelet/meta/.github/workflows/snapshot.yml@main
secrets:
goreleaser_key: ${{ secrets.GORELEASER_KEY }}
- name: Checkout code
uses: actions/checkout@v2
govulncheck:
uses: charmbracelet/meta/.github/workflows/govulncheck.yml@main
with:
go-version: stable
- name: Download Go modules
run: go mod download
semgrep:
uses: charmbracelet/meta/.github/workflows/semgrep.yml@main
- name: Build
run: go build -v ./...
ruleguard:
uses: charmbracelet/meta/.github/workflows/ruleguard.yml@main
with:
go-version: stable
- name: Test
run: go test ./...

View file

@ -3,22 +3,26 @@ on: [push, pull_request]
jobs:
coverage:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: [^1]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
env:
GO111MODULE: "on"
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install Go
uses: actions/setup-go@v6
uses: actions/setup-go@v2
with:
go-version: stable
go-version: ${{ matrix.go-version }}
- name: Checkout code
uses: actions/checkout@v2
- 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
GO111MODULE=off go get github.com/mattn/goveralls
$(go env GOPATH)/bin/goveralls -coverprofile=profile.cov -service=github

View file

@ -1,17 +0,0 @@
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 }}

View file

@ -1,25 +1,34 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: goreleaser
on:
pull_request:
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 }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: ~1.16
- name: Set up Snapcraft
run: |
sudo apt-get update
sudo apt-get -yq --no-install-suggests --no-install-recommends install snapcraft
mkdir -p $HOME/.cache/snapcraft/download
mkdir -p $HOME/.cache/snapcraft/stage-packages
- name: Login Snapcraft
env:
SNAPCRAFT_LOGIN: ${{ secrets.SNAPCRAFT_LOGIN }}
if: env.SNAPCRAFT_LOGIN != null
run: snapcraft login --with <(echo "$SNAPCRAFT_LOGIN")
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
with:
version: latest
args: release --snapshot --skip-publish --skip-sign --rm-dist

View file

@ -1,13 +0,0 @@
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

View file

@ -8,9 +8,9 @@ jobs:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v2
- name: golangci-lint
uses: golangci/golangci-lint-action@v9.2.0
uses: golangci/golangci-lint-action@v2
with:
# Optional: golangci-lint command line arguments.
args: --issues-exit-code=0

2
.gitignore vendored
View file

@ -1,5 +1,3 @@
glow
dist/
.envrc
completions/
manpages/

View file

@ -1,44 +1,27 @@
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:
linters:
enable:
- gofumpt
- bodyclose
- dupl
- exportloopref
- goconst
- godot
- godox
- goimports
exclusions:
generated: lax
- golint
- gomnd
- goprintffuncname
- gosec
- misspell
- prealloc
- rowserrcheck
- sqlclosecheck
- unconvert
- unparam
- whitespace

View file

@ -1,14 +1,95 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
env:
- GO111MODULE=on
- CGO_ENABLED=0
before:
hooks:
- go mod tidy
builds:
-
binary: glow
ldflags: -s -w -X main.Version={{ .Version }} -X main.CommitSHA={{ .Commit }}
goos:
- linux
- freebsd
- openbsd
- darwin
- windows
goarch:
- amd64
- arm64
- 386
- arm
goarm:
- 6
- 7
version: 2
archives:
-
builds:
- glow
format_overrides:
- goos: windows
format: zip
replacements:
windows: Windows
darwin: Darwin
386: i386
amd64: x86_64
includes:
- from_url:
url: charmbracelet/meta/main/goreleaser-glow.yaml
nfpms:
-
vendor: charmbracelet
homepage: "https://charm.sh/"
maintainer: "Christian Muehlhaeuser <muesli@gmail.com>"
description: "Render markdown on the CLI"
license: MIT
formats:
- apk
- deb
- rpm
bindir: /usr/bin
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"
brews:
- goarm: 6
tap:
owner: charmbracelet
name: homebrew-tap
commit_author:
name: "Christian Muehlhaeuser"
email: "muesli@gmail.com"
homepage: "https://charm.sh/"
description: "Render markdown on the CLI"
# skip_upload: true
snapcrafts:
-
name: glow
publish: true
summary: Render markdown on the CLI, with pizzazz!
description: |
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 and stash markdown files to your own private collection so you can read them anywhere. Glow will find local markdown files in subdirectories or a local Git repository.
By the way, all data stashed is encrypted end-to-end: only you can decrypt it. More on that below.
grade: stable
confinement: strict
license: MIT
base: core20
apps:
glow:
plugs: ["home", "network"]
signs:
- artifacts: checksum
checksum:
name_template: "checksums.txt"
snapshot:
name_template: "{{ .Tag }}-next"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"

View file

@ -1,3 +0,0 @@
FROM gcr.io/distroless/static
COPY glow /usr/local/bin/glow
ENTRYPOINT [ "/usr/local/bin/glow" ]

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2019-2024 Charmbracelet, Inc
Copyright (c) 2019 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

20
Makefile Normal file
View file

@ -0,0 +1,20 @@
# This Makefile is just for development purposes
.PHONY: default clean glow run log
LOGFILE := debug.log
default: glow
clean:
rm -f ./glow
glow:
go build
run: clean glow
GLOW_LOGFILE=$(LOGFILE) ./glow
log:
> $(LOGFILE)
tail -f $(LOGFILE)

142
README.md
View file

@ -7,11 +7,11 @@ Render markdown on the CLI, with _pizzazz_!
<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>
<a href="http://goreportcard.com/report/github.com/charmbracelet/glow"><img src="http://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">
<img src="https://stuff.charm.sh/glow/glow-1.3-trailer-github.gif" width="600" alt="Glow UI Demo">
</p>
## What is it?
@ -20,97 +20,51 @@ 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
line and stash markdown files to your own private collection so you can read
them anywhere. Glow will find local markdown files in subdirectories or a local
Git repository.
By the way, all data stashed is encrypted end-to-end: only you can decrypt it.
More on that below.
## Installation
### Package Manager
Use your fave package manager:
```bash
# macOS or Linux
brew install glow
```
```bash
# macOS (with MacPorts)
sudo port install glow
```
```bash
# Arch Linux (btw)
pacman -S glow
```
yay -S glow
```bash
# Void Linux
xbps-install -S glow
```
```bash
# Nix shell
nix-shell -p glow --command glow
```
# Nix
nix-env -iA nixpkgs.glow
```bash
# FreeBSD
pkg install glow
```
```bash
# Solus
eopkg install glow
```
```bash
# Windows (with Chocolatey, Scoop, or Winget)
choco install glow
# Windows (with Scoop)
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.
FreeBSD, and OpenBSD binaries are available, as well as Debian, RPM, and Alpine
packages. ARM builds are also available for 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+)
Or just build it yourself (requires Go 1.13+):
```bash
git clone https://github.com/charmbracelet/glow.git
@ -120,10 +74,11 @@ 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
browse local and stashed markdown. Glow will find local markdown files in the
current directory and below or, if youre in a Git repository, Glow will search
the repo.
@ -131,6 +86,15 @@ 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.
### Stashing
Glow works with the Charm Cloud to allow you to store any markdown files in
your own private collection. You can stash a local document from the Glow TUI by
pressing `s`.
Stashing is private, its contents will not be exposed publicly, and it's
encrypted end-to-end. More on encryption below.
## The CLI
In addition to a TUI, Glow has a CLI for working with Markdown. To format a
@ -141,7 +105,7 @@ document use a markdown source as the primary argument:
glow README.md
# Read from stdin
echo "[Glow](https://github.com/charmbracelet/glow)" | glow -
glow -
# Fetch README from GitHub / GitLab
glow github.com/charmbracelet/glow
@ -150,6 +114,18 @@ glow github.com/charmbracelet/glow
glow https://host.tld/file.md
```
### Stashing
You can also stash documents from the CLI:
```bash
glow stash README.md
```
Then, when you run `glow` without arguments will you can browse through your
stashed documents. This is a great way to keep track of things that you need to
reference often.
### Word Wrapping
The `-w` flag lets you set a maximum width at which the output will be wrapped:
@ -201,42 +177,36 @@ Here's an example config:
```yaml
# style name or JSON path (default "auto")
style: "light"
# mouse wheel support (TUI-mode only)
# show local files only; no network (TUI-mode only)
local: true
# mouse support (TUI-mode only)
mouse: true
# use pager to display markdown
pager: true
# at which column should we word wrap?
# word-wrap at width
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
## 🔒 Encryption: How It Works
See [contributing][contribute].
Encryption works by issuing symmetric keys (basically a generated password) and
encrypting it with the local SSH public key generated by the open-source
[charm][charmlib] library. That encrypted key is then sent up to our server.
We cant read it since we dont have your private key. When you want to decrypt
something or view your stash, that key is downloaded from our server and
decrypted locally using the SSH private key. When you link accounts, the
symmetric key is encrypted for each new public key. This happens on your
machine and not our server, so we never see any unencrypted data.
[contribute]: https://github.com/charmbracelet/glow/contribute
## Feedback
Wed 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)
[charmlib]: https://github.com/charmbracelet/charm
## 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>
<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge-unrounded.jpg" width="400"></a>
Charm热爱开源! / Charm loves open source!
Charm热爱开源 • Charm loves open source

View file

@ -1,23 +0,0 @@
# 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]

View file

@ -3,86 +3,82 @@ package main
import (
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path"
"path/filepath"
"github.com/charmbracelet/x/editor"
"github.com/charmbracelet/charm/ui/common"
gap "github.com/muesli/go-app-paths"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
const defaultConfig = `# style name or JSON path (default "auto")
style: "auto"
# show local files only; no network (TUI-mode only)
local: false
# 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
`
width: 80`
var configCmd = &cobra.Command{
Use: "config",
Hidden: false,
Short: "Edit the glow config file",
Long: paragraph(fmt.Sprintf("\n%s the glow config file. Well 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"),
Long: formatBlock(fmt.Sprintf("\n%s the glow config file. Well use EDITOR to determine which editor to use. If the config file doesn't exist, it will be created.", common.Keyword("Edit"))),
Example: formatBlock("glow config\nglow config --config path/to/config.yml"),
Args: cobra.NoArgs,
RunE: func(*cobra.Command, []string) error {
if err := ensureConfigFile(); err != nil {
RunE: func(cmd *cobra.Command, args []string) error {
editor := os.Getenv("EDITOR")
if editor == "" {
return errors.New("no EDITOR environment variable set")
}
if configFile == "" {
scope := gap.NewScope(gap.User, "glow")
var err error
configFile, err = scope.ConfigPath("glow.yml")
if err != nil {
return err
}
}
if ext := path.Ext(configFile); ext != ".yaml" && ext != ".yml" {
return fmt.Errorf("'%s' is not a supported config type: use '%s' or '%s'\n", ext, ".yaml", ".yml")
}
if _, err := os.Stat(configFile); os.IsNotExist(err) {
// File doesn't exist yet, create all necessary directories and
// write the default config file
if err := os.MkdirAll(path.Dir(configFile), 0700); err != nil {
return err
}
f, err := os.Create(configFile)
if err != nil {
return err
}
defer f.Close()
if _, err := f.WriteString(defaultConfig); err != nil {
return err
}
} else if err != nil { // some other error occurred
return err
}
c, err := editor.Cmd("Glow", configFile)
if err != nil {
return fmt.Errorf("unable to set config file: %w", err)
}
c := exec.Command(editor, configFile)
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)
return 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
}

15
formatting.go Normal file
View file

@ -0,0 +1,15 @@
package main
import (
"github.com/muesli/reflow/indent"
"github.com/muesli/reflow/wordwrap"
)
const (
wrapAt = 78
indentAmount = 2
)
func formatBlock(s string) string {
return indent.String(wordwrap.String(s, wrapAt-indentAmount), indentAmount)
}

View file

@ -1,55 +1,45 @@
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())
// isGitHubURL tests a string to determine if it is a well-structured GitHub URL.
func isGitHubURL(s string) (string, bool) {
if strings.HasPrefix(s, "github.com/") {
s = "https://" + s
}
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
u, err := url.ParseRequestURI(s)
if err != nil {
return nil, fmt.Errorf("unable to get url: %w", err)
return "", false
}
body, err := io.ReadAll(res.Body)
return u.String(), strings.ToLower(u.Host) == "github.com"
}
// findGitHubREADME tries to find the correct README filename in a repository.
func findGitHubREADME(s string) (*source, error) {
u, err := url.ParseRequestURI(s)
if err != nil {
return nil, fmt.Errorf("unable to read http response body: %w", err)
return nil, err
}
u.Host = "raw.githubusercontent.com"
var result readme
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("unable to parse json: %w", err)
}
for _, r := range readmeNames {
v := u
v.Path += "/master/" + r
if res.StatusCode == http.StatusOK {
//nolint:bodyclose
// it is closed on the caller
resp, err := http.Get(result.DownloadURL) //nolint: noctx
resp, err := http.Get(v.String())
if err != nil {
return nil, fmt.Errorf("unable to get url: %w", err)
return nil, err
}
if resp.StatusCode == http.StatusOK {
return &source{resp.Body, result.DownloadURL}, nil
return &source{resp.Body, v.String()}, nil
}
}

View file

@ -1,59 +1,44 @@
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())
// isGitLabURL tests a string to determine if it is a well-structured GitLab URL.
func isGitLabURL(s string) (string, bool) {
if strings.HasPrefix(s, "gitlab.com/") {
s = "https://" + s
}
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
u, err := url.ParseRequestURI(s)
if err != nil {
return nil, fmt.Errorf("unable to get url: %w", err)
return "", false
}
body, err := io.ReadAll(res.Body)
return u.String(), strings.ToLower(u.Host) == "gitlab.com"
}
// findGitLabREADME tries to find the correct README filename in a repository.
func findGitLabREADME(s string) (*source, error) {
u, err := url.ParseRequestURI(s)
if err != nil {
return nil, fmt.Errorf("unable to read http response body: %w", err)
return nil, err
}
var result readme
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("unable to parse json: %w", err)
}
for _, r := range readmeNames {
v := u
v.Path += "/raw/master/" + r
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
resp, err := http.Get(v.String())
if err != nil {
return nil, fmt.Errorf("unable to get url: %w", err)
return nil, err
}
if resp.StatusCode == http.StatusOK {
return &source{resp.Body, readmeRawURL}, nil
return &source{resp.Body, v.String()}, nil
}
}

View file

@ -1,9 +1,31 @@
package main
import (
"bytes"
"testing"
)
func TestGlowSources(t *testing.T) {
tt := []string{
".",
"README.md",
"github.com/charmbracelet/glow",
"https://github.com/charmbracelet/glow",
}
for _, v := range tt {
buf := &bytes.Buffer{}
err := executeArg(rootCmd, v, buf)
if err != nil {
t.Errorf("Error during execution (args: %s): %v", v, err)
}
if buf.Len() == 0 {
t.Errorf("Output buffer should not be empty (args: %s)", v)
}
}
}
func TestGlowFlags(t *testing.T) {
tt := []struct {
args []string

94
go.mod
View file

@ -1,75 +1,29 @@
module github.com/charmbracelet/glow/v2
module github.com/charmbracelet/glow
go 1.25.12
toolchain go1.26.5
go 1.13
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/charmbracelet/bubbles v0.7.6
github.com/charmbracelet/bubbletea v0.13.2
github.com/charmbracelet/charm v0.8.6
github.com/charmbracelet/glamour v0.2.1-0.20210402234443-abe9cda419ba
github.com/dustin/go-humanize v1.0.1-0.20200219035652-afde56e7acac
github.com/google/uuid v1.1.2 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-runewidth v0.0.12
github.com/meowgorithm/babyenv v1.3.1
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.43.0
golang.org/x/term v0.42.0
golang.org/x/text v0.39.0
mvdan.cc/sh/v3 v3.13.1
)
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/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.17 // 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.53.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
github.com/muesli/gitcha v0.2.0
github.com/muesli/go-app-paths v0.2.1
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68
github.com/muesli/termenv v0.8.1
github.com/sahilm/fuzzy v0.1.0
github.com/segmentio/ksuid v1.0.3
github.com/spf13/cobra v1.1.3
github.com/spf13/viper v1.7.1
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc // indirect
golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect
golang.org/x/text v0.3.2
)

574
go.sum
View file

@ -1,177 +1,445 @@
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=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
github.com/alecthomas/chroma v0.8.2 h1:x3zkuE2lUk/RIekyAJ3XRqSCP4zwWDfcw/YJCuCAACg=
github.com/alecthomas/chroma v0.8.2/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=
github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
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/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
github.com/calmh/randomart v1.1.0 h1:evl+iwc10LXtHdMZhzLxmsCQVmWnkXs44SbC6Uk0Il8=
github.com/calmh/randomart v1.1.0/go.mod h1:DQUbPVyP+7PAs21w/AnfMKG5NioxS3TbZ2F9MSK/jFM=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/charmbracelet/bubbles v0.7.5/go.mod h1:IRTORFvhEI6OUH7WhN2Ks8Z8miNGimk1BE6cmHijOkM=
github.com/charmbracelet/bubbles v0.7.6 h1:SCAp4ZEUf2tBNEsufo+Xxxu2dvbFhYSDPrX45toQZrM=
github.com/charmbracelet/bubbles v0.7.6/go.mod h1:0D4XRYK0tjo8JMvflz1obpVcOikNZSG46SFauoZj22s=
github.com/charmbracelet/bubbletea v0.12.2/go.mod h1:3gZkYELUOiEUOp0bTInkxguucy/xRbGSOcbMs1geLxg=
github.com/charmbracelet/bubbletea v0.13.2 h1:fSOx3q0/VbA3ChWeiNcUsNeNysD9FFWD1tZypShBuCQ=
github.com/charmbracelet/bubbletea v0.13.2/go.mod h1:okqaA5VF0aSpEZ2HB+L/cxVw2HthIDZ1dmWoRZs8/4g=
github.com/charmbracelet/charm v0.8.6 h1:/U6rxGj4J6zZ1Ex8+wTr4hNMr4ESBzNZbC1UyrJPVbg=
github.com/charmbracelet/charm v0.8.6/go.mod h1:8dE3uX+TYSpa7Q6e/CmjN6WSd7koSAKNQTGWugFREx4=
github.com/charmbracelet/glamour v0.2.1-0.20210402234443-abe9cda419ba h1:smKYYwwVPZyMK2LCirIi2WY25tZZW0IU7GYe1ASGCe4=
github.com/charmbracelet/glamour v0.2.1-0.20210402234443-abe9cda419ba/go.mod h1:nHP5wEbsv2eOJ7XfiScQV3p5dpZSM051R0VkxnOIPgg=
github.com/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU=
github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc=
github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw=
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
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-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk=
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dustin/go-humanize v1.0.1-0.20200219035652-afde56e7acac h1:opbrjaN/L8gg6Xh5D04Tem+8xVcz6ajZlGCs49mQgyg=
github.com/dustin/go-humanize v1.0.1-0.20200219035652-afde56e7acac/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
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.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
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/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow=
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/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/meowgorithm/babyenv v1.3.0/go.mod h1:lwNX+J6AGBFqNrMZ2PTLkM6SO+W4X8DOg9zBDO4j3Ig=
github.com/meowgorithm/babyenv v1.3.1 h1:18ZEYIgbzoFQfRLF9+lxjRfk/ui6w8U0FWl07CgWvvc=
github.com/meowgorithm/babyenv v1.3.1/go.mod h1:lwNX+J6AGBFqNrMZ2PTLkM6SO+W4X8DOg9zBDO4j3Ig=
github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg=
github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE=
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a/go.mod h1:v8eSC2SMp9/7FTKUncp7fH9IwPfw+ysMObcEz5FWheQ=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
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/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/muesli/gitcha v0.2.0 h1:+wOgT2dI9s2Tznj1t1rb/qkK5e0cb6qD8c4IX2TR/YY=
github.com/muesli/gitcha v0.2.0/go.mod h1:Ri8m9TZS4+ORG4JVmVKUQcWZuxDvUW3UKxMdQfzG2zI=
github.com/muesli/go-app-paths v0.2.1 h1:Qi+2igkDX2aPqyRddp7P0sMQIBwBqhkfQfNcjdGjL6Y=
github.com/muesli/go-app-paths v0.2.1/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho=
github.com/muesli/reflow v0.1.0/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I=
github.com/muesli/reflow v0.2.0/go.mod h1:qT22vjVmM9MIUeLgsVYe/Ye7eZlbv9dZjL3dVhUqLX8=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9FnmSjdbWUlLGvcxrY0Rw3ATltrxOhk=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/sasquatch v0.0.0-20200811221207-66979d92330a h1:Hw/15RYEOUD6T9UCRkUmNBa33kJkH33Fui6hE4sRLKU=
github.com/muesli/sasquatch v0.0.0-20200811221207-66979d92330a/go.mod h1:+XG0ne5zXWBTSbbe7Z3/RWxaT8PZY6zaZ1dX6KjprYY=
github.com/muesli/termenv v0.7.2/go.mod h1:ct2L5N2lmix82RaY3bMWwVu/jUFc9Ule0KGDCiKYPh8=
github.com/muesli/termenv v0.7.4/go.mod h1:pZ7qY9l3F7e5xsAOS0zCew2tME+p7bWeBkotCEcIIcc=
github.com/muesli/termenv v0.8.1 h1:9q230czSP3DHVpkaPDXGp0TOfAwyjyYwXlUCQxQSaBk=
github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
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/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/segmentio/ksuid v1.0.3 h1:FoResxvleQwYiPAVKe1tMUlEirodZqlqglIuFsdDntY=
github.com/segmentio/ksuid v1.0.3/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M=
github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk=
github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
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.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.1 h1:eVwehsLsZlCJCwXyGLgg+Q4iFWE/eTIMG0e8waCmm/I=
github.com/yuin/goldmark v1.3.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc h1:zK/HqS5bZxDptfPJNq8v7vJfXtkU7r9TLIoSr1bXaP4=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78 h1:nVuTkr9L6Bq62qpUqKo/RnZCFfzDBL0bYo6w9OJUqZY=
golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M=
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
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/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
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=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=

40
log.go
View file

@ -1,40 +0,0 @@
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
}

399
main.go
View file

@ -1,11 +1,10 @@
// Package main provides the entry point for the Glow CLI application.
package main
import (
"errors"
"fmt"
"io"
"io/fs"
"io/ioutil"
"net/http"
"net/url"
"os"
@ -13,55 +12,40 @@ import (
"path/filepath"
"strings"
"mvdan.cc/sh/v3/shell"
"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"
"github.com/meowgorithm/babyenv"
gap "github.com/muesli/go-app-paths"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/term"
"golang.org/x/crypto/ssh/terminal"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/charm/ui/common"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glow/ui"
"github.com/charmbracelet/glow/utils"
)
var (
// Version as provided by goreleaser.
Version = ""
// CommitSHA as provided by goreleaser.
Version = ""
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
readmeNames = []string{"README.md", "README"}
configFile string
pager bool
style string
width uint
showAllFiles bool
localOnly 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")),
),
Use: "glow [SOURCE|DIR]",
Short: "Render markdown on the CLI, with pizzazz!",
Long: formatBlock(fmt.Sprintf("\nRender markdown on the CLI, %s!", common.Keyword("with pizzazz"))),
SilenceErrors: false,
SilenceUsage: true,
SilenceUsage: false,
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,
RunE: execute,
}
)
@ -79,22 +63,31 @@ func sourceFromArg(arg string) (*source, error) {
}
// 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...
if u, ok := isGitHubURL(arg); ok {
src, err := findGitHubREADME(u)
if err != nil {
return nil, err
}
return src, nil
}
if u, ok := isGitLabURL(arg); ok {
src, err := findGitLabREADME(u)
if err != nil {
return nil, err
}
return src, nil
}
// HTTP(S) URLs:
if u, err := url.ParseRequestURI(arg); err == nil && strings.Contains(arg, "://") { //nolint:nestif
if u, err := url.ParseRequestURI(arg); err == nil && strings.Contains(arg, "://") {
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
resp, err := http.Get(u.String())
if err != nil {
return nil, fmt.Errorf("unable to get url: %w", err)
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP status %d", resp.StatusCode)
@ -109,12 +102,9 @@ func sourceFromArg(arg string) (*source, error) {
arg = "."
}
st, err := os.Stat(arg)
if err == nil && st.IsDir() { //nolint:nestif
if err == nil && st.IsDir() {
var src *source
_ = filepath.Walk(arg, func(path string, _ os.FileInfo, err error) error {
if err != nil {
return err
}
_ = filepath.Walk(arg, func(path string, info os.FileInfo, err error) error {
for _, v := range readmeNames {
if strings.EqualFold(filepath.Base(path), v) {
r, err := os.Open(path)
@ -139,52 +129,31 @@ func sourceFromArg(arg string) (*source, error) {
return nil, errors.New("missing markdown source")
}
// a file:
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
u, _ := filepath.Abs(arg)
return &source{r, u}, err
}
func validateOptions(cmd *cobra.Command) error {
// grab config values from Viper
width = viper.GetUint("width")
localOnly = viper.GetBool("local")
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
if style != "auto" && glamour.DefaultStyles[style] == nil {
style = utils.ExpandPath(style)
if _, err := os.Stat(style); os.IsNotExist(err) {
return fmt.Errorf("Specified style does not exist: %s", style)
} else if err != nil {
return err
}
}
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
isTerminal := terminal.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") {
@ -192,50 +161,33 @@ func validateOptions(cmd *cobra.Command) error {
}
// 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 isTerminal && width == 0 && !cmd.Flags().Changed("width") {
w, _, err := terminal.GetSize(int(os.Stdout.Fd()))
if err == nil {
width = uint(w)
}
if width > 120 {
width = 120
}
}
if width == 0 {
width = 80
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 {
initConfig()
if err := validateOptions(cmd); 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("", "")
return runTUI("", false)
// TUI with possible dir argument
case 1:
@ -245,7 +197,7 @@ func execute(cmd *cobra.Command, args []string) error {
if err == nil && info.IsDir() {
p, err := filepath.Abs(args[0])
if err == nil {
return runTUI(p, "")
return runTUI(p, false)
}
}
fallthrough
@ -268,14 +220,10 @@ func executeArg(cmd *cobra.Command, arg string, w io.Writer) error {
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)
defer src.reader.Close()
b, err := ioutil.ReadAll(src.reader)
if err != nil {
return fmt.Errorf("unable to read from reader: %w", err)
return err
}
b = utils.RemoveFrontmatter(b)
@ -288,106 +236,110 @@ func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error {
baseURL = u.String() + "/"
}
isCode := !utils.IsMarkdownFile(src.URL)
// initialize glamour
var gs glamour.TermRendererOption
if style == "auto" {
gs = glamour.WithEnvironmentConfig()
} else {
gs = glamour.WithStylePath(style)
}
r, err := glamour.NewTermRenderer(
glamour.WithColorProfile(lipgloss.ColorProfile()),
utils.GlamourStyle(style, isCode),
glamour.WithWordWrap(int(width)), //nolint:gosec
gs,
glamour.WithWordWrap(int(width)),
glamour.WithBaseURL(baseURL),
glamour.WithPreservedNewLines(),
)
if err != nil {
return fmt.Errorf("unable to create renderer: %w", err)
return err
}
content := string(b)
ext := filepath.Ext(src.URL)
if isCode {
content = utils.WrapCodeBlock(string(b), ext)
}
out, err := r.Render(content)
out, err := r.RenderBytes(b)
if err != nil {
return fmt.Errorf("unable to render markdown: %w", err)
return err
}
// trim lines
lines := strings.Split(string(out), "\n")
var content string
for i, s := range lines {
content += strings.TrimSpace(s)
// don't add an artificial newline after the last split
if i+1 < len(lines) {
content += "\n"
}
}
// display
switch {
case pager || cmd.Flags().Changed("pager"):
if pager || cmd.Flags().Changed("pager") {
pagerCmd := os.Getenv("PAGER")
if pagerCmd == "" {
pagerCmd = "less -r"
}
fields, err := shell.Fields(pagerCmd, os.Getenv)
if err != nil || len(fields) == 0 {
return fmt.Errorf("unable to parse PAGER command: %s", pagerCmd)
}
c := exec.Command(fields[0], fields[1:]...) //nolint:gosec
c.Stdin = strings.NewReader(out)
pa := strings.Split(pagerCmd, " ")
c := exec.Command(pa[0], pa[1:]...)
c.Stdin = strings.NewReader(content)
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
return c.Run()
}
fmt.Fprint(w, content)
return nil
}
func runTUI(path string, content string) error {
func runTUI(workingDirectory string, stashedOnly bool) error {
// Read environment to get debugging stuff
cfg, err := env.ParseAs[ui.Config]()
if err != nil {
var cfg ui.Config
if err := babyenv.Parse(&cfg); 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
// Log to file, if set
if cfg.Logfile != "" {
f, err := tea.LogToFile(cfg.Logfile, "glow")
if err != nil {
return err
}
defer f.Close()
}
cfg.Path = path
cfg.WorkingDirectory = workingDirectory
cfg.DocumentTypes = ui.NewDocTypeSet()
cfg.ShowAllFiles = showAllFiles
cfg.ShowLineNumbers = showLineNumbers
cfg.GlamourMaxWidth = width
cfg.EnableMouse = mouse
cfg.PreserveNewLines = preserveNewLines
cfg.GlamourStyle = style
if stashedOnly {
cfg.DocumentTypes.Add(ui.StashedDoc, ui.NewsDoc)
} else if localOnly {
cfg.DocumentTypes.Add(ui.LocalDoc)
}
// Run Bubble Tea program
if _, err := ui.NewProgram(cfg, content).Run(); err != nil {
return fmt.Errorf("unable to run tui program: %w", err)
p := ui.NewProgram(cfg)
p.EnterAltScreen()
defer p.ExitAltScreen()
if mouse {
p.EnableMouseCellMotion()
defer p.DisableMouseCellMotion()
}
if err := p.Start(); err != nil {
return err
}
// Exit message
fmt.Printf("\n Thanks for using Glow!\n\n")
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")
@ -396,78 +348,63 @@ func init() {
Version = "unknown (built from source)"
}
rootCmd.Version = Version
rootCmd.InitDefaultCompletionCmd()
scope := gap.NewScope(gap.User, "glow")
defaultConfigFile, _ := scope.ConfigPath("glow.yml")
// "Glow Classic" cli arguments
rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default %s)", viper.GetViper().ConfigFileUsed()))
rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default %s)", defaultConfigFile))
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().StringVarP(&style, "style", "s", "auto", "style name or JSON path")
rootCmd.Flags().UintVarP(&width, "width", "w", 0, "word-wrap at width")
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(&localOnly, "local", "l", false, "show local files only; no network (TUI-mode only)")
rootCmd.Flags().BoolVarP(&mouse, "mouse", "m", false, "enable mouse wheel (TUI-mode only)")
_ = rootCmd.Flags().MarkHidden("mouse")
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("local", rootCmd.Flags().Lookup("local"))
_ = 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("style", "auto")
viper.SetDefault("width", 0)
viper.SetDefault("all", true)
viper.SetDefault("local", "false")
rootCmd.AddCommand(configCmd, manCmd)
// Stash
stashCmd.PersistentFlags().StringVarP(&memo, "memo", "m", "", "memo/note for stashing")
rootCmd.AddCommand(stashCmd)
rootCmd.AddCommand(configCmd)
}
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)
func initConfig() {
if configFile != "" {
viper.SetConfigFile(configFile)
} else {
scope := gap.NewScope(gap.User, "glow")
dirs, err := scope.ConfigDirs()
if err != nil {
fmt.Println("Can't retrieve default config. Please manually pass a config file with '--config'")
os.Exit(1)
}
for _, v := range dirs {
viper.AddConfigPath(v)
}
viper.SetConfigName("glow")
viper.SetConfigType("yaml")
}
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)
fmt.Println("Error parsing config:", err)
os.Exit(1)
}
}
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)
}
// fmt.Println("Using config file:", viper.ConfigFileUsed())
}

View file

@ -1,29 +0,0 @@
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
},
}

83
stash_cmd.go Normal file
View file

@ -0,0 +1,83 @@
package main
import (
"fmt"
"io/ioutil"
"log"
"os"
"path"
"strings"
"github.com/charmbracelet/charm"
"github.com/charmbracelet/charm/ui/common"
"github.com/muesli/termenv"
"github.com/spf13/cobra"
)
var (
memo string
stashCmd = &cobra.Command{
Use: "stash [SOURCE]",
Hidden: false,
Short: "Stash a markdown",
Long: formatBlock(fmt.Sprintf("\nDo %s stuff. Run with no arguments to browse your stash or pass a path to a markdown file to stash it.", common.Keyword("stash"))),
Example: formatBlock("glow stash\nglow stash README.md\nglow stash -m \"secret notes\" path/to/notes.md"),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
initConfig()
if len(args) == 0 {
return runTUI("", true)
}
filePath := args[0]
if memo == "" {
memo = strings.Replace(path.Base(filePath), path.Ext(filePath), "", 1)
}
cc := initCharmClient()
f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("bad filename")
}
defer f.Close()
b, err := ioutil.ReadAll(f)
if err != nil {
return fmt.Errorf("error reading file")
}
_, err = cc.StashMarkdown(memo, string(b))
if err != nil {
return fmt.Errorf("error stashing markdown")
}
dot := termenv.String("•").Foreground(common.Green.Color()).String()
fmt.Println(dot + " Stashed!")
return nil
},
}
)
func getCharmConfig() *charm.Config {
cfg, err := charm.ConfigFromEnv()
if err != nil {
log.Fatal(err)
}
return cfg
}
func initCharmClient() *charm.Client {
cfg := getCharmConfig()
cc, err := charm.NewClient(cfg)
if err == charm.ErrMissingSSHAuth {
fmt.Println(formatBlock("We had some trouble authenticating via SSH. If this continues to happen the Charm tool may be able to help you. More info at https://github.com/charmbracelet/charm."))
os.Exit(1)
} else if err != nil {
fmt.Println(err)
os.Exit(1)
}
return cc
}

View file

@ -1,14 +0,0 @@
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
)

View file

@ -2,19 +2,32 @@ 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
ShowAllFiles bool
Gopath string `env:"GOPATH"`
HomeDir string `env:"HOME"`
GlamourMaxWidth uint
GlamourStyle string
// Working directory or file path
Path string
// Which directory should we start from?
WorkingDirectory string
// Which document types shall we show?
DocumentTypes DocTypeSet
// For debugging the UI
HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" envDefault:"true"`
GlamourEnabled bool `env:"GLOW_ENABLE_GLAMOUR" envDefault:"true"`
Logfile string `env:"GLOW_LOGFILE"`
HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" default:"true"`
GlamourEnabled bool `env:"GLOW_ENABLE_GLAMOUR" default:"true"`
}
func (c Config) showLocalFiles() bool {
return c.DocumentTypes.Contains(LocalDoc)
}
func (c Config) localOnly() bool {
return c.DocumentTypes.Equals(NewDocTypeSet(LocalDoc))
}
func (c Config) stashedOnly() bool {
return c.DocumentTypes.Contains(StashedDoc) && !c.DocumentTypes.Contains(LocalDoc)
}

7
ui/consts_unix.go Normal file
View file

@ -0,0 +1,7 @@
// +build !windows
package ui
const (
pagerStashIcon = "🔒"
)

7
ui/consts_windows.go Normal file
View file

@ -0,0 +1,7 @@
// +build windows
package ui
const (
pagerStashIcon = "•"
)

90
ui/doctypes.go Normal file
View file

@ -0,0 +1,90 @@
package ui
// DocType represents a type of markdown document.
type DocType int
// Available document types.
const (
NoDocType DocType = iota
LocalDoc
StashedDoc
ConvertedDoc
NewsDoc
)
func (d DocType) String() string {
return [...]string{
"none",
"local",
"stashed",
"converted",
"news",
}[d]
}
// DocTypeSet is a set (in the mathematic sense) of document types.
type DocTypeSet map[DocType]struct{}
// NewDocTypeSet returns a set of document types.
func NewDocTypeSet(t ...DocType) DocTypeSet {
d := DocTypeSet(make(map[DocType]struct{}))
if len(t) > 0 {
d.Add(t...)
}
return d
}
// Add adds a document type of the set.
func (d *DocTypeSet) Add(t ...DocType) int {
for _, v := range t {
(*d)[v] = struct{}{}
}
return len(*d)
}
// Contains returns whether or not the set contains the given DocTypes.
func (d DocTypeSet) Contains(m ...DocType) bool {
matches := 0
for _, t := range m {
if _, found := d[t]; found {
matches++
}
}
return matches == len(m)
}
// Difference return a DocumentType set that does not contain the given types.
func (d DocTypeSet) Difference(t ...DocType) DocTypeSet {
c := copyDocumentTypes(d)
for k := range c {
for _, docType := range t {
if k == docType {
delete(c, k)
break
}
}
}
return c
}
// Equals returns whether or not the two sets are equal.
func (d DocTypeSet) Equals(other DocTypeSet) bool {
return d.Contains(other.AsSlice()...) && len(d) == len(other)
}
// AsSlice returns the set as a slice of document types.
func (d DocTypeSet) AsSlice() (agg []DocType) {
for k := range d {
agg = append(agg, k)
}
return
}
// Return a copy of the given DoctTypes map.
func copyDocumentTypes(d DocTypeSet) DocTypeSet {
c := make(map[DocType]struct{})
for k, v := range d {
c[k] = v
}
return c
}

48
ui/doctypes_test.go Normal file
View file

@ -0,0 +1,48 @@
package ui
import (
"reflect"
"testing"
)
func TestDocTypeContains(t *testing.T) {
d := NewDocTypeSet(LocalDoc)
if !d.Contains(LocalDoc) {
t.Error("Contains reported it doesn't contain a value which it absolutely does contain")
}
if d.Contains(NewsDoc) {
t.Error("Contains reported the set contains a value it certainly does not")
}
}
func TestDocTypeDifference(t *testing.T) {
original := NewDocTypeSet(LocalDoc, StashedDoc, ConvertedDoc, NewsDoc)
difference := original.Difference(LocalDoc, NewsDoc)
expected := NewDocTypeSet(StashedDoc, ConvertedDoc)
// Make sure the difference operation worked
if !reflect.DeepEqual(difference, expected) {
t.Errorf("difference returned %+v; expected %+v", difference, expected)
}
// Make sure original set was not mutated
if reflect.DeepEqual(original, difference) {
t.Errorf("original set was mutated when it should not have been")
}
}
func TestDocTypeEquality(t *testing.T) {
a := NewDocTypeSet(LocalDoc, StashedDoc)
b := NewDocTypeSet(LocalDoc, StashedDoc)
c := NewDocTypeSet(LocalDoc)
if !a.Equals(b) {
t.Errorf("Equality test failed for %+v and %+v; expected true, got false", a, b)
}
if a.Equals(c) {
t.Errorf("Equality test failed for %+v and %+v; expected false, got true", a, c)
}
}

View file

@ -1,19 +0,0 @@
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)
}

View file

@ -1,14 +1,13 @@
//go:build darwin
// +build darwin
package ui
import "path/filepath"
func ignorePatterns(m commonModel) []string {
func ignorePatterns(m model) []string {
return []string{
filepath.Join(m.cfg.HomeDir, "Library"),
m.cfg.Gopath,
filepath.Join(m.common.cfg.HomeDir, "Library"),
m.common.cfg.Gopath,
"node_modules",
".*",
}

View file

@ -1,11 +1,10 @@
//go:build !darwin
// +build !darwin
package ui
func ignorePatterns(m commonModel) []string {
func ignorePatterns(m model) []string {
return []string{
m.cfg.Gopath,
m.common.cfg.Gopath,
"node_modules",
".*",
}

View file

@ -1,6 +0,0 @@
package ui
const (
keyEnter = "enter"
keyEsc = "esc"
)

View file

@ -1,19 +1,42 @@
package ui
import (
"fmt"
"log"
"math"
"path"
"strings"
"time"
"unicode"
"github.com/charmbracelet/log"
"github.com/charmbracelet/charm"
"github.com/dustin/go-humanize"
"github.com/segmentio/ksuid"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
// markdown wraps charm.Markdown.
type markdown struct {
docType DocType
// Stash identifier. This exists so we can keep track of documents stashed
// in-session as they relate to their original, non-stashed counterparts.
// All documents have a stashID, however when a document is stashed that
// document inherits the stashID of the original.
stashID ksuid.KSUID
// Unique identifier. Unlike the stash identifier, this value should always
// be unique so we can confidently find it an operate on it (versus stashID,
// which could match both an original or stashed document).
uniqueID ksuid.KSUID
// Some of the document's original values before this document was stashed.
// These are irrelevant if this document was not stashed in this session.
originalDocType DocType
originalTimestamp time.Time
originalNote string
// Full path of a local markdown file. Only relevant to local documents and
// those that have been stashed in this session.
localPath string
@ -23,24 +46,102 @@ type markdown struct {
// field is ephemeral, and should only be referenced during filtering.
filterValue string
Body string
Note string
Modtime time.Time
charm.Markdown
}
func (m *markdown) generateIDs() {
if m.stashID.IsNil() {
m.stashID = ksuid.New()
}
m.uniqueID = ksuid.New()
}
// convertToStashed converts this document into its stashed state.
func (m *markdown) convertToStashed() {
if m.docType == ConvertedDoc {
if debug {
log.Println("not converting already converted document:", m)
}
return
}
m.originalDocType = m.docType
m.originalTimestamp = m.CreatedAt
m.originalNote = m.Note
if m.docType == LocalDoc {
m.Note = strings.Replace(path.Base(m.localPath), path.Ext(m.localPath), "", 1)
}
m.CreatedAt = time.Now()
m.docType = ConvertedDoc
}
// revert reverts this document from its stashed state.
func (m *markdown) revertFromStashed() {
if m.docType != ConvertedDoc {
log.Printf("not reverting document of type %s: %v", m.docType, m)
}
m.docType = m.originalDocType
m.CreatedAt = m.originalTimestamp
m.Note = m.originalNote
}
// 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)
if debug {
log.Printf("error normalizing '%s': %v", m.Note, err)
}
m.filterValue = m.Note
}
m.filterValue = note
}
// shouldSortAsLocal returns whether or not this markdown should be sorted as though
// it's a local markdown document.
func (m markdown) shouldSortAsLocal() bool {
return m.docType == LocalDoc || m.docType == ConvertedDoc
}
// Sort documents with local files first, then by date.
type markdownsByLocalFirst []*markdown
func (m markdownsByLocalFirst) Len() int { return len(m) }
func (m markdownsByLocalFirst) Swap(i, j int) { m[i], m[j] = m[j], m[i] }
func (m markdownsByLocalFirst) Less(i, j int) bool {
iIsLocal := m[i].shouldSortAsLocal()
jIsLocal := m[j].shouldSortAsLocal()
// Local files (and files that used to be local) come first
if iIsLocal && !jIsLocal {
return true
}
if !iIsLocal && jIsLocal {
return false
}
// If both are local files, sort by filename. Note that we should never
// hit equality here since two files can't have the same path.
if iIsLocal && jIsLocal {
return strings.Compare(m[i].localPath, m[j].localPath) == -1
}
// Neither are local files so sort by date descending
if !m[i].CreatedAt.Equal(m[j].CreatedAt) {
return m[i].CreatedAt.After(m[j].CreatedAt)
}
// If the times also match, sort by unqiue ID.
ids := []ksuid.KSUID{m[i].uniqueID, m[j].uniqueID}
ksuid.Sort(ids)
return ids[0] == m[i].uniqueID
}
func (m markdown) relativeTime() string {
return relativeTime(m.Modtime)
return relativeTime(m.CreatedAt)
}
// Normalize text to aid in the filtering process. In particular, we remove
@ -49,16 +150,26 @@ func (m markdown) relativeTime() string {
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, err
}
// wrapMarkdowns wraps a *charm.Markdown with a *markdown in order to add some
// extra metadata.
func wrapMarkdowns(t DocType, md []*charm.Markdown) (m []*markdown) {
for _, v := range md {
m = append(m, &markdown{
docType: t,
Markdown: *v,
})
}
return out, nil
return m
}
// 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 {
ago := now.Sub(then)
if ago < time.Minute {
return "just now"
} else if ago < humanize.Week {
return humanize.CustomRelTime(then, now, "ago", "from now", magnitudes)

View file

@ -2,98 +2,72 @@ package ui
import (
"fmt"
"log"
"math"
"path/filepath"
"strings"
"time"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/charm"
lib "github.com/charmbracelet/charm/ui/common"
"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"
te "github.com/muesli/termenv"
)
const (
statusBarHeight = 1
lineNumberWidth = 4
)
const statusBarHeight = 1
var (
pagerHelpHeight int
mintGreen = lipgloss.AdaptiveColor{Light: "#89F0CB", Dark: "#89F0CB"}
darkGreen = lipgloss.AdaptiveColor{Light: "#1C8760", Dark: "#1C8760"}
mintGreen = lib.NewColorPair("#89F0CB", "#89F0CB")
darkGreen = lib.NewColorPair("#1C8760", "#1C8760")
lineNumberFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
noteHeading = te.String(" Set Memo ").
Foreground(lib.Cream.Color()).
Background(lib.Green.Color()).
String()
statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
statusBarBg = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"}
statusBarNoteFg = lib.NewColorPair("#7D7D7D", "#656565")
statusBarBg = lib.NewColorPair("#242424", "#E6E6E6")
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
// Styling funcs.
statusBarScrollPosStyle = newStyle(lib.NewColorPair("#5A5A5A", "#949494"), statusBarBg, false)
statusBarNoteStyle = newStyle(statusBarNoteFg, statusBarBg, false)
statusBarHelpStyle = newStyle(statusBarNoteFg, lib.NewColorPair("#323232", "#DCDCDC"), false)
statusBarStashDotStyle = newStyle(lib.Green, statusBarBg, false)
statusBarMessageStyle = newStyle(mintGreen, darkGreen, false)
statusBarMessageStashIconStyle = newStyle(mintGreen, darkGreen, false)
statusBarMessageScrollPosStyle = newStyle(mintGreen, darkGreen, false)
statusBarMessageHelpStyle = newStyle(lib.NewColorPair("#B6FFE4", "#B6FFE4"), lib.Green, false)
helpViewStyle = newStyle(statusBarNoteFg, lib.NewColorPair("#1B1B1B", "#f2f2f2"), false)
)
type (
contentRenderedMsg string
reloadMsg struct{}
)
type contentRenderedMsg string
type noteSavedMsg *charm.Markdown
type pagerState int
const (
pagerStateBrowse pagerState = iota
pagerStateSetNote
pagerStateStashing
pagerStateStashSuccess
pagerStateStatusMessage
)
type pagerModel struct {
common *commonModel
viewport viewport.Model
state pagerState
showHelp bool
common *commonModel
viewport viewport.Model
state pagerState
showHelp bool
textInput textinput.Model
spinner spinner.Model
statusMessage string
statusMessageTimer *time.Timer
@ -102,27 +76,51 @@ type pagerModel struct {
// it here so we can re-render it on resize.
currentDocument markdown
watcher *fsnotify.Watcher
// Newly stashed markdown. We store it here temporarily so we can replace
// currentDocument above after a stash.
stashedDocument *markdown
}
func newPagerModel(common *commonModel) pagerModel {
// Init viewport
vp := viewport.New(0, 0)
vp := viewport.Model{}
vp.YPosition = 0
vp.HighPerformanceRendering = config.HighPerformancePager
m := pagerModel{
common: common,
state: pagerStateBrowse,
viewport: vp,
// Text input for notes/memos
ti := textinput.NewModel()
ti.Prompt = te.String(" > ").
Foreground(lib.Color(darkGray)).
Background(lib.YellowGreen.Color()).
String()
ti.TextColor = darkGray
ti.BackgroundColor = lib.YellowGreen.String()
ti.CursorColor = lib.Fuschia.String()
ti.CharLimit = noteCharacterLimit
ti.Focus()
// Text input for search
sp := spinner.NewModel()
sp.ForegroundColor = statusBarNoteFg.String()
sp.BackgroundColor = statusBarBg.String()
sp.HideFor = time.Millisecond * 50
sp.MinimumLifetime = time.Millisecond * 180
return pagerModel{
common: common,
state: pagerStateBrowse,
textInput: ti,
viewport: vp,
spinner: sp,
}
m.initWatcher()
return m
}
func (m *pagerModel) setSize(w, h int) {
m.viewport.Width = w
m.viewport.Height = h - statusBarHeight
m.textInput.Width = w -
ansi.PrintableRuneWidth(noteHeading) -
ansi.PrintableRuneWidth(m.textInput.Prompt) - 1
if m.showHelp {
if pagerHelpHeight == 0 {
@ -144,18 +142,13 @@ func (m *pagerModel) toggleHelp() {
}
}
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
// that the the returned command should be sent back the through the pager
// update function.
func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd {
func (m *pagerModel) showStatusMessage(statusMessage string) tea.Cmd {
// Show a success message to the user
m.state = pagerStateStatusMessage
m.statusMessage = msg.message
m.statusMessage = statusMessage
if m.statusMessageTimer != nil {
m.statusMessageTimer.Stop()
}
@ -165,7 +158,6 @@ func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd {
}
func (m *pagerModel) unload() {
log.Debug("unload")
if m.showHelp {
m.toggleHelp()
}
@ -175,7 +167,7 @@ func (m *pagerModel) unload() {
m.state = pagerStateBrowse
m.viewport.SetContent("")
m.viewport.YOffset = 0
m.unwatchFile()
m.textInput.Reset()
}
func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
@ -186,95 +178,156 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", keyEsc:
if m.state != pagerStateBrowse {
switch m.state {
case pagerStateSetNote:
switch msg.String() {
case "esc":
m.state = pagerStateBrowse
return m, nil
case "enter":
var cmd tea.Cmd
if m.textInput.Value() != m.currentDocument.Note { // don't update if the note didn't change
m.currentDocument.Note = m.textInput.Value() // update optimistically
cmd = saveDocumentNote(m.common.cc, m.currentDocument.ID, m.currentDocument.Note)
}
m.state = pagerStateBrowse
m.textInput.Reset()
return m, cmd
}
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))
}
default:
switch msg.String() {
case "q", "esc":
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 "m":
isStashed := m.currentDocument.docType == StashedDoc ||
m.currentDocument.docType == ConvertedDoc
case "d":
m.viewport.HalfViewDown()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
// Users can only set the note on user-stashed markdown
if !isStashed {
break
}
m.state = pagerStateSetNote
// Stop the timer for hiding a status message since changing
// the state above will have cleared it.
if m.statusMessageTimer != nil {
m.statusMessageTimer.Stop()
}
// Pre-populate note with existing value
if m.textInput.Value() == "" {
m.textInput.SetValue(m.currentDocument.Note)
m.textInput.CursorEnd()
}
return m, textinput.Blink
case "s":
if m.common.authStatus != authOK {
break
}
md := m.currentDocument
_, alreadyStashed := m.common.filesStashed[md.stashID]
if alreadyStashed {
cmds = append(cmds, m.showStatusMessage("Already stashed"))
break
}
// Stash a local document
if m.state != pagerStateStashing && stashableDocTypes.Contains(md.docType) {
m.state = pagerStateStashing
m.spinner.Start()
cmds = append(
cmds,
stashDocument(m.common.cc, md),
spinner.Tick,
)
}
case "?":
m.toggleHelp()
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))
}
case spinner.TickMsg:
if m.state == pagerStateStashing || m.spinner.Visible() {
// If we're still stashing, or if the spinner still needs to
// finish, spin it along.
newSpinnerModel, cmd := m.spinner.Update(msg)
m.spinner = newSpinnerModel
cmds = append(cmds, cmd)
} else if m.state == pagerStateStashSuccess && !m.spinner.Visible() {
// If the spinner's finished and we haven't told the user the
// stash was successful, do that.
m.state = pagerStateBrowse
m.currentDocument = *m.stashedDocument
m.stashedDocument = nil
cmds = append(cmds, m.showStatusMessage("Stashed!"))
}
// 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
// We've reveived terminal dimensions, either for the first time or
// after a resize
case tea.WindowSizeMsg:
return m, renderWithGlamour(m, m.currentDocument.Body)
case stashSuccessMsg:
// Stashing was successful. Convert the loaded document to a stashed
// one and show a status message. Note that we're also handling this
// message in the main update function where we're adding this stashed
// item to the stash listing.
m.state = pagerStateStashSuccess
if !m.spinner.Visible() {
// The spinner has finished spinning, so tell the user the stash
// was successful.
m.state = pagerStateBrowse
m.currentDocument = markdown(msg)
cmds = append(cmds, m.showStatusMessage("Stashed!"))
} else {
// The spinner is still spinning, so just take note of the newly
// stashed document for now.
md := markdown(msg)
m.stashedDocument = &md
}
case stashFailMsg:
delete(m.common.filesStashed, msg.markdown.stashID)
case statusMessageTimeoutMsg:
m.state = pagerStateBrowse
}
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
switch m.state {
case pagerStateSetNote:
m.textInput, cmd = m.textInput.Update(msg)
cmds = append(cmds, cmd)
default:
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
@ -284,7 +337,12 @@ func (m pagerModel) View() string {
fmt.Fprint(&b, m.viewport.View()+"\n")
// Footer
m.statusBarView(&b)
switch m.state {
case pagerStateSetNote:
m.setNoteView(&b)
default:
m.statusBarView(&b)
}
if m.showHelp {
fmt.Fprint(&b, "\n"+m.helpView())
@ -299,11 +357,13 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
maxPercent float64 = 1.0
percentToStringMagnitude float64 = 100.0
)
showStatusMessage := m.state == pagerStateStatusMessage
var (
isStashed bool = m.currentDocument.docType == StashedDoc || m.currentDocument.docType == ConvertedDoc
showStatusMessage bool = m.state == pagerStateStatusMessage
)
// Logo
logo := glowLogoView()
logo := glowLogoView(" Glow ")
// Scroll percent
percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent()))
@ -322,16 +382,32 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
helpNote = statusBarHelpStyle(" ? Help ")
}
// Status indicator; spinner or stash dot
var statusIndicator string
if m.state == pagerStateStashing || m.state == pagerStateStashSuccess {
if m.spinner.Visible() {
statusIndicator = statusBarNoteStyle(" ") + m.spinner.View()
}
} else if isStashed && showStatusMessage {
statusIndicator = statusBarMessageStashIconStyle(" " + pagerStashIcon)
} else if isStashed {
statusIndicator = statusBarStashDotStyle(" " + pagerStashIcon)
}
// Note
var note string
if showStatusMessage {
note = m.statusMessage
} else {
note = m.currentDocument.Note
if len(note) == 0 {
note = "(No memo)"
}
}
note = truncate.StringWithTail(" "+note+" ", uint(max(0, //nolint:gosec
note = truncate.StringWithTail(" "+note+" ", uint(max(0,
m.common.width-
ansi.PrintableRuneWidth(logo)-
ansi.PrintableRuneWidth(statusIndicator)-
ansi.PrintableRuneWidth(scrollPercent)-
ansi.PrintableRuneWidth(helpNote),
)), ellipsis)
@ -345,6 +421,7 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
padding := max(0,
m.common.width-
ansi.PrintableRuneWidth(logo)-
ansi.PrintableRuneWidth(statusIndicator)-
ansi.PrintableRuneWidth(note)-
ansi.PrintableRuneWidth(scrollPercent)-
ansi.PrintableRuneWidth(helpNote),
@ -356,8 +433,9 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
emptySpace = statusBarNoteStyle(emptySpace)
}
fmt.Fprintf(b, "%s%s%s%s%s",
fmt.Fprintf(b, "%s%s%s%s%s%s",
logo,
statusIndicator,
note,
emptySpace,
scrollPercent,
@ -365,17 +443,30 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
)
}
func (m pagerModel) setNoteView(b *strings.Builder) {
fmt.Fprint(b, noteHeading)
fmt.Fprint(b, m.textInput.View())
}
func (m pagerModel) helpView() (s string) {
memoOrStash := "m set memo"
if m.common.authStatus == authOK && m.currentDocument.docType == LocalDoc {
memoOrStash = "s stash this document"
}
col1 := []string{
"g/home go to top",
"G/end go to bottom",
"c copy contents",
"e edit this document",
"r reload this document",
"",
memoOrStash,
"esc back to files",
"q quit",
}
if m.currentDocument.docType == NewsDoc {
deleteFromStringSlice(col1, 3)
}
s += "\n"
s += "k/↑ up " + col1[0] + "\n"
s += "j/↓ down " + col1[1] + "\n"
@ -411,7 +502,9 @@ 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)
if debug {
log.Println("error rendering with Glamour:", err)
}
return errMsg{err}
}
return contentRenderedMsg(s)
@ -420,116 +513,53 @@ func renderWithGlamour(m pagerModel, md string) tea.Cmd {
// 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
// initialize glamour
var gs glamour.TermRendererOption
if m.common.cfg.GlamourStyle == "auto" {
gs = glamour.WithAutoStyle()
} else {
gs = glamour.WithStylePath(m.common.cfg.GlamourStyle)
}
options := []glamour.TermRendererOption{
utils.GlamourStyle(m.common.cfg.GlamourStyle, isCode),
width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width))
r, err := glamour.NewTermRenderer(
gs,
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))
return "", err
}
out, err := r.Render(markdown)
if err != nil {
return "", fmt.Errorf("error rendering markdown: %w", err)
}
if isCode {
out = strings.TrimSpace(out)
return "", err
}
// trim lines
lines := strings.Split(out, "\n")
var content strings.Builder
var content string
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)
}
content += strings.TrimSpace(s)
// don't add an artificial newline after the last split
if i+1 < len(lines) {
content.WriteRune('\n')
content += "\n"
}
}
return content.String(), nil
return content, 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)
// ETC
// Note: this runs in linear time; O(n).
func deleteFromStringSlice(a []string, i int) []string {
copy(a[i:], a[i+1:])
a[len(a)-1] = ""
return a[:len(a)-1]
}

View file

@ -1,12 +0,0 @@
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 diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ import (
"fmt"
"strings"
lib "github.com/charmbracelet/charm/ui/common"
"github.com/muesli/reflow/ansi"
)
@ -15,7 +16,7 @@ type helpEntry struct{ key, val string }
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
// represeting 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 {
@ -99,15 +100,30 @@ func (m stashModel) helpView() (string, int) {
return m.renderHelp(h)
}
// Help for when we're interacting with a single document
switch m.selectionState {
case selectionSettingNote:
return m.renderHelp([]string{"enter", "confirm", "esc", "cancel"}, []string{"q", "quit"})
case selectionPromptingDelete:
return m.renderHelp([]string{"y", "delete", "n", "cancel"}, []string{"q", "quit"})
}
var (
isStashed bool
isStashable bool
navHelp []string
filterHelp []string
selectionHelp []string
editHelp []string
sectionHelp []string
appHelp []string
)
if numDocs > 0 {
md := m.selectedMarkdown()
isStashed = md != nil && md.docType == StashedDoc
isStashable = md != nil && md.docType == LocalDoc && m.online()
}
if numDocs > 0 && m.showFullHelp {
navHelp = []string{"enter", "open", "j/k ↑/↓", "choose"}
}
@ -125,23 +141,23 @@ func (m stashModel) helpView() (string, int) {
}
// If we're browsing a filtered set
if m.filterApplied() {
filterHelp = []string{"/", "edit search", "esc", "clear filter"}
if m.filterState == filterApplied {
filterHelp = []string{"/", "edit search", "esc", "clear search"}
} else {
filterHelp = []string{"/", "find"}
}
if isStashed {
selectionHelp = []string{"x", "delete", "m", "set memo"}
} else if isStashable {
selectionHelp = []string{"s", "stash"}
}
// 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
@ -149,31 +165,29 @@ func (m stashModel) helpView() (string, int) {
if m.filterState != filtering {
appHelp = append(appHelp, "?", "close help")
}
return m.renderHelp(navHelp, filterHelp, append(selectionHelp, editHelp...), sectionHelp, appHelp)
return m.renderHelp(navHelp, filterHelp, selectionHelp, sectionHelp, appHelp)
}
// Mini help
if m.filterState != filtering {
appHelp = append(appHelp, "?", "more")
}
return m.renderHelp(navHelp, filterHelp, selectionHelp, editHelp, sectionHelp, appHelp)
return m.renderHelp(navHelp, filterHelp, selectionHelp, 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 str, numLines
}
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,
// would otherwise wrap to two lines. Help view entires 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 {
@ -181,7 +195,7 @@ func (m stashModel) miniHelpView(entries ...string) string {
}
var (
truncationChar = subtleStyle.Render("…")
truncationChar = lib.Subtle("…")
truncationWidth = ansi.PrintableRuneWidth(truncationChar)
)
@ -199,13 +213,19 @@ func (m stashModel) miniHelpView(entries ...string) string {
k := entries[i]
v := entries[i+1]
k = grayFg(k)
v = midGrayFg(v)
switch k {
case "s":
k = greenFg(k)
v = semiDimGreenFg(v)
default:
k = grayFg(k)
v = midGrayFg(v)
}
next = fmt.Sprintf("%s %s", k, v)
if i < len(entries)-2 {
next += dividerDot.String()
next += dividerDot
}
// Only this (and the following) help text items if we have the
@ -221,9 +241,11 @@ func (m stashModel) miniHelpView(entries ...string) string {
}
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
var (
columns []helpColumn
tallestCol int
renderedCols [][]string // final rows grouped by column
)
// Get key/value pairs
for _, g := range groups {

View file

@ -2,31 +2,48 @@ package ui
import (
"fmt"
"log"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
lib "github.com/charmbracelet/charm/ui/common"
"github.com/muesli/reflow/ansi"
"github.com/muesli/reflow/truncate"
"github.com/muesli/termenv"
"github.com/sahilm/fuzzy"
)
const (
verticalLine = "│"
noMemoTitle = "No Memo"
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 = ""
truncateTo = uint(m.common.width - stashViewHorizontalPadding*2)
gutter string
title = md.Note
date = md.relativeTime()
icon = ""
)
switch md.docType {
case NewsDoc:
if title == "" {
title = "News"
} else {
title = truncate.StringWithTail(title, truncateTo, ellipsis)
}
case StashedDoc, ConvertedDoc:
icon = fileListingStashIcon
if title == "" {
title = noMemoTitle
}
title = truncate.StringWithTail(title, truncateTo-uint(ansi.PrintableRuneWidth(icon)), ellipsis)
default:
title = truncate.StringWithTail(title, truncateTo, ellipsis)
}
isSelected := index == m.cursor()
isFiltering := m.filterState == filtering
singleFilteredItem := isFiltering && len(m.getVisibleMarkdowns()) == 1
@ -34,72 +51,94 @@ func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) {
// 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
if isSelected && !isFiltering || singleFilteredItem {
// 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))
switch m.selectionState {
case selectionPromptingDelete:
gutter = faintRedFg(verticalLine)
icon = faintRedFg(icon)
title = redFg(title)
date = faintRedFg(date)
case selectionSettingNote:
gutter = dullYellowFg(verticalLine)
icon = ""
title = m.noteInput.View()
date = dullYellowFg(date)
default:
if m.common.latestFileStashed == md.stashID &&
m.statusMessage == stashedStatusMessage {
gutter = greenFg(verticalLine)
icon = dimGreenFg(icon)
title = greenFg(title)
date = semiDimGreenFg(date)
} else {
title = fuchsiaFg(title)
icon = fuchsiaFg(icon)
gutter = dullFuchsiaFg(verticalLine)
icon = dullFuchsiaFg(icon)
if m.currentSection().key == filterSection &&
m.filterState == filterApplied || singleFilteredItem {
s := termenv.Style{}.Foreground(lib.Fuschia.Color())
title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline())
} else {
title = fuchsiaFg(title)
}
date = dullFuchsiaFg(date)
}
date = dimFuchsiaFg(date)
editedBy = dimDullFuchsiaFg(editedBy)
separator = dullFuchsiaFg(separator)
}
} else {
// Regular (non-selected) items
gutter = " "
if m.statusMessage == stashingStatusMessage {
if m.common.latestFileStashed == md.stashID &&
m.statusMessage == stashedStatusMessage {
icon = dimGreenFg(icon)
title = greenFg(title)
date = semiDimGreenFg(date)
editedBy = semiDimGreenFg(editedBy)
separator = semiDimGreenFg(separator)
} else if md.docType == NewsDoc {
if isFiltering && m.filterInput.Value() == "" {
title = dimIndigoFg(title)
date = dimSubtleIndigoFg(date)
} else {
s := termenv.Style{}.Foreground(lib.Indigo.Color())
title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline())
date = subtleIndigoFg(date)
}
} else if isFiltering && m.filterInput.Value() == "" {
icon = dimGreenFg(icon)
title = dimNormalFg(title)
if title == noMemoTitle {
title = dimBrightGrayFg(title)
} else {
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)
if title == noMemoTitle {
title = brightGrayFg(title)
} else {
s := termenv.Style{}.Foreground(lib.NewColorPair("#dddddd", "#1a1a1a").Color())
title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline())
}
date = brightGrayFg(date)
}
}
fmt.Fprintf(b, "%s %s%s%s%s\n", gutter, icon, separator, separator, title)
fmt.Fprintf(b, "%s %s%s\n", gutter, icon, 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 {
func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle termenv.Style) string {
b := strings.Builder{}
normalizedHay, err := normalize(haystack)
if err != nil {
log.Error("error normalizing", "haystack", haystack, "error", err)
if err != nil && debug {
log.Printf("error normalizing '%s': %v", haystack, err)
}
matches := fuzzy.Find(needles, []string{normalizedHay})
if len(matches) == 0 {
return defaultStyle.Render(haystack)
return defaultStyle.Styled(haystack)
}
m := matches[0] // only one match exists
@ -107,12 +146,12 @@ func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle lipg
styled := false
for _, mi := range m.MatchedIndexes {
if i == mi {
b.WriteString(matchedStyle.Render(string(rune)))
b.WriteString(matchedStyle.Styled(string(rune)))
styled = true
}
}
if !styled {
b.WriteString(defaultStyle.Render(string(rune)))
b.WriteString(defaultStyle.Styled(string(rune)))
}
}

View file

@ -1,46 +1,63 @@
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"}
import (
lib "github.com/charmbracelet/charm/ui/common"
te "github.com/muesli/termenv"
)
// 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
type styleFunc func(string) string
const (
darkGray = "#333333"
)
var (
normalFg = newFgStyle(lib.NewColorPair("#dddddd", "#1a1a1a"))
dimNormalFg = newFgStyle(lib.NewColorPair("#777777", "#A49FA5"))
brightGrayFg = newFgStyle(lib.NewColorPair("#979797", "#847A85"))
dimBrightGrayFg = newFgStyle(lib.NewColorPair("#4D4D4D", "#C2B8C2"))
grayFg = newFgStyle(lib.NewColorPair("#626262", "#909090"))
midGrayFg = newFgStyle(lib.NewColorPair("#4A4A4A", "#B2B2B2"))
darkGrayFg = newFgStyle(lib.NewColorPair("#3C3C3C", "#DDDADA"))
greenFg = newFgStyle(lib.NewColorPair("#04B575", "#04B575"))
semiDimGreenFg = newFgStyle(lib.NewColorPair("#036B46", "#35D79C"))
dimGreenFg = newFgStyle(lib.NewColorPair("#0B5137", "#72D2B0"))
fuchsiaFg = newFgStyle(lib.Fuschia)
dimFuchsiaFg = newFgStyle(lib.NewColorPair("#99519E", "#F1A8FF"))
dullFuchsiaFg = newFgStyle(lib.NewColorPair("#AD58B4", "#F793FF"))
dimDullFuchsiaFg = newFgStyle(lib.NewColorPair("#6B3A6F", "#F6C9FF"))
indigoFg = newFgStyle(lib.Indigo)
dimIndigoFg = newFgStyle(lib.NewColorPair("#494690", "#9498FF"))
subtleIndigoFg = newFgStyle(lib.NewColorPair("#514DC1", "#7D79F6"))
dimSubtleIndigoFg = newFgStyle(lib.NewColorPair("#383584", "#BBBDFF"))
yellowFg = newFgStyle(lib.YellowGreen) // renders light green on light backgrounds
dullYellowFg = newFgStyle(lib.NewColorPair("#9BA92F", "#6BCB94")) // renders light green on light backgrounds
redFg = newFgStyle(lib.Red)
faintRedFg = newFgStyle(lib.FaintRed)
// Ultimately, we should transition to named styles
tabColor = newFgStyle(lib.NewColorPair("#626262", "#909090"))
selectedTabColor = newFgStyle(lib.NewColorPair("#979797", "#332F33"))
)
// Returns a termenv style with foreground and background options.
func newStyle(fg, bg lib.ColorPair, bold bool) func(string) string {
s := te.Style{}.Foreground(fg.Color()).Background(bg.Color())
if bold {
s = s.Bold()
}
return s.Styled
}
// Returns a new termenv style with background options only.
func newFgStyle(c lib.ColorPair) styleFunc {
return te.Style{}.Foreground(c.Color()).Styled
}

593
ui/ui.go
View file

@ -1,71 +1,87 @@
// Package ui provides the main UI for the glow application.
package ui
import (
"errors"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour/styles"
"github.com/charmbracelet/glow/v2/utils"
"github.com/charmbracelet/log"
"github.com/charmbracelet/charm"
"github.com/charmbracelet/charm/keygen"
"github.com/charmbracelet/charm/ui/common"
lib "github.com/charmbracelet/charm/ui/common"
"github.com/charmbracelet/glow/utils"
"github.com/muesli/gitcha"
te "github.com/muesli/termenv"
"github.com/segmentio/ksuid"
)
const (
statusMessageTimeout = time.Second * 3 // how long to show status messages like "stashed!"
noteCharacterLimit = 256 // should match server
statusMessageTimeout = time.Second * 2 // how long to show status messages like "stashed!"
ellipsis = "…"
)
var (
config Config
config Config
glowLogoTextColor = lib.Color("#ECFD65")
markdownExtensions = []string{
"*.md", "*.mdown", "*.mkdn", "*.mkd", "*.markdown",
}
// True if we're logging to a file, in which case we'll log more stuff.
debug = false
// Types of documents we allow the user to stash.
stashableDocTypes = NewDocTypeSet(LocalDoc, NewsDoc)
)
// 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())
func NewProgram(cfg Config) *tea.Program {
if cfg.Logfile != "" {
log.Println("-- Starting Glow ----------------")
log.Printf("High performance pager: %v", cfg.HighPerformancePager)
log.Printf("Glamour rendering: %v", cfg.GlamourEnabled)
log.Println("Bubble Tea now initializing...")
debug = true
}
m := newModel(cfg, content)
return tea.NewProgram(m, opts...)
config = cfg
return tea.NewProgram(newModel(cfg))
}
type errMsg struct{ err error }
func (e errMsg) Error() string { return e.err.Error() }
type (
initLocalFileSearchMsg struct {
cwd string
ch chan gitcha.SearchResult
}
)
type newCharmClientMsg *charm.Client
type sshAuthErrMsg struct{}
type keygenFailedMsg struct{ err error }
type keygenSuccessMsg struct{}
type initLocalFileSearchMsg struct {
cwd string
ch chan gitcha.SearchResult
}
type foundLocalFileMsg gitcha.SearchResult
type localFileSearchFinished struct{}
type gotStashMsg []*charm.Markdown
type stashLoadErrMsg struct{ err error }
type gotNewsMsg []*charm.Markdown
type statusMessageTimeoutMsg applicationContext
type newsLoadErrMsg struct{ err error }
type stashSuccessMsg markdown
type stashFailMsg struct {
err error
markdown markdown
}
type (
foundLocalFileMsg gitcha.SearchResult
localFileSearchFinished struct{}
statusMessageTimeoutMsg applicationContext
)
// applicationContext indicates the area of the application something applies
// applicationContext indicates the area of the application something appies
// to. Occasionally used as an argument to commands and messages.
type applicationContext int
@ -89,18 +105,60 @@ func (s state) String() string {
}[s]
}
type authStatus int
const (
authConnecting authStatus = iota
authOK
authFailed
)
func (s authStatus) String() string {
return map[authStatus]string{
authConnecting: "connecting",
authOK: "ok",
authFailed: "failed",
}[s]
}
type keygenState int
const (
keygenUnstarted keygenState = iota
keygenRunning
keygenFinished
)
// Common stuff we'll need to access in all models.
type commonModel struct {
cfg Config
cwd string
width int
height int
cfg Config
cc *charm.Client
cwd string
authStatus authStatus
width int
height int
// Local IDs of files stashed this session. We treat this like a set,
// ignoring the value portion with an empty struct.
filesStashed map[ksuid.KSUID]struct{}
// ID of the most recently stashed markdown
latestFileStashed ksuid.KSUID
// Files currently being stashed. We remove files from this set once
// a stash operation has either succeeded or failed.
filesStashing map[ksuid.KSUID]struct{}
}
func (c commonModel) isStashing() bool {
return len(c.filesStashing) > 0
}
type model struct {
common *commonModel
state state
fatalErr error
common *commonModel
state state
keygenState keygenState
fatalErr error
// Sub-models
stash stashModel
@ -121,82 +179,57 @@ func (m *model) unloadDocument() []tea.Cmd {
var batch []tea.Cmd
if m.pager.viewport.HighPerformanceRendering {
batch = append(batch, tea.ClearScrollArea) //nolint:staticcheck
batch = append(batch, tea.ClearScrollArea)
}
if !m.stash.shouldSpin() {
batch = append(batch, m.stash.spinner.Tick)
if !m.stash.loadingDone() {
batch = append(batch, spinner.Tick)
}
return batch
}
func newModel(cfg Config, content string) tea.Model {
initSections()
if cfg.GlamourStyle == styles.AutoStyle {
func newModel(cfg Config) tea.Model {
if cfg.GlamourStyle == "auto" {
if te.HasDarkBackground() {
cfg.GlamourStyle = styles.DarkStyle
cfg.GlamourStyle = "dark"
} else {
cfg.GlamourStyle = styles.LightStyle
cfg.GlamourStyle = "light"
}
}
if len(cfg.DocumentTypes) == 0 {
cfg.DocumentTypes.Add(LocalDoc, StashedDoc, ConvertedDoc, NewsDoc)
}
common := commonModel{
cfg: cfg,
cfg: cfg,
authStatus: authConnecting,
filesStashed: make(map[ksuid.KSUID]struct{}),
filesStashing: make(map[ksuid.KSUID]struct{}),
}
m := model{
common: &common,
state: stateShowStash,
pager: newPagerModel(&common),
stash: newStashModel(&common),
return model{
common: &common,
state: stateShowStash,
keygenState: keygenUnstarted,
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}
var cmds []tea.Cmd
d := m.common.cfg.DocumentTypes
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))
if d.Contains(StashedDoc) || d.Contains(NewsDoc) {
cmds = append(cmds,
newCharmClient,
spinner.Tick,
)
}
if d.Contains(LocalDoc) {
cmds = append(cmds, findLocalFiles(m))
}
return tea.Batch(cmds...)
@ -216,45 +249,43 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument {
if m.state == stateShowDocument {
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
switch m.state {
case stateShowStash:
// pass through all keys if we're editing the filter
if m.stash.filterState == filtering {
if m.stash.filterState == filtering || m.stash.selectionState == selectionSettingNote {
m.stash, cmd = m.stash.update(msg)
return m, cmd
}
// Special cases for the pager
case stateShowDocument:
switch m.pager.state {
// If setting a note send all keys straight through
case pagerStateSetNote:
var batch []tea.Cmd
newPagerModel, cmd := m.pager.update(msg)
m.pager = newPagerModel
batch = append(batch, cmd)
return m, tea.Batch(batch...)
}
}
return m, tea.Quit
case "left", "h", "delete":
if m.state == stateShowDocument {
if m.state == stateShowDocument && m.pager.state != pagerStateSetNote {
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
@ -272,16 +303,66 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.common.cwd = msg.cwd
cmds = append(cmds, findNextLocalFile(m))
case sshAuthErrMsg:
if m.keygenState != keygenFinished { // if we haven't run the keygen yet, do that
m.keygenState = keygenRunning
cmds = append(cmds, generateSSHKeys)
} else {
// The keygen ran but things still didn't work and we can't auth
m.common.authStatus = authFailed
m.stash.err = errors.New("SSH authentication failed; we tried ssh-agent, loading keys from disk, and generating SSH keys")
if debug {
log.Println("entering offline mode;", m.stash.err)
}
// Even though it failed, news/stash loading is finished
m.stash.loaded.Add(StashedDoc, NewsDoc)
}
case keygenFailedMsg:
// Keygen failed. That sucks.
m.common.authStatus = authFailed
m.stash.err = errors.New("could not authenticate; could not generate SSH keys")
if debug {
log.Println("entering offline mode;", m.stash.err)
}
m.keygenState = keygenFinished
// Even though it failed, news/stash loading is finished
m.stash.loaded.Add(StashedDoc, NewsDoc)
case keygenSuccessMsg:
// The keygen's done, so let's try initializing the charm client again
m.keygenState = keygenFinished
cmds = append(cmds, newCharmClient)
case newCharmClientMsg:
m.common.cc = msg
m.common.authStatus = authOK
cmds = append(cmds, loadStash(m.stash), loadNews(m.stash))
case stashLoadErrMsg:
m.common.authStatus = authFailed
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))
msg.Body = string(utils.RemoveFrontmatter([]byte(msg.Body)))
cmds = append(cmds, renderWithGlamour(m.pager, msg.Body))
case contentRenderedMsg:
m.state = stateShowDocument
case localFileSearchFinished:
case noteSavedMsg:
// A note was saved to a document. This will have been done in the
// pager, so we'll need to find the corresponding note in the stash.
// So, pass the message to the stash for processing.
stashModel, cmd := m.stash.update(msg)
m.stash = stashModel
return m, cmd
case localFileSearchFinished, gotStashMsg, gotNewsMsg:
// 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.
@ -300,6 +381,35 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
cmds = append(cmds, findNextLocalFile(m))
case stashSuccessMsg:
// Common handling that should happen regardless of application state
md := markdown(msg)
m.stash.addMarkdowns(&md)
m.common.filesStashed[msg.stashID] = struct{}{}
delete(m.common.filesStashing, md.stashID)
if m.stash.filterApplied() {
for _, v := range m.stash.filteredMarkdowns {
if v.stashID == msg.stashID && v.docType == ConvertedDoc {
// Add the server-side ID we got back so we can do things
// like rename and stash it.
v.ID = msg.ID
// Keep the unique ID in sync so we can do things like
// delete. Note that the markdown received a new unique ID
// when it was added to the file listing in
// stash.addMarkdowns.
v.uniqueID = md.uniqueID
break
}
}
}
case stashFailMsg:
// Common handling that should happen regardless of application state
delete(m.common.filesStashed, msg.markdown.stashID)
delete(m.common.filesStashing, msg.markdown.stashID)
case filteredMarkdownMsg:
if m.state == stateShowDocument {
newStashModel, cmd := m.stash.update(msg)
@ -329,7 +439,7 @@ func (m model) View() string {
return errorView(m.fatalErr, true)
}
switch m.state { //nolint:exhaustive
switch m.state {
case stateShowDocument:
return m.pager.View()
default:
@ -345,20 +455,22 @@ func errorView(err error, fatal bool) string {
exitMsg += "return"
}
s := fmt.Sprintf("%s\n\n%v\n\n%s",
errorTitleStyle.Render("ERROR"),
te.String(" ERROR ").
Foreground(lib.Cream.Color()).
Background(lib.Red.Color()).
String(),
err,
subtleStyle.Render(exitMsg),
common.Subtle(exitMsg),
)
return "\n" + indent(s, 3)
}
// COMMANDS
func findLocalFiles(m commonModel) tea.Cmd {
func findLocalFiles(m model) tea.Cmd {
return func() tea.Msg {
log.Info("findLocalFiles")
var (
cwd = m.cfg.Path
cwd = m.common.cfg.WorkingDirectory
err error
)
@ -374,22 +486,26 @@ func findLocalFiles(m commonModel) tea.Cmd {
// Note that this is one error check for both cases above
if err != nil {
log.Error("error finding local files", "error", err)
if debug {
log.Println("error finding local files:", 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 debug {
log.Println("local directory is:", cwd)
}
var ignore []string
if !m.common.cfg.ShowAllFiles {
ignore = ignorePatterns(m)
}
ch, err := gitcha.FindFilesExcept(cwd, markdownExtensions, ignore)
if err != nil {
log.Error("error finding local files", "error", err)
if debug {
log.Println("error finding local files:", err)
}
return errMsg{err}
}
@ -406,11 +522,187 @@ func findNextLocalFile(m model) tea.Cmd {
return foundLocalFileMsg(res)
}
// We're done
log.Debug("local file search finished")
if debug {
log.Println("local file search finished")
}
return localFileSearchFinished{}
}
}
func newCharmClient() tea.Msg {
cfg, err := charm.ConfigFromEnv()
if err != nil {
return errMsg{err}
}
cc, err := charm.NewClient(cfg)
if err == charm.ErrMissingSSHAuth {
if debug {
log.Println("missing SSH auth:", err)
}
return sshAuthErrMsg{}
} else if err != nil {
if debug {
log.Println("error creating new charm client:", err)
}
return errMsg{err}
}
return newCharmClientMsg(cc)
}
func loadStash(m stashModel) tea.Cmd {
return func() tea.Msg {
if m.common.cc == nil {
err := errors.New("no charm client")
if debug {
log.Println("error loading stash:", err)
}
return stashLoadErrMsg{err}
}
stash, err := m.common.cc.GetStash(m.serverPage)
if err != nil {
if debug {
if _, ok := err.(charm.ErrAuthFailed); ok {
log.Println("auth failure while loading stash:", err)
} else {
log.Println("error loading stash:", err)
}
}
return stashLoadErrMsg{err}
}
if debug {
log.Println("loaded stash page", m.serverPage)
}
return gotStashMsg(stash)
}
}
func loadNews(m stashModel) tea.Cmd {
return func() tea.Msg {
if m.common.cc == nil {
err := errors.New("no charm client")
if debug {
log.Println("error loading news:", err)
}
return newsLoadErrMsg{err}
}
news, err := m.common.cc.GetNews(1) // just fetch the first page
if err != nil {
if debug {
log.Println("error loading news:", err)
}
return newsLoadErrMsg{err}
}
if debug {
log.Println("fetched news")
}
return gotNewsMsg(news)
}
}
func generateSSHKeys() tea.Msg {
if debug {
log.Println("running keygen...")
}
_, err := keygen.NewSSHKeyPair(nil)
if err != nil {
if debug {
log.Println("keygen failed:", err)
}
return keygenFailedMsg{err}
}
if debug {
log.Println("keys generated succcessfully")
}
return keygenSuccessMsg{}
}
func saveDocumentNote(cc *charm.Client, id int, note string) tea.Cmd {
if cc == nil {
return func() tea.Msg {
err := errors.New("can't set note; no charm client")
if debug {
log.Println("error saving note:", err)
}
return errMsg{err}
}
}
return func() tea.Msg {
if err := cc.SetMarkdownNote(id, note); err != nil {
if debug {
log.Println("error saving note:", err)
}
return errMsg{err}
}
return noteSavedMsg(&charm.Markdown{ID: id, Note: note})
}
}
func stashDocument(cc *charm.Client, md markdown) tea.Cmd {
return func() tea.Msg {
if cc == nil {
err := errors.New("can't stash; no charm client")
if debug {
log.Println("error stashing document:", err)
}
return stashFailMsg{err, md}
}
// Is the document missing a body? If so, it likely means it needs to
// be loaded. But...if it turns out the document body really is empty
// then we'll stash it anyway.
if len(md.Body) == 0 {
switch md.docType {
case LocalDoc:
data, err := ioutil.ReadFile(md.localPath)
if err != nil {
if debug {
log.Println("error loading document body for stashing:", err)
}
return stashFailMsg{err, md}
}
md.Body = string(data)
case NewsDoc:
newMD, err := fetchMarkdown(cc, md.ID, md.docType)
if err != nil {
if debug {
log.Println(err)
}
return stashFailMsg{err, md}
}
md.Body = newMD.Body
default:
err := fmt.Errorf("user is attempting to stash an unsupported markdown type: %s", md.docType)
if debug {
log.Println(err)
}
return stashFailMsg{err, md}
}
}
newMd, err := cc.StashMarkdown(md.Note, md.Body)
if err != nil {
if debug {
log.Println("error stashing document:", err)
}
return stashFailMsg{err, md}
}
md.convertToStashed()
// The server sends the whole stashed document back, but we really just
// need to know the ID so we can operate on this newly stashed
// markdown.
md.ID = newMd.ID
return stashSuccessMsg(md)
}
}
func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.Cmd {
return func() tea.Msg {
<-t.C
@ -424,17 +716,20 @@ func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.C
// 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{
md := &markdown{
docType: LocalDoc,
localPath: res.Path,
Note: stripAbsolutePath(res.Path, cwd),
Modtime: res.Info.ModTime(),
Markdown: charm.Markdown{
Note: stripAbsolutePath(res.Path, cwd),
CreatedAt: res.Info.ModTime(),
},
}
return md
}
func stripAbsolutePath(fullPath, cwd string) string {
fp, _ := filepath.EvalSymlinks(fullPath)
cp, _ := filepath.EvalSymlinks(cwd)
return strings.ReplaceAll(fp, cp+string(os.PathSeparator), "")
return strings.Replace(fullPath, cwd+string(os.PathSeparator), "", -1)
}
// Lightweight version of reflow's indent function.
@ -450,3 +745,17 @@ func indent(s string, n int) string {
}
return b.String()
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

86
url.go
View file

@ -1,86 +0,0 @@
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, "://")
}

View file

@ -1,30 +0,0 @@
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)
}
})
}
}

View file

@ -1,20 +1,12 @@
// 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]:]
@ -31,7 +23,7 @@ func detectFrontmatter(c []byte) []int {
return []int{-1, -1}
}
// ExpandPath expands tilde and all environment variables from the given path.
// Expands tilde and all environment variables from the given path.
func ExpandPath(path string) string {
s, err := homedir.Expand(path)
if err == nil {
@ -39,75 +31,3 @@ func ExpandPath(path string) string {
}
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)
}