diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5de2df8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# https://editorconfig.org/ + +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.go] +indent_style = tab +indent_size = 8 + +[*.golden] +insert_final_newline = false +trim_trailing_whitespace = false diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..c19be87 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Setup** +Please complete the following information along with version numbers, if applicable. + - OS [e.g. Ubuntu, macOS] + - Shell [e.g. zsh, fish] + - Terminal Emulator [e.g. kitty, iterm] + - Terminal Multiplexer [e.g. tmux] + - Locale [e.g. en_US.UTF-8, zh_CN.UTF-8, etc.] + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Source Code** +Please include source code if needed to reproduce the behavior. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +Add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..897a394 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..11fc491 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d944991 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,57 @@ +version: 2 + +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "05:00" + timezone: "America/New_York" + labels: + - "dependencies" + commit-message: + prefix: "chore" + include: "scope" + groups: + all: + patterns: + - "*" + ignore: + - dependency-name: github.com/charmbracelet/bubbletea/v2 + versions: + - v2.0.0-beta1 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "05:00" + timezone: "America/New_York" + labels: + - "dependencies" + commit-message: + prefix: "chore" + include: "scope" + groups: + all: + patterns: + - "*" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "05:00" + timezone: "America/New_York" + labels: + - "dependencies" + commit-message: + prefix: "chore" + include: "scope" + groups: + all: + patterns: + - "*" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..adcb449 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,25 @@ +name: build + +on: [push, pull_request] + +jobs: + build: + uses: charmbracelet/meta/.github/workflows/build.yml@main + + snapshot: + uses: charmbracelet/meta/.github/workflows/snapshot.yml@main + secrets: + goreleaser_key: ${{ secrets.GORELEASER_KEY }} + + govulncheck: + uses: charmbracelet/meta/.github/workflows/govulncheck.yml@main + with: + go-version: stable + + semgrep: + uses: charmbracelet/meta/.github/workflows/semgrep.yml@main + + ruleguard: + uses: charmbracelet/meta/.github/workflows/ruleguard.yml@main + with: + go-version: stable diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..7fbd40b --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,24 @@ +name: coverage +on: [push, pull_request] + +jobs: + coverage: + runs-on: ubuntu-latest + env: + GO111MODULE: "on" + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Install Go + uses: actions/setup-go@v6 + with: + go-version: stable + + - name: Coverage + env: + COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + go test -race -covermode atomic -coverprofile=profile.cov ./... + go install github.com/mattn/goveralls@latest + goveralls -coverprofile=profile.cov -service=github diff --git a/.github/workflows/dependabot-sync.yml b/.github/workflows/dependabot-sync.yml new file mode 100644 index 0000000..9b08259 --- /dev/null +++ b/.github/workflows/dependabot-sync.yml @@ -0,0 +1,17 @@ +name: dependabot-sync +on: + schedule: + - cron: "0 0 * * 0" # every Sunday at midnight + workflow_dispatch: # allows manual triggering + +permissions: + contents: write + pull-requests: write + +jobs: + dependabot-sync: + uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main + with: + repo_name: ${{ github.event.repository.name }} + secrets: + gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml deleted file mode 100644 index b1699e8..0000000 --- a/.github/workflows/go.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: build -on: [push, pull_request] -jobs: - test: - strategy: - matrix: - go-version: [1.11.x, 1.12.x, 1.13.x] - platform: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.platform }} - env: - GO111MODULE: "on" - steps: - - - name: Install Go - uses: actions/setup-go@v1 - with: - go-version: ${{ matrix.go-version }} - - - name: Checkout code - uses: actions/checkout@v1 - - - name: Download Go modules - run: go mod download - - - name: Build - run: go build -v ./... - - - name: Test - run: go test ./... - - - name: Coverage - env: - COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - go test -race -covermode atomic -coverprofile=profile.cov ./... - GO111MODULE=off go get github.com/mattn/goveralls - $(go env GOPATH)/bin/goveralls -coverprofile=profile.cov -service=github - if: matrix.go-version == '1.13.x' && matrix.platform == 'ubuntu-latest' diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml new file mode 100644 index 0000000..fbc2046 --- /dev/null +++ b/.github/workflows/goreleaser.yml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + +name: goreleaser + +on: + push: + tags: + - v*.*.* + +concurrency: + group: goreleaser + cancel-in-progress: true + +jobs: + goreleaser: + uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main + secrets: + docker_username: ${{ secrets.DOCKERHUB_USERNAME }} + docker_token: ${{ secrets.DOCKERHUB_TOKEN }} + gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + goreleaser_key: ${{ secrets.GORELEASER_KEY }} + fury_token: ${{ secrets.FURY_TOKEN }} + nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }} + nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }} + snapcraft_token: ${{ secrets.SNAPCRAFT_TOKEN }} diff --git a/.github/workflows/lint-sync.yml b/.github/workflows/lint-sync.yml new file mode 100644 index 0000000..43f380f --- /dev/null +++ b/.github/workflows/lint-sync.yml @@ -0,0 +1,13 @@ +name: lint-sync +on: + schedule: + - cron: "0 0 * * 0" # every sunday at midnight + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + lint: + uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..1e21125 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,18 @@ +name: lint +on: + push: + pull_request: + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: golangci-lint + uses: golangci/golangci-lint-action@v9.2.0 + with: + # Optional: golangci-lint command line arguments. + args: --issues-exit-code=0 + # Optional: show only new issues if it's a pull request. The default value is `false`. + only-new-issues: true diff --git a/.gitignore b/.gitignore index 4b20fa2..8a5159a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ glow dist/ +.envrc +completions/ +manpages/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..929cb0a --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,44 @@ +version: "2" +run: + tests: false +linters: + enable: + - bodyclose + - exhaustive + - goconst + - godot + - gomoddirectives + - goprintffuncname + - gosec + - misspell + - nakedret + - nestif + - nilerr + - noctx + - nolintlint + - prealloc + - revive + - rowserrcheck + - sqlclosecheck + - tparallel + - unconvert + - unparam + - whitespace + - wrapcheck + exclusions: + rules: + - text: '(slog|log)\.\w+' + linters: + - noctx + generated: lax + presets: + - common-false-positives +issues: + max-issues-per-linter: 0 + max-same-issues: 0 +formatters: + enable: + - gofumpt + - goimports + exclusions: + generated: lax diff --git a/.goreleaser.yml b/.goreleaser.yml index 0b27e47..0c539b7 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,90 +1,14 @@ -env: - - GO111MODULE=on -before: - hooks: - - go mod download -builds: - - - id: "glow" - binary: glow - ldflags: -s -w -X main.Version={{ .Version }} -X main.CommitSHA={{ .Commit }} - goos: - - linux - - freebsd - - windows - goarch: - - amd64 - - arm64 - - 386 - - arm - goarm: - - 6 - - 7 - - - id: "darwin" - binary: "glow" - ldflags: -s -w -X main.Version={{ .Version }} -X main.CommitSHA={{ .Commit }} - goos: - - darwin - goarch: - - amd64 +# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json -archives: - - id: default - builds: - - glow - format_overrides: - - goos: windows - format: zip - replacements: - windows: Windows - 386: i386 - amd64: x86_64 - - id: darwin - builds: - - darwin - replacements: - darwin: Darwin - amd64: x86_64 +version: 2 -nfpms: - - - builds: - - glow +includes: + - from_url: + url: charmbracelet/meta/main/goreleaser-glow.yaml - vendor: charmbracelet - homepage: "https://charm.sh/" - maintainer: "Christian Muehlhaeuser " - description: "Render markdown on the CLI" - license: MIT - formats: - - deb - - rpm - bindir: /usr/bin - -brews: - - ids: - - darwin - github: - owner: charmbracelet - name: homebrew-tap - commit_author: - name: "Christian Muehlhaeuser" - email: "muesli@gmail.com" - homepage: "https://charm.sh/" - description: "Render markdown on the CLI" - # skip_upload: true - -signs: - - artifacts: checksum - -checksum: - name_template: 'checksums.txt' -snapshot: - name_template: "{{ .Tag }}-next" -changelog: - sort: asc - filters: - exclude: - - '^docs:' - - '^test:' +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 new file mode 100644 index 0000000..8719b4e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM gcr.io/distroless/static +COPY glow /usr/local/bin/glow +ENTRYPOINT [ "/usr/local/bin/glow" ] diff --git a/LICENSE b/LICENSE index beda40d..1e27a61 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Charmbracelet, Inc +Copyright (c) 2019-2024 Charmbracelet, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index ac59668..cf3bb15 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,147 @@ # Glow -[![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://godoc.org/github.com/charmbracelet/glow) [![Build Status](https://github.com/charmbracelet/glow/workflows/build/badge.svg)](https://github.com/charmbracelet/glow/actions) [![Go ReportCard](http://goreportcard.com/badge/charmbracelet/glow)](http://goreportcard.com/report/charmbracelet/glow) - Render markdown on the CLI, with _pizzazz_! -![Glow example output](https://github.com/charmbracelet/glow/raw/master/example.png) +

+ 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. + +Use it to discover markdown files, read documentation directly on the command +line. Glow will find local markdown files in subdirectories or a local +Git repository. ## Installation -Use your fave package manager: +### Package Manager ```bash -# MacOS -brew install charmbracelet/homebrew-tap/glow +# macOS or Linux +brew install glow +``` +```bash +# macOS (with MacPorts) +sudo port install glow +``` + +```bash # Arch Linux (btw) -yay -S glow +pacman -S glow +``` +```bash +# Void Linux +xbps-install -S glow +``` + +```bash +# Nix shell +nix-shell -p glow --command glow +``` + +```bash # FreeBSD pkg install glow ``` -Or download a binary from the [releases][] page. Windows, MacOS, and Linux -(including ARM) binaries are available, as well as Debian and RPM packages. - -Or just use `go get`: +```bash +# Solus +eopkg install glow +``` ```bash -go get github.com/charmbracelet/glow +# 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 -## Usage +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. -Use a markdown source as the primary argument: +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 -glow - +echo "[Glow](https://github.com/charmbracelet/glow)" | glow - # Fetch README from GitHub / GitLab glow github.com/charmbracelet/glow @@ -52,9 +150,6 @@ glow github.com/charmbracelet/glow glow https://host.tld/file.md ``` -When `glow` is started without a markdown source, it will try to find a -`README.md` or `README` file in the current working directory. - ### Word Wrapping The `-w` flag lets you set a maximum width at which the output will be wrapped: @@ -65,12 +160,14 @@ glow -w 60 ### Paging -The output can be displayed in the user's preferred pager with the `-p` flag. -This defaults to the ANSI-aware `less -r` if `$PAGER` is not explicitly set. +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 (`dark` being the default): +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] @@ -82,27 +179,64 @@ Alternatively you can also supply a custom JSON stylesheet: glow -s mystyle.json ``` -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)! - -*** - 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)! -## Authors +## The Config File -* [Christian Muehlhaeuser](https://github.com/muesli) -* [Toby Padilla](https://github.com/toby) -* [Christian Rocha](https://github.com/meowgorithm) +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`. -Part of [Charm](https://charm.sh). For more info see `ssh charm.sh` +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 diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..6d8c844 --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,23 @@ +# https://taskfile.dev + +version: '3' + +tasks: + lint: + desc: Run base linters + cmds: + - golangci-lint run + + test: + desc: Run tests + cmds: + - go test ./... {{.CLI_ARGS}} + + log: + desc: Watch for glow logs + aliases: [tail] + cmds: + - cmd: tail -f ~/Library/Caches/glow/glow.log + platforms: [darwin] + - cmd: tail -f ~/.cache/glow/glow.log + platforms: [linux, windows] diff --git a/config_cmd.go b/config_cmd.go new file mode 100644 index 0000000..390a1db --- /dev/null +++ b/config_cmd.go @@ -0,0 +1,88 @@ +package main + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + + "github.com/charmbracelet/x/editor" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +const defaultConfig = `# style name or JSON path (default "auto") +style: "auto" +# mouse support (TUI-mode only) +mouse: false +# use pager to display markdown +pager: false +# word-wrap at width +width: 80 +# show all files, including hidden and ignored. +all: false +` + +var configCmd = &cobra.Command{ + Use: "config", + Hidden: false, + Short: "Edit the glow config file", + Long: paragraph(fmt.Sprintf("\n%s the glow config file. We’ll use EDITOR to determine which editor to use. If the config file doesn't exist, it will be created.", keyword("Edit"))), + Example: paragraph("glow config\nglow config --config path/to/config.yml"), + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { + if err := ensureConfigFile(); err != nil { + return err + } + + c, err := editor.Cmd("Glow", configFile) + if err != nil { + return fmt.Errorf("unable to set config file: %w", err) + } + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + return fmt.Errorf("unable to run command: %w", err) + } + + fmt.Println("Wrote config file to:", configFile) + return nil + }, +} + +func ensureConfigFile() error { + if configFile == "" { + configFile = viper.GetViper().ConfigFileUsed() + if err := os.MkdirAll(filepath.Dir(configFile), 0o755); err != nil { //nolint:gosec + return fmt.Errorf("could not write configuration file: %w", err) + } + } + + if ext := path.Ext(configFile); ext != ".yaml" && ext != ".yml" { + return fmt.Errorf("'%s' is not a supported configuration type: use '%s' or '%s'", ext, ".yaml", ".yml") + } + + if _, err := os.Stat(configFile); errors.Is(err, fs.ErrNotExist) { + // File doesn't exist yet, create all necessary directories and + // write the default config file + if err := os.MkdirAll(filepath.Dir(configFile), 0o700); err != nil { + return fmt.Errorf("unable create directory: %w", err) + } + + f, err := os.Create(configFile) + if err != nil { + return fmt.Errorf("unable to create config file: %w", err) + } + defer func() { _ = f.Close() }() + + if _, err := f.WriteString(defaultConfig); err != nil { + return fmt.Errorf("unable to write config file: %w", err) + } + } else if err != nil { // some other error occurred + return fmt.Errorf("unable to stat config file: %w", err) + } + return nil +} diff --git a/example.png b/example.png index 1b72483..b146f20 100644 Binary files a/example.png and b/example.png differ diff --git a/github.go b/github.go index 1b04da7..fe862e3 100644 --- a/github.go +++ b/github.go @@ -1,45 +1,55 @@ package main import ( + "encoding/json" "errors" + "fmt" + "io" "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 +// 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()) } - u, err := url.ParseRequestURI(s) + type readme struct { + DownloadURL string `json:"download_url"` + } + + apiURL := fmt.Sprintf("https://api.%s/repos/%s/%s/readme", u.Hostname(), owner, repo) + + //nolint:bodyclose + // it is closed on the caller + res, err := http.Get(apiURL) //nolint: gosec,noctx if err != nil { - return "", false + return nil, fmt.Errorf("unable to get url: %w", err) } - return u.String(), strings.ToLower(u.Host) == "github.com" -} - -// findGitHubREADME tries to find the correct README filename in a repository -func findGitHubREADME(s string) (*Source, error) { - u, err := url.ParseRequestURI(s) + body, err := io.ReadAll(res.Body) if err != nil { - return nil, err + return nil, fmt.Errorf("unable to read http response body: %w", err) } - u.Host = "raw.githubusercontent.com" - for _, r := range readmeNames { - v := u - v.Path += "/master/" + r + var result readme + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("unable to parse json: %w", err) + } - resp, err := http.Get(v.String()) + 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, err + return nil, fmt.Errorf("unable to get url: %w", err) } if resp.StatusCode == http.StatusOK { - return &Source{resp.Body, v.String()}, nil + return &source{resp.Body, result.DownloadURL}, nil } } diff --git a/gitlab.go b/gitlab.go index 7c6dfa5..68256be 100644 --- a/gitlab.go +++ b/gitlab.go @@ -1,44 +1,59 @@ package main import ( + "encoding/json" "errors" + "fmt" + "io" "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 +// 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()) } - u, err := url.ParseRequestURI(s) + projectPath := url.QueryEscape(owner + "/" + repo) + + type readme struct { + ReadmeURL string `json:"readme_url"` + } + + apiURL := fmt.Sprintf("https://%s/api/v4/projects/%s", u.Hostname(), projectPath) + + //nolint:bodyclose + // it is closed on the caller + res, err := http.Get(apiURL) //nolint: gosec,noctx if err != nil { - return "", false + return nil, fmt.Errorf("unable to get url: %w", err) } - return u.String(), strings.ToLower(u.Host) == "gitlab.com" -} - -// findGitLabREADME tries to find the correct README filename in a repository -func findGitLabREADME(s string) (*Source, error) { - u, err := url.ParseRequestURI(s) + body, err := io.ReadAll(res.Body) if err != nil { - return nil, err + return nil, fmt.Errorf("unable to read http response body: %w", err) } - for _, r := range readmeNames { - v := u - v.Path += "/raw/master/" + r + var result readme + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("unable to parse json: %w", err) + } - resp, err := http.Get(v.String()) + 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, err + return nil, fmt.Errorf("unable to get url: %w", err) } if resp.StatusCode == http.StatusOK { - return &Source{resp.Body, v.String()}, nil + return &source{resp.Body, readmeRawURL}, nil } } diff --git a/glow_test.go b/glow_test.go index a2c70b6..8743be2 100644 --- a/glow_test.go +++ b/glow_test.go @@ -1,32 +1,9 @@ package main import ( - "bytes" "testing" ) -func TestGlowSources(t *testing.T) { - tt := []string{ - "", - ".", - "README.md", - "github.com/charmbracelet/glow", - "https://github.com/charmbracelet/glow", - } - - for _, v := range tt { - buf := &bytes.Buffer{} - err := executeArg(rootCmd, v, buf) - - if err != nil { - t.Errorf("Error during execution (args: %s): %v", v, err) - } - if buf.Len() == 0 { - t.Errorf("Output buffer should not be empty (args: %s)", v) - } - } -} - func TestGlowFlags(t *testing.T) { tt := []struct { args []string diff --git a/go.mod b/go.mod index a0b110a..6e05b0d 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,75 @@ -module github.com/charmbracelet/glow +module github.com/charmbracelet/glow/v2 -go 1.13 +go 1.25.12 + +toolchain go1.26.5 require ( - github.com/charmbracelet/glamour v0.1.0 - github.com/dlclark/regexp2 v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.4 - github.com/spf13/cobra v0.0.5 - golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 // indirect - golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a + 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 ) diff --git a/go.sum b/go.sum index adb9866..0ccd7cd 100644 --- a/go.sum +++ b/go.sum @@ -1,99 +1,177 @@ -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= -github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0= -github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= -github.com/alecthomas/chroma v0.7.0 h1:z+0HgTUmkpRDRz0SRSdMaqOLfJV4F+N1FPDZUZIDUzw= -github.com/alecthomas/chroma v0.7.0/go.mod h1:1U/PfCsTALWWYHDnsIQkxEBM0+6LLe0v8+RSVMOwxeY= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= -github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= -github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= -github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA= -github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY= -github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= -github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= -github.com/charmbracelet/glamour v0.1.0 h1:BHCtc+YJjoBjNUnFKBtXyyM4Bp9u7L2kf49qV+/AGYw= -github.com/charmbracelet/glamour v0.1.0/go.mod h1:Z1C2JkVGBom/RYfoKcPBZ81lHMR3xp3W6OCLNWWEIMc= -github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= -github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= -github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/editor v0.1.0 h1:p69/dpvlwRTs9uYiPeAWruwsHqTFzHhTvQOd/WVSX98= +github.com/charmbracelet/x/editor v0.1.0/go.mod h1:oivrEbcP/AYt/Hpvk5pwDXXrQ933gQS6UzL6fxqAGSA= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/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/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= -github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -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-20191116043053-66b7ad493a23 h1:Wp7NjqGKGN9te9N/rvXYRhlVcrulGdxnz8zadXWs7fc= -github.com/logrusorgru/aurora v0.0.0-20191116043053-66b7ad493a23/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= -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 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= -github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54= -github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s= -github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/muesli/reflow v0.0.0-20191216070243-e5efeac4e302 h1:jOh3Kh03uOFkRPV3PI4Am5tqACv2aELgbPgr7YgNX00= -github.com/muesli/reflow v0.0.0-20191216070243-e5efeac4e302/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I= -github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= -github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8= -github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/gitcha v0.3.0 h1:+PJkVKrDXVB0VgRn/yVx2CqSVSDGMSepzvohsCrPYtQ= +github.com/muesli/gitcha v0.3.0/go.mod h1:vX3jFL+XcEUq1uY74RCjLSZfAV+ZuvLg70/NGPdXn84= +github.com/muesli/go-app-paths v0.2.2 h1:NqG4EEZwNIhBq/pREgfBmgDmt3h1Smr1MjZiXbpZUnI= +github.com/muesli/go-app-paths v0.2.2/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho= +github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= +github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec= +github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= -github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 h1:G04eS0JkAIVZfaJLjla9dNxkJCPiKIGZlw9AfOhzOD0= +github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94/go.mod h1:b18R55ulyQ/h3RaWyloPyER7fWQVZvimKKhnI5OfrJQ= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= -github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= -github.com/yuin/goldmark v1.1.19 h1:0s2/60x0XsFCXHeFut+F3azDVAAyIMyUfJRbRexiTYs= -github.com/yuin/goldmark v1.1.19/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -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/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA= +github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM= +golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= +mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/log.go b/log.go new file mode 100644 index 0000000..a864a71 --- /dev/null +++ b/log.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/charmbracelet/log" + gap "github.com/muesli/go-app-paths" +) + +func getLogFilePath() (string, error) { + dir, err := gap.NewScope(gap.User, "glow").CacheDir() + if err != nil { + return "", fmt.Errorf("unable to get cache dir: %w", err) + } + return filepath.Join(dir, "glow.log"), nil +} + +func setupLog() (func() error, error) { + log.SetOutput(io.Discard) + // Log to file, if set + logFile, err := getLogFilePath() + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(logFile), 0o755); err != nil { //nolint:gosec + // log disabled + return func() error { return nil }, nil //nolint:nilerr + } + f, err := os.OpenFile(logFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644) //nolint:gosec + if err != nil { + // log disabled + return func() error { return nil }, nil //nolint:nilerr + } + log.SetOutput(f) + log.SetLevel(log.DebugLevel) + return f.Close, nil +} diff --git a/main.go b/main.go index 0d53e02..b31ca15 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,11 @@ +// Package main provides the entry point for the Glow CLI application. package main import ( "errors" "fmt" "io" - "io/ioutil" + "io/fs" "net/http" "net/url" "os" @@ -12,126 +13,273 @@ import ( "path/filepath" "strings" - "github.com/mattn/go-isatty" - "github.com/spf13/cobra" + "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 = "" + // Version as provided by goreleaser. + Version = "" + // CommitSHA as provided by goreleaser. CommitSHA = "" - readmeNames = []string{"README.md", "README"} - pager bool - style string - width uint + 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", - Short: "Render markdown on the CLI, with pizzazz!", - SilenceErrors: false, - SilenceUsage: false, - RunE: execute, + 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, } ) -type Source struct { +// source provides a readable markdown source. +type source struct { reader io.ReadCloser URL string } -func readerFromArg(s string) (*Source, error) { - if s == "-" { - return &Source{reader: os.Stdin}, nil +// 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): - if u, ok := isGitHubURL(s); ok { - src, err := findGitHubREADME(u) - if err != nil { - return nil, err - } - return src, nil - } - if u, ok := isGitLabURL(s); ok { - src, err := findGitLabREADME(u) - if err != nil { - return nil, err - } + 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(s); err == nil { + 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) } - - resp, err := http.Get(u.String()) + // consumer of the source is responsible for closing the ReadCloser. + resp, err := http.Get(u.String()) //nolint: noctx,bodyclose if err != nil { - return nil, err + 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 + return &source{resp.Body, u.String()}, nil } } - // a valid file or directory: - st, err := os.Stat(s) - if len(s) == 0 || (err == nil && st.IsDir()) { - for _, v := range readmeNames { - n := filepath.Join(s, v) - r, err := os.Open(n) - if err == nil { - u, _ := filepath.Abs(n) - return &Source{r, u}, 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(s) - u, _ := filepath.Abs(s) - return &Source{r, u}, err + 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 } -func execute(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return executeArg(cmd, "", os.Stdout) - } - - for _, arg := range args { - if err := executeArg(cmd, arg, os.Stdout); err != nil { - return err +// 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 executeArg(cmd *cobra.Command, arg string, w io.Writer) error { - // create an io.Reader from the markdown source in cli-args - src, err := readerFromArg(arg) - if err != nil { - return err +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") } - defer src.reader.Close() - b, err := ioutil.ReadAll(src.reader) - if err != nil { + + // 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 !isatty.IsTerminal(os.Stdout.Fd()) && - !cmd.Flags().Changed("style") { + 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) @@ -140,57 +288,106 @@ func executeArg(cmd *cobra.Command, arg string, w io.Writer) error { baseURL = u.String() + "/" } + isCode := !utils.IsMarkdownFile(src.URL) + + // initialize glamour r, err := glamour.NewTermRenderer( - glamour.WithStylePath(style), - glamour.WithWordWrap(int(width)), + glamour.WithColorProfile(lipgloss.ColorProfile()), + utils.GlamourStyle(style, isCode), + glamour.WithWordWrap(int(width)), //nolint:gosec glamour.WithBaseURL(baseURL), + glamour.WithPreservedNewLines(), ) if err != nil { - return err + return fmt.Errorf("unable to create renderer: %w", err) } - out, err := r.RenderBytes(b) + 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 err - } - - // trim lines - lines := strings.Split(string(out), "\n") - var content string - for i, s := range lines { - content += strings.TrimSpace(s) - - // don't add an artificial newline after the last split - if i+1 < len(lines) { - content += "\n" - } + return fmt.Errorf("unable to render markdown: %w", err) } // display - if cmd.Flags().Changed("pager") { - pager := os.Getenv("PAGER") - if pager == "" { - pager = "less -r" + switch { + case pager || cmd.Flags().Changed("pager"): + pagerCmd := os.Getenv("PAGER") + if pagerCmd == "" { + pagerCmd = "less -r" } - pa := strings.Split(pager, " ") - c := exec.Command(pa[0], pa[1:]...) - c.Stdin = strings.NewReader(content) + 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 - return c.Run() + 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) } - fmt.Fprint(w, content) return nil } func main() { - if err := rootCmd.Execute(); err != nil { - os.Exit(-1) + 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") @@ -199,8 +396,78 @@ func init() { 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().StringVarP(&style, "style", "s", "dark", "style name or JSON path") - rootCmd.Flags().UintVarP(&width, "width", "w", 100, "word-wrap at width") + 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 new file mode 100644 index 0000000..a179ef7 --- /dev/null +++ b/man_cmd.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "os" + + mcobra "github.com/muesli/mango-cobra" + "github.com/muesli/roff" + "github.com/spf13/cobra" +) + +var manCmd = &cobra.Command{ + Use: "man", + Short: "Generates manpages", + SilenceUsage: true, + DisableFlagsInUseLine: true, + Hidden: true, + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { + manPage, err := mcobra.NewManPage(1, rootCmd) + if err != nil { + return fmt.Errorf("unable to instantiate man page: %w", err) + } + if _, err := fmt.Fprint(os.Stdout, manPage.Build(roff.NewDocument())); err != nil { + return fmt.Errorf("unable to build man page: %w", err) + } + return nil + }, +} diff --git a/style.go b/style.go new file mode 100644 index 0000000..b688a4e --- /dev/null +++ b/style.go @@ -0,0 +1,14 @@ +package main + +import "github.com/charmbracelet/lipgloss" + +var ( + keyword = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#04B575")). + Render + + paragraph = lipgloss.NewStyle(). + Width(78). + Padding(0, 0, 0, 2). + Render +) diff --git a/ui/config.go b/ui/config.go new file mode 100644 index 0000000..001d5b8 --- /dev/null +++ b/ui/config.go @@ -0,0 +1,20 @@ +package ui + +// Config contains TUI-specific configuration. +type Config struct { + ShowAllFiles bool + ShowLineNumbers bool + Gopath string `env:"GOPATH"` + HomeDir string `env:"HOME"` + GlamourMaxWidth uint + GlamourStyle string `env:"GLAMOUR_STYLE"` + EnableMouse bool + PreserveNewLines bool + + // Working directory or file path + Path string + + // For debugging the UI + HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" envDefault:"true"` + GlamourEnabled bool `env:"GLOW_ENABLE_GLAMOUR" envDefault:"true"` +} diff --git a/ui/editor.go b/ui/editor.go new file mode 100644 index 0000000..a567c59 --- /dev/null +++ b/ui/editor.go @@ -0,0 +1,19 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/editor" +) + +type editorFinishedMsg struct{ err error } + +func openEditor(path string, lineno int) tea.Cmd { + cb := func(err error) tea.Msg { + return editorFinishedMsg{err} + } + cmd, err := editor.Cmd("Glow", path, editor.LineNumber(uint(lineno))) //nolint:gosec + if err != nil { + return func() tea.Msg { return cb(err) } + } + return tea.ExecProcess(cmd, cb) +} diff --git a/ui/ignore_darwin.go b/ui/ignore_darwin.go new file mode 100644 index 0000000..b69437a --- /dev/null +++ b/ui/ignore_darwin.go @@ -0,0 +1,15 @@ +//go:build darwin +// +build darwin + +package ui + +import "path/filepath" + +func ignorePatterns(m commonModel) []string { + return []string{ + filepath.Join(m.cfg.HomeDir, "Library"), + m.cfg.Gopath, + "node_modules", + ".*", + } +} diff --git a/ui/ignore_general.go b/ui/ignore_general.go new file mode 100644 index 0000000..f4dc8be --- /dev/null +++ b/ui/ignore_general.go @@ -0,0 +1,12 @@ +//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 new file mode 100644 index 0000000..8e13f95 --- /dev/null +++ b/ui/keys.go @@ -0,0 +1,6 @@ +package ui + +const ( + keyEnter = "enter" + keyEsc = "esc" +) diff --git a/ui/markdown.go b/ui/markdown.go new file mode 100644 index 0000000..c67b1dc --- /dev/null +++ b/ui/markdown.go @@ -0,0 +1,88 @@ +package ui + +import ( + "fmt" + "math" + "time" + "unicode" + + "github.com/charmbracelet/log" + "github.com/dustin/go-humanize" + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" +) + +type markdown struct { + // Full path of a local markdown file. Only relevant to local documents and + // those that have been stashed in this session. + localPath string + + // Value we filter against. This exists so that we can maintain positions + // of filtered items if notes are edited while a filter is active. This + // field is ephemeral, and should only be referenced during filtering. + filterValue string + + Body string + Note string + Modtime time.Time +} + +// Generate the value we're doing to filter against. +func (m *markdown) buildFilterValue() { + note, err := normalize(m.Note) + if err != nil { + log.Error("error normalizing", "note", m.Note, "error", err) + m.filterValue = m.Note + } + + m.filterValue = note +} + +func (m markdown) relativeTime() string { + return relativeTime(m.Modtime) +} + +// Normalize text to aid in the filtering process. In particular, we remove +// diacritics, "ö" becomes "o". Note that Mn is the unicode key for nonspacing +// marks. +func normalize(in string) (string, error) { + t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + out, _, err := transform.String(t, in) + if err != nil { + return "", fmt.Errorf("error normalizing: %w", err) + } + return out, nil +} + +// Return the time in a human-readable format relative to the current time. +func relativeTime(then time.Time) string { + now := time.Now() + if ago := now.Sub(then); ago < time.Minute { + return "just now" + } else if ago < humanize.Week { + return humanize.CustomRelTime(then, now, "ago", "from now", magnitudes) + } + return then.Format("02 Jan 2006 15:04 MST") +} + +// Magnitudes for relative time. +var magnitudes = []humanize.RelTimeMagnitude{ + {D: time.Second, Format: "now", DivBy: time.Second}, + {D: 2 * time.Second, Format: "1 second %s", DivBy: 1}, + {D: time.Minute, Format: "%d seconds %s", DivBy: time.Second}, + {D: 2 * time.Minute, Format: "1 minute %s", DivBy: 1}, + {D: time.Hour, Format: "%d minutes %s", DivBy: time.Minute}, + {D: 2 * time.Hour, Format: "1 hour %s", DivBy: 1}, + {D: humanize.Day, Format: "%d hours %s", DivBy: time.Hour}, + {D: 2 * humanize.Day, Format: "1 day %s", DivBy: 1}, + {D: humanize.Week, Format: "%d days %s", DivBy: humanize.Day}, + {D: 2 * humanize.Week, Format: "1 week %s", DivBy: 1}, + {D: humanize.Month, Format: "%d weeks %s", DivBy: humanize.Week}, + {D: 2 * humanize.Month, Format: "1 month %s", DivBy: 1}, + {D: humanize.Year, Format: "%d months %s", DivBy: humanize.Month}, + {D: 18 * humanize.Month, Format: "1 year %s", DivBy: 1}, + {D: 2 * humanize.Year, Format: "2 years %s", DivBy: 1}, + {D: humanize.LongTime, Format: "%d years %s", DivBy: humanize.Year}, + {D: math.MaxInt64, Format: "a long while %s", DivBy: 1}, +} diff --git a/ui/pager.go b/ui/pager.go new file mode 100644 index 0000000..5c522e7 --- /dev/null +++ b/ui/pager.go @@ -0,0 +1,535 @@ +package ui + +import ( + "fmt" + "math" + "path/filepath" + "strings" + "time" + + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glow/v2/utils" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/log" + "github.com/fsnotify/fsnotify" + runewidth "github.com/mattn/go-runewidth" + "github.com/muesli/reflow/ansi" + "github.com/muesli/reflow/truncate" + "github.com/muesli/termenv" +) + +const ( + statusBarHeight = 1 + lineNumberWidth = 4 +) + +var ( + pagerHelpHeight int + + mintGreen = lipgloss.AdaptiveColor{Light: "#89F0CB", Dark: "#89F0CB"} + darkGreen = lipgloss.AdaptiveColor{Light: "#1C8760", Dark: "#1C8760"} + + lineNumberFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"} + + statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"} + statusBarBg = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"} + + statusBarScrollPosStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#949494", Dark: "#5A5A5A"}). + Background(statusBarBg). + Render + + statusBarNoteStyle = lipgloss.NewStyle(). + Foreground(statusBarNoteFg). + Background(statusBarBg). + Render + + statusBarHelpStyle = lipgloss.NewStyle(). + Foreground(statusBarNoteFg). + Background(lipgloss.AdaptiveColor{Light: "#DCDCDC", Dark: "#323232"}). + Render + + statusBarMessageStyle = lipgloss.NewStyle(). + Foreground(mintGreen). + Background(darkGreen). + Render + + statusBarMessageScrollPosStyle = lipgloss.NewStyle(). + Foreground(mintGreen). + Background(darkGreen). + Render + + statusBarMessageHelpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#B6FFE4")). + Background(green). + Render + + helpViewStyle = lipgloss.NewStyle(). + Foreground(statusBarNoteFg). + Background(lipgloss.AdaptiveColor{Light: "#f2f2f2", Dark: "#1B1B1B"}). + Render + + lineNumberStyle = lipgloss.NewStyle(). + Foreground(lineNumberFg). + Render +) + +type ( + contentRenderedMsg string + reloadMsg struct{} +) + +type pagerState int + +const ( + pagerStateBrowse pagerState = iota + pagerStateStatusMessage +) + +type pagerModel struct { + common *commonModel + viewport viewport.Model + state pagerState + showHelp bool + + statusMessage string + statusMessageTimer *time.Timer + + // Current document being rendered, sans-glamour rendering. We cache + // it here so we can re-render it on resize. + currentDocument markdown + + watcher *fsnotify.Watcher +} + +func newPagerModel(common *commonModel) pagerModel { + // Init viewport + vp := viewport.New(0, 0) + vp.YPosition = 0 + vp.HighPerformanceRendering = config.HighPerformancePager + + m := pagerModel{ + common: common, + state: pagerStateBrowse, + viewport: vp, + } + m.initWatcher() + return m +} + +func (m *pagerModel) setSize(w, h int) { + m.viewport.Width = w + m.viewport.Height = h - statusBarHeight + + if m.showHelp { + if pagerHelpHeight == 0 { + pagerHelpHeight = strings.Count(m.helpView(), "\n") + } + m.viewport.Height -= (statusBarHeight + pagerHelpHeight) + } +} + +func (m *pagerModel) setContent(s string) { + m.viewport.SetContent(s) +} + +func (m *pagerModel) toggleHelp() { + m.showHelp = !m.showHelp + m.setSize(m.common.width, m.common.height) + if m.viewport.PastBottom() { + m.viewport.GotoBottom() + } +} + +type pagerStatusMessage struct { + message string + isError bool +} + +// Perform stuff that needs to happen after a successful markdown stash. Note +// that the returned command should be sent back the through the pager +// update function. +func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd { + // Show a success message to the user + m.state = pagerStateStatusMessage + m.statusMessage = msg.message + if m.statusMessageTimer != nil { + m.statusMessageTimer.Stop() + } + m.statusMessageTimer = time.NewTimer(statusMessageTimeout) + + return waitForStatusMessageTimeout(pagerContext, m.statusMessageTimer) +} + +func (m *pagerModel) unload() { + log.Debug("unload") + if m.showHelp { + m.toggleHelp() + } + if m.statusMessageTimer != nil { + m.statusMessageTimer.Stop() + } + m.state = pagerStateBrowse + m.viewport.SetContent("") + m.viewport.YOffset = 0 + m.unwatchFile() +} + +func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", keyEsc: + if m.state != pagerStateBrowse { + m.state = pagerStateBrowse + return m, nil + } + case "home", "g": + m.viewport.GotoTop() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + case "end", "G": + m.viewport.GotoBottom() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + + case "d": + m.viewport.HalfViewDown() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + + case "u": + m.viewport.HalfViewUp() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + + case "e": + lineno := int(math.RoundToEven(float64(m.viewport.TotalLineCount()) * m.viewport.ScrollPercent())) + if m.viewport.AtTop() { + lineno = 0 + } + log.Info( + "opening editor", + "file", m.currentDocument.localPath, + "line", fmt.Sprintf("%d/%d", lineno, m.viewport.TotalLineCount()), + ) + return m, openEditor(m.currentDocument.localPath, lineno) + + case "c": + // Copy using OSC 52 + termenv.Copy(m.currentDocument.Body) + // Copy using native system clipboard + _ = clipboard.WriteAll(m.currentDocument.Body) + cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Copied contents", false})) + + case "r": + return m, loadLocalMarkdown(&m.currentDocument) + + case "?": + m.toggleHelp() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + } + + // Glow has rendered the content + case contentRenderedMsg: + log.Info("content rendered", "state", m.state) + + m.setContent(string(msg)) + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + cmds = append(cmds, m.watchFile) + + // The file was changed on disk and we're reloading it + case reloadMsg: + return m, loadLocalMarkdown(&m.currentDocument) + + // We've finished editing the document, potentially making changes. Let's + // retrieve the latest version of the document so that we display + // up-to-date contents. + case editorFinishedMsg: + return m, loadLocalMarkdown(&m.currentDocument) + + // We've received terminal dimensions, either for the first time or + // after a resize + case tea.WindowSizeMsg: + return m, renderWithGlamour(m, m.currentDocument.Body) + + case statusMessageTimeoutMsg: + m.state = pagerStateBrowse + } + + m.viewport, cmd = m.viewport.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m pagerModel) View() string { + var b strings.Builder + fmt.Fprint(&b, m.viewport.View()+"\n") + + // Footer + m.statusBarView(&b) + + if m.showHelp { + fmt.Fprint(&b, "\n"+m.helpView()) + } + + return b.String() +} + +func (m pagerModel) statusBarView(b *strings.Builder) { + const ( + minPercent float64 = 0.0 + maxPercent float64 = 1.0 + percentToStringMagnitude float64 = 100.0 + ) + + showStatusMessage := m.state == pagerStateStatusMessage + + // Logo + logo := glowLogoView() + + // Scroll percent + percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent())) + scrollPercent := fmt.Sprintf(" %3.f%% ", percent*percentToStringMagnitude) + if showStatusMessage { + scrollPercent = statusBarMessageScrollPosStyle(scrollPercent) + } else { + scrollPercent = statusBarScrollPosStyle(scrollPercent) + } + + // "Help" note + var helpNote string + if showStatusMessage { + helpNote = statusBarMessageHelpStyle(" ? Help ") + } else { + helpNote = statusBarHelpStyle(" ? Help ") + } + + // Note + var note string + if showStatusMessage { + note = m.statusMessage + } else { + note = m.currentDocument.Note + } + note = truncate.StringWithTail(" "+note+" ", uint(max(0, //nolint:gosec + m.common.width- + ansi.PrintableRuneWidth(logo)- + ansi.PrintableRuneWidth(scrollPercent)- + ansi.PrintableRuneWidth(helpNote), + )), ellipsis) + if showStatusMessage { + note = statusBarMessageStyle(note) + } else { + note = statusBarNoteStyle(note) + } + + // Empty space + padding := max(0, + m.common.width- + ansi.PrintableRuneWidth(logo)- + ansi.PrintableRuneWidth(note)- + ansi.PrintableRuneWidth(scrollPercent)- + ansi.PrintableRuneWidth(helpNote), + ) + emptySpace := strings.Repeat(" ", padding) + if showStatusMessage { + emptySpace = statusBarMessageStyle(emptySpace) + } else { + emptySpace = statusBarNoteStyle(emptySpace) + } + + fmt.Fprintf(b, "%s%s%s%s%s", + logo, + note, + emptySpace, + scrollPercent, + helpNote, + ) +} + +func (m pagerModel) helpView() (s string) { + col1 := []string{ + "g/home go to top", + "G/end go to bottom", + "c copy contents", + "e edit this document", + "r reload this document", + "esc back to files", + "q quit", + } + + s += "\n" + s += "k/↑ up " + col1[0] + "\n" + s += "j/↓ down " + col1[1] + "\n" + s += "b/pgup page up " + col1[2] + "\n" + s += "f/pgdn page down " + col1[3] + "\n" + s += "u ½ page up " + col1[4] + "\n" + s += "d ½ page down " + + if len(col1) > 5 { + s += col1[5] + } + + s = indent(s, 2) + + // Fill up empty cells with spaces for background coloring + if m.common.width > 0 { + lines := strings.Split(s, "\n") + for i := 0; i < len(lines); i++ { + l := runewidth.StringWidth(lines[i]) + n := max(m.common.width-l, 0) + lines[i] += strings.Repeat(" ", n) + } + + s = strings.Join(lines, "\n") + } + + return helpViewStyle(s) +} + +// COMMANDS + +func renderWithGlamour(m pagerModel, md string) tea.Cmd { + return func() tea.Msg { + s, err := glamourRender(m, md) + if err != nil { + log.Error("error rendering with Glamour", "error", err) + return errMsg{err} + } + return contentRenderedMsg(s) + } +} + +// This is where the magic happens. +func glamourRender(m pagerModel, markdown string) (string, error) { + trunc := lipgloss.NewStyle().MaxWidth(m.viewport.Width - lineNumberWidth).Render + + if !config.GlamourEnabled { + return markdown, nil + } + + isCode := !utils.IsMarkdownFile(m.currentDocument.Note) + width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width)) //nolint:gosec + if isCode { + width = 0 + } + + options := []glamour.TermRendererOption{ + utils.GlamourStyle(m.common.cfg.GlamourStyle, isCode), + glamour.WithWordWrap(width), + } + + if m.common.cfg.PreserveNewLines { + options = append(options, glamour.WithPreservedNewLines()) + } + r, err := glamour.NewTermRenderer(options...) + if err != nil { + return "", fmt.Errorf("error creating glamour renderer: %w", err) + } + + if isCode { + markdown = utils.WrapCodeBlock(markdown, filepath.Ext(m.currentDocument.Note)) + } + + out, err := r.Render(markdown) + if err != nil { + return "", fmt.Errorf("error rendering markdown: %w", err) + } + + if isCode { + out = strings.TrimSpace(out) + } + + // trim lines + lines := strings.Split(out, "\n") + + var content strings.Builder + for i, s := range lines { + if isCode || m.common.cfg.ShowLineNumbers { + content.WriteString(lineNumberStyle(fmt.Sprintf("%"+fmt.Sprint(lineNumberWidth)+"d", i+1))) + content.WriteString(trunc(s)) + } else { + content.WriteString(s) + } + + // don't add an artificial newline after the last split + if i+1 < len(lines) { + content.WriteRune('\n') + } + } + + return content.String(), nil +} + +func (m *pagerModel) initWatcher() { + var err error + m.watcher, err = fsnotify.NewWatcher() + if err != nil { + log.Error("error creating fsnotify watcher", "error", err) + } +} + +func (m *pagerModel) watchFile() tea.Msg { + dir := m.localDir() + + if err := m.watcher.Add(dir); err != nil { + log.Error("error adding dir to fsnotify watcher", "error", err) + return nil + } + + log.Info("fsnotify watching dir", "dir", dir) + + for { + select { + case event, ok := <-m.watcher.Events: + if !ok || event.Name != m.currentDocument.localPath { + continue + } + + if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) { + continue + } + + log.Debug("fsnotify event", "file", event.Name, "event", event.Op) + return reloadMsg{} + case err, ok := <-m.watcher.Errors: + if !ok { + continue + } + log.Debug("fsnotify error", "dir", dir, "error", err) + } + } +} + +func (m *pagerModel) unwatchFile() { + dir := m.localDir() + + err := m.watcher.Remove(dir) + if err == nil { + log.Debug("fsnotify dir unwatched", "dir", dir) + } else { + log.Error("fsnotify fail to unwatch dir", "dir", dir, "error", err) + } +} + +func (m *pagerModel) localDir() string { + return filepath.Dir(m.currentDocument.localPath) +} diff --git a/ui/sort.go b/ui/sort.go new file mode 100644 index 0000000..4f389fb --- /dev/null +++ b/ui/sort.go @@ -0,0 +1,12 @@ +package ui + +import ( + "cmp" + "slices" +) + +func sortMarkdowns(mds []*markdown) { + slices.SortStableFunc(mds, func(a, b *markdown) int { + return cmp.Compare(a.Note, b.Note) + }) +} diff --git a/ui/stash.go b/ui/stash.go new file mode 100644 index 0000000..c3739b2 --- /dev/null +++ b/ui/stash.go @@ -0,0 +1,891 @@ +package ui + +import ( + "errors" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/paginator" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/log" + "github.com/muesli/reflow/ansi" + "github.com/muesli/reflow/truncate" + "github.com/sahilm/fuzzy" +) + +const ( + stashIndent = 1 + stashViewItemHeight = 3 // height of stash entry, including gap + stashViewTopPadding = 5 // logo, status bar, gaps + stashViewBottomPadding = 3 // pagination and gaps, but not help + stashViewHorizontalPadding = 6 +) + +var stashingStatusMessage = statusMessage{normalStatusMessage, "Stashing..."} + +var ( + dividerDot = darkGrayFg.SetString(" • ") + dividerBar = darkGrayFg.SetString(" │ ") + + logoStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ECFD65")). + Background(fuchsia). + Bold(true) + + stashSpinnerStyle = lipgloss.NewStyle(). + Foreground(gray) + stashInputPromptStyle = lipgloss.NewStyle(). + Foreground(yellowGreen). + MarginRight(1) + stashInputCursorStyle = lipgloss.NewStyle(). + Foreground(fuchsia). + MarginRight(1) +) + +// MSG + +type ( + filteredMarkdownMsg []*markdown + fetchedMarkdownMsg *markdown +) + +// MODEL + +// stashViewState is the high-level state of the file listing. +type stashViewState int + +const ( + stashStateReady stashViewState = iota + stashStateLoadingDocument + stashStateShowingError +) + +// The types of documents we are currently showing to the user. +type sectionKey int + +const ( + documentsSection = iota + filterSection +) + +// section contains definitions and state information for displaying a tab and +// its contents in the file listing view. +type section struct { + key sectionKey + paginator paginator.Model + cursor int +} + +// map sections to their associated types. +var sections = map[sectionKey]section{} + +// filterState is the current filtering state in the file listing. +type filterState int + +const ( + unfiltered filterState = iota // no filter set + filtering // user is actively setting a filter + filterApplied // a filter is applied and user is not editing filter +) + +// statusMessageType adds some context to the status message being sent. +type statusMessageType int + +// Types of status messages. +const ( + normalStatusMessage statusMessageType = iota + subtleStatusMessage + errorStatusMessage +) + +// statusMessage is an ephemeral note displayed in the UI. +type statusMessage struct { + status statusMessageType + message string +} + +func initSections() { + sections = map[sectionKey]section{ + documentsSection: { + key: documentsSection, + paginator: newStashPaginator(), + }, + filterSection: { + key: filterSection, + paginator: newStashPaginator(), + }, + } +} + +// String returns a styled version of the status message appropriate for the +// given context. +func (s statusMessage) String() string { + switch s.status { //nolint:exhaustive + case subtleStatusMessage: + return dimGreenFg(s.message) + case errorStatusMessage: + return redFg(s.message) + default: + return greenFg(s.message) + } +} + +type stashModel struct { + common *commonModel + err error + spinner spinner.Model + filterInput textinput.Model + viewState stashViewState + filterState filterState + showFullHelp bool + showStatusMessage bool + statusMessage statusMessage + statusMessageTimer *time.Timer + + // Available document sections we can cycle through. We use a slice, rather + // than a map, because order is important. + sections []section + + // Index of the section we're currently looking at + sectionIndex int + + // Tracks if docs were loaded + loaded bool + + // The master set of markdown documents we're working with. + markdowns []*markdown + + // Markdown documents we're currently displaying. Filtering, toggles and so + // on will alter this slice so we can show what is relevant. For that + // reason, this field should be considered ephemeral. + filteredMarkdowns []*markdown + + // Page we're fetching stash items from on the server, which is different + // from the local pagination. Generally, the server will return more items + // than we can display at a time so we can paginate locally without having + // to fetch every time. + serverPage int64 +} + +func (m stashModel) loadingDone() bool { + return m.loaded +} + +func (m stashModel) currentSection() *section { + return &m.sections[m.sectionIndex] +} + +func (m stashModel) paginator() *paginator.Model { + return &m.currentSection().paginator +} + +func (m *stashModel) setPaginator(p paginator.Model) { + m.currentSection().paginator = p +} + +func (m stashModel) cursor() int { + return m.currentSection().cursor +} + +func (m *stashModel) setCursor(i int) { + m.currentSection().cursor = i +} + +// Whether or not the spinner should be spinning. +func (m stashModel) shouldSpin() bool { + loading := !m.loadingDone() + openingDocument := m.viewState == stashStateLoadingDocument + return loading || openingDocument +} + +func (m *stashModel) setSize(width, height int) { + m.common.width = width + m.common.height = height + + m.filterInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth( + m.filterInput.Prompt, + ) + + m.updatePagination() +} + +func (m *stashModel) resetFiltering() { + m.filterState = unfiltered + m.filterInput.Reset() + m.filteredMarkdowns = nil + + sortMarkdowns(m.markdowns) + + // If the filtered section is present (it's always at the end) slice it out + // of the sections slice to remove it from the UI. + if m.sections[len(m.sections)-1].key == filterSection { + m.sections = m.sections[:len(m.sections)-1] + } + + // If the current section is out of bounds (it would be if we cut down the + // slice above) then return to the first section. + if m.sectionIndex > len(m.sections)-1 { + m.sectionIndex = 0 + } + + // Update pagination after we've switched sections. + m.updatePagination() +} + +// Is a filter currently being applied? +func (m stashModel) filterApplied() bool { + return m.filterState != unfiltered +} + +// Should we be updating the filter? +func (m stashModel) shouldUpdateFilter() bool { + // If we're in the middle of setting a note don't update the filter so that + // the focus won't jump around. + return m.filterApplied() +} + +// Update pagination according to the amount of markdowns for the current +// state. +func (m *stashModel) updatePagination() { + _, helpHeight := m.helpView() + + availableHeight := m.common.height - + stashViewTopPadding - + helpHeight - + stashViewBottomPadding + + m.paginator().PerPage = max(1, availableHeight/stashViewItemHeight) + + if pages := len(m.getVisibleMarkdowns()); pages < 1 { + m.paginator().SetTotalPages(1) + } else { + m.paginator().SetTotalPages(pages) + } + + // Make sure the page stays in bounds + if m.paginator().Page >= m.paginator().TotalPages-1 { + m.paginator().Page = max(0, m.paginator().TotalPages-1) + } +} + +// MarkdownIndex returns the index of the currently selected markdown item. +func (m stashModel) markdownIndex() int { + return m.paginator().Page*m.paginator().PerPage + m.cursor() +} + +// Return the current selected markdown in the stash. +func (m stashModel) selectedMarkdown() *markdown { + i := m.markdownIndex() + + mds := m.getVisibleMarkdowns() + if i < 0 || len(mds) == 0 || len(mds) <= i { + return nil + } + + return mds[i] +} + +// Adds markdown documents to the model. +func (m *stashModel) addMarkdowns(mds ...*markdown) { + if len(mds) == 0 { + return + } + + m.markdowns = append(m.markdowns, mds...) + if !m.filterApplied() { + sortMarkdowns(m.markdowns) + } + + m.updatePagination() +} + +// Returns the markdowns that should be currently shown. +func (m stashModel) getVisibleMarkdowns() []*markdown { + if m.filterState == filtering || m.currentSection().key == filterSection { + return m.filteredMarkdowns + } + + return m.markdowns +} + +// Command for opening a markdown document in the pager. Note that this also +// alters the model. +func (m *stashModel) openMarkdown(md *markdown) tea.Cmd { + m.viewState = stashStateLoadingDocument + cmd := loadLocalMarkdown(md) + return tea.Batch(cmd, m.spinner.Tick) +} + +func (m *stashModel) hideStatusMessage() { + m.showStatusMessage = false + m.statusMessage = statusMessage{} + if m.statusMessageTimer != nil { + m.statusMessageTimer.Stop() + } +} + +func (m *stashModel) moveCursorUp() { + m.setCursor(m.cursor() - 1) + if m.cursor() < 0 && m.paginator().Page == 0 { + // Stop + m.setCursor(0) + return + } + + if m.cursor() >= 0 { + return + } + // Go to previous page + m.paginator().PrevPage() + + m.setCursor(m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns())) - 1) +} + +func (m *stashModel) moveCursorDown() { + itemsOnPage := m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns())) + + m.setCursor(m.cursor() + 1) + if m.cursor() < itemsOnPage { + return + } + + if !m.paginator().OnLastPage() { + m.paginator().NextPage() + m.setCursor(0) + return + } + + // During filtering the cursor position can exceed the number of + // itemsOnPage. It's more intuitive to start the cursor at the + // topmost position when moving it down in this scenario. + if m.cursor() > itemsOnPage { + m.setCursor(0) + return + } + m.setCursor(itemsOnPage - 1) +} + +// INIT + +func newStashModel(common *commonModel) stashModel { + sp := spinner.New() + sp.Spinner = spinner.Line + sp.Style = stashSpinnerStyle + + si := textinput.New() + si.Prompt = "Find:" + si.PromptStyle = stashInputPromptStyle + si.Cursor.Style = stashInputCursorStyle + si.Focus() + + s := []section{ + sections[documentsSection], + } + + m := stashModel{ + common: common, + spinner: sp, + filterInput: si, + serverPage: 1, + sections: s, + } + + return m +} + +func newStashPaginator() paginator.Model { + p := paginator.New() + p.Type = paginator.Dots + p.ActiveDot = brightGrayFg("•") + p.InactiveDot = darkGrayFg.Render("•") + return p +} + +// UPDATE + +func (m stashModel) update(msg tea.Msg) (stashModel, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case errMsg: + m.err = msg + + case localFileSearchFinished: + // We're finished searching for local files + m.loaded = true + + case filteredMarkdownMsg: + m.filteredMarkdowns = msg + m.setCursor(0) + return m, nil + + case spinner.TickMsg: + if m.shouldSpin() { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) + } + + case statusMessageTimeoutMsg: + if applicationContext(msg) == stashContext { + m.hideStatusMessage() + } + } + + if m.filterState == filtering { + cmds = append(cmds, m.handleFiltering(msg)) + return m, tea.Batch(cmds...) + } + + // Updates per the current state + switch m.viewState { //nolint:exhaustive + case stashStateReady: + cmds = append(cmds, m.handleDocumentBrowsing(msg)) + case stashStateShowingError: + // Any key exists the error view + if _, ok := msg.(tea.KeyMsg); ok { + m.viewState = stashStateReady + } + } + + return m, tea.Batch(cmds...) +} + +// Updates for when a user is browsing the markdown listing. +func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + + numDocs := len(m.getVisibleMarkdowns()) + + switch msg := msg.(type) { + // Handle keys + case tea.KeyMsg: + switch msg.String() { + case "k", "ctrl+k", "up": + m.moveCursorUp() + + case "j", "ctrl+j", "down": + m.moveCursorDown() + + // Go to the very start + case "home", "g": + m.paginator().Page = 0 + m.setCursor(0) + + // Go to the very end + case "end", "G": + m.paginator().Page = m.paginator().TotalPages - 1 + m.setCursor(m.paginator().ItemsOnPage(numDocs) - 1) + + // Clear filter (if applicable) + case keyEsc: + if m.filterApplied() { + m.resetFiltering() + } + + // Next section + case "tab", "L": + if len(m.sections) == 0 || m.filterState == filtering { + break + } + m.sectionIndex++ + if m.sectionIndex >= len(m.sections) { + m.sectionIndex = 0 + } + m.updatePagination() + + // Previous section + case "shift+tab", "H": + if len(m.sections) == 0 || m.filterState == filtering { + break + } + m.sectionIndex-- + if m.sectionIndex < 0 { + m.sectionIndex = len(m.sections) - 1 + } + m.updatePagination() + + case "F": + m.loaded = false + return findLocalFiles(*m.common) + + // Edit document in EDITOR + case "e": + md := m.selectedMarkdown() + + // In case no file is available + if md == nil { + return nil + } + + return openEditor(md.localPath, 0) + + // Open document + case keyEnter: + m.hideStatusMessage() + + if numDocs == 0 { + break + } + + // Load the document from the server. We'll handle the message + // that comes back in the main update function. + md := m.selectedMarkdown() + cmds = append(cmds, m.openMarkdown(md)) + + // Filter your notes + case "/": + m.hideStatusMessage() + + // Build values we'll filter against + for _, md := range m.markdowns { + md.buildFilterValue() + } + + m.filteredMarkdowns = m.markdowns + + m.paginator().Page = 0 + m.setCursor(0) + m.filterState = filtering + m.filterInput.CursorEnd() + m.filterInput.Focus() + return textinput.Blink + + // Toggle full help + case "?": + m.showFullHelp = !m.showFullHelp + m.updatePagination() + + // Show errors + case "!": + if m.err != nil && m.viewState == stashStateReady { + m.viewState = stashStateShowingError + return nil + } + } + } + + // Update paginator. Pagination key handling is done here, but it could + // also be moved up to this level, in which case we'd use model methods + // like model.PageUp(). + newPaginatorModel, cmd := m.paginator().Update(msg) + m.setPaginator(newPaginatorModel) + cmds = append(cmds, cmd) + + // Extra paginator keystrokes + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "b", "u": + m.paginator().PrevPage() + case "f", "d": + m.paginator().NextPage() + } + } + + // Keep the index in bounds when paginating + itemsOnPage := m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns())) + if m.cursor() > itemsOnPage-1 { + m.setCursor(max(0, itemsOnPage-1)) + } + + return tea.Batch(cmds...) +} + +// Updates for when a user is in the filter editing interface. +func (m *stashModel) handleFiltering(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + + // Handle keys + if msg, ok := msg.(tea.KeyMsg); ok { //nolint:nestif + switch msg.String() { + case keyEsc: + // Cancel filtering + m.resetFiltering() + case keyEnter, "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down": + m.hideStatusMessage() + + if len(m.markdowns) == 0 { + break + } + + h := m.getVisibleMarkdowns() + + // If we've filtered down to nothing, clear the filter + if len(h) == 0 { + m.viewState = stashStateReady + m.resetFiltering() + break + } + + // When there's only one filtered markdown left we can just + // "open" it directly + if len(h) == 1 { + m.viewState = stashStateReady + m.resetFiltering() + cmds = append(cmds, m.openMarkdown(h[0])) + break + } + + // Add new section if it's not present + if m.sections[len(m.sections)-1].key != filterSection { + m.sections = append(m.sections, sections[filterSection]) + } + m.sectionIndex = len(m.sections) - 1 + + m.filterInput.Blur() + + m.filterState = filterApplied + if m.filterInput.Value() == "" { + m.resetFiltering() + } + } + } + + // Update the filter text input component + newFilterInputModel, inputCmd := m.filterInput.Update(msg) + currentFilterVal := m.filterInput.Value() + newFilterVal := newFilterInputModel.Value() + m.filterInput = newFilterInputModel + cmds = append(cmds, inputCmd) + + // If the filtering input has changed, request updated filtering + if newFilterVal != currentFilterVal { + cmds = append(cmds, filterMarkdowns(*m)) + } + + // Update pagination + m.updatePagination() + + return tea.Batch(cmds...) +} + +// VIEW + +func (m stashModel) view() string { + var s string + switch m.viewState { + case stashStateShowingError: + return errorView(m.err, false) + case stashStateLoadingDocument: + s += " " + m.spinner.View() + " Loading document..." + case stashStateReady: + loadingIndicator := " " + if m.shouldSpin() { + loadingIndicator = m.spinner.View() + } + + // Only draw the normal header if we're not using the header area for + // something else (like a note or delete prompt). + header := m.headerView() + + // Rules for the logo, filter and status message. + logoOrFilter := " " + if m.showStatusMessage && m.filterState == filtering { + logoOrFilter += m.statusMessage.String() + } else if m.filterState == filtering { + logoOrFilter += m.filterInput.View() + } else { + logoOrFilter += glowLogoView() + if m.showStatusMessage { + logoOrFilter += " " + m.statusMessage.String() + } + } + logoOrFilter = truncate.StringWithTail(logoOrFilter, uint(m.common.width-1), ellipsis) //nolint:gosec + + help, helpHeight := m.helpView() + + populatedView := m.populatedView() + populatedViewHeight := strings.Count(populatedView, "\n") + 2 + + // We need to fill any empty height with newlines so the footer reaches + // the bottom. + availHeight := m.common.height - + stashViewTopPadding - + populatedViewHeight - + helpHeight - + stashViewBottomPadding + blankLines := strings.Repeat("\n", max(0, availHeight)) + + var pagination string + if m.paginator().TotalPages > 1 { + pagination = m.paginator().View() + + // If the dot pagination is wider than the width of the window + // use the arabic paginator. + if ansi.PrintableRuneWidth(pagination) > m.common.width-stashViewHorizontalPadding { + // Copy the paginator since m.paginator() returns a pointer to + // the active paginator and we don't want to mutate it. In + // normal cases, where the paginator is not a pointer, we could + // safely change the model parameters for rendering here as the + // current model is discarded after reuturning from a View(). + // One could argue, in fact, that using pointers in + // a functional framework is an antipattern and our use of + // pointers in our model should be refactored away. + p := *(m.paginator()) + p.Type = paginator.Arabic + pagination = paginationStyle.Render(p.View()) + } + } + + s += fmt.Sprintf( + "%s%s\n\n %s\n\n%s\n\n%s %s\n\n%s", + loadingIndicator, + logoOrFilter, + header, + populatedView, + blankLines, + pagination, + help, + ) + } + return "\n" + indent(s, stashIndent) +} + +func glowLogoView() string { + return logoStyle.Render(" Glow ") +} + +func (m stashModel) headerView() string { + localCount := len(m.markdowns) + + var sections []string //nolint:prealloc + + // Filter results + if m.filterState == filtering { + if localCount == 0 { + return grayFg("Nothing found.") + } + if localCount > 0 { + sections = append(sections, fmt.Sprintf("%d local", localCount)) + } + + for i := range sections { + sections[i] = grayFg(sections[i]) + } + + return strings.Join(sections, dividerDot.String()) + } + + // Tabs + for i, v := range m.sections { + var s string + + switch v.key { + case documentsSection: + s = fmt.Sprintf("%d documents", localCount) + + case filterSection: + s = fmt.Sprintf("%d “%s”", len(m.filteredMarkdowns), m.filterInput.Value()) + } + + if m.sectionIndex == i && len(m.sections) > 1 { + s = selectedTabStyle.Render(s) + } else { + s = tabStyle.Render(s) + } + sections = append(sections, s) + } + + return strings.Join(sections, dividerBar.String()) +} + +func (m stashModel) populatedView() string { + mds := m.getVisibleMarkdowns() + + var b strings.Builder + + // Empty states + if len(mds) == 0 { + f := func(s string) { + b.WriteString(" " + grayFg(s)) + } + + switch m.sections[m.sectionIndex].key { + case documentsSection: + if m.loadingDone() { + f("No files found.") + } else { + f("Looking for local files...") + } + case filterSection: + return "" + } + } + + if len(mds) > 0 { + start, end := m.paginator().GetSliceBounds(len(mds)) + docs := mds[start:end] + + for i, md := range docs { + stashItemView(&b, m, i, md) + if i != len(docs)-1 { + fmt.Fprintf(&b, "\n\n") + } + } + } + + // If there aren't enough items to fill up this page (always the last page) + // then we need to add some newlines to fill up the space where stash items + // would have been. + itemsOnPage := m.paginator().ItemsOnPage(len(mds)) + if itemsOnPage < m.paginator().PerPage { + n := (m.paginator().PerPage - itemsOnPage) * stashViewItemHeight + if len(mds) == 0 { + n -= stashViewItemHeight - 1 + } + for i := 0; i < n; i++ { + fmt.Fprint(&b, "\n") + } + } + + return b.String() +} + +// COMMANDS + +func loadLocalMarkdown(md *markdown) tea.Cmd { + return func() tea.Msg { + if md.localPath == "" { + return errMsg{errors.New("could not load file: missing path")} + } + + data, err := os.ReadFile(md.localPath) + if err != nil { + log.Debug("error reading local file", "error", err) + return errMsg{err} + } + md.Body = string(data) + return fetchedMarkdownMsg(md) + } +} + +func filterMarkdowns(m stashModel) tea.Cmd { + return func() tea.Msg { + if m.filterInput.Value() == "" || !m.filterApplied() { + return filteredMarkdownMsg(m.markdowns) // return everything + } + + targets := []string{} + mds := m.markdowns + + for _, t := range mds { + targets = append(targets, t.filterValue) + } + + ranks := fuzzy.Find(m.filterInput.Value(), targets) + sort.Stable(ranks) + + filtered := []*markdown{} + for _, r := range ranks { + filtered = append(filtered, mds[r.Index]) + } + + return filteredMarkdownMsg(filtered) + } +} diff --git a/ui/stashhelp.go b/ui/stashhelp.go new file mode 100644 index 0000000..6c0b474 --- /dev/null +++ b/ui/stashhelp.go @@ -0,0 +1,297 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/muesli/reflow/ansi" +) + +// helpEntry is a entry in a help menu containing values for a keystroke and +// it's associated action. +type helpEntry struct{ key, val string } + +// helpColumn is a group of helpEntries which will be rendered into a column. +type helpColumn []helpEntry + +// newHelpColumn creates a help column from pairs of string arguments +// representing keys and values. If the arguments are not even (and therein +// not every key has a matching value) the function will panic. +func newHelpColumn(pairs ...string) (h helpColumn) { + if len(pairs)%2 != 0 { + panic("help text group must have an even number of items") + } + + for i := 0; i < len(pairs); i = i + 2 { + h = append(h, helpEntry{key: pairs[i], val: pairs[i+1]}) + } + + return +} + +// render returns styled and formatted rows from keys and values. +func (h helpColumn) render(height int) (rows []string) { + keyWidth, valWidth := h.maxWidths() + + for i := 0; i < height; i++ { + var ( + b = strings.Builder{} + k, v string + ) + if i < len(h) { + k = h[i].key + v = h[i].val + + switch k { + case "s": + k = greenFg(k) + v = semiDimGreenFg(v) + default: + k = grayFg(k) + v = midGrayFg(v) + } + } + b.WriteString(k) + b.WriteString(strings.Repeat(" ", keyWidth-ansi.PrintableRuneWidth(k))) // pad keys + b.WriteString(" ") // gap + b.WriteString(v) + b.WriteString(strings.Repeat(" ", valWidth-ansi.PrintableRuneWidth(v))) // pad vals + rows = append(rows, b.String()) + } + + return +} + +// maxWidths returns the widest key and values in the column, respectively. +func (h helpColumn) maxWidths() (maxKey int, maxVal int) { + for _, v := range h { + kw := ansi.PrintableRuneWidth(v.key) + vw := ansi.PrintableRuneWidth(v.val) + if kw > maxKey { + maxKey = kw + } + if vw > maxVal { + maxVal = vw + } + } + + return +} + +// helpView returns either the mini or full help view depending on the state of +// the model, as well as the total height of the help view. +func (m stashModel) helpView() (string, int) { + numDocs := len(m.getVisibleMarkdowns()) + + // Help for when we're filtering + if m.filterState == filtering { + var h []string + + switch numDocs { + case 0: + h = []string{"enter/esc", "cancel"} + case 1: + h = []string{"enter", "open", "esc", "cancel"} + default: + h = []string{"enter", "confirm", "esc", "cancel", "ctrl+j/ctrl+k ↑/↓", "choose"} + } + + return m.renderHelp(h) + } + + var ( + navHelp []string + filterHelp []string + selectionHelp []string + editHelp []string + sectionHelp []string + appHelp []string + ) + + if numDocs > 0 && m.showFullHelp { + navHelp = []string{"enter", "open", "j/k ↑/↓", "choose"} + } + + if len(m.sections) > 1 { + if m.showFullHelp { + navHelp = append(navHelp, "tab/shift+tab", "section") + } else { + navHelp = append(navHelp, "tab", "section") + } + } + + if m.paginator().TotalPages > 1 { + navHelp = append(navHelp, "h/l ←/→", "page") + } + + // If we're browsing a filtered set + if m.filterApplied() { + filterHelp = []string{"/", "edit search", "esc", "clear filter"} + } else { + filterHelp = []string{"/", "find"} + } + + // If there are errors + if m.err != nil { + appHelp = append(appHelp, "!", "errors") + } + + appHelp = append(appHelp, "r", "refresh") + + if numDocs > 0 { + appHelp = append(appHelp, "e", "edit") + } + + appHelp = append(appHelp, "q", "quit") + + // Detailed help + if m.showFullHelp { + if m.filterState != filtering { + appHelp = append(appHelp, "?", "close help") + } + return m.renderHelp(navHelp, filterHelp, append(selectionHelp, editHelp...), sectionHelp, appHelp) + } + + // Mini help + if m.filterState != filtering { + appHelp = append(appHelp, "?", "more") + } + return m.renderHelp(navHelp, filterHelp, selectionHelp, editHelp, sectionHelp, appHelp) +} + +const minHelpViewHeight = 5 + +// renderHelp returns the rendered help view and associated line height for +// the given groups of help items. +func (m stashModel) renderHelp(groups ...[]string) (string, int) { + if m.showFullHelp { + str := m.fullHelpView(groups...) + numLines := strings.Count(str, "\n") + 1 + return str, max(numLines, minHelpViewHeight) + } + return m.miniHelpView(concatStringSlices(groups...)...), 1 +} + +// Builds the help view from various sections pieces, truncating it if the view +// would otherwise wrap to two lines. Help view entries should come in as pairs, +// with the first being the key and the second being the help text. +func (m stashModel) miniHelpView(entries ...string) string { + if len(entries) == 0 { + return "" + } + + var ( + truncationChar = subtleStyle.Render("…") + truncationWidth = ansi.PrintableRuneWidth(truncationChar) + ) + + var ( + next string + leftGutter = " " + maxWidth = m.common.width - + stashViewHorizontalPadding - + truncationWidth - + ansi.PrintableRuneWidth(leftGutter) + s = leftGutter + ) + + for i := 0; i < len(entries); i = i + 2 { + k := entries[i] + v := entries[i+1] + + k = grayFg(k) + v = midGrayFg(v) + + next = fmt.Sprintf("%s %s", k, v) + + if i < len(entries)-2 { + next += dividerDot.String() + } + + // Only this (and the following) help text items if we have the + // horizontal space + if ansi.PrintableRuneWidth(s)+ansi.PrintableRuneWidth(next) >= maxWidth { + s += truncationChar + break + } + + s += next + } + return s +} + +func (m stashModel) fullHelpView(groups ...[]string) string { + var tallestCol int + columns := make([]helpColumn, 0, len(groups)) + renderedCols := make([][]string, 0, len(groups)) // final rows grouped by column + + // Get key/value pairs + for _, g := range groups { + if len(g) == 0 { + continue // ignore empty columns + } + + columns = append(columns, newHelpColumn(g...)) + } + + // Find the tallest column + for _, c := range columns { + if len(c) > tallestCol { + tallestCol = len(c) + } + } + + // Build columns + for _, c := range columns { + renderedCols = append(renderedCols, c.render(tallestCol)) + } + + // Merge columns + return mergeColumns(renderedCols...) +} + +// Merge columns together to build the help view. +func mergeColumns(cols ...[]string) string { + const minimumHeight = 3 + + // Find the tallest column + var tallestCol int + for _, v := range cols { + n := len(v) + if n > tallestCol { + tallestCol = n + } + } + + // Make sure the tallest column meets the minimum height + if tallestCol < minimumHeight { + tallestCol = minimumHeight + } + + b := strings.Builder{} + for i := 0; i < tallestCol; i++ { + for j, col := range cols { + if i >= len(col) { + continue // skip if we're past the length of this column + } + if j == 0 { + b.WriteString(" ") // gutter + } else if j > 0 { + b.WriteString(" ") // gap + } + b.WriteString(col[i]) + } + if i < tallestCol-1 { + b.WriteRune('\n') + } + } + + return b.String() +} + +func concatStringSlices(s ...[]string) (agg []string) { + for _, v := range s { + agg = append(agg, v...) + } + return +} diff --git a/ui/stashitem.go b/ui/stashitem.go new file mode 100644 index 0000000..0000928 --- /dev/null +++ b/ui/stashitem.go @@ -0,0 +1,120 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/log" + "github.com/muesli/reflow/truncate" + "github.com/sahilm/fuzzy" +) + +const ( + verticalLine = "│" + fileListingStashIcon = "• " +) + +func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) { + var ( + truncateTo = uint(m.common.width - stashViewHorizontalPadding*2) //nolint:gosec + gutter string + title = truncate.StringWithTail(md.Note, truncateTo, ellipsis) + date = md.relativeTime() + editedBy = "" + hasEditedBy = false + icon = "" + separator = "" + ) + + isSelected := index == m.cursor() + isFiltering := m.filterState == filtering + singleFilteredItem := isFiltering && len(m.getVisibleMarkdowns()) == 1 + + // If there are multiple items being filtered don't highlight a selected + // item in the results. If we've filtered down to one item, however, + // highlight that first item since pressing return will open it. + if isSelected && !isFiltering || singleFilteredItem { //nolint:nestif + // Selected item + if m.statusMessage == stashingStatusMessage { + gutter = greenFg(verticalLine) + icon = dimGreenFg(icon) + title = greenFg(title) + date = semiDimGreenFg(date) + editedBy = semiDimGreenFg(editedBy) + separator = semiDimGreenFg(separator) + } else { + gutter = dullFuchsiaFg(verticalLine) + if m.currentSection().key == filterSection && + m.filterState == filterApplied || singleFilteredItem { + s := lipgloss.NewStyle().Foreground(fuchsia) + title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true)) + } else { + title = fuchsiaFg(title) + icon = fuchsiaFg(icon) + } + date = dimFuchsiaFg(date) + editedBy = dimDullFuchsiaFg(editedBy) + separator = dullFuchsiaFg(separator) + } + } else { + gutter = " " + if m.statusMessage == stashingStatusMessage { + icon = dimGreenFg(icon) + title = greenFg(title) + date = semiDimGreenFg(date) + editedBy = semiDimGreenFg(editedBy) + separator = semiDimGreenFg(separator) + } else if isFiltering && m.filterInput.Value() == "" { + icon = dimGreenFg(icon) + title = dimNormalFg(title) + date = dimBrightGrayFg(date) + editedBy = dimBrightGrayFg(editedBy) + separator = dimBrightGrayFg(separator) + } else { + icon = greenFg(icon) + + s := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}) + title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true)) + date = grayFg(date) + editedBy = midGrayFg(editedBy) + separator = brightGrayFg(separator) + } + } + + fmt.Fprintf(b, "%s %s%s%s%s\n", gutter, icon, separator, separator, title) + fmt.Fprintf(b, "%s %s", gutter, date) + if hasEditedBy { + fmt.Fprintf(b, " %s", editedBy) + } +} + +func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle lipgloss.Style) string { + b := strings.Builder{} + + normalizedHay, err := normalize(haystack) + if err != nil { + log.Error("error normalizing", "haystack", haystack, "error", err) + } + + matches := fuzzy.Find(needles, []string{normalizedHay}) + if len(matches) == 0 { + return defaultStyle.Render(haystack) + } + + m := matches[0] // only one match exists + for i, rune := range []rune(haystack) { + styled := false + for _, mi := range m.MatchedIndexes { + if i == mi { + b.WriteString(matchedStyle.Render(string(rune))) + styled = true + } + } + if !styled { + b.WriteString(defaultStyle.Render(string(rune))) + } + } + + return b.String() +} diff --git a/ui/styles.go b/ui/styles.go new file mode 100644 index 0000000..fd25882 --- /dev/null +++ b/ui/styles.go @@ -0,0 +1,46 @@ +package ui + +import "github.com/charmbracelet/lipgloss" + +// Colors. +var ( + normalDim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"} + gray = lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"} + midGray = lipgloss.AdaptiveColor{Light: "#B2B2B2", Dark: "#4A4A4A"} + darkGray = lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"} + brightGray = lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"} + dimBrightGray = lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"} + cream = lipgloss.AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"} + yellowGreen = lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"} + fuchsia = lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"} + dimFuchsia = lipgloss.AdaptiveColor{Light: "#F1A8FF", Dark: "#99519E"} + dullFuchsia = lipgloss.AdaptiveColor{Dark: "#AD58B4", Light: "#F793FF"} + dimDullFuchsia = lipgloss.AdaptiveColor{Light: "#F6C9FF", Dark: "#7B4380"} + green = lipgloss.Color("#04B575") + red = lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"} + semiDimGreen = lipgloss.AdaptiveColor{Light: "#35D79C", Dark: "#036B46"} + dimGreen = lipgloss.AdaptiveColor{Light: "#72D2B0", Dark: "#0B5137"} +) + +// Ulimately, we'll transition to named styles. +var ( + dimNormalFg = lipgloss.NewStyle().Foreground(normalDim).Render + brightGrayFg = lipgloss.NewStyle().Foreground(brightGray).Render + dimBrightGrayFg = lipgloss.NewStyle().Foreground(dimBrightGray).Render + grayFg = lipgloss.NewStyle().Foreground(gray).Render + midGrayFg = lipgloss.NewStyle().Foreground(midGray).Render + darkGrayFg = lipgloss.NewStyle().Foreground(darkGray) + greenFg = lipgloss.NewStyle().Foreground(green).Render + semiDimGreenFg = lipgloss.NewStyle().Foreground(semiDimGreen).Render + dimGreenFg = lipgloss.NewStyle().Foreground(dimGreen).Render + fuchsiaFg = lipgloss.NewStyle().Foreground(fuchsia).Render + dimFuchsiaFg = lipgloss.NewStyle().Foreground(dimFuchsia).Render + dullFuchsiaFg = lipgloss.NewStyle().Foreground(dullFuchsia).Render + dimDullFuchsiaFg = lipgloss.NewStyle().Foreground(dimDullFuchsia).Render + redFg = lipgloss.NewStyle().Foreground(red).Render + tabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"}) + selectedTabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#333333", Dark: "#979797"}) + errorTitleStyle = lipgloss.NewStyle().Foreground(cream).Background(red).Padding(0, 1) + subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}) + paginationStyle = subtleStyle +) diff --git a/ui/ui.go b/ui/ui.go new file mode 100644 index 0000000..3537d3f --- /dev/null +++ b/ui/ui.go @@ -0,0 +1,452 @@ +// Package ui provides the main UI for the glow application. +package ui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour/styles" + "github.com/charmbracelet/glow/v2/utils" + "github.com/charmbracelet/log" + "github.com/muesli/gitcha" + te "github.com/muesli/termenv" +) + +const ( + statusMessageTimeout = time.Second * 3 // how long to show status messages like "stashed!" + ellipsis = "…" +) + +var ( + config Config + + markdownExtensions = []string{ + "*.md", "*.mdown", "*.mkdn", "*.mkd", "*.markdown", + } +) + +// NewProgram returns a new Tea program. +func NewProgram(cfg Config, content string) *tea.Program { + log.Debug( + "Starting glow", + "high_perf_pager", + cfg.HighPerformancePager, + "glamour", + cfg.GlamourEnabled, + ) + + config = cfg + opts := []tea.ProgramOption{tea.WithAltScreen()} + if cfg.EnableMouse { + opts = append(opts, tea.WithMouseCellMotion()) + } + m := newModel(cfg, content) + return tea.NewProgram(m, opts...) +} + +type errMsg struct{ err error } + +func (e errMsg) Error() string { return e.err.Error() } + +type ( + initLocalFileSearchMsg struct { + cwd string + ch chan gitcha.SearchResult + } +) + +type ( + foundLocalFileMsg gitcha.SearchResult + localFileSearchFinished struct{} + statusMessageTimeoutMsg applicationContext +) + +// applicationContext indicates the area of the application something applies +// to. Occasionally used as an argument to commands and messages. +type applicationContext int + +const ( + stashContext applicationContext = iota + pagerContext +) + +// state is the top-level application state. +type state int + +const ( + stateShowStash state = iota + stateShowDocument +) + +func (s state) String() string { + return map[state]string{ + stateShowStash: "showing file listing", + stateShowDocument: "showing document", + }[s] +} + +// Common stuff we'll need to access in all models. +type commonModel struct { + cfg Config + cwd string + width int + height int +} + +type model struct { + common *commonModel + state state + fatalErr error + + // Sub-models + stash stashModel + pager pagerModel + + // Channel that receives paths to local markdown files + // (via the github.com/muesli/gitcha package) + localFileFinder chan gitcha.SearchResult +} + +// unloadDocument unloads a document from the pager. Note that while this +// method alters the model we also need to send along any commands returned. +func (m *model) unloadDocument() []tea.Cmd { + m.state = stateShowStash + m.stash.viewState = stashStateReady + m.pager.unload() + m.pager.showHelp = false + + var batch []tea.Cmd + if m.pager.viewport.HighPerformanceRendering { + batch = append(batch, tea.ClearScrollArea) //nolint:staticcheck + } + + if !m.stash.shouldSpin() { + batch = append(batch, m.stash.spinner.Tick) + } + return batch +} + +func newModel(cfg Config, content string) tea.Model { + initSections() + + if cfg.GlamourStyle == styles.AutoStyle { + if te.HasDarkBackground() { + cfg.GlamourStyle = styles.DarkStyle + } else { + cfg.GlamourStyle = styles.LightStyle + } + } + + common := commonModel{ + cfg: cfg, + } + + m := model{ + common: &common, + state: stateShowStash, + pager: newPagerModel(&common), + stash: newStashModel(&common), + } + + path := cfg.Path + if path == "" && content != "" { + m.state = stateShowDocument + m.pager.currentDocument = markdown{Body: content} + return m + } + + if path == "" { + path = "." + } + info, err := os.Stat(path) + if err != nil { + log.Error("unable to stat file", "file", path, "error", err) + m.fatalErr = err + return m + } + if info.IsDir() { + m.state = stateShowStash + } else { + cwd, _ := os.Getwd() + m.state = stateShowDocument + m.pager.currentDocument = markdown{ + localPath: path, + Note: stripAbsolutePath(path, cwd), + Modtime: info.ModTime(), + } + } + + return m +} + +func (m model) Init() tea.Cmd { + cmds := []tea.Cmd{m.stash.spinner.Tick} + + switch m.state { + case stateShowStash: + cmds = append(cmds, findLocalFiles(*m.common)) + case stateShowDocument: + content, err := os.ReadFile(m.common.cfg.Path) + if err != nil { + log.Error("unable to read file", "file", m.common.cfg.Path, "error", err) + return func() tea.Msg { return errMsg{err} } + } + body := string(utils.RemoveFrontmatter(content)) + cmds = append(cmds, renderWithGlamour(m.pager, body)) + } + + return tea.Batch(cmds...) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // If there's been an error, any key exits + if m.fatalErr != nil { + if _, ok := msg.(tea.KeyMsg); ok { + return m, tea.Quit + } + } + + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc": + if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument { + batch := m.unloadDocument() + return m, tea.Batch(batch...) + } + case "r": + var cmd tea.Cmd + if m.state == stateShowStash { + // pass through all keys if we're editing the filter + if m.stash.filterState == filtering { + m.stash, cmd = m.stash.update(msg) + return m, cmd + } + m.stash.markdowns = nil + return m, m.Init() + } + + case "q": + var cmd tea.Cmd + + switch m.state { //nolint:exhaustive + case stateShowStash: + // pass through all keys if we're editing the filter + if m.stash.filterState == filtering { + m.stash, cmd = m.stash.update(msg) + return m, cmd + } + } + + return m, tea.Quit + + case "left", "h", "delete": + if m.state == stateShowDocument { + cmds = append(cmds, m.unloadDocument()...) + return m, tea.Batch(cmds...) + } + + case "ctrl+z": + return m, tea.Suspend + + // Ctrl+C always quits no matter where in the application you are. + case "ctrl+c": + return m, tea.Quit + } + + // Window size is received when starting up and on every resize + case tea.WindowSizeMsg: + m.common.width = msg.Width + m.common.height = msg.Height + m.stash.setSize(msg.Width, msg.Height) + m.pager.setSize(msg.Width, msg.Height) + + case initLocalFileSearchMsg: + m.localFileFinder = msg.ch + m.common.cwd = msg.cwd + cmds = append(cmds, findNextLocalFile(m)) + + case fetchedMarkdownMsg: + // We've loaded a markdown file's contents for rendering + m.pager.currentDocument = *msg + body := string(utils.RemoveFrontmatter([]byte(msg.Body))) + cmds = append(cmds, renderWithGlamour(m.pager, body)) + + case contentRenderedMsg: + m.state = stateShowDocument + + case localFileSearchFinished: + // Always pass these messages to the stash so we can keep it updated + // about network activity, even if the user isn't currently viewing + // the stash. + stashModel, cmd := m.stash.update(msg) + m.stash = stashModel + return m, cmd + + case foundLocalFileMsg: + newMd := localFileToMarkdown(m.common.cwd, gitcha.SearchResult(msg)) + m.stash.addMarkdowns(newMd) + if m.stash.filterApplied() { + newMd.buildFilterValue() + } + if m.stash.shouldUpdateFilter() { + cmds = append(cmds, filterMarkdowns(m.stash)) + } + cmds = append(cmds, findNextLocalFile(m)) + + case filteredMarkdownMsg: + if m.state == stateShowDocument { + newStashModel, cmd := m.stash.update(msg) + m.stash = newStashModel + cmds = append(cmds, cmd) + } + } + + // Process children + switch m.state { + case stateShowStash: + newStashModel, cmd := m.stash.update(msg) + m.stash = newStashModel + cmds = append(cmds, cmd) + + case stateShowDocument: + newPagerModel, cmd := m.pager.update(msg) + m.pager = newPagerModel + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m model) View() string { + if m.fatalErr != nil { + return errorView(m.fatalErr, true) + } + + switch m.state { //nolint:exhaustive + case stateShowDocument: + return m.pager.View() + default: + return m.stash.view() + } +} + +func errorView(err error, fatal bool) string { + exitMsg := "press any key to " + if fatal { + exitMsg += "exit" + } else { + exitMsg += "return" + } + s := fmt.Sprintf("%s\n\n%v\n\n%s", + errorTitleStyle.Render("ERROR"), + err, + subtleStyle.Render(exitMsg), + ) + return "\n" + indent(s, 3) +} + +// COMMANDS + +func findLocalFiles(m commonModel) tea.Cmd { + return func() tea.Msg { + log.Info("findLocalFiles") + var ( + cwd = m.cfg.Path + err error + ) + + if cwd == "" { + cwd, err = os.Getwd() + } else { + var info os.FileInfo + info, err = os.Stat(cwd) + if err == nil && info.IsDir() { + cwd, err = filepath.Abs(cwd) + } + } + + // Note that this is one error check for both cases above + if err != nil { + log.Error("error finding local files", "error", err) + return errMsg{err} + } + + log.Debug("local directory is", "cwd", cwd) + + // Switch between FindFiles and FindAllFiles to bypass .gitignore rules + var ch chan gitcha.SearchResult + if m.cfg.ShowAllFiles { + ch, err = gitcha.FindAllFilesExcept(cwd, markdownExtensions, nil) + } else { + ch, err = gitcha.FindFilesExcept(cwd, markdownExtensions, ignorePatterns(m)) + } + + if err != nil { + log.Error("error finding local files", "error", err) + return errMsg{err} + } + + return initLocalFileSearchMsg{ch: ch, cwd: cwd} + } +} + +func findNextLocalFile(m model) tea.Cmd { + return func() tea.Msg { + res, ok := <-m.localFileFinder + + if ok { + // Okay now find the next one + return foundLocalFileMsg(res) + } + // We're done + log.Debug("local file search finished") + return localFileSearchFinished{} + } +} + +func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.Cmd { + return func() tea.Msg { + <-t.C + return statusMessageTimeoutMsg(appCtx) + } +} + +// ETC + +// Convert a Gitcha result to an internal representation of a markdown +// document. Note that we could be doing things like checking if the file is +// a directory, but we trust that gitcha has already done that. +func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown { + return &markdown{ + localPath: res.Path, + Note: stripAbsolutePath(res.Path, cwd), + Modtime: res.Info.ModTime(), + } +} + +func stripAbsolutePath(fullPath, cwd string) string { + fp, _ := filepath.EvalSymlinks(fullPath) + cp, _ := filepath.EvalSymlinks(cwd) + return strings.ReplaceAll(fp, cp+string(os.PathSeparator), "") +} + +// Lightweight version of reflow's indent function. +func indent(s string, n int) string { + if n <= 0 || s == "" { + return s + } + l := strings.Split(s, "\n") + b := strings.Builder{} + i := strings.Repeat(" ", n) + for _, v := range l { + fmt.Fprintf(&b, "%s%s\n", i, v) + } + return b.String() +} diff --git a/url.go b/url.go new file mode 100644 index 0000000..f716681 --- /dev/null +++ b/url.go @@ -0,0 +1,86 @@ +package main + +import ( + "fmt" + "net/url" + "strings" + "sync" +) + +const ( + protoGithub = "github://" + protoGitlab = "gitlab://" + protoHTTPS = "https://" +) + +var ( + githubURL *url.URL + gitlabURL *url.URL + urlsOnce sync.Once +) + +func init() { + urlsOnce.Do(func() { + githubURL, _ = url.Parse("https://github.com") + gitlabURL, _ = url.Parse("https://gitlab.com") + }) +} + +func readmeURL(path string) (*source, error) { + switch { + case strings.HasPrefix(path, protoGithub): + if u := githubReadmeURL(path); u != nil { + return readmeURL(u.String()) + } + return nil, nil + case strings.HasPrefix(path, protoGitlab): + if u := gitlabReadmeURL(path); u != nil { + return readmeURL(u.String()) + } + return nil, nil + } + + if !strings.HasPrefix(path, protoHTTPS) { + path = protoHTTPS + path + } + u, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("unable to parse url: %w", err) + } + + switch { + case u.Hostname() == githubURL.Hostname(): + return findGitHubREADME(u) + case u.Hostname() == gitlabURL.Hostname(): + return findGitLabREADME(u) + } + + return nil, nil +} + +func githubReadmeURL(path string) *url.URL { + path = strings.TrimPrefix(path, protoGithub) + parts := strings.Split(path, "/") + if len(parts) != 2 { + // custom hostnames are not supported yet + return nil + } + u, _ := url.Parse(githubURL.String()) + return u.JoinPath(path) +} + +func gitlabReadmeURL(path string) *url.URL { + path = strings.TrimPrefix(path, protoGitlab) + parts := strings.Split(path, "/") + if len(parts) != 2 { + // custom hostnames are not supported yet + return nil + } + u, _ := url.Parse(gitlabURL.String()) + return u.JoinPath(path) +} + +func isURL(path string) bool { + _, err := url.ParseRequestURI(path) + return err == nil && strings.Contains(path, "://") +} diff --git a/url_test.go b/url_test.go new file mode 100644 index 0000000..9683892 --- /dev/null +++ b/url_test.go @@ -0,0 +1,30 @@ +package main + +import "testing" + +func TestURLParser(t *testing.T) { + for path, url := range map[string]string{ + "github.com/charmbracelet/glow": "https://raw.githubusercontent.com/charmbracelet/glow/master/README.md", + "github://charmbracelet/glow": "https://raw.githubusercontent.com/charmbracelet/glow/master/README.md", + "github://caarlos0/dotfiles.fish": "https://raw.githubusercontent.com/caarlos0/dotfiles.fish/main/README.md", + "github://tj/git-extras": "https://raw.githubusercontent.com/tj/git-extras/main/Readme.md", + "https://github.com/goreleaser/nfpm": "https://raw.githubusercontent.com/goreleaser/nfpm/main/README.md", + "gitlab.com/caarlos0/test": "https://gitlab.com/caarlos0/test/-/raw/master/README.md", + "gitlab://caarlos0/test": "https://gitlab.com/caarlos0/test/-/raw/master/README.md", + "https://gitlab.com/terrakok/gitlab-client": "https://gitlab.com/terrakok/gitlab-client/-/raw/develop/Readme.md", + } { + t.Run(path, func(t *testing.T) { + t.Skip("test uses network, sometimes fails for no reason") + got, err := readmeURL(path) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got == nil { + t.Fatalf("should not be nil") + } + if url != got.URL { + t.Errorf("expected url for %s to be %s, was %s", path, url, got.URL) + } + }) + } +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..9f33a1e --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,113 @@ +// Package utils provides utility functions. +package utils + +import ( + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glamour/ansi" + "github.com/charmbracelet/glamour/styles" + "github.com/charmbracelet/lipgloss" + "github.com/mitchellh/go-homedir" +) + +// RemoveFrontmatter removes the front matter header of a markdown file. +func RemoveFrontmatter(content []byte) []byte { + if frontmatterBoundaries := detectFrontmatter(content); frontmatterBoundaries[0] == 0 { + return content[frontmatterBoundaries[1]:] + } + return content +} + +var yamlPattern = regexp.MustCompile(`(?m)^---\r?\n(\s*\r?\n)?`) + +func detectFrontmatter(c []byte) []int { + if matches := yamlPattern.FindAllIndex(c, 2); len(matches) > 1 { + return []int{matches[0][0], matches[1][1]} + } + return []int{-1, -1} +} + +// ExpandPath expands tilde and all environment variables from the given path. +func ExpandPath(path string) string { + s, err := homedir.Expand(path) + if err == nil { + return os.ExpandEnv(s) + } + return os.ExpandEnv(path) +} + +// WrapCodeBlock wraps a string in a code block with the given language. +func WrapCodeBlock(s, language string) string { + return "```" + language + "\n" + s + "```" +} + +var markdownExtensions = []string{ + ".md", ".mdown", ".mkdn", ".mkd", ".markdown", +} + +// IsMarkdownFile returns whether the filename has a markdown extension. +func IsMarkdownFile(filename string) bool { + ext := filepath.Ext(filename) + + if ext == "" { + // By default, assume it's a markdown file. + return true + } + + for _, v := range markdownExtensions { + if strings.EqualFold(ext, v) { + return true + } + } + + // Has an extension but not markdown + // so assume this is a code file. + return false +} + +// GlamourStyle returns a glamour.TermRendererOption based on the given style. +func GlamourStyle(style string, isCode bool) glamour.TermRendererOption { + if !isCode { + if style == styles.AutoStyle { + return glamour.WithAutoStyle() + } + return glamour.WithStylePath(style) + } + + // If we are rendering a pure code block, we need to modify the style to + // remove the indentation. + + var styleConfig ansi.StyleConfig + + switch style { + case styles.AutoStyle: + if lipgloss.HasDarkBackground() { + styleConfig = styles.DarkStyleConfig + } else { + styleConfig = styles.LightStyleConfig + } + case styles.DarkStyle: + styleConfig = styles.DarkStyleConfig + case styles.LightStyle: + styleConfig = styles.LightStyleConfig + case styles.PinkStyle: + styleConfig = styles.PinkStyleConfig + case styles.NoTTYStyle: + styleConfig = styles.NoTTYStyleConfig + case styles.DraculaStyle: + styleConfig = styles.DraculaStyleConfig + case styles.TokyoNightStyle: + styleConfig = styles.DraculaStyleConfig + default: + return glamour.WithStylesFromJSONFile(style) + } + + var margin uint + styleConfig.CodeBlock.Margin = &margin + + return glamour.WithStyles(styleConfig) +}