Repository: charmbracelet/glow Branch: master Commit: 752de97c5a33 Files: 46 Total size: 119.4 KB Directory structure: gitextract_ljxy3y3l/ ├── .editorconfig ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── config.yml │ │ └── feature_request.md │ ├── dependabot.yml │ └── workflows/ │ ├── build.yml │ ├── coverage.yml │ ├── dependabot-sync.yml │ ├── goreleaser.yml │ ├── lint-sync.yml │ └── lint.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── README.md ├── Taskfile.yaml ├── config_cmd.go ├── console_windows.go ├── github.go ├── gitlab.go ├── glow_test.go ├── go.mod ├── go.sum ├── log.go ├── main.go ├── man_cmd.go ├── style.go ├── ui/ │ ├── config.go │ ├── editor.go │ ├── ignore_darwin.go │ ├── ignore_general.go │ ├── keys.go │ ├── markdown.go │ ├── pager.go │ ├── sort.go │ ├── stash.go │ ├── stashhelp.go │ ├── stashitem.go │ ├── styles.go │ └── ui.go ├── url.go ├── url_test.go └── utils/ └── utils.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # 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 ================================================ FILE: .github/CODEOWNERS ================================================ * @charmbracelet/everyone ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- 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. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: Discord url: https://charm.sh/discord about: Chat on our Discord. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- 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. ================================================ FILE: .github/dependabot.yml ================================================ 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: - "*" ================================================ FILE: .github/workflows/build.yml ================================================ 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 ================================================ FILE: .github/workflows/coverage.yml ================================================ 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 ================================================ FILE: .github/workflows/dependabot-sync.yml ================================================ 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 }} ================================================ FILE: .github/workflows/goreleaser.yml ================================================ # 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 }} ================================================ FILE: .github/workflows/lint-sync.yml ================================================ 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 ================================================ FILE: .github/workflows/lint.yml ================================================ 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 ================================================ FILE: .gitignore ================================================ glow dist/ .envrc completions/ manpages/ ================================================ FILE: .golangci.yml ================================================ 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 ================================================ FILE: .goreleaser.yml ================================================ # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json version: 2 includes: - from_url: url: charmbracelet/meta/main/goreleaser-glow.yaml variables: description: "Render markdown on the CLI, with pizzazz!" github_url: "https://github.com/charmbracelet/glow" maintainer: "Christian Muehlhaeuser " brew_commit_author_name: "Christian Muehlhaeuser" brew_commit_author_email: "muesli@charm.sh" ================================================ FILE: Dockerfile ================================================ FROM gcr.io/distroless/static COPY glow /usr/local/bin/glow ENTRYPOINT [ "/usr/local/bin/glow" ] ================================================ FILE: LICENSE ================================================ MIT License 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 in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Glow Render markdown on the CLI, with _pizzazz_!

Glow Logo Latest Release GoDoc Build Status Go ReportCard

Glow UI Demo

## What is it? Glow is a terminal based markdown reader designed from the ground up to bring out the beauty—and power—of the CLI. 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 ### Package Manager ```bash # macOS or Linux brew install glow ``` ```bash # macOS (with MacPorts) sudo port install glow ``` ```bash # Arch Linux (btw) pacman -S glow ``` ```bash # Void Linux xbps-install -S glow ``` ```bash # Nix shell nix-shell -p glow --command glow ``` ```bash # FreeBSD pkg install glow ``` ```bash # Solus eopkg install glow ``` ```bash # Windows (with Chocolatey, Scoop, or Winget) choco install glow scoop install glow winget install charmbracelet.glow ``` ```bash # Android (with termux) pkg install glow ``` ```bash # Ubuntu (Snapcraft) sudo snap install glow ``` ```bash # Debian/Ubuntu sudo mkdir -p /etc/apt/keyrings curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list sudo apt update && sudo apt install glow ``` ```bash # Fedora/RHEL echo '[charm] name=Charm baseurl=https://repo.charm.sh/yum/ enabled=1 gpgcheck=1 gpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo sudo yum install glow ``` Or download a binary from the [releases][releases] page. MacOS, Linux, Windows, FreeBSD and OpenBSD binaries are available, as well as Debian, RPM, and Alpine packages. ARM builds are also available for macOS, Linux, FreeBSD and OpenBSD. ### Go Or just install it with `go`: ```bash go install github.com/charmbracelet/glow/v2@latest ``` ### Build (requires Go 1.21+) ```bash git clone https://github.com/charmbracelet/glow.git cd glow go build ``` [releases]: https://github.com/charmbracelet/glow/releases ## The TUI Simply run `glow` without arguments to start the textual user interface and browse local. Glow will find local markdown files in the current directory and below or, if you’re in a Git repository, Glow will search the repo. Markdown files can be read with Glow's high-performance pager. Most of the keystrokes you know from `less` are the same, but you can press `?` to list the hotkeys. ## The CLI In addition to a TUI, Glow has a CLI for working with Markdown. To format a document use a markdown source as the primary argument: ```bash # Read from file glow README.md # Read from stdin echo "[Glow](https://github.com/charmbracelet/glow)" | glow - # Fetch README from GitHub / GitLab glow github.com/charmbracelet/glow # Fetch markdown from HTTP glow https://host.tld/file.md ``` ### Word Wrapping The `-w` flag lets you set a maximum width at which the output will be wrapped: ```bash glow -w 60 ``` ### Paging CLI output can be displayed in your preferred pager with the `-p` flag. This defaults to the ANSI-aware `less -r` if `$PAGER` is not explicitly set. ### Styles You can choose a style with the `-s` flag. When no flag is provided `glow` tries to detect your terminal's current background color and automatically picks either the `dark` or the `light` style for you. ```bash glow -s [dark|light] ``` Alternatively you can also supply a custom JSON stylesheet: ```bash glow -s mystyle.json ``` For additional usage details see: ```bash glow --help ``` Check out the [Glamour Style Section](https://github.com/charmbracelet/glamour/blob/master/styles/gallery/README.md) to find more styles. Or [make your own](https://github.com/charmbracelet/glamour/tree/master/styles)! ## The Config File If you find yourself supplying the same flags to `glow` all the time, it's probably a good idea to create a config file. Run `glow config`, which will open it in your favorite $EDITOR. Alternatively you can manually put a file named `glow.yml` in the default config path of you platform. If you're not sure where that is, please refer to `glow --help`. Here's an example config: ```yaml # style name or JSON path (default "auto") style: "light" # mouse wheel support (TUI-mode only) mouse: true # use pager to display markdown pager: true # at which column should we word wrap? width: 80 # show all files, including hidden and ignored. all: false # show line numbers (TUI-mode only) showLineNumbers: false # preserve newlines in the output preserveNewLines: false ``` ## Contributing See [contributing][contribute]. [contribute]: https://github.com/charmbracelet/glow/contribute ## Feedback We’d love to hear your thoughts on this project. Feel free to drop us a note! - [Twitter](https://twitter.com/charmcli) - [The Fediverse](https://mastodon.social/@charmcli) - [Discord](https://charm.sh/chat) ## License [MIT](https://github.com/charmbracelet/glow/raw/master/LICENSE) --- Part of [Charm](https://charm.sh). The Charm logo Charm热爱开源 • Charm loves open source ================================================ FILE: Taskfile.yaml ================================================ # 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] ================================================ FILE: config_cmd.go ================================================ 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 } ================================================ FILE: console_windows.go ================================================ // +build windows package main import ( "os" "golang.org/x/sys/windows" ) // enableAnsiColors enables support for ANSI color sequences in Windows // default console. Note that this only works with Windows 10. func enableAnsiColors() { stdout := windows.Handle(os.Stdout.Fd()) var originalMode uint32 windows.GetConsoleMode(stdout, &originalMode) windows.SetConsoleMode(stdout, originalMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) } func init() { enableAnsiColors() } ================================================ FILE: github.go ================================================ package main import ( "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" ) // findGitHubREADME tries to find the correct README filename in a repository using GitHub API. func findGitHubREADME(u *url.URL) (*source, error) { owner, repo, ok := strings.Cut(strings.TrimPrefix(u.Path, "/"), "/") if !ok { return nil, fmt.Errorf("invalid url: %s", u.String()) } type readme struct { DownloadURL string `json:"download_url"` } apiURL := fmt.Sprintf("https://api.%s/repos/%s/%s/readme", u.Hostname(), owner, repo) //nolint:bodyclose // it is closed on the caller res, err := http.Get(apiURL) //nolint: gosec,noctx if err != nil { return nil, fmt.Errorf("unable to get url: %w", err) } body, err := io.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("unable to read http response body: %w", err) } var result readme if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("unable to parse json: %w", err) } if res.StatusCode == http.StatusOK { //nolint:bodyclose // it is closed on the caller resp, err := http.Get(result.DownloadURL) //nolint: noctx if err != nil { return nil, fmt.Errorf("unable to get url: %w", err) } if resp.StatusCode == http.StatusOK { return &source{resp.Body, result.DownloadURL}, nil } } return nil, errors.New("can't find README in GitHub repository") } ================================================ FILE: gitlab.go ================================================ package main import ( "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" ) // findGitLabREADME tries to find the correct README filename in a repository using GitLab API. func findGitLabREADME(u *url.URL) (*source, error) { owner, repo, ok := strings.Cut(strings.TrimPrefix(u.Path, "/"), "/") if !ok { return nil, fmt.Errorf("invalid url: %s", u.String()) } projectPath := url.QueryEscape(owner + "/" + repo) type readme struct { ReadmeURL string `json:"readme_url"` } apiURL := fmt.Sprintf("https://%s/api/v4/projects/%s", u.Hostname(), projectPath) //nolint:bodyclose // it is closed on the caller res, err := http.Get(apiURL) //nolint: gosec,noctx if err != nil { return nil, fmt.Errorf("unable to get url: %w", err) } body, err := io.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("unable to read http response body: %w", err) } var result readme if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("unable to parse json: %w", err) } readmeRawURL := strings.ReplaceAll(result.ReadmeURL, "blob", "raw") if res.StatusCode == http.StatusOK { //nolint:bodyclose // it is closed on the caller resp, err := http.Get(readmeRawURL) //nolint: gosec,noctx if err != nil { return nil, fmt.Errorf("unable to get url: %w", err) } if resp.StatusCode == http.StatusOK { return &source{resp.Body, readmeRawURL}, nil } } return nil, errors.New("can't find README in GitLab repository") } ================================================ FILE: glow_test.go ================================================ package main import ( "testing" ) func TestGlowFlags(t *testing.T) { tt := []struct { args []string check func() bool }{ { args: []string{"-p"}, check: func() bool { return pager }, }, { args: []string{"-s", "light"}, check: func() bool { return style == "light" }, }, { args: []string{"-w", "40"}, check: func() bool { return width == 40 }, }, } for _, v := range tt { err := rootCmd.ParseFlags(v.args) if err != nil { t.Fatal(err) } if !v.check() { t.Errorf("Parsing flag failed: %s", v.args) } } } ================================================ FILE: go.mod ================================================ module github.com/charmbracelet/glow/v2 go 1.24.0 toolchain go1.24.1 require ( github.com/atotto/clipboard v0.1.4 github.com/caarlos0/env/v11 v11.3.1 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/log v0.4.2 github.com/charmbracelet/x/editor v0.1.0 github.com/dustin/go-humanize v1.0.1 github.com/fsnotify/fsnotify v1.9.0 github.com/mattn/go-runewidth v0.0.19 github.com/mitchellh/go-homedir v1.1.0 github.com/muesli/gitcha v0.3.0 github.com/muesli/go-app-paths v0.2.2 github.com/muesli/mango-cobra v1.3.0 github.com/muesli/reflow v0.3.0 github.com/muesli/roff v0.1.0 github.com/muesli/termenv v0.16.0 github.com/sahilm/fuzzy v0.1.1 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 golang.org/x/sys v0.39.0 golang.org/x/term v0.38.0 golang.org/x/text v0.32.0 ) 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/rogpeppe/go-internal v1.12.0 // 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.8 // 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.40.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) ================================================ FILE: go.sum ================================================ 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.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-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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/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/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.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 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.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.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 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.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: log.go ================================================ 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 } ================================================ FILE: main.go ================================================ // Package main provides the entry point for the Glow CLI application. package main import ( "errors" "fmt" "io" "io/fs" "net/http" "net/url" "os" "os/exec" "path/filepath" "strings" "github.com/caarlos0/env/v11" "github.com/charmbracelet/glamour" "github.com/charmbracelet/glamour/styles" "github.com/charmbracelet/glow/v2/ui" "github.com/charmbracelet/glow/v2/utils" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/log" gap "github.com/muesli/go-app-paths" "github.com/spf13/cobra" "github.com/spf13/viper" "golang.org/x/term" ) var ( // Version as provided by goreleaser. Version = "" // CommitSHA as provided by goreleaser. CommitSHA = "" readmeNames = []string{"README.md", "README", "Readme.md", "Readme", "readme.md", "readme"} configFile string pager bool tui bool style string width uint showAllFiles bool showLineNumbers bool preserveNewLines bool mouse bool rootCmd = &cobra.Command{ Use: "glow [SOURCE|DIR]", Short: "Render markdown on the CLI, with pizzazz!", Long: paragraph( fmt.Sprintf("\nRender markdown on the CLI, %s!", keyword("with pizzazz")), ), SilenceErrors: false, SilenceUsage: true, TraverseChildren: true, Args: cobra.MaximumNArgs(1), ValidArgsFunction: func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { return nil, cobra.ShellCompDirectiveDefault }, PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { return validateOptions(cmd) }, RunE: execute, } ) // source provides a readable markdown source. type source struct { reader io.ReadCloser URL string } // sourceFromArg parses an argument and creates a readable source for it. func sourceFromArg(arg string) (*source, error) { // from stdin if arg == "-" { return &source{reader: os.Stdin}, nil } // a GitHub or GitLab URL (even without the protocol): src, err := readmeURL(arg) if src != nil && err == nil { // if there's an error, try next methods... return src, nil } // HTTP(S) URLs: if u, err := url.ParseRequestURI(arg); err == nil && strings.Contains(arg, "://") { //nolint:nestif if u.Scheme != "" { if u.Scheme != "http" && u.Scheme != "https" { return nil, fmt.Errorf("%s is not a supported protocol", u.Scheme) } // consumer of the source is responsible for closing the ReadCloser. resp, err := http.Get(u.String()) //nolint: noctx,bodyclose if err != nil { return nil, fmt.Errorf("unable to get url: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP status %d", resp.StatusCode) } return &source{resp.Body, u.String()}, nil } } // a directory: if len(arg) == 0 { // use the current working dir if no argument was supplied arg = "." } st, err := os.Stat(arg) if err == nil && st.IsDir() { //nolint:nestif var src *source _ = filepath.Walk(arg, func(path string, _ os.FileInfo, err error) error { if err != nil { return err } for _, v := range readmeNames { if strings.EqualFold(filepath.Base(path), v) { r, err := os.Open(path) if err != nil { continue } u, _ := filepath.Abs(path) src = &source{r, u} // abort filepath.Walk return errors.New("source found") } } return nil }) if src != nil { return src, nil } return nil, errors.New("missing markdown source") } r, err := os.Open(arg) if err != nil { return nil, fmt.Errorf("unable to open file: %w", err) } u, err := filepath.Abs(arg) if err != nil { return nil, fmt.Errorf("unable to get absolute path: %w", err) } return &source{r, u}, nil } // validateStyle checks if the style is a default style, if not, checks that // the custom style exists. func validateStyle(style string) error { if style != "auto" && styles.DefaultStyles[style] == nil { style = utils.ExpandPath(style) if _, err := os.Stat(style); errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("specified style does not exist: %s", style) } else if err != nil { return fmt.Errorf("unable to stat file: %w", err) } } return nil } func validateOptions(cmd *cobra.Command) error { // grab config values from Viper width = viper.GetUint("width") mouse = viper.GetBool("mouse") pager = viper.GetBool("pager") tui = viper.GetBool("tui") showAllFiles = viper.GetBool("all") preserveNewLines = viper.GetBool("preserveNewLines") showLineNumbers = viper.GetBool("showLineNumbers") if pager && tui { return errors.New("cannot use both pager and tui") } // validate the glamour style style = viper.GetString("style") if err := validateStyle(style); err != nil { return err } isTerminal := term.IsTerminal(int(os.Stdout.Fd())) // We want to use a special no-TTY style, when stdout is not a terminal // and there was no specific style passed by arg if !isTerminal && !cmd.Flags().Changed("style") { style = "notty" } // Detect terminal width if !cmd.Flags().Changed("width") { //nolint:nestif if isTerminal && width == 0 { w, _, err := term.GetSize(int(os.Stdout.Fd())) if err == nil { width = uint(w) //nolint:gosec } if width > 120 { width = 120 } } if width == 0 { width = 80 } } return nil } func stdinIsPipe() (bool, error) { stat, err := os.Stdin.Stat() if err != nil { return false, fmt.Errorf("unable to open file: %w", err) } if stat.Mode()&os.ModeCharDevice == 0 || stat.Size() > 0 { return true, nil } return false, nil } func execute(cmd *cobra.Command, args []string) error { // if stdin is a pipe then use stdin for input. note that you can also // explicitly use a - to read from stdin. if yes, err := stdinIsPipe(); err != nil { return err } else if yes { src := &source{reader: os.Stdin} defer src.reader.Close() //nolint:errcheck return executeCLI(cmd, src, os.Stdout) } switch len(args) { // TUI running on cwd case 0: return runTUI("", "") // TUI with possible dir argument case 1: // Validate that the argument is a directory. If it's not treat it as // an argument to the non-TUI version of Glow (via fallthrough). info, err := os.Stat(args[0]) if err == nil && info.IsDir() { p, err := filepath.Abs(args[0]) if err == nil { return runTUI(p, "") } } fallthrough // CLI default: for _, arg := range args { if err := executeArg(cmd, arg, os.Stdout); err != nil { return err } } } return nil } func executeArg(cmd *cobra.Command, arg string, w io.Writer) error { // create an io.Reader from the markdown source in cli-args src, err := sourceFromArg(arg) if err != nil { return err } defer src.reader.Close() //nolint:errcheck return executeCLI(cmd, src, w) } func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error { b, err := io.ReadAll(src.reader) if err != nil { return fmt.Errorf("unable to read from reader: %w", err) } b = utils.RemoveFrontmatter(b) // render var baseURL string u, err := url.ParseRequestURI(src.URL) if err == nil { u.Path = filepath.Dir(u.Path) baseURL = u.String() + "/" } isCode := !utils.IsMarkdownFile(src.URL) // initialize glamour r, err := glamour.NewTermRenderer( glamour.WithColorProfile(lipgloss.ColorProfile()), utils.GlamourStyle(style, isCode), glamour.WithWordWrap(int(width)), //nolint:gosec glamour.WithBaseURL(baseURL), glamour.WithPreservedNewLines(), ) if err != nil { return fmt.Errorf("unable to create renderer: %w", err) } content := string(b) ext := filepath.Ext(src.URL) if isCode { content = utils.WrapCodeBlock(string(b), ext) } out, err := r.Render(content) if err != nil { return fmt.Errorf("unable to render markdown: %w", err) } // display switch { case pager || cmd.Flags().Changed("pager"): pagerCmd := os.Getenv("PAGER") if pagerCmd == "" { pagerCmd = "less -r" } pa := strings.Split(pagerCmd, " ") c := exec.Command(pa[0], pa[1:]...) //nolint:gosec c.Stdin = strings.NewReader(out) c.Stdout = os.Stdout if err := c.Run(); err != nil { return fmt.Errorf("unable to run command: %w", err) } return nil case tui || cmd.Flags().Changed("tui"): path := "" if !isURL(src.URL) { path = src.URL } return runTUI(path, content) default: if _, err = fmt.Fprint(w, out); err != nil { return fmt.Errorf("unable to write to writer: %w", err) } return nil } } func runTUI(path string, content string) error { // Read environment to get debugging stuff cfg, err := env.ParseAs[ui.Config]() if err != nil { return fmt.Errorf("error parsing config: %v", err) } // use style set in env, or auto if unset if err := validateStyle(cfg.GlamourStyle); err != nil { cfg.GlamourStyle = style } cfg.Path = path cfg.ShowAllFiles = showAllFiles cfg.ShowLineNumbers = showLineNumbers cfg.GlamourMaxWidth = width cfg.EnableMouse = mouse cfg.PreserveNewLines = preserveNewLines // Run Bubble Tea program if _, err := ui.NewProgram(cfg, content).Run(); err != nil { return fmt.Errorf("unable to run tui program: %w", err) } return nil } func main() { closer, err := setupLog() if err != nil { fmt.Println(err) os.Exit(1) } if err := rootCmd.Execute(); err != nil { _ = closer() os.Exit(1) } _ = closer() } func init() { tryLoadConfigFromDefaultPlaces() if len(CommitSHA) >= 7 { vt := rootCmd.VersionTemplate() rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n") } if Version == "" { Version = "unknown (built from source)" } rootCmd.Version = Version rootCmd.InitDefaultCompletionCmd() // "Glow Classic" cli arguments rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default %s)", viper.GetViper().ConfigFileUsed())) rootCmd.Flags().BoolVarP(&pager, "pager", "p", false, "display with pager") rootCmd.Flags().BoolVarP(&tui, "tui", "t", false, "display with tui") rootCmd.Flags().StringVarP(&style, "style", "s", styles.AutoStyle, "style name or JSON path") rootCmd.Flags().UintVarP(&width, "width", "w", 0, "word-wrap at width (set to 0 to disable)") rootCmd.Flags().BoolVarP(&showAllFiles, "all", "a", false, "show system files and directories (TUI-mode only)") rootCmd.Flags().BoolVarP(&showLineNumbers, "line-numbers", "l", false, "show line numbers (TUI-mode only)") rootCmd.Flags().BoolVarP(&preserveNewLines, "preserve-new-lines", "n", false, "preserve newlines in the output") rootCmd.Flags().BoolVarP(&mouse, "mouse", "m", false, "enable mouse wheel (TUI-mode only)") _ = rootCmd.Flags().MarkHidden("mouse") // Config bindings _ = viper.BindPFlag("pager", rootCmd.Flags().Lookup("pager")) _ = viper.BindPFlag("tui", rootCmd.Flags().Lookup("tui")) _ = viper.BindPFlag("style", rootCmd.Flags().Lookup("style")) _ = viper.BindPFlag("width", rootCmd.Flags().Lookup("width")) _ = viper.BindPFlag("debug", rootCmd.Flags().Lookup("debug")) _ = viper.BindPFlag("mouse", rootCmd.Flags().Lookup("mouse")) _ = viper.BindPFlag("preserveNewLines", rootCmd.Flags().Lookup("preserve-new-lines")) _ = viper.BindPFlag("showLineNumbers", rootCmd.Flags().Lookup("line-numbers")) _ = viper.BindPFlag("all", rootCmd.Flags().Lookup("all")) viper.SetDefault("style", styles.AutoStyle) viper.SetDefault("width", 0) viper.SetDefault("all", true) rootCmd.AddCommand(configCmd, manCmd) } func tryLoadConfigFromDefaultPlaces() { scope := gap.NewScope(gap.User, "glow") dirs, err := scope.ConfigDirs() if err != nil { fmt.Println("Could not load find configuration directory.") os.Exit(1) } if c := os.Getenv("XDG_CONFIG_HOME"); c != "" { dirs = append([]string{filepath.Join(c, "glow")}, dirs...) } if c := os.Getenv("GLOW_CONFIG_HOME"); c != "" { dirs = append([]string{c}, dirs...) } for _, v := range dirs { viper.AddConfigPath(v) } viper.SetConfigName("glow") viper.SetConfigType("yaml") viper.SetEnvPrefix("glow") viper.AutomaticEnv() if err := viper.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { log.Warn("Could not parse configuration file", "err", err) } } if used := viper.ConfigFileUsed(); used != "" { log.Debug("Using configuration file", "path", viper.ConfigFileUsed()) return } if viper.ConfigFileUsed() == "" { configFile = filepath.Join(dirs[0], "glow.yml") } if err := ensureConfigFile(); err != nil { log.Error("Could not create default configuration", "error", err) } } ================================================ FILE: man_cmd.go ================================================ 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 }, } ================================================ FILE: style.go ================================================ 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 ) ================================================ FILE: ui/config.go ================================================ 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"` } ================================================ FILE: ui/editor.go ================================================ 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) } ================================================ FILE: ui/ignore_darwin.go ================================================ //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", ".*", } } ================================================ FILE: ui/ignore_general.go ================================================ //go:build !darwin // +build !darwin package ui func ignorePatterns(m commonModel) []string { return []string{ m.cfg.Gopath, "node_modules", ".*", } } ================================================ FILE: ui/keys.go ================================================ package ui const ( keyEnter = "enter" keyEsc = "esc" ) ================================================ FILE: ui/markdown.go ================================================ 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}, } ================================================ FILE: ui/pager.go ================================================ 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) } ================================================ FILE: ui/sort.go ================================================ package ui import ( "cmp" "slices" ) func sortMarkdowns(mds []*markdown) { slices.SortStableFunc(mds, func(a, b *markdown) int { return cmp.Compare(a.Note, b.Note) }) } ================================================ FILE: ui/stash.go ================================================ 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) } } ================================================ FILE: ui/stashhelp.go ================================================ 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 } ================================================ FILE: ui/stashitem.go ================================================ 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() } ================================================ FILE: ui/styles.go ================================================ 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 ) ================================================ FILE: ui/ui.go ================================================ // 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() } ================================================ FILE: url.go ================================================ 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, "://") } ================================================ FILE: url_test.go ================================================ 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) } }) } } ================================================ FILE: utils/utils.go ================================================ // 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) }