mirror of
https://github.com/charmbracelet/glow.git
synced 2026-08-09 09:49:09 +02:00
Compare commits
No commits in common. "master" and "pixterm" have entirely different histories.
54 changed files with 1044 additions and 4387 deletions
|
|
@ -1,18 +0,0 @@
|
||||||
# https://editorconfig.org/
|
|
||||||
|
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.go]
|
|
||||||
indent_style = tab
|
|
||||||
indent_size = 8
|
|
||||||
|
|
||||||
[*.golden]
|
|
||||||
insert_final_newline = false
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -1,38 +0,0 @@
|
||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
**Setup**
|
|
||||||
Please complete the following information along with version numbers, if applicable.
|
|
||||||
- OS [e.g. Ubuntu, macOS]
|
|
||||||
- Shell [e.g. zsh, fish]
|
|
||||||
- Terminal Emulator [e.g. kitty, iterm]
|
|
||||||
- Terminal Multiplexer [e.g. tmux]
|
|
||||||
- Locale [e.g. en_US.UTF-8, zh_CN.UTF-8, etc.]
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Source Code**
|
|
||||||
Please include source code if needed to reproduce the behavior.
|
|
||||||
|
|
||||||
**Expected behavior**
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
Add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context about the problem here.
|
|
||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
5
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,5 +0,0 @@
|
||||||
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
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
|
@ -1,20 +0,0 @@
|
||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: ''
|
|
||||||
labels: enhancement
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
||||||
57
.github/dependabot.yml
vendored
57
.github/dependabot.yml
vendored
|
|
@ -1,57 +0,0 @@
|
||||||
version: 2
|
|
||||||
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: "gomod"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
day: "monday"
|
|
||||||
time: "05:00"
|
|
||||||
timezone: "America/New_York"
|
|
||||||
labels:
|
|
||||||
- "dependencies"
|
|
||||||
commit-message:
|
|
||||||
prefix: "chore"
|
|
||||||
include: "scope"
|
|
||||||
groups:
|
|
||||||
all:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
ignore:
|
|
||||||
- dependency-name: github.com/charmbracelet/bubbletea/v2
|
|
||||||
versions:
|
|
||||||
- v2.0.0-beta1
|
|
||||||
|
|
||||||
- package-ecosystem: "github-actions"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
day: "monday"
|
|
||||||
time: "05:00"
|
|
||||||
timezone: "America/New_York"
|
|
||||||
labels:
|
|
||||||
- "dependencies"
|
|
||||||
commit-message:
|
|
||||||
prefix: "chore"
|
|
||||||
include: "scope"
|
|
||||||
groups:
|
|
||||||
all:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
|
|
||||||
- package-ecosystem: "docker"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
day: "monday"
|
|
||||||
time: "05:00"
|
|
||||||
timezone: "America/New_York"
|
|
||||||
labels:
|
|
||||||
- "dependencies"
|
|
||||||
commit-message:
|
|
||||||
prefix: "chore"
|
|
||||||
include: "scope"
|
|
||||||
groups:
|
|
||||||
all:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
25
.github/workflows/build.yml
vendored
25
.github/workflows/build.yml
vendored
|
|
@ -1,25 +0,0 @@
|
||||||
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
24
.github/workflows/coverage.yml
vendored
|
|
@ -1,24 +0,0 @@
|
||||||
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
17
.github/workflows/dependabot-sync.yml
vendored
|
|
@ -1,17 +0,0 @@
|
||||||
name: dependabot-sync
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 0 * * 0" # every Sunday at midnight
|
|
||||||
workflow_dispatch: # allows manual triggering
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
dependabot-sync:
|
|
||||||
uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main
|
|
||||||
with:
|
|
||||||
repo_name: ${{ github.event.repository.name }}
|
|
||||||
secrets:
|
|
||||||
gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
|
||||||
25
.github/workflows/goreleaser.yml
vendored
25
.github/workflows/goreleaser.yml
vendored
|
|
@ -1,25 +0,0 @@
|
||||||
# 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
13
.github/workflows/lint-sync.yml
vendored
|
|
@ -1,13 +0,0 @@
|
||||||
name: lint-sync
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 0 * * 0" # every sunday at midnight
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main
|
|
||||||
18
.github/workflows/lint.yml
vendored
18
.github/workflows/lint.yml
vendored
|
|
@ -1,18 +0,0 @@
|
||||||
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,5 +1,2 @@
|
||||||
glow
|
cmd/gold/gold
|
||||||
dist/
|
cmd/gold/dist/
|
||||||
.envrc
|
|
||||||
completions/
|
|
||||||
manpages/
|
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
# 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"
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
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
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2019-2024 Charmbracelet, Inc
|
Copyright (c) 2019 Charmbracelet, Inc
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
|
||||||
242
README.md
242
README.md
|
|
@ -1,242 +1,18 @@
|
||||||
# Glow
|
# Gold
|
||||||
|
|
||||||
Render markdown on the CLI, with _pizzazz_!
|
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?
|
## What is it?
|
||||||
|
|
||||||
Glow is a terminal based markdown reader designed from the ground up to bring
|
Gold is a Golang library that allows you to use JSON based stylesheets to
|
||||||
out the beauty—and power—of the CLI.
|
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.
|
||||||
|
|
||||||
Use it to discover markdown files, read documentation directly on the command
|
## Usage
|
||||||
line. Glow will find local markdown files in subdirectories or a local
|
|
||||||
Git repository.
|
|
||||||
|
|
||||||
## Installation
|
See [cmd/gold](cmd/gold/).
|
||||||
|
|
||||||
### Package Manager
|
## Colors
|
||||||
|
|
||||||
```bash
|
Currently `gold` uses the [Aurora ANSI colors](https://godoc.org/github.com/logrusorgru/aurora#Index).
|
||||||
# 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
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
# https://taskfile.dev
|
|
||||||
|
|
||||||
version: '3'
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
lint:
|
|
||||||
desc: Run base linters
|
|
||||||
cmds:
|
|
||||||
- golangci-lint run
|
|
||||||
|
|
||||||
test:
|
|
||||||
desc: Run tests
|
|
||||||
cmds:
|
|
||||||
- go test ./... {{.CLI_ARGS}}
|
|
||||||
|
|
||||||
log:
|
|
||||||
desc: Watch for glow logs
|
|
||||||
aliases: [tail]
|
|
||||||
cmds:
|
|
||||||
- cmd: tail -f ~/Library/Caches/glow/glow.log
|
|
||||||
platforms: [darwin]
|
|
||||||
- cmd: tail -f ~/.cache/glow/glow.log
|
|
||||||
platforms: [linux, windows]
|
|
||||||
47
cmd/gold/.goreleaser.yml
Normal file
47
cmd/gold/.goreleaser.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
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:'
|
||||||
38
cmd/gold/README.md
Normal file
38
cmd/gold/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|

|
||||||
30
cmd/gold/dark.json
Normal file
30
cmd/gold/dark.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
47
cmd/gold/github.go
Normal file
47
cmd/gold/github.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
46
cmd/gold/gitlab.go
Normal file
46
cmd/gold/gitlab.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
BIN
cmd/gold/gold_dark.png
Normal file
BIN
cmd/gold/gold_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
117
cmd/gold/main.go
Normal file
117
cmd/gold/main.go
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
// +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
BIN
example.png
Binary file not shown.
|
Before Width: | Height: | Size: 499 KiB |
57
github.go
57
github.go
|
|
@ -1,57 +0,0 @@
|
||||||
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
61
gitlab.go
|
|
@ -1,61 +0,0 @@
|
||||||
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
41
glow_test.go
|
|
@ -1,41 +0,0 @@
|
||||||
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,75 +1,17 @@
|
||||||
module github.com/charmbracelet/glow/v2
|
module github.com/charmbracelet/gold
|
||||||
|
|
||||||
go 1.25.12
|
go 1.13
|
||||||
|
|
||||||
toolchain go1.26.5
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4
|
github.com/alecthomas/chroma v0.7.0
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
github.com/charmbracelet/bubbles v0.21.0
|
github.com/eliukblau/pixterm v1.2.4
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/logrusorgru/aurora v0.0.0-20191017060258-dc85c304c434
|
||||||
github.com/charmbracelet/glamour v0.10.0
|
github.com/lucasb-eyer/go-colorful v0.0.0-20170903184257-231272389856
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
github.com/mattn/go-isatty v0.0.10
|
||||||
github.com/charmbracelet/log v0.4.2
|
github.com/microcosm-cc/bluemonday v1.0.2
|
||||||
github.com/charmbracelet/x/editor v0.1.0
|
github.com/muesli/go-wordwrap v1.0.1-0.20191125090158-10bc5504e2a8
|
||||||
github.com/dustin/go-humanize v1.0.1
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/spf13/cobra v0.0.5
|
||||||
github.com/mattn/go-runewidth v0.0.19
|
gopkg.in/russross/blackfriday.v2 v2.0.0
|
||||||
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,177 +1,101 @@
|
||||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
|
||||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
|
||||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
|
||||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
|
||||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/alecthomas/chroma v0.7.0 h1:z+0HgTUmkpRDRz0SRSdMaqOLfJV4F+N1FPDZUZIDUzw=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/alecthomas/chroma v0.7.0/go.mod h1:1U/PfCsTALWWYHDnsIQkxEBM0+6LLe0v8+RSVMOwxeY=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY=
|
||||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
|
||||||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
|
||||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
|
||||||
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/eliukblau/pixterm v1.2.4 h1:EQ8KuWLeDNdm/AWZlylR9hX0elAve+MXLoIFh9Ssrjo=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/eliukblau/pixterm v1.2.4/go.mod h1:SLrMnkvTqu64HiEUZ+UEQOAh81M4FlxDwxU69KmWJuQ=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
|
||||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/logrusorgru/aurora v0.0.0-20191017060258-dc85c304c434 h1:im9kkmH0WWwxzegiv18gSUJbuXR9y028rXrWuPp6Jug=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/logrusorgru/aurora v0.0.0-20191017060258-dc85c304c434/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/lucasb-eyer/go-colorful v0.0.0-20170903184257-231272389856 h1:r+WvXmgROttp7pckv7TPN7OCUEPXmvhRklOOsL2iPPc=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/lucasb-eyer/go-colorful v0.0.0-20170903184257-231272389856/go.mod h1:NXg0ArsFk0Y01623LgUqoqcouGDB+PwCCQlrwrG6xJ4=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
|
||||||
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/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
github.com/muesli/go-wordwrap v1.0.1-0.20191125090158-10bc5504e2a8 h1:pkJGPjqhekH0hq/lJABlh5EhzU7eU/+VdFq9ZMOae2E=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/go-wordwrap v1.0.1-0.20191125090158-10bc5504e2a8/go.mod h1:isHWaQQfvQ38DUiq7ugbuPZowGJ+9gPmACGc3tDUN4M=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
|
||||||
github.com/muesli/gitcha v0.3.0 h1:+PJkVKrDXVB0VgRn/yVx2CqSVSDGMSepzvohsCrPYtQ=
|
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||||
github.com/muesli/gitcha v0.3.0/go.mod h1:vX3jFL+XcEUq1uY74RCjLSZfAV+ZuvLg70/NGPdXn84=
|
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/muesli/go-app-paths v0.2.2 h1:NqG4EEZwNIhBq/pREgfBmgDmt3h1Smr1MjZiXbpZUnI=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||||
github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 h1:G04eS0JkAIVZfaJLjla9dNxkJCPiKIGZlw9AfOhzOD0=
|
github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s=
|
||||||
github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94/go.mod h1:b18R55ulyQ/h3RaWyloPyER7fWQVZvimKKhnI5OfrJQ=
|
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
|
||||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||||
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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0=
|
||||||
github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
|
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM=
|
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a h1:1n5lsVfiQW3yfsRGu98756EH1YthsFqr/5mxHduZW2A=
|
||||||
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
golang.org/x/sys v0.0.0-20191008105621-543471e840be h1:QAcqgptGM8IQBC9K/RC4o+O9YmqEm0diQn9QmZw/0mU=
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
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/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/russross/blackfriday.v2 v2.0.0 h1:+FlnIV8DSQnT7NZ43hcVKcdJdzZoeCmJj4Ql8gq5keA=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/russross/blackfriday.v2 v2.0.0/go.mod h1:6sSBNz/GtOm/pJTuh5UmBK2ZHfmnxGbl2NZg1UliSOI=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
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
Normal file
497
gold.go
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
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
40
log.go
|
|
@ -1,40 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/log"
|
|
||||||
gap "github.com/muesli/go-app-paths"
|
|
||||||
)
|
|
||||||
|
|
||||||
func getLogFilePath() (string, error) {
|
|
||||||
dir, err := gap.NewScope(gap.User, "glow").CacheDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("unable to get cache dir: %w", err)
|
|
||||||
}
|
|
||||||
return filepath.Join(dir, "glow.log"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupLog() (func() error, error) {
|
|
||||||
log.SetOutput(io.Discard)
|
|
||||||
// Log to file, if set
|
|
||||||
logFile, err := getLogFilePath()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(logFile), 0o755); err != nil { //nolint:gosec
|
|
||||||
// log disabled
|
|
||||||
return func() error { return nil }, nil //nolint:nilerr
|
|
||||||
}
|
|
||||||
f, err := os.OpenFile(logFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644) //nolint:gosec
|
|
||||||
if err != nil {
|
|
||||||
// log disabled
|
|
||||||
return func() error { return nil }, nil //nolint:nilerr
|
|
||||||
}
|
|
||||||
log.SetOutput(f)
|
|
||||||
log.SetLevel(log.DebugLevel)
|
|
||||||
return f.Close, nil
|
|
||||||
}
|
|
||||||
473
main.go
473
main.go
|
|
@ -1,473 +0,0 @@
|
||||||
// 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
29
man_cmd.go
|
|
@ -1,29 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
mcobra "github.com/muesli/mango-cobra"
|
|
||||||
"github.com/muesli/roff"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var manCmd = &cobra.Command{
|
|
||||||
Use: "man",
|
|
||||||
Short: "Generates manpages",
|
|
||||||
SilenceUsage: true,
|
|
||||||
DisableFlagsInUseLine: true,
|
|
||||||
Hidden: true,
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(*cobra.Command, []string) error {
|
|
||||||
manPage, err := mcobra.NewManPage(1, rootCmd)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to instantiate man page: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := fmt.Fprint(os.Stdout, manPage.Build(roff.NewDocument())); err != nil {
|
|
||||||
return fmt.Errorf("unable to build man page: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
118
style.go
118
style.go
|
|
@ -1,14 +1,108 @@
|
||||||
package main
|
package gold
|
||||||
|
|
||||||
import "github.com/charmbracelet/lipgloss"
|
import (
|
||||||
|
"fmt"
|
||||||
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
20
ui/config.go
|
|
@ -1,20 +0,0 @@
|
||||||
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
19
ui/editor.go
|
|
@ -1,19 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/x/editor"
|
|
||||||
)
|
|
||||||
|
|
||||||
type editorFinishedMsg struct{ err error }
|
|
||||||
|
|
||||||
func openEditor(path string, lineno int) tea.Cmd {
|
|
||||||
cb := func(err error) tea.Msg {
|
|
||||||
return editorFinishedMsg{err}
|
|
||||||
}
|
|
||||||
cmd, err := editor.Cmd("Glow", path, editor.LineNumber(uint(lineno))) //nolint:gosec
|
|
||||||
if err != nil {
|
|
||||||
return func() tea.Msg { return cb(err) }
|
|
||||||
}
|
|
||||||
return tea.ExecProcess(cmd, cb)
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
//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",
|
|
||||||
".*",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
//go:build !darwin
|
|
||||||
// +build !darwin
|
|
||||||
|
|
||||||
package ui
|
|
||||||
|
|
||||||
func ignorePatterns(m commonModel) []string {
|
|
||||||
return []string{
|
|
||||||
m.cfg.Gopath,
|
|
||||||
"node_modules",
|
|
||||||
".*",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
const (
|
|
||||||
keyEnter = "enter"
|
|
||||||
keyEsc = "esc"
|
|
||||||
)
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
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
535
ui/pager.go
|
|
@ -1,535 +0,0 @@
|
||||||
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
12
ui/sort.go
|
|
@ -1,12 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"slices"
|
|
||||||
)
|
|
||||||
|
|
||||||
func sortMarkdowns(mds []*markdown) {
|
|
||||||
slices.SortStableFunc(mds, func(a, b *markdown) int {
|
|
||||||
return cmp.Compare(a.Note, b.Note)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
891
ui/stash.go
891
ui/stash.go
|
|
@ -1,891 +0,0 @@
|
||||||
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
297
ui/stashhelp.go
|
|
@ -1,297 +0,0 @@
|
||||||
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
120
ui/stashitem.go
|
|
@ -1,120 +0,0 @@
|
||||||
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
46
ui/styles.go
|
|
@ -1,46 +0,0 @@
|
||||||
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
452
ui/ui.go
|
|
@ -1,452 +0,0 @@
|
||||||
// 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
86
url.go
|
|
@ -1,86 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
protoGithub = "github://"
|
|
||||||
protoGitlab = "gitlab://"
|
|
||||||
protoHTTPS = "https://"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
githubURL *url.URL
|
|
||||||
gitlabURL *url.URL
|
|
||||||
urlsOnce sync.Once
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
urlsOnce.Do(func() {
|
|
||||||
githubURL, _ = url.Parse("https://github.com")
|
|
||||||
gitlabURL, _ = url.Parse("https://gitlab.com")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func readmeURL(path string) (*source, error) {
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(path, protoGithub):
|
|
||||||
if u := githubReadmeURL(path); u != nil {
|
|
||||||
return readmeURL(u.String())
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
case strings.HasPrefix(path, protoGitlab):
|
|
||||||
if u := gitlabReadmeURL(path); u != nil {
|
|
||||||
return readmeURL(u.String())
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(path, protoHTTPS) {
|
|
||||||
path = protoHTTPS + path
|
|
||||||
}
|
|
||||||
u, err := url.Parse(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("unable to parse url: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case u.Hostname() == githubURL.Hostname():
|
|
||||||
return findGitHubREADME(u)
|
|
||||||
case u.Hostname() == gitlabURL.Hostname():
|
|
||||||
return findGitLabREADME(u)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func githubReadmeURL(path string) *url.URL {
|
|
||||||
path = strings.TrimPrefix(path, protoGithub)
|
|
||||||
parts := strings.Split(path, "/")
|
|
||||||
if len(parts) != 2 {
|
|
||||||
// custom hostnames are not supported yet
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
u, _ := url.Parse(githubURL.String())
|
|
||||||
return u.JoinPath(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func gitlabReadmeURL(path string) *url.URL {
|
|
||||||
path = strings.TrimPrefix(path, protoGitlab)
|
|
||||||
parts := strings.Split(path, "/")
|
|
||||||
if len(parts) != 2 {
|
|
||||||
// custom hostnames are not supported yet
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
u, _ := url.Parse(gitlabURL.String())
|
|
||||||
return u.JoinPath(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isURL(path string) bool {
|
|
||||||
_, err := url.ParseRequestURI(path)
|
|
||||||
return err == nil && strings.Contains(path, "://")
|
|
||||||
}
|
|
||||||
30
url_test.go
30
url_test.go
|
|
@ -1,30 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestURLParser(t *testing.T) {
|
|
||||||
for path, url := range map[string]string{
|
|
||||||
"github.com/charmbracelet/glow": "https://raw.githubusercontent.com/charmbracelet/glow/master/README.md",
|
|
||||||
"github://charmbracelet/glow": "https://raw.githubusercontent.com/charmbracelet/glow/master/README.md",
|
|
||||||
"github://caarlos0/dotfiles.fish": "https://raw.githubusercontent.com/caarlos0/dotfiles.fish/main/README.md",
|
|
||||||
"github://tj/git-extras": "https://raw.githubusercontent.com/tj/git-extras/main/Readme.md",
|
|
||||||
"https://github.com/goreleaser/nfpm": "https://raw.githubusercontent.com/goreleaser/nfpm/main/README.md",
|
|
||||||
"gitlab.com/caarlos0/test": "https://gitlab.com/caarlos0/test/-/raw/master/README.md",
|
|
||||||
"gitlab://caarlos0/test": "https://gitlab.com/caarlos0/test/-/raw/master/README.md",
|
|
||||||
"https://gitlab.com/terrakok/gitlab-client": "https://gitlab.com/terrakok/gitlab-client/-/raw/develop/Readme.md",
|
|
||||||
} {
|
|
||||||
t.Run(path, func(t *testing.T) {
|
|
||||||
t.Skip("test uses network, sometimes fails for no reason")
|
|
||||||
got, err := readmeURL(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if got == nil {
|
|
||||||
t.Fatalf("should not be nil")
|
|
||||||
}
|
|
||||||
if url != got.URL {
|
|
||||||
t.Errorf("expected url for %s to be %s, was %s", path, url, got.URL)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
113
utils/utils.go
113
utils/utils.go
|
|
@ -1,113 +0,0 @@
|
||||||
// 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