├── .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 ├── example.png ├── 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 /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.go] 13 | indent_style = tab 14 | indent_size = 8 15 | 16 | [*.golden] 17 | insert_final_newline = false 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @charmbracelet/everyone 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Setup** 14 | Please complete the following information along with version numbers, if applicable. 15 | - OS [e.g. Ubuntu, macOS] 16 | - Shell [e.g. zsh, fish] 17 | - Terminal Emulator [e.g. kitty, iterm] 18 | - Terminal Multiplexer [e.g. tmux] 19 | - Locale [e.g. en_US.UTF-8, zh_CN.UTF-8, etc.] 20 | 21 | **To Reproduce** 22 | Steps to reproduce the behavior: 23 | 1. Go to '...' 24 | 2. Click on '....' 25 | 3. Scroll down to '....' 26 | 4. See error 27 | 28 | **Source Code** 29 | Please include source code if needed to reproduce the behavior. 30 | 31 | **Expected behavior** 32 | A clear and concise description of what you expected to happen. 33 | 34 | **Screenshots** 35 | Add screenshots to help explain your problem. 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Discord 4 | url: https://charm.sh/discord 5 | about: Chat on our Discord. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | time: "05:00" 10 | timezone: "America/New_York" 11 | labels: 12 | - "dependencies" 13 | commit-message: 14 | prefix: "chore" 15 | include: "scope" 16 | 17 | - package-ecosystem: "github-actions" 18 | directory: "/" 19 | schedule: 20 | interval: "weekly" 21 | day: "monday" 22 | time: "05:00" 23 | timezone: "America/New_York" 24 | labels: 25 | - "dependencies" 26 | commit-message: 27 | prefix: "chore" 28 | include: "scope" 29 | 30 | - package-ecosystem: "docker" 31 | directory: "/" 32 | schedule: 33 | interval: "weekly" 34 | day: "monday" 35 | time: "05:00" 36 | timezone: "America/New_York" 37 | labels: 38 | - "dependencies" 39 | commit-message: 40 | prefix: "chore" 41 | include: "scope" 42 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | uses: charmbracelet/meta/.github/workflows/build.yml@main 8 | 9 | snapshot: 10 | uses: charmbracelet/meta/.github/workflows/snapshot.yml@main 11 | secrets: 12 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 13 | 14 | govulncheck: 15 | uses: charmbracelet/meta/.github/workflows/govulncheck.yml@main 16 | with: 17 | go-version: stable 18 | 19 | semgrep: 20 | uses: charmbracelet/meta/.github/workflows/semgrep.yml@main 21 | 22 | ruleguard: 23 | uses: charmbracelet/meta/.github/workflows/ruleguard.yml@main 24 | with: 25 | go-version: stable 26 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | coverage: 6 | runs-on: ubuntu-latest 7 | env: 8 | GO111MODULE: "on" 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v4 12 | 13 | - name: Install Go 14 | uses: actions/setup-go@v5 15 | with: 16 | go-version: stable 17 | 18 | - name: Coverage 19 | env: 20 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | run: | 22 | go test -race -covermode atomic -coverprofile=profile.cov ./... 23 | go install github.com/mattn/goveralls@latest 24 | goveralls -coverprofile=profile.cov -service=github 25 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-sync.yml: -------------------------------------------------------------------------------- 1 | name: dependabot-sync 2 | on: 3 | schedule: 4 | - cron: "0 0 * * 0" # every Sunday at midnight 5 | workflow_dispatch: # allows manual triggering 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot-sync: 13 | uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main 14 | with: 15 | repo_name: ${{ github.event.repository.name }} 16 | secrets: 17 | gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: goreleaser 4 | 5 | on: 6 | push: 7 | tags: 8 | - v*.*.* 9 | 10 | concurrency: 11 | group: goreleaser 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | goreleaser: 16 | uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main 17 | secrets: 18 | docker_username: ${{ secrets.DOCKERHUB_USERNAME }} 19 | docker_token: ${{ secrets.DOCKERHUB_TOKEN }} 20 | gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 21 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 22 | fury_token: ${{ secrets.FURY_TOKEN }} 23 | nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }} 24 | nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }} 25 | snapcraft_token: ${{ secrets.SNAPCRAFT_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/workflows/lint-sync.yml: -------------------------------------------------------------------------------- 1 | name: lint-sync 2 | on: 3 | schedule: 4 | - cron: "0 0 * * 0" # every sunday at midnight 5 | workflow_dispatch: 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | lint: 13 | uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main 14 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | golangci: 8 | name: lint 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: golangci-lint 13 | uses: golangci/golangci-lint-action@v8.0.0 14 | with: 15 | # Optional: golangci-lint command line arguments. 16 | args: --issues-exit-code=0 17 | # Optional: show only new issues if it's a pull request. The default value is `false`. 18 | only-new-issues: true 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | glow 2 | dist/ 3 | .envrc 4 | completions/ 5 | manpages/ 6 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | tests: false 4 | linters: 5 | enable: 6 | - bodyclose 7 | - exhaustive 8 | - goconst 9 | - godot 10 | - gomoddirectives 11 | - goprintffuncname 12 | - gosec 13 | - misspell 14 | - nakedret 15 | - nestif 16 | - nilerr 17 | - noctx 18 | - nolintlint 19 | - prealloc 20 | - revive 21 | - rowserrcheck 22 | - sqlclosecheck 23 | - tparallel 24 | - unconvert 25 | - unparam 26 | - whitespace 27 | - wrapcheck 28 | exclusions: 29 | generated: lax 30 | presets: 31 | - common-false-positives 32 | issues: 33 | max-issues-per-linter: 0 34 | max-same-issues: 0 35 | formatters: 36 | enable: 37 | - gofumpt 38 | - goimports 39 | exclusions: 40 | generated: lax 41 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json 2 | 3 | version: 2 4 | 5 | includes: 6 | - from_url: 7 | url: charmbracelet/meta/main/goreleaser-glow.yaml 8 | 9 | variables: 10 | description: "Render markdown on the CLI, with pizzazz!" 11 | github_url: "https://github.com/charmbracelet/glow" 12 | maintainer: "Christian Muehlhaeuser " 13 | brew_commit_author_name: "Christian Muehlhaeuser" 14 | brew_commit_author_email: "muesli@charm.sh" 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gcr.io/distroless/static 2 | COPY glow /usr/local/bin/glow 3 | ENTRYPOINT [ "/usr/local/bin/glow" ] 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2024 Charmbracelet, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Glow 2 | 3 | Render markdown on the CLI, with _pizzazz_! 4 | 5 |

6 | Glow Logo 7 | Latest Release 8 | GoDoc 9 | Build Status 10 | Go ReportCard 11 |

12 | 13 |

14 | Glow UI Demo 15 |

16 | 17 | ## What is it? 18 | 19 | Glow is a terminal based markdown reader designed from the ground up to bring 20 | out the beauty—and power—of the CLI. 21 | 22 | Use it to discover markdown files, read documentation directly on the command 23 | line. Glow will find local markdown files in subdirectories or a local 24 | Git repository. 25 | 26 | ## Installation 27 | 28 | ### Package Manager 29 | 30 | ```bash 31 | # macOS or Linux 32 | brew install glow 33 | ``` 34 | 35 | ```bash 36 | # macOS (with MacPorts) 37 | sudo port install glow 38 | ``` 39 | 40 | ```bash 41 | # Arch Linux (btw) 42 | pacman -S glow 43 | ``` 44 | 45 | ```bash 46 | # Void Linux 47 | xbps-install -S glow 48 | ``` 49 | 50 | ```bash 51 | # Nix shell 52 | nix-shell -p glow --command glow 53 | ``` 54 | 55 | ```bash 56 | # FreeBSD 57 | pkg install glow 58 | ``` 59 | 60 | ```bash 61 | # Solus 62 | eopkg install glow 63 | ``` 64 | 65 | ```bash 66 | # Windows (with Chocolatey, Scoop, or Winget) 67 | choco install glow 68 | scoop install glow 69 | winget install charmbracelet.glow 70 | ``` 71 | 72 | ```bash 73 | # Android (with termux) 74 | pkg install glow 75 | ``` 76 | 77 | ```bash 78 | # Ubuntu (Snapcraft) 79 | sudo snap install glow 80 | ``` 81 | 82 | ```bash 83 | # Debian/Ubuntu 84 | sudo mkdir -p /etc/apt/keyrings 85 | curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg 86 | echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list 87 | sudo apt update && sudo apt install glow 88 | ``` 89 | 90 | ```bash 91 | # Fedora/RHEL 92 | echo '[charm] 93 | name=Charm 94 | baseurl=https://repo.charm.sh/yum/ 95 | enabled=1 96 | gpgcheck=1 97 | gpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo 98 | sudo yum install glow 99 | ``` 100 | 101 | Or download a binary from the [releases][releases] page. MacOS, Linux, Windows, 102 | FreeBSD and OpenBSD binaries are available, as well as Debian, RPM, and Alpine 103 | packages. ARM builds are also available for macOS, Linux, FreeBSD and OpenBSD. 104 | 105 | ### Go 106 | 107 | Or just install it with `go`: 108 | 109 | ```bash 110 | go install github.com/charmbracelet/glow@latest 111 | ``` 112 | 113 | ### Build (requires Go 1.21+) 114 | 115 | ```bash 116 | git clone https://github.com/charmbracelet/glow.git 117 | cd glow 118 | go build 119 | ``` 120 | 121 | [releases]: https://github.com/charmbracelet/glow/releases 122 | 123 | ## The TUI 124 | 125 | Simply run `glow` without arguments to start the textual user interface and 126 | browse local. Glow will find local markdown files in the 127 | current directory and below or, if you’re in a Git repository, Glow will search 128 | the repo. 129 | 130 | Markdown files can be read with Glow's high-performance pager. Most of the 131 | keystrokes you know from `less` are the same, but you can press `?` to list 132 | the hotkeys. 133 | 134 | ## The CLI 135 | 136 | In addition to a TUI, Glow has a CLI for working with Markdown. To format a 137 | document use a markdown source as the primary argument: 138 | 139 | ```bash 140 | # Read from file 141 | glow README.md 142 | 143 | # Read from stdin 144 | echo "[Glow](https://github.com/charmbracelet/glow)" | glow - 145 | 146 | # Fetch README from GitHub / GitLab 147 | glow github.com/charmbracelet/glow 148 | 149 | # Fetch markdown from HTTP 150 | glow https://host.tld/file.md 151 | ``` 152 | 153 | ### Word Wrapping 154 | 155 | The `-w` flag lets you set a maximum width at which the output will be wrapped: 156 | 157 | ```bash 158 | glow -w 60 159 | ``` 160 | 161 | ### Paging 162 | 163 | CLI output can be displayed in your preferred pager with the `-p` flag. This defaults 164 | to the ANSI-aware `less -r` if `$PAGER` is not explicitly set. 165 | 166 | ### Styles 167 | 168 | You can choose a style with the `-s` flag. When no flag is provided `glow` tries 169 | to detect your terminal's current background color and automatically picks 170 | either the `dark` or the `light` style for you. 171 | 172 | ```bash 173 | glow -s [dark|light] 174 | ``` 175 | 176 | Alternatively you can also supply a custom JSON stylesheet: 177 | 178 | ```bash 179 | glow -s mystyle.json 180 | ``` 181 | 182 | For additional usage details see: 183 | 184 | ```bash 185 | glow --help 186 | ``` 187 | 188 | Check out the [Glamour Style Section](https://github.com/charmbracelet/glamour/blob/master/styles/gallery/README.md) 189 | to find more styles. Or [make your own](https://github.com/charmbracelet/glamour/tree/master/styles)! 190 | 191 | ## The Config File 192 | 193 | If you find yourself supplying the same flags to `glow` all the time, it's 194 | probably a good idea to create a config file. Run `glow config`, which will open 195 | it in your favorite $EDITOR. Alternatively you can manually put a file named 196 | `glow.yml` in the default config path of you platform. If you're not sure where 197 | that is, please refer to `glow --help`. 198 | 199 | Here's an example config: 200 | 201 | ```yaml 202 | # style name or JSON path (default "auto") 203 | style: "light" 204 | # mouse wheel support (TUI-mode only) 205 | mouse: true 206 | # use pager to display markdown 207 | pager: true 208 | # at which column should we word wrap? 209 | width: 80 210 | # show all files, including hidden and ignored. 211 | all: false 212 | ``` 213 | 214 | ## Feedback 215 | 216 | We’d love to hear your thoughts on this project. Feel free to drop us a note! 217 | 218 | - [Twitter](https://twitter.com/charmcli) 219 | - [The Fediverse](https://mastodon.social/@charmcli) 220 | - [Discord](https://charm.sh/chat) 221 | 222 | ## License 223 | 224 | [MIT](https://github.com/charmbracelet/glow/raw/master/LICENSE) 225 | 226 | --- 227 | 228 | Part of [Charm](https://charm.sh). 229 | 230 | The Charm logo 231 | 232 | Charm热爱开源 • Charm loves open source 233 | -------------------------------------------------------------------------------- /Taskfile.yaml: -------------------------------------------------------------------------------- 1 | # https://taskfile.dev 2 | 3 | version: '3' 4 | 5 | tasks: 6 | lint: 7 | desc: Run base linters 8 | cmds: 9 | - golangci-lint run 10 | 11 | test: 12 | desc: Run tests 13 | cmds: 14 | - go test ./... {{.CLI_ARGS}} 15 | 16 | log: 17 | desc: Watch for glow logs 18 | aliases: [tail] 19 | cmds: 20 | - cmd: tail -f ~/Library/Caches/glow/glow.log 21 | platforms: [darwin] 22 | - cmd: tail -f ~/.cache/glow/glow.log 23 | platforms: [linux, windows] 24 | -------------------------------------------------------------------------------- /config_cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | 11 | "github.com/charmbracelet/x/editor" 12 | "github.com/spf13/cobra" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | const defaultConfig = `# style name or JSON path (default "auto") 17 | style: "auto" 18 | # mouse support (TUI-mode only) 19 | mouse: false 20 | # use pager to display markdown 21 | pager: false 22 | # word-wrap at width 23 | width: 80 24 | # show all files, including hidden and ignored. 25 | all: false 26 | ` 27 | 28 | var configCmd = &cobra.Command{ 29 | Use: "config", 30 | Hidden: false, 31 | Short: "Edit the glow config file", 32 | 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"))), 33 | Example: paragraph("glow config\nglow config --config path/to/config.yml"), 34 | Args: cobra.NoArgs, 35 | RunE: func(*cobra.Command, []string) error { 36 | if err := ensureConfigFile(); err != nil { 37 | return err 38 | } 39 | 40 | c, err := editor.Cmd("Glow", configFile) 41 | if err != nil { 42 | return fmt.Errorf("unable to set config file: %w", err) 43 | } 44 | c.Stdin = os.Stdin 45 | c.Stdout = os.Stdout 46 | c.Stderr = os.Stderr 47 | if err := c.Run(); err != nil { 48 | return fmt.Errorf("unable to run command: %w", err) 49 | } 50 | 51 | fmt.Println("Wrote config file to:", configFile) 52 | return nil 53 | }, 54 | } 55 | 56 | func ensureConfigFile() error { 57 | if configFile == "" { 58 | configFile = viper.GetViper().ConfigFileUsed() 59 | if err := os.MkdirAll(filepath.Dir(configFile), 0o755); err != nil { //nolint:gosec 60 | return fmt.Errorf("could not write configuration file: %w", err) 61 | } 62 | } 63 | 64 | if ext := path.Ext(configFile); ext != ".yaml" && ext != ".yml" { 65 | return fmt.Errorf("'%s' is not a supported configuration type: use '%s' or '%s'", ext, ".yaml", ".yml") 66 | } 67 | 68 | if _, err := os.Stat(configFile); errors.Is(err, fs.ErrNotExist) { 69 | // File doesn't exist yet, create all necessary directories and 70 | // write the default config file 71 | if err := os.MkdirAll(filepath.Dir(configFile), 0o700); err != nil { 72 | return fmt.Errorf("unable create directory: %w", err) 73 | } 74 | 75 | f, err := os.Create(configFile) 76 | if err != nil { 77 | return fmt.Errorf("unable to create config file: %w", err) 78 | } 79 | defer func() { _ = f.Close() }() 80 | 81 | if _, err := f.WriteString(defaultConfig); err != nil { 82 | return fmt.Errorf("unable to write config file: %w", err) 83 | } 84 | } else if err != nil { // some other error occurred 85 | return fmt.Errorf("unable to stat config file: %w", err) 86 | } 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /console_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | 8 | "golang.org/x/sys/windows" 9 | ) 10 | 11 | // enableAnsiColors enables support for ANSI color sequences in Windows 12 | // default console. Note that this only works with Windows 10. 13 | func enableAnsiColors() { 14 | stdout := windows.Handle(os.Stdout.Fd()) 15 | var originalMode uint32 16 | 17 | windows.GetConsoleMode(stdout, &originalMode) 18 | windows.SetConsoleMode(stdout, originalMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) 19 | } 20 | 21 | func init() { 22 | enableAnsiColors() 23 | } 24 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/glow/e54618ef13963dd58e43fee87a1db7a9ad182993/example.png -------------------------------------------------------------------------------- /github.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | ) 12 | 13 | // findGitHubREADME tries to find the correct README filename in a repository using GitHub API. 14 | func findGitHubREADME(u *url.URL) (*source, error) { 15 | owner, repo, ok := strings.Cut(strings.TrimPrefix(u.Path, "/"), "/") 16 | if !ok { 17 | return nil, fmt.Errorf("invalid url: %s", u.String()) 18 | } 19 | 20 | type readme struct { 21 | DownloadURL string `json:"download_url"` 22 | } 23 | 24 | apiURL := fmt.Sprintf("https://api.%s/repos/%s/%s/readme", u.Hostname(), owner, repo) 25 | 26 | //nolint:bodyclose 27 | // it is closed on the caller 28 | res, err := http.Get(apiURL) //nolint: gosec,noctx 29 | if err != nil { 30 | return nil, fmt.Errorf("unable to get url: %w", err) 31 | } 32 | 33 | body, err := io.ReadAll(res.Body) 34 | if err != nil { 35 | return nil, fmt.Errorf("unable to read http response body: %w", err) 36 | } 37 | 38 | var result readme 39 | if err := json.Unmarshal(body, &result); err != nil { 40 | return nil, fmt.Errorf("unable to parse json: %w", err) 41 | } 42 | 43 | if res.StatusCode == http.StatusOK { 44 | //nolint:bodyclose 45 | // it is closed on the caller 46 | resp, err := http.Get(result.DownloadURL) //nolint: noctx 47 | if err != nil { 48 | return nil, fmt.Errorf("unable to get url: %w", err) 49 | } 50 | 51 | if resp.StatusCode == http.StatusOK { 52 | return &source{resp.Body, result.DownloadURL}, nil 53 | } 54 | } 55 | 56 | return nil, errors.New("can't find README in GitHub repository") 57 | } 58 | -------------------------------------------------------------------------------- /gitlab.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | ) 12 | 13 | // findGitLabREADME tries to find the correct README filename in a repository using GitLab API. 14 | func findGitLabREADME(u *url.URL) (*source, error) { 15 | owner, repo, ok := strings.Cut(strings.TrimPrefix(u.Path, "/"), "/") 16 | if !ok { 17 | return nil, fmt.Errorf("invalid url: %s", u.String()) 18 | } 19 | 20 | projectPath := url.QueryEscape(owner + "/" + repo) 21 | 22 | type readme struct { 23 | ReadmeURL string `json:"readme_url"` 24 | } 25 | 26 | apiURL := fmt.Sprintf("https://%s/api/v4/projects/%s", u.Hostname(), projectPath) 27 | 28 | //nolint:bodyclose 29 | // it is closed on the caller 30 | res, err := http.Get(apiURL) //nolint: gosec,noctx 31 | if err != nil { 32 | return nil, fmt.Errorf("unable to get url: %w", err) 33 | } 34 | 35 | body, err := io.ReadAll(res.Body) 36 | if err != nil { 37 | return nil, fmt.Errorf("unable to read http response body: %w", err) 38 | } 39 | 40 | var result readme 41 | if err := json.Unmarshal(body, &result); err != nil { 42 | return nil, fmt.Errorf("unable to parse json: %w", err) 43 | } 44 | 45 | readmeRawURL := strings.ReplaceAll(result.ReadmeURL, "blob", "raw") 46 | 47 | if res.StatusCode == http.StatusOK { 48 | //nolint:bodyclose 49 | // it is closed on the caller 50 | resp, err := http.Get(readmeRawURL) //nolint: gosec,noctx 51 | if err != nil { 52 | return nil, fmt.Errorf("unable to get url: %w", err) 53 | } 54 | 55 | if resp.StatusCode == http.StatusOK { 56 | return &source{resp.Body, readmeRawURL}, nil 57 | } 58 | } 59 | 60 | return nil, errors.New("can't find README in GitLab repository") 61 | } 62 | -------------------------------------------------------------------------------- /glow_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGlowFlags(t *testing.T) { 8 | tt := []struct { 9 | args []string 10 | check func() bool 11 | }{ 12 | { 13 | args: []string{"-p"}, 14 | check: func() bool { 15 | return pager 16 | }, 17 | }, 18 | { 19 | args: []string{"-s", "light"}, 20 | check: func() bool { 21 | return style == "light" 22 | }, 23 | }, 24 | { 25 | args: []string{"-w", "40"}, 26 | check: func() bool { 27 | return width == 40 28 | }, 29 | }, 30 | } 31 | 32 | for _, v := range tt { 33 | err := rootCmd.ParseFlags(v.args) 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | if !v.check() { 38 | t.Errorf("Parsing flag failed: %s", v.args) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/charmbracelet/glow/v2 2 | 3 | go 1.23.6 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/atotto/clipboard v0.1.4 9 | github.com/caarlos0/env/v11 v11.3.1 10 | github.com/charmbracelet/bubbles v0.21.0 11 | github.com/charmbracelet/bubbletea v1.3.5 12 | github.com/charmbracelet/glamour v0.10.0 13 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 14 | github.com/charmbracelet/log v0.4.2 15 | github.com/charmbracelet/x/editor v0.1.0 16 | github.com/dustin/go-humanize v1.0.1 17 | github.com/fsnotify/fsnotify v1.9.0 18 | github.com/mattn/go-runewidth v0.0.16 19 | github.com/mitchellh/go-homedir v1.1.0 20 | github.com/muesli/gitcha v0.3.0 21 | github.com/muesli/go-app-paths v0.2.2 22 | github.com/muesli/mango-cobra v1.2.0 23 | github.com/muesli/reflow v0.3.0 24 | github.com/muesli/roff v0.1.0 25 | github.com/muesli/termenv v0.16.0 26 | github.com/sahilm/fuzzy v0.1.1 27 | github.com/spf13/cobra v1.9.1 28 | github.com/spf13/viper v1.20.1 29 | golang.org/x/sys v0.33.0 30 | golang.org/x/term v0.32.0 31 | golang.org/x/text v0.25.0 32 | ) 33 | 34 | require ( 35 | github.com/alecthomas/chroma/v2 v2.14.0 // indirect 36 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 37 | github.com/aymerick/douceur v0.2.0 // indirect 38 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 39 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 40 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 41 | github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect 42 | github.com/charmbracelet/x/term v0.2.1 // indirect 43 | github.com/dlclark/regexp2 v1.11.0 // indirect 44 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 45 | github.com/go-logfmt/logfmt v0.6.0 // indirect 46 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 47 | github.com/gorilla/css v1.0.1 // indirect 48 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 49 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 50 | github.com/mattn/go-isatty v0.0.20 // indirect 51 | github.com/mattn/go-localereader v0.0.1 // indirect 52 | github.com/microcosm-cc/bluemonday v1.0.27 // indirect 53 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 54 | github.com/muesli/cancelreader v0.2.2 // indirect 55 | github.com/muesli/mango v0.1.0 // indirect 56 | github.com/muesli/mango-pflag v0.1.0 // indirect 57 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 58 | github.com/rivo/uniseg v0.4.7 // indirect 59 | github.com/rogpeppe/go-internal v1.12.0 // indirect 60 | github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 // indirect 61 | github.com/sagikazarmark/locafero v0.7.0 // indirect 62 | github.com/sourcegraph/conc v0.3.0 // indirect 63 | github.com/spf13/afero v1.12.0 // indirect 64 | github.com/spf13/cast v1.7.1 // indirect 65 | github.com/spf13/pflag v1.0.6 // indirect 66 | github.com/subosito/gotenv v1.6.0 // indirect 67 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 68 | github.com/yuin/goldmark v1.7.8 // indirect 69 | github.com/yuin/goldmark-emoji v1.0.5 // indirect 70 | go.uber.org/atomic v1.9.0 // indirect 71 | go.uber.org/multierr v1.9.0 // indirect 72 | golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect 73 | golang.org/x/net v0.40.0 // indirect 74 | golang.org/x/sync v0.14.0 // indirect 75 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 76 | gopkg.in/yaml.v3 v3.0.1 // indirect 77 | ) 78 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= 2 | github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 3 | github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= 4 | github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= 5 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 6 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 7 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 8 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 9 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 10 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 11 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 12 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 13 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 14 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 15 | github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= 16 | github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= 17 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 18 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 19 | github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= 20 | github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= 21 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 22 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 23 | github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= 24 | github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= 25 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= 26 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= 27 | github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 28 | github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 29 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 30 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 31 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 32 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 33 | github.com/charmbracelet/x/editor v0.1.0 h1:p69/dpvlwRTs9uYiPeAWruwsHqTFzHhTvQOd/WVSX98= 34 | github.com/charmbracelet/x/editor v0.1.0/go.mod h1:oivrEbcP/AYt/Hpvk5pwDXXrQ933gQS6UzL6fxqAGSA= 35 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 36 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 37 | github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= 38 | github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= 39 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 40 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 41 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 42 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 43 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 44 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 45 | github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 46 | github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 47 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 48 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 49 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 50 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 51 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 52 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 53 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 54 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 55 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 56 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 57 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 58 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 59 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 60 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 61 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 62 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 63 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 64 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 65 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 66 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 67 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 68 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 69 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 70 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 71 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 72 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 73 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 74 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 75 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 76 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 77 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 78 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 79 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 80 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 81 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 82 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 83 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 84 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 85 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 86 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 87 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 88 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 89 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 90 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 91 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 92 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 93 | github.com/muesli/gitcha v0.3.0 h1:+PJkVKrDXVB0VgRn/yVx2CqSVSDGMSepzvohsCrPYtQ= 94 | github.com/muesli/gitcha v0.3.0/go.mod h1:vX3jFL+XcEUq1uY74RCjLSZfAV+ZuvLg70/NGPdXn84= 95 | github.com/muesli/go-app-paths v0.2.2 h1:NqG4EEZwNIhBq/pREgfBmgDmt3h1Smr1MjZiXbpZUnI= 96 | github.com/muesli/go-app-paths v0.2.2/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho= 97 | github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= 98 | github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= 99 | github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= 100 | github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= 101 | github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= 102 | github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= 103 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 104 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 105 | github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= 106 | github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 107 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 108 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 109 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 110 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 111 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 112 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 113 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 114 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 115 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 116 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 117 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 118 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 119 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 120 | github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 h1:G04eS0JkAIVZfaJLjla9dNxkJCPiKIGZlw9AfOhzOD0= 121 | github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94/go.mod h1:b18R55ulyQ/h3RaWyloPyER7fWQVZvimKKhnI5OfrJQ= 122 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 123 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 124 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 125 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 126 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 127 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 128 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 129 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 130 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 131 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 132 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 133 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 134 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 135 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 136 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 137 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 138 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 139 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 140 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 141 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 142 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 143 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 144 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 145 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 146 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 147 | github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 148 | github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= 149 | github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 150 | github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= 151 | github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 152 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 153 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 154 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 155 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 156 | golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM= 157 | golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= 158 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 159 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 160 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 161 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 162 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 163 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 164 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 165 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 166 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 167 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 168 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 169 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 170 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 171 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 172 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 173 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 174 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 175 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 176 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/charmbracelet/log" 10 | gap "github.com/muesli/go-app-paths" 11 | ) 12 | 13 | func getLogFilePath() (string, error) { 14 | dir, err := gap.NewScope(gap.User, "glow").CacheDir() 15 | if err != nil { 16 | return "", fmt.Errorf("unable to get cache dir: %w", err) 17 | } 18 | return filepath.Join(dir, "glow.log"), nil 19 | } 20 | 21 | func setupLog() (func() error, error) { 22 | log.SetOutput(io.Discard) 23 | // Log to file, if set 24 | logFile, err := getLogFilePath() 25 | if err != nil { 26 | return nil, err 27 | } 28 | if err := os.MkdirAll(filepath.Dir(logFile), 0o755); err != nil { //nolint:gosec 29 | // log disabled 30 | return func() error { return nil }, nil //nolint:nilerr 31 | } 32 | f, err := os.OpenFile(logFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644) //nolint:gosec 33 | if err != nil { 34 | // log disabled 35 | return func() error { return nil }, nil //nolint:nilerr 36 | } 37 | log.SetOutput(f) 38 | log.SetLevel(log.DebugLevel) 39 | return f.Close, nil 40 | } 41 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Package main provides the entry point for the Glow CLI application. 2 | package main 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "io" 8 | "io/fs" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "os/exec" 13 | "path/filepath" 14 | "strings" 15 | 16 | "github.com/caarlos0/env/v11" 17 | "github.com/charmbracelet/glamour" 18 | "github.com/charmbracelet/glamour/styles" 19 | "github.com/charmbracelet/glow/v2/ui" 20 | "github.com/charmbracelet/glow/v2/utils" 21 | "github.com/charmbracelet/lipgloss" 22 | "github.com/charmbracelet/log" 23 | gap "github.com/muesli/go-app-paths" 24 | "github.com/spf13/cobra" 25 | "github.com/spf13/viper" 26 | "golang.org/x/term" 27 | ) 28 | 29 | var ( 30 | // Version as provided by goreleaser. 31 | Version = "" 32 | // CommitSHA as provided by goreleaser. 33 | CommitSHA = "" 34 | 35 | readmeNames = []string{"README.md", "README", "Readme.md", "Readme", "readme.md", "readme"} 36 | configFile string 37 | pager bool 38 | tui bool 39 | style string 40 | width uint 41 | showAllFiles bool 42 | showLineNumbers bool 43 | preserveNewLines bool 44 | mouse bool 45 | 46 | rootCmd = &cobra.Command{ 47 | Use: "glow [SOURCE|DIR]", 48 | Short: "Render markdown on the CLI, with pizzazz!", 49 | Long: paragraph( 50 | fmt.Sprintf("\nRender markdown on the CLI, %s!", keyword("with pizzazz")), 51 | ), 52 | SilenceErrors: false, 53 | SilenceUsage: true, 54 | TraverseChildren: true, 55 | Args: cobra.MaximumNArgs(1), 56 | ValidArgsFunction: func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { 57 | return nil, cobra.ShellCompDirectiveDefault 58 | }, 59 | PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { 60 | return validateOptions(cmd) 61 | }, 62 | RunE: execute, 63 | } 64 | ) 65 | 66 | // source provides a readable markdown source. 67 | type source struct { 68 | reader io.ReadCloser 69 | URL string 70 | } 71 | 72 | // sourceFromArg parses an argument and creates a readable source for it. 73 | func sourceFromArg(arg string) (*source, error) { 74 | // from stdin 75 | if arg == "-" { 76 | return &source{reader: os.Stdin}, nil 77 | } 78 | 79 | // a GitHub or GitLab URL (even without the protocol): 80 | src, err := readmeURL(arg) 81 | if src != nil && err == nil { 82 | // if there's an error, try next methods... 83 | return src, nil 84 | } 85 | 86 | // HTTP(S) URLs: 87 | if u, err := url.ParseRequestURI(arg); err == nil && strings.Contains(arg, "://") { //nolint:nestif 88 | if u.Scheme != "" { 89 | if u.Scheme != "http" && u.Scheme != "https" { 90 | return nil, fmt.Errorf("%s is not a supported protocol", u.Scheme) 91 | } 92 | // consumer of the source is responsible for closing the ReadCloser. 93 | resp, err := http.Get(u.String()) //nolint: noctx,bodyclose 94 | if err != nil { 95 | return nil, fmt.Errorf("unable to get url: %w", err) 96 | } 97 | if resp.StatusCode != http.StatusOK { 98 | return nil, fmt.Errorf("HTTP status %d", resp.StatusCode) 99 | } 100 | return &source{resp.Body, u.String()}, nil 101 | } 102 | } 103 | 104 | // a directory: 105 | if len(arg) == 0 { 106 | // use the current working dir if no argument was supplied 107 | arg = "." 108 | } 109 | st, err := os.Stat(arg) 110 | if err == nil && st.IsDir() { //nolint:nestif 111 | var src *source 112 | _ = filepath.Walk(arg, func(path string, _ os.FileInfo, err error) error { 113 | if err != nil { 114 | return err 115 | } 116 | for _, v := range readmeNames { 117 | if strings.EqualFold(filepath.Base(path), v) { 118 | r, err := os.Open(path) 119 | if err != nil { 120 | continue 121 | } 122 | 123 | u, _ := filepath.Abs(path) 124 | src = &source{r, u} 125 | 126 | // abort filepath.Walk 127 | return errors.New("source found") 128 | } 129 | } 130 | return nil 131 | }) 132 | 133 | if src != nil { 134 | return src, nil 135 | } 136 | 137 | return nil, errors.New("missing markdown source") 138 | } 139 | 140 | r, err := os.Open(arg) 141 | if err != nil { 142 | return nil, fmt.Errorf("unable to open file: %w", err) 143 | } 144 | u, err := filepath.Abs(arg) 145 | if err != nil { 146 | return nil, fmt.Errorf("unable to get absolute path: %w", err) 147 | } 148 | return &source{r, u}, nil 149 | } 150 | 151 | // validateStyle checks if the style is a default style, if not, checks that 152 | // the custom style exists. 153 | func validateStyle(style string) error { 154 | if style != "auto" && styles.DefaultStyles[style] == nil { 155 | style = utils.ExpandPath(style) 156 | if _, err := os.Stat(style); errors.Is(err, fs.ErrNotExist) { 157 | return fmt.Errorf("specified style does not exist: %s", style) 158 | } else if err != nil { 159 | return fmt.Errorf("unable to stat file: %w", err) 160 | } 161 | } 162 | return nil 163 | } 164 | 165 | func validateOptions(cmd *cobra.Command) error { 166 | // grab config values from Viper 167 | width = viper.GetUint("width") 168 | mouse = viper.GetBool("mouse") 169 | pager = viper.GetBool("pager") 170 | tui = viper.GetBool("tui") 171 | showAllFiles = viper.GetBool("all") 172 | preserveNewLines = viper.GetBool("preserveNewLines") 173 | 174 | if pager && tui { 175 | return errors.New("cannot use both pager and tui") 176 | } 177 | 178 | // validate the glamour style 179 | style = viper.GetString("style") 180 | if err := validateStyle(style); err != nil { 181 | return err 182 | } 183 | 184 | isTerminal := term.IsTerminal(int(os.Stdout.Fd())) 185 | // We want to use a special no-TTY style, when stdout is not a terminal 186 | // and there was no specific style passed by arg 187 | if !isTerminal && !cmd.Flags().Changed("style") { 188 | style = "notty" 189 | } 190 | 191 | // Detect terminal width 192 | if !cmd.Flags().Changed("width") { //nolint:nestif 193 | if isTerminal && width == 0 { 194 | w, _, err := term.GetSize(int(os.Stdout.Fd())) 195 | if err == nil { 196 | width = uint(w) //nolint:gosec 197 | } 198 | 199 | if width > 120 { 200 | width = 120 201 | } 202 | } 203 | if width == 0 { 204 | width = 80 205 | } 206 | } 207 | return nil 208 | } 209 | 210 | func stdinIsPipe() (bool, error) { 211 | stat, err := os.Stdin.Stat() 212 | if err != nil { 213 | return false, fmt.Errorf("unable to open file: %w", err) 214 | } 215 | if stat.Mode()&os.ModeCharDevice == 0 || stat.Size() > 0 { 216 | return true, nil 217 | } 218 | return false, nil 219 | } 220 | 221 | func execute(cmd *cobra.Command, args []string) error { 222 | // if stdin is a pipe then use stdin for input. note that you can also 223 | // explicitly use a - to read from stdin. 224 | if yes, err := stdinIsPipe(); err != nil { 225 | return err 226 | } else if yes { 227 | src := &source{reader: os.Stdin} 228 | defer src.reader.Close() //nolint:errcheck 229 | return executeCLI(cmd, src, os.Stdout) 230 | } 231 | 232 | switch len(args) { 233 | // TUI running on cwd 234 | case 0: 235 | return runTUI("", "") 236 | 237 | // TUI with possible dir argument 238 | case 1: 239 | // Validate that the argument is a directory. If it's not treat it as 240 | // an argument to the non-TUI version of Glow (via fallthrough). 241 | info, err := os.Stat(args[0]) 242 | if err == nil && info.IsDir() { 243 | p, err := filepath.Abs(args[0]) 244 | if err == nil { 245 | return runTUI(p, "") 246 | } 247 | } 248 | fallthrough 249 | 250 | // CLI 251 | default: 252 | for _, arg := range args { 253 | if err := executeArg(cmd, arg, os.Stdout); err != nil { 254 | return err 255 | } 256 | } 257 | } 258 | 259 | return nil 260 | } 261 | 262 | func executeArg(cmd *cobra.Command, arg string, w io.Writer) error { 263 | // create an io.Reader from the markdown source in cli-args 264 | src, err := sourceFromArg(arg) 265 | if err != nil { 266 | return err 267 | } 268 | defer src.reader.Close() //nolint:errcheck 269 | return executeCLI(cmd, src, w) 270 | } 271 | 272 | func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error { 273 | b, err := io.ReadAll(src.reader) 274 | if err != nil { 275 | return fmt.Errorf("unable to read from reader: %w", err) 276 | } 277 | 278 | b = utils.RemoveFrontmatter(b) 279 | 280 | // render 281 | var baseURL string 282 | u, err := url.ParseRequestURI(src.URL) 283 | if err == nil { 284 | u.Path = filepath.Dir(u.Path) 285 | baseURL = u.String() + "/" 286 | } 287 | 288 | isCode := !utils.IsMarkdownFile(src.URL) 289 | 290 | // initialize glamour 291 | r, err := glamour.NewTermRenderer( 292 | glamour.WithColorProfile(lipgloss.ColorProfile()), 293 | utils.GlamourStyle(style, isCode), 294 | glamour.WithWordWrap(int(width)), //nolint:gosec 295 | glamour.WithBaseURL(baseURL), 296 | glamour.WithPreservedNewLines(), 297 | ) 298 | if err != nil { 299 | return fmt.Errorf("unable to create renderer: %w", err) 300 | } 301 | 302 | content := string(b) 303 | ext := filepath.Ext(src.URL) 304 | if isCode { 305 | content = utils.WrapCodeBlock(string(b), ext) 306 | } 307 | 308 | out, err := r.Render(content) 309 | if err != nil { 310 | return fmt.Errorf("unable to render markdown: %w", err) 311 | } 312 | 313 | // display 314 | switch { 315 | case pager || cmd.Flags().Changed("pager"): 316 | pagerCmd := os.Getenv("PAGER") 317 | if pagerCmd == "" { 318 | pagerCmd = "less -r" 319 | } 320 | 321 | pa := strings.Split(pagerCmd, " ") 322 | c := exec.Command(pa[0], pa[1:]...) //nolint:gosec 323 | c.Stdin = strings.NewReader(out) 324 | c.Stdout = os.Stdout 325 | if err := c.Run(); err != nil { 326 | return fmt.Errorf("unable to run command: %w", err) 327 | } 328 | return nil 329 | case tui || cmd.Flags().Changed("tui"): 330 | path := "" 331 | if !isURL(src.URL) { 332 | path = src.URL 333 | } 334 | return runTUI(path, content) 335 | default: 336 | if _, err = fmt.Fprint(w, out); err != nil { 337 | return fmt.Errorf("unable to write to writer: %w", err) 338 | } 339 | return nil 340 | } 341 | } 342 | 343 | func runTUI(path string, content string) error { 344 | // Read environment to get debugging stuff 345 | cfg, err := env.ParseAs[ui.Config]() 346 | if err != nil { 347 | return fmt.Errorf("error parsing config: %v", err) 348 | } 349 | 350 | // use style set in env, or auto if unset 351 | if err := validateStyle(cfg.GlamourStyle); err != nil { 352 | cfg.GlamourStyle = style 353 | } 354 | 355 | cfg.Path = path 356 | cfg.ShowAllFiles = showAllFiles 357 | cfg.ShowLineNumbers = showLineNumbers 358 | cfg.GlamourMaxWidth = width 359 | cfg.EnableMouse = mouse 360 | cfg.PreserveNewLines = preserveNewLines 361 | 362 | // Run Bubble Tea program 363 | if _, err := ui.NewProgram(cfg, content).Run(); err != nil { 364 | return fmt.Errorf("unable to run tui program: %w", err) 365 | } 366 | 367 | return nil 368 | } 369 | 370 | func main() { 371 | closer, err := setupLog() 372 | if err != nil { 373 | fmt.Println(err) 374 | os.Exit(1) 375 | } 376 | if err := rootCmd.Execute(); err != nil { 377 | _ = closer() 378 | os.Exit(1) 379 | } 380 | _ = closer() 381 | } 382 | 383 | func init() { 384 | tryLoadConfigFromDefaultPlaces() 385 | if len(CommitSHA) >= 7 { 386 | vt := rootCmd.VersionTemplate() 387 | rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n") 388 | } 389 | if Version == "" { 390 | Version = "unknown (built from source)" 391 | } 392 | rootCmd.Version = Version 393 | rootCmd.InitDefaultCompletionCmd() 394 | 395 | // "Glow Classic" cli arguments 396 | rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default %s)", viper.GetViper().ConfigFileUsed())) 397 | rootCmd.Flags().BoolVarP(&pager, "pager", "p", false, "display with pager") 398 | rootCmd.Flags().BoolVarP(&tui, "tui", "t", false, "display with tui") 399 | rootCmd.Flags().StringVarP(&style, "style", "s", styles.AutoStyle, "style name or JSON path") 400 | rootCmd.Flags().UintVarP(&width, "width", "w", 0, "word-wrap at width (set to 0 to disable)") 401 | rootCmd.Flags().BoolVarP(&showAllFiles, "all", "a", false, "show system files and directories (TUI-mode only)") 402 | rootCmd.Flags().BoolVarP(&showLineNumbers, "line-numbers", "l", false, "show line numbers (TUI-mode only)") 403 | rootCmd.Flags().BoolVarP(&preserveNewLines, "preserve-new-lines", "n", false, "preserve newlines in the output") 404 | rootCmd.Flags().BoolVarP(&mouse, "mouse", "m", false, "enable mouse wheel (TUI-mode only)") 405 | _ = rootCmd.Flags().MarkHidden("mouse") 406 | 407 | // Config bindings 408 | _ = viper.BindPFlag("pager", rootCmd.Flags().Lookup("pager")) 409 | _ = viper.BindPFlag("tui", rootCmd.Flags().Lookup("tui")) 410 | _ = viper.BindPFlag("style", rootCmd.Flags().Lookup("style")) 411 | _ = viper.BindPFlag("width", rootCmd.Flags().Lookup("width")) 412 | _ = viper.BindPFlag("debug", rootCmd.Flags().Lookup("debug")) 413 | _ = viper.BindPFlag("mouse", rootCmd.Flags().Lookup("mouse")) 414 | _ = viper.BindPFlag("preserveNewLines", rootCmd.Flags().Lookup("preserve-new-lines")) 415 | _ = viper.BindPFlag("showLineNumbers", rootCmd.Flags().Lookup("line-numbers")) 416 | _ = viper.BindPFlag("all", rootCmd.Flags().Lookup("all")) 417 | 418 | viper.SetDefault("style", styles.AutoStyle) 419 | viper.SetDefault("width", 0) 420 | viper.SetDefault("all", true) 421 | 422 | rootCmd.AddCommand(configCmd, manCmd) 423 | } 424 | 425 | func tryLoadConfigFromDefaultPlaces() { 426 | scope := gap.NewScope(gap.User, "glow") 427 | dirs, err := scope.ConfigDirs() 428 | if err != nil { 429 | fmt.Println("Could not load find configuration directory.") 430 | os.Exit(1) 431 | } 432 | 433 | if c := os.Getenv("XDG_CONFIG_HOME"); c != "" { 434 | dirs = append([]string{filepath.Join(c, "glow")}, dirs...) 435 | } 436 | 437 | if c := os.Getenv("GLOW_CONFIG_HOME"); c != "" { 438 | dirs = append([]string{c}, dirs...) 439 | } 440 | 441 | for _, v := range dirs { 442 | viper.AddConfigPath(v) 443 | } 444 | 445 | viper.SetConfigName("glow") 446 | viper.SetConfigType("yaml") 447 | viper.SetEnvPrefix("glow") 448 | viper.AutomaticEnv() 449 | 450 | if err := viper.ReadInConfig(); err != nil { 451 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 452 | log.Warn("Could not parse configuration file", "err", err) 453 | } 454 | } 455 | 456 | if used := viper.ConfigFileUsed(); used != "" { 457 | log.Debug("Using configuration file", "path", viper.ConfigFileUsed()) 458 | return 459 | } 460 | 461 | if viper.ConfigFileUsed() == "" { 462 | configFile = filepath.Join(dirs[0], "glow.yml") 463 | } 464 | if err := ensureConfigFile(); err != nil { 465 | log.Error("Could not create default configuration", "error", err) 466 | } 467 | } 468 | -------------------------------------------------------------------------------- /man_cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | mcobra "github.com/muesli/mango-cobra" 8 | "github.com/muesli/roff" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var manCmd = &cobra.Command{ 13 | Use: "man", 14 | Short: "Generates manpages", 15 | SilenceUsage: true, 16 | DisableFlagsInUseLine: true, 17 | Hidden: true, 18 | Args: cobra.NoArgs, 19 | RunE: func(*cobra.Command, []string) error { 20 | manPage, err := mcobra.NewManPage(1, rootCmd) 21 | if err != nil { 22 | return fmt.Errorf("unable to instantiate man page: %w", err) 23 | } 24 | if _, err := fmt.Fprint(os.Stdout, manPage.Build(roff.NewDocument())); err != nil { 25 | return fmt.Errorf("unable to build man page: %w", err) 26 | } 27 | return nil 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /style.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | var ( 6 | keyword = lipgloss.NewStyle(). 7 | Foreground(lipgloss.Color("#04B575")). 8 | Render 9 | 10 | paragraph = lipgloss.NewStyle(). 11 | Width(78). 12 | Padding(0, 0, 0, 2). 13 | Render 14 | ) 15 | -------------------------------------------------------------------------------- /ui/config.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | // Config contains TUI-specific configuration. 4 | type Config struct { 5 | ShowAllFiles bool 6 | ShowLineNumbers bool 7 | Gopath string `env:"GOPATH"` 8 | HomeDir string `env:"HOME"` 9 | GlamourMaxWidth uint 10 | GlamourStyle string `env:"GLAMOUR_STYLE"` 11 | EnableMouse bool 12 | PreserveNewLines bool 13 | 14 | // Working directory or file path 15 | Path string 16 | 17 | // For debugging the UI 18 | HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" envDefault:"true"` 19 | GlamourEnabled bool `env:"GLOW_ENABLE_GLAMOUR" envDefault:"true"` 20 | } 21 | -------------------------------------------------------------------------------- /ui/editor.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/x/editor" 6 | ) 7 | 8 | type editorFinishedMsg struct{ err error } 9 | 10 | func openEditor(path string, lineno int) tea.Cmd { 11 | cb := func(err error) tea.Msg { 12 | return editorFinishedMsg{err} 13 | } 14 | cmd, err := editor.Cmd("Glow", path, editor.LineNumber(uint(lineno))) //nolint:gosec 15 | if err != nil { 16 | return func() tea.Msg { return cb(err) } 17 | } 18 | return tea.ExecProcess(cmd, cb) 19 | } 20 | -------------------------------------------------------------------------------- /ui/ignore_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | // +build darwin 3 | 4 | package ui 5 | 6 | import "path/filepath" 7 | 8 | func ignorePatterns(m commonModel) []string { 9 | return []string{ 10 | filepath.Join(m.cfg.HomeDir, "Library"), 11 | m.cfg.Gopath, 12 | "node_modules", 13 | ".*", 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ui/ignore_general.go: -------------------------------------------------------------------------------- 1 | //go:build !darwin 2 | // +build !darwin 3 | 4 | package ui 5 | 6 | func ignorePatterns(m commonModel) []string { 7 | return []string{ 8 | m.cfg.Gopath, 9 | "node_modules", 10 | ".*", 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /ui/keys.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | const ( 4 | keyEnter = "enter" 5 | keyEsc = "esc" 6 | ) 7 | -------------------------------------------------------------------------------- /ui/markdown.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "time" 7 | "unicode" 8 | 9 | "github.com/charmbracelet/log" 10 | "github.com/dustin/go-humanize" 11 | "golang.org/x/text/runes" 12 | "golang.org/x/text/transform" 13 | "golang.org/x/text/unicode/norm" 14 | ) 15 | 16 | type markdown struct { 17 | // Full path of a local markdown file. Only relevant to local documents and 18 | // those that have been stashed in this session. 19 | localPath string 20 | 21 | // Value we filter against. This exists so that we can maintain positions 22 | // of filtered items if notes are edited while a filter is active. This 23 | // field is ephemeral, and should only be referenced during filtering. 24 | filterValue string 25 | 26 | Body string 27 | Note string 28 | Modtime time.Time 29 | } 30 | 31 | // Generate the value we're doing to filter against. 32 | func (m *markdown) buildFilterValue() { 33 | note, err := normalize(m.Note) 34 | if err != nil { 35 | log.Error("error normalizing", "note", m.Note, "error", err) 36 | m.filterValue = m.Note 37 | } 38 | 39 | m.filterValue = note 40 | } 41 | 42 | func (m markdown) relativeTime() string { 43 | return relativeTime(m.Modtime) 44 | } 45 | 46 | // Normalize text to aid in the filtering process. In particular, we remove 47 | // diacritics, "ö" becomes "o". Note that Mn is the unicode key for nonspacing 48 | // marks. 49 | func normalize(in string) (string, error) { 50 | t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) 51 | out, _, err := transform.String(t, in) 52 | if err != nil { 53 | return "", fmt.Errorf("error normalizing: %w", err) 54 | } 55 | return out, nil 56 | } 57 | 58 | // Return the time in a human-readable format relative to the current time. 59 | func relativeTime(then time.Time) string { 60 | now := time.Now() 61 | if ago := now.Sub(then); ago < time.Minute { 62 | return "just now" 63 | } else if ago < humanize.Week { 64 | return humanize.CustomRelTime(then, now, "ago", "from now", magnitudes) 65 | } 66 | return then.Format("02 Jan 2006 15:04 MST") 67 | } 68 | 69 | // Magnitudes for relative time. 70 | var magnitudes = []humanize.RelTimeMagnitude{ 71 | {D: time.Second, Format: "now", DivBy: time.Second}, 72 | {D: 2 * time.Second, Format: "1 second %s", DivBy: 1}, 73 | {D: time.Minute, Format: "%d seconds %s", DivBy: time.Second}, 74 | {D: 2 * time.Minute, Format: "1 minute %s", DivBy: 1}, 75 | {D: time.Hour, Format: "%d minutes %s", DivBy: time.Minute}, 76 | {D: 2 * time.Hour, Format: "1 hour %s", DivBy: 1}, 77 | {D: humanize.Day, Format: "%d hours %s", DivBy: time.Hour}, 78 | {D: 2 * humanize.Day, Format: "1 day %s", DivBy: 1}, 79 | {D: humanize.Week, Format: "%d days %s", DivBy: humanize.Day}, 80 | {D: 2 * humanize.Week, Format: "1 week %s", DivBy: 1}, 81 | {D: humanize.Month, Format: "%d weeks %s", DivBy: humanize.Week}, 82 | {D: 2 * humanize.Month, Format: "1 month %s", DivBy: 1}, 83 | {D: humanize.Year, Format: "%d months %s", DivBy: humanize.Month}, 84 | {D: 18 * humanize.Month, Format: "1 year %s", DivBy: 1}, 85 | {D: 2 * humanize.Year, Format: "2 years %s", DivBy: 1}, 86 | {D: humanize.LongTime, Format: "%d years %s", DivBy: humanize.Year}, 87 | {D: math.MaxInt64, Format: "a long while %s", DivBy: 1}, 88 | } 89 | -------------------------------------------------------------------------------- /ui/pager.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | 10 | "github.com/atotto/clipboard" 11 | "github.com/charmbracelet/bubbles/viewport" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/charmbracelet/glamour" 14 | "github.com/charmbracelet/glow/v2/utils" 15 | "github.com/charmbracelet/lipgloss" 16 | "github.com/charmbracelet/log" 17 | "github.com/fsnotify/fsnotify" 18 | runewidth "github.com/mattn/go-runewidth" 19 | "github.com/muesli/reflow/ansi" 20 | "github.com/muesli/reflow/truncate" 21 | "github.com/muesli/termenv" 22 | ) 23 | 24 | const ( 25 | statusBarHeight = 1 26 | lineNumberWidth = 4 27 | ) 28 | 29 | var ( 30 | pagerHelpHeight int 31 | 32 | mintGreen = lipgloss.AdaptiveColor{Light: "#89F0CB", Dark: "#89F0CB"} 33 | darkGreen = lipgloss.AdaptiveColor{Light: "#1C8760", Dark: "#1C8760"} 34 | 35 | lineNumberFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"} 36 | 37 | statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"} 38 | statusBarBg = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"} 39 | 40 | statusBarScrollPosStyle = lipgloss.NewStyle(). 41 | Foreground(lipgloss.AdaptiveColor{Light: "#949494", Dark: "#5A5A5A"}). 42 | Background(statusBarBg). 43 | Render 44 | 45 | statusBarNoteStyle = lipgloss.NewStyle(). 46 | Foreground(statusBarNoteFg). 47 | Background(statusBarBg). 48 | Render 49 | 50 | statusBarHelpStyle = lipgloss.NewStyle(). 51 | Foreground(statusBarNoteFg). 52 | Background(lipgloss.AdaptiveColor{Light: "#DCDCDC", Dark: "#323232"}). 53 | Render 54 | 55 | statusBarMessageStyle = lipgloss.NewStyle(). 56 | Foreground(mintGreen). 57 | Background(darkGreen). 58 | Render 59 | 60 | statusBarMessageScrollPosStyle = lipgloss.NewStyle(). 61 | Foreground(mintGreen). 62 | Background(darkGreen). 63 | Render 64 | 65 | statusBarMessageHelpStyle = lipgloss.NewStyle(). 66 | Foreground(lipgloss.Color("#B6FFE4")). 67 | Background(green). 68 | Render 69 | 70 | helpViewStyle = lipgloss.NewStyle(). 71 | Foreground(statusBarNoteFg). 72 | Background(lipgloss.AdaptiveColor{Light: "#f2f2f2", Dark: "#1B1B1B"}). 73 | Render 74 | 75 | lineNumberStyle = lipgloss.NewStyle(). 76 | Foreground(lineNumberFg). 77 | Render 78 | ) 79 | 80 | type ( 81 | contentRenderedMsg string 82 | reloadMsg struct{} 83 | ) 84 | 85 | type pagerState int 86 | 87 | const ( 88 | pagerStateBrowse pagerState = iota 89 | pagerStateStatusMessage 90 | ) 91 | 92 | type pagerModel struct { 93 | common *commonModel 94 | viewport viewport.Model 95 | state pagerState 96 | showHelp bool 97 | 98 | statusMessage string 99 | statusMessageTimer *time.Timer 100 | 101 | // Current document being rendered, sans-glamour rendering. We cache 102 | // it here so we can re-render it on resize. 103 | currentDocument markdown 104 | 105 | watcher *fsnotify.Watcher 106 | } 107 | 108 | func newPagerModel(common *commonModel) pagerModel { 109 | // Init viewport 110 | vp := viewport.New(0, 0) 111 | vp.YPosition = 0 112 | vp.HighPerformanceRendering = config.HighPerformancePager 113 | 114 | m := pagerModel{ 115 | common: common, 116 | state: pagerStateBrowse, 117 | viewport: vp, 118 | } 119 | m.initWatcher() 120 | return m 121 | } 122 | 123 | func (m *pagerModel) setSize(w, h int) { 124 | m.viewport.Width = w 125 | m.viewport.Height = h - statusBarHeight 126 | 127 | if m.showHelp { 128 | if pagerHelpHeight == 0 { 129 | pagerHelpHeight = strings.Count(m.helpView(), "\n") 130 | } 131 | m.viewport.Height -= (statusBarHeight + pagerHelpHeight) 132 | } 133 | } 134 | 135 | func (m *pagerModel) setContent(s string) { 136 | m.viewport.SetContent(s) 137 | } 138 | 139 | func (m *pagerModel) toggleHelp() { 140 | m.showHelp = !m.showHelp 141 | m.setSize(m.common.width, m.common.height) 142 | if m.viewport.PastBottom() { 143 | m.viewport.GotoBottom() 144 | } 145 | } 146 | 147 | type pagerStatusMessage struct { 148 | message string 149 | isError bool 150 | } 151 | 152 | // Perform stuff that needs to happen after a successful markdown stash. Note 153 | // that the the returned command should be sent back the through the pager 154 | // update function. 155 | func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd { 156 | // Show a success message to the user 157 | m.state = pagerStateStatusMessage 158 | m.statusMessage = msg.message 159 | if m.statusMessageTimer != nil { 160 | m.statusMessageTimer.Stop() 161 | } 162 | m.statusMessageTimer = time.NewTimer(statusMessageTimeout) 163 | 164 | return waitForStatusMessageTimeout(pagerContext, m.statusMessageTimer) 165 | } 166 | 167 | func (m *pagerModel) unload() { 168 | log.Debug("unload") 169 | if m.showHelp { 170 | m.toggleHelp() 171 | } 172 | if m.statusMessageTimer != nil { 173 | m.statusMessageTimer.Stop() 174 | } 175 | m.state = pagerStateBrowse 176 | m.viewport.SetContent("") 177 | m.viewport.YOffset = 0 178 | m.unwatchFile() 179 | } 180 | 181 | func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { 182 | var ( 183 | cmd tea.Cmd 184 | cmds []tea.Cmd 185 | ) 186 | 187 | switch msg := msg.(type) { 188 | case tea.KeyMsg: 189 | switch msg.String() { 190 | case "q", keyEsc: 191 | if m.state != pagerStateBrowse { 192 | m.state = pagerStateBrowse 193 | return m, nil 194 | } 195 | case "home", "g": 196 | m.viewport.GotoTop() 197 | if m.viewport.HighPerformanceRendering { 198 | cmds = append(cmds, viewport.Sync(m.viewport)) 199 | } 200 | case "end", "G": 201 | m.viewport.GotoBottom() 202 | if m.viewport.HighPerformanceRendering { 203 | cmds = append(cmds, viewport.Sync(m.viewport)) 204 | } 205 | 206 | case "d": 207 | m.viewport.HalfViewDown() 208 | if m.viewport.HighPerformanceRendering { 209 | cmds = append(cmds, viewport.Sync(m.viewport)) 210 | } 211 | 212 | case "u": 213 | m.viewport.HalfViewUp() 214 | if m.viewport.HighPerformanceRendering { 215 | cmds = append(cmds, viewport.Sync(m.viewport)) 216 | } 217 | 218 | case "e": 219 | lineno := int(math.RoundToEven(float64(m.viewport.TotalLineCount()) * m.viewport.ScrollPercent())) 220 | if m.viewport.AtTop() { 221 | lineno = 0 222 | } 223 | log.Info( 224 | "opening editor", 225 | "file", m.currentDocument.localPath, 226 | "line", fmt.Sprintf("%d/%d", lineno, m.viewport.TotalLineCount()), 227 | ) 228 | return m, openEditor(m.currentDocument.localPath, lineno) 229 | 230 | case "c": 231 | // Copy using OSC 52 232 | termenv.Copy(m.currentDocument.Body) 233 | // Copy using native system clipboard 234 | _ = clipboard.WriteAll(m.currentDocument.Body) 235 | cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Copied contents", false})) 236 | 237 | case "r": 238 | return m, loadLocalMarkdown(&m.currentDocument) 239 | 240 | case "?": 241 | m.toggleHelp() 242 | if m.viewport.HighPerformanceRendering { 243 | cmds = append(cmds, viewport.Sync(m.viewport)) 244 | } 245 | } 246 | 247 | // Glow has rendered the content 248 | case contentRenderedMsg: 249 | log.Info("content rendered", "state", m.state) 250 | 251 | m.setContent(string(msg)) 252 | if m.viewport.HighPerformanceRendering { 253 | cmds = append(cmds, viewport.Sync(m.viewport)) 254 | } 255 | cmds = append(cmds, m.watchFile) 256 | 257 | // The file was changed on disk and we're reloading it 258 | case reloadMsg: 259 | return m, loadLocalMarkdown(&m.currentDocument) 260 | 261 | // We've finished editing the document, potentially making changes. Let's 262 | // retrieve the latest version of the document so that we display 263 | // up-to-date contents. 264 | case editorFinishedMsg: 265 | return m, loadLocalMarkdown(&m.currentDocument) 266 | 267 | // We've received terminal dimensions, either for the first time or 268 | // after a resize 269 | case tea.WindowSizeMsg: 270 | return m, renderWithGlamour(m, m.currentDocument.Body) 271 | 272 | case statusMessageTimeoutMsg: 273 | m.state = pagerStateBrowse 274 | } 275 | 276 | m.viewport, cmd = m.viewport.Update(msg) 277 | cmds = append(cmds, cmd) 278 | 279 | return m, tea.Batch(cmds...) 280 | } 281 | 282 | func (m pagerModel) View() string { 283 | var b strings.Builder 284 | fmt.Fprint(&b, m.viewport.View()+"\n") 285 | 286 | // Footer 287 | m.statusBarView(&b) 288 | 289 | if m.showHelp { 290 | fmt.Fprint(&b, "\n"+m.helpView()) 291 | } 292 | 293 | return b.String() 294 | } 295 | 296 | func (m pagerModel) statusBarView(b *strings.Builder) { 297 | const ( 298 | minPercent float64 = 0.0 299 | maxPercent float64 = 1.0 300 | percentToStringMagnitude float64 = 100.0 301 | ) 302 | 303 | showStatusMessage := m.state == pagerStateStatusMessage 304 | 305 | // Logo 306 | logo := glowLogoView() 307 | 308 | // Scroll percent 309 | percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent())) 310 | scrollPercent := fmt.Sprintf(" %3.f%% ", percent*percentToStringMagnitude) 311 | if showStatusMessage { 312 | scrollPercent = statusBarMessageScrollPosStyle(scrollPercent) 313 | } else { 314 | scrollPercent = statusBarScrollPosStyle(scrollPercent) 315 | } 316 | 317 | // "Help" note 318 | var helpNote string 319 | if showStatusMessage { 320 | helpNote = statusBarMessageHelpStyle(" ? Help ") 321 | } else { 322 | helpNote = statusBarHelpStyle(" ? Help ") 323 | } 324 | 325 | // Note 326 | var note string 327 | if showStatusMessage { 328 | note = m.statusMessage 329 | } else { 330 | note = m.currentDocument.Note 331 | } 332 | note = truncate.StringWithTail(" "+note+" ", uint(max(0, //nolint:gosec 333 | m.common.width- 334 | ansi.PrintableRuneWidth(logo)- 335 | ansi.PrintableRuneWidth(scrollPercent)- 336 | ansi.PrintableRuneWidth(helpNote), 337 | )), ellipsis) 338 | if showStatusMessage { 339 | note = statusBarMessageStyle(note) 340 | } else { 341 | note = statusBarNoteStyle(note) 342 | } 343 | 344 | // Empty space 345 | padding := max(0, 346 | m.common.width- 347 | ansi.PrintableRuneWidth(logo)- 348 | ansi.PrintableRuneWidth(note)- 349 | ansi.PrintableRuneWidth(scrollPercent)- 350 | ansi.PrintableRuneWidth(helpNote), 351 | ) 352 | emptySpace := strings.Repeat(" ", padding) 353 | if showStatusMessage { 354 | emptySpace = statusBarMessageStyle(emptySpace) 355 | } else { 356 | emptySpace = statusBarNoteStyle(emptySpace) 357 | } 358 | 359 | fmt.Fprintf(b, "%s%s%s%s%s", 360 | logo, 361 | note, 362 | emptySpace, 363 | scrollPercent, 364 | helpNote, 365 | ) 366 | } 367 | 368 | func (m pagerModel) helpView() (s string) { 369 | col1 := []string{ 370 | "g/home go to top", 371 | "G/end go to bottom", 372 | "c copy contents", 373 | "e edit this document", 374 | "r reload this document", 375 | "esc back to files", 376 | "q quit", 377 | } 378 | 379 | s += "\n" 380 | s += "k/↑ up " + col1[0] + "\n" 381 | s += "j/↓ down " + col1[1] + "\n" 382 | s += "b/pgup page up " + col1[2] + "\n" 383 | s += "f/pgdn page down " + col1[3] + "\n" 384 | s += "u ½ page up " + col1[4] + "\n" 385 | s += "d ½ page down " 386 | 387 | if len(col1) > 5 { 388 | s += col1[5] 389 | } 390 | 391 | s = indent(s, 2) 392 | 393 | // Fill up empty cells with spaces for background coloring 394 | if m.common.width > 0 { 395 | lines := strings.Split(s, "\n") 396 | for i := 0; i < len(lines); i++ { 397 | l := runewidth.StringWidth(lines[i]) 398 | n := max(m.common.width-l, 0) 399 | lines[i] += strings.Repeat(" ", n) 400 | } 401 | 402 | s = strings.Join(lines, "\n") 403 | } 404 | 405 | return helpViewStyle(s) 406 | } 407 | 408 | // COMMANDS 409 | 410 | func renderWithGlamour(m pagerModel, md string) tea.Cmd { 411 | return func() tea.Msg { 412 | s, err := glamourRender(m, md) 413 | if err != nil { 414 | log.Error("error rendering with Glamour", "error", err) 415 | return errMsg{err} 416 | } 417 | return contentRenderedMsg(s) 418 | } 419 | } 420 | 421 | // This is where the magic happens. 422 | func glamourRender(m pagerModel, markdown string) (string, error) { 423 | trunc := lipgloss.NewStyle().MaxWidth(m.viewport.Width - lineNumberWidth).Render 424 | 425 | if !config.GlamourEnabled { 426 | return markdown, nil 427 | } 428 | 429 | isCode := !utils.IsMarkdownFile(m.currentDocument.Note) 430 | width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width)) //nolint:gosec 431 | if isCode { 432 | width = 0 433 | } 434 | 435 | options := []glamour.TermRendererOption{ 436 | utils.GlamourStyle(m.common.cfg.GlamourStyle, isCode), 437 | glamour.WithWordWrap(width), 438 | } 439 | 440 | if m.common.cfg.PreserveNewLines { 441 | options = append(options, glamour.WithPreservedNewLines()) 442 | } 443 | r, err := glamour.NewTermRenderer(options...) 444 | if err != nil { 445 | return "", fmt.Errorf("error creating glamour renderer: %w", err) 446 | } 447 | 448 | if isCode { 449 | markdown = utils.WrapCodeBlock(markdown, filepath.Ext(m.currentDocument.Note)) 450 | } 451 | 452 | out, err := r.Render(markdown) 453 | if err != nil { 454 | return "", fmt.Errorf("error rendering markdown: %w", err) 455 | } 456 | 457 | if isCode { 458 | out = strings.TrimSpace(out) 459 | } 460 | 461 | // trim lines 462 | lines := strings.Split(out, "\n") 463 | 464 | var content strings.Builder 465 | for i, s := range lines { 466 | if isCode || m.common.cfg.ShowLineNumbers { 467 | content.WriteString(lineNumberStyle(fmt.Sprintf("%"+fmt.Sprint(lineNumberWidth)+"d", i+1))) 468 | content.WriteString(trunc(s)) 469 | } else { 470 | content.WriteString(s) 471 | } 472 | 473 | // don't add an artificial newline after the last split 474 | if i+1 < len(lines) { 475 | content.WriteRune('\n') 476 | } 477 | } 478 | 479 | return content.String(), nil 480 | } 481 | 482 | func (m *pagerModel) initWatcher() { 483 | var err error 484 | m.watcher, err = fsnotify.NewWatcher() 485 | if err != nil { 486 | log.Error("error creating fsnotify watcher", "error", err) 487 | } 488 | } 489 | 490 | func (m *pagerModel) watchFile() tea.Msg { 491 | dir := m.localDir() 492 | 493 | if err := m.watcher.Add(dir); err != nil { 494 | log.Error("error adding dir to fsnotify watcher", "error", err) 495 | return nil 496 | } 497 | 498 | log.Info("fsnotify watching dir", "dir", dir) 499 | 500 | for { 501 | select { 502 | case event, ok := <-m.watcher.Events: 503 | if !ok || event.Name != m.currentDocument.localPath { 504 | continue 505 | } 506 | 507 | if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) { 508 | continue 509 | } 510 | 511 | log.Debug("fsnotify event", "file", event.Name, "event", event.Op) 512 | return reloadMsg{} 513 | case err, ok := <-m.watcher.Errors: 514 | if !ok { 515 | continue 516 | } 517 | log.Debug("fsnotify error", "dir", dir, "error", err) 518 | } 519 | } 520 | } 521 | 522 | func (m *pagerModel) unwatchFile() { 523 | dir := m.localDir() 524 | 525 | err := m.watcher.Remove(dir) 526 | if err == nil { 527 | log.Debug("fsnotify dir unwatched", "dir", dir) 528 | } else { 529 | log.Error("fsnotify fail to unwatch dir", "dir", dir, "error", err) 530 | } 531 | } 532 | 533 | func (m *pagerModel) localDir() string { 534 | return filepath.Dir(m.currentDocument.localPath) 535 | } 536 | -------------------------------------------------------------------------------- /ui/sort.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "cmp" 5 | "slices" 6 | ) 7 | 8 | func sortMarkdowns(mds []*markdown) { 9 | slices.SortStableFunc(mds, func(a, b *markdown) int { 10 | return cmp.Compare(a.Note, b.Note) 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /ui/stash.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "sort" 8 | "strings" 9 | "time" 10 | 11 | "github.com/charmbracelet/bubbles/paginator" 12 | "github.com/charmbracelet/bubbles/spinner" 13 | "github.com/charmbracelet/bubbles/textinput" 14 | tea "github.com/charmbracelet/bubbletea" 15 | "github.com/charmbracelet/lipgloss" 16 | "github.com/charmbracelet/log" 17 | "github.com/muesli/reflow/ansi" 18 | "github.com/muesli/reflow/truncate" 19 | "github.com/sahilm/fuzzy" 20 | ) 21 | 22 | const ( 23 | stashIndent = 1 24 | stashViewItemHeight = 3 // height of stash entry, including gap 25 | stashViewTopPadding = 5 // logo, status bar, gaps 26 | stashViewBottomPadding = 3 // pagination and gaps, but not help 27 | stashViewHorizontalPadding = 6 28 | ) 29 | 30 | var stashingStatusMessage = statusMessage{normalStatusMessage, "Stashing..."} 31 | 32 | var ( 33 | dividerDot = darkGrayFg.SetString(" • ") 34 | dividerBar = darkGrayFg.SetString(" │ ") 35 | 36 | logoStyle = lipgloss.NewStyle(). 37 | Foreground(lipgloss.Color("#ECFD65")). 38 | Background(fuchsia). 39 | Bold(true) 40 | 41 | stashSpinnerStyle = lipgloss.NewStyle(). 42 | Foreground(gray) 43 | stashInputPromptStyle = lipgloss.NewStyle(). 44 | Foreground(yellowGreen). 45 | MarginRight(1) 46 | stashInputCursorStyle = lipgloss.NewStyle(). 47 | Foreground(fuchsia). 48 | MarginRight(1) 49 | ) 50 | 51 | // MSG 52 | 53 | type ( 54 | filteredMarkdownMsg []*markdown 55 | fetchedMarkdownMsg *markdown 56 | ) 57 | 58 | // MODEL 59 | 60 | // stashViewState is the high-level state of the file listing. 61 | type stashViewState int 62 | 63 | const ( 64 | stashStateReady stashViewState = iota 65 | stashStateLoadingDocument 66 | stashStateShowingError 67 | ) 68 | 69 | // The types of documents we are currently showing to the user. 70 | type sectionKey int 71 | 72 | const ( 73 | documentsSection = iota 74 | filterSection 75 | ) 76 | 77 | // section contains definitions and state information for displaying a tab and 78 | // its contents in the file listing view. 79 | type section struct { 80 | key sectionKey 81 | paginator paginator.Model 82 | cursor int 83 | } 84 | 85 | // map sections to their associated types. 86 | var sections = map[sectionKey]section{} 87 | 88 | // filterState is the current filtering state in the file listing. 89 | type filterState int 90 | 91 | const ( 92 | unfiltered filterState = iota // no filter set 93 | filtering // user is actively setting a filter 94 | filterApplied // a filter is applied and user is not editing filter 95 | ) 96 | 97 | // statusMessageType adds some context to the status message being sent. 98 | type statusMessageType int 99 | 100 | // Types of status messages. 101 | const ( 102 | normalStatusMessage statusMessageType = iota 103 | subtleStatusMessage 104 | errorStatusMessage 105 | ) 106 | 107 | // statusMessage is an ephemeral note displayed in the UI. 108 | type statusMessage struct { 109 | status statusMessageType 110 | message string 111 | } 112 | 113 | func initSections() { 114 | sections = map[sectionKey]section{ 115 | documentsSection: { 116 | key: documentsSection, 117 | paginator: newStashPaginator(), 118 | }, 119 | filterSection: { 120 | key: filterSection, 121 | paginator: newStashPaginator(), 122 | }, 123 | } 124 | } 125 | 126 | // String returns a styled version of the status message appropriate for the 127 | // given context. 128 | func (s statusMessage) String() string { 129 | switch s.status { //nolint:exhaustive 130 | case subtleStatusMessage: 131 | return dimGreenFg(s.message) 132 | case errorStatusMessage: 133 | return redFg(s.message) 134 | default: 135 | return greenFg(s.message) 136 | } 137 | } 138 | 139 | type stashModel struct { 140 | common *commonModel 141 | err error 142 | spinner spinner.Model 143 | filterInput textinput.Model 144 | viewState stashViewState 145 | filterState filterState 146 | showFullHelp bool 147 | showStatusMessage bool 148 | statusMessage statusMessage 149 | statusMessageTimer *time.Timer 150 | 151 | // Available document sections we can cycle through. We use a slice, rather 152 | // than a map, because order is important. 153 | sections []section 154 | 155 | // Index of the section we're currently looking at 156 | sectionIndex int 157 | 158 | // Tracks if docs were loaded 159 | loaded bool 160 | 161 | // The master set of markdown documents we're working with. 162 | markdowns []*markdown 163 | 164 | // Markdown documents we're currently displaying. Filtering, toggles and so 165 | // on will alter this slice so we can show what is relevant. For that 166 | // reason, this field should be considered ephemeral. 167 | filteredMarkdowns []*markdown 168 | 169 | // Page we're fetching stash items from on the server, which is different 170 | // from the local pagination. Generally, the server will return more items 171 | // than we can display at a time so we can paginate locally without having 172 | // to fetch every time. 173 | serverPage int64 174 | } 175 | 176 | func (m stashModel) loadingDone() bool { 177 | return m.loaded 178 | } 179 | 180 | func (m stashModel) currentSection() *section { 181 | return &m.sections[m.sectionIndex] 182 | } 183 | 184 | func (m stashModel) paginator() *paginator.Model { 185 | return &m.currentSection().paginator 186 | } 187 | 188 | func (m *stashModel) setPaginator(p paginator.Model) { 189 | m.currentSection().paginator = p 190 | } 191 | 192 | func (m stashModel) cursor() int { 193 | return m.currentSection().cursor 194 | } 195 | 196 | func (m *stashModel) setCursor(i int) { 197 | m.currentSection().cursor = i 198 | } 199 | 200 | // Whether or not the spinner should be spinning. 201 | func (m stashModel) shouldSpin() bool { 202 | loading := !m.loadingDone() 203 | openingDocument := m.viewState == stashStateLoadingDocument 204 | return loading || openingDocument 205 | } 206 | 207 | func (m *stashModel) setSize(width, height int) { 208 | m.common.width = width 209 | m.common.height = height 210 | 211 | m.filterInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth( 212 | m.filterInput.Prompt, 213 | ) 214 | 215 | m.updatePagination() 216 | } 217 | 218 | func (m *stashModel) resetFiltering() { 219 | m.filterState = unfiltered 220 | m.filterInput.Reset() 221 | m.filteredMarkdowns = nil 222 | 223 | sortMarkdowns(m.markdowns) 224 | 225 | // If the filtered section is present (it's always at the end) slice it out 226 | // of the sections slice to remove it from the UI. 227 | if m.sections[len(m.sections)-1].key == filterSection { 228 | m.sections = m.sections[:len(m.sections)-1] 229 | } 230 | 231 | // If the current section is out of bounds (it would be if we cut down the 232 | // slice above) then return to the first section. 233 | if m.sectionIndex > len(m.sections)-1 { 234 | m.sectionIndex = 0 235 | } 236 | 237 | // Update pagination after we've switched sections. 238 | m.updatePagination() 239 | } 240 | 241 | // Is a filter currently being applied? 242 | func (m stashModel) filterApplied() bool { 243 | return m.filterState != unfiltered 244 | } 245 | 246 | // Should we be updating the filter? 247 | func (m stashModel) shouldUpdateFilter() bool { 248 | // If we're in the middle of setting a note don't update the filter so that 249 | // the focus won't jump around. 250 | return m.filterApplied() 251 | } 252 | 253 | // Update pagination according to the amount of markdowns for the current 254 | // state. 255 | func (m *stashModel) updatePagination() { 256 | _, helpHeight := m.helpView() 257 | 258 | availableHeight := m.common.height - 259 | stashViewTopPadding - 260 | helpHeight - 261 | stashViewBottomPadding 262 | 263 | m.paginator().PerPage = max(1, availableHeight/stashViewItemHeight) 264 | 265 | if pages := len(m.getVisibleMarkdowns()); pages < 1 { 266 | m.paginator().SetTotalPages(1) 267 | } else { 268 | m.paginator().SetTotalPages(pages) 269 | } 270 | 271 | // Make sure the page stays in bounds 272 | if m.paginator().Page >= m.paginator().TotalPages-1 { 273 | m.paginator().Page = max(0, m.paginator().TotalPages-1) 274 | } 275 | } 276 | 277 | // MarkdownIndex returns the index of the currently selected markdown item. 278 | func (m stashModel) markdownIndex() int { 279 | return m.paginator().Page*m.paginator().PerPage + m.cursor() 280 | } 281 | 282 | // Return the current selected markdown in the stash. 283 | func (m stashModel) selectedMarkdown() *markdown { 284 | i := m.markdownIndex() 285 | 286 | mds := m.getVisibleMarkdowns() 287 | if i < 0 || len(mds) == 0 || len(mds) <= i { 288 | return nil 289 | } 290 | 291 | return mds[i] 292 | } 293 | 294 | // Adds markdown documents to the model. 295 | func (m *stashModel) addMarkdowns(mds ...*markdown) { 296 | if len(mds) == 0 { 297 | return 298 | } 299 | 300 | m.markdowns = append(m.markdowns, mds...) 301 | if !m.filterApplied() { 302 | sortMarkdowns(m.markdowns) 303 | } 304 | 305 | m.updatePagination() 306 | } 307 | 308 | // Returns the markdowns that should be currently shown. 309 | func (m stashModel) getVisibleMarkdowns() []*markdown { 310 | if m.filterState == filtering || m.currentSection().key == filterSection { 311 | return m.filteredMarkdowns 312 | } 313 | 314 | return m.markdowns 315 | } 316 | 317 | // Command for opening a markdown document in the pager. Note that this also 318 | // alters the model. 319 | func (m *stashModel) openMarkdown(md *markdown) tea.Cmd { 320 | m.viewState = stashStateLoadingDocument 321 | cmd := loadLocalMarkdown(md) 322 | return tea.Batch(cmd, m.spinner.Tick) 323 | } 324 | 325 | func (m *stashModel) hideStatusMessage() { 326 | m.showStatusMessage = false 327 | m.statusMessage = statusMessage{} 328 | if m.statusMessageTimer != nil { 329 | m.statusMessageTimer.Stop() 330 | } 331 | } 332 | 333 | func (m *stashModel) moveCursorUp() { 334 | m.setCursor(m.cursor() - 1) 335 | if m.cursor() < 0 && m.paginator().Page == 0 { 336 | // Stop 337 | m.setCursor(0) 338 | return 339 | } 340 | 341 | if m.cursor() >= 0 { 342 | return 343 | } 344 | // Go to previous page 345 | m.paginator().PrevPage() 346 | 347 | m.setCursor(m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns())) - 1) 348 | } 349 | 350 | func (m *stashModel) moveCursorDown() { 351 | itemsOnPage := m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns())) 352 | 353 | m.setCursor(m.cursor() + 1) 354 | if m.cursor() < itemsOnPage { 355 | return 356 | } 357 | 358 | if !m.paginator().OnLastPage() { 359 | m.paginator().NextPage() 360 | m.setCursor(0) 361 | return 362 | } 363 | 364 | // During filtering the cursor position can exceed the number of 365 | // itemsOnPage. It's more intuitive to start the cursor at the 366 | // topmost position when moving it down in this scenario. 367 | if m.cursor() > itemsOnPage { 368 | m.setCursor(0) 369 | return 370 | } 371 | m.setCursor(itemsOnPage - 1) 372 | } 373 | 374 | // INIT 375 | 376 | func newStashModel(common *commonModel) stashModel { 377 | sp := spinner.New() 378 | sp.Spinner = spinner.Line 379 | sp.Style = stashSpinnerStyle 380 | 381 | si := textinput.New() 382 | si.Prompt = "Find:" 383 | si.PromptStyle = stashInputPromptStyle 384 | si.Cursor.Style = stashInputCursorStyle 385 | si.Focus() 386 | 387 | s := []section{ 388 | sections[documentsSection], 389 | } 390 | 391 | m := stashModel{ 392 | common: common, 393 | spinner: sp, 394 | filterInput: si, 395 | serverPage: 1, 396 | sections: s, 397 | } 398 | 399 | return m 400 | } 401 | 402 | func newStashPaginator() paginator.Model { 403 | p := paginator.New() 404 | p.Type = paginator.Dots 405 | p.ActiveDot = brightGrayFg("•") 406 | p.InactiveDot = darkGrayFg.Render("•") 407 | return p 408 | } 409 | 410 | // UPDATE 411 | 412 | func (m stashModel) update(msg tea.Msg) (stashModel, tea.Cmd) { 413 | var cmds []tea.Cmd 414 | 415 | switch msg := msg.(type) { 416 | case errMsg: 417 | m.err = msg 418 | 419 | case localFileSearchFinished: 420 | // We're finished searching for local files 421 | m.loaded = true 422 | 423 | case filteredMarkdownMsg: 424 | m.filteredMarkdowns = msg 425 | m.setCursor(0) 426 | return m, nil 427 | 428 | case spinner.TickMsg: 429 | if m.shouldSpin() { 430 | var cmd tea.Cmd 431 | m.spinner, cmd = m.spinner.Update(msg) 432 | cmds = append(cmds, cmd) 433 | } 434 | 435 | case statusMessageTimeoutMsg: 436 | if applicationContext(msg) == stashContext { 437 | m.hideStatusMessage() 438 | } 439 | } 440 | 441 | if m.filterState == filtering { 442 | cmds = append(cmds, m.handleFiltering(msg)) 443 | return m, tea.Batch(cmds...) 444 | } 445 | 446 | // Updates per the current state 447 | switch m.viewState { //nolint:exhaustive 448 | case stashStateReady: 449 | cmds = append(cmds, m.handleDocumentBrowsing(msg)) 450 | case stashStateShowingError: 451 | // Any key exists the error view 452 | if _, ok := msg.(tea.KeyMsg); ok { 453 | m.viewState = stashStateReady 454 | } 455 | } 456 | 457 | return m, tea.Batch(cmds...) 458 | } 459 | 460 | // Updates for when a user is browsing the markdown listing. 461 | func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd { 462 | var cmds []tea.Cmd 463 | 464 | numDocs := len(m.getVisibleMarkdowns()) 465 | 466 | switch msg := msg.(type) { 467 | // Handle keys 468 | case tea.KeyMsg: 469 | switch msg.String() { 470 | case "k", "ctrl+k", "up": 471 | m.moveCursorUp() 472 | 473 | case "j", "ctrl+j", "down": 474 | m.moveCursorDown() 475 | 476 | // Go to the very start 477 | case "home", "g": 478 | m.paginator().Page = 0 479 | m.setCursor(0) 480 | 481 | // Go to the very end 482 | case "end", "G": 483 | m.paginator().Page = m.paginator().TotalPages - 1 484 | m.setCursor(m.paginator().ItemsOnPage(numDocs) - 1) 485 | 486 | // Clear filter (if applicable) 487 | case keyEsc: 488 | if m.filterApplied() { 489 | m.resetFiltering() 490 | } 491 | 492 | // Next section 493 | case "tab", "L": 494 | if len(m.sections) == 0 || m.filterState == filtering { 495 | break 496 | } 497 | m.sectionIndex++ 498 | if m.sectionIndex >= len(m.sections) { 499 | m.sectionIndex = 0 500 | } 501 | m.updatePagination() 502 | 503 | // Previous section 504 | case "shift+tab", "H": 505 | if len(m.sections) == 0 || m.filterState == filtering { 506 | break 507 | } 508 | m.sectionIndex-- 509 | if m.sectionIndex < 0 { 510 | m.sectionIndex = len(m.sections) - 1 511 | } 512 | m.updatePagination() 513 | 514 | case "F": 515 | m.loaded = false 516 | return findLocalFiles(*m.common) 517 | 518 | // Edit document in EDITOR 519 | case "e": 520 | md := m.selectedMarkdown() 521 | 522 | // In case no file is available 523 | if md == nil { 524 | return nil 525 | } 526 | 527 | return openEditor(md.localPath, 0) 528 | 529 | // Open document 530 | case keyEnter: 531 | m.hideStatusMessage() 532 | 533 | if numDocs == 0 { 534 | break 535 | } 536 | 537 | // Load the document from the server. We'll handle the message 538 | // that comes back in the main update function. 539 | md := m.selectedMarkdown() 540 | cmds = append(cmds, m.openMarkdown(md)) 541 | 542 | // Filter your notes 543 | case "/": 544 | m.hideStatusMessage() 545 | 546 | // Build values we'll filter against 547 | for _, md := range m.markdowns { 548 | md.buildFilterValue() 549 | } 550 | 551 | m.filteredMarkdowns = m.markdowns 552 | 553 | m.paginator().Page = 0 554 | m.setCursor(0) 555 | m.filterState = filtering 556 | m.filterInput.CursorEnd() 557 | m.filterInput.Focus() 558 | return textinput.Blink 559 | 560 | // Toggle full help 561 | case "?": 562 | m.showFullHelp = !m.showFullHelp 563 | m.updatePagination() 564 | 565 | // Show errors 566 | case "!": 567 | if m.err != nil && m.viewState == stashStateReady { 568 | m.viewState = stashStateShowingError 569 | return nil 570 | } 571 | } 572 | } 573 | 574 | // Update paginator. Pagination key handling is done here, but it could 575 | // also be moved up to this level, in which case we'd use model methods 576 | // like model.PageUp(). 577 | newPaginatorModel, cmd := m.paginator().Update(msg) 578 | m.setPaginator(newPaginatorModel) 579 | cmds = append(cmds, cmd) 580 | 581 | // Extra paginator keystrokes 582 | if key, ok := msg.(tea.KeyMsg); ok { 583 | switch key.String() { 584 | case "b", "u": 585 | m.paginator().PrevPage() 586 | case "f", "d": 587 | m.paginator().NextPage() 588 | } 589 | } 590 | 591 | // Keep the index in bounds when paginating 592 | itemsOnPage := m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns())) 593 | if m.cursor() > itemsOnPage-1 { 594 | m.setCursor(max(0, itemsOnPage-1)) 595 | } 596 | 597 | return tea.Batch(cmds...) 598 | } 599 | 600 | // Updates for when a user is in the filter editing interface. 601 | func (m *stashModel) handleFiltering(msg tea.Msg) tea.Cmd { 602 | var cmds []tea.Cmd 603 | 604 | // Handle keys 605 | if msg, ok := msg.(tea.KeyMsg); ok { //nolint:nestif 606 | switch msg.String() { 607 | case keyEsc: 608 | // Cancel filtering 609 | m.resetFiltering() 610 | case keyEnter, "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down": 611 | m.hideStatusMessage() 612 | 613 | if len(m.markdowns) == 0 { 614 | break 615 | } 616 | 617 | h := m.getVisibleMarkdowns() 618 | 619 | // If we've filtered down to nothing, clear the filter 620 | if len(h) == 0 { 621 | m.viewState = stashStateReady 622 | m.resetFiltering() 623 | break 624 | } 625 | 626 | // When there's only one filtered markdown left we can just 627 | // "open" it directly 628 | if len(h) == 1 { 629 | m.viewState = stashStateReady 630 | m.resetFiltering() 631 | cmds = append(cmds, m.openMarkdown(h[0])) 632 | break 633 | } 634 | 635 | // Add new section if it's not present 636 | if m.sections[len(m.sections)-1].key != filterSection { 637 | m.sections = append(m.sections, sections[filterSection]) 638 | } 639 | m.sectionIndex = len(m.sections) - 1 640 | 641 | m.filterInput.Blur() 642 | 643 | m.filterState = filterApplied 644 | if m.filterInput.Value() == "" { 645 | m.resetFiltering() 646 | } 647 | } 648 | } 649 | 650 | // Update the filter text input component 651 | newFilterInputModel, inputCmd := m.filterInput.Update(msg) 652 | currentFilterVal := m.filterInput.Value() 653 | newFilterVal := newFilterInputModel.Value() 654 | m.filterInput = newFilterInputModel 655 | cmds = append(cmds, inputCmd) 656 | 657 | // If the filtering input has changed, request updated filtering 658 | if newFilterVal != currentFilterVal { 659 | cmds = append(cmds, filterMarkdowns(*m)) 660 | } 661 | 662 | // Update pagination 663 | m.updatePagination() 664 | 665 | return tea.Batch(cmds...) 666 | } 667 | 668 | // VIEW 669 | 670 | func (m stashModel) view() string { 671 | var s string 672 | switch m.viewState { 673 | case stashStateShowingError: 674 | return errorView(m.err, false) 675 | case stashStateLoadingDocument: 676 | s += " " + m.spinner.View() + " Loading document..." 677 | case stashStateReady: 678 | loadingIndicator := " " 679 | if m.shouldSpin() { 680 | loadingIndicator = m.spinner.View() 681 | } 682 | 683 | // Only draw the normal header if we're not using the header area for 684 | // something else (like a note or delete prompt). 685 | header := m.headerView() 686 | 687 | // Rules for the logo, filter and status message. 688 | logoOrFilter := " " 689 | if m.showStatusMessage && m.filterState == filtering { 690 | logoOrFilter += m.statusMessage.String() 691 | } else if m.filterState == filtering { 692 | logoOrFilter += m.filterInput.View() 693 | } else { 694 | logoOrFilter += glowLogoView() 695 | if m.showStatusMessage { 696 | logoOrFilter += " " + m.statusMessage.String() 697 | } 698 | } 699 | logoOrFilter = truncate.StringWithTail(logoOrFilter, uint(m.common.width-1), ellipsis) //nolint:gosec 700 | 701 | help, helpHeight := m.helpView() 702 | 703 | populatedView := m.populatedView() 704 | populatedViewHeight := strings.Count(populatedView, "\n") + 2 705 | 706 | // We need to fill any empty height with newlines so the footer reaches 707 | // the bottom. 708 | availHeight := m.common.height - 709 | stashViewTopPadding - 710 | populatedViewHeight - 711 | helpHeight - 712 | stashViewBottomPadding 713 | blankLines := strings.Repeat("\n", max(0, availHeight)) 714 | 715 | var pagination string 716 | if m.paginator().TotalPages > 1 { 717 | pagination = m.paginator().View() 718 | 719 | // If the dot pagination is wider than the width of the window 720 | // use the arabic paginator. 721 | if ansi.PrintableRuneWidth(pagination) > m.common.width-stashViewHorizontalPadding { 722 | // Copy the paginator since m.paginator() returns a pointer to 723 | // the active paginator and we don't want to mutate it. In 724 | // normal cases, where the paginator is not a pointer, we could 725 | // safely change the model parameters for rendering here as the 726 | // current model is discarded after reuturning from a View(). 727 | // One could argue, in fact, that using pointers in 728 | // a functional framework is an antipattern and our use of 729 | // pointers in our model should be refactored away. 730 | p := *(m.paginator()) 731 | p.Type = paginator.Arabic 732 | pagination = paginationStyle.Render(p.View()) 733 | } 734 | } 735 | 736 | s += fmt.Sprintf( 737 | "%s%s\n\n %s\n\n%s\n\n%s %s\n\n%s", 738 | loadingIndicator, 739 | logoOrFilter, 740 | header, 741 | populatedView, 742 | blankLines, 743 | pagination, 744 | help, 745 | ) 746 | } 747 | return "\n" + indent(s, stashIndent) 748 | } 749 | 750 | func glowLogoView() string { 751 | return logoStyle.Render(" Glow ") 752 | } 753 | 754 | func (m stashModel) headerView() string { 755 | localCount := len(m.markdowns) 756 | 757 | var sections []string //nolint:prealloc 758 | 759 | // Filter results 760 | if m.filterState == filtering { 761 | if localCount == 0 { 762 | return grayFg("Nothing found.") 763 | } 764 | if localCount > 0 { 765 | sections = append(sections, fmt.Sprintf("%d local", localCount)) 766 | } 767 | 768 | for i := range sections { 769 | sections[i] = grayFg(sections[i]) 770 | } 771 | 772 | return strings.Join(sections, dividerDot.String()) 773 | } 774 | 775 | // Tabs 776 | for i, v := range m.sections { 777 | var s string 778 | 779 | switch v.key { 780 | case documentsSection: 781 | s = fmt.Sprintf("%d documents", localCount) 782 | 783 | case filterSection: 784 | s = fmt.Sprintf("%d “%s”", len(m.filteredMarkdowns), m.filterInput.Value()) 785 | } 786 | 787 | if m.sectionIndex == i && len(m.sections) > 1 { 788 | s = selectedTabStyle.Render(s) 789 | } else { 790 | s = tabStyle.Render(s) 791 | } 792 | sections = append(sections, s) 793 | } 794 | 795 | return strings.Join(sections, dividerBar.String()) 796 | } 797 | 798 | func (m stashModel) populatedView() string { 799 | mds := m.getVisibleMarkdowns() 800 | 801 | var b strings.Builder 802 | 803 | // Empty states 804 | if len(mds) == 0 { 805 | f := func(s string) { 806 | b.WriteString(" " + grayFg(s)) 807 | } 808 | 809 | switch m.sections[m.sectionIndex].key { 810 | case documentsSection: 811 | if m.loadingDone() { 812 | f("No files found.") 813 | } else { 814 | f("Looking for local files...") 815 | } 816 | case filterSection: 817 | return "" 818 | } 819 | } 820 | 821 | if len(mds) > 0 { 822 | start, end := m.paginator().GetSliceBounds(len(mds)) 823 | docs := mds[start:end] 824 | 825 | for i, md := range docs { 826 | stashItemView(&b, m, i, md) 827 | if i != len(docs)-1 { 828 | fmt.Fprintf(&b, "\n\n") 829 | } 830 | } 831 | } 832 | 833 | // If there aren't enough items to fill up this page (always the last page) 834 | // then we need to add some newlines to fill up the space where stash items 835 | // would have been. 836 | itemsOnPage := m.paginator().ItemsOnPage(len(mds)) 837 | if itemsOnPage < m.paginator().PerPage { 838 | n := (m.paginator().PerPage - itemsOnPage) * stashViewItemHeight 839 | if len(mds) == 0 { 840 | n -= stashViewItemHeight - 1 841 | } 842 | for i := 0; i < n; i++ { 843 | fmt.Fprint(&b, "\n") 844 | } 845 | } 846 | 847 | return b.String() 848 | } 849 | 850 | // COMMANDS 851 | 852 | func loadLocalMarkdown(md *markdown) tea.Cmd { 853 | return func() tea.Msg { 854 | if md.localPath == "" { 855 | return errMsg{errors.New("could not load file: missing path")} 856 | } 857 | 858 | data, err := os.ReadFile(md.localPath) 859 | if err != nil { 860 | log.Debug("error reading local file", "error", err) 861 | return errMsg{err} 862 | } 863 | md.Body = string(data) 864 | return fetchedMarkdownMsg(md) 865 | } 866 | } 867 | 868 | func filterMarkdowns(m stashModel) tea.Cmd { 869 | return func() tea.Msg { 870 | if m.filterInput.Value() == "" || !m.filterApplied() { 871 | return filteredMarkdownMsg(m.markdowns) // return everything 872 | } 873 | 874 | targets := []string{} 875 | mds := m.markdowns 876 | 877 | for _, t := range mds { 878 | targets = append(targets, t.filterValue) 879 | } 880 | 881 | ranks := fuzzy.Find(m.filterInput.Value(), targets) 882 | sort.Stable(ranks) 883 | 884 | filtered := []*markdown{} 885 | for _, r := range ranks { 886 | filtered = append(filtered, mds[r.Index]) 887 | } 888 | 889 | return filteredMarkdownMsg(filtered) 890 | } 891 | } 892 | -------------------------------------------------------------------------------- /ui/stashhelp.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/muesli/reflow/ansi" 8 | ) 9 | 10 | // helpEntry is a entry in a help menu containing values for a keystroke and 11 | // it's associated action. 12 | type helpEntry struct{ key, val string } 13 | 14 | // helpColumn is a group of helpEntries which will be rendered into a column. 15 | type helpColumn []helpEntry 16 | 17 | // newHelpColumn creates a help column from pairs of string arguments 18 | // representing keys and values. If the arguments are not even (and therein 19 | // not every key has a matching value) the function will panic. 20 | func newHelpColumn(pairs ...string) (h helpColumn) { 21 | if len(pairs)%2 != 0 { 22 | panic("help text group must have an even number of items") 23 | } 24 | 25 | for i := 0; i < len(pairs); i = i + 2 { 26 | h = append(h, helpEntry{key: pairs[i], val: pairs[i+1]}) 27 | } 28 | 29 | return 30 | } 31 | 32 | // render returns styled and formatted rows from keys and values. 33 | func (h helpColumn) render(height int) (rows []string) { 34 | keyWidth, valWidth := h.maxWidths() 35 | 36 | for i := 0; i < height; i++ { 37 | var ( 38 | b = strings.Builder{} 39 | k, v string 40 | ) 41 | if i < len(h) { 42 | k = h[i].key 43 | v = h[i].val 44 | 45 | switch k { 46 | case "s": 47 | k = greenFg(k) 48 | v = semiDimGreenFg(v) 49 | default: 50 | k = grayFg(k) 51 | v = midGrayFg(v) 52 | } 53 | } 54 | b.WriteString(k) 55 | b.WriteString(strings.Repeat(" ", keyWidth-ansi.PrintableRuneWidth(k))) // pad keys 56 | b.WriteString(" ") // gap 57 | b.WriteString(v) 58 | b.WriteString(strings.Repeat(" ", valWidth-ansi.PrintableRuneWidth(v))) // pad vals 59 | rows = append(rows, b.String()) 60 | } 61 | 62 | return 63 | } 64 | 65 | // maxWidths returns the widest key and values in the column, respectively. 66 | func (h helpColumn) maxWidths() (maxKey int, maxVal int) { 67 | for _, v := range h { 68 | kw := ansi.PrintableRuneWidth(v.key) 69 | vw := ansi.PrintableRuneWidth(v.val) 70 | if kw > maxKey { 71 | maxKey = kw 72 | } 73 | if vw > maxVal { 74 | maxVal = vw 75 | } 76 | } 77 | 78 | return 79 | } 80 | 81 | // helpView returns either the mini or full help view depending on the state of 82 | // the model, as well as the total height of the help view. 83 | func (m stashModel) helpView() (string, int) { 84 | numDocs := len(m.getVisibleMarkdowns()) 85 | 86 | // Help for when we're filtering 87 | if m.filterState == filtering { 88 | var h []string 89 | 90 | switch numDocs { 91 | case 0: 92 | h = []string{"enter/esc", "cancel"} 93 | case 1: 94 | h = []string{"enter", "open", "esc", "cancel"} 95 | default: 96 | h = []string{"enter", "confirm", "esc", "cancel", "ctrl+j/ctrl+k ↑/↓", "choose"} 97 | } 98 | 99 | return m.renderHelp(h) 100 | } 101 | 102 | var ( 103 | navHelp []string 104 | filterHelp []string 105 | selectionHelp []string 106 | editHelp []string 107 | sectionHelp []string 108 | appHelp []string 109 | ) 110 | 111 | if numDocs > 0 && m.showFullHelp { 112 | navHelp = []string{"enter", "open", "j/k ↑/↓", "choose"} 113 | } 114 | 115 | if len(m.sections) > 1 { 116 | if m.showFullHelp { 117 | navHelp = append(navHelp, "tab/shift+tab", "section") 118 | } else { 119 | navHelp = append(navHelp, "tab", "section") 120 | } 121 | } 122 | 123 | if m.paginator().TotalPages > 1 { 124 | navHelp = append(navHelp, "h/l ←/→", "page") 125 | } 126 | 127 | // If we're browsing a filtered set 128 | if m.filterApplied() { 129 | filterHelp = []string{"/", "edit search", "esc", "clear filter"} 130 | } else { 131 | filterHelp = []string{"/", "find"} 132 | } 133 | 134 | // If there are errors 135 | if m.err != nil { 136 | appHelp = append(appHelp, "!", "errors") 137 | } 138 | 139 | appHelp = append(appHelp, "r", "refresh") 140 | 141 | if numDocs > 0 { 142 | appHelp = append(appHelp, "e", "edit") 143 | } 144 | 145 | appHelp = append(appHelp, "q", "quit") 146 | 147 | // Detailed help 148 | if m.showFullHelp { 149 | if m.filterState != filtering { 150 | appHelp = append(appHelp, "?", "close help") 151 | } 152 | return m.renderHelp(navHelp, filterHelp, append(selectionHelp, editHelp...), sectionHelp, appHelp) 153 | } 154 | 155 | // Mini help 156 | if m.filterState != filtering { 157 | appHelp = append(appHelp, "?", "more") 158 | } 159 | return m.renderHelp(navHelp, filterHelp, selectionHelp, editHelp, sectionHelp, appHelp) 160 | } 161 | 162 | const minHelpViewHeight = 5 163 | 164 | // renderHelp returns the rendered help view and associated line height for 165 | // the given groups of help items. 166 | func (m stashModel) renderHelp(groups ...[]string) (string, int) { 167 | if m.showFullHelp { 168 | str := m.fullHelpView(groups...) 169 | numLines := strings.Count(str, "\n") + 1 170 | return str, max(numLines, minHelpViewHeight) 171 | } 172 | return m.miniHelpView(concatStringSlices(groups...)...), 1 173 | } 174 | 175 | // Builds the help view from various sections pieces, truncating it if the view 176 | // would otherwise wrap to two lines. Help view entries should come in as pairs, 177 | // with the first being the key and the second being the help text. 178 | func (m stashModel) miniHelpView(entries ...string) string { 179 | if len(entries) == 0 { 180 | return "" 181 | } 182 | 183 | var ( 184 | truncationChar = subtleStyle.Render("…") 185 | truncationWidth = ansi.PrintableRuneWidth(truncationChar) 186 | ) 187 | 188 | var ( 189 | next string 190 | leftGutter = " " 191 | maxWidth = m.common.width - 192 | stashViewHorizontalPadding - 193 | truncationWidth - 194 | ansi.PrintableRuneWidth(leftGutter) 195 | s = leftGutter 196 | ) 197 | 198 | for i := 0; i < len(entries); i = i + 2 { 199 | k := entries[i] 200 | v := entries[i+1] 201 | 202 | k = grayFg(k) 203 | v = midGrayFg(v) 204 | 205 | next = fmt.Sprintf("%s %s", k, v) 206 | 207 | if i < len(entries)-2 { 208 | next += dividerDot.String() 209 | } 210 | 211 | // Only this (and the following) help text items if we have the 212 | // horizontal space 213 | if ansi.PrintableRuneWidth(s)+ansi.PrintableRuneWidth(next) >= maxWidth { 214 | s += truncationChar 215 | break 216 | } 217 | 218 | s += next 219 | } 220 | return s 221 | } 222 | 223 | func (m stashModel) fullHelpView(groups ...[]string) string { 224 | var tallestCol int 225 | columns := make([]helpColumn, 0, len(groups)) 226 | renderedCols := make([][]string, 0, len(groups)) // final rows grouped by column 227 | 228 | // Get key/value pairs 229 | for _, g := range groups { 230 | if len(g) == 0 { 231 | continue // ignore empty columns 232 | } 233 | 234 | columns = append(columns, newHelpColumn(g...)) 235 | } 236 | 237 | // Find the tallest column 238 | for _, c := range columns { 239 | if len(c) > tallestCol { 240 | tallestCol = len(c) 241 | } 242 | } 243 | 244 | // Build columns 245 | for _, c := range columns { 246 | renderedCols = append(renderedCols, c.render(tallestCol)) 247 | } 248 | 249 | // Merge columns 250 | return mergeColumns(renderedCols...) 251 | } 252 | 253 | // Merge columns together to build the help view. 254 | func mergeColumns(cols ...[]string) string { 255 | const minimumHeight = 3 256 | 257 | // Find the tallest column 258 | var tallestCol int 259 | for _, v := range cols { 260 | n := len(v) 261 | if n > tallestCol { 262 | tallestCol = n 263 | } 264 | } 265 | 266 | // Make sure the tallest column meets the minimum height 267 | if tallestCol < minimumHeight { 268 | tallestCol = minimumHeight 269 | } 270 | 271 | b := strings.Builder{} 272 | for i := 0; i < tallestCol; i++ { 273 | for j, col := range cols { 274 | if i >= len(col) { 275 | continue // skip if we're past the length of this column 276 | } 277 | if j == 0 { 278 | b.WriteString(" ") // gutter 279 | } else if j > 0 { 280 | b.WriteString(" ") // gap 281 | } 282 | b.WriteString(col[i]) 283 | } 284 | if i < tallestCol-1 { 285 | b.WriteRune('\n') 286 | } 287 | } 288 | 289 | return b.String() 290 | } 291 | 292 | func concatStringSlices(s ...[]string) (agg []string) { 293 | for _, v := range s { 294 | agg = append(agg, v...) 295 | } 296 | return 297 | } 298 | -------------------------------------------------------------------------------- /ui/stashitem.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/charmbracelet/log" 9 | "github.com/muesli/reflow/truncate" 10 | "github.com/sahilm/fuzzy" 11 | ) 12 | 13 | const ( 14 | verticalLine = "│" 15 | fileListingStashIcon = "• " 16 | ) 17 | 18 | func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) { 19 | var ( 20 | truncateTo = uint(m.common.width - stashViewHorizontalPadding*2) //nolint:gosec 21 | gutter string 22 | title = truncate.StringWithTail(md.Note, truncateTo, ellipsis) 23 | date = md.relativeTime() 24 | editedBy = "" 25 | hasEditedBy = false 26 | icon = "" 27 | separator = "" 28 | ) 29 | 30 | isSelected := index == m.cursor() 31 | isFiltering := m.filterState == filtering 32 | singleFilteredItem := isFiltering && len(m.getVisibleMarkdowns()) == 1 33 | 34 | // If there are multiple items being filtered don't highlight a selected 35 | // item in the results. If we've filtered down to one item, however, 36 | // highlight that first item since pressing return will open it. 37 | if isSelected && !isFiltering || singleFilteredItem { //nolint:nestif 38 | // Selected item 39 | if m.statusMessage == stashingStatusMessage { 40 | gutter = greenFg(verticalLine) 41 | icon = dimGreenFg(icon) 42 | title = greenFg(title) 43 | date = semiDimGreenFg(date) 44 | editedBy = semiDimGreenFg(editedBy) 45 | separator = semiDimGreenFg(separator) 46 | } else { 47 | gutter = dullFuchsiaFg(verticalLine) 48 | if m.currentSection().key == filterSection && 49 | m.filterState == filterApplied || singleFilteredItem { 50 | s := lipgloss.NewStyle().Foreground(fuchsia) 51 | title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true)) 52 | } else { 53 | title = fuchsiaFg(title) 54 | icon = fuchsiaFg(icon) 55 | } 56 | date = dimFuchsiaFg(date) 57 | editedBy = dimDullFuchsiaFg(editedBy) 58 | separator = dullFuchsiaFg(separator) 59 | } 60 | } else { 61 | gutter = " " 62 | if m.statusMessage == stashingStatusMessage { 63 | icon = dimGreenFg(icon) 64 | title = greenFg(title) 65 | date = semiDimGreenFg(date) 66 | editedBy = semiDimGreenFg(editedBy) 67 | separator = semiDimGreenFg(separator) 68 | } else if isFiltering && m.filterInput.Value() == "" { 69 | icon = dimGreenFg(icon) 70 | title = dimNormalFg(title) 71 | date = dimBrightGrayFg(date) 72 | editedBy = dimBrightGrayFg(editedBy) 73 | separator = dimBrightGrayFg(separator) 74 | } else { 75 | icon = greenFg(icon) 76 | 77 | s := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}) 78 | title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true)) 79 | date = grayFg(date) 80 | editedBy = midGrayFg(editedBy) 81 | separator = brightGrayFg(separator) 82 | } 83 | } 84 | 85 | fmt.Fprintf(b, "%s %s%s%s%s\n", gutter, icon, separator, separator, title) 86 | fmt.Fprintf(b, "%s %s", gutter, date) 87 | if hasEditedBy { 88 | fmt.Fprintf(b, " %s", editedBy) 89 | } 90 | } 91 | 92 | func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle lipgloss.Style) string { 93 | b := strings.Builder{} 94 | 95 | normalizedHay, err := normalize(haystack) 96 | if err != nil { 97 | log.Error("error normalizing", "haystack", haystack, "error", err) 98 | } 99 | 100 | matches := fuzzy.Find(needles, []string{normalizedHay}) 101 | if len(matches) == 0 { 102 | return defaultStyle.Render(haystack) 103 | } 104 | 105 | m := matches[0] // only one match exists 106 | for i, rune := range []rune(haystack) { 107 | styled := false 108 | for _, mi := range m.MatchedIndexes { 109 | if i == mi { 110 | b.WriteString(matchedStyle.Render(string(rune))) 111 | styled = true 112 | } 113 | } 114 | if !styled { 115 | b.WriteString(defaultStyle.Render(string(rune))) 116 | } 117 | } 118 | 119 | return b.String() 120 | } 121 | -------------------------------------------------------------------------------- /ui/styles.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | // Colors. 6 | var ( 7 | normalDim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"} 8 | gray = lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"} 9 | midGray = lipgloss.AdaptiveColor{Light: "#B2B2B2", Dark: "#4A4A4A"} 10 | darkGray = lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"} 11 | brightGray = lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"} 12 | dimBrightGray = lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"} 13 | cream = lipgloss.AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"} 14 | yellowGreen = lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"} 15 | fuchsia = lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"} 16 | dimFuchsia = lipgloss.AdaptiveColor{Light: "#F1A8FF", Dark: "#99519E"} 17 | dullFuchsia = lipgloss.AdaptiveColor{Dark: "#AD58B4", Light: "#F793FF"} 18 | dimDullFuchsia = lipgloss.AdaptiveColor{Light: "#F6C9FF", Dark: "#7B4380"} 19 | green = lipgloss.Color("#04B575") 20 | red = lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"} 21 | semiDimGreen = lipgloss.AdaptiveColor{Light: "#35D79C", Dark: "#036B46"} 22 | dimGreen = lipgloss.AdaptiveColor{Light: "#72D2B0", Dark: "#0B5137"} 23 | ) 24 | 25 | // Ulimately, we'll transition to named styles. 26 | var ( 27 | dimNormalFg = lipgloss.NewStyle().Foreground(normalDim).Render 28 | brightGrayFg = lipgloss.NewStyle().Foreground(brightGray).Render 29 | dimBrightGrayFg = lipgloss.NewStyle().Foreground(dimBrightGray).Render 30 | grayFg = lipgloss.NewStyle().Foreground(gray).Render 31 | midGrayFg = lipgloss.NewStyle().Foreground(midGray).Render 32 | darkGrayFg = lipgloss.NewStyle().Foreground(darkGray) 33 | greenFg = lipgloss.NewStyle().Foreground(green).Render 34 | semiDimGreenFg = lipgloss.NewStyle().Foreground(semiDimGreen).Render 35 | dimGreenFg = lipgloss.NewStyle().Foreground(dimGreen).Render 36 | fuchsiaFg = lipgloss.NewStyle().Foreground(fuchsia).Render 37 | dimFuchsiaFg = lipgloss.NewStyle().Foreground(dimFuchsia).Render 38 | dullFuchsiaFg = lipgloss.NewStyle().Foreground(dullFuchsia).Render 39 | dimDullFuchsiaFg = lipgloss.NewStyle().Foreground(dimDullFuchsia).Render 40 | redFg = lipgloss.NewStyle().Foreground(red).Render 41 | tabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"}) 42 | selectedTabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#333333", Dark: "#979797"}) 43 | errorTitleStyle = lipgloss.NewStyle().Foreground(cream).Background(red).Padding(0, 1) 44 | subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}) 45 | paginationStyle = subtleStyle 46 | ) 47 | -------------------------------------------------------------------------------- /ui/ui.go: -------------------------------------------------------------------------------- 1 | // Package ui provides the main UI for the glow application. 2 | package ui 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/glamour/styles" 13 | "github.com/charmbracelet/glow/v2/utils" 14 | "github.com/charmbracelet/log" 15 | "github.com/muesli/gitcha" 16 | te "github.com/muesli/termenv" 17 | ) 18 | 19 | const ( 20 | statusMessageTimeout = time.Second * 3 // how long to show status messages like "stashed!" 21 | ellipsis = "…" 22 | ) 23 | 24 | var ( 25 | config Config 26 | 27 | markdownExtensions = []string{ 28 | "*.md", "*.mdown", "*.mkdn", "*.mkd", "*.markdown", 29 | } 30 | ) 31 | 32 | // NewProgram returns a new Tea program. 33 | func NewProgram(cfg Config, content string) *tea.Program { 34 | log.Debug( 35 | "Starting glow", 36 | "high_perf_pager", 37 | cfg.HighPerformancePager, 38 | "glamour", 39 | cfg.GlamourEnabled, 40 | ) 41 | 42 | config = cfg 43 | opts := []tea.ProgramOption{tea.WithAltScreen()} 44 | if cfg.EnableMouse { 45 | opts = append(opts, tea.WithMouseCellMotion()) 46 | } 47 | m := newModel(cfg, content) 48 | return tea.NewProgram(m, opts...) 49 | } 50 | 51 | type errMsg struct{ err error } 52 | 53 | func (e errMsg) Error() string { return e.err.Error() } 54 | 55 | type ( 56 | initLocalFileSearchMsg struct { 57 | cwd string 58 | ch chan gitcha.SearchResult 59 | } 60 | ) 61 | 62 | type ( 63 | foundLocalFileMsg gitcha.SearchResult 64 | localFileSearchFinished struct{} 65 | statusMessageTimeoutMsg applicationContext 66 | ) 67 | 68 | // applicationContext indicates the area of the application something applies 69 | // to. Occasionally used as an argument to commands and messages. 70 | type applicationContext int 71 | 72 | const ( 73 | stashContext applicationContext = iota 74 | pagerContext 75 | ) 76 | 77 | // state is the top-level application state. 78 | type state int 79 | 80 | const ( 81 | stateShowStash state = iota 82 | stateShowDocument 83 | ) 84 | 85 | func (s state) String() string { 86 | return map[state]string{ 87 | stateShowStash: "showing file listing", 88 | stateShowDocument: "showing document", 89 | }[s] 90 | } 91 | 92 | // Common stuff we'll need to access in all models. 93 | type commonModel struct { 94 | cfg Config 95 | cwd string 96 | width int 97 | height int 98 | } 99 | 100 | type model struct { 101 | common *commonModel 102 | state state 103 | fatalErr error 104 | 105 | // Sub-models 106 | stash stashModel 107 | pager pagerModel 108 | 109 | // Channel that receives paths to local markdown files 110 | // (via the github.com/muesli/gitcha package) 111 | localFileFinder chan gitcha.SearchResult 112 | } 113 | 114 | // unloadDocument unloads a document from the pager. Note that while this 115 | // method alters the model we also need to send along any commands returned. 116 | func (m *model) unloadDocument() []tea.Cmd { 117 | m.state = stateShowStash 118 | m.stash.viewState = stashStateReady 119 | m.pager.unload() 120 | m.pager.showHelp = false 121 | 122 | var batch []tea.Cmd 123 | if m.pager.viewport.HighPerformanceRendering { 124 | batch = append(batch, tea.ClearScrollArea) //nolint:staticcheck 125 | } 126 | 127 | if !m.stash.shouldSpin() { 128 | batch = append(batch, m.stash.spinner.Tick) 129 | } 130 | return batch 131 | } 132 | 133 | func newModel(cfg Config, content string) tea.Model { 134 | initSections() 135 | 136 | if cfg.GlamourStyle == styles.AutoStyle { 137 | if te.HasDarkBackground() { 138 | cfg.GlamourStyle = styles.DarkStyle 139 | } else { 140 | cfg.GlamourStyle = styles.LightStyle 141 | } 142 | } 143 | 144 | common := commonModel{ 145 | cfg: cfg, 146 | } 147 | 148 | m := model{ 149 | common: &common, 150 | state: stateShowStash, 151 | pager: newPagerModel(&common), 152 | stash: newStashModel(&common), 153 | } 154 | 155 | path := cfg.Path 156 | if path == "" && content != "" { 157 | m.state = stateShowDocument 158 | m.pager.currentDocument = markdown{Body: content} 159 | return m 160 | } 161 | 162 | if path == "" { 163 | path = "." 164 | } 165 | info, err := os.Stat(path) 166 | if err != nil { 167 | log.Error("unable to stat file", "file", path, "error", err) 168 | m.fatalErr = err 169 | return m 170 | } 171 | if info.IsDir() { 172 | m.state = stateShowStash 173 | } else { 174 | cwd, _ := os.Getwd() 175 | m.state = stateShowDocument 176 | m.pager.currentDocument = markdown{ 177 | localPath: path, 178 | Note: stripAbsolutePath(path, cwd), 179 | Modtime: info.ModTime(), 180 | } 181 | } 182 | 183 | return m 184 | } 185 | 186 | func (m model) Init() tea.Cmd { 187 | cmds := []tea.Cmd{m.stash.spinner.Tick} 188 | 189 | switch m.state { 190 | case stateShowStash: 191 | cmds = append(cmds, findLocalFiles(*m.common)) 192 | case stateShowDocument: 193 | content, err := os.ReadFile(m.common.cfg.Path) 194 | if err != nil { 195 | log.Error("unable to read file", "file", m.common.cfg.Path, "error", err) 196 | return func() tea.Msg { return errMsg{err} } 197 | } 198 | body := string(utils.RemoveFrontmatter(content)) 199 | cmds = append(cmds, renderWithGlamour(m.pager, body)) 200 | } 201 | 202 | return tea.Batch(cmds...) 203 | } 204 | 205 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 206 | // If there's been an error, any key exits 207 | if m.fatalErr != nil { 208 | if _, ok := msg.(tea.KeyMsg); ok { 209 | return m, tea.Quit 210 | } 211 | } 212 | 213 | var cmds []tea.Cmd 214 | 215 | switch msg := msg.(type) { 216 | case tea.KeyMsg: 217 | switch msg.String() { 218 | case "esc": 219 | if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument { 220 | batch := m.unloadDocument() 221 | return m, tea.Batch(batch...) 222 | } 223 | case "r": 224 | var cmd tea.Cmd 225 | if m.state == stateShowStash { 226 | // pass through all keys if we're editing the filter 227 | if m.stash.filterState == filtering { 228 | m.stash, cmd = m.stash.update(msg) 229 | return m, cmd 230 | } 231 | m.stash.markdowns = nil 232 | return m, m.Init() 233 | } 234 | 235 | case "q": 236 | var cmd tea.Cmd 237 | 238 | switch m.state { //nolint:exhaustive 239 | case stateShowStash: 240 | // pass through all keys if we're editing the filter 241 | if m.stash.filterState == filtering { 242 | m.stash, cmd = m.stash.update(msg) 243 | return m, cmd 244 | } 245 | } 246 | 247 | return m, tea.Quit 248 | 249 | case "left", "h", "delete": 250 | if m.state == stateShowDocument { 251 | cmds = append(cmds, m.unloadDocument()...) 252 | return m, tea.Batch(cmds...) 253 | } 254 | 255 | case "ctrl+z": 256 | return m, tea.Suspend 257 | 258 | // Ctrl+C always quits no matter where in the application you are. 259 | case "ctrl+c": 260 | return m, tea.Quit 261 | } 262 | 263 | // Window size is received when starting up and on every resize 264 | case tea.WindowSizeMsg: 265 | m.common.width = msg.Width 266 | m.common.height = msg.Height 267 | m.stash.setSize(msg.Width, msg.Height) 268 | m.pager.setSize(msg.Width, msg.Height) 269 | 270 | case initLocalFileSearchMsg: 271 | m.localFileFinder = msg.ch 272 | m.common.cwd = msg.cwd 273 | cmds = append(cmds, findNextLocalFile(m)) 274 | 275 | case fetchedMarkdownMsg: 276 | // We've loaded a markdown file's contents for rendering 277 | m.pager.currentDocument = *msg 278 | body := string(utils.RemoveFrontmatter([]byte(msg.Body))) 279 | cmds = append(cmds, renderWithGlamour(m.pager, body)) 280 | 281 | case contentRenderedMsg: 282 | m.state = stateShowDocument 283 | 284 | case localFileSearchFinished: 285 | // Always pass these messages to the stash so we can keep it updated 286 | // about network activity, even if the user isn't currently viewing 287 | // the stash. 288 | stashModel, cmd := m.stash.update(msg) 289 | m.stash = stashModel 290 | return m, cmd 291 | 292 | case foundLocalFileMsg: 293 | newMd := localFileToMarkdown(m.common.cwd, gitcha.SearchResult(msg)) 294 | m.stash.addMarkdowns(newMd) 295 | if m.stash.filterApplied() { 296 | newMd.buildFilterValue() 297 | } 298 | if m.stash.shouldUpdateFilter() { 299 | cmds = append(cmds, filterMarkdowns(m.stash)) 300 | } 301 | cmds = append(cmds, findNextLocalFile(m)) 302 | 303 | case filteredMarkdownMsg: 304 | if m.state == stateShowDocument { 305 | newStashModel, cmd := m.stash.update(msg) 306 | m.stash = newStashModel 307 | cmds = append(cmds, cmd) 308 | } 309 | } 310 | 311 | // Process children 312 | switch m.state { 313 | case stateShowStash: 314 | newStashModel, cmd := m.stash.update(msg) 315 | m.stash = newStashModel 316 | cmds = append(cmds, cmd) 317 | 318 | case stateShowDocument: 319 | newPagerModel, cmd := m.pager.update(msg) 320 | m.pager = newPagerModel 321 | cmds = append(cmds, cmd) 322 | } 323 | 324 | return m, tea.Batch(cmds...) 325 | } 326 | 327 | func (m model) View() string { 328 | if m.fatalErr != nil { 329 | return errorView(m.fatalErr, true) 330 | } 331 | 332 | switch m.state { //nolint:exhaustive 333 | case stateShowDocument: 334 | return m.pager.View() 335 | default: 336 | return m.stash.view() 337 | } 338 | } 339 | 340 | func errorView(err error, fatal bool) string { 341 | exitMsg := "press any key to " 342 | if fatal { 343 | exitMsg += "exit" 344 | } else { 345 | exitMsg += "return" 346 | } 347 | s := fmt.Sprintf("%s\n\n%v\n\n%s", 348 | errorTitleStyle.Render("ERROR"), 349 | err, 350 | subtleStyle.Render(exitMsg), 351 | ) 352 | return "\n" + indent(s, 3) 353 | } 354 | 355 | // COMMANDS 356 | 357 | func findLocalFiles(m commonModel) tea.Cmd { 358 | return func() tea.Msg { 359 | log.Info("findLocalFiles") 360 | var ( 361 | cwd = m.cfg.Path 362 | err error 363 | ) 364 | 365 | if cwd == "" { 366 | cwd, err = os.Getwd() 367 | } else { 368 | var info os.FileInfo 369 | info, err = os.Stat(cwd) 370 | if err == nil && info.IsDir() { 371 | cwd, err = filepath.Abs(cwd) 372 | } 373 | } 374 | 375 | // Note that this is one error check for both cases above 376 | if err != nil { 377 | log.Error("error finding local files", "error", err) 378 | return errMsg{err} 379 | } 380 | 381 | log.Debug("local directory is", "cwd", cwd) 382 | 383 | // Switch between FindFiles and FindAllFiles to bypass .gitignore rules 384 | var ch chan gitcha.SearchResult 385 | if m.cfg.ShowAllFiles { 386 | ch, err = gitcha.FindAllFilesExcept(cwd, markdownExtensions, nil) 387 | } else { 388 | ch, err = gitcha.FindFilesExcept(cwd, markdownExtensions, ignorePatterns(m)) 389 | } 390 | 391 | if err != nil { 392 | log.Error("error finding local files", "error", err) 393 | return errMsg{err} 394 | } 395 | 396 | return initLocalFileSearchMsg{ch: ch, cwd: cwd} 397 | } 398 | } 399 | 400 | func findNextLocalFile(m model) tea.Cmd { 401 | return func() tea.Msg { 402 | res, ok := <-m.localFileFinder 403 | 404 | if ok { 405 | // Okay now find the next one 406 | return foundLocalFileMsg(res) 407 | } 408 | // We're done 409 | log.Debug("local file search finished") 410 | return localFileSearchFinished{} 411 | } 412 | } 413 | 414 | func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.Cmd { 415 | return func() tea.Msg { 416 | <-t.C 417 | return statusMessageTimeoutMsg(appCtx) 418 | } 419 | } 420 | 421 | // ETC 422 | 423 | // Convert a Gitcha result to an internal representation of a markdown 424 | // document. Note that we could be doing things like checking if the file is 425 | // a directory, but we trust that gitcha has already done that. 426 | func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown { 427 | return &markdown{ 428 | localPath: res.Path, 429 | Note: stripAbsolutePath(res.Path, cwd), 430 | Modtime: res.Info.ModTime(), 431 | } 432 | } 433 | 434 | func stripAbsolutePath(fullPath, cwd string) string { 435 | fp, _ := filepath.EvalSymlinks(fullPath) 436 | cp, _ := filepath.EvalSymlinks(cwd) 437 | return strings.ReplaceAll(fp, cp+string(os.PathSeparator), "") 438 | } 439 | 440 | // Lightweight version of reflow's indent function. 441 | func indent(s string, n int) string { 442 | if n <= 0 || s == "" { 443 | return s 444 | } 445 | l := strings.Split(s, "\n") 446 | b := strings.Builder{} 447 | i := strings.Repeat(" ", n) 448 | for _, v := range l { 449 | fmt.Fprintf(&b, "%s%s\n", i, v) 450 | } 451 | return b.String() 452 | } 453 | -------------------------------------------------------------------------------- /url.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | "sync" 8 | ) 9 | 10 | const ( 11 | protoGithub = "github://" 12 | protoGitlab = "gitlab://" 13 | protoHTTPS = "https://" 14 | ) 15 | 16 | var ( 17 | githubURL *url.URL 18 | gitlabURL *url.URL 19 | urlsOnce sync.Once 20 | ) 21 | 22 | func init() { 23 | urlsOnce.Do(func() { 24 | githubURL, _ = url.Parse("https://github.com") 25 | gitlabURL, _ = url.Parse("https://gitlab.com") 26 | }) 27 | } 28 | 29 | func readmeURL(path string) (*source, error) { 30 | switch { 31 | case strings.HasPrefix(path, protoGithub): 32 | if u := githubReadmeURL(path); u != nil { 33 | return readmeURL(u.String()) 34 | } 35 | return nil, nil 36 | case strings.HasPrefix(path, protoGitlab): 37 | if u := gitlabReadmeURL(path); u != nil { 38 | return readmeURL(u.String()) 39 | } 40 | return nil, nil 41 | } 42 | 43 | if !strings.HasPrefix(path, protoHTTPS) { 44 | path = protoHTTPS + path 45 | } 46 | u, err := url.Parse(path) 47 | if err != nil { 48 | return nil, fmt.Errorf("unable to parse url: %w", err) 49 | } 50 | 51 | switch { 52 | case u.Hostname() == githubURL.Hostname(): 53 | return findGitHubREADME(u) 54 | case u.Hostname() == gitlabURL.Hostname(): 55 | return findGitLabREADME(u) 56 | } 57 | 58 | return nil, nil 59 | } 60 | 61 | func githubReadmeURL(path string) *url.URL { 62 | path = strings.TrimPrefix(path, protoGithub) 63 | parts := strings.Split(path, "/") 64 | if len(parts) != 2 { 65 | // custom hostnames are not supported yet 66 | return nil 67 | } 68 | u, _ := url.Parse(githubURL.String()) 69 | return u.JoinPath(path) 70 | } 71 | 72 | func gitlabReadmeURL(path string) *url.URL { 73 | path = strings.TrimPrefix(path, protoGitlab) 74 | parts := strings.Split(path, "/") 75 | if len(parts) != 2 { 76 | // custom hostnames are not supported yet 77 | return nil 78 | } 79 | u, _ := url.Parse(gitlabURL.String()) 80 | return u.JoinPath(path) 81 | } 82 | 83 | func isURL(path string) bool { 84 | _, err := url.ParseRequestURI(path) 85 | return err == nil && strings.Contains(path, "://") 86 | } 87 | -------------------------------------------------------------------------------- /url_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestURLParser(t *testing.T) { 6 | for path, url := range map[string]string{ 7 | "github.com/charmbracelet/glow": "https://raw.githubusercontent.com/charmbracelet/glow/master/README.md", 8 | "github://charmbracelet/glow": "https://raw.githubusercontent.com/charmbracelet/glow/master/README.md", 9 | "github://caarlos0/dotfiles.fish": "https://raw.githubusercontent.com/caarlos0/dotfiles.fish/main/README.md", 10 | "github://tj/git-extras": "https://raw.githubusercontent.com/tj/git-extras/main/Readme.md", 11 | "https://github.com/goreleaser/nfpm": "https://raw.githubusercontent.com/goreleaser/nfpm/main/README.md", 12 | "gitlab.com/caarlos0/test": "https://gitlab.com/caarlos0/test/-/raw/master/README.md", 13 | "gitlab://caarlos0/test": "https://gitlab.com/caarlos0/test/-/raw/master/README.md", 14 | "https://gitlab.com/terrakok/gitlab-client": "https://gitlab.com/terrakok/gitlab-client/-/raw/develop/Readme.md", 15 | } { 16 | t.Run(path, func(t *testing.T) { 17 | t.Skip("test uses network, sometimes fails for no reason") 18 | got, err := readmeURL(path) 19 | if err != nil { 20 | t.Fatalf("expected no error, got %v", err) 21 | } 22 | if got == nil { 23 | t.Fatalf("should not be nil") 24 | } 25 | if url != got.URL { 26 | t.Errorf("expected url for %s to be %s, was %s", path, url, got.URL) 27 | } 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | // Package utils provides utility functions. 2 | package utils 3 | 4 | import ( 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/charmbracelet/glamour" 11 | "github.com/charmbracelet/glamour/ansi" 12 | "github.com/charmbracelet/glamour/styles" 13 | "github.com/charmbracelet/lipgloss" 14 | "github.com/mitchellh/go-homedir" 15 | ) 16 | 17 | // RemoveFrontmatter removes the front matter header of a markdown file. 18 | func RemoveFrontmatter(content []byte) []byte { 19 | if frontmatterBoundaries := detectFrontmatter(content); frontmatterBoundaries[0] == 0 { 20 | return content[frontmatterBoundaries[1]:] 21 | } 22 | return content 23 | } 24 | 25 | var yamlPattern = regexp.MustCompile(`(?m)^---\r?\n(\s*\r?\n)?`) 26 | 27 | func detectFrontmatter(c []byte) []int { 28 | if matches := yamlPattern.FindAllIndex(c, 2); len(matches) > 1 { 29 | return []int{matches[0][0], matches[1][1]} 30 | } 31 | return []int{-1, -1} 32 | } 33 | 34 | // ExpandPath expands tilde and all environment variables from the given path. 35 | func ExpandPath(path string) string { 36 | s, err := homedir.Expand(path) 37 | if err == nil { 38 | return os.ExpandEnv(s) 39 | } 40 | return os.ExpandEnv(path) 41 | } 42 | 43 | // WrapCodeBlock wraps a string in a code block with the given language. 44 | func WrapCodeBlock(s, language string) string { 45 | return "```" + language + "\n" + s + "```" 46 | } 47 | 48 | var markdownExtensions = []string{ 49 | ".md", ".mdown", ".mkdn", ".mkd", ".markdown", 50 | } 51 | 52 | // IsMarkdownFile returns whether the filename has a markdown extension. 53 | func IsMarkdownFile(filename string) bool { 54 | ext := filepath.Ext(filename) 55 | 56 | if ext == "" { 57 | // By default, assume it's a markdown file. 58 | return true 59 | } 60 | 61 | for _, v := range markdownExtensions { 62 | if strings.EqualFold(ext, v) { 63 | return true 64 | } 65 | } 66 | 67 | // Has an extension but not markdown 68 | // so assume this is a code file. 69 | return false 70 | } 71 | 72 | // GlamourStyle returns a glamour.TermRendererOption based on the given style. 73 | func GlamourStyle(style string, isCode bool) glamour.TermRendererOption { 74 | if !isCode { 75 | if style == styles.AutoStyle { 76 | return glamour.WithAutoStyle() 77 | } 78 | return glamour.WithStylePath(style) 79 | } 80 | 81 | // If we are rendering a pure code block, we need to modify the style to 82 | // remove the indentation. 83 | 84 | var styleConfig ansi.StyleConfig 85 | 86 | switch style { 87 | case styles.AutoStyle: 88 | if lipgloss.HasDarkBackground() { 89 | styleConfig = styles.DarkStyleConfig 90 | } else { 91 | styleConfig = styles.LightStyleConfig 92 | } 93 | case styles.DarkStyle: 94 | styleConfig = styles.DarkStyleConfig 95 | case styles.LightStyle: 96 | styleConfig = styles.LightStyleConfig 97 | case styles.PinkStyle: 98 | styleConfig = styles.PinkStyleConfig 99 | case styles.NoTTYStyle: 100 | styleConfig = styles.NoTTYStyleConfig 101 | case styles.DraculaStyle: 102 | styleConfig = styles.DraculaStyleConfig 103 | case styles.TokyoNightStyle: 104 | styleConfig = styles.DraculaStyleConfig 105 | default: 106 | return glamour.WithStylesFromJSONFile(style) 107 | } 108 | 109 | var margin uint 110 | styleConfig.CodeBlock.Margin = &margin 111 | 112 | return glamour.WithStyles(styleConfig) 113 | } 114 | --------------------------------------------------------------------------------