diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 5de2df8..0000000 --- a/.editorconfig +++ /dev/null @@ -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 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index c19be87..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -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. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 897a394..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,5 +0,0 @@ -blank_issues_enabled: true -contact_links: -- name: Discord - url: https://charm.sh/discord - about: Chat on our Discord. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 11fc491..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -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. diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index d944991..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -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: - - "*" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index adcb449..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index 7fbd40b..0000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/dependabot-sync.yml b/.github/workflows/dependabot-sync.yml deleted file mode 100644 index 9b08259..0000000 --- a/.github/workflows/dependabot-sync.yml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml deleted file mode 100644 index fbc2046..0000000 --- a/.github/workflows/goreleaser.yml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/lint-sync.yml b/.github/workflows/lint-sync.yml deleted file mode 100644 index 43f380f..0000000 --- a/.github/workflows/lint-sync.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 1e21125..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore index 8a5159a..0d8c7fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,2 @@ -glow -dist/ -.envrc -completions/ -manpages/ +cmd/gold/gold +cmd/gold/dist/ diff --git a/.golangci.yml b/.golangci.yml deleted file mode 100644 index 929cb0a..0000000 --- a/.golangci.yml +++ /dev/null @@ -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 diff --git a/.goreleaser.yml b/.goreleaser.yml deleted file mode 100644 index 0c539b7..0000000 --- a/.goreleaser.yml +++ /dev/null @@ -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 " - brew_commit_author_name: "Christian Muehlhaeuser" - brew_commit_author_email: "muesli@charm.sh" diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 8719b4e..0000000 --- a/Dockerfile +++ /dev/null @@ -1,3 +0,0 @@ -FROM gcr.io/distroless/static -COPY glow /usr/local/bin/glow -ENTRYPOINT [ "/usr/local/bin/glow" ] diff --git a/LICENSE b/LICENSE index 1e27a61..beda40d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-2024 Charmbracelet, Inc +Copyright (c) 2019 Charmbracelet, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index cf3bb15..9d5f1aa 100644 --- a/README.md +++ b/README.md @@ -1,242 +1,18 @@ -# Glow +# Gold Render markdown on the CLI, with _pizzazz_! -

- Glow Logo - Latest Release - GoDoc - Build Status - Go ReportCard -

- -

- Glow UI Demo -

- ## What is it? -Glow is a terminal based markdown reader designed from the ground up to bring -out the beauty—and power—of the CLI. +Gold is a Golang library that allows you to use JSON based stylesheets to +render Markdown files in the terminal. Just like CSS, you can define color and +style attributes on Markdown elements. The difference is that you use ANSI +color and terminal codes instead of CSS properties and hex colors. -Use it to discover markdown files, read documentation directly on the command -line. Glow will find local markdown files in subdirectories or a local -Git repository. +## Usage -## Installation +See [cmd/gold](cmd/gold/). -### Package Manager +## Colors -```bash -# macOS or Linux -brew install glow -``` - -```bash -# macOS (with MacPorts) -sudo port install glow -``` - -```bash -# Arch Linux (btw) -pacman -S glow -``` - -```bash -# Void Linux -xbps-install -S glow -``` - -```bash -# Nix shell -nix-shell -p glow --command glow -``` - -```bash -# FreeBSD -pkg install glow -``` - -```bash -# Solus -eopkg install glow -``` - -```bash -# Windows (with Chocolatey, Scoop, or Winget) -choco install glow -scoop install glow -winget install charmbracelet.glow -``` - -```bash -# Android (with termux) -pkg install glow -``` - -```bash -# Ubuntu (Snapcraft) -sudo snap install glow -``` - -```bash -# Debian/Ubuntu -sudo mkdir -p /etc/apt/keyrings -curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg -echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list -sudo apt update && sudo apt install glow -``` - -```bash -# Fedora/RHEL -echo '[charm] -name=Charm -baseurl=https://repo.charm.sh/yum/ -enabled=1 -gpgcheck=1 -gpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo -sudo yum install glow -``` - -Or download a binary from the [releases][releases] page. MacOS, Linux, Windows, -FreeBSD and OpenBSD binaries are available, as well as Debian, RPM, and Alpine -packages. ARM builds are also available for macOS, Linux, FreeBSD and OpenBSD. - -### Go - -Or just install it with `go`: - -```bash -go install github.com/charmbracelet/glow/v2@latest -``` - -### Build (requires Go 1.21+) - -```bash -git clone https://github.com/charmbracelet/glow.git -cd glow -go build -``` - -[releases]: https://github.com/charmbracelet/glow/releases - -## The TUI - -Simply run `glow` without arguments to start the textual user interface and -browse local. Glow will find local markdown files in the -current directory and below or, if you’re in a Git repository, Glow will search -the repo. - -Markdown files can be read with Glow's high-performance pager. Most of the -keystrokes you know from `less` are the same, but you can press `?` to list -the hotkeys. - -## The CLI - -In addition to a TUI, Glow has a CLI for working with Markdown. To format a -document use a markdown source as the primary argument: - -```bash -# Read from file -glow README.md - -# Read from stdin -echo "[Glow](https://github.com/charmbracelet/glow)" | glow - - -# Fetch README from GitHub / GitLab -glow github.com/charmbracelet/glow - -# Fetch markdown from HTTP -glow https://host.tld/file.md -``` - -### Word Wrapping - -The `-w` flag lets you set a maximum width at which the output will be wrapped: - -```bash -glow -w 60 -``` - -### Paging - -CLI output can be displayed in your preferred pager with the `-p` flag. This defaults -to the ANSI-aware `less -r` if `$PAGER` is not explicitly set. - -### Styles - -You can choose a style with the `-s` flag. When no flag is provided `glow` tries -to detect your terminal's current background color and automatically picks -either the `dark` or the `light` style for you. - -```bash -glow -s [dark|light] -``` - -Alternatively you can also supply a custom JSON stylesheet: - -```bash -glow -s mystyle.json -``` - -For additional usage details see: - -```bash -glow --help -``` - -Check out the [Glamour Style Section](https://github.com/charmbracelet/glamour/blob/master/styles/gallery/README.md) -to find more styles. Or [make your own](https://github.com/charmbracelet/glamour/tree/master/styles)! - -## The Config File - -If you find yourself supplying the same flags to `glow` all the time, it's -probably a good idea to create a config file. Run `glow config`, which will open -it in your favorite $EDITOR. Alternatively you can manually put a file named -`glow.yml` in the default config path of you platform. If you're not sure where -that is, please refer to `glow --help`. - -Here's an example config: - -```yaml -# style name or JSON path (default "auto") -style: "light" -# mouse wheel support (TUI-mode only) -mouse: true -# use pager to display markdown -pager: true -# at which column should we word wrap? -width: 80 -# show all files, including hidden and ignored. -all: false -# show line numbers (TUI-mode only) -showLineNumbers: false -# preserve newlines in the output -preserveNewLines: false -``` - -## Contributing - -See [contributing][contribute]. - -[contribute]: https://github.com/charmbracelet/glow/contribute - -## Feedback - -We’d love to hear your thoughts on this project. Feel free to drop us a note! - -- [Twitter](https://twitter.com/charmcli) -- [The Fediverse](https://mastodon.social/@charmcli) -- [Discord](https://charm.sh/chat) - -## License - -[MIT](https://github.com/charmbracelet/glow/raw/master/LICENSE) - ---- - -Part of [Charm](https://charm.sh). - -The Charm logo - -Charm热爱开源 • Charm loves open source +Currently `gold` uses the [Aurora ANSI colors](https://godoc.org/github.com/logrusorgru/aurora#Index). diff --git a/Taskfile.yaml b/Taskfile.yaml deleted file mode 100644 index 6d8c844..0000000 --- a/Taskfile.yaml +++ /dev/null @@ -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] diff --git a/cmd/gold/.goreleaser.yml b/cmd/gold/.goreleaser.yml new file mode 100644 index 0000000..20556ac --- /dev/null +++ b/cmd/gold/.goreleaser.yml @@ -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:' diff --git a/cmd/gold/README.md b/cmd/gold/README.md new file mode 100644 index 0000000..dde04e4 --- /dev/null +++ b/cmd/gold/README.md @@ -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 + +![Gold Dark Theme](https://github.com/charmbracelet/gold/raw/master/cmd/gold/gold_dark.png) diff --git a/cmd/gold/dark.json b/cmd/gold/dark.json new file mode 100644 index 0000000..b853212 --- /dev/null +++ b/cmd/gold/dark.json @@ -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 + } +} diff --git a/cmd/gold/github.go b/cmd/gold/github.go new file mode 100644 index 0000000..2a7661a --- /dev/null +++ b/cmd/gold/github.go @@ -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") +} diff --git a/cmd/gold/gitlab.go b/cmd/gold/gitlab.go new file mode 100644 index 0000000..2c5676e --- /dev/null +++ b/cmd/gold/gitlab.go @@ -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") +} diff --git a/cmd/gold/gold_dark.png b/cmd/gold/gold_dark.png new file mode 100644 index 0000000..921dcb3 Binary files /dev/null and b/cmd/gold/gold_dark.png differ diff --git a/cmd/gold/main.go b/cmd/gold/main.go new file mode 100644 index 0000000..b5244a8 --- /dev/null +++ b/cmd/gold/main.go @@ -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") +} diff --git a/config_cmd.go b/config_cmd.go deleted file mode 100644 index 390a1db..0000000 --- a/config_cmd.go +++ /dev/null @@ -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 -} diff --git a/console_windows.go b/console_windows.go deleted file mode 100644 index 2516b16..0000000 --- a/console_windows.go +++ /dev/null @@ -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() -} diff --git a/example.png b/example.png deleted file mode 100644 index b146f20..0000000 Binary files a/example.png and /dev/null differ diff --git a/github.go b/github.go deleted file mode 100644 index fe862e3..0000000 --- a/github.go +++ /dev/null @@ -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") -} diff --git a/gitlab.go b/gitlab.go deleted file mode 100644 index 68256be..0000000 --- a/gitlab.go +++ /dev/null @@ -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") -} diff --git a/glow_test.go b/glow_test.go deleted file mode 100644 index 8743be2..0000000 --- a/glow_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/go.mod b/go.mod index 6e05b0d..3c829f2 100644 --- a/go.mod +++ b/go.mod @@ -1,75 +1,17 @@ -module github.com/charmbracelet/glow/v2 +module github.com/charmbracelet/gold -go 1.25.12 - -toolchain go1.26.5 +go 1.13 require ( - github.com/atotto/clipboard v0.1.4 - github.com/caarlos0/env/v11 v11.3.1 - github.com/charmbracelet/bubbles v0.21.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/glamour v0.10.0 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/charmbracelet/log v0.4.2 - github.com/charmbracelet/x/editor v0.1.0 - github.com/dustin/go-humanize v1.0.1 - github.com/fsnotify/fsnotify v1.9.0 - github.com/mattn/go-runewidth v0.0.19 - github.com/mitchellh/go-homedir v1.1.0 - github.com/muesli/gitcha v0.3.0 - github.com/muesli/go-app-paths v0.2.2 - github.com/muesli/mango-cobra v1.3.0 - github.com/muesli/reflow v0.3.0 - github.com/muesli/roff v0.1.0 - github.com/muesli/termenv v0.16.0 - github.com/sahilm/fuzzy v0.1.1 - github.com/spf13/cobra v1.10.2 - github.com/spf13/viper v1.21.0 - golang.org/x/sys v0.43.0 - golang.org/x/term v0.42.0 - golang.org/x/text v0.39.0 - mvdan.cc/sh/v3 v3.13.1 -) - -require ( - github.com/alecthomas/chroma/v2 v2.14.0 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/aymerick/douceur v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/clipperhouse/uax29/v2 v2.2.0 // indirect - github.com/dlclark/regexp2 v1.11.0 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/go-logfmt/logfmt v0.6.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/gorilla/css v1.0.1 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/mango v0.2.0 // indirect - github.com/muesli/mango-pflag v0.1.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 // indirect - github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect - github.com/spf13/afero v1.15.0 // indirect - github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.7.17 // indirect - github.com/yuin/goldmark-emoji v1.0.5 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect - golang.org/x/net v0.53.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + github.com/alecthomas/chroma v0.7.0 + github.com/disintegration/imaging v1.6.2 // indirect + github.com/eliukblau/pixterm v1.2.4 + github.com/logrusorgru/aurora v0.0.0-20191017060258-dc85c304c434 + github.com/lucasb-eyer/go-colorful v0.0.0-20170903184257-231272389856 + github.com/mattn/go-isatty v0.0.10 + github.com/microcosm-cc/bluemonday v1.0.2 + github.com/muesli/go-wordwrap v1.0.1-0.20191125090158-10bc5504e2a8 + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/spf13/cobra v0.0.5 + gopkg.in/russross/blackfriday.v2 v2.0.0 ) diff --git a/go.sum b/go.sum index 0ccd7cd..5f0ac0b 100644 --- a/go.sum +++ b/go.sum @@ -1,177 +1,101 @@ -github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= -github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= -github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= -github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= -github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= -github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= -github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/editor v0.1.0 h1:p69/dpvlwRTs9uYiPeAWruwsHqTFzHhTvQOd/WVSX98= -github.com/charmbracelet/x/editor v0.1.0/go.mod h1:oivrEbcP/AYt/Hpvk5pwDXXrQ933gQS6UzL6fxqAGSA= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= -github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= +github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0= +github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= +github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= +github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= +github.com/alecthomas/chroma v0.7.0 h1:z+0HgTUmkpRDRz0SRSdMaqOLfJV4F+N1FPDZUZIDUzw= +github.com/alecthomas/chroma v0.7.0/go.mod h1:1U/PfCsTALWWYHDnsIQkxEBM0+6LLe0v8+RSVMOwxeY= +github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= +github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= +github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= +github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= +github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA= +github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY= +github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= +github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= +github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= -github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= -github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= -github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= -github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= -github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg= +github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/eliukblau/pixterm v1.2.4 h1:EQ8KuWLeDNdm/AWZlylR9hX0elAve+MXLoIFh9Ssrjo= +github.com/eliukblau/pixterm v1.2.4/go.mod h1:SLrMnkvTqu64HiEUZ+UEQOAh81M4FlxDwxU69KmWJuQ= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI= +github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/logrusorgru/aurora v0.0.0-20191017060258-dc85c304c434 h1:im9kkmH0WWwxzegiv18gSUJbuXR9y028rXrWuPp6Jug= +github.com/logrusorgru/aurora v0.0.0-20191017060258-dc85c304c434/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/lucasb-eyer/go-colorful v0.0.0-20170903184257-231272389856 h1:r+WvXmgROttp7pckv7TPN7OCUEPXmvhRklOOsL2iPPc= +github.com/lucasb-eyer/go-colorful v0.0.0-20170903184257-231272389856/go.mod h1:NXg0ArsFk0Y01623LgUqoqcouGDB+PwCCQlrwrG6xJ4= +github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s= +github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/gitcha v0.3.0 h1:+PJkVKrDXVB0VgRn/yVx2CqSVSDGMSepzvohsCrPYtQ= -github.com/muesli/gitcha v0.3.0/go.mod h1:vX3jFL+XcEUq1uY74RCjLSZfAV+ZuvLg70/NGPdXn84= -github.com/muesli/go-app-paths v0.2.2 h1:NqG4EEZwNIhBq/pREgfBmgDmt3h1Smr1MjZiXbpZUnI= -github.com/muesli/go-app-paths v0.2.2/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho= -github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= -github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= -github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec= -github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E= -github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= -github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= -github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/muesli/go-wordwrap v1.0.1-0.20191125090158-10bc5504e2a8 h1:pkJGPjqhekH0hq/lJABlh5EhzU7eU/+VdFq9ZMOae2E= +github.com/muesli/go-wordwrap v1.0.1-0.20191125090158-10bc5504e2a8/go.mod h1:isHWaQQfvQ38DUiq7ugbuPZowGJ+9gPmACGc3tDUN4M= +github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 h1:G04eS0JkAIVZfaJLjla9dNxkJCPiKIGZlw9AfOhzOD0= -github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94/go.mod h1:b18R55ulyQ/h3RaWyloPyER7fWQVZvimKKhnI5OfrJQ= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA= -github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= -github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM= -golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a h1:1n5lsVfiQW3yfsRGu98756EH1YthsFqr/5mxHduZW2A= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191008105621-543471e840be h1:QAcqgptGM8IQBC9K/RC4o+O9YmqEm0diQn9QmZw/0mU= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= -mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= +gopkg.in/russross/blackfriday.v2 v2.0.0 h1:+FlnIV8DSQnT7NZ43hcVKcdJdzZoeCmJj4Ql8gq5keA= +gopkg.in/russross/blackfriday.v2 v2.0.0/go.mod h1:6sSBNz/GtOm/pJTuh5UmBK2ZHfmnxGbl2NZg1UliSOI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/gold.go b/gold.go new file mode 100644 index 0000000..0912f11 --- /dev/null +++ b/gold.go @@ -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 + } +} diff --git a/log.go b/log.go deleted file mode 100644 index a864a71..0000000 --- a/log.go +++ /dev/null @@ -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 -} diff --git a/main.go b/main.go deleted file mode 100644 index b31ca15..0000000 --- a/main.go +++ /dev/null @@ -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) - } -} diff --git a/man_cmd.go b/man_cmd.go deleted file mode 100644 index a179ef7..0000000 --- a/man_cmd.go +++ /dev/null @@ -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 - }, -} diff --git a/style.go b/style.go index b688a4e..3afe646 100644 --- a/style.go +++ b/style.go @@ -1,14 +1,108 @@ -package main +package gold -import "github.com/charmbracelet/lipgloss" - -var ( - keyword = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#04B575")). - Render - - paragraph = lipgloss.NewStyle(). - Width(78). - Padding(0, 0, 0, 2). - Render +import ( + "fmt" ) + +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) + } +} diff --git a/ui/config.go b/ui/config.go deleted file mode 100644 index 001d5b8..0000000 --- a/ui/config.go +++ /dev/null @@ -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"` -} diff --git a/ui/editor.go b/ui/editor.go deleted file mode 100644 index a567c59..0000000 --- a/ui/editor.go +++ /dev/null @@ -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) -} diff --git a/ui/ignore_darwin.go b/ui/ignore_darwin.go deleted file mode 100644 index b69437a..0000000 --- a/ui/ignore_darwin.go +++ /dev/null @@ -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", - ".*", - } -} diff --git a/ui/ignore_general.go b/ui/ignore_general.go deleted file mode 100644 index f4dc8be..0000000 --- a/ui/ignore_general.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build !darwin -// +build !darwin - -package ui - -func ignorePatterns(m commonModel) []string { - return []string{ - m.cfg.Gopath, - "node_modules", - ".*", - } -} diff --git a/ui/keys.go b/ui/keys.go deleted file mode 100644 index 8e13f95..0000000 --- a/ui/keys.go +++ /dev/null @@ -1,6 +0,0 @@ -package ui - -const ( - keyEnter = "enter" - keyEsc = "esc" -) diff --git a/ui/markdown.go b/ui/markdown.go deleted file mode 100644 index c67b1dc..0000000 --- a/ui/markdown.go +++ /dev/null @@ -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}, -} diff --git a/ui/pager.go b/ui/pager.go deleted file mode 100644 index 5c522e7..0000000 --- a/ui/pager.go +++ /dev/null @@ -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) -} diff --git a/ui/sort.go b/ui/sort.go deleted file mode 100644 index 4f389fb..0000000 --- a/ui/sort.go +++ /dev/null @@ -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) - }) -} diff --git a/ui/stash.go b/ui/stash.go deleted file mode 100644 index c3739b2..0000000 --- a/ui/stash.go +++ /dev/null @@ -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) - } -} diff --git a/ui/stashhelp.go b/ui/stashhelp.go deleted file mode 100644 index 6c0b474..0000000 --- a/ui/stashhelp.go +++ /dev/null @@ -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 -} diff --git a/ui/stashitem.go b/ui/stashitem.go deleted file mode 100644 index 0000928..0000000 --- a/ui/stashitem.go +++ /dev/null @@ -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() -} diff --git a/ui/styles.go b/ui/styles.go deleted file mode 100644 index fd25882..0000000 --- a/ui/styles.go +++ /dev/null @@ -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 -) diff --git a/ui/ui.go b/ui/ui.go deleted file mode 100644 index 3537d3f..0000000 --- a/ui/ui.go +++ /dev/null @@ -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() -} diff --git a/url.go b/url.go deleted file mode 100644 index f716681..0000000 --- a/url.go +++ /dev/null @@ -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, "://") -} diff --git a/url_test.go b/url_test.go deleted file mode 100644 index 9683892..0000000 --- a/url_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/utils/utils.go b/utils/utils.go deleted file mode 100644 index 9f33a1e..0000000 --- a/utils/utils.go +++ /dev/null @@ -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) -}