[
  {
    "path": ".editorconfig",
    "content": "# https://editorconfig.org/\n\nroot = true\n\n[*]\ncharset = utf-8\ninsert_final_newline = true\ntrim_trailing_whitespace = true\nindent_style = space\nindent_size = 2\n\n[*.go]\nindent_style = tab\nindent_size = 8\n\n[*.golden]\ninsert_final_newline = false\ntrim_trailing_whitespace = false\n"
  },
  {
    "path": ".github/CODEOWNERS",
    "content": "* @charmbracelet/everyone\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**Setup**\nPlease complete the following information along with version numbers, if applicable.\n - OS [e.g. Ubuntu, macOS]\n - Shell [e.g. zsh, fish]\n - Terminal Emulator [e.g. kitty, iterm]\n - Terminal Multiplexer [e.g. tmux]\n - Locale [e.g. en_US.UTF-8, zh_CN.UTF-8, etc.]\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Source Code**\nPlease include source code if needed to reproduce the behavior. \n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nAdd screenshots to help explain your problem.\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: true\ncontact_links:\n- name: Discord\n  url: https://charm.sh/discord\n  about: Chat on our Discord.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: enhancement\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\n\nupdates:\n  - package-ecosystem: \"gomod\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n      day: \"monday\"\n      time: \"05:00\"\n      timezone: \"America/New_York\"\n    labels:\n      - \"dependencies\"\n    commit-message:\n      prefix: \"chore\"\n      include: \"scope\"\n    groups:\n      all:\n        patterns:\n          - \"*\"\n    ignore:\n      - dependency-name: github.com/charmbracelet/bubbletea/v2\n        versions:\n          - v2.0.0-beta1\n\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n      day: \"monday\"\n      time: \"05:00\"\n      timezone: \"America/New_York\"\n    labels:\n      - \"dependencies\"\n    commit-message:\n      prefix: \"chore\"\n      include: \"scope\"\n    groups:\n      all:\n        patterns:\n          - \"*\"\n\n  - package-ecosystem: \"docker\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n      day: \"monday\"\n      time: \"05:00\"\n      timezone: \"America/New_York\"\n    labels:\n      - \"dependencies\"\n    commit-message:\n      prefix: \"chore\"\n      include: \"scope\"\n    groups:\n      all:\n        patterns:\n          - \"*\"\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: build\n\non: [push, pull_request]\n\njobs:\n  build:\n    uses: charmbracelet/meta/.github/workflows/build.yml@main\n\n  snapshot:\n    uses: charmbracelet/meta/.github/workflows/snapshot.yml@main\n    secrets:\n      goreleaser_key: ${{ secrets.GORELEASER_KEY }}\n\n  govulncheck:\n    uses: charmbracelet/meta/.github/workflows/govulncheck.yml@main\n    with:\n      go-version: stable\n\n  semgrep:\n    uses: charmbracelet/meta/.github/workflows/semgrep.yml@main\n\n  ruleguard:\n    uses: charmbracelet/meta/.github/workflows/ruleguard.yml@main\n    with:\n      go-version: stable\n"
  },
  {
    "path": ".github/workflows/coverage.yml",
    "content": "name: coverage\non: [push, pull_request]\n\njobs:\n  coverage:\n    runs-on: ubuntu-latest\n    env:\n      GO111MODULE: \"on\"\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Install Go\n        uses: actions/setup-go@v6\n        with:\n          go-version: stable\n\n      - name: Coverage\n        env:\n          COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          go test -race -covermode atomic -coverprofile=profile.cov ./...\n          go install github.com/mattn/goveralls@latest\n          goveralls -coverprofile=profile.cov -service=github\n"
  },
  {
    "path": ".github/workflows/dependabot-sync.yml",
    "content": "name: dependabot-sync\non:\n  schedule:\n    - cron: \"0 0 * * 0\" # every Sunday at midnight\n  workflow_dispatch: # allows manual triggering\n\npermissions:\n  contents: write\n  pull-requests: write\n\njobs:\n  dependabot-sync:\n    uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main\n    with:\n      repo_name: ${{ github.event.repository.name }}\n    secrets:\n      gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/goreleaser.yml",
    "content": "# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json\n\nname: goreleaser\n\non:\n  push:\n    tags:\n      - v*.*.*\n\nconcurrency:\n  group: goreleaser\n  cancel-in-progress: true\n\njobs:\n  goreleaser:\n    uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main\n    secrets:\n      docker_username: ${{ secrets.DOCKERHUB_USERNAME }}\n      docker_token: ${{ secrets.DOCKERHUB_TOKEN }}\n      gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }}\n      goreleaser_key: ${{ secrets.GORELEASER_KEY }}\n      fury_token: ${{ secrets.FURY_TOKEN }}\n      nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }}\n      nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }}\n      snapcraft_token: ${{ secrets.SNAPCRAFT_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/lint-sync.yml",
    "content": "name: lint-sync\non:\n  schedule:\n    - cron: \"0 0 * * 0\" # every sunday at midnight\n  workflow_dispatch:\n\npermissions:\n  contents: write\n  pull-requests: write\n\njobs:\n  lint:\n    uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "name: lint\non:\n  push:\n  pull_request:\n\njobs:\n  golangci:\n    name: lint\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - name: golangci-lint\n        uses: golangci/golangci-lint-action@v9.2.0\n        with:\n          # Optional: golangci-lint command line arguments.\n          args: --issues-exit-code=0\n          # Optional: show only new issues if it's a pull request. The default value is `false`.\n          only-new-issues: true\n"
  },
  {
    "path": ".gitignore",
    "content": "glow\ndist/\n.envrc\ncompletions/\nmanpages/\n"
  },
  {
    "path": ".golangci.yml",
    "content": "version: \"2\"\nrun:\n  tests: false\nlinters:\n  enable:\n    - bodyclose\n    - exhaustive\n    - goconst\n    - godot\n    - gomoddirectives\n    - goprintffuncname\n    - gosec\n    - misspell\n    - nakedret\n    - nestif\n    - nilerr\n    - noctx\n    - nolintlint\n    - prealloc\n    - revive\n    - rowserrcheck\n    - sqlclosecheck\n    - tparallel\n    - unconvert\n    - unparam\n    - whitespace\n    - wrapcheck\n  exclusions:\n    rules:\n      - text: '(slog|log)\\.\\w+'\n        linters:\n          - noctx\n    generated: lax\n    presets:\n      - common-false-positives\nissues:\n  max-issues-per-linter: 0\n  max-same-issues: 0\nformatters:\n  enable:\n    - gofumpt\n    - goimports\n  exclusions:\n    generated: lax\n"
  },
  {
    "path": ".goreleaser.yml",
    "content": "# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json\n\nversion: 2\n\nincludes:\n  - from_url:\n      url: charmbracelet/meta/main/goreleaser-glow.yaml\n\nvariables:\n  description: \"Render markdown on the CLI, with pizzazz!\"\n  github_url: \"https://github.com/charmbracelet/glow\"\n  maintainer: \"Christian Muehlhaeuser <muesli@charm.sh>\"\n  brew_commit_author_name: \"Christian Muehlhaeuser\"\n  brew_commit_author_email: \"muesli@charm.sh\"\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM gcr.io/distroless/static\nCOPY glow /usr/local/bin/glow\nENTRYPOINT [ \"/usr/local/bin/glow\" ]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2019-2024 Charmbracelet, Inc\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Glow\n\nRender markdown on the CLI, with _pizzazz_!\n\n<p align=\"center\">\n    <img src=\"https://stuff.charm.sh/glow/glow-banner-github.gif\" alt=\"Glow Logo\">\n    <a href=\"https://github.com/charmbracelet/glow/releases\"><img src=\"https://img.shields.io/github/release/charmbracelet/glow.svg\" alt=\"Latest Release\"></a>\n    <a href=\"https://pkg.go.dev/github.com/charmbracelet/glow?tab=doc\"><img src=\"https://godoc.org/github.com/golang/gddo?status.svg\" alt=\"GoDoc\"></a>\n    <a href=\"https://github.com/charmbracelet/glow/actions\"><img src=\"https://github.com/charmbracelet/glow/workflows/build/badge.svg\" alt=\"Build Status\"></a>\n    <a href=\"https://goreportcard.com/report/github.com/charmbracelet/glow\"><img src=\"https://goreportcard.com/badge/charmbracelet/glow\" alt=\"Go ReportCard\"></a>\n</p>\n\n<p align=\"center\">\n    <img src=\"https://github.com/user-attachments/assets/c2246366-f84b-4847-b431-32a61ca07b74\" width=\"800\" alt=\"Glow UI Demo\">\n</p>\n\n## What is it?\n\nGlow is a terminal based markdown reader designed from the ground up to bring\nout the beauty—and power—of the CLI.\n\nUse it to discover markdown files, read documentation directly on the command\nline. Glow will find local markdown files in subdirectories or a local\nGit repository.\n\n## Installation\n\n### Package Manager\n\n```bash\n# macOS or Linux\nbrew install glow\n```\n\n```bash\n# macOS (with MacPorts)\nsudo port install glow\n```\n\n```bash\n# Arch Linux (btw)\npacman -S glow\n```\n\n```bash\n# Void Linux\nxbps-install -S glow\n```\n\n```bash\n# Nix shell\nnix-shell -p glow --command glow\n```\n\n```bash\n# FreeBSD\npkg install glow\n```\n\n```bash\n# Solus\neopkg install glow\n```\n\n```bash\n# Windows (with Chocolatey, Scoop, or Winget)\nchoco install glow\nscoop install glow\nwinget install charmbracelet.glow\n```\n\n```bash\n# Android (with termux)\npkg install glow\n```\n\n```bash\n# Ubuntu (Snapcraft)\nsudo snap install glow\n```\n\n```bash\n# Debian/Ubuntu\nsudo mkdir -p /etc/apt/keyrings\ncurl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg\necho \"deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *\" | sudo tee /etc/apt/sources.list.d/charm.list\nsudo apt update && sudo apt install glow\n```\n\n```bash\n# Fedora/RHEL\necho '[charm]\nname=Charm\nbaseurl=https://repo.charm.sh/yum/\nenabled=1\ngpgcheck=1\ngpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo\nsudo yum install glow\n```\n\nOr download a binary from the [releases][releases] page. MacOS, Linux, Windows,\nFreeBSD and OpenBSD binaries are available, as well as Debian, RPM, and Alpine\npackages. ARM builds are also available for macOS, Linux, FreeBSD and OpenBSD.\n\n### Go\n\nOr just install it with `go`:\n\n```bash\ngo install github.com/charmbracelet/glow/v2@latest\n```\n\n### Build (requires Go 1.21+)\n\n```bash\ngit clone https://github.com/charmbracelet/glow.git\ncd glow\ngo build\n```\n\n[releases]: https://github.com/charmbracelet/glow/releases\n\n## The TUI\n\nSimply run `glow` without arguments to start the textual user interface and\nbrowse local. Glow will find local markdown files in the\ncurrent directory and below or, if you’re in a Git repository, Glow will search\nthe repo.\n\nMarkdown files can be read with Glow's high-performance pager. Most of the\nkeystrokes you know from `less` are the same, but you can press `?` to list\nthe hotkeys.\n\n## The CLI\n\nIn addition to a TUI, Glow has a CLI for working with Markdown. To format a\ndocument use a markdown source as the primary argument:\n\n```bash\n# Read from file\nglow README.md\n\n# Read from stdin\necho \"[Glow](https://github.com/charmbracelet/glow)\" | glow -\n\n# Fetch README from GitHub / GitLab\nglow github.com/charmbracelet/glow\n\n# Fetch markdown from HTTP\nglow https://host.tld/file.md\n```\n\n### Word Wrapping\n\nThe `-w` flag lets you set a maximum width at which the output will be wrapped:\n\n```bash\nglow -w 60\n```\n\n### Paging\n\nCLI output can be displayed in your preferred pager with the `-p` flag. This defaults\nto the ANSI-aware `less -r` if `$PAGER` is not explicitly set.\n\n### Styles\n\nYou can choose a style with the `-s` flag. When no flag is provided `glow` tries\nto detect your terminal's current background color and automatically picks\neither the `dark` or the `light` style for you.\n\n```bash\nglow -s [dark|light]\n```\n\nAlternatively you can also supply a custom JSON stylesheet:\n\n```bash\nglow -s mystyle.json\n```\n\nFor additional usage details see:\n\n```bash\nglow --help\n```\n\nCheck out the [Glamour Style Section](https://github.com/charmbracelet/glamour/blob/master/styles/gallery/README.md)\nto find more styles. Or [make your own](https://github.com/charmbracelet/glamour/tree/master/styles)!\n\n## The Config File\n\nIf you find yourself supplying the same flags to `glow` all the time, it's\nprobably a good idea to create a config file. Run `glow config`, which will open\nit in your favorite $EDITOR. Alternatively you can manually put a file named\n`glow.yml` in the default config path of you platform. If you're not sure where\nthat is, please refer to `glow --help`.\n\nHere's an example config:\n\n```yaml\n# style name or JSON path (default \"auto\")\nstyle: \"light\"\n# mouse wheel support (TUI-mode only)\nmouse: true\n# use pager to display markdown\npager: true\n# at which column should we word wrap?\nwidth: 80\n# show all files, including hidden and ignored.\nall: false\n# show line numbers (TUI-mode only)\nshowLineNumbers: false\n# preserve newlines in the output\npreserveNewLines: false\n```\n\n## Contributing\n\nSee [contributing][contribute].\n\n[contribute]: https://github.com/charmbracelet/glow/contribute\n\n## Feedback\n\nWe’d love to hear your thoughts on this project. Feel free to drop us a note!\n\n- [Twitter](https://twitter.com/charmcli)\n- [The Fediverse](https://mastodon.social/@charmcli)\n- [Discord](https://charm.sh/chat)\n\n## License\n\n[MIT](https://github.com/charmbracelet/glow/raw/master/LICENSE)\n\n---\n\nPart of [Charm](https://charm.sh).\n\n<a href=\"https://charm.sh/\"><img alt=\"The Charm logo\" src=\"https://stuff.charm.sh/charm-badge.jpg\" width=\"400\"></a>\n\nCharm热爱开源 • Charm loves open source\n"
  },
  {
    "path": "Taskfile.yaml",
    "content": "# https://taskfile.dev\n\nversion: '3'\n\ntasks:\n  lint:\n    desc: Run base linters\n    cmds:\n      - golangci-lint run\n\n  test:\n    desc: Run tests\n    cmds:\n      - go test ./... {{.CLI_ARGS}}\n\n  log:\n    desc: Watch for glow logs\n    aliases: [tail]\n    cmds:\n      - cmd: tail -f ~/Library/Caches/glow/glow.log\n        platforms: [darwin]\n      - cmd: tail -f ~/.cache/glow/glow.log\n        platforms: [linux, windows]\n"
  },
  {
    "path": "config_cmd.go",
    "content": "package main\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\n\t\"github.com/charmbracelet/x/editor\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/viper\"\n)\n\nconst defaultConfig = `# style name or JSON path (default \"auto\")\nstyle: \"auto\"\n# mouse support (TUI-mode only)\nmouse: false\n# use pager to display markdown\npager: false\n# word-wrap at width\nwidth: 80\n# show all files, including hidden and ignored.\nall: false\n`\n\nvar configCmd = &cobra.Command{\n\tUse:     \"config\",\n\tHidden:  false,\n\tShort:   \"Edit the glow config file\",\n\tLong:    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\"))),\n\tExample: paragraph(\"glow config\\nglow config --config path/to/config.yml\"),\n\tArgs:    cobra.NoArgs,\n\tRunE: func(*cobra.Command, []string) error {\n\t\tif err := ensureConfigFile(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tc, err := editor.Cmd(\"Glow\", configFile)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"unable to set config file: %w\", err)\n\t\t}\n\t\tc.Stdin = os.Stdin\n\t\tc.Stdout = os.Stdout\n\t\tc.Stderr = os.Stderr\n\t\tif err := c.Run(); err != nil {\n\t\t\treturn fmt.Errorf(\"unable to run command: %w\", err)\n\t\t}\n\n\t\tfmt.Println(\"Wrote config file to:\", configFile)\n\t\treturn nil\n\t},\n}\n\nfunc ensureConfigFile() error {\n\tif configFile == \"\" {\n\t\tconfigFile = viper.GetViper().ConfigFileUsed()\n\t\tif err := os.MkdirAll(filepath.Dir(configFile), 0o755); err != nil { //nolint:gosec\n\t\t\treturn fmt.Errorf(\"could not write configuration file: %w\", err)\n\t\t}\n\t}\n\n\tif ext := path.Ext(configFile); ext != \".yaml\" && ext != \".yml\" {\n\t\treturn fmt.Errorf(\"'%s' is not a supported configuration type: use '%s' or '%s'\", ext, \".yaml\", \".yml\")\n\t}\n\n\tif _, err := os.Stat(configFile); errors.Is(err, fs.ErrNotExist) {\n\t\t// File doesn't exist yet, create all necessary directories and\n\t\t// write the default config file\n\t\tif err := os.MkdirAll(filepath.Dir(configFile), 0o700); err != nil {\n\t\t\treturn fmt.Errorf(\"unable create directory: %w\", err)\n\t\t}\n\n\t\tf, err := os.Create(configFile)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"unable to create config file: %w\", err)\n\t\t}\n\t\tdefer func() { _ = f.Close() }()\n\n\t\tif _, err := f.WriteString(defaultConfig); err != nil {\n\t\t\treturn fmt.Errorf(\"unable to write config file: %w\", err)\n\t\t}\n\t} else if err != nil { // some other error occurred\n\t\treturn fmt.Errorf(\"unable to stat config file: %w\", err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "console_windows.go",
    "content": "// +build windows\n\npackage main\n\nimport (\n\t\"os\"\n\n\t\"golang.org/x/sys/windows\"\n)\n\n// enableAnsiColors enables support for ANSI color sequences in Windows\n// default console. Note that this only works with Windows 10.\nfunc enableAnsiColors() {\n\tstdout := windows.Handle(os.Stdout.Fd())\n\tvar originalMode uint32\n\n\twindows.GetConsoleMode(stdout, &originalMode)\n\twindows.SetConsoleMode(stdout, originalMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING)\n}\n\nfunc init() {\n\tenableAnsiColors()\n}\n"
  },
  {
    "path": "github.go",
    "content": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n)\n\n// findGitHubREADME tries to find the correct README filename in a repository using GitHub API.\nfunc findGitHubREADME(u *url.URL) (*source, error) {\n\towner, repo, ok := strings.Cut(strings.TrimPrefix(u.Path, \"/\"), \"/\")\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"invalid url: %s\", u.String())\n\t}\n\n\ttype readme struct {\n\t\tDownloadURL string `json:\"download_url\"`\n\t}\n\n\tapiURL := fmt.Sprintf(\"https://api.%s/repos/%s/%s/readme\", u.Hostname(), owner, repo)\n\n\t//nolint:bodyclose\n\t// it is closed on the caller\n\tres, err := http.Get(apiURL) //nolint: gosec,noctx\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to get url: %w\", err)\n\t}\n\n\tbody, err := io.ReadAll(res.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to read http response body: %w\", err)\n\t}\n\n\tvar result readme\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to parse json: %w\", err)\n\t}\n\n\tif res.StatusCode == http.StatusOK {\n\t\t//nolint:bodyclose\n\t\t// it is closed on the caller\n\t\tresp, err := http.Get(result.DownloadURL) //nolint: noctx\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to get url: %w\", err)\n\t\t}\n\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn &source{resp.Body, result.DownloadURL}, nil\n\t\t}\n\t}\n\n\treturn nil, errors.New(\"can't find README in GitHub repository\")\n}\n"
  },
  {
    "path": "gitlab.go",
    "content": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n)\n\n// findGitLabREADME tries to find the correct README filename in a repository using GitLab API.\nfunc findGitLabREADME(u *url.URL) (*source, error) {\n\towner, repo, ok := strings.Cut(strings.TrimPrefix(u.Path, \"/\"), \"/\")\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"invalid url: %s\", u.String())\n\t}\n\n\tprojectPath := url.QueryEscape(owner + \"/\" + repo)\n\n\ttype readme struct {\n\t\tReadmeURL string `json:\"readme_url\"`\n\t}\n\n\tapiURL := fmt.Sprintf(\"https://%s/api/v4/projects/%s\", u.Hostname(), projectPath)\n\n\t//nolint:bodyclose\n\t// it is closed on the caller\n\tres, err := http.Get(apiURL) //nolint: gosec,noctx\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to get url: %w\", err)\n\t}\n\n\tbody, err := io.ReadAll(res.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to read http response body: %w\", err)\n\t}\n\n\tvar result readme\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to parse json: %w\", err)\n\t}\n\n\treadmeRawURL := strings.ReplaceAll(result.ReadmeURL, \"blob\", \"raw\")\n\n\tif res.StatusCode == http.StatusOK {\n\t\t//nolint:bodyclose\n\t\t// it is closed on the caller\n\t\tresp, err := http.Get(readmeRawURL) //nolint: gosec,noctx\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to get url: %w\", err)\n\t\t}\n\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn &source{resp.Body, readmeRawURL}, nil\n\t\t}\n\t}\n\n\treturn nil, errors.New(\"can't find README in GitLab repository\")\n}\n"
  },
  {
    "path": "glow_test.go",
    "content": "package main\n\nimport (\n\t\"testing\"\n)\n\nfunc TestGlowFlags(t *testing.T) {\n\ttt := []struct {\n\t\targs  []string\n\t\tcheck func() bool\n\t}{\n\t\t{\n\t\t\targs: []string{\"-p\"},\n\t\t\tcheck: func() bool {\n\t\t\t\treturn pager\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\targs: []string{\"-s\", \"light\"},\n\t\t\tcheck: func() bool {\n\t\t\t\treturn style == \"light\"\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\targs: []string{\"-w\", \"40\"},\n\t\t\tcheck: func() bool {\n\t\t\t\treturn width == 40\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, v := range tt {\n\t\terr := rootCmd.ParseFlags(v.args)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif !v.check() {\n\t\t\tt.Errorf(\"Parsing flag failed: %s\", v.args)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/charmbracelet/glow/v2\n\ngo 1.24.0\n\ntoolchain go1.24.1\n\nrequire (\n\tgithub.com/atotto/clipboard v0.1.4\n\tgithub.com/caarlos0/env/v11 v11.3.1\n\tgithub.com/charmbracelet/bubbles v0.21.0\n\tgithub.com/charmbracelet/bubbletea v1.3.10\n\tgithub.com/charmbracelet/glamour v0.10.0\n\tgithub.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834\n\tgithub.com/charmbracelet/log v0.4.2\n\tgithub.com/charmbracelet/x/editor v0.1.0\n\tgithub.com/dustin/go-humanize v1.0.1\n\tgithub.com/fsnotify/fsnotify v1.9.0\n\tgithub.com/mattn/go-runewidth v0.0.19\n\tgithub.com/mitchellh/go-homedir v1.1.0\n\tgithub.com/muesli/gitcha v0.3.0\n\tgithub.com/muesli/go-app-paths v0.2.2\n\tgithub.com/muesli/mango-cobra v1.3.0\n\tgithub.com/muesli/reflow v0.3.0\n\tgithub.com/muesli/roff v0.1.0\n\tgithub.com/muesli/termenv v0.16.0\n\tgithub.com/sahilm/fuzzy v0.1.1\n\tgithub.com/spf13/cobra v1.10.2\n\tgithub.com/spf13/viper v1.21.0\n\tgolang.org/x/sys v0.39.0\n\tgolang.org/x/term v0.38.0\n\tgolang.org/x/text v0.32.0\n)\n\nrequire (\n\tgithub.com/alecthomas/chroma/v2 v2.14.0 // indirect\n\tgithub.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect\n\tgithub.com/aymerick/douceur v0.2.0 // indirect\n\tgithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect\n\tgithub.com/charmbracelet/x/ansi v0.10.1 // indirect\n\tgithub.com/charmbracelet/x/cellbuf v0.0.13 // indirect\n\tgithub.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect\n\tgithub.com/charmbracelet/x/term v0.2.1 // indirect\n\tgithub.com/clipperhouse/uax29/v2 v2.2.0 // indirect\n\tgithub.com/dlclark/regexp2 v1.11.0 // indirect\n\tgithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect\n\tgithub.com/go-logfmt/logfmt v0.6.0 // indirect\n\tgithub.com/go-viper/mapstructure/v2 v2.4.0 // indirect\n\tgithub.com/gorilla/css v1.0.1 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/lucasb-eyer/go-colorful v1.2.0 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mattn/go-localereader v0.0.1 // indirect\n\tgithub.com/microcosm-cc/bluemonday v1.0.27 // indirect\n\tgithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect\n\tgithub.com/muesli/cancelreader v0.2.2 // indirect\n\tgithub.com/muesli/mango v0.2.0 // indirect\n\tgithub.com/muesli/mango-pflag v0.1.0 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.4 // indirect\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgithub.com/rogpeppe/go-internal v1.12.0 // indirect\n\tgithub.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 // indirect\n\tgithub.com/sagikazarmark/locafero v0.11.0 // indirect\n\tgithub.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect\n\tgithub.com/spf13/afero v1.15.0 // indirect\n\tgithub.com/spf13/cast v1.10.0 // indirect\n\tgithub.com/spf13/pflag v1.0.10 // indirect\n\tgithub.com/subosito/gotenv v1.6.0 // indirect\n\tgithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect\n\tgithub.com/yuin/goldmark v1.7.8 // indirect\n\tgithub.com/yuin/goldmark-emoji v1.0.5 // indirect\n\tgo.yaml.in/yaml/v3 v3.0.4 // indirect\n\tgolang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect\n\tgolang.org/x/net v0.40.0 // indirect\n\tgopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=\ngithub.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=\ngithub.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=\ngithub.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=\ngithub.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=\ngithub.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=\ngithub.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=\ngithub.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=\ngithub.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=\ngithub.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=\ngithub.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=\ngithub.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=\ngithub.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=\ngithub.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=\ngithub.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=\ngithub.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=\ngithub.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=\ngithub.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=\ngithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=\ngithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=\ngithub.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=\ngithub.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=\ngithub.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=\ngithub.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=\ngithub.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=\ngithub.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=\ngithub.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=\ngithub.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=\ngithub.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=\ngithub.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=\ngithub.com/charmbracelet/x/editor v0.1.0 h1:p69/dpvlwRTs9uYiPeAWruwsHqTFzHhTvQOd/WVSX98=\ngithub.com/charmbracelet/x/editor v0.1.0/go.mod h1:oivrEbcP/AYt/Hpvk5pwDXXrQ933gQS6UzL6fxqAGSA=\ngithub.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=\ngithub.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=\ngithub.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=\ngithub.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=\ngithub.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=\ngithub.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=\ngithub.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=\ngithub.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=\ngithub.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=\ngithub.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=\ngithub.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=\ngithub.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=\ngithub.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=\ngithub.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=\ngithub.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=\ngithub.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=\ngithub.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=\ngithub.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=\ngithub.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=\ngithub.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=\ngithub.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=\ngithub.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=\ngithub.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=\ngithub.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=\ngithub.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=\ngithub.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=\ngithub.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=\ngithub.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=\ngithub.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=\ngithub.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=\ngithub.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=\ngithub.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=\ngithub.com/muesli/gitcha v0.3.0 h1:+PJkVKrDXVB0VgRn/yVx2CqSVSDGMSepzvohsCrPYtQ=\ngithub.com/muesli/gitcha v0.3.0/go.mod h1:vX3jFL+XcEUq1uY74RCjLSZfAV+ZuvLg70/NGPdXn84=\ngithub.com/muesli/go-app-paths v0.2.2 h1:NqG4EEZwNIhBq/pREgfBmgDmt3h1Smr1MjZiXbpZUnI=\ngithub.com/muesli/go-app-paths v0.2.2/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho=\ngithub.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=\ngithub.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=\ngithub.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec=\ngithub.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E=\ngithub.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=\ngithub.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=\ngithub.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=\ngithub.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=\ngithub.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=\ngithub.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=\ngithub.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=\ngithub.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=\ngithub.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=\ngithub.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=\ngithub.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 h1:G04eS0JkAIVZfaJLjla9dNxkJCPiKIGZlw9AfOhzOD0=\ngithub.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94/go.mod h1:b18R55ulyQ/h3RaWyloPyER7fWQVZvimKKhnI5OfrJQ=\ngithub.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=\ngithub.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=\ngithub.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=\ngithub.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=\ngithub.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=\ngithub.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=\ngithub.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=\ngithub.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=\ngithub.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=\ngithub.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=\ngithub.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=\ngithub.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=\ngithub.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=\ngithub.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=\ngithub.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=\ngithub.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=\ngithub.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=\ngithub.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=\ngithub.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=\ngithub.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=\ngithub.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=\ngo.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM=\ngolang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=\ngolang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=\ngolang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=\ngolang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=\ngolang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=\ngolang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=\ngolang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=\ngolang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "log.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/charmbracelet/log\"\n\tgap \"github.com/muesli/go-app-paths\"\n)\n\nfunc getLogFilePath() (string, error) {\n\tdir, err := gap.NewScope(gap.User, \"glow\").CacheDir()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"unable to get cache dir: %w\", err)\n\t}\n\treturn filepath.Join(dir, \"glow.log\"), nil\n}\n\nfunc setupLog() (func() error, error) {\n\tlog.SetOutput(io.Discard)\n\t// Log to file, if set\n\tlogFile, err := getLogFilePath()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := os.MkdirAll(filepath.Dir(logFile), 0o755); err != nil { //nolint:gosec\n\t\t// log disabled\n\t\treturn func() error { return nil }, nil //nolint:nilerr\n\t}\n\tf, err := os.OpenFile(logFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644) //nolint:gosec\n\tif err != nil {\n\t\t// log disabled\n\t\treturn func() error { return nil }, nil //nolint:nilerr\n\t}\n\tlog.SetOutput(f)\n\tlog.SetLevel(log.DebugLevel)\n\treturn f.Close, nil\n}\n"
  },
  {
    "path": "main.go",
    "content": "// Package main provides the entry point for the Glow CLI application.\npackage main\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/caarlos0/env/v11\"\n\t\"github.com/charmbracelet/glamour\"\n\t\"github.com/charmbracelet/glamour/styles\"\n\t\"github.com/charmbracelet/glow/v2/ui\"\n\t\"github.com/charmbracelet/glow/v2/utils\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/charmbracelet/log\"\n\tgap \"github.com/muesli/go-app-paths\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/viper\"\n\t\"golang.org/x/term\"\n)\n\nvar (\n\t// Version as provided by goreleaser.\n\tVersion = \"\"\n\t// CommitSHA as provided by goreleaser.\n\tCommitSHA = \"\"\n\n\treadmeNames      = []string{\"README.md\", \"README\", \"Readme.md\", \"Readme\", \"readme.md\", \"readme\"}\n\tconfigFile       string\n\tpager            bool\n\ttui              bool\n\tstyle            string\n\twidth            uint\n\tshowAllFiles     bool\n\tshowLineNumbers  bool\n\tpreserveNewLines bool\n\tmouse            bool\n\n\trootCmd = &cobra.Command{\n\t\tUse:   \"glow [SOURCE|DIR]\",\n\t\tShort: \"Render markdown on the CLI, with pizzazz!\",\n\t\tLong: paragraph(\n\t\t\tfmt.Sprintf(\"\\nRender markdown on the CLI, %s!\", keyword(\"with pizzazz\")),\n\t\t),\n\t\tSilenceErrors:    false,\n\t\tSilenceUsage:     true,\n\t\tTraverseChildren: true,\n\t\tArgs:             cobra.MaximumNArgs(1),\n\t\tValidArgsFunction: func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {\n\t\t\treturn nil, cobra.ShellCompDirectiveDefault\n\t\t},\n\t\tPersistentPreRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\treturn validateOptions(cmd)\n\t\t},\n\t\tRunE: execute,\n\t}\n)\n\n// source provides a readable markdown source.\ntype source struct {\n\treader io.ReadCloser\n\tURL    string\n}\n\n// sourceFromArg parses an argument and creates a readable source for it.\nfunc sourceFromArg(arg string) (*source, error) {\n\t// from stdin\n\tif arg == \"-\" {\n\t\treturn &source{reader: os.Stdin}, nil\n\t}\n\n\t// a GitHub or GitLab URL (even without the protocol):\n\tsrc, err := readmeURL(arg)\n\tif src != nil && err == nil {\n\t\t// if there's an error, try next methods...\n\t\treturn src, nil\n\t}\n\n\t// HTTP(S) URLs:\n\tif u, err := url.ParseRequestURI(arg); err == nil && strings.Contains(arg, \"://\") { //nolint:nestif\n\t\tif u.Scheme != \"\" {\n\t\t\tif u.Scheme != \"http\" && u.Scheme != \"https\" {\n\t\t\t\treturn nil, fmt.Errorf(\"%s is not a supported protocol\", u.Scheme)\n\t\t\t}\n\t\t\t// consumer of the source is responsible for closing the ReadCloser.\n\t\t\tresp, err := http.Get(u.String()) //nolint: noctx,bodyclose\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"unable to get url: %w\", err)\n\t\t\t}\n\t\t\tif resp.StatusCode != http.StatusOK {\n\t\t\t\treturn nil, fmt.Errorf(\"HTTP status %d\", resp.StatusCode)\n\t\t\t}\n\t\t\treturn &source{resp.Body, u.String()}, nil\n\t\t}\n\t}\n\n\t// a directory:\n\tif len(arg) == 0 {\n\t\t// use the current working dir if no argument was supplied\n\t\targ = \".\"\n\t}\n\tst, err := os.Stat(arg)\n\tif err == nil && st.IsDir() { //nolint:nestif\n\t\tvar src *source\n\t\t_ = filepath.Walk(arg, func(path string, _ os.FileInfo, err error) error {\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tfor _, v := range readmeNames {\n\t\t\t\tif strings.EqualFold(filepath.Base(path), v) {\n\t\t\t\t\tr, err := os.Open(path)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tu, _ := filepath.Abs(path)\n\t\t\t\t\tsrc = &source{r, u}\n\n\t\t\t\t\t// abort filepath.Walk\n\t\t\t\t\treturn errors.New(\"source found\")\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\n\t\tif src != nil {\n\t\t\treturn src, nil\n\t\t}\n\n\t\treturn nil, errors.New(\"missing markdown source\")\n\t}\n\n\tr, err := os.Open(arg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to open file: %w\", err)\n\t}\n\tu, err := filepath.Abs(arg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to get absolute path: %w\", err)\n\t}\n\treturn &source{r, u}, nil\n}\n\n// validateStyle checks if the style is a default style, if not, checks that\n// the custom style exists.\nfunc validateStyle(style string) error {\n\tif style != \"auto\" && styles.DefaultStyles[style] == nil {\n\t\tstyle = utils.ExpandPath(style)\n\t\tif _, err := os.Stat(style); errors.Is(err, fs.ErrNotExist) {\n\t\t\treturn fmt.Errorf(\"specified style does not exist: %s\", style)\n\t\t} else if err != nil {\n\t\t\treturn fmt.Errorf(\"unable to stat file: %w\", err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc validateOptions(cmd *cobra.Command) error {\n\t// grab config values from Viper\n\twidth = viper.GetUint(\"width\")\n\tmouse = viper.GetBool(\"mouse\")\n\tpager = viper.GetBool(\"pager\")\n\ttui = viper.GetBool(\"tui\")\n\tshowAllFiles = viper.GetBool(\"all\")\n\tpreserveNewLines = viper.GetBool(\"preserveNewLines\")\n\tshowLineNumbers = viper.GetBool(\"showLineNumbers\")\n\n\tif pager && tui {\n\t\treturn errors.New(\"cannot use both pager and tui\")\n\t}\n\n\t// validate the glamour style\n\tstyle = viper.GetString(\"style\")\n\tif err := validateStyle(style); err != nil {\n\t\treturn err\n\t}\n\n\tisTerminal := term.IsTerminal(int(os.Stdout.Fd()))\n\t// We want to use a special no-TTY style, when stdout is not a terminal\n\t// and there was no specific style passed by arg\n\tif !isTerminal && !cmd.Flags().Changed(\"style\") {\n\t\tstyle = \"notty\"\n\t}\n\n\t// Detect terminal width\n\tif !cmd.Flags().Changed(\"width\") { //nolint:nestif\n\t\tif isTerminal && width == 0 {\n\t\t\tw, _, err := term.GetSize(int(os.Stdout.Fd()))\n\t\t\tif err == nil {\n\t\t\t\twidth = uint(w) //nolint:gosec\n\t\t\t}\n\n\t\t\tif width > 120 {\n\t\t\t\twidth = 120\n\t\t\t}\n\t\t}\n\t\tif width == 0 {\n\t\t\twidth = 80\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc stdinIsPipe() (bool, error) {\n\tstat, err := os.Stdin.Stat()\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"unable to open file: %w\", err)\n\t}\n\tif stat.Mode()&os.ModeCharDevice == 0 || stat.Size() > 0 {\n\t\treturn true, nil\n\t}\n\treturn false, nil\n}\n\nfunc execute(cmd *cobra.Command, args []string) error {\n\t// if stdin is a pipe then use stdin for input. note that you can also\n\t// explicitly use a - to read from stdin.\n\tif yes, err := stdinIsPipe(); err != nil {\n\t\treturn err\n\t} else if yes {\n\t\tsrc := &source{reader: os.Stdin}\n\t\tdefer src.reader.Close() //nolint:errcheck\n\t\treturn executeCLI(cmd, src, os.Stdout)\n\t}\n\n\tswitch len(args) {\n\t// TUI running on cwd\n\tcase 0:\n\t\treturn runTUI(\"\", \"\")\n\n\t// TUI with possible dir argument\n\tcase 1:\n\t\t// Validate that the argument is a directory. If it's not treat it as\n\t\t// an argument to the non-TUI version of Glow (via fallthrough).\n\t\tinfo, err := os.Stat(args[0])\n\t\tif err == nil && info.IsDir() {\n\t\t\tp, err := filepath.Abs(args[0])\n\t\t\tif err == nil {\n\t\t\t\treturn runTUI(p, \"\")\n\t\t\t}\n\t\t}\n\t\tfallthrough\n\n\t// CLI\n\tdefault:\n\t\tfor _, arg := range args {\n\t\t\tif err := executeArg(cmd, arg, os.Stdout); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc executeArg(cmd *cobra.Command, arg string, w io.Writer) error {\n\t// create an io.Reader from the markdown source in cli-args\n\tsrc, err := sourceFromArg(arg)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer src.reader.Close() //nolint:errcheck\n\treturn executeCLI(cmd, src, w)\n}\n\nfunc executeCLI(cmd *cobra.Command, src *source, w io.Writer) error {\n\tb, err := io.ReadAll(src.reader)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to read from reader: %w\", err)\n\t}\n\n\tb = utils.RemoveFrontmatter(b)\n\n\t// render\n\tvar baseURL string\n\tu, err := url.ParseRequestURI(src.URL)\n\tif err == nil {\n\t\tu.Path = filepath.Dir(u.Path)\n\t\tbaseURL = u.String() + \"/\"\n\t}\n\n\tisCode := !utils.IsMarkdownFile(src.URL)\n\n\t// initialize glamour\n\tr, err := glamour.NewTermRenderer(\n\t\tglamour.WithColorProfile(lipgloss.ColorProfile()),\n\t\tutils.GlamourStyle(style, isCode),\n\t\tglamour.WithWordWrap(int(width)), //nolint:gosec\n\t\tglamour.WithBaseURL(baseURL),\n\t\tglamour.WithPreservedNewLines(),\n\t)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to create renderer: %w\", err)\n\t}\n\n\tcontent := string(b)\n\text := filepath.Ext(src.URL)\n\tif isCode {\n\t\tcontent = utils.WrapCodeBlock(string(b), ext)\n\t}\n\n\tout, err := r.Render(content)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to render markdown: %w\", err)\n\t}\n\n\t// display\n\tswitch {\n\tcase pager || cmd.Flags().Changed(\"pager\"):\n\t\tpagerCmd := os.Getenv(\"PAGER\")\n\t\tif pagerCmd == \"\" {\n\t\t\tpagerCmd = \"less -r\"\n\t\t}\n\n\t\tpa := strings.Split(pagerCmd, \" \")\n\t\tc := exec.Command(pa[0], pa[1:]...) //nolint:gosec\n\t\tc.Stdin = strings.NewReader(out)\n\t\tc.Stdout = os.Stdout\n\t\tif err := c.Run(); err != nil {\n\t\t\treturn fmt.Errorf(\"unable to run command: %w\", err)\n\t\t}\n\t\treturn nil\n\tcase tui || cmd.Flags().Changed(\"tui\"):\n\t\tpath := \"\"\n\t\tif !isURL(src.URL) {\n\t\t\tpath = src.URL\n\t\t}\n\t\treturn runTUI(path, content)\n\tdefault:\n\t\tif _, err = fmt.Fprint(w, out); err != nil {\n\t\t\treturn fmt.Errorf(\"unable to write to writer: %w\", err)\n\t\t}\n\t\treturn nil\n\t}\n}\n\nfunc runTUI(path string, content string) error {\n\t// Read environment to get debugging stuff\n\tcfg, err := env.ParseAs[ui.Config]()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error parsing config: %v\", err)\n\t}\n\n\t// use style set in env, or auto if unset\n\tif err := validateStyle(cfg.GlamourStyle); err != nil {\n\t\tcfg.GlamourStyle = style\n\t}\n\n\tcfg.Path = path\n\tcfg.ShowAllFiles = showAllFiles\n\tcfg.ShowLineNumbers = showLineNumbers\n\tcfg.GlamourMaxWidth = width\n\tcfg.EnableMouse = mouse\n\tcfg.PreserveNewLines = preserveNewLines\n\n\t// Run Bubble Tea program\n\tif _, err := ui.NewProgram(cfg, content).Run(); err != nil {\n\t\treturn fmt.Errorf(\"unable to run tui program: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc main() {\n\tcloser, err := setupLog()\n\tif err != nil {\n\t\tfmt.Println(err)\n\t\tos.Exit(1)\n\t}\n\tif err := rootCmd.Execute(); err != nil {\n\t\t_ = closer()\n\t\tos.Exit(1)\n\t}\n\t_ = closer()\n}\n\nfunc init() {\n\ttryLoadConfigFromDefaultPlaces()\n\tif len(CommitSHA) >= 7 {\n\t\tvt := rootCmd.VersionTemplate()\n\t\trootCmd.SetVersionTemplate(vt[:len(vt)-1] + \" (\" + CommitSHA[0:7] + \")\\n\")\n\t}\n\tif Version == \"\" {\n\t\tVersion = \"unknown (built from source)\"\n\t}\n\trootCmd.Version = Version\n\trootCmd.InitDefaultCompletionCmd()\n\n\t// \"Glow Classic\" cli arguments\n\trootCmd.PersistentFlags().StringVar(&configFile, \"config\", \"\", fmt.Sprintf(\"config file (default %s)\", viper.GetViper().ConfigFileUsed()))\n\trootCmd.Flags().BoolVarP(&pager, \"pager\", \"p\", false, \"display with pager\")\n\trootCmd.Flags().BoolVarP(&tui, \"tui\", \"t\", false, \"display with tui\")\n\trootCmd.Flags().StringVarP(&style, \"style\", \"s\", styles.AutoStyle, \"style name or JSON path\")\n\trootCmd.Flags().UintVarP(&width, \"width\", \"w\", 0, \"word-wrap at width (set to 0 to disable)\")\n\trootCmd.Flags().BoolVarP(&showAllFiles, \"all\", \"a\", false, \"show system files and directories (TUI-mode only)\")\n\trootCmd.Flags().BoolVarP(&showLineNumbers, \"line-numbers\", \"l\", false, \"show line numbers (TUI-mode only)\")\n\trootCmd.Flags().BoolVarP(&preserveNewLines, \"preserve-new-lines\", \"n\", false, \"preserve newlines in the output\")\n\trootCmd.Flags().BoolVarP(&mouse, \"mouse\", \"m\", false, \"enable mouse wheel (TUI-mode only)\")\n\t_ = rootCmd.Flags().MarkHidden(\"mouse\")\n\n\t// Config bindings\n\t_ = viper.BindPFlag(\"pager\", rootCmd.Flags().Lookup(\"pager\"))\n\t_ = viper.BindPFlag(\"tui\", rootCmd.Flags().Lookup(\"tui\"))\n\t_ = viper.BindPFlag(\"style\", rootCmd.Flags().Lookup(\"style\"))\n\t_ = viper.BindPFlag(\"width\", rootCmd.Flags().Lookup(\"width\"))\n\t_ = viper.BindPFlag(\"debug\", rootCmd.Flags().Lookup(\"debug\"))\n\t_ = viper.BindPFlag(\"mouse\", rootCmd.Flags().Lookup(\"mouse\"))\n\t_ = viper.BindPFlag(\"preserveNewLines\", rootCmd.Flags().Lookup(\"preserve-new-lines\"))\n\t_ = viper.BindPFlag(\"showLineNumbers\", rootCmd.Flags().Lookup(\"line-numbers\"))\n\t_ = viper.BindPFlag(\"all\", rootCmd.Flags().Lookup(\"all\"))\n\n\tviper.SetDefault(\"style\", styles.AutoStyle)\n\tviper.SetDefault(\"width\", 0)\n\tviper.SetDefault(\"all\", true)\n\n\trootCmd.AddCommand(configCmd, manCmd)\n}\n\nfunc tryLoadConfigFromDefaultPlaces() {\n\tscope := gap.NewScope(gap.User, \"glow\")\n\tdirs, err := scope.ConfigDirs()\n\tif err != nil {\n\t\tfmt.Println(\"Could not load find configuration directory.\")\n\t\tos.Exit(1)\n\t}\n\n\tif c := os.Getenv(\"XDG_CONFIG_HOME\"); c != \"\" {\n\t\tdirs = append([]string{filepath.Join(c, \"glow\")}, dirs...)\n\t}\n\n\tif c := os.Getenv(\"GLOW_CONFIG_HOME\"); c != \"\" {\n\t\tdirs = append([]string{c}, dirs...)\n\t}\n\n\tfor _, v := range dirs {\n\t\tviper.AddConfigPath(v)\n\t}\n\n\tviper.SetConfigName(\"glow\")\n\tviper.SetConfigType(\"yaml\")\n\tviper.SetEnvPrefix(\"glow\")\n\tviper.AutomaticEnv()\n\n\tif err := viper.ReadInConfig(); err != nil {\n\t\tif _, ok := err.(viper.ConfigFileNotFoundError); !ok {\n\t\t\tlog.Warn(\"Could not parse configuration file\", \"err\", err)\n\t\t}\n\t}\n\n\tif used := viper.ConfigFileUsed(); used != \"\" {\n\t\tlog.Debug(\"Using configuration file\", \"path\", viper.ConfigFileUsed())\n\t\treturn\n\t}\n\n\tif viper.ConfigFileUsed() == \"\" {\n\t\tconfigFile = filepath.Join(dirs[0], \"glow.yml\")\n\t}\n\tif err := ensureConfigFile(); err != nil {\n\t\tlog.Error(\"Could not create default configuration\", \"error\", err)\n\t}\n}\n"
  },
  {
    "path": "man_cmd.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\tmcobra \"github.com/muesli/mango-cobra\"\n\t\"github.com/muesli/roff\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar manCmd = &cobra.Command{\n\tUse:                   \"man\",\n\tShort:                 \"Generates manpages\",\n\tSilenceUsage:          true,\n\tDisableFlagsInUseLine: true,\n\tHidden:                true,\n\tArgs:                  cobra.NoArgs,\n\tRunE: func(*cobra.Command, []string) error {\n\t\tmanPage, err := mcobra.NewManPage(1, rootCmd)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"unable to instantiate man page: %w\", err)\n\t\t}\n\t\tif _, err := fmt.Fprint(os.Stdout, manPage.Build(roff.NewDocument())); err != nil {\n\t\t\treturn fmt.Errorf(\"unable to build man page: %w\", err)\n\t\t}\n\t\treturn nil\n\t},\n}\n"
  },
  {
    "path": "style.go",
    "content": "package main\n\nimport \"github.com/charmbracelet/lipgloss\"\n\nvar (\n\tkeyword = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"#04B575\")).\n\t\tRender\n\n\tparagraph = lipgloss.NewStyle().\n\t\t\tWidth(78).\n\t\t\tPadding(0, 0, 0, 2).\n\t\t\tRender\n)\n"
  },
  {
    "path": "ui/config.go",
    "content": "package ui\n\n// Config contains TUI-specific configuration.\ntype Config struct {\n\tShowAllFiles     bool\n\tShowLineNumbers  bool\n\tGopath           string `env:\"GOPATH\"`\n\tHomeDir          string `env:\"HOME\"`\n\tGlamourMaxWidth  uint\n\tGlamourStyle     string `env:\"GLAMOUR_STYLE\"`\n\tEnableMouse      bool\n\tPreserveNewLines bool\n\n\t// Working directory or file path\n\tPath string\n\n\t// For debugging the UI\n\tHighPerformancePager bool `env:\"GLOW_HIGH_PERFORMANCE_PAGER\" envDefault:\"true\"`\n\tGlamourEnabled       bool `env:\"GLOW_ENABLE_GLAMOUR\"         envDefault:\"true\"`\n}\n"
  },
  {
    "path": "ui/editor.go",
    "content": "package ui\n\nimport (\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/x/editor\"\n)\n\ntype editorFinishedMsg struct{ err error }\n\nfunc openEditor(path string, lineno int) tea.Cmd {\n\tcb := func(err error) tea.Msg {\n\t\treturn editorFinishedMsg{err}\n\t}\n\tcmd, err := editor.Cmd(\"Glow\", path, editor.LineNumber(uint(lineno))) //nolint:gosec\n\tif err != nil {\n\t\treturn func() tea.Msg { return cb(err) }\n\t}\n\treturn tea.ExecProcess(cmd, cb)\n}\n"
  },
  {
    "path": "ui/ignore_darwin.go",
    "content": "//go:build darwin\n// +build darwin\n\npackage ui\n\nimport \"path/filepath\"\n\nfunc ignorePatterns(m commonModel) []string {\n\treturn []string{\n\t\tfilepath.Join(m.cfg.HomeDir, \"Library\"),\n\t\tm.cfg.Gopath,\n\t\t\"node_modules\",\n\t\t\".*\",\n\t}\n}\n"
  },
  {
    "path": "ui/ignore_general.go",
    "content": "//go:build !darwin\n// +build !darwin\n\npackage ui\n\nfunc ignorePatterns(m commonModel) []string {\n\treturn []string{\n\t\tm.cfg.Gopath,\n\t\t\"node_modules\",\n\t\t\".*\",\n\t}\n}\n"
  },
  {
    "path": "ui/keys.go",
    "content": "package ui\n\nconst (\n\tkeyEnter = \"enter\"\n\tkeyEsc   = \"esc\"\n)\n"
  },
  {
    "path": "ui/markdown.go",
    "content": "package ui\n\nimport (\n\t\"fmt\"\n\t\"math\"\n\t\"time\"\n\t\"unicode\"\n\n\t\"github.com/charmbracelet/log\"\n\t\"github.com/dustin/go-humanize\"\n\t\"golang.org/x/text/runes\"\n\t\"golang.org/x/text/transform\"\n\t\"golang.org/x/text/unicode/norm\"\n)\n\ntype markdown struct {\n\t// Full path of a local markdown file. Only relevant to local documents and\n\t// those that have been stashed in this session.\n\tlocalPath string\n\n\t// Value we filter against. This exists so that we can maintain positions\n\t// of filtered items if notes are edited while a filter is active. This\n\t// field is ephemeral, and should only be referenced during filtering.\n\tfilterValue string\n\n\tBody    string\n\tNote    string\n\tModtime time.Time\n}\n\n// Generate the value we're doing to filter against.\nfunc (m *markdown) buildFilterValue() {\n\tnote, err := normalize(m.Note)\n\tif err != nil {\n\t\tlog.Error(\"error normalizing\", \"note\", m.Note, \"error\", err)\n\t\tm.filterValue = m.Note\n\t}\n\n\tm.filterValue = note\n}\n\nfunc (m markdown) relativeTime() string {\n\treturn relativeTime(m.Modtime)\n}\n\n// Normalize text to aid in the filtering process. In particular, we remove\n// diacritics, \"ö\" becomes \"o\". Note that Mn is the unicode key for nonspacing\n// marks.\nfunc normalize(in string) (string, error) {\n\tt := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)\n\tout, _, err := transform.String(t, in)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error normalizing: %w\", err)\n\t}\n\treturn out, nil\n}\n\n// Return the time in a human-readable format relative to the current time.\nfunc relativeTime(then time.Time) string {\n\tnow := time.Now()\n\tif ago := now.Sub(then); ago < time.Minute {\n\t\treturn \"just now\"\n\t} else if ago < humanize.Week {\n\t\treturn humanize.CustomRelTime(then, now, \"ago\", \"from now\", magnitudes)\n\t}\n\treturn then.Format(\"02 Jan 2006 15:04 MST\")\n}\n\n// Magnitudes for relative time.\nvar magnitudes = []humanize.RelTimeMagnitude{\n\t{D: time.Second, Format: \"now\", DivBy: time.Second},\n\t{D: 2 * time.Second, Format: \"1 second %s\", DivBy: 1},\n\t{D: time.Minute, Format: \"%d seconds %s\", DivBy: time.Second},\n\t{D: 2 * time.Minute, Format: \"1 minute %s\", DivBy: 1},\n\t{D: time.Hour, Format: \"%d minutes %s\", DivBy: time.Minute},\n\t{D: 2 * time.Hour, Format: \"1 hour %s\", DivBy: 1},\n\t{D: humanize.Day, Format: \"%d hours %s\", DivBy: time.Hour},\n\t{D: 2 * humanize.Day, Format: \"1 day %s\", DivBy: 1},\n\t{D: humanize.Week, Format: \"%d days %s\", DivBy: humanize.Day},\n\t{D: 2 * humanize.Week, Format: \"1 week %s\", DivBy: 1},\n\t{D: humanize.Month, Format: \"%d weeks %s\", DivBy: humanize.Week},\n\t{D: 2 * humanize.Month, Format: \"1 month %s\", DivBy: 1},\n\t{D: humanize.Year, Format: \"%d months %s\", DivBy: humanize.Month},\n\t{D: 18 * humanize.Month, Format: \"1 year %s\", DivBy: 1},\n\t{D: 2 * humanize.Year, Format: \"2 years %s\", DivBy: 1},\n\t{D: humanize.LongTime, Format: \"%d years %s\", DivBy: humanize.Year},\n\t{D: math.MaxInt64, Format: \"a long while %s\", DivBy: 1},\n}\n"
  },
  {
    "path": "ui/pager.go",
    "content": "package ui\n\nimport (\n\t\"fmt\"\n\t\"math\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/atotto/clipboard\"\n\t\"github.com/charmbracelet/bubbles/viewport\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/glamour\"\n\t\"github.com/charmbracelet/glow/v2/utils\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/charmbracelet/log\"\n\t\"github.com/fsnotify/fsnotify\"\n\trunewidth \"github.com/mattn/go-runewidth\"\n\t\"github.com/muesli/reflow/ansi\"\n\t\"github.com/muesli/reflow/truncate\"\n\t\"github.com/muesli/termenv\"\n)\n\nconst (\n\tstatusBarHeight = 1\n\tlineNumberWidth = 4\n)\n\nvar (\n\tpagerHelpHeight int\n\n\tmintGreen = lipgloss.AdaptiveColor{Light: \"#89F0CB\", Dark: \"#89F0CB\"}\n\tdarkGreen = lipgloss.AdaptiveColor{Light: \"#1C8760\", Dark: \"#1C8760\"}\n\n\tlineNumberFg = lipgloss.AdaptiveColor{Light: \"#656565\", Dark: \"#7D7D7D\"}\n\n\tstatusBarNoteFg = lipgloss.AdaptiveColor{Light: \"#656565\", Dark: \"#7D7D7D\"}\n\tstatusBarBg     = lipgloss.AdaptiveColor{Light: \"#E6E6E6\", Dark: \"#242424\"}\n\n\tstatusBarScrollPosStyle = lipgloss.NewStyle().\n\t\t\t\tForeground(lipgloss.AdaptiveColor{Light: \"#949494\", Dark: \"#5A5A5A\"}).\n\t\t\t\tBackground(statusBarBg).\n\t\t\t\tRender\n\n\tstatusBarNoteStyle = lipgloss.NewStyle().\n\t\t\t\tForeground(statusBarNoteFg).\n\t\t\t\tBackground(statusBarBg).\n\t\t\t\tRender\n\n\tstatusBarHelpStyle = lipgloss.NewStyle().\n\t\t\t\tForeground(statusBarNoteFg).\n\t\t\t\tBackground(lipgloss.AdaptiveColor{Light: \"#DCDCDC\", Dark: \"#323232\"}).\n\t\t\t\tRender\n\n\tstatusBarMessageStyle = lipgloss.NewStyle().\n\t\t\t\tForeground(mintGreen).\n\t\t\t\tBackground(darkGreen).\n\t\t\t\tRender\n\n\tstatusBarMessageScrollPosStyle = lipgloss.NewStyle().\n\t\t\t\t\tForeground(mintGreen).\n\t\t\t\t\tBackground(darkGreen).\n\t\t\t\t\tRender\n\n\tstatusBarMessageHelpStyle = lipgloss.NewStyle().\n\t\t\t\t\tForeground(lipgloss.Color(\"#B6FFE4\")).\n\t\t\t\t\tBackground(green).\n\t\t\t\t\tRender\n\n\thelpViewStyle = lipgloss.NewStyle().\n\t\t\tForeground(statusBarNoteFg).\n\t\t\tBackground(lipgloss.AdaptiveColor{Light: \"#f2f2f2\", Dark: \"#1B1B1B\"}).\n\t\t\tRender\n\n\tlineNumberStyle = lipgloss.NewStyle().\n\t\t\tForeground(lineNumberFg).\n\t\t\tRender\n)\n\ntype (\n\tcontentRenderedMsg string\n\treloadMsg          struct{}\n)\n\ntype pagerState int\n\nconst (\n\tpagerStateBrowse pagerState = iota\n\tpagerStateStatusMessage\n)\n\ntype pagerModel struct {\n\tcommon   *commonModel\n\tviewport viewport.Model\n\tstate    pagerState\n\tshowHelp bool\n\n\tstatusMessage      string\n\tstatusMessageTimer *time.Timer\n\n\t// Current document being rendered, sans-glamour rendering. We cache\n\t// it here so we can re-render it on resize.\n\tcurrentDocument markdown\n\n\twatcher *fsnotify.Watcher\n}\n\nfunc newPagerModel(common *commonModel) pagerModel {\n\t// Init viewport\n\tvp := viewport.New(0, 0)\n\tvp.YPosition = 0\n\tvp.HighPerformanceRendering = config.HighPerformancePager\n\n\tm := pagerModel{\n\t\tcommon:   common,\n\t\tstate:    pagerStateBrowse,\n\t\tviewport: vp,\n\t}\n\tm.initWatcher()\n\treturn m\n}\n\nfunc (m *pagerModel) setSize(w, h int) {\n\tm.viewport.Width = w\n\tm.viewport.Height = h - statusBarHeight\n\n\tif m.showHelp {\n\t\tif pagerHelpHeight == 0 {\n\t\t\tpagerHelpHeight = strings.Count(m.helpView(), \"\\n\")\n\t\t}\n\t\tm.viewport.Height -= (statusBarHeight + pagerHelpHeight)\n\t}\n}\n\nfunc (m *pagerModel) setContent(s string) {\n\tm.viewport.SetContent(s)\n}\n\nfunc (m *pagerModel) toggleHelp() {\n\tm.showHelp = !m.showHelp\n\tm.setSize(m.common.width, m.common.height)\n\tif m.viewport.PastBottom() {\n\t\tm.viewport.GotoBottom()\n\t}\n}\n\ntype pagerStatusMessage struct {\n\tmessage string\n\tisError bool\n}\n\n// Perform stuff that needs to happen after a successful markdown stash. Note\n// that the returned command should be sent back the through the pager\n// update function.\nfunc (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd {\n\t// Show a success message to the user\n\tm.state = pagerStateStatusMessage\n\tm.statusMessage = msg.message\n\tif m.statusMessageTimer != nil {\n\t\tm.statusMessageTimer.Stop()\n\t}\n\tm.statusMessageTimer = time.NewTimer(statusMessageTimeout)\n\n\treturn waitForStatusMessageTimeout(pagerContext, m.statusMessageTimer)\n}\n\nfunc (m *pagerModel) unload() {\n\tlog.Debug(\"unload\")\n\tif m.showHelp {\n\t\tm.toggleHelp()\n\t}\n\tif m.statusMessageTimer != nil {\n\t\tm.statusMessageTimer.Stop()\n\t}\n\tm.state = pagerStateBrowse\n\tm.viewport.SetContent(\"\")\n\tm.viewport.YOffset = 0\n\tm.unwatchFile()\n}\n\nfunc (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {\n\tvar (\n\t\tcmd  tea.Cmd\n\t\tcmds []tea.Cmd\n\t)\n\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\tswitch msg.String() {\n\t\tcase \"q\", keyEsc:\n\t\t\tif m.state != pagerStateBrowse {\n\t\t\t\tm.state = pagerStateBrowse\n\t\t\t\treturn m, nil\n\t\t\t}\n\t\tcase \"home\", \"g\":\n\t\t\tm.viewport.GotoTop()\n\t\t\tif m.viewport.HighPerformanceRendering {\n\t\t\t\tcmds = append(cmds, viewport.Sync(m.viewport))\n\t\t\t}\n\t\tcase \"end\", \"G\":\n\t\t\tm.viewport.GotoBottom()\n\t\t\tif m.viewport.HighPerformanceRendering {\n\t\t\t\tcmds = append(cmds, viewport.Sync(m.viewport))\n\t\t\t}\n\n\t\tcase \"d\":\n\t\t\tm.viewport.HalfViewDown()\n\t\t\tif m.viewport.HighPerformanceRendering {\n\t\t\t\tcmds = append(cmds, viewport.Sync(m.viewport))\n\t\t\t}\n\n\t\tcase \"u\":\n\t\t\tm.viewport.HalfViewUp()\n\t\t\tif m.viewport.HighPerformanceRendering {\n\t\t\t\tcmds = append(cmds, viewport.Sync(m.viewport))\n\t\t\t}\n\n\t\tcase \"e\":\n\t\t\tlineno := int(math.RoundToEven(float64(m.viewport.TotalLineCount()) * m.viewport.ScrollPercent()))\n\t\t\tif m.viewport.AtTop() {\n\t\t\t\tlineno = 0\n\t\t\t}\n\t\t\tlog.Info(\n\t\t\t\t\"opening editor\",\n\t\t\t\t\"file\", m.currentDocument.localPath,\n\t\t\t\t\"line\", fmt.Sprintf(\"%d/%d\", lineno, m.viewport.TotalLineCount()),\n\t\t\t)\n\t\t\treturn m, openEditor(m.currentDocument.localPath, lineno)\n\n\t\tcase \"c\":\n\t\t\t// Copy using OSC 52\n\t\t\ttermenv.Copy(m.currentDocument.Body)\n\t\t\t// Copy using native system clipboard\n\t\t\t_ = clipboard.WriteAll(m.currentDocument.Body)\n\t\t\tcmds = append(cmds, m.showStatusMessage(pagerStatusMessage{\"Copied contents\", false}))\n\n\t\tcase \"r\":\n\t\t\treturn m, loadLocalMarkdown(&m.currentDocument)\n\n\t\tcase \"?\":\n\t\t\tm.toggleHelp()\n\t\t\tif m.viewport.HighPerformanceRendering {\n\t\t\t\tcmds = append(cmds, viewport.Sync(m.viewport))\n\t\t\t}\n\t\t}\n\n\t// Glow has rendered the content\n\tcase contentRenderedMsg:\n\t\tlog.Info(\"content rendered\", \"state\", m.state)\n\n\t\tm.setContent(string(msg))\n\t\tif m.viewport.HighPerformanceRendering {\n\t\t\tcmds = append(cmds, viewport.Sync(m.viewport))\n\t\t}\n\t\tcmds = append(cmds, m.watchFile)\n\n\t// The file was changed on disk and we're reloading it\n\tcase reloadMsg:\n\t\treturn m, loadLocalMarkdown(&m.currentDocument)\n\n\t// We've finished editing the document, potentially making changes. Let's\n\t// retrieve the latest version of the document so that we display\n\t// up-to-date contents.\n\tcase editorFinishedMsg:\n\t\treturn m, loadLocalMarkdown(&m.currentDocument)\n\n\t// We've received terminal dimensions, either for the first time or\n\t// after a resize\n\tcase tea.WindowSizeMsg:\n\t\treturn m, renderWithGlamour(m, m.currentDocument.Body)\n\n\tcase statusMessageTimeoutMsg:\n\t\tm.state = pagerStateBrowse\n\t}\n\n\tm.viewport, cmd = m.viewport.Update(msg)\n\tcmds = append(cmds, cmd)\n\n\treturn m, tea.Batch(cmds...)\n}\n\nfunc (m pagerModel) View() string {\n\tvar b strings.Builder\n\tfmt.Fprint(&b, m.viewport.View()+\"\\n\")\n\n\t// Footer\n\tm.statusBarView(&b)\n\n\tif m.showHelp {\n\t\tfmt.Fprint(&b, \"\\n\"+m.helpView())\n\t}\n\n\treturn b.String()\n}\n\nfunc (m pagerModel) statusBarView(b *strings.Builder) {\n\tconst (\n\t\tminPercent               float64 = 0.0\n\t\tmaxPercent               float64 = 1.0\n\t\tpercentToStringMagnitude float64 = 100.0\n\t)\n\n\tshowStatusMessage := m.state == pagerStateStatusMessage\n\n\t// Logo\n\tlogo := glowLogoView()\n\n\t// Scroll percent\n\tpercent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent()))\n\tscrollPercent := fmt.Sprintf(\" %3.f%% \", percent*percentToStringMagnitude)\n\tif showStatusMessage {\n\t\tscrollPercent = statusBarMessageScrollPosStyle(scrollPercent)\n\t} else {\n\t\tscrollPercent = statusBarScrollPosStyle(scrollPercent)\n\t}\n\n\t// \"Help\" note\n\tvar helpNote string\n\tif showStatusMessage {\n\t\thelpNote = statusBarMessageHelpStyle(\" ? Help \")\n\t} else {\n\t\thelpNote = statusBarHelpStyle(\" ? Help \")\n\t}\n\n\t// Note\n\tvar note string\n\tif showStatusMessage {\n\t\tnote = m.statusMessage\n\t} else {\n\t\tnote = m.currentDocument.Note\n\t}\n\tnote = truncate.StringWithTail(\" \"+note+\" \", uint(max(0, //nolint:gosec\n\t\tm.common.width-\n\t\t\tansi.PrintableRuneWidth(logo)-\n\t\t\tansi.PrintableRuneWidth(scrollPercent)-\n\t\t\tansi.PrintableRuneWidth(helpNote),\n\t)), ellipsis)\n\tif showStatusMessage {\n\t\tnote = statusBarMessageStyle(note)\n\t} else {\n\t\tnote = statusBarNoteStyle(note)\n\t}\n\n\t// Empty space\n\tpadding := max(0,\n\t\tm.common.width-\n\t\t\tansi.PrintableRuneWidth(logo)-\n\t\t\tansi.PrintableRuneWidth(note)-\n\t\t\tansi.PrintableRuneWidth(scrollPercent)-\n\t\t\tansi.PrintableRuneWidth(helpNote),\n\t)\n\temptySpace := strings.Repeat(\" \", padding)\n\tif showStatusMessage {\n\t\temptySpace = statusBarMessageStyle(emptySpace)\n\t} else {\n\t\temptySpace = statusBarNoteStyle(emptySpace)\n\t}\n\n\tfmt.Fprintf(b, \"%s%s%s%s%s\",\n\t\tlogo,\n\t\tnote,\n\t\temptySpace,\n\t\tscrollPercent,\n\t\thelpNote,\n\t)\n}\n\nfunc (m pagerModel) helpView() (s string) {\n\tcol1 := []string{\n\t\t\"g/home  go to top\",\n\t\t\"G/end   go to bottom\",\n\t\t\"c       copy contents\",\n\t\t\"e       edit this document\",\n\t\t\"r       reload this document\",\n\t\t\"esc     back to files\",\n\t\t\"q       quit\",\n\t}\n\n\ts += \"\\n\"\n\ts += \"k/↑      up                  \" + col1[0] + \"\\n\"\n\ts += \"j/↓      down                \" + col1[1] + \"\\n\"\n\ts += \"b/pgup   page up             \" + col1[2] + \"\\n\"\n\ts += \"f/pgdn   page down           \" + col1[3] + \"\\n\"\n\ts += \"u        ½ page up           \" + col1[4] + \"\\n\"\n\ts += \"d        ½ page down         \"\n\n\tif len(col1) > 5 {\n\t\ts += col1[5]\n\t}\n\n\ts = indent(s, 2)\n\n\t// Fill up empty cells with spaces for background coloring\n\tif m.common.width > 0 {\n\t\tlines := strings.Split(s, \"\\n\")\n\t\tfor i := 0; i < len(lines); i++ {\n\t\t\tl := runewidth.StringWidth(lines[i])\n\t\t\tn := max(m.common.width-l, 0)\n\t\t\tlines[i] += strings.Repeat(\" \", n)\n\t\t}\n\n\t\ts = strings.Join(lines, \"\\n\")\n\t}\n\n\treturn helpViewStyle(s)\n}\n\n// COMMANDS\n\nfunc renderWithGlamour(m pagerModel, md string) tea.Cmd {\n\treturn func() tea.Msg {\n\t\ts, err := glamourRender(m, md)\n\t\tif err != nil {\n\t\t\tlog.Error(\"error rendering with Glamour\", \"error\", err)\n\t\t\treturn errMsg{err}\n\t\t}\n\t\treturn contentRenderedMsg(s)\n\t}\n}\n\n// This is where the magic happens.\nfunc glamourRender(m pagerModel, markdown string) (string, error) {\n\ttrunc := lipgloss.NewStyle().MaxWidth(m.viewport.Width - lineNumberWidth).Render\n\n\tif !config.GlamourEnabled {\n\t\treturn markdown, nil\n\t}\n\n\tisCode := !utils.IsMarkdownFile(m.currentDocument.Note)\n\twidth := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width)) //nolint:gosec\n\tif isCode {\n\t\twidth = 0\n\t}\n\n\toptions := []glamour.TermRendererOption{\n\t\tutils.GlamourStyle(m.common.cfg.GlamourStyle, isCode),\n\t\tglamour.WithWordWrap(width),\n\t}\n\n\tif m.common.cfg.PreserveNewLines {\n\t\toptions = append(options, glamour.WithPreservedNewLines())\n\t}\n\tr, err := glamour.NewTermRenderer(options...)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error creating glamour renderer: %w\", err)\n\t}\n\n\tif isCode {\n\t\tmarkdown = utils.WrapCodeBlock(markdown, filepath.Ext(m.currentDocument.Note))\n\t}\n\n\tout, err := r.Render(markdown)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error rendering markdown: %w\", err)\n\t}\n\n\tif isCode {\n\t\tout = strings.TrimSpace(out)\n\t}\n\n\t// trim lines\n\tlines := strings.Split(out, \"\\n\")\n\n\tvar content strings.Builder\n\tfor i, s := range lines {\n\t\tif isCode || m.common.cfg.ShowLineNumbers {\n\t\t\tcontent.WriteString(lineNumberStyle(fmt.Sprintf(\"%\"+fmt.Sprint(lineNumberWidth)+\"d\", i+1)))\n\t\t\tcontent.WriteString(trunc(s))\n\t\t} else {\n\t\t\tcontent.WriteString(s)\n\t\t}\n\n\t\t// don't add an artificial newline after the last split\n\t\tif i+1 < len(lines) {\n\t\t\tcontent.WriteRune('\\n')\n\t\t}\n\t}\n\n\treturn content.String(), nil\n}\n\nfunc (m *pagerModel) initWatcher() {\n\tvar err error\n\tm.watcher, err = fsnotify.NewWatcher()\n\tif err != nil {\n\t\tlog.Error(\"error creating fsnotify watcher\", \"error\", err)\n\t}\n}\n\nfunc (m *pagerModel) watchFile() tea.Msg {\n\tdir := m.localDir()\n\n\tif err := m.watcher.Add(dir); err != nil {\n\t\tlog.Error(\"error adding dir to fsnotify watcher\", \"error\", err)\n\t\treturn nil\n\t}\n\n\tlog.Info(\"fsnotify watching dir\", \"dir\", dir)\n\n\tfor {\n\t\tselect {\n\t\tcase event, ok := <-m.watcher.Events:\n\t\t\tif !ok || event.Name != m.currentDocument.localPath {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlog.Debug(\"fsnotify event\", \"file\", event.Name, \"event\", event.Op)\n\t\t\treturn reloadMsg{}\n\t\tcase err, ok := <-m.watcher.Errors:\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tlog.Debug(\"fsnotify error\", \"dir\", dir, \"error\", err)\n\t\t}\n\t}\n}\n\nfunc (m *pagerModel) unwatchFile() {\n\tdir := m.localDir()\n\n\terr := m.watcher.Remove(dir)\n\tif err == nil {\n\t\tlog.Debug(\"fsnotify dir unwatched\", \"dir\", dir)\n\t} else {\n\t\tlog.Error(\"fsnotify fail to unwatch dir\", \"dir\", dir, \"error\", err)\n\t}\n}\n\nfunc (m *pagerModel) localDir() string {\n\treturn filepath.Dir(m.currentDocument.localPath)\n}\n"
  },
  {
    "path": "ui/sort.go",
    "content": "package ui\n\nimport (\n\t\"cmp\"\n\t\"slices\"\n)\n\nfunc sortMarkdowns(mds []*markdown) {\n\tslices.SortStableFunc(mds, func(a, b *markdown) int {\n\t\treturn cmp.Compare(a.Note, b.Note)\n\t})\n}\n"
  },
  {
    "path": "ui/stash.go",
    "content": "package ui\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/bubbles/paginator\"\n\t\"github.com/charmbracelet/bubbles/spinner\"\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/charmbracelet/log\"\n\t\"github.com/muesli/reflow/ansi\"\n\t\"github.com/muesli/reflow/truncate\"\n\t\"github.com/sahilm/fuzzy\"\n)\n\nconst (\n\tstashIndent                = 1\n\tstashViewItemHeight        = 3 // height of stash entry, including gap\n\tstashViewTopPadding        = 5 // logo, status bar, gaps\n\tstashViewBottomPadding     = 3 // pagination and gaps, but not help\n\tstashViewHorizontalPadding = 6\n)\n\nvar stashingStatusMessage = statusMessage{normalStatusMessage, \"Stashing...\"}\n\nvar (\n\tdividerDot = darkGrayFg.SetString(\" • \")\n\tdividerBar = darkGrayFg.SetString(\" │ \")\n\n\tlogoStyle = lipgloss.NewStyle().\n\t\t\tForeground(lipgloss.Color(\"#ECFD65\")).\n\t\t\tBackground(fuchsia).\n\t\t\tBold(true)\n\n\tstashSpinnerStyle = lipgloss.NewStyle().\n\t\t\t\tForeground(gray)\n\tstashInputPromptStyle = lipgloss.NewStyle().\n\t\t\t\tForeground(yellowGreen).\n\t\t\t\tMarginRight(1)\n\tstashInputCursorStyle = lipgloss.NewStyle().\n\t\t\t\tForeground(fuchsia).\n\t\t\t\tMarginRight(1)\n)\n\n// MSG\n\ntype (\n\tfilteredMarkdownMsg []*markdown\n\tfetchedMarkdownMsg  *markdown\n)\n\n// MODEL\n\n// stashViewState is the high-level state of the file listing.\ntype stashViewState int\n\nconst (\n\tstashStateReady stashViewState = iota\n\tstashStateLoadingDocument\n\tstashStateShowingError\n)\n\n// The types of documents we are currently showing to the user.\ntype sectionKey int\n\nconst (\n\tdocumentsSection = iota\n\tfilterSection\n)\n\n// section contains definitions and state information for displaying a tab and\n// its contents in the file listing view.\ntype section struct {\n\tkey       sectionKey\n\tpaginator paginator.Model\n\tcursor    int\n}\n\n// map sections to their associated types.\nvar sections = map[sectionKey]section{}\n\n// filterState is the current filtering state in the file listing.\ntype filterState int\n\nconst (\n\tunfiltered    filterState = iota // no filter set\n\tfiltering                        // user is actively setting a filter\n\tfilterApplied                    // a filter is applied and user is not editing filter\n)\n\n// statusMessageType adds some context to the status message being sent.\ntype statusMessageType int\n\n// Types of status messages.\nconst (\n\tnormalStatusMessage statusMessageType = iota\n\tsubtleStatusMessage\n\terrorStatusMessage\n)\n\n// statusMessage is an ephemeral note displayed in the UI.\ntype statusMessage struct {\n\tstatus  statusMessageType\n\tmessage string\n}\n\nfunc initSections() {\n\tsections = map[sectionKey]section{\n\t\tdocumentsSection: {\n\t\t\tkey:       documentsSection,\n\t\t\tpaginator: newStashPaginator(),\n\t\t},\n\t\tfilterSection: {\n\t\t\tkey:       filterSection,\n\t\t\tpaginator: newStashPaginator(),\n\t\t},\n\t}\n}\n\n// String returns a styled version of the status message appropriate for the\n// given context.\nfunc (s statusMessage) String() string {\n\tswitch s.status { //nolint:exhaustive\n\tcase subtleStatusMessage:\n\t\treturn dimGreenFg(s.message)\n\tcase errorStatusMessage:\n\t\treturn redFg(s.message)\n\tdefault:\n\t\treturn greenFg(s.message)\n\t}\n}\n\ntype stashModel struct {\n\tcommon             *commonModel\n\terr                error\n\tspinner            spinner.Model\n\tfilterInput        textinput.Model\n\tviewState          stashViewState\n\tfilterState        filterState\n\tshowFullHelp       bool\n\tshowStatusMessage  bool\n\tstatusMessage      statusMessage\n\tstatusMessageTimer *time.Timer\n\n\t// Available document sections we can cycle through. We use a slice, rather\n\t// than a map, because order is important.\n\tsections []section\n\n\t// Index of the section we're currently looking at\n\tsectionIndex int\n\n\t// Tracks if docs were loaded\n\tloaded bool\n\n\t// The master set of markdown documents we're working with.\n\tmarkdowns []*markdown\n\n\t// Markdown documents we're currently displaying. Filtering, toggles and so\n\t// on will alter this slice so we can show what is relevant. For that\n\t// reason, this field should be considered ephemeral.\n\tfilteredMarkdowns []*markdown\n\n\t// Page we're fetching stash items from on the server, which is different\n\t// from the local pagination. Generally, the server will return more items\n\t// than we can display at a time so we can paginate locally without having\n\t// to fetch every time.\n\tserverPage int64\n}\n\nfunc (m stashModel) loadingDone() bool {\n\treturn m.loaded\n}\n\nfunc (m stashModel) currentSection() *section {\n\treturn &m.sections[m.sectionIndex]\n}\n\nfunc (m stashModel) paginator() *paginator.Model {\n\treturn &m.currentSection().paginator\n}\n\nfunc (m *stashModel) setPaginator(p paginator.Model) {\n\tm.currentSection().paginator = p\n}\n\nfunc (m stashModel) cursor() int {\n\treturn m.currentSection().cursor\n}\n\nfunc (m *stashModel) setCursor(i int) {\n\tm.currentSection().cursor = i\n}\n\n// Whether or not the spinner should be spinning.\nfunc (m stashModel) shouldSpin() bool {\n\tloading := !m.loadingDone()\n\topeningDocument := m.viewState == stashStateLoadingDocument\n\treturn loading || openingDocument\n}\n\nfunc (m *stashModel) setSize(width, height int) {\n\tm.common.width = width\n\tm.common.height = height\n\n\tm.filterInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth(\n\t\tm.filterInput.Prompt,\n\t)\n\n\tm.updatePagination()\n}\n\nfunc (m *stashModel) resetFiltering() {\n\tm.filterState = unfiltered\n\tm.filterInput.Reset()\n\tm.filteredMarkdowns = nil\n\n\tsortMarkdowns(m.markdowns)\n\n\t// If the filtered section is present (it's always at the end) slice it out\n\t// of the sections slice to remove it from the UI.\n\tif m.sections[len(m.sections)-1].key == filterSection {\n\t\tm.sections = m.sections[:len(m.sections)-1]\n\t}\n\n\t// If the current section is out of bounds (it would be if we cut down the\n\t// slice above) then return to the first section.\n\tif m.sectionIndex > len(m.sections)-1 {\n\t\tm.sectionIndex = 0\n\t}\n\n\t// Update pagination after we've switched sections.\n\tm.updatePagination()\n}\n\n// Is a filter currently being applied?\nfunc (m stashModel) filterApplied() bool {\n\treturn m.filterState != unfiltered\n}\n\n// Should we be updating the filter?\nfunc (m stashModel) shouldUpdateFilter() bool {\n\t// If we're in the middle of setting a note don't update the filter so that\n\t// the focus won't jump around.\n\treturn m.filterApplied()\n}\n\n// Update pagination according to the amount of markdowns for the current\n// state.\nfunc (m *stashModel) updatePagination() {\n\t_, helpHeight := m.helpView()\n\n\tavailableHeight := m.common.height -\n\t\tstashViewTopPadding -\n\t\thelpHeight -\n\t\tstashViewBottomPadding\n\n\tm.paginator().PerPage = max(1, availableHeight/stashViewItemHeight)\n\n\tif pages := len(m.getVisibleMarkdowns()); pages < 1 {\n\t\tm.paginator().SetTotalPages(1)\n\t} else {\n\t\tm.paginator().SetTotalPages(pages)\n\t}\n\n\t// Make sure the page stays in bounds\n\tif m.paginator().Page >= m.paginator().TotalPages-1 {\n\t\tm.paginator().Page = max(0, m.paginator().TotalPages-1)\n\t}\n}\n\n// MarkdownIndex returns the index of the currently selected markdown item.\nfunc (m stashModel) markdownIndex() int {\n\treturn m.paginator().Page*m.paginator().PerPage + m.cursor()\n}\n\n// Return the current selected markdown in the stash.\nfunc (m stashModel) selectedMarkdown() *markdown {\n\ti := m.markdownIndex()\n\n\tmds := m.getVisibleMarkdowns()\n\tif i < 0 || len(mds) == 0 || len(mds) <= i {\n\t\treturn nil\n\t}\n\n\treturn mds[i]\n}\n\n// Adds markdown documents to the model.\nfunc (m *stashModel) addMarkdowns(mds ...*markdown) {\n\tif len(mds) == 0 {\n\t\treturn\n\t}\n\n\tm.markdowns = append(m.markdowns, mds...)\n\tif !m.filterApplied() {\n\t\tsortMarkdowns(m.markdowns)\n\t}\n\n\tm.updatePagination()\n}\n\n// Returns the markdowns that should be currently shown.\nfunc (m stashModel) getVisibleMarkdowns() []*markdown {\n\tif m.filterState == filtering || m.currentSection().key == filterSection {\n\t\treturn m.filteredMarkdowns\n\t}\n\n\treturn m.markdowns\n}\n\n// Command for opening a markdown document in the pager. Note that this also\n// alters the model.\nfunc (m *stashModel) openMarkdown(md *markdown) tea.Cmd {\n\tm.viewState = stashStateLoadingDocument\n\tcmd := loadLocalMarkdown(md)\n\treturn tea.Batch(cmd, m.spinner.Tick)\n}\n\nfunc (m *stashModel) hideStatusMessage() {\n\tm.showStatusMessage = false\n\tm.statusMessage = statusMessage{}\n\tif m.statusMessageTimer != nil {\n\t\tm.statusMessageTimer.Stop()\n\t}\n}\n\nfunc (m *stashModel) moveCursorUp() {\n\tm.setCursor(m.cursor() - 1)\n\tif m.cursor() < 0 && m.paginator().Page == 0 {\n\t\t// Stop\n\t\tm.setCursor(0)\n\t\treturn\n\t}\n\n\tif m.cursor() >= 0 {\n\t\treturn\n\t}\n\t// Go to previous page\n\tm.paginator().PrevPage()\n\n\tm.setCursor(m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns())) - 1)\n}\n\nfunc (m *stashModel) moveCursorDown() {\n\titemsOnPage := m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns()))\n\n\tm.setCursor(m.cursor() + 1)\n\tif m.cursor() < itemsOnPage {\n\t\treturn\n\t}\n\n\tif !m.paginator().OnLastPage() {\n\t\tm.paginator().NextPage()\n\t\tm.setCursor(0)\n\t\treturn\n\t}\n\n\t// During filtering the cursor position can exceed the number of\n\t// itemsOnPage. It's more intuitive to start the cursor at the\n\t// topmost position when moving it down in this scenario.\n\tif m.cursor() > itemsOnPage {\n\t\tm.setCursor(0)\n\t\treturn\n\t}\n\tm.setCursor(itemsOnPage - 1)\n}\n\n// INIT\n\nfunc newStashModel(common *commonModel) stashModel {\n\tsp := spinner.New()\n\tsp.Spinner = spinner.Line\n\tsp.Style = stashSpinnerStyle\n\n\tsi := textinput.New()\n\tsi.Prompt = \"Find:\"\n\tsi.PromptStyle = stashInputPromptStyle\n\tsi.Cursor.Style = stashInputCursorStyle\n\tsi.Focus()\n\n\ts := []section{\n\t\tsections[documentsSection],\n\t}\n\n\tm := stashModel{\n\t\tcommon:      common,\n\t\tspinner:     sp,\n\t\tfilterInput: si,\n\t\tserverPage:  1,\n\t\tsections:    s,\n\t}\n\n\treturn m\n}\n\nfunc newStashPaginator() paginator.Model {\n\tp := paginator.New()\n\tp.Type = paginator.Dots\n\tp.ActiveDot = brightGrayFg(\"•\")\n\tp.InactiveDot = darkGrayFg.Render(\"•\")\n\treturn p\n}\n\n// UPDATE\n\nfunc (m stashModel) update(msg tea.Msg) (stashModel, tea.Cmd) {\n\tvar cmds []tea.Cmd\n\n\tswitch msg := msg.(type) {\n\tcase errMsg:\n\t\tm.err = msg\n\n\tcase localFileSearchFinished:\n\t\t// We're finished searching for local files\n\t\tm.loaded = true\n\n\tcase filteredMarkdownMsg:\n\t\tm.filteredMarkdowns = msg\n\t\tm.setCursor(0)\n\t\treturn m, nil\n\n\tcase spinner.TickMsg:\n\t\tif m.shouldSpin() {\n\t\t\tvar cmd tea.Cmd\n\t\t\tm.spinner, cmd = m.spinner.Update(msg)\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\n\tcase statusMessageTimeoutMsg:\n\t\tif applicationContext(msg) == stashContext {\n\t\t\tm.hideStatusMessage()\n\t\t}\n\t}\n\n\tif m.filterState == filtering {\n\t\tcmds = append(cmds, m.handleFiltering(msg))\n\t\treturn m, tea.Batch(cmds...)\n\t}\n\n\t// Updates per the current state\n\tswitch m.viewState { //nolint:exhaustive\n\tcase stashStateReady:\n\t\tcmds = append(cmds, m.handleDocumentBrowsing(msg))\n\tcase stashStateShowingError:\n\t\t// Any key exists the error view\n\t\tif _, ok := msg.(tea.KeyMsg); ok {\n\t\t\tm.viewState = stashStateReady\n\t\t}\n\t}\n\n\treturn m, tea.Batch(cmds...)\n}\n\n// Updates for when a user is browsing the markdown listing.\nfunc (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd {\n\tvar cmds []tea.Cmd\n\n\tnumDocs := len(m.getVisibleMarkdowns())\n\n\tswitch msg := msg.(type) {\n\t// Handle keys\n\tcase tea.KeyMsg:\n\t\tswitch msg.String() {\n\t\tcase \"k\", \"ctrl+k\", \"up\":\n\t\t\tm.moveCursorUp()\n\n\t\tcase \"j\", \"ctrl+j\", \"down\":\n\t\t\tm.moveCursorDown()\n\n\t\t// Go to the very start\n\t\tcase \"home\", \"g\":\n\t\t\tm.paginator().Page = 0\n\t\t\tm.setCursor(0)\n\n\t\t// Go to the very end\n\t\tcase \"end\", \"G\":\n\t\t\tm.paginator().Page = m.paginator().TotalPages - 1\n\t\t\tm.setCursor(m.paginator().ItemsOnPage(numDocs) - 1)\n\n\t\t// Clear filter (if applicable)\n\t\tcase keyEsc:\n\t\t\tif m.filterApplied() {\n\t\t\t\tm.resetFiltering()\n\t\t\t}\n\n\t\t// Next section\n\t\tcase \"tab\", \"L\":\n\t\t\tif len(m.sections) == 0 || m.filterState == filtering {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tm.sectionIndex++\n\t\t\tif m.sectionIndex >= len(m.sections) {\n\t\t\t\tm.sectionIndex = 0\n\t\t\t}\n\t\t\tm.updatePagination()\n\n\t\t// Previous section\n\t\tcase \"shift+tab\", \"H\":\n\t\t\tif len(m.sections) == 0 || m.filterState == filtering {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tm.sectionIndex--\n\t\t\tif m.sectionIndex < 0 {\n\t\t\t\tm.sectionIndex = len(m.sections) - 1\n\t\t\t}\n\t\t\tm.updatePagination()\n\n\t\tcase \"F\":\n\t\t\tm.loaded = false\n\t\t\treturn findLocalFiles(*m.common)\n\n\t\t// Edit document in EDITOR\n\t\tcase \"e\":\n\t\t\tmd := m.selectedMarkdown()\n\n\t\t\t// In case no file is available\n\t\t\tif md == nil {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\treturn openEditor(md.localPath, 0)\n\n\t\t// Open document\n\t\tcase keyEnter:\n\t\t\tm.hideStatusMessage()\n\n\t\t\tif numDocs == 0 {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// Load the document from the server. We'll handle the message\n\t\t\t// that comes back in the main update function.\n\t\t\tmd := m.selectedMarkdown()\n\t\t\tcmds = append(cmds, m.openMarkdown(md))\n\n\t\t// Filter your notes\n\t\tcase \"/\":\n\t\t\tm.hideStatusMessage()\n\n\t\t\t// Build values we'll filter against\n\t\t\tfor _, md := range m.markdowns {\n\t\t\t\tmd.buildFilterValue()\n\t\t\t}\n\n\t\t\tm.filteredMarkdowns = m.markdowns\n\n\t\t\tm.paginator().Page = 0\n\t\t\tm.setCursor(0)\n\t\t\tm.filterState = filtering\n\t\t\tm.filterInput.CursorEnd()\n\t\t\tm.filterInput.Focus()\n\t\t\treturn textinput.Blink\n\n\t\t// Toggle full help\n\t\tcase \"?\":\n\t\t\tm.showFullHelp = !m.showFullHelp\n\t\t\tm.updatePagination()\n\n\t\t// Show errors\n\t\tcase \"!\":\n\t\t\tif m.err != nil && m.viewState == stashStateReady {\n\t\t\t\tm.viewState = stashStateShowingError\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t}\n\n\t// Update paginator. Pagination key handling is done here, but it could\n\t// also be moved up to this level, in which case we'd use model methods\n\t// like model.PageUp().\n\tnewPaginatorModel, cmd := m.paginator().Update(msg)\n\tm.setPaginator(newPaginatorModel)\n\tcmds = append(cmds, cmd)\n\n\t// Extra paginator keystrokes\n\tif key, ok := msg.(tea.KeyMsg); ok {\n\t\tswitch key.String() {\n\t\tcase \"b\", \"u\":\n\t\t\tm.paginator().PrevPage()\n\t\tcase \"f\", \"d\":\n\t\t\tm.paginator().NextPage()\n\t\t}\n\t}\n\n\t// Keep the index in bounds when paginating\n\titemsOnPage := m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns()))\n\tif m.cursor() > itemsOnPage-1 {\n\t\tm.setCursor(max(0, itemsOnPage-1))\n\t}\n\n\treturn tea.Batch(cmds...)\n}\n\n// Updates for when a user is in the filter editing interface.\nfunc (m *stashModel) handleFiltering(msg tea.Msg) tea.Cmd {\n\tvar cmds []tea.Cmd\n\n\t// Handle keys\n\tif msg, ok := msg.(tea.KeyMsg); ok { //nolint:nestif\n\t\tswitch msg.String() {\n\t\tcase keyEsc:\n\t\t\t// Cancel filtering\n\t\t\tm.resetFiltering()\n\t\tcase keyEnter, \"tab\", \"shift+tab\", \"ctrl+k\", \"up\", \"ctrl+j\", \"down\":\n\t\t\tm.hideStatusMessage()\n\n\t\t\tif len(m.markdowns) == 0 {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\th := m.getVisibleMarkdowns()\n\n\t\t\t// If we've filtered down to nothing, clear the filter\n\t\t\tif len(h) == 0 {\n\t\t\t\tm.viewState = stashStateReady\n\t\t\t\tm.resetFiltering()\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// When there's only one filtered markdown left we can just\n\t\t\t// \"open\" it directly\n\t\t\tif len(h) == 1 {\n\t\t\t\tm.viewState = stashStateReady\n\t\t\t\tm.resetFiltering()\n\t\t\t\tcmds = append(cmds, m.openMarkdown(h[0]))\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// Add new section if it's not present\n\t\t\tif m.sections[len(m.sections)-1].key != filterSection {\n\t\t\t\tm.sections = append(m.sections, sections[filterSection])\n\t\t\t}\n\t\t\tm.sectionIndex = len(m.sections) - 1\n\n\t\t\tm.filterInput.Blur()\n\n\t\t\tm.filterState = filterApplied\n\t\t\tif m.filterInput.Value() == \"\" {\n\t\t\t\tm.resetFiltering()\n\t\t\t}\n\t\t}\n\t}\n\n\t// Update the filter text input component\n\tnewFilterInputModel, inputCmd := m.filterInput.Update(msg)\n\tcurrentFilterVal := m.filterInput.Value()\n\tnewFilterVal := newFilterInputModel.Value()\n\tm.filterInput = newFilterInputModel\n\tcmds = append(cmds, inputCmd)\n\n\t// If the filtering input has changed, request updated filtering\n\tif newFilterVal != currentFilterVal {\n\t\tcmds = append(cmds, filterMarkdowns(*m))\n\t}\n\n\t// Update pagination\n\tm.updatePagination()\n\n\treturn tea.Batch(cmds...)\n}\n\n// VIEW\n\nfunc (m stashModel) view() string {\n\tvar s string\n\tswitch m.viewState {\n\tcase stashStateShowingError:\n\t\treturn errorView(m.err, false)\n\tcase stashStateLoadingDocument:\n\t\ts += \" \" + m.spinner.View() + \" Loading document...\"\n\tcase stashStateReady:\n\t\tloadingIndicator := \" \"\n\t\tif m.shouldSpin() {\n\t\t\tloadingIndicator = m.spinner.View()\n\t\t}\n\n\t\t// Only draw the normal header if we're not using the header area for\n\t\t// something else (like a note or delete prompt).\n\t\theader := m.headerView()\n\n\t\t// Rules for the logo, filter and status message.\n\t\tlogoOrFilter := \" \"\n\t\tif m.showStatusMessage && m.filterState == filtering {\n\t\t\tlogoOrFilter += m.statusMessage.String()\n\t\t} else if m.filterState == filtering {\n\t\t\tlogoOrFilter += m.filterInput.View()\n\t\t} else {\n\t\t\tlogoOrFilter += glowLogoView()\n\t\t\tif m.showStatusMessage {\n\t\t\t\tlogoOrFilter += \"  \" + m.statusMessage.String()\n\t\t\t}\n\t\t}\n\t\tlogoOrFilter = truncate.StringWithTail(logoOrFilter, uint(m.common.width-1), ellipsis) //nolint:gosec\n\n\t\thelp, helpHeight := m.helpView()\n\n\t\tpopulatedView := m.populatedView()\n\t\tpopulatedViewHeight := strings.Count(populatedView, \"\\n\") + 2\n\n\t\t// We need to fill any empty height with newlines so the footer reaches\n\t\t// the bottom.\n\t\tavailHeight := m.common.height -\n\t\t\tstashViewTopPadding -\n\t\t\tpopulatedViewHeight -\n\t\t\thelpHeight -\n\t\t\tstashViewBottomPadding\n\t\tblankLines := strings.Repeat(\"\\n\", max(0, availHeight))\n\n\t\tvar pagination string\n\t\tif m.paginator().TotalPages > 1 {\n\t\t\tpagination = m.paginator().View()\n\n\t\t\t// If the dot pagination is wider than the width of the window\n\t\t\t// use the arabic paginator.\n\t\t\tif ansi.PrintableRuneWidth(pagination) > m.common.width-stashViewHorizontalPadding {\n\t\t\t\t// Copy the paginator since m.paginator() returns a pointer to\n\t\t\t\t// the active paginator and we don't want to mutate it. In\n\t\t\t\t// normal cases, where the paginator is not a pointer, we could\n\t\t\t\t// safely change the model parameters for rendering here as the\n\t\t\t\t// current model is discarded after reuturning from a View().\n\t\t\t\t// One could argue, in fact, that using pointers in\n\t\t\t\t// a functional framework is an antipattern and our use of\n\t\t\t\t// pointers in our model should be refactored away.\n\t\t\t\tp := *(m.paginator())\n\t\t\t\tp.Type = paginator.Arabic\n\t\t\t\tpagination = paginationStyle.Render(p.View())\n\t\t\t}\n\t\t}\n\n\t\ts += fmt.Sprintf(\n\t\t\t\"%s%s\\n\\n  %s\\n\\n%s\\n\\n%s  %s\\n\\n%s\",\n\t\t\tloadingIndicator,\n\t\t\tlogoOrFilter,\n\t\t\theader,\n\t\t\tpopulatedView,\n\t\t\tblankLines,\n\t\t\tpagination,\n\t\t\thelp,\n\t\t)\n\t}\n\treturn \"\\n\" + indent(s, stashIndent)\n}\n\nfunc glowLogoView() string {\n\treturn logoStyle.Render(\" Glow \")\n}\n\nfunc (m stashModel) headerView() string {\n\tlocalCount := len(m.markdowns)\n\n\tvar sections []string //nolint:prealloc\n\n\t// Filter results\n\tif m.filterState == filtering {\n\t\tif localCount == 0 {\n\t\t\treturn grayFg(\"Nothing found.\")\n\t\t}\n\t\tif localCount > 0 {\n\t\t\tsections = append(sections, fmt.Sprintf(\"%d local\", localCount))\n\t\t}\n\n\t\tfor i := range sections {\n\t\t\tsections[i] = grayFg(sections[i])\n\t\t}\n\n\t\treturn strings.Join(sections, dividerDot.String())\n\t}\n\n\t// Tabs\n\tfor i, v := range m.sections {\n\t\tvar s string\n\n\t\tswitch v.key {\n\t\tcase documentsSection:\n\t\t\ts = fmt.Sprintf(\"%d documents\", localCount)\n\n\t\tcase filterSection:\n\t\t\ts = fmt.Sprintf(\"%d “%s”\", len(m.filteredMarkdowns), m.filterInput.Value())\n\t\t}\n\n\t\tif m.sectionIndex == i && len(m.sections) > 1 {\n\t\t\ts = selectedTabStyle.Render(s)\n\t\t} else {\n\t\t\ts = tabStyle.Render(s)\n\t\t}\n\t\tsections = append(sections, s)\n\t}\n\n\treturn strings.Join(sections, dividerBar.String())\n}\n\nfunc (m stashModel) populatedView() string {\n\tmds := m.getVisibleMarkdowns()\n\n\tvar b strings.Builder\n\n\t// Empty states\n\tif len(mds) == 0 {\n\t\tf := func(s string) {\n\t\t\tb.WriteString(\"  \" + grayFg(s))\n\t\t}\n\n\t\tswitch m.sections[m.sectionIndex].key {\n\t\tcase documentsSection:\n\t\t\tif m.loadingDone() {\n\t\t\t\tf(\"No files found.\")\n\t\t\t} else {\n\t\t\t\tf(\"Looking for local files...\")\n\t\t\t}\n\t\tcase filterSection:\n\t\t\treturn \"\"\n\t\t}\n\t}\n\n\tif len(mds) > 0 {\n\t\tstart, end := m.paginator().GetSliceBounds(len(mds))\n\t\tdocs := mds[start:end]\n\n\t\tfor i, md := range docs {\n\t\t\tstashItemView(&b, m, i, md)\n\t\t\tif i != len(docs)-1 {\n\t\t\t\tfmt.Fprintf(&b, \"\\n\\n\")\n\t\t\t}\n\t\t}\n\t}\n\n\t// If there aren't enough items to fill up this page (always the last page)\n\t// then we need to add some newlines to fill up the space where stash items\n\t// would have been.\n\titemsOnPage := m.paginator().ItemsOnPage(len(mds))\n\tif itemsOnPage < m.paginator().PerPage {\n\t\tn := (m.paginator().PerPage - itemsOnPage) * stashViewItemHeight\n\t\tif len(mds) == 0 {\n\t\t\tn -= stashViewItemHeight - 1\n\t\t}\n\t\tfor i := 0; i < n; i++ {\n\t\t\tfmt.Fprint(&b, \"\\n\")\n\t\t}\n\t}\n\n\treturn b.String()\n}\n\n// COMMANDS\n\nfunc loadLocalMarkdown(md *markdown) tea.Cmd {\n\treturn func() tea.Msg {\n\t\tif md.localPath == \"\" {\n\t\t\treturn errMsg{errors.New(\"could not load file: missing path\")}\n\t\t}\n\n\t\tdata, err := os.ReadFile(md.localPath)\n\t\tif err != nil {\n\t\t\tlog.Debug(\"error reading local file\", \"error\", err)\n\t\t\treturn errMsg{err}\n\t\t}\n\t\tmd.Body = string(data)\n\t\treturn fetchedMarkdownMsg(md)\n\t}\n}\n\nfunc filterMarkdowns(m stashModel) tea.Cmd {\n\treturn func() tea.Msg {\n\t\tif m.filterInput.Value() == \"\" || !m.filterApplied() {\n\t\t\treturn filteredMarkdownMsg(m.markdowns) // return everything\n\t\t}\n\n\t\ttargets := []string{}\n\t\tmds := m.markdowns\n\n\t\tfor _, t := range mds {\n\t\t\ttargets = append(targets, t.filterValue)\n\t\t}\n\n\t\tranks := fuzzy.Find(m.filterInput.Value(), targets)\n\t\tsort.Stable(ranks)\n\n\t\tfiltered := []*markdown{}\n\t\tfor _, r := range ranks {\n\t\t\tfiltered = append(filtered, mds[r.Index])\n\t\t}\n\n\t\treturn filteredMarkdownMsg(filtered)\n\t}\n}\n"
  },
  {
    "path": "ui/stashhelp.go",
    "content": "package ui\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/muesli/reflow/ansi\"\n)\n\n// helpEntry is a entry in a help menu containing values for a keystroke and\n// it's associated action.\ntype helpEntry struct{ key, val string }\n\n// helpColumn is a group of helpEntries which will be rendered into a column.\ntype helpColumn []helpEntry\n\n// newHelpColumn creates a help column from pairs of string arguments\n// representing keys and values. If the arguments are not even (and therein\n// not every key has a matching value) the function will panic.\nfunc newHelpColumn(pairs ...string) (h helpColumn) {\n\tif len(pairs)%2 != 0 {\n\t\tpanic(\"help text group must have an even number of items\")\n\t}\n\n\tfor i := 0; i < len(pairs); i = i + 2 {\n\t\th = append(h, helpEntry{key: pairs[i], val: pairs[i+1]})\n\t}\n\n\treturn\n}\n\n// render returns styled and formatted rows from keys and values.\nfunc (h helpColumn) render(height int) (rows []string) {\n\tkeyWidth, valWidth := h.maxWidths()\n\n\tfor i := 0; i < height; i++ {\n\t\tvar (\n\t\t\tb    = strings.Builder{}\n\t\t\tk, v string\n\t\t)\n\t\tif i < len(h) {\n\t\t\tk = h[i].key\n\t\t\tv = h[i].val\n\n\t\t\tswitch k {\n\t\t\tcase \"s\":\n\t\t\t\tk = greenFg(k)\n\t\t\t\tv = semiDimGreenFg(v)\n\t\t\tdefault:\n\t\t\t\tk = grayFg(k)\n\t\t\t\tv = midGrayFg(v)\n\t\t\t}\n\t\t}\n\t\tb.WriteString(k)\n\t\tb.WriteString(strings.Repeat(\" \", keyWidth-ansi.PrintableRuneWidth(k))) // pad keys\n\t\tb.WriteString(\"  \")                                                     // gap\n\t\tb.WriteString(v)\n\t\tb.WriteString(strings.Repeat(\" \", valWidth-ansi.PrintableRuneWidth(v))) // pad vals\n\t\trows = append(rows, b.String())\n\t}\n\n\treturn\n}\n\n// maxWidths returns the widest key and values in the column, respectively.\nfunc (h helpColumn) maxWidths() (maxKey int, maxVal int) {\n\tfor _, v := range h {\n\t\tkw := ansi.PrintableRuneWidth(v.key)\n\t\tvw := ansi.PrintableRuneWidth(v.val)\n\t\tif kw > maxKey {\n\t\t\tmaxKey = kw\n\t\t}\n\t\tif vw > maxVal {\n\t\t\tmaxVal = vw\n\t\t}\n\t}\n\n\treturn\n}\n\n// helpView returns either the mini or full help view depending on the state of\n// the model, as well as the total height of the help view.\nfunc (m stashModel) helpView() (string, int) {\n\tnumDocs := len(m.getVisibleMarkdowns())\n\n\t// Help for when we're filtering\n\tif m.filterState == filtering {\n\t\tvar h []string\n\n\t\tswitch numDocs {\n\t\tcase 0:\n\t\t\th = []string{\"enter/esc\", \"cancel\"}\n\t\tcase 1:\n\t\t\th = []string{\"enter\", \"open\", \"esc\", \"cancel\"}\n\t\tdefault:\n\t\t\th = []string{\"enter\", \"confirm\", \"esc\", \"cancel\", \"ctrl+j/ctrl+k ↑/↓\", \"choose\"}\n\t\t}\n\n\t\treturn m.renderHelp(h)\n\t}\n\n\tvar (\n\t\tnavHelp       []string\n\t\tfilterHelp    []string\n\t\tselectionHelp []string\n\t\teditHelp      []string\n\t\tsectionHelp   []string\n\t\tappHelp       []string\n\t)\n\n\tif numDocs > 0 && m.showFullHelp {\n\t\tnavHelp = []string{\"enter\", \"open\", \"j/k ↑/↓\", \"choose\"}\n\t}\n\n\tif len(m.sections) > 1 {\n\t\tif m.showFullHelp {\n\t\t\tnavHelp = append(navHelp, \"tab/shift+tab\", \"section\")\n\t\t} else {\n\t\t\tnavHelp = append(navHelp, \"tab\", \"section\")\n\t\t}\n\t}\n\n\tif m.paginator().TotalPages > 1 {\n\t\tnavHelp = append(navHelp, \"h/l ←/→\", \"page\")\n\t}\n\n\t// If we're browsing a filtered set\n\tif m.filterApplied() {\n\t\tfilterHelp = []string{\"/\", \"edit search\", \"esc\", \"clear filter\"}\n\t} else {\n\t\tfilterHelp = []string{\"/\", \"find\"}\n\t}\n\n\t// If there are errors\n\tif m.err != nil {\n\t\tappHelp = append(appHelp, \"!\", \"errors\")\n\t}\n\n\tappHelp = append(appHelp, \"r\", \"refresh\")\n\n\tif numDocs > 0 {\n\t\tappHelp = append(appHelp, \"e\", \"edit\")\n\t}\n\n\tappHelp = append(appHelp, \"q\", \"quit\")\n\n\t// Detailed help\n\tif m.showFullHelp {\n\t\tif m.filterState != filtering {\n\t\t\tappHelp = append(appHelp, \"?\", \"close help\")\n\t\t}\n\t\treturn m.renderHelp(navHelp, filterHelp, append(selectionHelp, editHelp...), sectionHelp, appHelp)\n\t}\n\n\t// Mini help\n\tif m.filterState != filtering {\n\t\tappHelp = append(appHelp, \"?\", \"more\")\n\t}\n\treturn m.renderHelp(navHelp, filterHelp, selectionHelp, editHelp, sectionHelp, appHelp)\n}\n\nconst minHelpViewHeight = 5\n\n// renderHelp returns the rendered help view and associated line height for\n// the given groups of help items.\nfunc (m stashModel) renderHelp(groups ...[]string) (string, int) {\n\tif m.showFullHelp {\n\t\tstr := m.fullHelpView(groups...)\n\t\tnumLines := strings.Count(str, \"\\n\") + 1\n\t\treturn str, max(numLines, minHelpViewHeight)\n\t}\n\treturn m.miniHelpView(concatStringSlices(groups...)...), 1\n}\n\n// Builds the help view from various sections pieces, truncating it if the view\n// would otherwise wrap to two lines. Help view entries should come in as pairs,\n// with the first being the key and the second being the help text.\nfunc (m stashModel) miniHelpView(entries ...string) string {\n\tif len(entries) == 0 {\n\t\treturn \"\"\n\t}\n\n\tvar (\n\t\ttruncationChar  = subtleStyle.Render(\"…\")\n\t\ttruncationWidth = ansi.PrintableRuneWidth(truncationChar)\n\t)\n\n\tvar (\n\t\tnext       string\n\t\tleftGutter = \"  \"\n\t\tmaxWidth   = m.common.width -\n\t\t\tstashViewHorizontalPadding -\n\t\t\ttruncationWidth -\n\t\t\tansi.PrintableRuneWidth(leftGutter)\n\t\ts = leftGutter\n\t)\n\n\tfor i := 0; i < len(entries); i = i + 2 {\n\t\tk := entries[i]\n\t\tv := entries[i+1]\n\n\t\tk = grayFg(k)\n\t\tv = midGrayFg(v)\n\n\t\tnext = fmt.Sprintf(\"%s %s\", k, v)\n\n\t\tif i < len(entries)-2 {\n\t\t\tnext += dividerDot.String()\n\t\t}\n\n\t\t// Only this (and the following) help text items if we have the\n\t\t// horizontal space\n\t\tif ansi.PrintableRuneWidth(s)+ansi.PrintableRuneWidth(next) >= maxWidth {\n\t\t\ts += truncationChar\n\t\t\tbreak\n\t\t}\n\n\t\ts += next\n\t}\n\treturn s\n}\n\nfunc (m stashModel) fullHelpView(groups ...[]string) string {\n\tvar tallestCol int\n\tcolumns := make([]helpColumn, 0, len(groups))\n\trenderedCols := make([][]string, 0, len(groups)) // final rows grouped by column\n\n\t// Get key/value pairs\n\tfor _, g := range groups {\n\t\tif len(g) == 0 {\n\t\t\tcontinue // ignore empty columns\n\t\t}\n\n\t\tcolumns = append(columns, newHelpColumn(g...))\n\t}\n\n\t// Find the tallest column\n\tfor _, c := range columns {\n\t\tif len(c) > tallestCol {\n\t\t\ttallestCol = len(c)\n\t\t}\n\t}\n\n\t// Build columns\n\tfor _, c := range columns {\n\t\trenderedCols = append(renderedCols, c.render(tallestCol))\n\t}\n\n\t// Merge columns\n\treturn mergeColumns(renderedCols...)\n}\n\n// Merge columns together to build the help view.\nfunc mergeColumns(cols ...[]string) string {\n\tconst minimumHeight = 3\n\n\t// Find the tallest column\n\tvar tallestCol int\n\tfor _, v := range cols {\n\t\tn := len(v)\n\t\tif n > tallestCol {\n\t\t\ttallestCol = n\n\t\t}\n\t}\n\n\t// Make sure the tallest column meets the minimum height\n\tif tallestCol < minimumHeight {\n\t\ttallestCol = minimumHeight\n\t}\n\n\tb := strings.Builder{}\n\tfor i := 0; i < tallestCol; i++ {\n\t\tfor j, col := range cols {\n\t\t\tif i >= len(col) {\n\t\t\t\tcontinue // skip if we're past the length of this column\n\t\t\t}\n\t\t\tif j == 0 {\n\t\t\t\tb.WriteString(\"  \") // gutter\n\t\t\t} else if j > 0 {\n\t\t\t\tb.WriteString(\"    \") // gap\n\t\t\t}\n\t\t\tb.WriteString(col[i])\n\t\t}\n\t\tif i < tallestCol-1 {\n\t\t\tb.WriteRune('\\n')\n\t\t}\n\t}\n\n\treturn b.String()\n}\n\nfunc concatStringSlices(s ...[]string) (agg []string) {\n\tfor _, v := range s {\n\t\tagg = append(agg, v...)\n\t}\n\treturn\n}\n"
  },
  {
    "path": "ui/stashitem.go",
    "content": "package ui\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/charmbracelet/log\"\n\t\"github.com/muesli/reflow/truncate\"\n\t\"github.com/sahilm/fuzzy\"\n)\n\nconst (\n\tverticalLine         = \"│\"\n\tfileListingStashIcon = \"• \"\n)\n\nfunc stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) {\n\tvar (\n\t\ttruncateTo  = uint(m.common.width - stashViewHorizontalPadding*2) //nolint:gosec\n\t\tgutter      string\n\t\ttitle       = truncate.StringWithTail(md.Note, truncateTo, ellipsis)\n\t\tdate        = md.relativeTime()\n\t\teditedBy    = \"\"\n\t\thasEditedBy = false\n\t\ticon        = \"\"\n\t\tseparator   = \"\"\n\t)\n\n\tisSelected := index == m.cursor()\n\tisFiltering := m.filterState == filtering\n\tsingleFilteredItem := isFiltering && len(m.getVisibleMarkdowns()) == 1\n\n\t// If there are multiple items being filtered don't highlight a selected\n\t// item in the results. If we've filtered down to one item, however,\n\t// highlight that first item since pressing return will open it.\n\tif isSelected && !isFiltering || singleFilteredItem { //nolint:nestif\n\t\t// Selected item\n\t\tif m.statusMessage == stashingStatusMessage {\n\t\t\tgutter = greenFg(verticalLine)\n\t\t\ticon = dimGreenFg(icon)\n\t\t\ttitle = greenFg(title)\n\t\t\tdate = semiDimGreenFg(date)\n\t\t\teditedBy = semiDimGreenFg(editedBy)\n\t\t\tseparator = semiDimGreenFg(separator)\n\t\t} else {\n\t\t\tgutter = dullFuchsiaFg(verticalLine)\n\t\t\tif m.currentSection().key == filterSection &&\n\t\t\t\tm.filterState == filterApplied || singleFilteredItem {\n\t\t\t\ts := lipgloss.NewStyle().Foreground(fuchsia)\n\t\t\t\ttitle = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true))\n\t\t\t} else {\n\t\t\t\ttitle = fuchsiaFg(title)\n\t\t\t\ticon = fuchsiaFg(icon)\n\t\t\t}\n\t\t\tdate = dimFuchsiaFg(date)\n\t\t\teditedBy = dimDullFuchsiaFg(editedBy)\n\t\t\tseparator = dullFuchsiaFg(separator)\n\t\t}\n\t} else {\n\t\tgutter = \" \"\n\t\tif m.statusMessage == stashingStatusMessage {\n\t\t\ticon = dimGreenFg(icon)\n\t\t\ttitle = greenFg(title)\n\t\t\tdate = semiDimGreenFg(date)\n\t\t\teditedBy = semiDimGreenFg(editedBy)\n\t\t\tseparator = semiDimGreenFg(separator)\n\t\t} else if isFiltering && m.filterInput.Value() == \"\" {\n\t\t\ticon = dimGreenFg(icon)\n\t\t\ttitle = dimNormalFg(title)\n\t\t\tdate = dimBrightGrayFg(date)\n\t\t\teditedBy = dimBrightGrayFg(editedBy)\n\t\t\tseparator = dimBrightGrayFg(separator)\n\t\t} else {\n\t\t\ticon = greenFg(icon)\n\n\t\t\ts := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: \"#1a1a1a\", Dark: \"#dddddd\"})\n\t\t\ttitle = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true))\n\t\t\tdate = grayFg(date)\n\t\t\teditedBy = midGrayFg(editedBy)\n\t\t\tseparator = brightGrayFg(separator)\n\t\t}\n\t}\n\n\tfmt.Fprintf(b, \"%s %s%s%s%s\\n\", gutter, icon, separator, separator, title)\n\tfmt.Fprintf(b, \"%s %s\", gutter, date)\n\tif hasEditedBy {\n\t\tfmt.Fprintf(b, \" %s\", editedBy)\n\t}\n}\n\nfunc styleFilteredText(haystack, needles string, defaultStyle, matchedStyle lipgloss.Style) string {\n\tb := strings.Builder{}\n\n\tnormalizedHay, err := normalize(haystack)\n\tif err != nil {\n\t\tlog.Error(\"error normalizing\", \"haystack\", haystack, \"error\", err)\n\t}\n\n\tmatches := fuzzy.Find(needles, []string{normalizedHay})\n\tif len(matches) == 0 {\n\t\treturn defaultStyle.Render(haystack)\n\t}\n\n\tm := matches[0] // only one match exists\n\tfor i, rune := range []rune(haystack) {\n\t\tstyled := false\n\t\tfor _, mi := range m.MatchedIndexes {\n\t\t\tif i == mi {\n\t\t\t\tb.WriteString(matchedStyle.Render(string(rune)))\n\t\t\t\tstyled = true\n\t\t\t}\n\t\t}\n\t\tif !styled {\n\t\t\tb.WriteString(defaultStyle.Render(string(rune)))\n\t\t}\n\t}\n\n\treturn b.String()\n}\n"
  },
  {
    "path": "ui/styles.go",
    "content": "package ui\n\nimport \"github.com/charmbracelet/lipgloss\"\n\n// Colors.\nvar (\n\tnormalDim      = lipgloss.AdaptiveColor{Light: \"#A49FA5\", Dark: \"#777777\"}\n\tgray           = lipgloss.AdaptiveColor{Light: \"#909090\", Dark: \"#626262\"}\n\tmidGray        = lipgloss.AdaptiveColor{Light: \"#B2B2B2\", Dark: \"#4A4A4A\"}\n\tdarkGray       = lipgloss.AdaptiveColor{Light: \"#DDDADA\", Dark: \"#3C3C3C\"}\n\tbrightGray     = lipgloss.AdaptiveColor{Light: \"#847A85\", Dark: \"#979797\"}\n\tdimBrightGray  = lipgloss.AdaptiveColor{Light: \"#C2B8C2\", Dark: \"#4D4D4D\"}\n\tcream          = lipgloss.AdaptiveColor{Light: \"#FFFDF5\", Dark: \"#FFFDF5\"}\n\tyellowGreen    = lipgloss.AdaptiveColor{Light: \"#04B575\", Dark: \"#ECFD65\"}\n\tfuchsia        = lipgloss.AdaptiveColor{Light: \"#EE6FF8\", Dark: \"#EE6FF8\"}\n\tdimFuchsia     = lipgloss.AdaptiveColor{Light: \"#F1A8FF\", Dark: \"#99519E\"}\n\tdullFuchsia    = lipgloss.AdaptiveColor{Dark: \"#AD58B4\", Light: \"#F793FF\"}\n\tdimDullFuchsia = lipgloss.AdaptiveColor{Light: \"#F6C9FF\", Dark: \"#7B4380\"}\n\tgreen          = lipgloss.Color(\"#04B575\")\n\tred            = lipgloss.AdaptiveColor{Light: \"#FF4672\", Dark: \"#ED567A\"}\n\tsemiDimGreen   = lipgloss.AdaptiveColor{Light: \"#35D79C\", Dark: \"#036B46\"}\n\tdimGreen       = lipgloss.AdaptiveColor{Light: \"#72D2B0\", Dark: \"#0B5137\"}\n)\n\n// Ulimately, we'll transition to named styles.\nvar (\n\tdimNormalFg      = lipgloss.NewStyle().Foreground(normalDim).Render\n\tbrightGrayFg     = lipgloss.NewStyle().Foreground(brightGray).Render\n\tdimBrightGrayFg  = lipgloss.NewStyle().Foreground(dimBrightGray).Render\n\tgrayFg           = lipgloss.NewStyle().Foreground(gray).Render\n\tmidGrayFg        = lipgloss.NewStyle().Foreground(midGray).Render\n\tdarkGrayFg       = lipgloss.NewStyle().Foreground(darkGray)\n\tgreenFg          = lipgloss.NewStyle().Foreground(green).Render\n\tsemiDimGreenFg   = lipgloss.NewStyle().Foreground(semiDimGreen).Render\n\tdimGreenFg       = lipgloss.NewStyle().Foreground(dimGreen).Render\n\tfuchsiaFg        = lipgloss.NewStyle().Foreground(fuchsia).Render\n\tdimFuchsiaFg     = lipgloss.NewStyle().Foreground(dimFuchsia).Render\n\tdullFuchsiaFg    = lipgloss.NewStyle().Foreground(dullFuchsia).Render\n\tdimDullFuchsiaFg = lipgloss.NewStyle().Foreground(dimDullFuchsia).Render\n\tredFg            = lipgloss.NewStyle().Foreground(red).Render\n\ttabStyle         = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: \"#909090\", Dark: \"#626262\"})\n\tselectedTabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: \"#333333\", Dark: \"#979797\"})\n\terrorTitleStyle  = lipgloss.NewStyle().Foreground(cream).Background(red).Padding(0, 1)\n\tsubtleStyle      = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: \"#9B9B9B\", Dark: \"#5C5C5C\"})\n\tpaginationStyle  = subtleStyle\n)\n"
  },
  {
    "path": "ui/ui.go",
    "content": "// Package ui provides the main UI for the glow application.\npackage ui\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/glamour/styles\"\n\t\"github.com/charmbracelet/glow/v2/utils\"\n\t\"github.com/charmbracelet/log\"\n\t\"github.com/muesli/gitcha\"\n\tte \"github.com/muesli/termenv\"\n)\n\nconst (\n\tstatusMessageTimeout = time.Second * 3 // how long to show status messages like \"stashed!\"\n\tellipsis             = \"…\"\n)\n\nvar (\n\tconfig Config\n\n\tmarkdownExtensions = []string{\n\t\t\"*.md\", \"*.mdown\", \"*.mkdn\", \"*.mkd\", \"*.markdown\",\n\t}\n)\n\n// NewProgram returns a new Tea program.\nfunc NewProgram(cfg Config, content string) *tea.Program {\n\tlog.Debug(\n\t\t\"Starting glow\",\n\t\t\"high_perf_pager\",\n\t\tcfg.HighPerformancePager,\n\t\t\"glamour\",\n\t\tcfg.GlamourEnabled,\n\t)\n\n\tconfig = cfg\n\topts := []tea.ProgramOption{tea.WithAltScreen()}\n\tif cfg.EnableMouse {\n\t\topts = append(opts, tea.WithMouseCellMotion())\n\t}\n\tm := newModel(cfg, content)\n\treturn tea.NewProgram(m, opts...)\n}\n\ntype errMsg struct{ err error }\n\nfunc (e errMsg) Error() string { return e.err.Error() }\n\ntype (\n\tinitLocalFileSearchMsg struct {\n\t\tcwd string\n\t\tch  chan gitcha.SearchResult\n\t}\n)\n\ntype (\n\tfoundLocalFileMsg       gitcha.SearchResult\n\tlocalFileSearchFinished struct{}\n\tstatusMessageTimeoutMsg applicationContext\n)\n\n// applicationContext indicates the area of the application something applies\n// to. Occasionally used as an argument to commands and messages.\ntype applicationContext int\n\nconst (\n\tstashContext applicationContext = iota\n\tpagerContext\n)\n\n// state is the top-level application state.\ntype state int\n\nconst (\n\tstateShowStash state = iota\n\tstateShowDocument\n)\n\nfunc (s state) String() string {\n\treturn map[state]string{\n\t\tstateShowStash:    \"showing file listing\",\n\t\tstateShowDocument: \"showing document\",\n\t}[s]\n}\n\n// Common stuff we'll need to access in all models.\ntype commonModel struct {\n\tcfg    Config\n\tcwd    string\n\twidth  int\n\theight int\n}\n\ntype model struct {\n\tcommon   *commonModel\n\tstate    state\n\tfatalErr error\n\n\t// Sub-models\n\tstash stashModel\n\tpager pagerModel\n\n\t// Channel that receives paths to local markdown files\n\t// (via the github.com/muesli/gitcha package)\n\tlocalFileFinder chan gitcha.SearchResult\n}\n\n// unloadDocument unloads a document from the pager. Note that while this\n// method alters the model we also need to send along any commands returned.\nfunc (m *model) unloadDocument() []tea.Cmd {\n\tm.state = stateShowStash\n\tm.stash.viewState = stashStateReady\n\tm.pager.unload()\n\tm.pager.showHelp = false\n\n\tvar batch []tea.Cmd\n\tif m.pager.viewport.HighPerformanceRendering {\n\t\tbatch = append(batch, tea.ClearScrollArea) //nolint:staticcheck\n\t}\n\n\tif !m.stash.shouldSpin() {\n\t\tbatch = append(batch, m.stash.spinner.Tick)\n\t}\n\treturn batch\n}\n\nfunc newModel(cfg Config, content string) tea.Model {\n\tinitSections()\n\n\tif cfg.GlamourStyle == styles.AutoStyle {\n\t\tif te.HasDarkBackground() {\n\t\t\tcfg.GlamourStyle = styles.DarkStyle\n\t\t} else {\n\t\t\tcfg.GlamourStyle = styles.LightStyle\n\t\t}\n\t}\n\n\tcommon := commonModel{\n\t\tcfg: cfg,\n\t}\n\n\tm := model{\n\t\tcommon: &common,\n\t\tstate:  stateShowStash,\n\t\tpager:  newPagerModel(&common),\n\t\tstash:  newStashModel(&common),\n\t}\n\n\tpath := cfg.Path\n\tif path == \"\" && content != \"\" {\n\t\tm.state = stateShowDocument\n\t\tm.pager.currentDocument = markdown{Body: content}\n\t\treturn m\n\t}\n\n\tif path == \"\" {\n\t\tpath = \".\"\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tlog.Error(\"unable to stat file\", \"file\", path, \"error\", err)\n\t\tm.fatalErr = err\n\t\treturn m\n\t}\n\tif info.IsDir() {\n\t\tm.state = stateShowStash\n\t} else {\n\t\tcwd, _ := os.Getwd()\n\t\tm.state = stateShowDocument\n\t\tm.pager.currentDocument = markdown{\n\t\t\tlocalPath: path,\n\t\t\tNote:      stripAbsolutePath(path, cwd),\n\t\t\tModtime:   info.ModTime(),\n\t\t}\n\t}\n\n\treturn m\n}\n\nfunc (m model) Init() tea.Cmd {\n\tcmds := []tea.Cmd{m.stash.spinner.Tick}\n\n\tswitch m.state {\n\tcase stateShowStash:\n\t\tcmds = append(cmds, findLocalFiles(*m.common))\n\tcase stateShowDocument:\n\t\tcontent, err := os.ReadFile(m.common.cfg.Path)\n\t\tif err != nil {\n\t\t\tlog.Error(\"unable to read file\", \"file\", m.common.cfg.Path, \"error\", err)\n\t\t\treturn func() tea.Msg { return errMsg{err} }\n\t\t}\n\t\tbody := string(utils.RemoveFrontmatter(content))\n\t\tcmds = append(cmds, renderWithGlamour(m.pager, body))\n\t}\n\n\treturn tea.Batch(cmds...)\n}\n\nfunc (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\t// If there's been an error, any key exits\n\tif m.fatalErr != nil {\n\t\tif _, ok := msg.(tea.KeyMsg); ok {\n\t\t\treturn m, tea.Quit\n\t\t}\n\t}\n\n\tvar cmds []tea.Cmd\n\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\tswitch msg.String() {\n\t\tcase \"esc\":\n\t\t\tif m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument {\n\t\t\t\tbatch := m.unloadDocument()\n\t\t\t\treturn m, tea.Batch(batch...)\n\t\t\t}\n\t\tcase \"r\":\n\t\t\tvar cmd tea.Cmd\n\t\t\tif m.state == stateShowStash {\n\t\t\t\t// pass through all keys if we're editing the filter\n\t\t\t\tif m.stash.filterState == filtering {\n\t\t\t\t\tm.stash, cmd = m.stash.update(msg)\n\t\t\t\t\treturn m, cmd\n\t\t\t\t}\n\t\t\t\tm.stash.markdowns = nil\n\t\t\t\treturn m, m.Init()\n\t\t\t}\n\n\t\tcase \"q\":\n\t\t\tvar cmd tea.Cmd\n\n\t\t\tswitch m.state { //nolint:exhaustive\n\t\t\tcase stateShowStash:\n\t\t\t\t// pass through all keys if we're editing the filter\n\t\t\t\tif m.stash.filterState == filtering {\n\t\t\t\t\tm.stash, cmd = m.stash.update(msg)\n\t\t\t\t\treturn m, cmd\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn m, tea.Quit\n\n\t\tcase \"left\", \"h\", \"delete\":\n\t\t\tif m.state == stateShowDocument {\n\t\t\t\tcmds = append(cmds, m.unloadDocument()...)\n\t\t\t\treturn m, tea.Batch(cmds...)\n\t\t\t}\n\n\t\tcase \"ctrl+z\":\n\t\t\treturn m, tea.Suspend\n\n\t\t// Ctrl+C always quits no matter where in the application you are.\n\t\tcase \"ctrl+c\":\n\t\t\treturn m, tea.Quit\n\t\t}\n\n\t// Window size is received when starting up and on every resize\n\tcase tea.WindowSizeMsg:\n\t\tm.common.width = msg.Width\n\t\tm.common.height = msg.Height\n\t\tm.stash.setSize(msg.Width, msg.Height)\n\t\tm.pager.setSize(msg.Width, msg.Height)\n\n\tcase initLocalFileSearchMsg:\n\t\tm.localFileFinder = msg.ch\n\t\tm.common.cwd = msg.cwd\n\t\tcmds = append(cmds, findNextLocalFile(m))\n\n\tcase fetchedMarkdownMsg:\n\t\t// We've loaded a markdown file's contents for rendering\n\t\tm.pager.currentDocument = *msg\n\t\tbody := string(utils.RemoveFrontmatter([]byte(msg.Body)))\n\t\tcmds = append(cmds, renderWithGlamour(m.pager, body))\n\n\tcase contentRenderedMsg:\n\t\tm.state = stateShowDocument\n\n\tcase localFileSearchFinished:\n\t\t// Always pass these messages to the stash so we can keep it updated\n\t\t// about network activity, even if the user isn't currently viewing\n\t\t// the stash.\n\t\tstashModel, cmd := m.stash.update(msg)\n\t\tm.stash = stashModel\n\t\treturn m, cmd\n\n\tcase foundLocalFileMsg:\n\t\tnewMd := localFileToMarkdown(m.common.cwd, gitcha.SearchResult(msg))\n\t\tm.stash.addMarkdowns(newMd)\n\t\tif m.stash.filterApplied() {\n\t\t\tnewMd.buildFilterValue()\n\t\t}\n\t\tif m.stash.shouldUpdateFilter() {\n\t\t\tcmds = append(cmds, filterMarkdowns(m.stash))\n\t\t}\n\t\tcmds = append(cmds, findNextLocalFile(m))\n\n\tcase filteredMarkdownMsg:\n\t\tif m.state == stateShowDocument {\n\t\t\tnewStashModel, cmd := m.stash.update(msg)\n\t\t\tm.stash = newStashModel\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\t}\n\n\t// Process children\n\tswitch m.state {\n\tcase stateShowStash:\n\t\tnewStashModel, cmd := m.stash.update(msg)\n\t\tm.stash = newStashModel\n\t\tcmds = append(cmds, cmd)\n\n\tcase stateShowDocument:\n\t\tnewPagerModel, cmd := m.pager.update(msg)\n\t\tm.pager = newPagerModel\n\t\tcmds = append(cmds, cmd)\n\t}\n\n\treturn m, tea.Batch(cmds...)\n}\n\nfunc (m model) View() string {\n\tif m.fatalErr != nil {\n\t\treturn errorView(m.fatalErr, true)\n\t}\n\n\tswitch m.state { //nolint:exhaustive\n\tcase stateShowDocument:\n\t\treturn m.pager.View()\n\tdefault:\n\t\treturn m.stash.view()\n\t}\n}\n\nfunc errorView(err error, fatal bool) string {\n\texitMsg := \"press any key to \"\n\tif fatal {\n\t\texitMsg += \"exit\"\n\t} else {\n\t\texitMsg += \"return\"\n\t}\n\ts := fmt.Sprintf(\"%s\\n\\n%v\\n\\n%s\",\n\t\terrorTitleStyle.Render(\"ERROR\"),\n\t\terr,\n\t\tsubtleStyle.Render(exitMsg),\n\t)\n\treturn \"\\n\" + indent(s, 3)\n}\n\n// COMMANDS\n\nfunc findLocalFiles(m commonModel) tea.Cmd {\n\treturn func() tea.Msg {\n\t\tlog.Info(\"findLocalFiles\")\n\t\tvar (\n\t\t\tcwd = m.cfg.Path\n\t\t\terr error\n\t\t)\n\n\t\tif cwd == \"\" {\n\t\t\tcwd, err = os.Getwd()\n\t\t} else {\n\t\t\tvar info os.FileInfo\n\t\t\tinfo, err = os.Stat(cwd)\n\t\t\tif err == nil && info.IsDir() {\n\t\t\t\tcwd, err = filepath.Abs(cwd)\n\t\t\t}\n\t\t}\n\n\t\t// Note that this is one error check for both cases above\n\t\tif err != nil {\n\t\t\tlog.Error(\"error finding local files\", \"error\", err)\n\t\t\treturn errMsg{err}\n\t\t}\n\n\t\tlog.Debug(\"local directory is\", \"cwd\", cwd)\n\n\t\t// Switch between FindFiles and FindAllFiles to bypass .gitignore rules\n\t\tvar ch chan gitcha.SearchResult\n\t\tif m.cfg.ShowAllFiles {\n\t\t\tch, err = gitcha.FindAllFilesExcept(cwd, markdownExtensions, nil)\n\t\t} else {\n\t\t\tch, err = gitcha.FindFilesExcept(cwd, markdownExtensions, ignorePatterns(m))\n\t\t}\n\n\t\tif err != nil {\n\t\t\tlog.Error(\"error finding local files\", \"error\", err)\n\t\t\treturn errMsg{err}\n\t\t}\n\n\t\treturn initLocalFileSearchMsg{ch: ch, cwd: cwd}\n\t}\n}\n\nfunc findNextLocalFile(m model) tea.Cmd {\n\treturn func() tea.Msg {\n\t\tres, ok := <-m.localFileFinder\n\n\t\tif ok {\n\t\t\t// Okay now find the next one\n\t\t\treturn foundLocalFileMsg(res)\n\t\t}\n\t\t// We're done\n\t\tlog.Debug(\"local file search finished\")\n\t\treturn localFileSearchFinished{}\n\t}\n}\n\nfunc waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.Cmd {\n\treturn func() tea.Msg {\n\t\t<-t.C\n\t\treturn statusMessageTimeoutMsg(appCtx)\n\t}\n}\n\n// ETC\n\n// Convert a Gitcha result to an internal representation of a markdown\n// document. Note that we could be doing things like checking if the file is\n// a directory, but we trust that gitcha has already done that.\nfunc localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown {\n\treturn &markdown{\n\t\tlocalPath: res.Path,\n\t\tNote:      stripAbsolutePath(res.Path, cwd),\n\t\tModtime:   res.Info.ModTime(),\n\t}\n}\n\nfunc stripAbsolutePath(fullPath, cwd string) string {\n\tfp, _ := filepath.EvalSymlinks(fullPath)\n\tcp, _ := filepath.EvalSymlinks(cwd)\n\treturn strings.ReplaceAll(fp, cp+string(os.PathSeparator), \"\")\n}\n\n// Lightweight version of reflow's indent function.\nfunc indent(s string, n int) string {\n\tif n <= 0 || s == \"\" {\n\t\treturn s\n\t}\n\tl := strings.Split(s, \"\\n\")\n\tb := strings.Builder{}\n\ti := strings.Repeat(\" \", n)\n\tfor _, v := range l {\n\t\tfmt.Fprintf(&b, \"%s%s\\n\", i, v)\n\t}\n\treturn b.String()\n}\n"
  },
  {
    "path": "url.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"strings\"\n\t\"sync\"\n)\n\nconst (\n\tprotoGithub = \"github://\"\n\tprotoGitlab = \"gitlab://\"\n\tprotoHTTPS  = \"https://\"\n)\n\nvar (\n\tgithubURL *url.URL\n\tgitlabURL *url.URL\n\turlsOnce  sync.Once\n)\n\nfunc init() {\n\turlsOnce.Do(func() {\n\t\tgithubURL, _ = url.Parse(\"https://github.com\")\n\t\tgitlabURL, _ = url.Parse(\"https://gitlab.com\")\n\t})\n}\n\nfunc readmeURL(path string) (*source, error) {\n\tswitch {\n\tcase strings.HasPrefix(path, protoGithub):\n\t\tif u := githubReadmeURL(path); u != nil {\n\t\t\treturn readmeURL(u.String())\n\t\t}\n\t\treturn nil, nil\n\tcase strings.HasPrefix(path, protoGitlab):\n\t\tif u := gitlabReadmeURL(path); u != nil {\n\t\t\treturn readmeURL(u.String())\n\t\t}\n\t\treturn nil, nil\n\t}\n\n\tif !strings.HasPrefix(path, protoHTTPS) {\n\t\tpath = protoHTTPS + path\n\t}\n\tu, err := url.Parse(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to parse url: %w\", err)\n\t}\n\n\tswitch {\n\tcase u.Hostname() == githubURL.Hostname():\n\t\treturn findGitHubREADME(u)\n\tcase u.Hostname() == gitlabURL.Hostname():\n\t\treturn findGitLabREADME(u)\n\t}\n\n\treturn nil, nil\n}\n\nfunc githubReadmeURL(path string) *url.URL {\n\tpath = strings.TrimPrefix(path, protoGithub)\n\tparts := strings.Split(path, \"/\")\n\tif len(parts) != 2 {\n\t\t// custom hostnames are not supported yet\n\t\treturn nil\n\t}\n\tu, _ := url.Parse(githubURL.String())\n\treturn u.JoinPath(path)\n}\n\nfunc gitlabReadmeURL(path string) *url.URL {\n\tpath = strings.TrimPrefix(path, protoGitlab)\n\tparts := strings.Split(path, \"/\")\n\tif len(parts) != 2 {\n\t\t// custom hostnames are not supported yet\n\t\treturn nil\n\t}\n\tu, _ := url.Parse(gitlabURL.String())\n\treturn u.JoinPath(path)\n}\n\nfunc isURL(path string) bool {\n\t_, err := url.ParseRequestURI(path)\n\treturn err == nil && strings.Contains(path, \"://\")\n}\n"
  },
  {
    "path": "url_test.go",
    "content": "package main\n\nimport \"testing\"\n\nfunc TestURLParser(t *testing.T) {\n\tfor path, url := range map[string]string{\n\t\t\"github.com/charmbracelet/glow\":             \"https://raw.githubusercontent.com/charmbracelet/glow/master/README.md\",\n\t\t\"github://charmbracelet/glow\":               \"https://raw.githubusercontent.com/charmbracelet/glow/master/README.md\",\n\t\t\"github://caarlos0/dotfiles.fish\":           \"https://raw.githubusercontent.com/caarlos0/dotfiles.fish/main/README.md\",\n\t\t\"github://tj/git-extras\":                    \"https://raw.githubusercontent.com/tj/git-extras/main/Readme.md\",\n\t\t\"https://github.com/goreleaser/nfpm\":        \"https://raw.githubusercontent.com/goreleaser/nfpm/main/README.md\",\n\t\t\"gitlab.com/caarlos0/test\":                  \"https://gitlab.com/caarlos0/test/-/raw/master/README.md\",\n\t\t\"gitlab://caarlos0/test\":                    \"https://gitlab.com/caarlos0/test/-/raw/master/README.md\",\n\t\t\"https://gitlab.com/terrakok/gitlab-client\": \"https://gitlab.com/terrakok/gitlab-client/-/raw/develop/Readme.md\",\n\t} {\n\t\tt.Run(path, func(t *testing.T) {\n\t\t\tt.Skip(\"test uses network, sometimes fails for no reason\")\n\t\t\tgot, err := readmeURL(path)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t\t\t}\n\t\t\tif got == nil {\n\t\t\t\tt.Fatalf(\"should not be nil\")\n\t\t\t}\n\t\t\tif url != got.URL {\n\t\t\t\tt.Errorf(\"expected url for %s to be %s, was %s\", path, url, got.URL)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "utils/utils.go",
    "content": "// Package utils provides utility functions.\npackage utils\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/glamour\"\n\t\"github.com/charmbracelet/glamour/ansi\"\n\t\"github.com/charmbracelet/glamour/styles\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/mitchellh/go-homedir\"\n)\n\n// RemoveFrontmatter removes the front matter header of a markdown file.\nfunc RemoveFrontmatter(content []byte) []byte {\n\tif frontmatterBoundaries := detectFrontmatter(content); frontmatterBoundaries[0] == 0 {\n\t\treturn content[frontmatterBoundaries[1]:]\n\t}\n\treturn content\n}\n\nvar yamlPattern = regexp.MustCompile(`(?m)^---\\r?\\n(\\s*\\r?\\n)?`)\n\nfunc detectFrontmatter(c []byte) []int {\n\tif matches := yamlPattern.FindAllIndex(c, 2); len(matches) > 1 {\n\t\treturn []int{matches[0][0], matches[1][1]}\n\t}\n\treturn []int{-1, -1}\n}\n\n// ExpandPath expands tilde and all environment variables from the given path.\nfunc ExpandPath(path string) string {\n\ts, err := homedir.Expand(path)\n\tif err == nil {\n\t\treturn os.ExpandEnv(s)\n\t}\n\treturn os.ExpandEnv(path)\n}\n\n// WrapCodeBlock wraps a string in a code block with the given language.\nfunc WrapCodeBlock(s, language string) string {\n\treturn \"```\" + language + \"\\n\" + s + \"```\"\n}\n\nvar markdownExtensions = []string{\n\t\".md\", \".mdown\", \".mkdn\", \".mkd\", \".markdown\",\n}\n\n// IsMarkdownFile returns whether the filename has a markdown extension.\nfunc IsMarkdownFile(filename string) bool {\n\text := filepath.Ext(filename)\n\n\tif ext == \"\" {\n\t\t// By default, assume it's a markdown file.\n\t\treturn true\n\t}\n\n\tfor _, v := range markdownExtensions {\n\t\tif strings.EqualFold(ext, v) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\t// Has an extension but not markdown\n\t// so assume this is a code file.\n\treturn false\n}\n\n// GlamourStyle returns a glamour.TermRendererOption based on the given style.\nfunc GlamourStyle(style string, isCode bool) glamour.TermRendererOption {\n\tif !isCode {\n\t\tif style == styles.AutoStyle {\n\t\t\treturn glamour.WithAutoStyle()\n\t\t}\n\t\treturn glamour.WithStylePath(style)\n\t}\n\n\t// If we are rendering a pure code block, we need to modify the style to\n\t// remove the indentation.\n\n\tvar styleConfig ansi.StyleConfig\n\n\tswitch style {\n\tcase styles.AutoStyle:\n\t\tif lipgloss.HasDarkBackground() {\n\t\t\tstyleConfig = styles.DarkStyleConfig\n\t\t} else {\n\t\t\tstyleConfig = styles.LightStyleConfig\n\t\t}\n\tcase styles.DarkStyle:\n\t\tstyleConfig = styles.DarkStyleConfig\n\tcase styles.LightStyle:\n\t\tstyleConfig = styles.LightStyleConfig\n\tcase styles.PinkStyle:\n\t\tstyleConfig = styles.PinkStyleConfig\n\tcase styles.NoTTYStyle:\n\t\tstyleConfig = styles.NoTTYStyleConfig\n\tcase styles.DraculaStyle:\n\t\tstyleConfig = styles.DraculaStyleConfig\n\tcase styles.TokyoNightStyle:\n\t\tstyleConfig = styles.DraculaStyleConfig\n\tdefault:\n\t\treturn glamour.WithStylesFromJSONFile(style)\n\t}\n\n\tvar margin uint\n\tstyleConfig.CodeBlock.Margin = &margin\n\n\treturn glamour.WithStyles(styleConfig)\n}\n"
  }
]