mirror of
https://github.com/charmbracelet/glow.git
synced 2026-08-09 17:59:10 +02:00
Compare commits
No commits in common. "pixterm" and "master" have entirely different histories.
54 changed files with 4387 additions and 1044 deletions
18
.editorconfig
Normal file
18
.editorconfig
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# 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
|
||||
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
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.
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Discord
|
||||
url: https://charm.sh/discord
|
||||
about: Chat on our Discord.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
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.
|
||||
57
.github/dependabot.yml
vendored
Normal file
57
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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:
|
||||
- "*"
|
||||
25
.github/workflows/build.yml
vendored
Normal file
25
.github/workflows/build.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
name: build
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: charmbracelet/meta/.github/workflows/build.yml@main
|
||||
|
||||
snapshot:
|
||||
uses: charmbracelet/meta/.github/workflows/snapshot.yml@main
|
||||
secrets:
|
||||
goreleaser_key: ${{ secrets.GORELEASER_KEY }}
|
||||
|
||||
govulncheck:
|
||||
uses: charmbracelet/meta/.github/workflows/govulncheck.yml@main
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
semgrep:
|
||||
uses: charmbracelet/meta/.github/workflows/semgrep.yml@main
|
||||
|
||||
ruleguard:
|
||||
uses: charmbracelet/meta/.github/workflows/ruleguard.yml@main
|
||||
with:
|
||||
go-version: stable
|
||||
24
.github/workflows/coverage.yml
vendored
Normal file
24
.github/workflows/coverage.yml
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
name: coverage
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GO111MODULE: "on"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Coverage
|
||||
env:
|
||||
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
go test -race -covermode atomic -coverprofile=profile.cov ./...
|
||||
go install github.com/mattn/goveralls@latest
|
||||
goveralls -coverprofile=profile.cov -service=github
|
||||
17
.github/workflows/dependabot-sync.yml
vendored
Normal file
17
.github/workflows/dependabot-sync.yml
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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 }}
|
||||
25
.github/workflows/goreleaser.yml
vendored
Normal file
25
.github/workflows/goreleaser.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
|
||||
name: goreleaser
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*.*.*
|
||||
|
||||
concurrency:
|
||||
group: goreleaser
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main
|
||||
secrets:
|
||||
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
goreleaser_key: ${{ secrets.GORELEASER_KEY }}
|
||||
fury_token: ${{ secrets.FURY_TOKEN }}
|
||||
nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }}
|
||||
nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }}
|
||||
snapcraft_token: ${{ secrets.SNAPCRAFT_TOKEN }}
|
||||
13
.github/workflows/lint-sync.yml
vendored
Normal file
13
.github/workflows/lint-sync.yml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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
|
||||
18
.github/workflows/lint.yml
vendored
Normal file
18
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
name: lint
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9.2.0
|
||||
with:
|
||||
# Optional: golangci-lint command line arguments.
|
||||
args: --issues-exit-code=0
|
||||
# Optional: show only new issues if it's a pull request. The default value is `false`.
|
||||
only-new-issues: true
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -1,2 +1,5 @@
|
|||
cmd/gold/gold
|
||||
cmd/gold/dist/
|
||||
glow
|
||||
dist/
|
||||
.envrc
|
||||
completions/
|
||||
manpages/
|
||||
|
|
|
|||
44
.golangci.yml
Normal file
44
.golangci.yml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
version: "2"
|
||||
run:
|
||||
tests: false
|
||||
linters:
|
||||
enable:
|
||||
- bodyclose
|
||||
- exhaustive
|
||||
- goconst
|
||||
- godot
|
||||
- gomoddirectives
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- misspell
|
||||
- nakedret
|
||||
- nestif
|
||||
- nilerr
|
||||
- noctx
|
||||
- nolintlint
|
||||
- prealloc
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
- tparallel
|
||||
- unconvert
|
||||
- unparam
|
||||
- whitespace
|
||||
- wrapcheck
|
||||
exclusions:
|
||||
rules:
|
||||
- text: '(slog|log)\.\w+'
|
||||
linters:
|
||||
- noctx
|
||||
generated: lax
|
||||
presets:
|
||||
- common-false-positives
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
formatters:
|
||||
enable:
|
||||
- gofumpt
|
||||
- goimports
|
||||
exclusions:
|
||||
generated: lax
|
||||
14
.goreleaser.yml
Normal file
14
.goreleaser.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
|
||||
|
||||
version: 2
|
||||
|
||||
includes:
|
||||
- from_url:
|
||||
url: charmbracelet/meta/main/goreleaser-glow.yaml
|
||||
|
||||
variables:
|
||||
description: "Render markdown on the CLI, with pizzazz!"
|
||||
github_url: "https://github.com/charmbracelet/glow"
|
||||
maintainer: "Christian Muehlhaeuser <muesli@charm.sh>"
|
||||
brew_commit_author_name: "Christian Muehlhaeuser"
|
||||
brew_commit_author_email: "muesli@charm.sh"
|
||||
3
Dockerfile
Normal file
3
Dockerfile
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
FROM gcr.io/distroless/static
|
||||
COPY glow /usr/local/bin/glow
|
||||
ENTRYPOINT [ "/usr/local/bin/glow" ]
|
||||
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2019 Charmbracelet, Inc
|
||||
Copyright (c) 2019-2024 Charmbracelet, Inc
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
|||
242
README.md
242
README.md
|
|
@ -1,18 +1,242 @@
|
|||
# Gold
|
||||
# Glow
|
||||
|
||||
Render markdown on the CLI, with _pizzazz_!
|
||||
|
||||
<p align="center">
|
||||
<img src="https://stuff.charm.sh/glow/glow-banner-github.gif" alt="Glow Logo">
|
||||
<a href="https://github.com/charmbracelet/glow/releases"><img src="https://img.shields.io/github/release/charmbracelet/glow.svg" alt="Latest Release"></a>
|
||||
<a href="https://pkg.go.dev/github.com/charmbracelet/glow?tab=doc"><img src="https://godoc.org/github.com/golang/gddo?status.svg" alt="GoDoc"></a>
|
||||
<a href="https://github.com/charmbracelet/glow/actions"><img src="https://github.com/charmbracelet/glow/workflows/build/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://goreportcard.com/report/github.com/charmbracelet/glow"><img src="https://goreportcard.com/badge/charmbracelet/glow" alt="Go ReportCard"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/user-attachments/assets/c2246366-f84b-4847-b431-32a61ca07b74" width="800" alt="Glow UI Demo">
|
||||
</p>
|
||||
|
||||
## What is it?
|
||||
|
||||
Gold is a Golang library that allows you to use JSON based stylesheets to
|
||||
render Markdown files in the terminal. Just like CSS, you can define color and
|
||||
style attributes on Markdown elements. The difference is that you use ANSI
|
||||
color and terminal codes instead of CSS properties and hex colors.
|
||||
Glow is a terminal based markdown reader designed from the ground up to bring
|
||||
out the beauty—and power—of the CLI.
|
||||
|
||||
## Usage
|
||||
Use it to discover markdown files, read documentation directly on the command
|
||||
line. Glow will find local markdown files in subdirectories or a local
|
||||
Git repository.
|
||||
|
||||
See [cmd/gold](cmd/gold/).
|
||||
## Installation
|
||||
|
||||
## Colors
|
||||
### Package Manager
|
||||
|
||||
Currently `gold` uses the [Aurora ANSI colors](https://godoc.org/github.com/logrusorgru/aurora#Index).
|
||||
```bash
|
||||
# macOS or Linux
|
||||
brew install glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# macOS (with MacPorts)
|
||||
sudo port install glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Arch Linux (btw)
|
||||
pacman -S glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Void Linux
|
||||
xbps-install -S glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Nix shell
|
||||
nix-shell -p glow --command glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# FreeBSD
|
||||
pkg install glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Solus
|
||||
eopkg install glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Windows (with Chocolatey, Scoop, or Winget)
|
||||
choco install glow
|
||||
scoop install glow
|
||||
winget install charmbracelet.glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Android (with termux)
|
||||
pkg install glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Ubuntu (Snapcraft)
|
||||
sudo snap install glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list
|
||||
sudo apt update && sudo apt install glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Fedora/RHEL
|
||||
echo '[charm]
|
||||
name=Charm
|
||||
baseurl=https://repo.charm.sh/yum/
|
||||
enabled=1
|
||||
gpgcheck=1
|
||||
gpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo
|
||||
sudo yum install glow
|
||||
```
|
||||
|
||||
Or download a binary from the [releases][releases] page. MacOS, Linux, Windows,
|
||||
FreeBSD and OpenBSD binaries are available, as well as Debian, RPM, and Alpine
|
||||
packages. ARM builds are also available for macOS, Linux, FreeBSD and OpenBSD.
|
||||
|
||||
### Go
|
||||
|
||||
Or just install it with `go`:
|
||||
|
||||
```bash
|
||||
go install github.com/charmbracelet/glow/v2@latest
|
||||
```
|
||||
|
||||
### Build (requires Go 1.21+)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/charmbracelet/glow.git
|
||||
cd glow
|
||||
go build
|
||||
```
|
||||
|
||||
[releases]: https://github.com/charmbracelet/glow/releases
|
||||
|
||||
## The TUI
|
||||
|
||||
Simply run `glow` without arguments to start the textual user interface and
|
||||
browse local. Glow will find local markdown files in the
|
||||
current directory and below or, if you’re in a Git repository, Glow will search
|
||||
the repo.
|
||||
|
||||
Markdown files can be read with Glow's high-performance pager. Most of the
|
||||
keystrokes you know from `less` are the same, but you can press `?` to list
|
||||
the hotkeys.
|
||||
|
||||
## The CLI
|
||||
|
||||
In addition to a TUI, Glow has a CLI for working with Markdown. To format a
|
||||
document use a markdown source as the primary argument:
|
||||
|
||||
```bash
|
||||
# Read from file
|
||||
glow README.md
|
||||
|
||||
# Read from stdin
|
||||
echo "[Glow](https://github.com/charmbracelet/glow)" | glow -
|
||||
|
||||
# Fetch README from GitHub / GitLab
|
||||
glow github.com/charmbracelet/glow
|
||||
|
||||
# Fetch markdown from HTTP
|
||||
glow https://host.tld/file.md
|
||||
```
|
||||
|
||||
### Word Wrapping
|
||||
|
||||
The `-w` flag lets you set a maximum width at which the output will be wrapped:
|
||||
|
||||
```bash
|
||||
glow -w 60
|
||||
```
|
||||
|
||||
### Paging
|
||||
|
||||
CLI output can be displayed in your preferred pager with the `-p` flag. This defaults
|
||||
to the ANSI-aware `less -r` if `$PAGER` is not explicitly set.
|
||||
|
||||
### Styles
|
||||
|
||||
You can choose a style with the `-s` flag. When no flag is provided `glow` tries
|
||||
to detect your terminal's current background color and automatically picks
|
||||
either the `dark` or the `light` style for you.
|
||||
|
||||
```bash
|
||||
glow -s [dark|light]
|
||||
```
|
||||
|
||||
Alternatively you can also supply a custom JSON stylesheet:
|
||||
|
||||
```bash
|
||||
glow -s mystyle.json
|
||||
```
|
||||
|
||||
For additional usage details see:
|
||||
|
||||
```bash
|
||||
glow --help
|
||||
```
|
||||
|
||||
Check out the [Glamour Style Section](https://github.com/charmbracelet/glamour/blob/master/styles/gallery/README.md)
|
||||
to find more styles. Or [make your own](https://github.com/charmbracelet/glamour/tree/master/styles)!
|
||||
|
||||
## The Config File
|
||||
|
||||
If you find yourself supplying the same flags to `glow` all the time, it's
|
||||
probably a good idea to create a config file. Run `glow config`, which will open
|
||||
it in your favorite $EDITOR. Alternatively you can manually put a file named
|
||||
`glow.yml` in the default config path of you platform. If you're not sure where
|
||||
that is, please refer to `glow --help`.
|
||||
|
||||
Here's an example config:
|
||||
|
||||
```yaml
|
||||
# style name or JSON path (default "auto")
|
||||
style: "light"
|
||||
# mouse wheel support (TUI-mode only)
|
||||
mouse: true
|
||||
# use pager to display markdown
|
||||
pager: true
|
||||
# at which column should we word wrap?
|
||||
width: 80
|
||||
# show all files, including hidden and ignored.
|
||||
all: false
|
||||
# show line numbers (TUI-mode only)
|
||||
showLineNumbers: false
|
||||
# preserve newlines in the output
|
||||
preserveNewLines: false
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [contributing][contribute].
|
||||
|
||||
[contribute]: https://github.com/charmbracelet/glow/contribute
|
||||
|
||||
## Feedback
|
||||
|
||||
We’d love to hear your thoughts on this project. Feel free to drop us a note!
|
||||
|
||||
- [Twitter](https://twitter.com/charmcli)
|
||||
- [The Fediverse](https://mastodon.social/@charmcli)
|
||||
- [Discord](https://charm.sh/chat)
|
||||
|
||||
## License
|
||||
|
||||
[MIT](https://github.com/charmbracelet/glow/raw/master/LICENSE)
|
||||
|
||||
---
|
||||
|
||||
Part of [Charm](https://charm.sh).
|
||||
|
||||
<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>
|
||||
|
||||
Charm热爱开源 • Charm loves open source
|
||||
|
|
|
|||
23
Taskfile.yaml
Normal file
23
Taskfile.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# 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]
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
env:
|
||||
- GO111MODULE=on
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
builds:
|
||||
-
|
||||
binary: gold
|
||||
ldflags: -s -w -X main.Version={{ .Version }} -X main.CommitSHA={{ .Commit }}
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- 386
|
||||
- arm
|
||||
goarm:
|
||||
- 6
|
||||
- 7
|
||||
ignore:
|
||||
- goos: darwin
|
||||
goarch: 386
|
||||
- goos: linux
|
||||
goarch: arm
|
||||
goarm: 7
|
||||
sign:
|
||||
artifacts: checksum
|
||||
archives:
|
||||
-
|
||||
replacements:
|
||||
darwin: Darwin
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
name_template: "{{ .Tag }}-next"
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
# Gold
|
||||
|
||||
Render markdown on the CLI, with _pizzazz_!
|
||||
|
||||
## Usage
|
||||
|
||||
Supply a JSON stylesheet with the `-s` flag. Use a markdown source as the argument.
|
||||
|
||||
Read from file:
|
||||
```
|
||||
./gold -s dark.json README.md
|
||||
```
|
||||
|
||||
Read from stdin:
|
||||
```
|
||||
./gold -s dark.json -
|
||||
```
|
||||
|
||||
Fetch README from GitHub:
|
||||
```
|
||||
./gold -s dark.json github.com/charmbracelet/gold
|
||||
```
|
||||
|
||||
Fetch markdown from an HTTP source:
|
||||
```
|
||||
./gold -s dark.json https://host.tld/file.md
|
||||
```
|
||||
|
||||
When `gold` is started without any markdown source, it will try to find a `README.md`
|
||||
or `README` file in the current working directory.
|
||||
|
||||
## Colors
|
||||
|
||||
Currently `gold` uses the [Aurora ANSI colors](https://godoc.org/github.com/logrusorgru/aurora#Index).
|
||||
|
||||
## Example Output
|
||||
|
||||

|
||||
|
|
@ -1,30 +0,0 @@
|
|||
{
|
||||
"link": {
|
||||
"color": "123",
|
||||
"underline": true
|
||||
},
|
||||
"link_text": {
|
||||
"color": "123",
|
||||
"bold": true
|
||||
},
|
||||
"image": {
|
||||
"color": "8"
|
||||
},
|
||||
"heading": {
|
||||
"bold": true,
|
||||
"color": "3"
|
||||
},
|
||||
"code": {
|
||||
"color": "200"
|
||||
},
|
||||
"code_block": {
|
||||
"color": "200",
|
||||
"theme": "solarized-dark"
|
||||
},
|
||||
"emph": {
|
||||
"italic": true
|
||||
},
|
||||
"strong": {
|
||||
"bold": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
u, err := url.ParseRequestURI(s)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return u.String(), strings.ToLower(u.Host) == "github.com"
|
||||
}
|
||||
|
||||
// findGitHubREADME tries to find the correct README filename in a repository
|
||||
func findGitHubREADME(s string) (*http.Response, error) {
|
||||
u, err := url.ParseRequestURI(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Host = "raw.githubusercontent.com"
|
||||
|
||||
for _, r := range readmeNames {
|
||||
v := u
|
||||
v.Path += "/master/" + r
|
||||
|
||||
resp, err := http.Get(v.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("can't find README in GitHub repository")
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
u, err := url.ParseRequestURI(s)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return u.String(), strings.ToLower(u.Host) == "gitlab.com"
|
||||
}
|
||||
|
||||
// findGitLabREADME tries to find the correct README filename in a repository
|
||||
func findGitLabREADME(s string) (*http.Response, error) {
|
||||
u, err := url.ParseRequestURI(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, r := range readmeNames {
|
||||
v := u
|
||||
v.Path += "/raw/master/" + r
|
||||
|
||||
resp, err := http.Get(v.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("can't find README in GitLab repository")
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 264 KiB |
117
cmd/gold/main.go
117
cmd/gold/main.go
|
|
@ -1,117 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/muesli/go-wordwrap"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/charmbracelet/gold"
|
||||
)
|
||||
|
||||
var (
|
||||
readmeNames = []string{"README.md", "README"}
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "gold SOURCE",
|
||||
Short: "Render markdown on the CLI, with pizzazz!",
|
||||
SilenceErrors: false,
|
||||
SilenceUsage: false,
|
||||
RunE: execute,
|
||||
}
|
||||
|
||||
style string
|
||||
width uint
|
||||
)
|
||||
|
||||
func readerFromArg(s string) (io.ReadCloser, error) {
|
||||
if s == "-" {
|
||||
return os.Stdin, nil
|
||||
}
|
||||
|
||||
if u, ok := isGitHubURL(s); ok {
|
||||
resp, err := findGitHubREADME(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
if u, ok := isGitLabURL(s); ok {
|
||||
resp, err := findGitLabREADME(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
if u, err := url.ParseRequestURI(s); err == nil {
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, fmt.Errorf("%s is not a supported protocol", u.Scheme)
|
||||
}
|
||||
|
||||
resp, err := http.Get(u.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP status %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
if len(s) == 0 {
|
||||
for _, v := range readmeNames {
|
||||
r, err := os.Open(v)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("missing markdown source")
|
||||
}
|
||||
|
||||
return os.Open(s)
|
||||
}
|
||||
|
||||
func execute(cmd *cobra.Command, args []string) error {
|
||||
var arg string
|
||||
if len(args) > 0 {
|
||||
arg = args[0]
|
||||
}
|
||||
in, err := readerFromArg(arg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
b, _ := ioutil.ReadAll(in)
|
||||
|
||||
r := gold.NewPlainTermRenderer()
|
||||
if isatty.IsTerminal(os.Stdout.Fd()) {
|
||||
r, err = gold.NewTermRenderer(style)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
out := r.RenderBytes(b)
|
||||
fmt.Printf("%s", wordwrap.WrapString(string(out), width))
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(-1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Flags().StringVarP(&style, "style", "s", "dark.json", "style JSON path")
|
||||
rootCmd.Flags().UintVarP(&width, "width", "w", 100, "word-wrap at width")
|
||||
}
|
||||
88
config_cmd.go
Normal file
88
config_cmd.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/charmbracelet/x/editor"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const defaultConfig = `# style name or JSON path (default "auto")
|
||||
style: "auto"
|
||||
# mouse support (TUI-mode only)
|
||||
mouse: false
|
||||
# use pager to display markdown
|
||||
pager: false
|
||||
# word-wrap at width
|
||||
width: 80
|
||||
# show all files, including hidden and ignored.
|
||||
all: false
|
||||
`
|
||||
|
||||
var configCmd = &cobra.Command{
|
||||
Use: "config",
|
||||
Hidden: false,
|
||||
Short: "Edit the glow config file",
|
||||
Long: paragraph(fmt.Sprintf("\n%s the glow config file. We’ll use EDITOR to determine which editor to use. If the config file doesn't exist, it will be created.", keyword("Edit"))),
|
||||
Example: paragraph("glow config\nglow config --config path/to/config.yml"),
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
if err := ensureConfigFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c, err := editor.Cmd("Glow", configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to set config file: %w", err)
|
||||
}
|
||||
c.Stdin = os.Stdin
|
||||
c.Stdout = os.Stdout
|
||||
c.Stderr = os.Stderr
|
||||
if err := c.Run(); err != nil {
|
||||
return fmt.Errorf("unable to run command: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Wrote config file to:", configFile)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func ensureConfigFile() error {
|
||||
if configFile == "" {
|
||||
configFile = viper.GetViper().ConfigFileUsed()
|
||||
if err := os.MkdirAll(filepath.Dir(configFile), 0o755); err != nil { //nolint:gosec
|
||||
return fmt.Errorf("could not write configuration file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if ext := path.Ext(configFile); ext != ".yaml" && ext != ".yml" {
|
||||
return fmt.Errorf("'%s' is not a supported configuration type: use '%s' or '%s'", ext, ".yaml", ".yml")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configFile); errors.Is(err, fs.ErrNotExist) {
|
||||
// File doesn't exist yet, create all necessary directories and
|
||||
// write the default config file
|
||||
if err := os.MkdirAll(filepath.Dir(configFile), 0o700); err != nil {
|
||||
return fmt.Errorf("unable create directory: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Create(configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create config file: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
if _, err := f.WriteString(defaultConfig); err != nil {
|
||||
return fmt.Errorf("unable to write config file: %w", err)
|
||||
}
|
||||
} else if err != nil { // some other error occurred
|
||||
return fmt.Errorf("unable to stat config file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
23
console_windows.go
Normal file
23
console_windows.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// +build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// enableAnsiColors enables support for ANSI color sequences in Windows
|
||||
// default console. Note that this only works with Windows 10.
|
||||
func enableAnsiColors() {
|
||||
stdout := windows.Handle(os.Stdout.Fd())
|
||||
var originalMode uint32
|
||||
|
||||
windows.GetConsoleMode(stdout, &originalMode)
|
||||
windows.SetConsoleMode(stdout, originalMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING)
|
||||
}
|
||||
|
||||
func init() {
|
||||
enableAnsiColors()
|
||||
}
|
||||
BIN
example.png
Normal file
BIN
example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 499 KiB |
57
github.go
Normal file
57
github.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// findGitHubREADME tries to find the correct README filename in a repository using GitHub API.
|
||||
func findGitHubREADME(u *url.URL) (*source, error) {
|
||||
owner, repo, ok := strings.Cut(strings.TrimPrefix(u.Path, "/"), "/")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid url: %s", u.String())
|
||||
}
|
||||
|
||||
type readme struct {
|
||||
DownloadURL string `json:"download_url"`
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.%s/repos/%s/%s/readme", u.Hostname(), owner, repo)
|
||||
|
||||
//nolint:bodyclose
|
||||
// it is closed on the caller
|
||||
res, err := http.Get(apiURL) //nolint: gosec,noctx
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get url: %w", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to read http response body: %w", err)
|
||||
}
|
||||
|
||||
var result readme
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("unable to parse json: %w", err)
|
||||
}
|
||||
|
||||
if res.StatusCode == http.StatusOK {
|
||||
//nolint:bodyclose
|
||||
// it is closed on the caller
|
||||
resp, err := http.Get(result.DownloadURL) //nolint: noctx
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get url: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return &source{resp.Body, result.DownloadURL}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("can't find README in GitHub repository")
|
||||
}
|
||||
61
gitlab.go
Normal file
61
gitlab.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// findGitLabREADME tries to find the correct README filename in a repository using GitLab API.
|
||||
func findGitLabREADME(u *url.URL) (*source, error) {
|
||||
owner, repo, ok := strings.Cut(strings.TrimPrefix(u.Path, "/"), "/")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid url: %s", u.String())
|
||||
}
|
||||
|
||||
projectPath := url.QueryEscape(owner + "/" + repo)
|
||||
|
||||
type readme struct {
|
||||
ReadmeURL string `json:"readme_url"`
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://%s/api/v4/projects/%s", u.Hostname(), projectPath)
|
||||
|
||||
//nolint:bodyclose
|
||||
// it is closed on the caller
|
||||
res, err := http.Get(apiURL) //nolint: gosec,noctx
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get url: %w", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to read http response body: %w", err)
|
||||
}
|
||||
|
||||
var result readme
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("unable to parse json: %w", err)
|
||||
}
|
||||
|
||||
readmeRawURL := strings.ReplaceAll(result.ReadmeURL, "blob", "raw")
|
||||
|
||||
if res.StatusCode == http.StatusOK {
|
||||
//nolint:bodyclose
|
||||
// it is closed on the caller
|
||||
resp, err := http.Get(readmeRawURL) //nolint: gosec,noctx
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get url: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return &source{resp.Body, readmeRawURL}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("can't find README in GitLab repository")
|
||||
}
|
||||
41
glow_test.go
Normal file
41
glow_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGlowFlags(t *testing.T) {
|
||||
tt := []struct {
|
||||
args []string
|
||||
check func() bool
|
||||
}{
|
||||
{
|
||||
args: []string{"-p"},
|
||||
check: func() bool {
|
||||
return pager
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{"-s", "light"},
|
||||
check: func() bool {
|
||||
return style == "light"
|
||||
},
|
||||
},
|
||||
{
|
||||
args: []string{"-w", "40"},
|
||||
check: func() bool {
|
||||
return width == 40
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, v := range tt {
|
||||
err := rootCmd.ParseFlags(v.args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !v.check() {
|
||||
t.Errorf("Parsing flag failed: %s", v.args)
|
||||
}
|
||||
}
|
||||
}
|
||||
84
go.mod
84
go.mod
|
|
@ -1,17 +1,75 @@
|
|||
module github.com/charmbracelet/gold
|
||||
module github.com/charmbracelet/glow/v2
|
||||
|
||||
go 1.13
|
||||
go 1.25.12
|
||||
|
||||
toolchain go1.26.5
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma v0.7.0
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/eliukblau/pixterm v1.2.4
|
||||
github.com/logrusorgru/aurora v0.0.0-20191017060258-dc85c304c434
|
||||
github.com/lucasb-eyer/go-colorful v0.0.0-20170903184257-231272389856
|
||||
github.com/mattn/go-isatty v0.0.10
|
||||
github.com/microcosm-cc/bluemonday v1.0.2
|
||||
github.com/muesli/go-wordwrap v1.0.1-0.20191125090158-10bc5504e2a8
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
github.com/spf13/cobra v0.0.5
|
||||
gopkg.in/russross/blackfriday.v2 v2.0.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
github.com/charmbracelet/x/editor v0.1.0
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/mattn/go-runewidth v0.0.19
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/muesli/gitcha v0.3.0
|
||||
github.com/muesli/go-app-paths v0.2.2
|
||||
github.com/muesli/mango-cobra v1.3.0
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/muesli/roff v0.1.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/sahilm/fuzzy v0.1.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/sys v0.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
|
||||
)
|
||||
|
|
|
|||
262
go.sum
262
go.sum
|
|
@ -1,101 +1,177 @@
|
|||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
|
||||
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
|
||||
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
|
||||
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.7.0 h1:z+0HgTUmkpRDRz0SRSdMaqOLfJV4F+N1FPDZUZIDUzw=
|
||||
github.com/alecthomas/chroma v0.7.0/go.mod h1:1U/PfCsTALWWYHDnsIQkxEBM0+6LLe0v8+RSVMOwxeY=
|
||||
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.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
|
||||
github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
|
||||
github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA=
|
||||
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/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
|
||||
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/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/editor v0.1.0 h1:p69/dpvlwRTs9uYiPeAWruwsHqTFzHhTvQOd/WVSX98=
|
||||
github.com/charmbracelet/x/editor v0.1.0/go.mod h1:oivrEbcP/AYt/Hpvk5pwDXXrQ933gQS6UzL6fxqAGSA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg=
|
||||
github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/eliukblau/pixterm v1.2.4 h1:EQ8KuWLeDNdm/AWZlylR9hX0elAve+MXLoIFh9Ssrjo=
|
||||
github.com/eliukblau/pixterm v1.2.4/go.mod h1:SLrMnkvTqu64HiEUZ+UEQOAh81M4FlxDwxU69KmWJuQ=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI=
|
||||
github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
|
||||
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/logrusorgru/aurora v0.0.0-20191017060258-dc85c304c434 h1:im9kkmH0WWwxzegiv18gSUJbuXR9y028rXrWuPp6Jug=
|
||||
github.com/logrusorgru/aurora v0.0.0-20191017060258-dc85c304c434/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||
github.com/lucasb-eyer/go-colorful v0.0.0-20170903184257-231272389856 h1:r+WvXmgROttp7pckv7TPN7OCUEPXmvhRklOOsL2iPPc=
|
||||
github.com/lucasb-eyer/go-colorful v0.0.0-20170903184257-231272389856/go.mod h1:NXg0ArsFk0Y01623LgUqoqcouGDB+PwCCQlrwrG6xJ4=
|
||||
github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac=
|
||||
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/magiconair/properties v1.8.0/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-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10=
|
||||
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
|
||||
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/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/muesli/go-wordwrap v1.0.1-0.20191125090158-10bc5504e2a8 h1:pkJGPjqhekH0hq/lJABlh5EhzU7eU/+VdFq9ZMOae2E=
|
||||
github.com/muesli/go-wordwrap v1.0.1-0.20191125090158-10bc5504e2a8/go.mod h1:isHWaQQfvQ38DUiq7ugbuPZowGJ+9gPmACGc3tDUN4M=
|
||||
github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
|
||||
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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/gitcha v0.3.0 h1:+PJkVKrDXVB0VgRn/yVx2CqSVSDGMSepzvohsCrPYtQ=
|
||||
github.com/muesli/gitcha v0.3.0/go.mod h1:vX3jFL+XcEUq1uY74RCjLSZfAV+ZuvLg70/NGPdXn84=
|
||||
github.com/muesli/go-app-paths v0.2.2 h1:NqG4EEZwNIhBq/pREgfBmgDmt3h1Smr1MjZiXbpZUnI=
|
||||
github.com/muesli/go-app-paths v0.2.2/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho=
|
||||
github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=
|
||||
github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
|
||||
github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec=
|
||||
github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E=
|
||||
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
|
||||
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
|
||||
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
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 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s=
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.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/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 h1:G04eS0JkAIVZfaJLjla9dNxkJCPiKIGZlw9AfOhzOD0=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94/go.mod h1:b18R55ulyQ/h3RaWyloPyER7fWQVZvimKKhnI5OfrJQ=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a h1:1n5lsVfiQW3yfsRGu98756EH1YthsFqr/5mxHduZW2A=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20191008105621-543471e840be h1:QAcqgptGM8IQBC9K/RC4o+O9YmqEm0diQn9QmZw/0mU=
|
||||
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
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=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/russross/blackfriday.v2 v2.0.0 h1:+FlnIV8DSQnT7NZ43hcVKcdJdzZoeCmJj4Ql8gq5keA=
|
||||
gopkg.in/russross/blackfriday.v2 v2.0.0/go.mod h1:6sSBNz/GtOm/pJTuh5UmBK2ZHfmnxGbl2NZg1UliSOI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
|
||||
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
|
||||
|
|
|
|||
497
gold.go
497
gold.go
|
|
@ -1,497 +0,0 @@
|
|||
package gold
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/alecthomas/chroma/quick"
|
||||
"github.com/eliukblau/pixterm/ansimage"
|
||||
"github.com/logrusorgru/aurora"
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
bf "gopkg.in/russross/blackfriday.v2"
|
||||
)
|
||||
|
||||
var (
|
||||
stripper = bluemonday.StrictPolicy()
|
||||
)
|
||||
|
||||
type Fragment struct {
|
||||
Token string
|
||||
Pre string
|
||||
Post string
|
||||
Style StyleType
|
||||
}
|
||||
|
||||
type Element struct {
|
||||
Pre string
|
||||
Post string
|
||||
Fragments []Fragment
|
||||
}
|
||||
|
||||
type TermRenderer struct {
|
||||
style map[StyleType]*ElementStyle
|
||||
}
|
||||
|
||||
func Render(in string, stylePath string) ([]byte, error) {
|
||||
return RenderBytes([]byte(in), stylePath)
|
||||
}
|
||||
|
||||
func RenderBytes(in []byte, stylePath string) ([]byte, error) {
|
||||
r, err := NewTermRenderer(stylePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.RenderBytes(in), nil
|
||||
}
|
||||
|
||||
func NewPlainTermRenderer() *TermRenderer {
|
||||
return &TermRenderer{}
|
||||
}
|
||||
|
||||
func NewTermRenderer(stylePath string) (*TermRenderer, error) {
|
||||
if stylePath == "" {
|
||||
return NewTermRendererFromBytes([]byte("{}"))
|
||||
}
|
||||
f, err := os.Open(stylePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
b, _ := ioutil.ReadAll(f)
|
||||
return NewTermRendererFromBytes(b)
|
||||
}
|
||||
|
||||
func NewTermRendererFromBytes(b []byte) (*TermRenderer, error) {
|
||||
e := make(map[string]*ElementStyle)
|
||||
err := json.Unmarshal(b, &e)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr := &TermRenderer{}
|
||||
tr.style = make(map[StyleType]*ElementStyle)
|
||||
|
||||
for k, v := range e {
|
||||
t, err := keyToType(k)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
continue
|
||||
}
|
||||
tr.style[t] = v
|
||||
}
|
||||
return tr, nil
|
||||
}
|
||||
|
||||
func NewElement(node *bf.Node) Element {
|
||||
switch node.Type {
|
||||
case bf.Document:
|
||||
return Element{
|
||||
Pre: "",
|
||||
Post: "",
|
||||
Fragments: []Fragment{{
|
||||
Token: "",
|
||||
Style: Document,
|
||||
}},
|
||||
}
|
||||
case bf.BlockQuote:
|
||||
return Element{
|
||||
Pre: "\n",
|
||||
Post: "\n",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.Literal),
|
||||
Style: BlockQuote,
|
||||
}},
|
||||
}
|
||||
case bf.List:
|
||||
return Element{
|
||||
Pre: "\n",
|
||||
Post: "",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.Literal),
|
||||
Style: List,
|
||||
}},
|
||||
}
|
||||
case bf.Item:
|
||||
return Element{
|
||||
Pre: "• ",
|
||||
Post: "",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.Literal),
|
||||
Style: Item,
|
||||
}},
|
||||
}
|
||||
case bf.Paragraph:
|
||||
pre := "\n"
|
||||
if node.Prev == nil || (node.Parent != nil && node.Parent.Type == bf.Item) {
|
||||
pre = ""
|
||||
}
|
||||
|
||||
return Element{
|
||||
Pre: pre,
|
||||
Post: "\n",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.Literal),
|
||||
Style: Paragraph,
|
||||
}},
|
||||
}
|
||||
case bf.Heading:
|
||||
var pre string
|
||||
if node.Prev != nil {
|
||||
pre = "\n"
|
||||
}
|
||||
|
||||
return Element{
|
||||
Pre: pre,
|
||||
Post: "\n",
|
||||
Fragments: []Fragment{{
|
||||
Token: fmt.Sprintf("%s %s", strings.Repeat("#", node.HeadingData.Level), node.FirstChild.Literal),
|
||||
Style: Heading,
|
||||
}},
|
||||
}
|
||||
case bf.HorizontalRule:
|
||||
return Element{
|
||||
Pre: "\n",
|
||||
Post: "\n",
|
||||
Fragments: []Fragment{{
|
||||
Token: "---",
|
||||
Style: HorizontalRule,
|
||||
}},
|
||||
}
|
||||
case bf.Emph:
|
||||
return Element{
|
||||
Pre: "",
|
||||
Post: "",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.FirstChild.Literal),
|
||||
Style: Emph,
|
||||
}},
|
||||
}
|
||||
case bf.Strong:
|
||||
return Element{
|
||||
Pre: "",
|
||||
Post: "",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.FirstChild.Literal),
|
||||
Style: Strong,
|
||||
}},
|
||||
}
|
||||
case bf.Del:
|
||||
return Element{
|
||||
Pre: "",
|
||||
Post: "",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.Literal),
|
||||
Style: Del,
|
||||
}},
|
||||
}
|
||||
|
||||
case bf.Link:
|
||||
f := []Fragment{}
|
||||
|
||||
if node.LastChild != nil {
|
||||
if node.LastChild.Type == bf.Image {
|
||||
el := NewElement(node.LastChild)
|
||||
f = el.Fragments
|
||||
}
|
||||
if len(node.LastChild.Literal) > 0 {
|
||||
f = append(f, Fragment{
|
||||
Token: string(node.LastChild.Literal),
|
||||
Style: LinkText,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(node.LinkData.Destination) > 0 {
|
||||
f = append(f, Fragment{
|
||||
Token: string(node.LinkData.Destination),
|
||||
Pre: " (",
|
||||
Post: ")",
|
||||
Style: Link,
|
||||
})
|
||||
}
|
||||
|
||||
return Element{
|
||||
Pre: "",
|
||||
Post: "",
|
||||
Fragments: f,
|
||||
}
|
||||
|
||||
case bf.Image:
|
||||
f := []Fragment{}
|
||||
if len(node.LastChild.Literal) > 0 {
|
||||
f = append(f, Fragment{
|
||||
Token: string(node.LastChild.Literal),
|
||||
Style: Image,
|
||||
})
|
||||
}
|
||||
if len(node.LinkData.Destination) > 0 {
|
||||
f = append(f, Fragment{
|
||||
Token: string(node.LinkData.Destination),
|
||||
// Pre: " [Image: ",
|
||||
// Post: "]",
|
||||
Style: Image,
|
||||
})
|
||||
}
|
||||
|
||||
return Element{
|
||||
Pre: "",
|
||||
Post: "",
|
||||
Fragments: f,
|
||||
}
|
||||
|
||||
case bf.Text:
|
||||
return Element{
|
||||
Pre: "",
|
||||
Post: "",
|
||||
Fragments: []Fragment{{
|
||||
Token: html.UnescapeString(stripper.Sanitize(string(node.Literal))),
|
||||
Style: Text,
|
||||
}},
|
||||
}
|
||||
case bf.HTMLBlock:
|
||||
return Element{
|
||||
Pre: "",
|
||||
Post: "",
|
||||
Fragments: []Fragment{{
|
||||
Token: html.UnescapeString(strings.TrimSpace(stripper.Sanitize(string(node.Literal)))) + "\n",
|
||||
Style: HTMLBlock,
|
||||
}},
|
||||
}
|
||||
case bf.CodeBlock:
|
||||
return Element{
|
||||
Pre: "\n",
|
||||
Post: "\n",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.Literal),
|
||||
Style: CodeBlock,
|
||||
}},
|
||||
}
|
||||
case bf.Softbreak:
|
||||
return Element{
|
||||
Pre: "",
|
||||
Post: "\n",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.Literal),
|
||||
Style: Softbreak,
|
||||
}},
|
||||
}
|
||||
case bf.Hardbreak:
|
||||
return Element{
|
||||
Pre: "\n",
|
||||
Post: "\n",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.Literal),
|
||||
Style: Hardbreak,
|
||||
}},
|
||||
}
|
||||
case bf.Code:
|
||||
return Element{
|
||||
Pre: "",
|
||||
Post: "",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.Literal),
|
||||
Style: Code,
|
||||
}},
|
||||
}
|
||||
case bf.HTMLSpan:
|
||||
return Element{
|
||||
Pre: "",
|
||||
Post: "",
|
||||
Fragments: []Fragment{{
|
||||
Token: html.UnescapeString(strings.TrimSpace(stripper.Sanitize(string(node.Literal)))) + "\n",
|
||||
Style: HTMLSpan,
|
||||
}},
|
||||
}
|
||||
case bf.Table:
|
||||
return Element{
|
||||
Pre: "\n",
|
||||
Post: "\n",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.Literal),
|
||||
Style: Table,
|
||||
}},
|
||||
}
|
||||
case bf.TableCell:
|
||||
return Element{
|
||||
Pre: "",
|
||||
Post: "",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.Literal),
|
||||
Style: TableCell,
|
||||
}},
|
||||
}
|
||||
case bf.TableHead:
|
||||
return Element{
|
||||
Pre: "",
|
||||
Post: "",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.Literal),
|
||||
Style: TableHead,
|
||||
}},
|
||||
}
|
||||
case bf.TableBody:
|
||||
return Element{
|
||||
Pre: "",
|
||||
Post: "",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.Literal),
|
||||
Style: TableBody,
|
||||
}},
|
||||
}
|
||||
case bf.TableRow:
|
||||
return Element{
|
||||
Pre: "\n",
|
||||
Post: "\n",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.Literal),
|
||||
Style: TableRow,
|
||||
}},
|
||||
}
|
||||
|
||||
default:
|
||||
return Element{
|
||||
Pre: "",
|
||||
Post: "",
|
||||
Fragments: []Fragment{{
|
||||
Token: string(node.Literal),
|
||||
}},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (tr *TermRenderer) Render(in string) string {
|
||||
return string(tr.RenderBytes([]byte(in)))
|
||||
}
|
||||
|
||||
func (tr *TermRenderer) RenderBytes(in []byte) []byte {
|
||||
return bf.Run(in, bf.WithRenderer(tr))
|
||||
}
|
||||
|
||||
func (tr *TermRenderer) renderFragment(w io.Writer, f Fragment) {
|
||||
rules := tr.style[f.Style]
|
||||
if rules == nil {
|
||||
fmt.Fprintf(w, "%s", f.Token)
|
||||
return
|
||||
}
|
||||
|
||||
if f.Style == Image {
|
||||
sm := ansimage.ScaleMode(2)
|
||||
dm := ansimage.DitheringMode(0)
|
||||
mc, _ := colorful.Hex("#000000")
|
||||
pix, err := ansimage.NewScaledFromURL(f.Token, 62, 80, mc, sm, dm)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s", pix.Render())
|
||||
return
|
||||
}
|
||||
|
||||
out := aurora.Reset(f.Token)
|
||||
if rules.Color != "" {
|
||||
i, err := strconv.Atoi(rules.Color)
|
||||
if err == nil && i >= 0 && i <= 255 {
|
||||
out = out.Index(uint8(i))
|
||||
}
|
||||
}
|
||||
if rules.BackgroundColor != "" {
|
||||
i, err := strconv.Atoi(rules.BackgroundColor)
|
||||
if err == nil && i >= 0 && i <= 255 {
|
||||
out = out.Index(uint8(i))
|
||||
}
|
||||
}
|
||||
if rules.Underline {
|
||||
out = out.Underline()
|
||||
}
|
||||
if rules.Bold {
|
||||
out = out.Bold()
|
||||
}
|
||||
if rules.Italic {
|
||||
out = out.Italic()
|
||||
}
|
||||
if rules.CrossedOut {
|
||||
out = out.CrossedOut()
|
||||
}
|
||||
if rules.Overlined {
|
||||
out = out.Overlined()
|
||||
}
|
||||
if rules.Inverse {
|
||||
out = out.Reverse()
|
||||
}
|
||||
if rules.Blink {
|
||||
out = out.Blink()
|
||||
}
|
||||
|
||||
w.Write([]byte(aurora.Sprintf("%s", out)))
|
||||
}
|
||||
|
||||
func (tr *TermRenderer) RenderNode(w io.Writer, node *bf.Node, entering bool) bf.WalkStatus {
|
||||
// fmt.Fprintf(w, "%s %t", node.Type, entering)
|
||||
e := NewElement(node)
|
||||
if entering && e.Pre != "" {
|
||||
fmt.Fprintf(w, "%s", e.Pre)
|
||||
}
|
||||
if !entering && e.Post != "" {
|
||||
fmt.Fprintf(w, "%s", e.Post)
|
||||
}
|
||||
if isChild(node) {
|
||||
return bf.GoToNext
|
||||
}
|
||||
if !entering {
|
||||
return bf.GoToNext
|
||||
}
|
||||
|
||||
for _, f := range e.Fragments {
|
||||
if node.Type == bf.CodeBlock {
|
||||
var theme string
|
||||
if rules, ok := tr.style[f.Style]; ok {
|
||||
if len(rules.Theme) > 0 {
|
||||
theme = rules.Theme
|
||||
}
|
||||
}
|
||||
if len(theme) > 0 {
|
||||
err := quick.Highlight(w, f.Token, string(node.CodeBlockData.Info), "terminal16m", theme)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
// if chroma failed, render the fragment as text below
|
||||
}
|
||||
}
|
||||
|
||||
if f.Token == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if f.Pre != "" {
|
||||
fmt.Fprintf(w, "%s", f.Pre)
|
||||
}
|
||||
tr.renderFragment(w, f)
|
||||
if f.Post != "" {
|
||||
fmt.Fprintf(w, "%s", f.Post)
|
||||
}
|
||||
}
|
||||
|
||||
return bf.GoToNext
|
||||
}
|
||||
|
||||
func (tr *TermRenderer) RenderHeader(w io.Writer, ast *bf.Node) {
|
||||
}
|
||||
|
||||
func (tr *TermRenderer) RenderFooter(w io.Writer, ast *bf.Node) {
|
||||
}
|
||||
|
||||
func isChild(node *bf.Node) bool {
|
||||
if node.Parent == nil {
|
||||
return false
|
||||
}
|
||||
switch node.Parent.Type {
|
||||
case bf.Heading, bf.Link, bf.Image, bf.Emph, bf.Strong:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
40
log.go
Normal file
40
log.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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
|
||||
}
|
||||
473
main.go
Normal file
473
main.go
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
// Package main provides the entry point for the Glow CLI application.
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
gap "github.com/muesli/go-app-paths"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var (
|
||||
// Version as provided by goreleaser.
|
||||
Version = ""
|
||||
// CommitSHA as provided by goreleaser.
|
||||
CommitSHA = ""
|
||||
|
||||
readmeNames = []string{"README.md", "README", "Readme.md", "Readme", "readme.md", "readme"}
|
||||
configFile string
|
||||
pager bool
|
||||
tui bool
|
||||
style string
|
||||
width uint
|
||||
showAllFiles bool
|
||||
showLineNumbers bool
|
||||
preserveNewLines bool
|
||||
mouse bool
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "glow [SOURCE|DIR]",
|
||||
Short: "Render markdown on the CLI, with pizzazz!",
|
||||
Long: paragraph(
|
||||
fmt.Sprintf("\nRender markdown on the CLI, %s!", keyword("with pizzazz")),
|
||||
),
|
||||
SilenceErrors: false,
|
||||
SilenceUsage: true,
|
||||
TraverseChildren: true,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
ValidArgsFunction: func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
},
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return validateOptions(cmd)
|
||||
},
|
||||
RunE: execute,
|
||||
}
|
||||
)
|
||||
|
||||
// source provides a readable markdown source.
|
||||
type source struct {
|
||||
reader io.ReadCloser
|
||||
URL string
|
||||
}
|
||||
|
||||
// sourceFromArg parses an argument and creates a readable source for it.
|
||||
func sourceFromArg(arg string) (*source, error) {
|
||||
// from stdin
|
||||
if arg == "-" {
|
||||
return &source{reader: os.Stdin}, nil
|
||||
}
|
||||
|
||||
// a GitHub or GitLab URL (even without the protocol):
|
||||
src, err := readmeURL(arg)
|
||||
if src != nil && err == nil {
|
||||
// if there's an error, try next methods...
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// HTTP(S) URLs:
|
||||
if u, err := url.ParseRequestURI(arg); err == nil && strings.Contains(arg, "://") { //nolint:nestif
|
||||
if u.Scheme != "" {
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, fmt.Errorf("%s is not a supported protocol", u.Scheme)
|
||||
}
|
||||
// consumer of the source is responsible for closing the ReadCloser.
|
||||
resp, err := http.Get(u.String()) //nolint: noctx,bodyclose
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get url: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP status %d", resp.StatusCode)
|
||||
}
|
||||
return &source{resp.Body, u.String()}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// a directory:
|
||||
if len(arg) == 0 {
|
||||
// use the current working dir if no argument was supplied
|
||||
arg = "."
|
||||
}
|
||||
st, err := os.Stat(arg)
|
||||
if err == nil && st.IsDir() { //nolint:nestif
|
||||
var src *source
|
||||
_ = filepath.Walk(arg, func(path string, _ os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, v := range readmeNames {
|
||||
if strings.EqualFold(filepath.Base(path), v) {
|
||||
r, err := os.Open(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
u, _ := filepath.Abs(path)
|
||||
src = &source{r, u}
|
||||
|
||||
// abort filepath.Walk
|
||||
return errors.New("source found")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if src != nil {
|
||||
return src, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("missing markdown source")
|
||||
}
|
||||
|
||||
r, err := os.Open(arg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to open file: %w", err)
|
||||
}
|
||||
u, err := filepath.Abs(arg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get absolute path: %w", err)
|
||||
}
|
||||
return &source{r, u}, nil
|
||||
}
|
||||
|
||||
// validateStyle checks if the style is a default style, if not, checks that
|
||||
// the custom style exists.
|
||||
func validateStyle(style string) error {
|
||||
if style != "auto" && styles.DefaultStyles[style] == nil {
|
||||
style = utils.ExpandPath(style)
|
||||
if _, err := os.Stat(style); errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("specified style does not exist: %s", style)
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("unable to stat file: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOptions(cmd *cobra.Command) error {
|
||||
// grab config values from Viper
|
||||
width = viper.GetUint("width")
|
||||
mouse = viper.GetBool("mouse")
|
||||
pager = viper.GetBool("pager")
|
||||
tui = viper.GetBool("tui")
|
||||
showAllFiles = viper.GetBool("all")
|
||||
preserveNewLines = viper.GetBool("preserveNewLines")
|
||||
showLineNumbers = viper.GetBool("showLineNumbers")
|
||||
|
||||
if pager && tui {
|
||||
return errors.New("cannot use both pager and tui")
|
||||
}
|
||||
|
||||
// validate the glamour style
|
||||
style = viper.GetString("style")
|
||||
if err := validateStyle(style); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
|
||||
// We want to use a special no-TTY style, when stdout is not a terminal
|
||||
// and there was no specific style passed by arg
|
||||
if !isTerminal && !cmd.Flags().Changed("style") {
|
||||
style = "notty"
|
||||
}
|
||||
|
||||
// Detect terminal width
|
||||
if !cmd.Flags().Changed("width") { //nolint:nestif
|
||||
if isTerminal && width == 0 {
|
||||
w, _, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err == nil {
|
||||
width = uint(w) //nolint:gosec
|
||||
}
|
||||
|
||||
if width > 120 {
|
||||
width = 120
|
||||
}
|
||||
}
|
||||
if width == 0 {
|
||||
width = 80
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stdinIsPipe() (bool, error) {
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to open file: %w", err)
|
||||
}
|
||||
if stat.Mode()&os.ModeCharDevice == 0 || stat.Size() > 0 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func execute(cmd *cobra.Command, args []string) error {
|
||||
// if stdin is a pipe then use stdin for input. note that you can also
|
||||
// explicitly use a - to read from stdin.
|
||||
if yes, err := stdinIsPipe(); err != nil {
|
||||
return err
|
||||
} else if yes {
|
||||
src := &source{reader: os.Stdin}
|
||||
defer src.reader.Close() //nolint:errcheck
|
||||
return executeCLI(cmd, src, os.Stdout)
|
||||
}
|
||||
|
||||
switch len(args) {
|
||||
// TUI running on cwd
|
||||
case 0:
|
||||
return runTUI("", "")
|
||||
|
||||
// TUI with possible dir argument
|
||||
case 1:
|
||||
// Validate that the argument is a directory. If it's not treat it as
|
||||
// an argument to the non-TUI version of Glow (via fallthrough).
|
||||
info, err := os.Stat(args[0])
|
||||
if err == nil && info.IsDir() {
|
||||
p, err := filepath.Abs(args[0])
|
||||
if err == nil {
|
||||
return runTUI(p, "")
|
||||
}
|
||||
}
|
||||
fallthrough
|
||||
|
||||
// CLI
|
||||
default:
|
||||
for _, arg := range args {
|
||||
if err := executeArg(cmd, arg, os.Stdout); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeArg(cmd *cobra.Command, arg string, w io.Writer) error {
|
||||
// create an io.Reader from the markdown source in cli-args
|
||||
src, err := sourceFromArg(arg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.reader.Close() //nolint:errcheck
|
||||
return executeCLI(cmd, src, w)
|
||||
}
|
||||
|
||||
func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error {
|
||||
b, err := io.ReadAll(src.reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read from reader: %w", err)
|
||||
}
|
||||
|
||||
b = utils.RemoveFrontmatter(b)
|
||||
|
||||
// render
|
||||
var baseURL string
|
||||
u, err := url.ParseRequestURI(src.URL)
|
||||
if err == nil {
|
||||
u.Path = filepath.Dir(u.Path)
|
||||
baseURL = u.String() + "/"
|
||||
}
|
||||
|
||||
isCode := !utils.IsMarkdownFile(src.URL)
|
||||
|
||||
// initialize glamour
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithColorProfile(lipgloss.ColorProfile()),
|
||||
utils.GlamourStyle(style, isCode),
|
||||
glamour.WithWordWrap(int(width)), //nolint:gosec
|
||||
glamour.WithBaseURL(baseURL),
|
||||
glamour.WithPreservedNewLines(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create renderer: %w", err)
|
||||
}
|
||||
|
||||
content := string(b)
|
||||
ext := filepath.Ext(src.URL)
|
||||
if isCode {
|
||||
content = utils.WrapCodeBlock(string(b), ext)
|
||||
}
|
||||
|
||||
out, err := r.Render(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to render markdown: %w", err)
|
||||
}
|
||||
|
||||
// display
|
||||
switch {
|
||||
case pager || cmd.Flags().Changed("pager"):
|
||||
pagerCmd := os.Getenv("PAGER")
|
||||
if pagerCmd == "" {
|
||||
pagerCmd = "less -r"
|
||||
}
|
||||
|
||||
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)
|
||||
c.Stdout = os.Stdout
|
||||
if err := c.Run(); err != nil {
|
||||
return fmt.Errorf("unable to run command: %w", err)
|
||||
}
|
||||
return nil
|
||||
case tui || cmd.Flags().Changed("tui"):
|
||||
path := ""
|
||||
if !isURL(src.URL) {
|
||||
path = src.URL
|
||||
}
|
||||
return runTUI(path, content)
|
||||
default:
|
||||
if _, err = fmt.Fprint(w, out); err != nil {
|
||||
return fmt.Errorf("unable to write to writer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func runTUI(path string, content string) error {
|
||||
// Read environment to get debugging stuff
|
||||
cfg, err := env.ParseAs[ui.Config]()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing config: %v", err)
|
||||
}
|
||||
|
||||
// use style set in env, or auto if unset
|
||||
if err := validateStyle(cfg.GlamourStyle); err != nil {
|
||||
cfg.GlamourStyle = style
|
||||
}
|
||||
|
||||
cfg.Path = path
|
||||
cfg.ShowAllFiles = showAllFiles
|
||||
cfg.ShowLineNumbers = showLineNumbers
|
||||
cfg.GlamourMaxWidth = width
|
||||
cfg.EnableMouse = mouse
|
||||
cfg.PreserveNewLines = preserveNewLines
|
||||
|
||||
// Run Bubble Tea program
|
||||
if _, err := ui.NewProgram(cfg, content).Run(); err != nil {
|
||||
return fmt.Errorf("unable to run tui program: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
closer, err := setupLog()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
_ = closer()
|
||||
os.Exit(1)
|
||||
}
|
||||
_ = closer()
|
||||
}
|
||||
|
||||
func init() {
|
||||
tryLoadConfigFromDefaultPlaces()
|
||||
if len(CommitSHA) >= 7 {
|
||||
vt := rootCmd.VersionTemplate()
|
||||
rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n")
|
||||
}
|
||||
if Version == "" {
|
||||
Version = "unknown (built from source)"
|
||||
}
|
||||
rootCmd.Version = Version
|
||||
rootCmd.InitDefaultCompletionCmd()
|
||||
|
||||
// "Glow Classic" cli arguments
|
||||
rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default %s)", viper.GetViper().ConfigFileUsed()))
|
||||
rootCmd.Flags().BoolVarP(&pager, "pager", "p", false, "display with pager")
|
||||
rootCmd.Flags().BoolVarP(&tui, "tui", "t", false, "display with tui")
|
||||
rootCmd.Flags().StringVarP(&style, "style", "s", styles.AutoStyle, "style name or JSON path")
|
||||
rootCmd.Flags().UintVarP(&width, "width", "w", 0, "word-wrap at width (set to 0 to disable)")
|
||||
rootCmd.Flags().BoolVarP(&showAllFiles, "all", "a", false, "show system files and directories (TUI-mode only)")
|
||||
rootCmd.Flags().BoolVarP(&showLineNumbers, "line-numbers", "l", false, "show line numbers (TUI-mode only)")
|
||||
rootCmd.Flags().BoolVarP(&preserveNewLines, "preserve-new-lines", "n", false, "preserve newlines in the output")
|
||||
rootCmd.Flags().BoolVarP(&mouse, "mouse", "m", false, "enable mouse wheel (TUI-mode only)")
|
||||
_ = rootCmd.Flags().MarkHidden("mouse")
|
||||
|
||||
// Config bindings
|
||||
_ = viper.BindPFlag("pager", rootCmd.Flags().Lookup("pager"))
|
||||
_ = viper.BindPFlag("tui", rootCmd.Flags().Lookup("tui"))
|
||||
_ = viper.BindPFlag("style", rootCmd.Flags().Lookup("style"))
|
||||
_ = viper.BindPFlag("width", rootCmd.Flags().Lookup("width"))
|
||||
_ = viper.BindPFlag("debug", rootCmd.Flags().Lookup("debug"))
|
||||
_ = viper.BindPFlag("mouse", rootCmd.Flags().Lookup("mouse"))
|
||||
_ = viper.BindPFlag("preserveNewLines", rootCmd.Flags().Lookup("preserve-new-lines"))
|
||||
_ = viper.BindPFlag("showLineNumbers", rootCmd.Flags().Lookup("line-numbers"))
|
||||
_ = viper.BindPFlag("all", rootCmd.Flags().Lookup("all"))
|
||||
|
||||
viper.SetDefault("style", styles.AutoStyle)
|
||||
viper.SetDefault("width", 0)
|
||||
viper.SetDefault("all", true)
|
||||
|
||||
rootCmd.AddCommand(configCmd, manCmd)
|
||||
}
|
||||
|
||||
func tryLoadConfigFromDefaultPlaces() {
|
||||
scope := gap.NewScope(gap.User, "glow")
|
||||
dirs, err := scope.ConfigDirs()
|
||||
if err != nil {
|
||||
fmt.Println("Could not load find configuration directory.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if c := os.Getenv("XDG_CONFIG_HOME"); c != "" {
|
||||
dirs = append([]string{filepath.Join(c, "glow")}, dirs...)
|
||||
}
|
||||
|
||||
if c := os.Getenv("GLOW_CONFIG_HOME"); c != "" {
|
||||
dirs = append([]string{c}, dirs...)
|
||||
}
|
||||
|
||||
for _, v := range dirs {
|
||||
viper.AddConfigPath(v)
|
||||
}
|
||||
|
||||
viper.SetConfigName("glow")
|
||||
viper.SetConfigType("yaml")
|
||||
viper.SetEnvPrefix("glow")
|
||||
viper.AutomaticEnv()
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
log.Warn("Could not parse configuration file", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
if used := viper.ConfigFileUsed(); used != "" {
|
||||
log.Debug("Using configuration file", "path", viper.ConfigFileUsed())
|
||||
return
|
||||
}
|
||||
|
||||
if viper.ConfigFileUsed() == "" {
|
||||
configFile = filepath.Join(dirs[0], "glow.yml")
|
||||
}
|
||||
if err := ensureConfigFile(); err != nil {
|
||||
log.Error("Could not create default configuration", "error", err)
|
||||
}
|
||||
}
|
||||
29
man_cmd.go
Normal file
29
man_cmd.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
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
|
||||
},
|
||||
}
|
||||
118
style.go
118
style.go
|
|
@ -1,108 +1,14 @@
|
|||
package gold
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
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
|
||||
)
|
||||
|
||||
type StyleType int
|
||||
|
||||
const (
|
||||
Document StyleType = iota
|
||||
BlockQuote
|
||||
List
|
||||
Item
|
||||
Paragraph
|
||||
Heading
|
||||
HorizontalRule
|
||||
Emph
|
||||
Strong
|
||||
Del
|
||||
Link
|
||||
LinkText
|
||||
Image
|
||||
Text
|
||||
HTMLBlock
|
||||
CodeBlock
|
||||
Softbreak
|
||||
Hardbreak
|
||||
Code
|
||||
HTMLSpan
|
||||
Table
|
||||
TableCell
|
||||
TableHead
|
||||
TableBody
|
||||
TableRow
|
||||
)
|
||||
|
||||
type ElementStyle struct {
|
||||
Color string `json:"color"`
|
||||
BackgroundColor string `json:"background_color"`
|
||||
Underline bool `json:"underline"`
|
||||
Bold bool `json:"bold"`
|
||||
Italic bool `json:"italic"`
|
||||
CrossedOut bool `json:"crossed_out"`
|
||||
Faint bool `json:"faint"`
|
||||
Conceal bool `json:"conceal"`
|
||||
Overlined bool `json:"overlined"`
|
||||
Inverse bool `json:"inverse"`
|
||||
Blink bool `json:"blink"`
|
||||
Theme string `json:"theme"`
|
||||
}
|
||||
|
||||
func keyToType(key string) (StyleType, error) {
|
||||
switch key {
|
||||
case "document":
|
||||
return Document, nil
|
||||
case "block_quote":
|
||||
return BlockQuote, nil
|
||||
case "list":
|
||||
return List, nil
|
||||
case "item":
|
||||
return Item, nil
|
||||
case "paragraph":
|
||||
return Paragraph, nil
|
||||
case "heading":
|
||||
return Heading, nil
|
||||
case "hr":
|
||||
return HorizontalRule, nil
|
||||
case "emph":
|
||||
return Emph, nil
|
||||
case "strong":
|
||||
return Strong, nil
|
||||
case "del":
|
||||
return Del, nil
|
||||
case "link":
|
||||
return Link, nil
|
||||
case "link_text":
|
||||
return LinkText, nil
|
||||
case "image":
|
||||
return Image, nil
|
||||
case "text":
|
||||
return Text, nil
|
||||
case "html_block":
|
||||
return HTMLBlock, nil
|
||||
case "code_block":
|
||||
return CodeBlock, nil
|
||||
case "softbreak":
|
||||
return Softbreak, nil
|
||||
case "hardbreak":
|
||||
return Hardbreak, nil
|
||||
case "code":
|
||||
return Code, nil
|
||||
case "html_span":
|
||||
return HTMLSpan, nil
|
||||
case "table":
|
||||
return Table, nil
|
||||
case "table_cel":
|
||||
return TableCell, nil
|
||||
case "table_head":
|
||||
return TableHead, nil
|
||||
case "table_body":
|
||||
return TableBody, nil
|
||||
case "table_row":
|
||||
return TableRow, nil
|
||||
|
||||
default:
|
||||
return 0, fmt.Errorf("Invalid style element type: %s", key)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
ui/config.go
Normal file
20
ui/config.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package ui
|
||||
|
||||
// Config contains TUI-specific configuration.
|
||||
type Config struct {
|
||||
ShowAllFiles bool
|
||||
ShowLineNumbers bool
|
||||
Gopath string `env:"GOPATH"`
|
||||
HomeDir string `env:"HOME"`
|
||||
GlamourMaxWidth uint
|
||||
GlamourStyle string `env:"GLAMOUR_STYLE"`
|
||||
EnableMouse bool
|
||||
PreserveNewLines bool
|
||||
|
||||
// Working directory or file path
|
||||
Path string
|
||||
|
||||
// For debugging the UI
|
||||
HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" envDefault:"true"`
|
||||
GlamourEnabled bool `env:"GLOW_ENABLE_GLAMOUR" envDefault:"true"`
|
||||
}
|
||||
19
ui/editor.go
Normal file
19
ui/editor.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
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)
|
||||
}
|
||||
15
ui/ignore_darwin.go
Normal file
15
ui/ignore_darwin.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
//go:build darwin
|
||||
// +build darwin
|
||||
|
||||
package ui
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
func ignorePatterns(m commonModel) []string {
|
||||
return []string{
|
||||
filepath.Join(m.cfg.HomeDir, "Library"),
|
||||
m.cfg.Gopath,
|
||||
"node_modules",
|
||||
".*",
|
||||
}
|
||||
}
|
||||
12
ui/ignore_general.go
Normal file
12
ui/ignore_general.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
//go:build !darwin
|
||||
// +build !darwin
|
||||
|
||||
package ui
|
||||
|
||||
func ignorePatterns(m commonModel) []string {
|
||||
return []string{
|
||||
m.cfg.Gopath,
|
||||
"node_modules",
|
||||
".*",
|
||||
}
|
||||
}
|
||||
6
ui/keys.go
Normal file
6
ui/keys.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package ui
|
||||
|
||||
const (
|
||||
keyEnter = "enter"
|
||||
keyEsc = "esc"
|
||||
)
|
||||
88
ui/markdown.go
Normal file
88
ui/markdown.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/dustin/go-humanize"
|
||||
"golang.org/x/text/runes"
|
||||
"golang.org/x/text/transform"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
type markdown struct {
|
||||
// Full path of a local markdown file. Only relevant to local documents and
|
||||
// those that have been stashed in this session.
|
||||
localPath string
|
||||
|
||||
// Value we filter against. This exists so that we can maintain positions
|
||||
// of filtered items if notes are edited while a filter is active. This
|
||||
// field is ephemeral, and should only be referenced during filtering.
|
||||
filterValue string
|
||||
|
||||
Body string
|
||||
Note string
|
||||
Modtime time.Time
|
||||
}
|
||||
|
||||
// Generate the value we're doing to filter against.
|
||||
func (m *markdown) buildFilterValue() {
|
||||
note, err := normalize(m.Note)
|
||||
if err != nil {
|
||||
log.Error("error normalizing", "note", m.Note, "error", err)
|
||||
m.filterValue = m.Note
|
||||
}
|
||||
|
||||
m.filterValue = note
|
||||
}
|
||||
|
||||
func (m markdown) relativeTime() string {
|
||||
return relativeTime(m.Modtime)
|
||||
}
|
||||
|
||||
// Normalize text to aid in the filtering process. In particular, we remove
|
||||
// diacritics, "ö" becomes "o". Note that Mn is the unicode key for nonspacing
|
||||
// marks.
|
||||
func normalize(in string) (string, error) {
|
||||
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
|
||||
out, _, err := transform.String(t, in)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error normalizing: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Return the time in a human-readable format relative to the current time.
|
||||
func relativeTime(then time.Time) string {
|
||||
now := time.Now()
|
||||
if ago := now.Sub(then); ago < time.Minute {
|
||||
return "just now"
|
||||
} else if ago < humanize.Week {
|
||||
return humanize.CustomRelTime(then, now, "ago", "from now", magnitudes)
|
||||
}
|
||||
return then.Format("02 Jan 2006 15:04 MST")
|
||||
}
|
||||
|
||||
// Magnitudes for relative time.
|
||||
var magnitudes = []humanize.RelTimeMagnitude{
|
||||
{D: time.Second, Format: "now", DivBy: time.Second},
|
||||
{D: 2 * time.Second, Format: "1 second %s", DivBy: 1},
|
||||
{D: time.Minute, Format: "%d seconds %s", DivBy: time.Second},
|
||||
{D: 2 * time.Minute, Format: "1 minute %s", DivBy: 1},
|
||||
{D: time.Hour, Format: "%d minutes %s", DivBy: time.Minute},
|
||||
{D: 2 * time.Hour, Format: "1 hour %s", DivBy: 1},
|
||||
{D: humanize.Day, Format: "%d hours %s", DivBy: time.Hour},
|
||||
{D: 2 * humanize.Day, Format: "1 day %s", DivBy: 1},
|
||||
{D: humanize.Week, Format: "%d days %s", DivBy: humanize.Day},
|
||||
{D: 2 * humanize.Week, Format: "1 week %s", DivBy: 1},
|
||||
{D: humanize.Month, Format: "%d weeks %s", DivBy: humanize.Week},
|
||||
{D: 2 * humanize.Month, Format: "1 month %s", DivBy: 1},
|
||||
{D: humanize.Year, Format: "%d months %s", DivBy: humanize.Month},
|
||||
{D: 18 * humanize.Month, Format: "1 year %s", DivBy: 1},
|
||||
{D: 2 * humanize.Year, Format: "2 years %s", DivBy: 1},
|
||||
{D: humanize.LongTime, Format: "%d years %s", DivBy: humanize.Year},
|
||||
{D: math.MaxInt64, Format: "a long while %s", DivBy: 1},
|
||||
}
|
||||
535
ui/pager.go
Normal file
535
ui/pager.go
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glow/v2/utils"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
runewidth "github.com/mattn/go-runewidth"
|
||||
"github.com/muesli/reflow/ansi"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
const (
|
||||
statusBarHeight = 1
|
||||
lineNumberWidth = 4
|
||||
)
|
||||
|
||||
var (
|
||||
pagerHelpHeight int
|
||||
|
||||
mintGreen = lipgloss.AdaptiveColor{Light: "#89F0CB", Dark: "#89F0CB"}
|
||||
darkGreen = lipgloss.AdaptiveColor{Light: "#1C8760", Dark: "#1C8760"}
|
||||
|
||||
lineNumberFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
|
||||
|
||||
statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
|
||||
statusBarBg = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"}
|
||||
|
||||
statusBarScrollPosStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#949494", Dark: "#5A5A5A"}).
|
||||
Background(statusBarBg).
|
||||
Render
|
||||
|
||||
statusBarNoteStyle = lipgloss.NewStyle().
|
||||
Foreground(statusBarNoteFg).
|
||||
Background(statusBarBg).
|
||||
Render
|
||||
|
||||
statusBarHelpStyle = lipgloss.NewStyle().
|
||||
Foreground(statusBarNoteFg).
|
||||
Background(lipgloss.AdaptiveColor{Light: "#DCDCDC", Dark: "#323232"}).
|
||||
Render
|
||||
|
||||
statusBarMessageStyle = lipgloss.NewStyle().
|
||||
Foreground(mintGreen).
|
||||
Background(darkGreen).
|
||||
Render
|
||||
|
||||
statusBarMessageScrollPosStyle = lipgloss.NewStyle().
|
||||
Foreground(mintGreen).
|
||||
Background(darkGreen).
|
||||
Render
|
||||
|
||||
statusBarMessageHelpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#B6FFE4")).
|
||||
Background(green).
|
||||
Render
|
||||
|
||||
helpViewStyle = lipgloss.NewStyle().
|
||||
Foreground(statusBarNoteFg).
|
||||
Background(lipgloss.AdaptiveColor{Light: "#f2f2f2", Dark: "#1B1B1B"}).
|
||||
Render
|
||||
|
||||
lineNumberStyle = lipgloss.NewStyle().
|
||||
Foreground(lineNumberFg).
|
||||
Render
|
||||
)
|
||||
|
||||
type (
|
||||
contentRenderedMsg string
|
||||
reloadMsg struct{}
|
||||
)
|
||||
|
||||
type pagerState int
|
||||
|
||||
const (
|
||||
pagerStateBrowse pagerState = iota
|
||||
pagerStateStatusMessage
|
||||
)
|
||||
|
||||
type pagerModel struct {
|
||||
common *commonModel
|
||||
viewport viewport.Model
|
||||
state pagerState
|
||||
showHelp bool
|
||||
|
||||
statusMessage string
|
||||
statusMessageTimer *time.Timer
|
||||
|
||||
// Current document being rendered, sans-glamour rendering. We cache
|
||||
// it here so we can re-render it on resize.
|
||||
currentDocument markdown
|
||||
|
||||
watcher *fsnotify.Watcher
|
||||
}
|
||||
|
||||
func newPagerModel(common *commonModel) pagerModel {
|
||||
// Init viewport
|
||||
vp := viewport.New(0, 0)
|
||||
vp.YPosition = 0
|
||||
vp.HighPerformanceRendering = config.HighPerformancePager
|
||||
|
||||
m := pagerModel{
|
||||
common: common,
|
||||
state: pagerStateBrowse,
|
||||
viewport: vp,
|
||||
}
|
||||
m.initWatcher()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *pagerModel) setSize(w, h int) {
|
||||
m.viewport.Width = w
|
||||
m.viewport.Height = h - statusBarHeight
|
||||
|
||||
if m.showHelp {
|
||||
if pagerHelpHeight == 0 {
|
||||
pagerHelpHeight = strings.Count(m.helpView(), "\n")
|
||||
}
|
||||
m.viewport.Height -= (statusBarHeight + pagerHelpHeight)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *pagerModel) setContent(s string) {
|
||||
m.viewport.SetContent(s)
|
||||
}
|
||||
|
||||
func (m *pagerModel) toggleHelp() {
|
||||
m.showHelp = !m.showHelp
|
||||
m.setSize(m.common.width, m.common.height)
|
||||
if m.viewport.PastBottom() {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
}
|
||||
|
||||
type pagerStatusMessage struct {
|
||||
message string
|
||||
isError bool
|
||||
}
|
||||
|
||||
// Perform stuff that needs to happen after a successful markdown stash. Note
|
||||
// that the returned command should be sent back the through the pager
|
||||
// update function.
|
||||
func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd {
|
||||
// Show a success message to the user
|
||||
m.state = pagerStateStatusMessage
|
||||
m.statusMessage = msg.message
|
||||
if m.statusMessageTimer != nil {
|
||||
m.statusMessageTimer.Stop()
|
||||
}
|
||||
m.statusMessageTimer = time.NewTimer(statusMessageTimeout)
|
||||
|
||||
return waitForStatusMessageTimeout(pagerContext, m.statusMessageTimer)
|
||||
}
|
||||
|
||||
func (m *pagerModel) unload() {
|
||||
log.Debug("unload")
|
||||
if m.showHelp {
|
||||
m.toggleHelp()
|
||||
}
|
||||
if m.statusMessageTimer != nil {
|
||||
m.statusMessageTimer.Stop()
|
||||
}
|
||||
m.state = pagerStateBrowse
|
||||
m.viewport.SetContent("")
|
||||
m.viewport.YOffset = 0
|
||||
m.unwatchFile()
|
||||
}
|
||||
|
||||
func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
|
||||
var (
|
||||
cmd tea.Cmd
|
||||
cmds []tea.Cmd
|
||||
)
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", keyEsc:
|
||||
if m.state != pagerStateBrowse {
|
||||
m.state = pagerStateBrowse
|
||||
return m, nil
|
||||
}
|
||||
case "home", "g":
|
||||
m.viewport.GotoTop()
|
||||
if m.viewport.HighPerformanceRendering {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
case "end", "G":
|
||||
m.viewport.GotoBottom()
|
||||
if m.viewport.HighPerformanceRendering {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
|
||||
case "d":
|
||||
m.viewport.HalfViewDown()
|
||||
if m.viewport.HighPerformanceRendering {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
|
||||
case "u":
|
||||
m.viewport.HalfViewUp()
|
||||
if m.viewport.HighPerformanceRendering {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
|
||||
case "e":
|
||||
lineno := int(math.RoundToEven(float64(m.viewport.TotalLineCount()) * m.viewport.ScrollPercent()))
|
||||
if m.viewport.AtTop() {
|
||||
lineno = 0
|
||||
}
|
||||
log.Info(
|
||||
"opening editor",
|
||||
"file", m.currentDocument.localPath,
|
||||
"line", fmt.Sprintf("%d/%d", lineno, m.viewport.TotalLineCount()),
|
||||
)
|
||||
return m, openEditor(m.currentDocument.localPath, lineno)
|
||||
|
||||
case "c":
|
||||
// Copy using OSC 52
|
||||
termenv.Copy(m.currentDocument.Body)
|
||||
// Copy using native system clipboard
|
||||
_ = clipboard.WriteAll(m.currentDocument.Body)
|
||||
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Copied contents", false}))
|
||||
|
||||
case "r":
|
||||
return m, loadLocalMarkdown(&m.currentDocument)
|
||||
|
||||
case "?":
|
||||
m.toggleHelp()
|
||||
if m.viewport.HighPerformanceRendering {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
}
|
||||
|
||||
// Glow has rendered the content
|
||||
case contentRenderedMsg:
|
||||
log.Info("content rendered", "state", m.state)
|
||||
|
||||
m.setContent(string(msg))
|
||||
if m.viewport.HighPerformanceRendering {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
cmds = append(cmds, m.watchFile)
|
||||
|
||||
// The file was changed on disk and we're reloading it
|
||||
case reloadMsg:
|
||||
return m, loadLocalMarkdown(&m.currentDocument)
|
||||
|
||||
// We've finished editing the document, potentially making changes. Let's
|
||||
// retrieve the latest version of the document so that we display
|
||||
// up-to-date contents.
|
||||
case editorFinishedMsg:
|
||||
return m, loadLocalMarkdown(&m.currentDocument)
|
||||
|
||||
// We've received terminal dimensions, either for the first time or
|
||||
// after a resize
|
||||
case tea.WindowSizeMsg:
|
||||
return m, renderWithGlamour(m, m.currentDocument.Body)
|
||||
|
||||
case statusMessageTimeoutMsg:
|
||||
m.state = pagerStateBrowse
|
||||
}
|
||||
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m pagerModel) View() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprint(&b, m.viewport.View()+"\n")
|
||||
|
||||
// Footer
|
||||
m.statusBarView(&b)
|
||||
|
||||
if m.showHelp {
|
||||
fmt.Fprint(&b, "\n"+m.helpView())
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m pagerModel) statusBarView(b *strings.Builder) {
|
||||
const (
|
||||
minPercent float64 = 0.0
|
||||
maxPercent float64 = 1.0
|
||||
percentToStringMagnitude float64 = 100.0
|
||||
)
|
||||
|
||||
showStatusMessage := m.state == pagerStateStatusMessage
|
||||
|
||||
// Logo
|
||||
logo := glowLogoView()
|
||||
|
||||
// Scroll percent
|
||||
percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent()))
|
||||
scrollPercent := fmt.Sprintf(" %3.f%% ", percent*percentToStringMagnitude)
|
||||
if showStatusMessage {
|
||||
scrollPercent = statusBarMessageScrollPosStyle(scrollPercent)
|
||||
} else {
|
||||
scrollPercent = statusBarScrollPosStyle(scrollPercent)
|
||||
}
|
||||
|
||||
// "Help" note
|
||||
var helpNote string
|
||||
if showStatusMessage {
|
||||
helpNote = statusBarMessageHelpStyle(" ? Help ")
|
||||
} else {
|
||||
helpNote = statusBarHelpStyle(" ? Help ")
|
||||
}
|
||||
|
||||
// Note
|
||||
var note string
|
||||
if showStatusMessage {
|
||||
note = m.statusMessage
|
||||
} else {
|
||||
note = m.currentDocument.Note
|
||||
}
|
||||
note = truncate.StringWithTail(" "+note+" ", uint(max(0, //nolint:gosec
|
||||
m.common.width-
|
||||
ansi.PrintableRuneWidth(logo)-
|
||||
ansi.PrintableRuneWidth(scrollPercent)-
|
||||
ansi.PrintableRuneWidth(helpNote),
|
||||
)), ellipsis)
|
||||
if showStatusMessage {
|
||||
note = statusBarMessageStyle(note)
|
||||
} else {
|
||||
note = statusBarNoteStyle(note)
|
||||
}
|
||||
|
||||
// Empty space
|
||||
padding := max(0,
|
||||
m.common.width-
|
||||
ansi.PrintableRuneWidth(logo)-
|
||||
ansi.PrintableRuneWidth(note)-
|
||||
ansi.PrintableRuneWidth(scrollPercent)-
|
||||
ansi.PrintableRuneWidth(helpNote),
|
||||
)
|
||||
emptySpace := strings.Repeat(" ", padding)
|
||||
if showStatusMessage {
|
||||
emptySpace = statusBarMessageStyle(emptySpace)
|
||||
} else {
|
||||
emptySpace = statusBarNoteStyle(emptySpace)
|
||||
}
|
||||
|
||||
fmt.Fprintf(b, "%s%s%s%s%s",
|
||||
logo,
|
||||
note,
|
||||
emptySpace,
|
||||
scrollPercent,
|
||||
helpNote,
|
||||
)
|
||||
}
|
||||
|
||||
func (m pagerModel) helpView() (s string) {
|
||||
col1 := []string{
|
||||
"g/home go to top",
|
||||
"G/end go to bottom",
|
||||
"c copy contents",
|
||||
"e edit this document",
|
||||
"r reload this document",
|
||||
"esc back to files",
|
||||
"q quit",
|
||||
}
|
||||
|
||||
s += "\n"
|
||||
s += "k/↑ up " + col1[0] + "\n"
|
||||
s += "j/↓ down " + col1[1] + "\n"
|
||||
s += "b/pgup page up " + col1[2] + "\n"
|
||||
s += "f/pgdn page down " + col1[3] + "\n"
|
||||
s += "u ½ page up " + col1[4] + "\n"
|
||||
s += "d ½ page down "
|
||||
|
||||
if len(col1) > 5 {
|
||||
s += col1[5]
|
||||
}
|
||||
|
||||
s = indent(s, 2)
|
||||
|
||||
// Fill up empty cells with spaces for background coloring
|
||||
if m.common.width > 0 {
|
||||
lines := strings.Split(s, "\n")
|
||||
for i := 0; i < len(lines); i++ {
|
||||
l := runewidth.StringWidth(lines[i])
|
||||
n := max(m.common.width-l, 0)
|
||||
lines[i] += strings.Repeat(" ", n)
|
||||
}
|
||||
|
||||
s = strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
return helpViewStyle(s)
|
||||
}
|
||||
|
||||
// COMMANDS
|
||||
|
||||
func renderWithGlamour(m pagerModel, md string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
s, err := glamourRender(m, md)
|
||||
if err != nil {
|
||||
log.Error("error rendering with Glamour", "error", err)
|
||||
return errMsg{err}
|
||||
}
|
||||
return contentRenderedMsg(s)
|
||||
}
|
||||
}
|
||||
|
||||
// This is where the magic happens.
|
||||
func glamourRender(m pagerModel, markdown string) (string, error) {
|
||||
trunc := lipgloss.NewStyle().MaxWidth(m.viewport.Width - lineNumberWidth).Render
|
||||
|
||||
if !config.GlamourEnabled {
|
||||
return markdown, nil
|
||||
}
|
||||
|
||||
isCode := !utils.IsMarkdownFile(m.currentDocument.Note)
|
||||
width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width)) //nolint:gosec
|
||||
if isCode {
|
||||
width = 0
|
||||
}
|
||||
|
||||
options := []glamour.TermRendererOption{
|
||||
utils.GlamourStyle(m.common.cfg.GlamourStyle, isCode),
|
||||
glamour.WithWordWrap(width),
|
||||
}
|
||||
|
||||
if m.common.cfg.PreserveNewLines {
|
||||
options = append(options, glamour.WithPreservedNewLines())
|
||||
}
|
||||
r, err := glamour.NewTermRenderer(options...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating glamour renderer: %w", err)
|
||||
}
|
||||
|
||||
if isCode {
|
||||
markdown = utils.WrapCodeBlock(markdown, filepath.Ext(m.currentDocument.Note))
|
||||
}
|
||||
|
||||
out, err := r.Render(markdown)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error rendering markdown: %w", err)
|
||||
}
|
||||
|
||||
if isCode {
|
||||
out = strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// trim lines
|
||||
lines := strings.Split(out, "\n")
|
||||
|
||||
var content strings.Builder
|
||||
for i, s := range lines {
|
||||
if isCode || m.common.cfg.ShowLineNumbers {
|
||||
content.WriteString(lineNumberStyle(fmt.Sprintf("%"+fmt.Sprint(lineNumberWidth)+"d", i+1)))
|
||||
content.WriteString(trunc(s))
|
||||
} else {
|
||||
content.WriteString(s)
|
||||
}
|
||||
|
||||
// don't add an artificial newline after the last split
|
||||
if i+1 < len(lines) {
|
||||
content.WriteRune('\n')
|
||||
}
|
||||
}
|
||||
|
||||
return content.String(), nil
|
||||
}
|
||||
|
||||
func (m *pagerModel) initWatcher() {
|
||||
var err error
|
||||
m.watcher, err = fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Error("error creating fsnotify watcher", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *pagerModel) watchFile() tea.Msg {
|
||||
dir := m.localDir()
|
||||
|
||||
if err := m.watcher.Add(dir); err != nil {
|
||||
log.Error("error adding dir to fsnotify watcher", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info("fsnotify watching dir", "dir", dir)
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-m.watcher.Events:
|
||||
if !ok || event.Name != m.currentDocument.localPath {
|
||||
continue
|
||||
}
|
||||
|
||||
if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug("fsnotify event", "file", event.Name, "event", event.Op)
|
||||
return reloadMsg{}
|
||||
case err, ok := <-m.watcher.Errors:
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
log.Debug("fsnotify error", "dir", dir, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *pagerModel) unwatchFile() {
|
||||
dir := m.localDir()
|
||||
|
||||
err := m.watcher.Remove(dir)
|
||||
if err == nil {
|
||||
log.Debug("fsnotify dir unwatched", "dir", dir)
|
||||
} else {
|
||||
log.Error("fsnotify fail to unwatch dir", "dir", dir, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *pagerModel) localDir() string {
|
||||
return filepath.Dir(m.currentDocument.localPath)
|
||||
}
|
||||
12
ui/sort.go
Normal file
12
ui/sort.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func sortMarkdowns(mds []*markdown) {
|
||||
slices.SortStableFunc(mds, func(a, b *markdown) int {
|
||||
return cmp.Compare(a.Note, b.Note)
|
||||
})
|
||||
}
|
||||
891
ui/stash.go
Normal file
891
ui/stash.go
Normal file
|
|
@ -0,0 +1,891 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/paginator"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/muesli/reflow/ansi"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sahilm/fuzzy"
|
||||
)
|
||||
|
||||
const (
|
||||
stashIndent = 1
|
||||
stashViewItemHeight = 3 // height of stash entry, including gap
|
||||
stashViewTopPadding = 5 // logo, status bar, gaps
|
||||
stashViewBottomPadding = 3 // pagination and gaps, but not help
|
||||
stashViewHorizontalPadding = 6
|
||||
)
|
||||
|
||||
var stashingStatusMessage = statusMessage{normalStatusMessage, "Stashing..."}
|
||||
|
||||
var (
|
||||
dividerDot = darkGrayFg.SetString(" • ")
|
||||
dividerBar = darkGrayFg.SetString(" │ ")
|
||||
|
||||
logoStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#ECFD65")).
|
||||
Background(fuchsia).
|
||||
Bold(true)
|
||||
|
||||
stashSpinnerStyle = lipgloss.NewStyle().
|
||||
Foreground(gray)
|
||||
stashInputPromptStyle = lipgloss.NewStyle().
|
||||
Foreground(yellowGreen).
|
||||
MarginRight(1)
|
||||
stashInputCursorStyle = lipgloss.NewStyle().
|
||||
Foreground(fuchsia).
|
||||
MarginRight(1)
|
||||
)
|
||||
|
||||
// MSG
|
||||
|
||||
type (
|
||||
filteredMarkdownMsg []*markdown
|
||||
fetchedMarkdownMsg *markdown
|
||||
)
|
||||
|
||||
// MODEL
|
||||
|
||||
// stashViewState is the high-level state of the file listing.
|
||||
type stashViewState int
|
||||
|
||||
const (
|
||||
stashStateReady stashViewState = iota
|
||||
stashStateLoadingDocument
|
||||
stashStateShowingError
|
||||
)
|
||||
|
||||
// The types of documents we are currently showing to the user.
|
||||
type sectionKey int
|
||||
|
||||
const (
|
||||
documentsSection = iota
|
||||
filterSection
|
||||
)
|
||||
|
||||
// section contains definitions and state information for displaying a tab and
|
||||
// its contents in the file listing view.
|
||||
type section struct {
|
||||
key sectionKey
|
||||
paginator paginator.Model
|
||||
cursor int
|
||||
}
|
||||
|
||||
// map sections to their associated types.
|
||||
var sections = map[sectionKey]section{}
|
||||
|
||||
// filterState is the current filtering state in the file listing.
|
||||
type filterState int
|
||||
|
||||
const (
|
||||
unfiltered filterState = iota // no filter set
|
||||
filtering // user is actively setting a filter
|
||||
filterApplied // a filter is applied and user is not editing filter
|
||||
)
|
||||
|
||||
// statusMessageType adds some context to the status message being sent.
|
||||
type statusMessageType int
|
||||
|
||||
// Types of status messages.
|
||||
const (
|
||||
normalStatusMessage statusMessageType = iota
|
||||
subtleStatusMessage
|
||||
errorStatusMessage
|
||||
)
|
||||
|
||||
// statusMessage is an ephemeral note displayed in the UI.
|
||||
type statusMessage struct {
|
||||
status statusMessageType
|
||||
message string
|
||||
}
|
||||
|
||||
func initSections() {
|
||||
sections = map[sectionKey]section{
|
||||
documentsSection: {
|
||||
key: documentsSection,
|
||||
paginator: newStashPaginator(),
|
||||
},
|
||||
filterSection: {
|
||||
key: filterSection,
|
||||
paginator: newStashPaginator(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// String returns a styled version of the status message appropriate for the
|
||||
// given context.
|
||||
func (s statusMessage) String() string {
|
||||
switch s.status { //nolint:exhaustive
|
||||
case subtleStatusMessage:
|
||||
return dimGreenFg(s.message)
|
||||
case errorStatusMessage:
|
||||
return redFg(s.message)
|
||||
default:
|
||||
return greenFg(s.message)
|
||||
}
|
||||
}
|
||||
|
||||
type stashModel struct {
|
||||
common *commonModel
|
||||
err error
|
||||
spinner spinner.Model
|
||||
filterInput textinput.Model
|
||||
viewState stashViewState
|
||||
filterState filterState
|
||||
showFullHelp bool
|
||||
showStatusMessage bool
|
||||
statusMessage statusMessage
|
||||
statusMessageTimer *time.Timer
|
||||
|
||||
// Available document sections we can cycle through. We use a slice, rather
|
||||
// than a map, because order is important.
|
||||
sections []section
|
||||
|
||||
// Index of the section we're currently looking at
|
||||
sectionIndex int
|
||||
|
||||
// Tracks if docs were loaded
|
||||
loaded bool
|
||||
|
||||
// The master set of markdown documents we're working with.
|
||||
markdowns []*markdown
|
||||
|
||||
// Markdown documents we're currently displaying. Filtering, toggles and so
|
||||
// on will alter this slice so we can show what is relevant. For that
|
||||
// reason, this field should be considered ephemeral.
|
||||
filteredMarkdowns []*markdown
|
||||
|
||||
// Page we're fetching stash items from on the server, which is different
|
||||
// from the local pagination. Generally, the server will return more items
|
||||
// than we can display at a time so we can paginate locally without having
|
||||
// to fetch every time.
|
||||
serverPage int64
|
||||
}
|
||||
|
||||
func (m stashModel) loadingDone() bool {
|
||||
return m.loaded
|
||||
}
|
||||
|
||||
func (m stashModel) currentSection() *section {
|
||||
return &m.sections[m.sectionIndex]
|
||||
}
|
||||
|
||||
func (m stashModel) paginator() *paginator.Model {
|
||||
return &m.currentSection().paginator
|
||||
}
|
||||
|
||||
func (m *stashModel) setPaginator(p paginator.Model) {
|
||||
m.currentSection().paginator = p
|
||||
}
|
||||
|
||||
func (m stashModel) cursor() int {
|
||||
return m.currentSection().cursor
|
||||
}
|
||||
|
||||
func (m *stashModel) setCursor(i int) {
|
||||
m.currentSection().cursor = i
|
||||
}
|
||||
|
||||
// Whether or not the spinner should be spinning.
|
||||
func (m stashModel) shouldSpin() bool {
|
||||
loading := !m.loadingDone()
|
||||
openingDocument := m.viewState == stashStateLoadingDocument
|
||||
return loading || openingDocument
|
||||
}
|
||||
|
||||
func (m *stashModel) setSize(width, height int) {
|
||||
m.common.width = width
|
||||
m.common.height = height
|
||||
|
||||
m.filterInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth(
|
||||
m.filterInput.Prompt,
|
||||
)
|
||||
|
||||
m.updatePagination()
|
||||
}
|
||||
|
||||
func (m *stashModel) resetFiltering() {
|
||||
m.filterState = unfiltered
|
||||
m.filterInput.Reset()
|
||||
m.filteredMarkdowns = nil
|
||||
|
||||
sortMarkdowns(m.markdowns)
|
||||
|
||||
// If the filtered section is present (it's always at the end) slice it out
|
||||
// of the sections slice to remove it from the UI.
|
||||
if m.sections[len(m.sections)-1].key == filterSection {
|
||||
m.sections = m.sections[:len(m.sections)-1]
|
||||
}
|
||||
|
||||
// If the current section is out of bounds (it would be if we cut down the
|
||||
// slice above) then return to the first section.
|
||||
if m.sectionIndex > len(m.sections)-1 {
|
||||
m.sectionIndex = 0
|
||||
}
|
||||
|
||||
// Update pagination after we've switched sections.
|
||||
m.updatePagination()
|
||||
}
|
||||
|
||||
// Is a filter currently being applied?
|
||||
func (m stashModel) filterApplied() bool {
|
||||
return m.filterState != unfiltered
|
||||
}
|
||||
|
||||
// Should we be updating the filter?
|
||||
func (m stashModel) shouldUpdateFilter() bool {
|
||||
// If we're in the middle of setting a note don't update the filter so that
|
||||
// the focus won't jump around.
|
||||
return m.filterApplied()
|
||||
}
|
||||
|
||||
// Update pagination according to the amount of markdowns for the current
|
||||
// state.
|
||||
func (m *stashModel) updatePagination() {
|
||||
_, helpHeight := m.helpView()
|
||||
|
||||
availableHeight := m.common.height -
|
||||
stashViewTopPadding -
|
||||
helpHeight -
|
||||
stashViewBottomPadding
|
||||
|
||||
m.paginator().PerPage = max(1, availableHeight/stashViewItemHeight)
|
||||
|
||||
if pages := len(m.getVisibleMarkdowns()); pages < 1 {
|
||||
m.paginator().SetTotalPages(1)
|
||||
} else {
|
||||
m.paginator().SetTotalPages(pages)
|
||||
}
|
||||
|
||||
// Make sure the page stays in bounds
|
||||
if m.paginator().Page >= m.paginator().TotalPages-1 {
|
||||
m.paginator().Page = max(0, m.paginator().TotalPages-1)
|
||||
}
|
||||
}
|
||||
|
||||
// MarkdownIndex returns the index of the currently selected markdown item.
|
||||
func (m stashModel) markdownIndex() int {
|
||||
return m.paginator().Page*m.paginator().PerPage + m.cursor()
|
||||
}
|
||||
|
||||
// Return the current selected markdown in the stash.
|
||||
func (m stashModel) selectedMarkdown() *markdown {
|
||||
i := m.markdownIndex()
|
||||
|
||||
mds := m.getVisibleMarkdowns()
|
||||
if i < 0 || len(mds) == 0 || len(mds) <= i {
|
||||
return nil
|
||||
}
|
||||
|
||||
return mds[i]
|
||||
}
|
||||
|
||||
// Adds markdown documents to the model.
|
||||
func (m *stashModel) addMarkdowns(mds ...*markdown) {
|
||||
if len(mds) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
m.markdowns = append(m.markdowns, mds...)
|
||||
if !m.filterApplied() {
|
||||
sortMarkdowns(m.markdowns)
|
||||
}
|
||||
|
||||
m.updatePagination()
|
||||
}
|
||||
|
||||
// Returns the markdowns that should be currently shown.
|
||||
func (m stashModel) getVisibleMarkdowns() []*markdown {
|
||||
if m.filterState == filtering || m.currentSection().key == filterSection {
|
||||
return m.filteredMarkdowns
|
||||
}
|
||||
|
||||
return m.markdowns
|
||||
}
|
||||
|
||||
// Command for opening a markdown document in the pager. Note that this also
|
||||
// alters the model.
|
||||
func (m *stashModel) openMarkdown(md *markdown) tea.Cmd {
|
||||
m.viewState = stashStateLoadingDocument
|
||||
cmd := loadLocalMarkdown(md)
|
||||
return tea.Batch(cmd, m.spinner.Tick)
|
||||
}
|
||||
|
||||
func (m *stashModel) hideStatusMessage() {
|
||||
m.showStatusMessage = false
|
||||
m.statusMessage = statusMessage{}
|
||||
if m.statusMessageTimer != nil {
|
||||
m.statusMessageTimer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *stashModel) moveCursorUp() {
|
||||
m.setCursor(m.cursor() - 1)
|
||||
if m.cursor() < 0 && m.paginator().Page == 0 {
|
||||
// Stop
|
||||
m.setCursor(0)
|
||||
return
|
||||
}
|
||||
|
||||
if m.cursor() >= 0 {
|
||||
return
|
||||
}
|
||||
// Go to previous page
|
||||
m.paginator().PrevPage()
|
||||
|
||||
m.setCursor(m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns())) - 1)
|
||||
}
|
||||
|
||||
func (m *stashModel) moveCursorDown() {
|
||||
itemsOnPage := m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns()))
|
||||
|
||||
m.setCursor(m.cursor() + 1)
|
||||
if m.cursor() < itemsOnPage {
|
||||
return
|
||||
}
|
||||
|
||||
if !m.paginator().OnLastPage() {
|
||||
m.paginator().NextPage()
|
||||
m.setCursor(0)
|
||||
return
|
||||
}
|
||||
|
||||
// During filtering the cursor position can exceed the number of
|
||||
// itemsOnPage. It's more intuitive to start the cursor at the
|
||||
// topmost position when moving it down in this scenario.
|
||||
if m.cursor() > itemsOnPage {
|
||||
m.setCursor(0)
|
||||
return
|
||||
}
|
||||
m.setCursor(itemsOnPage - 1)
|
||||
}
|
||||
|
||||
// INIT
|
||||
|
||||
func newStashModel(common *commonModel) stashModel {
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Line
|
||||
sp.Style = stashSpinnerStyle
|
||||
|
||||
si := textinput.New()
|
||||
si.Prompt = "Find:"
|
||||
si.PromptStyle = stashInputPromptStyle
|
||||
si.Cursor.Style = stashInputCursorStyle
|
||||
si.Focus()
|
||||
|
||||
s := []section{
|
||||
sections[documentsSection],
|
||||
}
|
||||
|
||||
m := stashModel{
|
||||
common: common,
|
||||
spinner: sp,
|
||||
filterInput: si,
|
||||
serverPage: 1,
|
||||
sections: s,
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func newStashPaginator() paginator.Model {
|
||||
p := paginator.New()
|
||||
p.Type = paginator.Dots
|
||||
p.ActiveDot = brightGrayFg("•")
|
||||
p.InactiveDot = darkGrayFg.Render("•")
|
||||
return p
|
||||
}
|
||||
|
||||
// UPDATE
|
||||
|
||||
func (m stashModel) update(msg tea.Msg) (stashModel, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case errMsg:
|
||||
m.err = msg
|
||||
|
||||
case localFileSearchFinished:
|
||||
// We're finished searching for local files
|
||||
m.loaded = true
|
||||
|
||||
case filteredMarkdownMsg:
|
||||
m.filteredMarkdowns = msg
|
||||
m.setCursor(0)
|
||||
return m, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
if m.shouldSpin() {
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case statusMessageTimeoutMsg:
|
||||
if applicationContext(msg) == stashContext {
|
||||
m.hideStatusMessage()
|
||||
}
|
||||
}
|
||||
|
||||
if m.filterState == filtering {
|
||||
cmds = append(cmds, m.handleFiltering(msg))
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// Updates per the current state
|
||||
switch m.viewState { //nolint:exhaustive
|
||||
case stashStateReady:
|
||||
cmds = append(cmds, m.handleDocumentBrowsing(msg))
|
||||
case stashStateShowingError:
|
||||
// Any key exists the error view
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
m.viewState = stashStateReady
|
||||
}
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// Updates for when a user is browsing the markdown listing.
|
||||
func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
numDocs := len(m.getVisibleMarkdowns())
|
||||
|
||||
switch msg := msg.(type) {
|
||||
// Handle keys
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "k", "ctrl+k", "up":
|
||||
m.moveCursorUp()
|
||||
|
||||
case "j", "ctrl+j", "down":
|
||||
m.moveCursorDown()
|
||||
|
||||
// Go to the very start
|
||||
case "home", "g":
|
||||
m.paginator().Page = 0
|
||||
m.setCursor(0)
|
||||
|
||||
// Go to the very end
|
||||
case "end", "G":
|
||||
m.paginator().Page = m.paginator().TotalPages - 1
|
||||
m.setCursor(m.paginator().ItemsOnPage(numDocs) - 1)
|
||||
|
||||
// Clear filter (if applicable)
|
||||
case keyEsc:
|
||||
if m.filterApplied() {
|
||||
m.resetFiltering()
|
||||
}
|
||||
|
||||
// Next section
|
||||
case "tab", "L":
|
||||
if len(m.sections) == 0 || m.filterState == filtering {
|
||||
break
|
||||
}
|
||||
m.sectionIndex++
|
||||
if m.sectionIndex >= len(m.sections) {
|
||||
m.sectionIndex = 0
|
||||
}
|
||||
m.updatePagination()
|
||||
|
||||
// Previous section
|
||||
case "shift+tab", "H":
|
||||
if len(m.sections) == 0 || m.filterState == filtering {
|
||||
break
|
||||
}
|
||||
m.sectionIndex--
|
||||
if m.sectionIndex < 0 {
|
||||
m.sectionIndex = len(m.sections) - 1
|
||||
}
|
||||
m.updatePagination()
|
||||
|
||||
case "F":
|
||||
m.loaded = false
|
||||
return findLocalFiles(*m.common)
|
||||
|
||||
// Edit document in EDITOR
|
||||
case "e":
|
||||
md := m.selectedMarkdown()
|
||||
|
||||
// In case no file is available
|
||||
if md == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return openEditor(md.localPath, 0)
|
||||
|
||||
// Open document
|
||||
case keyEnter:
|
||||
m.hideStatusMessage()
|
||||
|
||||
if numDocs == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Load the document from the server. We'll handle the message
|
||||
// that comes back in the main update function.
|
||||
md := m.selectedMarkdown()
|
||||
cmds = append(cmds, m.openMarkdown(md))
|
||||
|
||||
// Filter your notes
|
||||
case "/":
|
||||
m.hideStatusMessage()
|
||||
|
||||
// Build values we'll filter against
|
||||
for _, md := range m.markdowns {
|
||||
md.buildFilterValue()
|
||||
}
|
||||
|
||||
m.filteredMarkdowns = m.markdowns
|
||||
|
||||
m.paginator().Page = 0
|
||||
m.setCursor(0)
|
||||
m.filterState = filtering
|
||||
m.filterInput.CursorEnd()
|
||||
m.filterInput.Focus()
|
||||
return textinput.Blink
|
||||
|
||||
// Toggle full help
|
||||
case "?":
|
||||
m.showFullHelp = !m.showFullHelp
|
||||
m.updatePagination()
|
||||
|
||||
// Show errors
|
||||
case "!":
|
||||
if m.err != nil && m.viewState == stashStateReady {
|
||||
m.viewState = stashStateShowingError
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update paginator. Pagination key handling is done here, but it could
|
||||
// also be moved up to this level, in which case we'd use model methods
|
||||
// like model.PageUp().
|
||||
newPaginatorModel, cmd := m.paginator().Update(msg)
|
||||
m.setPaginator(newPaginatorModel)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// Extra paginator keystrokes
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "b", "u":
|
||||
m.paginator().PrevPage()
|
||||
case "f", "d":
|
||||
m.paginator().NextPage()
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the index in bounds when paginating
|
||||
itemsOnPage := m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns()))
|
||||
if m.cursor() > itemsOnPage-1 {
|
||||
m.setCursor(max(0, itemsOnPage-1))
|
||||
}
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// Updates for when a user is in the filter editing interface.
|
||||
func (m *stashModel) handleFiltering(msg tea.Msg) tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
// Handle keys
|
||||
if msg, ok := msg.(tea.KeyMsg); ok { //nolint:nestif
|
||||
switch msg.String() {
|
||||
case keyEsc:
|
||||
// Cancel filtering
|
||||
m.resetFiltering()
|
||||
case keyEnter, "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down":
|
||||
m.hideStatusMessage()
|
||||
|
||||
if len(m.markdowns) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
h := m.getVisibleMarkdowns()
|
||||
|
||||
// If we've filtered down to nothing, clear the filter
|
||||
if len(h) == 0 {
|
||||
m.viewState = stashStateReady
|
||||
m.resetFiltering()
|
||||
break
|
||||
}
|
||||
|
||||
// When there's only one filtered markdown left we can just
|
||||
// "open" it directly
|
||||
if len(h) == 1 {
|
||||
m.viewState = stashStateReady
|
||||
m.resetFiltering()
|
||||
cmds = append(cmds, m.openMarkdown(h[0]))
|
||||
break
|
||||
}
|
||||
|
||||
// Add new section if it's not present
|
||||
if m.sections[len(m.sections)-1].key != filterSection {
|
||||
m.sections = append(m.sections, sections[filterSection])
|
||||
}
|
||||
m.sectionIndex = len(m.sections) - 1
|
||||
|
||||
m.filterInput.Blur()
|
||||
|
||||
m.filterState = filterApplied
|
||||
if m.filterInput.Value() == "" {
|
||||
m.resetFiltering()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the filter text input component
|
||||
newFilterInputModel, inputCmd := m.filterInput.Update(msg)
|
||||
currentFilterVal := m.filterInput.Value()
|
||||
newFilterVal := newFilterInputModel.Value()
|
||||
m.filterInput = newFilterInputModel
|
||||
cmds = append(cmds, inputCmd)
|
||||
|
||||
// If the filtering input has changed, request updated filtering
|
||||
if newFilterVal != currentFilterVal {
|
||||
cmds = append(cmds, filterMarkdowns(*m))
|
||||
}
|
||||
|
||||
// Update pagination
|
||||
m.updatePagination()
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// VIEW
|
||||
|
||||
func (m stashModel) view() string {
|
||||
var s string
|
||||
switch m.viewState {
|
||||
case stashStateShowingError:
|
||||
return errorView(m.err, false)
|
||||
case stashStateLoadingDocument:
|
||||
s += " " + m.spinner.View() + " Loading document..."
|
||||
case stashStateReady:
|
||||
loadingIndicator := " "
|
||||
if m.shouldSpin() {
|
||||
loadingIndicator = m.spinner.View()
|
||||
}
|
||||
|
||||
// Only draw the normal header if we're not using the header area for
|
||||
// something else (like a note or delete prompt).
|
||||
header := m.headerView()
|
||||
|
||||
// Rules for the logo, filter and status message.
|
||||
logoOrFilter := " "
|
||||
if m.showStatusMessage && m.filterState == filtering {
|
||||
logoOrFilter += m.statusMessage.String()
|
||||
} else if m.filterState == filtering {
|
||||
logoOrFilter += m.filterInput.View()
|
||||
} else {
|
||||
logoOrFilter += glowLogoView()
|
||||
if m.showStatusMessage {
|
||||
logoOrFilter += " " + m.statusMessage.String()
|
||||
}
|
||||
}
|
||||
logoOrFilter = truncate.StringWithTail(logoOrFilter, uint(m.common.width-1), ellipsis) //nolint:gosec
|
||||
|
||||
help, helpHeight := m.helpView()
|
||||
|
||||
populatedView := m.populatedView()
|
||||
populatedViewHeight := strings.Count(populatedView, "\n") + 2
|
||||
|
||||
// We need to fill any empty height with newlines so the footer reaches
|
||||
// the bottom.
|
||||
availHeight := m.common.height -
|
||||
stashViewTopPadding -
|
||||
populatedViewHeight -
|
||||
helpHeight -
|
||||
stashViewBottomPadding
|
||||
blankLines := strings.Repeat("\n", max(0, availHeight))
|
||||
|
||||
var pagination string
|
||||
if m.paginator().TotalPages > 1 {
|
||||
pagination = m.paginator().View()
|
||||
|
||||
// If the dot pagination is wider than the width of the window
|
||||
// use the arabic paginator.
|
||||
if ansi.PrintableRuneWidth(pagination) > m.common.width-stashViewHorizontalPadding {
|
||||
// Copy the paginator since m.paginator() returns a pointer to
|
||||
// the active paginator and we don't want to mutate it. In
|
||||
// normal cases, where the paginator is not a pointer, we could
|
||||
// safely change the model parameters for rendering here as the
|
||||
// current model is discarded after reuturning from a View().
|
||||
// One could argue, in fact, that using pointers in
|
||||
// a functional framework is an antipattern and our use of
|
||||
// pointers in our model should be refactored away.
|
||||
p := *(m.paginator())
|
||||
p.Type = paginator.Arabic
|
||||
pagination = paginationStyle.Render(p.View())
|
||||
}
|
||||
}
|
||||
|
||||
s += fmt.Sprintf(
|
||||
"%s%s\n\n %s\n\n%s\n\n%s %s\n\n%s",
|
||||
loadingIndicator,
|
||||
logoOrFilter,
|
||||
header,
|
||||
populatedView,
|
||||
blankLines,
|
||||
pagination,
|
||||
help,
|
||||
)
|
||||
}
|
||||
return "\n" + indent(s, stashIndent)
|
||||
}
|
||||
|
||||
func glowLogoView() string {
|
||||
return logoStyle.Render(" Glow ")
|
||||
}
|
||||
|
||||
func (m stashModel) headerView() string {
|
||||
localCount := len(m.markdowns)
|
||||
|
||||
var sections []string //nolint:prealloc
|
||||
|
||||
// Filter results
|
||||
if m.filterState == filtering {
|
||||
if localCount == 0 {
|
||||
return grayFg("Nothing found.")
|
||||
}
|
||||
if localCount > 0 {
|
||||
sections = append(sections, fmt.Sprintf("%d local", localCount))
|
||||
}
|
||||
|
||||
for i := range sections {
|
||||
sections[i] = grayFg(sections[i])
|
||||
}
|
||||
|
||||
return strings.Join(sections, dividerDot.String())
|
||||
}
|
||||
|
||||
// Tabs
|
||||
for i, v := range m.sections {
|
||||
var s string
|
||||
|
||||
switch v.key {
|
||||
case documentsSection:
|
||||
s = fmt.Sprintf("%d documents", localCount)
|
||||
|
||||
case filterSection:
|
||||
s = fmt.Sprintf("%d “%s”", len(m.filteredMarkdowns), m.filterInput.Value())
|
||||
}
|
||||
|
||||
if m.sectionIndex == i && len(m.sections) > 1 {
|
||||
s = selectedTabStyle.Render(s)
|
||||
} else {
|
||||
s = tabStyle.Render(s)
|
||||
}
|
||||
sections = append(sections, s)
|
||||
}
|
||||
|
||||
return strings.Join(sections, dividerBar.String())
|
||||
}
|
||||
|
||||
func (m stashModel) populatedView() string {
|
||||
mds := m.getVisibleMarkdowns()
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Empty states
|
||||
if len(mds) == 0 {
|
||||
f := func(s string) {
|
||||
b.WriteString(" " + grayFg(s))
|
||||
}
|
||||
|
||||
switch m.sections[m.sectionIndex].key {
|
||||
case documentsSection:
|
||||
if m.loadingDone() {
|
||||
f("No files found.")
|
||||
} else {
|
||||
f("Looking for local files...")
|
||||
}
|
||||
case filterSection:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
if len(mds) > 0 {
|
||||
start, end := m.paginator().GetSliceBounds(len(mds))
|
||||
docs := mds[start:end]
|
||||
|
||||
for i, md := range docs {
|
||||
stashItemView(&b, m, i, md)
|
||||
if i != len(docs)-1 {
|
||||
fmt.Fprintf(&b, "\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there aren't enough items to fill up this page (always the last page)
|
||||
// then we need to add some newlines to fill up the space where stash items
|
||||
// would have been.
|
||||
itemsOnPage := m.paginator().ItemsOnPage(len(mds))
|
||||
if itemsOnPage < m.paginator().PerPage {
|
||||
n := (m.paginator().PerPage - itemsOnPage) * stashViewItemHeight
|
||||
if len(mds) == 0 {
|
||||
n -= stashViewItemHeight - 1
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
fmt.Fprint(&b, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// COMMANDS
|
||||
|
||||
func loadLocalMarkdown(md *markdown) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if md.localPath == "" {
|
||||
return errMsg{errors.New("could not load file: missing path")}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(md.localPath)
|
||||
if err != nil {
|
||||
log.Debug("error reading local file", "error", err)
|
||||
return errMsg{err}
|
||||
}
|
||||
md.Body = string(data)
|
||||
return fetchedMarkdownMsg(md)
|
||||
}
|
||||
}
|
||||
|
||||
func filterMarkdowns(m stashModel) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.filterInput.Value() == "" || !m.filterApplied() {
|
||||
return filteredMarkdownMsg(m.markdowns) // return everything
|
||||
}
|
||||
|
||||
targets := []string{}
|
||||
mds := m.markdowns
|
||||
|
||||
for _, t := range mds {
|
||||
targets = append(targets, t.filterValue)
|
||||
}
|
||||
|
||||
ranks := fuzzy.Find(m.filterInput.Value(), targets)
|
||||
sort.Stable(ranks)
|
||||
|
||||
filtered := []*markdown{}
|
||||
for _, r := range ranks {
|
||||
filtered = append(filtered, mds[r.Index])
|
||||
}
|
||||
|
||||
return filteredMarkdownMsg(filtered)
|
||||
}
|
||||
}
|
||||
297
ui/stashhelp.go
Normal file
297
ui/stashhelp.go
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/muesli/reflow/ansi"
|
||||
)
|
||||
|
||||
// helpEntry is a entry in a help menu containing values for a keystroke and
|
||||
// it's associated action.
|
||||
type helpEntry struct{ key, val string }
|
||||
|
||||
// helpColumn is a group of helpEntries which will be rendered into a column.
|
||||
type helpColumn []helpEntry
|
||||
|
||||
// newHelpColumn creates a help column from pairs of string arguments
|
||||
// representing keys and values. If the arguments are not even (and therein
|
||||
// not every key has a matching value) the function will panic.
|
||||
func newHelpColumn(pairs ...string) (h helpColumn) {
|
||||
if len(pairs)%2 != 0 {
|
||||
panic("help text group must have an even number of items")
|
||||
}
|
||||
|
||||
for i := 0; i < len(pairs); i = i + 2 {
|
||||
h = append(h, helpEntry{key: pairs[i], val: pairs[i+1]})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// render returns styled and formatted rows from keys and values.
|
||||
func (h helpColumn) render(height int) (rows []string) {
|
||||
keyWidth, valWidth := h.maxWidths()
|
||||
|
||||
for i := 0; i < height; i++ {
|
||||
var (
|
||||
b = strings.Builder{}
|
||||
k, v string
|
||||
)
|
||||
if i < len(h) {
|
||||
k = h[i].key
|
||||
v = h[i].val
|
||||
|
||||
switch k {
|
||||
case "s":
|
||||
k = greenFg(k)
|
||||
v = semiDimGreenFg(v)
|
||||
default:
|
||||
k = grayFg(k)
|
||||
v = midGrayFg(v)
|
||||
}
|
||||
}
|
||||
b.WriteString(k)
|
||||
b.WriteString(strings.Repeat(" ", keyWidth-ansi.PrintableRuneWidth(k))) // pad keys
|
||||
b.WriteString(" ") // gap
|
||||
b.WriteString(v)
|
||||
b.WriteString(strings.Repeat(" ", valWidth-ansi.PrintableRuneWidth(v))) // pad vals
|
||||
rows = append(rows, b.String())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// maxWidths returns the widest key and values in the column, respectively.
|
||||
func (h helpColumn) maxWidths() (maxKey int, maxVal int) {
|
||||
for _, v := range h {
|
||||
kw := ansi.PrintableRuneWidth(v.key)
|
||||
vw := ansi.PrintableRuneWidth(v.val)
|
||||
if kw > maxKey {
|
||||
maxKey = kw
|
||||
}
|
||||
if vw > maxVal {
|
||||
maxVal = vw
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// helpView returns either the mini or full help view depending on the state of
|
||||
// the model, as well as the total height of the help view.
|
||||
func (m stashModel) helpView() (string, int) {
|
||||
numDocs := len(m.getVisibleMarkdowns())
|
||||
|
||||
// Help for when we're filtering
|
||||
if m.filterState == filtering {
|
||||
var h []string
|
||||
|
||||
switch numDocs {
|
||||
case 0:
|
||||
h = []string{"enter/esc", "cancel"}
|
||||
case 1:
|
||||
h = []string{"enter", "open", "esc", "cancel"}
|
||||
default:
|
||||
h = []string{"enter", "confirm", "esc", "cancel", "ctrl+j/ctrl+k ↑/↓", "choose"}
|
||||
}
|
||||
|
||||
return m.renderHelp(h)
|
||||
}
|
||||
|
||||
var (
|
||||
navHelp []string
|
||||
filterHelp []string
|
||||
selectionHelp []string
|
||||
editHelp []string
|
||||
sectionHelp []string
|
||||
appHelp []string
|
||||
)
|
||||
|
||||
if numDocs > 0 && m.showFullHelp {
|
||||
navHelp = []string{"enter", "open", "j/k ↑/↓", "choose"}
|
||||
}
|
||||
|
||||
if len(m.sections) > 1 {
|
||||
if m.showFullHelp {
|
||||
navHelp = append(navHelp, "tab/shift+tab", "section")
|
||||
} else {
|
||||
navHelp = append(navHelp, "tab", "section")
|
||||
}
|
||||
}
|
||||
|
||||
if m.paginator().TotalPages > 1 {
|
||||
navHelp = append(navHelp, "h/l ←/→", "page")
|
||||
}
|
||||
|
||||
// If we're browsing a filtered set
|
||||
if m.filterApplied() {
|
||||
filterHelp = []string{"/", "edit search", "esc", "clear filter"}
|
||||
} else {
|
||||
filterHelp = []string{"/", "find"}
|
||||
}
|
||||
|
||||
// If there are errors
|
||||
if m.err != nil {
|
||||
appHelp = append(appHelp, "!", "errors")
|
||||
}
|
||||
|
||||
appHelp = append(appHelp, "r", "refresh")
|
||||
|
||||
if numDocs > 0 {
|
||||
appHelp = append(appHelp, "e", "edit")
|
||||
}
|
||||
|
||||
appHelp = append(appHelp, "q", "quit")
|
||||
|
||||
// Detailed help
|
||||
if m.showFullHelp {
|
||||
if m.filterState != filtering {
|
||||
appHelp = append(appHelp, "?", "close help")
|
||||
}
|
||||
return m.renderHelp(navHelp, filterHelp, append(selectionHelp, editHelp...), sectionHelp, appHelp)
|
||||
}
|
||||
|
||||
// Mini help
|
||||
if m.filterState != filtering {
|
||||
appHelp = append(appHelp, "?", "more")
|
||||
}
|
||||
return m.renderHelp(navHelp, filterHelp, selectionHelp, editHelp, sectionHelp, appHelp)
|
||||
}
|
||||
|
||||
const minHelpViewHeight = 5
|
||||
|
||||
// renderHelp returns the rendered help view and associated line height for
|
||||
// the given groups of help items.
|
||||
func (m stashModel) renderHelp(groups ...[]string) (string, int) {
|
||||
if m.showFullHelp {
|
||||
str := m.fullHelpView(groups...)
|
||||
numLines := strings.Count(str, "\n") + 1
|
||||
return str, max(numLines, minHelpViewHeight)
|
||||
}
|
||||
return m.miniHelpView(concatStringSlices(groups...)...), 1
|
||||
}
|
||||
|
||||
// Builds the help view from various sections pieces, truncating it if the view
|
||||
// would otherwise wrap to two lines. Help view entries should come in as pairs,
|
||||
// with the first being the key and the second being the help text.
|
||||
func (m stashModel) miniHelpView(entries ...string) string {
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
truncationChar = subtleStyle.Render("…")
|
||||
truncationWidth = ansi.PrintableRuneWidth(truncationChar)
|
||||
)
|
||||
|
||||
var (
|
||||
next string
|
||||
leftGutter = " "
|
||||
maxWidth = m.common.width -
|
||||
stashViewHorizontalPadding -
|
||||
truncationWidth -
|
||||
ansi.PrintableRuneWidth(leftGutter)
|
||||
s = leftGutter
|
||||
)
|
||||
|
||||
for i := 0; i < len(entries); i = i + 2 {
|
||||
k := entries[i]
|
||||
v := entries[i+1]
|
||||
|
||||
k = grayFg(k)
|
||||
v = midGrayFg(v)
|
||||
|
||||
next = fmt.Sprintf("%s %s", k, v)
|
||||
|
||||
if i < len(entries)-2 {
|
||||
next += dividerDot.String()
|
||||
}
|
||||
|
||||
// Only this (and the following) help text items if we have the
|
||||
// horizontal space
|
||||
if ansi.PrintableRuneWidth(s)+ansi.PrintableRuneWidth(next) >= maxWidth {
|
||||
s += truncationChar
|
||||
break
|
||||
}
|
||||
|
||||
s += next
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (m stashModel) fullHelpView(groups ...[]string) string {
|
||||
var tallestCol int
|
||||
columns := make([]helpColumn, 0, len(groups))
|
||||
renderedCols := make([][]string, 0, len(groups)) // final rows grouped by column
|
||||
|
||||
// Get key/value pairs
|
||||
for _, g := range groups {
|
||||
if len(g) == 0 {
|
||||
continue // ignore empty columns
|
||||
}
|
||||
|
||||
columns = append(columns, newHelpColumn(g...))
|
||||
}
|
||||
|
||||
// Find the tallest column
|
||||
for _, c := range columns {
|
||||
if len(c) > tallestCol {
|
||||
tallestCol = len(c)
|
||||
}
|
||||
}
|
||||
|
||||
// Build columns
|
||||
for _, c := range columns {
|
||||
renderedCols = append(renderedCols, c.render(tallestCol))
|
||||
}
|
||||
|
||||
// Merge columns
|
||||
return mergeColumns(renderedCols...)
|
||||
}
|
||||
|
||||
// Merge columns together to build the help view.
|
||||
func mergeColumns(cols ...[]string) string {
|
||||
const minimumHeight = 3
|
||||
|
||||
// Find the tallest column
|
||||
var tallestCol int
|
||||
for _, v := range cols {
|
||||
n := len(v)
|
||||
if n > tallestCol {
|
||||
tallestCol = n
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the tallest column meets the minimum height
|
||||
if tallestCol < minimumHeight {
|
||||
tallestCol = minimumHeight
|
||||
}
|
||||
|
||||
b := strings.Builder{}
|
||||
for i := 0; i < tallestCol; i++ {
|
||||
for j, col := range cols {
|
||||
if i >= len(col) {
|
||||
continue // skip if we're past the length of this column
|
||||
}
|
||||
if j == 0 {
|
||||
b.WriteString(" ") // gutter
|
||||
} else if j > 0 {
|
||||
b.WriteString(" ") // gap
|
||||
}
|
||||
b.WriteString(col[i])
|
||||
}
|
||||
if i < tallestCol-1 {
|
||||
b.WriteRune('\n')
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func concatStringSlices(s ...[]string) (agg []string) {
|
||||
for _, v := range s {
|
||||
agg = append(agg, v...)
|
||||
}
|
||||
return
|
||||
}
|
||||
120
ui/stashitem.go
Normal file
120
ui/stashitem.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sahilm/fuzzy"
|
||||
)
|
||||
|
||||
const (
|
||||
verticalLine = "│"
|
||||
fileListingStashIcon = "• "
|
||||
)
|
||||
|
||||
func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) {
|
||||
var (
|
||||
truncateTo = uint(m.common.width - stashViewHorizontalPadding*2) //nolint:gosec
|
||||
gutter string
|
||||
title = truncate.StringWithTail(md.Note, truncateTo, ellipsis)
|
||||
date = md.relativeTime()
|
||||
editedBy = ""
|
||||
hasEditedBy = false
|
||||
icon = ""
|
||||
separator = ""
|
||||
)
|
||||
|
||||
isSelected := index == m.cursor()
|
||||
isFiltering := m.filterState == filtering
|
||||
singleFilteredItem := isFiltering && len(m.getVisibleMarkdowns()) == 1
|
||||
|
||||
// If there are multiple items being filtered don't highlight a selected
|
||||
// item in the results. If we've filtered down to one item, however,
|
||||
// highlight that first item since pressing return will open it.
|
||||
if isSelected && !isFiltering || singleFilteredItem { //nolint:nestif
|
||||
// Selected item
|
||||
if m.statusMessage == stashingStatusMessage {
|
||||
gutter = greenFg(verticalLine)
|
||||
icon = dimGreenFg(icon)
|
||||
title = greenFg(title)
|
||||
date = semiDimGreenFg(date)
|
||||
editedBy = semiDimGreenFg(editedBy)
|
||||
separator = semiDimGreenFg(separator)
|
||||
} else {
|
||||
gutter = dullFuchsiaFg(verticalLine)
|
||||
if m.currentSection().key == filterSection &&
|
||||
m.filterState == filterApplied || singleFilteredItem {
|
||||
s := lipgloss.NewStyle().Foreground(fuchsia)
|
||||
title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true))
|
||||
} else {
|
||||
title = fuchsiaFg(title)
|
||||
icon = fuchsiaFg(icon)
|
||||
}
|
||||
date = dimFuchsiaFg(date)
|
||||
editedBy = dimDullFuchsiaFg(editedBy)
|
||||
separator = dullFuchsiaFg(separator)
|
||||
}
|
||||
} else {
|
||||
gutter = " "
|
||||
if m.statusMessage == stashingStatusMessage {
|
||||
icon = dimGreenFg(icon)
|
||||
title = greenFg(title)
|
||||
date = semiDimGreenFg(date)
|
||||
editedBy = semiDimGreenFg(editedBy)
|
||||
separator = semiDimGreenFg(separator)
|
||||
} else if isFiltering && m.filterInput.Value() == "" {
|
||||
icon = dimGreenFg(icon)
|
||||
title = dimNormalFg(title)
|
||||
date = dimBrightGrayFg(date)
|
||||
editedBy = dimBrightGrayFg(editedBy)
|
||||
separator = dimBrightGrayFg(separator)
|
||||
} else {
|
||||
icon = greenFg(icon)
|
||||
|
||||
s := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"})
|
||||
title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true))
|
||||
date = grayFg(date)
|
||||
editedBy = midGrayFg(editedBy)
|
||||
separator = brightGrayFg(separator)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(b, "%s %s%s%s%s\n", gutter, icon, separator, separator, title)
|
||||
fmt.Fprintf(b, "%s %s", gutter, date)
|
||||
if hasEditedBy {
|
||||
fmt.Fprintf(b, " %s", editedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle lipgloss.Style) string {
|
||||
b := strings.Builder{}
|
||||
|
||||
normalizedHay, err := normalize(haystack)
|
||||
if err != nil {
|
||||
log.Error("error normalizing", "haystack", haystack, "error", err)
|
||||
}
|
||||
|
||||
matches := fuzzy.Find(needles, []string{normalizedHay})
|
||||
if len(matches) == 0 {
|
||||
return defaultStyle.Render(haystack)
|
||||
}
|
||||
|
||||
m := matches[0] // only one match exists
|
||||
for i, rune := range []rune(haystack) {
|
||||
styled := false
|
||||
for _, mi := range m.MatchedIndexes {
|
||||
if i == mi {
|
||||
b.WriteString(matchedStyle.Render(string(rune)))
|
||||
styled = true
|
||||
}
|
||||
}
|
||||
if !styled {
|
||||
b.WriteString(defaultStyle.Render(string(rune)))
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
46
ui/styles.go
Normal file
46
ui/styles.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package ui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Colors.
|
||||
var (
|
||||
normalDim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}
|
||||
gray = lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"}
|
||||
midGray = lipgloss.AdaptiveColor{Light: "#B2B2B2", Dark: "#4A4A4A"}
|
||||
darkGray = lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}
|
||||
brightGray = lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}
|
||||
dimBrightGray = lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"}
|
||||
cream = lipgloss.AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"}
|
||||
yellowGreen = lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}
|
||||
fuchsia = lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}
|
||||
dimFuchsia = lipgloss.AdaptiveColor{Light: "#F1A8FF", Dark: "#99519E"}
|
||||
dullFuchsia = lipgloss.AdaptiveColor{Dark: "#AD58B4", Light: "#F793FF"}
|
||||
dimDullFuchsia = lipgloss.AdaptiveColor{Light: "#F6C9FF", Dark: "#7B4380"}
|
||||
green = lipgloss.Color("#04B575")
|
||||
red = lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"}
|
||||
semiDimGreen = lipgloss.AdaptiveColor{Light: "#35D79C", Dark: "#036B46"}
|
||||
dimGreen = lipgloss.AdaptiveColor{Light: "#72D2B0", Dark: "#0B5137"}
|
||||
)
|
||||
|
||||
// Ulimately, we'll transition to named styles.
|
||||
var (
|
||||
dimNormalFg = lipgloss.NewStyle().Foreground(normalDim).Render
|
||||
brightGrayFg = lipgloss.NewStyle().Foreground(brightGray).Render
|
||||
dimBrightGrayFg = lipgloss.NewStyle().Foreground(dimBrightGray).Render
|
||||
grayFg = lipgloss.NewStyle().Foreground(gray).Render
|
||||
midGrayFg = lipgloss.NewStyle().Foreground(midGray).Render
|
||||
darkGrayFg = lipgloss.NewStyle().Foreground(darkGray)
|
||||
greenFg = lipgloss.NewStyle().Foreground(green).Render
|
||||
semiDimGreenFg = lipgloss.NewStyle().Foreground(semiDimGreen).Render
|
||||
dimGreenFg = lipgloss.NewStyle().Foreground(dimGreen).Render
|
||||
fuchsiaFg = lipgloss.NewStyle().Foreground(fuchsia).Render
|
||||
dimFuchsiaFg = lipgloss.NewStyle().Foreground(dimFuchsia).Render
|
||||
dullFuchsiaFg = lipgloss.NewStyle().Foreground(dullFuchsia).Render
|
||||
dimDullFuchsiaFg = lipgloss.NewStyle().Foreground(dimDullFuchsia).Render
|
||||
redFg = lipgloss.NewStyle().Foreground(red).Render
|
||||
tabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"})
|
||||
selectedTabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#333333", Dark: "#979797"})
|
||||
errorTitleStyle = lipgloss.NewStyle().Foreground(cream).Background(red).Padding(0, 1)
|
||||
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"})
|
||||
paginationStyle = subtleStyle
|
||||
)
|
||||
452
ui/ui.go
Normal file
452
ui/ui.go
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
// Package ui provides the main UI for the glow application.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour/styles"
|
||||
"github.com/charmbracelet/glow/v2/utils"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/muesli/gitcha"
|
||||
te "github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
const (
|
||||
statusMessageTimeout = time.Second * 3 // how long to show status messages like "stashed!"
|
||||
ellipsis = "…"
|
||||
)
|
||||
|
||||
var (
|
||||
config Config
|
||||
|
||||
markdownExtensions = []string{
|
||||
"*.md", "*.mdown", "*.mkdn", "*.mkd", "*.markdown",
|
||||
}
|
||||
)
|
||||
|
||||
// NewProgram returns a new Tea program.
|
||||
func NewProgram(cfg Config, content string) *tea.Program {
|
||||
log.Debug(
|
||||
"Starting glow",
|
||||
"high_perf_pager",
|
||||
cfg.HighPerformancePager,
|
||||
"glamour",
|
||||
cfg.GlamourEnabled,
|
||||
)
|
||||
|
||||
config = cfg
|
||||
opts := []tea.ProgramOption{tea.WithAltScreen()}
|
||||
if cfg.EnableMouse {
|
||||
opts = append(opts, tea.WithMouseCellMotion())
|
||||
}
|
||||
m := newModel(cfg, content)
|
||||
return tea.NewProgram(m, opts...)
|
||||
}
|
||||
|
||||
type errMsg struct{ err error }
|
||||
|
||||
func (e errMsg) Error() string { return e.err.Error() }
|
||||
|
||||
type (
|
||||
initLocalFileSearchMsg struct {
|
||||
cwd string
|
||||
ch chan gitcha.SearchResult
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
foundLocalFileMsg gitcha.SearchResult
|
||||
localFileSearchFinished struct{}
|
||||
statusMessageTimeoutMsg applicationContext
|
||||
)
|
||||
|
||||
// applicationContext indicates the area of the application something applies
|
||||
// to. Occasionally used as an argument to commands and messages.
|
||||
type applicationContext int
|
||||
|
||||
const (
|
||||
stashContext applicationContext = iota
|
||||
pagerContext
|
||||
)
|
||||
|
||||
// state is the top-level application state.
|
||||
type state int
|
||||
|
||||
const (
|
||||
stateShowStash state = iota
|
||||
stateShowDocument
|
||||
)
|
||||
|
||||
func (s state) String() string {
|
||||
return map[state]string{
|
||||
stateShowStash: "showing file listing",
|
||||
stateShowDocument: "showing document",
|
||||
}[s]
|
||||
}
|
||||
|
||||
// Common stuff we'll need to access in all models.
|
||||
type commonModel struct {
|
||||
cfg Config
|
||||
cwd string
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
type model struct {
|
||||
common *commonModel
|
||||
state state
|
||||
fatalErr error
|
||||
|
||||
// Sub-models
|
||||
stash stashModel
|
||||
pager pagerModel
|
||||
|
||||
// Channel that receives paths to local markdown files
|
||||
// (via the github.com/muesli/gitcha package)
|
||||
localFileFinder chan gitcha.SearchResult
|
||||
}
|
||||
|
||||
// unloadDocument unloads a document from the pager. Note that while this
|
||||
// method alters the model we also need to send along any commands returned.
|
||||
func (m *model) unloadDocument() []tea.Cmd {
|
||||
m.state = stateShowStash
|
||||
m.stash.viewState = stashStateReady
|
||||
m.pager.unload()
|
||||
m.pager.showHelp = false
|
||||
|
||||
var batch []tea.Cmd
|
||||
if m.pager.viewport.HighPerformanceRendering {
|
||||
batch = append(batch, tea.ClearScrollArea) //nolint:staticcheck
|
||||
}
|
||||
|
||||
if !m.stash.shouldSpin() {
|
||||
batch = append(batch, m.stash.spinner.Tick)
|
||||
}
|
||||
return batch
|
||||
}
|
||||
|
||||
func newModel(cfg Config, content string) tea.Model {
|
||||
initSections()
|
||||
|
||||
if cfg.GlamourStyle == styles.AutoStyle {
|
||||
if te.HasDarkBackground() {
|
||||
cfg.GlamourStyle = styles.DarkStyle
|
||||
} else {
|
||||
cfg.GlamourStyle = styles.LightStyle
|
||||
}
|
||||
}
|
||||
|
||||
common := commonModel{
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
m := model{
|
||||
common: &common,
|
||||
state: stateShowStash,
|
||||
pager: newPagerModel(&common),
|
||||
stash: newStashModel(&common),
|
||||
}
|
||||
|
||||
path := cfg.Path
|
||||
if path == "" && content != "" {
|
||||
m.state = stateShowDocument
|
||||
m.pager.currentDocument = markdown{Body: content}
|
||||
return m
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
log.Error("unable to stat file", "file", path, "error", err)
|
||||
m.fatalErr = err
|
||||
return m
|
||||
}
|
||||
if info.IsDir() {
|
||||
m.state = stateShowStash
|
||||
} else {
|
||||
cwd, _ := os.Getwd()
|
||||
m.state = stateShowDocument
|
||||
m.pager.currentDocument = markdown{
|
||||
localPath: path,
|
||||
Note: stripAbsolutePath(path, cwd),
|
||||
Modtime: info.ModTime(),
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
cmds := []tea.Cmd{m.stash.spinner.Tick}
|
||||
|
||||
switch m.state {
|
||||
case stateShowStash:
|
||||
cmds = append(cmds, findLocalFiles(*m.common))
|
||||
case stateShowDocument:
|
||||
content, err := os.ReadFile(m.common.cfg.Path)
|
||||
if err != nil {
|
||||
log.Error("unable to read file", "file", m.common.cfg.Path, "error", err)
|
||||
return func() tea.Msg { return errMsg{err} }
|
||||
}
|
||||
body := string(utils.RemoveFrontmatter(content))
|
||||
cmds = append(cmds, renderWithGlamour(m.pager, body))
|
||||
}
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// If there's been an error, any key exits
|
||||
if m.fatalErr != nil {
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument {
|
||||
batch := m.unloadDocument()
|
||||
return m, tea.Batch(batch...)
|
||||
}
|
||||
case "r":
|
||||
var cmd tea.Cmd
|
||||
if m.state == stateShowStash {
|
||||
// pass through all keys if we're editing the filter
|
||||
if m.stash.filterState == filtering {
|
||||
m.stash, cmd = m.stash.update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
m.stash.markdowns = nil
|
||||
return m, m.Init()
|
||||
}
|
||||
|
||||
case "q":
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch m.state { //nolint:exhaustive
|
||||
case stateShowStash:
|
||||
// pass through all keys if we're editing the filter
|
||||
if m.stash.filterState == filtering {
|
||||
m.stash, cmd = m.stash.update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
return m, tea.Quit
|
||||
|
||||
case "left", "h", "delete":
|
||||
if m.state == stateShowDocument {
|
||||
cmds = append(cmds, m.unloadDocument()...)
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
case "ctrl+z":
|
||||
return m, tea.Suspend
|
||||
|
||||
// Ctrl+C always quits no matter where in the application you are.
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
// Window size is received when starting up and on every resize
|
||||
case tea.WindowSizeMsg:
|
||||
m.common.width = msg.Width
|
||||
m.common.height = msg.Height
|
||||
m.stash.setSize(msg.Width, msg.Height)
|
||||
m.pager.setSize(msg.Width, msg.Height)
|
||||
|
||||
case initLocalFileSearchMsg:
|
||||
m.localFileFinder = msg.ch
|
||||
m.common.cwd = msg.cwd
|
||||
cmds = append(cmds, findNextLocalFile(m))
|
||||
|
||||
case fetchedMarkdownMsg:
|
||||
// We've loaded a markdown file's contents for rendering
|
||||
m.pager.currentDocument = *msg
|
||||
body := string(utils.RemoveFrontmatter([]byte(msg.Body)))
|
||||
cmds = append(cmds, renderWithGlamour(m.pager, body))
|
||||
|
||||
case contentRenderedMsg:
|
||||
m.state = stateShowDocument
|
||||
|
||||
case localFileSearchFinished:
|
||||
// Always pass these messages to the stash so we can keep it updated
|
||||
// about network activity, even if the user isn't currently viewing
|
||||
// the stash.
|
||||
stashModel, cmd := m.stash.update(msg)
|
||||
m.stash = stashModel
|
||||
return m, cmd
|
||||
|
||||
case foundLocalFileMsg:
|
||||
newMd := localFileToMarkdown(m.common.cwd, gitcha.SearchResult(msg))
|
||||
m.stash.addMarkdowns(newMd)
|
||||
if m.stash.filterApplied() {
|
||||
newMd.buildFilterValue()
|
||||
}
|
||||
if m.stash.shouldUpdateFilter() {
|
||||
cmds = append(cmds, filterMarkdowns(m.stash))
|
||||
}
|
||||
cmds = append(cmds, findNextLocalFile(m))
|
||||
|
||||
case filteredMarkdownMsg:
|
||||
if m.state == stateShowDocument {
|
||||
newStashModel, cmd := m.stash.update(msg)
|
||||
m.stash = newStashModel
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Process children
|
||||
switch m.state {
|
||||
case stateShowStash:
|
||||
newStashModel, cmd := m.stash.update(msg)
|
||||
m.stash = newStashModel
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
case stateShowDocument:
|
||||
newPagerModel, cmd := m.pager.update(msg)
|
||||
m.pager = newPagerModel
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if m.fatalErr != nil {
|
||||
return errorView(m.fatalErr, true)
|
||||
}
|
||||
|
||||
switch m.state { //nolint:exhaustive
|
||||
case stateShowDocument:
|
||||
return m.pager.View()
|
||||
default:
|
||||
return m.stash.view()
|
||||
}
|
||||
}
|
||||
|
||||
func errorView(err error, fatal bool) string {
|
||||
exitMsg := "press any key to "
|
||||
if fatal {
|
||||
exitMsg += "exit"
|
||||
} else {
|
||||
exitMsg += "return"
|
||||
}
|
||||
s := fmt.Sprintf("%s\n\n%v\n\n%s",
|
||||
errorTitleStyle.Render("ERROR"),
|
||||
err,
|
||||
subtleStyle.Render(exitMsg),
|
||||
)
|
||||
return "\n" + indent(s, 3)
|
||||
}
|
||||
|
||||
// COMMANDS
|
||||
|
||||
func findLocalFiles(m commonModel) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
log.Info("findLocalFiles")
|
||||
var (
|
||||
cwd = m.cfg.Path
|
||||
err error
|
||||
)
|
||||
|
||||
if cwd == "" {
|
||||
cwd, err = os.Getwd()
|
||||
} else {
|
||||
var info os.FileInfo
|
||||
info, err = os.Stat(cwd)
|
||||
if err == nil && info.IsDir() {
|
||||
cwd, err = filepath.Abs(cwd)
|
||||
}
|
||||
}
|
||||
|
||||
// Note that this is one error check for both cases above
|
||||
if err != nil {
|
||||
log.Error("error finding local files", "error", err)
|
||||
return errMsg{err}
|
||||
}
|
||||
|
||||
log.Debug("local directory is", "cwd", cwd)
|
||||
|
||||
// Switch between FindFiles and FindAllFiles to bypass .gitignore rules
|
||||
var ch chan gitcha.SearchResult
|
||||
if m.cfg.ShowAllFiles {
|
||||
ch, err = gitcha.FindAllFilesExcept(cwd, markdownExtensions, nil)
|
||||
} else {
|
||||
ch, err = gitcha.FindFilesExcept(cwd, markdownExtensions, ignorePatterns(m))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error("error finding local files", "error", err)
|
||||
return errMsg{err}
|
||||
}
|
||||
|
||||
return initLocalFileSearchMsg{ch: ch, cwd: cwd}
|
||||
}
|
||||
}
|
||||
|
||||
func findNextLocalFile(m model) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
res, ok := <-m.localFileFinder
|
||||
|
||||
if ok {
|
||||
// Okay now find the next one
|
||||
return foundLocalFileMsg(res)
|
||||
}
|
||||
// We're done
|
||||
log.Debug("local file search finished")
|
||||
return localFileSearchFinished{}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
<-t.C
|
||||
return statusMessageTimeoutMsg(appCtx)
|
||||
}
|
||||
}
|
||||
|
||||
// ETC
|
||||
|
||||
// Convert a Gitcha result to an internal representation of a markdown
|
||||
// document. Note that we could be doing things like checking if the file is
|
||||
// a directory, but we trust that gitcha has already done that.
|
||||
func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown {
|
||||
return &markdown{
|
||||
localPath: res.Path,
|
||||
Note: stripAbsolutePath(res.Path, cwd),
|
||||
Modtime: res.Info.ModTime(),
|
||||
}
|
||||
}
|
||||
|
||||
func stripAbsolutePath(fullPath, cwd string) string {
|
||||
fp, _ := filepath.EvalSymlinks(fullPath)
|
||||
cp, _ := filepath.EvalSymlinks(cwd)
|
||||
return strings.ReplaceAll(fp, cp+string(os.PathSeparator), "")
|
||||
}
|
||||
|
||||
// Lightweight version of reflow's indent function.
|
||||
func indent(s string, n int) string {
|
||||
if n <= 0 || s == "" {
|
||||
return s
|
||||
}
|
||||
l := strings.Split(s, "\n")
|
||||
b := strings.Builder{}
|
||||
i := strings.Repeat(" ", n)
|
||||
for _, v := range l {
|
||||
fmt.Fprintf(&b, "%s%s\n", i, v)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
86
url.go
Normal file
86
url.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
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, "://")
|
||||
}
|
||||
30
url_test.go
Normal file
30
url_test.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
113
utils/utils.go
Normal file
113
utils/utils.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// Package utils provides utility functions.
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glamour/ansi"
|
||||
"github.com/charmbracelet/glamour/styles"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/mitchellh/go-homedir"
|
||||
)
|
||||
|
||||
// RemoveFrontmatter removes the front matter header of a markdown file.
|
||||
func RemoveFrontmatter(content []byte) []byte {
|
||||
if frontmatterBoundaries := detectFrontmatter(content); frontmatterBoundaries[0] == 0 {
|
||||
return content[frontmatterBoundaries[1]:]
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
var yamlPattern = regexp.MustCompile(`(?m)^---\r?\n(\s*\r?\n)?`)
|
||||
|
||||
func detectFrontmatter(c []byte) []int {
|
||||
if matches := yamlPattern.FindAllIndex(c, 2); len(matches) > 1 {
|
||||
return []int{matches[0][0], matches[1][1]}
|
||||
}
|
||||
return []int{-1, -1}
|
||||
}
|
||||
|
||||
// ExpandPath expands tilde and all environment variables from the given path.
|
||||
func ExpandPath(path string) string {
|
||||
s, err := homedir.Expand(path)
|
||||
if err == nil {
|
||||
return os.ExpandEnv(s)
|
||||
}
|
||||
return os.ExpandEnv(path)
|
||||
}
|
||||
|
||||
// WrapCodeBlock wraps a string in a code block with the given language.
|
||||
func WrapCodeBlock(s, language string) string {
|
||||
return "```" + language + "\n" + s + "```"
|
||||
}
|
||||
|
||||
var markdownExtensions = []string{
|
||||
".md", ".mdown", ".mkdn", ".mkd", ".markdown",
|
||||
}
|
||||
|
||||
// IsMarkdownFile returns whether the filename has a markdown extension.
|
||||
func IsMarkdownFile(filename string) bool {
|
||||
ext := filepath.Ext(filename)
|
||||
|
||||
if ext == "" {
|
||||
// By default, assume it's a markdown file.
|
||||
return true
|
||||
}
|
||||
|
||||
for _, v := range markdownExtensions {
|
||||
if strings.EqualFold(ext, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Has an extension but not markdown
|
||||
// so assume this is a code file.
|
||||
return false
|
||||
}
|
||||
|
||||
// GlamourStyle returns a glamour.TermRendererOption based on the given style.
|
||||
func GlamourStyle(style string, isCode bool) glamour.TermRendererOption {
|
||||
if !isCode {
|
||||
if style == styles.AutoStyle {
|
||||
return glamour.WithAutoStyle()
|
||||
}
|
||||
return glamour.WithStylePath(style)
|
||||
}
|
||||
|
||||
// If we are rendering a pure code block, we need to modify the style to
|
||||
// remove the indentation.
|
||||
|
||||
var styleConfig ansi.StyleConfig
|
||||
|
||||
switch style {
|
||||
case styles.AutoStyle:
|
||||
if lipgloss.HasDarkBackground() {
|
||||
styleConfig = styles.DarkStyleConfig
|
||||
} else {
|
||||
styleConfig = styles.LightStyleConfig
|
||||
}
|
||||
case styles.DarkStyle:
|
||||
styleConfig = styles.DarkStyleConfig
|
||||
case styles.LightStyle:
|
||||
styleConfig = styles.LightStyleConfig
|
||||
case styles.PinkStyle:
|
||||
styleConfig = styles.PinkStyleConfig
|
||||
case styles.NoTTYStyle:
|
||||
styleConfig = styles.NoTTYStyleConfig
|
||||
case styles.DraculaStyle:
|
||||
styleConfig = styles.DraculaStyleConfig
|
||||
case styles.TokyoNightStyle:
|
||||
styleConfig = styles.DraculaStyleConfig
|
||||
default:
|
||||
return glamour.WithStylesFromJSONFile(style)
|
||||
}
|
||||
|
||||
var margin uint
|
||||
styleConfig.CodeBlock.Margin = &margin
|
||||
|
||||
return glamour.WithStyles(styleConfig)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue