diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5de2df8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# https://editorconfig.org/ + +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.go] +indent_style = tab +indent_size = 8 + +[*.golden] +insert_final_newline = false +trim_trailing_whitespace = false diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..c19be87 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Setup** +Please complete the following information along with version numbers, if applicable. + - OS [e.g. Ubuntu, macOS] + - Shell [e.g. zsh, fish] + - Terminal Emulator [e.g. kitty, iterm] + - Terminal Multiplexer [e.g. tmux] + - Locale [e.g. en_US.UTF-8, zh_CN.UTF-8, etc.] + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Source Code** +Please include source code if needed to reproduce the behavior. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +Add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..897a394 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: +- name: Discord + url: https://charm.sh/discord + about: Chat on our Discord. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..11fc491 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d944991 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,57 @@ +version: 2 + +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "05:00" + timezone: "America/New_York" + labels: + - "dependencies" + commit-message: + prefix: "chore" + include: "scope" + groups: + all: + patterns: + - "*" + ignore: + - dependency-name: github.com/charmbracelet/bubbletea/v2 + versions: + - v2.0.0-beta1 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "05:00" + timezone: "America/New_York" + labels: + - "dependencies" + commit-message: + prefix: "chore" + include: "scope" + groups: + all: + patterns: + - "*" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "05:00" + timezone: "America/New_York" + labels: + - "dependencies" + commit-message: + prefix: "chore" + include: "scope" + groups: + all: + patterns: + - "*" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b0c9f68..adcb449 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,28 +1,25 @@ name: build + on: [push, pull_request] + jobs: - test: - strategy: - matrix: - go-version: [~1.13, ^1] - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - env: - GO111MODULE: "on" - steps: - - name: Install Go - uses: actions/setup-go@v2 - with: - go-version: ${{ matrix.go-version }} + build: + uses: charmbracelet/meta/.github/workflows/build.yml@main - - name: Checkout code - uses: actions/checkout@v2 + snapshot: + uses: charmbracelet/meta/.github/workflows/snapshot.yml@main + secrets: + goreleaser_key: ${{ secrets.GORELEASER_KEY }} - - name: Download Go modules - run: go mod download + govulncheck: + uses: charmbracelet/meta/.github/workflows/govulncheck.yml@main + with: + go-version: stable - - name: Build - run: go build -v ./... + semgrep: + uses: charmbracelet/meta/.github/workflows/semgrep.yml@main - - name: Test - run: go test ./... + ruleguard: + uses: charmbracelet/meta/.github/workflows/ruleguard.yml@main + with: + go-version: stable diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 01c10d2..7fbd40b 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -3,26 +3,22 @@ on: [push, pull_request] jobs: coverage: - strategy: - matrix: - go-version: [^1] - os: [ubuntu-latest] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest env: GO111MODULE: "on" steps: - - name: Install Go - uses: actions/setup-go@v2 - with: - go-version: ${{ matrix.go-version }} - - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v6 + + - name: Install Go + uses: actions/setup-go@v6 + with: + go-version: stable - name: Coverage env: COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | go test -race -covermode atomic -coverprofile=profile.cov ./... - GO111MODULE=off go get github.com/mattn/goveralls - $(go env GOPATH)/bin/goveralls -coverprofile=profile.cov -service=github + go install github.com/mattn/goveralls@latest + goveralls -coverprofile=profile.cov -service=github diff --git a/.github/workflows/dependabot-sync.yml b/.github/workflows/dependabot-sync.yml new file mode 100644 index 0000000..9b08259 --- /dev/null +++ b/.github/workflows/dependabot-sync.yml @@ -0,0 +1,17 @@ +name: dependabot-sync +on: + schedule: + - cron: "0 0 * * 0" # every Sunday at midnight + workflow_dispatch: # allows manual triggering + +permissions: + contents: write + pull-requests: write + +jobs: + dependabot-sync: + uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main + with: + repo_name: ${{ github.event.repository.name }} + secrets: + gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 8940d0d..fbc2046 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -1,34 +1,25 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + name: goreleaser on: - pull_request: push: + tags: + - v*.*.* + +concurrency: + group: goreleaser + cancel-in-progress: true jobs: goreleaser: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@v2 - with: - go-version: ~1.16 - - name: Set up Snapcraft - run: | - sudo apt-get update - sudo apt-get -yq --no-install-suggests --no-install-recommends install snapcraft - mkdir -p $HOME/.cache/snapcraft/download - mkdir -p $HOME/.cache/snapcraft/stage-packages - - name: Login Snapcraft - env: - SNAPCRAFT_LOGIN: ${{ secrets.SNAPCRAFT_LOGIN }} - if: env.SNAPCRAFT_LOGIN != null - run: snapcraft login --with <(echo "$SNAPCRAFT_LOGIN") - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v2 - with: - version: latest - args: release --snapshot --skip-publish --skip-sign --rm-dist + uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main + secrets: + docker_username: ${{ secrets.DOCKERHUB_USERNAME }} + docker_token: ${{ secrets.DOCKERHUB_TOKEN }} + gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + goreleaser_key: ${{ secrets.GORELEASER_KEY }} + fury_token: ${{ secrets.FURY_TOKEN }} + nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }} + nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }} + snapcraft_token: ${{ secrets.SNAPCRAFT_TOKEN }} diff --git a/.github/workflows/lint-sync.yml b/.github/workflows/lint-sync.yml new file mode 100644 index 0000000..43f380f --- /dev/null +++ b/.github/workflows/lint-sync.yml @@ -0,0 +1,13 @@ +name: lint-sync +on: + schedule: + - cron: "0 0 * * 0" # every sunday at midnight + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + lint: + uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6249b5b..1e21125 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,9 +8,9 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: golangci-lint - uses: golangci/golangci-lint-action@v2 + uses: golangci/golangci-lint-action@v9.2.0 with: # Optional: golangci-lint command line arguments. args: --issues-exit-code=0 diff --git a/.github/workflows/soft-serve.yml b/.github/workflows/soft-serve.yml deleted file mode 100644 index 8eb3221..0000000 --- a/.github/workflows/soft-serve.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: soft-serve - -on: - push: - branches: - - master - -jobs: - soft-serve: - uses: charmbracelet/meta/.github/workflows/soft-serve.yml@main - secrets: - ssh-key: "${{ secrets.CHARM_SOFT_SERVE_KEY }}" diff --git a/.gitignore b/.gitignore index d3003c1..8a5159a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ glow dist/ .envrc +completions/ +manpages/ diff --git a/.golangci.yml b/.golangci.yml index 85cfb6d..929cb0a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,34 +1,44 @@ +version: "2" run: tests: false - -issues: - include: - - EXC0001 - - EXC0005 - - EXC0011 - - EXC0012 - - EXC0013 - - max-issues-per-linter: 0 - max-same-issues: 0 - linters: enable: - bodyclose - - dupl - - exportloopref + - exhaustive - goconst - godot - - godox - - goimports + - gomoddirectives - goprintffuncname - gosec - - ifshort - misspell + - nakedret + - nestif + - nilerr + - noctx + - nolintlint - prealloc - revive - rowserrcheck - sqlclosecheck + - tparallel - unconvert - unparam - whitespace + - wrapcheck + exclusions: + rules: + - text: '(slog|log)\.\w+' + linters: + - noctx + generated: lax + presets: + - common-false-positives +issues: + max-issues-per-linter: 0 + max-same-issues: 0 +formatters: + enable: + - gofumpt + - goimports + exclusions: + generated: lax diff --git a/.goreleaser.yml b/.goreleaser.yml index 04f31e0..0c539b7 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,95 +1,14 @@ -env: - - GO111MODULE=on - - CGO_ENABLED=0 -before: - hooks: - - go mod tidy -builds: - - - binary: glow - ldflags: -s -w -X main.Version={{ .Version }} -X main.CommitSHA={{ .Commit }} - goos: - - linux - - freebsd - - openbsd - - darwin - - windows - goarch: - - amd64 - - arm64 - - 386 - - arm - goarm: - - 6 - - 7 +# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json -archives: - - - builds: - - glow - format_overrides: - - goos: windows - format: zip - replacements: - windows: Windows - darwin: Darwin - 386: i386 - amd64: x86_64 +version: 2 -nfpms: - - - vendor: charmbracelet - homepage: "https://charm.sh/" - maintainer: "Christian Muehlhaeuser " - description: "Render markdown on the CLI" - license: MIT - formats: - - apk - - deb - - rpm - bindir: /usr/bin +includes: + - from_url: + url: charmbracelet/meta/main/goreleaser-glow.yaml -brews: - - goarm: 6 - tap: - owner: charmbracelet - name: homebrew-tap - commit_author: - name: "Christian Muehlhaeuser" - email: "muesli@gmail.com" - homepage: "https://charm.sh/" - description: "Render markdown on the CLI" - # skip_upload: true - -snapcrafts: - - - name: glow - publish: true - summary: Render markdown on the CLI, with pizzazz! - description: | - Glow is a terminal based markdown reader designed from the ground up to bring out the beauty—and power—of the CLI. - Use it to discover markdown files, read documentation directly on the command line and stash markdown files to your own private collection so you can read them anywhere. Glow will find local markdown files in subdirectories or a local Git repository. - By the way, all data stashed is encrypted end-to-end: only you can decrypt it. More on that below. - - grade: stable - confinement: strict - license: MIT - base: core20 - - apps: - glow: - plugs: ["home", "network"] - -signs: - - artifacts: checksum - -checksum: - name_template: "checksums.txt" -snapshot: - name_template: "{{ .Tag }}-next" -changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" +variables: + description: "Render markdown on the CLI, with pizzazz!" + github_url: "https://github.com/charmbracelet/glow" + maintainer: "Christian Muehlhaeuser " + brew_commit_author_name: "Christian Muehlhaeuser" + brew_commit_author_email: "muesli@charm.sh" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8719b4e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM gcr.io/distroless/static +COPY glow /usr/local/bin/glow +ENTRYPOINT [ "/usr/local/bin/glow" ] diff --git a/LICENSE b/LICENSE index 2e0a4da..1e27a61 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-2021 Charmbracelet, Inc +Copyright (c) 2019-2024 Charmbracelet, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile deleted file mode 100644 index 6d80864..0000000 --- a/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# This Makefile is just for development purposes - -.PHONY: default clean glow run log - -LOGFILE := debug.log - -default: glow - -clean: - rm -f ./glow - -glow: - go build - -run: clean glow - GLOW_LOGFILE=$(LOGFILE) ./glow - -log: - > $(LOGFILE) - tail -f $(LOGFILE) diff --git a/README.md b/README.md index f59d9fe..cf3bb15 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Render markdown on the CLI, with _pizzazz_!

- Glow UI Demo + Glow UI Demo

## What is it? @@ -20,51 +20,97 @@ Glow is a terminal based markdown reader designed from the ground up to bring out the beauty—and power—of the CLI. Use it to discover markdown files, read documentation directly on the command -line and stash markdown files to your own private collection so you can read -them anywhere. Glow will find local markdown files in subdirectories or a local +line. Glow will find local markdown files in subdirectories or a local Git repository. -By the way, all data stashed is encrypted end-to-end: only you can decrypt it. -More on that below. - ## Installation -Use your fave package manager: +### Package Manager ```bash # macOS or Linux brew install glow +``` +```bash # macOS (with MacPorts) sudo port install glow +``` +```bash # Arch Linux (btw) pacman -S glow +``` +```bash # Void Linux xbps-install -S glow +``` -# Nix -nix-env -iA nixpkgs.glow +```bash +# Nix shell +nix-shell -p glow --command glow +``` +```bash # FreeBSD pkg install glow +``` +```bash # Solus eopkg install glow +``` -# Windows (with Scoop) +```bash +# Windows (with Chocolatey, Scoop, or Winget) +choco install glow scoop install glow +winget install charmbracelet.glow +``` +```bash # Android (with termux) pkg install glow ``` -Or download a binary from the [releases][releases] page. MacOS, Linux, Windows, -FreeBSD, and OpenBSD binaries are available, as well as Debian, RPM, and Alpine -packages. ARM builds are also available for macOS, Linux, FreeBSD, and OpenBSD. +```bash +# Ubuntu (Snapcraft) +sudo snap install glow +``` -Or just build it yourself (requires Go 1.13+): +```bash +# Debian/Ubuntu +sudo mkdir -p /etc/apt/keyrings +curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg +echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list +sudo apt update && sudo apt install glow +``` + +```bash +# Fedora/RHEL +echo '[charm] +name=Charm +baseurl=https://repo.charm.sh/yum/ +enabled=1 +gpgcheck=1 +gpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo +sudo yum install glow +``` + +Or download a binary from the [releases][releases] page. MacOS, Linux, Windows, +FreeBSD and OpenBSD binaries are available, as well as Debian, RPM, and Alpine +packages. ARM builds are also available for macOS, Linux, FreeBSD and OpenBSD. + +### Go + +Or just install it with `go`: + +```bash +go install github.com/charmbracelet/glow/v2@latest +``` + +### Build (requires Go 1.21+) ```bash git clone https://github.com/charmbracelet/glow.git @@ -74,11 +120,10 @@ go build [releases]: https://github.com/charmbracelet/glow/releases - ## The TUI Simply run `glow` without arguments to start the textual user interface and -browse local and stashed markdown. Glow will find local markdown files in the +browse local. Glow will find local markdown files in the current directory and below or, if you’re in a Git repository, Glow will search the repo. @@ -86,15 +131,6 @@ Markdown files can be read with Glow's high-performance pager. Most of the keystrokes you know from `less` are the same, but you can press `?` to list the hotkeys. -### Stashing - -Glow works with the Charm Cloud to allow you to store any markdown files in -your own private collection. You can stash a local document from the Glow TUI by -pressing `s`. - -Stashing is private, its contents will not be exposed publicly, and it's -encrypted end-to-end. More on encryption below. - ## The CLI In addition to a TUI, Glow has a CLI for working with Markdown. To format a @@ -105,7 +141,7 @@ document use a markdown source as the primary argument: glow README.md # Read from stdin -glow - +echo "[Glow](https://github.com/charmbracelet/glow)" | glow - # Fetch README from GitHub / GitLab glow github.com/charmbracelet/glow @@ -114,18 +150,6 @@ glow github.com/charmbracelet/glow glow https://host.tld/file.md ``` -### Stashing - -You can also stash documents from the CLI: - -```bash -glow stash README.md -``` - -Then, when you run `glow` without arguments will you can browse through your -stashed documents. This is a great way to keep track of things that you need to -reference often. - ### Word Wrapping The `-w` flag lets you set a maximum width at which the output will be wrapped: @@ -177,38 +201,42 @@ Here's an example config: ```yaml # style name or JSON path (default "auto") style: "light" -# show local files only; no network (TUI-mode only) -local: true -# mouse support (TUI-mode only) +# mouse wheel support (TUI-mode only) mouse: true # use pager to display markdown pager: true -# word-wrap at width +# at which column should we word wrap? width: 80 +# show all files, including hidden and ignored. +all: false +# show line numbers (TUI-mode only) +showLineNumbers: false +# preserve newlines in the output +preserveNewLines: false ``` -## 🔒 Encryption: How It Works +## Contributing -Encryption works by issuing symmetric keys (basically a generated password) and -encrypting it with the local SSH public key generated by the open-source -[charm][charmlib] library. That encrypted key is then sent up to our server. -We can’t read it since we don’t have your private key. When you want to decrypt -something or view your stash, that key is downloaded from our server and -decrypted locally using the SSH private key. When you link accounts, the -symmetric key is encrypted for each new public key. This happens on your -machine and not our server, so we never see any unencrypted data. +See [contributing][contribute]. -[charmlib]: https://github.com/charmbracelet/charm +[contribute]: https://github.com/charmbracelet/glow/contribute + +## Feedback + +We’d love to hear your thoughts on this project. Feel free to drop us a note! + +- [Twitter](https://twitter.com/charmcli) +- [The Fediverse](https://mastodon.social/@charmcli) +- [Discord](https://charm.sh/chat) ## License [MIT](https://github.com/charmbracelet/glow/raw/master/LICENSE) -* * * +--- Part of [Charm](https://charm.sh). -The Charm logo +The Charm logo Charm热爱开源 • Charm loves open source - diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..6d8c844 --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,23 @@ +# https://taskfile.dev + +version: '3' + +tasks: + lint: + desc: Run base linters + cmds: + - golangci-lint run + + test: + desc: Run tests + cmds: + - go test ./... {{.CLI_ARGS}} + + log: + desc: Watch for glow logs + aliases: [tail] + cmds: + - cmd: tail -f ~/Library/Caches/glow/glow.log + platforms: [darwin] + - cmd: tail -f ~/.cache/glow/glow.log + platforms: [linux, windows] diff --git a/config_cmd.go b/config_cmd.go index a90b903..390a1db 100644 --- a/config_cmd.go +++ b/config_cmd.go @@ -3,82 +3,86 @@ package main import ( "errors" "fmt" + "io/fs" "os" - "os/exec" "path" + "path/filepath" - "github.com/charmbracelet/charm/ui/common" - gap "github.com/muesli/go-app-paths" + "github.com/charmbracelet/x/editor" "github.com/spf13/cobra" + "github.com/spf13/viper" ) const defaultConfig = `# style name or JSON path (default "auto") style: "auto" -# show local files only; no network (TUI-mode only) -local: false # mouse support (TUI-mode only) mouse: false # use pager to display markdown pager: false # word-wrap at width -width: 80` +width: 80 +# show all files, including hidden and ignored. +all: false +` var configCmd = &cobra.Command{ Use: "config", Hidden: false, Short: "Edit the glow config file", - Long: formatBlock(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.", common.Keyword("Edit"))), - Example: formatBlock("glow config\nglow config --config path/to/config.yml"), + Long: paragraph(fmt.Sprintf("\n%s the glow config file. We’ll use EDITOR to determine which editor to use. If the config file doesn't exist, it will be created.", keyword("Edit"))), + Example: paragraph("glow config\nglow config --config path/to/config.yml"), Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - editor := os.Getenv("EDITOR") - if editor == "" { - return errors.New("no EDITOR environment variable set") - } - - if configFile == "" { - scope := gap.NewScope(gap.User, "glow") - - var err error - configFile, err = scope.ConfigPath("glow.yml") - if err != nil { - return err - } - } - - if ext := path.Ext(configFile); ext != ".yaml" && ext != ".yml" { - return fmt.Errorf("'%s' is not a supported config type: use '%s' or '%s'\n", ext, ".yaml", ".yml") - } - - if _, err := os.Stat(configFile); os.IsNotExist(err) { - // File doesn't exist yet, create all necessary directories and - // write the default config file - if err := os.MkdirAll(path.Dir(configFile), 0700); err != nil { - return err - } - - f, err := os.Create(configFile) - if err != nil { - return err - } - defer f.Close() - - if _, err := f.WriteString(defaultConfig); err != nil { - return err - } - } else if err != nil { // some other error occurred + RunE: func(*cobra.Command, []string) error { + if err := ensureConfigFile(); err != nil { return err } - c := exec.Command(editor, configFile) + c, err := editor.Cmd("Glow", configFile) + if err != nil { + return fmt.Errorf("unable to set config file: %w", err) + } c.Stdin = os.Stdin c.Stdout = os.Stdout c.Stderr = os.Stderr if err := c.Run(); err != nil { - return err + return fmt.Errorf("unable to run command: %w", err) } fmt.Println("Wrote config file to:", configFile) return nil }, } + +func ensureConfigFile() error { + if configFile == "" { + configFile = viper.GetViper().ConfigFileUsed() + if err := os.MkdirAll(filepath.Dir(configFile), 0o755); err != nil { //nolint:gosec + return fmt.Errorf("could not write configuration file: %w", err) + } + } + + if ext := path.Ext(configFile); ext != ".yaml" && ext != ".yml" { + return fmt.Errorf("'%s' is not a supported configuration type: use '%s' or '%s'", ext, ".yaml", ".yml") + } + + if _, err := os.Stat(configFile); errors.Is(err, fs.ErrNotExist) { + // File doesn't exist yet, create all necessary directories and + // write the default config file + if err := os.MkdirAll(filepath.Dir(configFile), 0o700); err != nil { + return fmt.Errorf("unable create directory: %w", err) + } + + f, err := os.Create(configFile) + if err != nil { + return fmt.Errorf("unable to create config file: %w", err) + } + defer func() { _ = f.Close() }() + + if _, err := f.WriteString(defaultConfig); err != nil { + return fmt.Errorf("unable to write config file: %w", err) + } + } else if err != nil { // some other error occurred + return fmt.Errorf("unable to stat config file: %w", err) + } + return nil +} diff --git a/formatting.go b/formatting.go deleted file mode 100644 index 6e3dffa..0000000 --- a/formatting.go +++ /dev/null @@ -1,15 +0,0 @@ -package main - -import ( - "github.com/muesli/reflow/indent" - "github.com/muesli/reflow/wordwrap" -) - -const ( - wrapAt = 78 - indentAmount = 2 -) - -func formatBlock(s string) string { - return indent.String(wordwrap.String(s, wrapAt-indentAmount), indentAmount) -} diff --git a/github.go b/github.go index 7cf29bc..fe862e3 100644 --- a/github.go +++ b/github.go @@ -1,45 +1,55 @@ package main import ( + "encoding/json" "errors" + "fmt" + "io" "net/http" "net/url" "strings" ) -// isGitHubURL tests a string to determine if it is a well-structured GitHub URL. -func isGitHubURL(s string) (string, bool) { - if strings.HasPrefix(s, "github.com/") { - s = "https://" + s +// findGitHubREADME tries to find the correct README filename in a repository using GitHub API. +func findGitHubREADME(u *url.URL) (*source, error) { + owner, repo, ok := strings.Cut(strings.TrimPrefix(u.Path, "/"), "/") + if !ok { + return nil, fmt.Errorf("invalid url: %s", u.String()) } - u, err := url.ParseRequestURI(s) + type readme struct { + DownloadURL string `json:"download_url"` + } + + apiURL := fmt.Sprintf("https://api.%s/repos/%s/%s/readme", u.Hostname(), owner, repo) + + //nolint:bodyclose + // it is closed on the caller + res, err := http.Get(apiURL) //nolint: gosec,noctx if err != nil { - return "", false + return nil, fmt.Errorf("unable to get url: %w", err) } - return u.String(), strings.ToLower(u.Host) == "github.com" -} - -// findGitHubREADME tries to find the correct README filename in a repository. -func findGitHubREADME(s string) (*source, error) { - u, err := url.ParseRequestURI(s) + body, err := io.ReadAll(res.Body) if err != nil { - return nil, err + return nil, fmt.Errorf("unable to read http response body: %w", err) } - u.Host = "raw.githubusercontent.com" - for _, r := range readmeNames { - v := u - v.Path += "/master/" + r + var result readme + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("unable to parse json: %w", err) + } - resp, err := http.Get(v.String()) + if res.StatusCode == http.StatusOK { + //nolint:bodyclose + // it is closed on the caller + resp, err := http.Get(result.DownloadURL) //nolint: noctx if err != nil { - return nil, err + return nil, fmt.Errorf("unable to get url: %w", err) } if resp.StatusCode == http.StatusOK { - return &source{resp.Body, v.String()}, nil + return &source{resp.Body, result.DownloadURL}, nil } } diff --git a/gitlab.go b/gitlab.go index 5ec89c7..68256be 100644 --- a/gitlab.go +++ b/gitlab.go @@ -1,44 +1,59 @@ package main import ( + "encoding/json" "errors" + "fmt" + "io" "net/http" "net/url" "strings" ) -// isGitLabURL tests a string to determine if it is a well-structured GitLab URL. -func isGitLabURL(s string) (string, bool) { - if strings.HasPrefix(s, "gitlab.com/") { - s = "https://" + s +// findGitLabREADME tries to find the correct README filename in a repository using GitLab API. +func findGitLabREADME(u *url.URL) (*source, error) { + owner, repo, ok := strings.Cut(strings.TrimPrefix(u.Path, "/"), "/") + if !ok { + return nil, fmt.Errorf("invalid url: %s", u.String()) } - u, err := url.ParseRequestURI(s) + projectPath := url.QueryEscape(owner + "/" + repo) + + type readme struct { + ReadmeURL string `json:"readme_url"` + } + + apiURL := fmt.Sprintf("https://%s/api/v4/projects/%s", u.Hostname(), projectPath) + + //nolint:bodyclose + // it is closed on the caller + res, err := http.Get(apiURL) //nolint: gosec,noctx if err != nil { - return "", false + return nil, fmt.Errorf("unable to get url: %w", err) } - return u.String(), strings.ToLower(u.Host) == "gitlab.com" -} - -// findGitLabREADME tries to find the correct README filename in a repository. -func findGitLabREADME(s string) (*source, error) { - u, err := url.ParseRequestURI(s) + body, err := io.ReadAll(res.Body) if err != nil { - return nil, err + return nil, fmt.Errorf("unable to read http response body: %w", err) } - for _, r := range readmeNames { - v := u - v.Path += "/raw/master/" + r + var result readme + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("unable to parse json: %w", err) + } - resp, err := http.Get(v.String()) + readmeRawURL := strings.ReplaceAll(result.ReadmeURL, "blob", "raw") + + if res.StatusCode == http.StatusOK { + //nolint:bodyclose + // it is closed on the caller + resp, err := http.Get(readmeRawURL) //nolint: gosec,noctx if err != nil { - return nil, err + return nil, fmt.Errorf("unable to get url: %w", err) } if resp.StatusCode == http.StatusOK { - return &source{resp.Body, v.String()}, nil + return &source{resp.Body, readmeRawURL}, nil } } diff --git a/glow_test.go b/glow_test.go index ed4f0cd..8743be2 100644 --- a/glow_test.go +++ b/glow_test.go @@ -1,31 +1,9 @@ package main import ( - "bytes" "testing" ) -func TestGlowSources(t *testing.T) { - tt := []string{ - ".", - "README.md", - "github.com/charmbracelet/glow", - "https://github.com/charmbracelet/glow", - } - - for _, v := range tt { - buf := &bytes.Buffer{} - err := executeArg(rootCmd, v, buf) - - if err != nil { - t.Errorf("Error during execution (args: %s): %v", v, err) - } - if buf.Len() == 0 { - t.Errorf("Output buffer should not be empty (args: %s)", v) - } - } -} - func TestGlowFlags(t *testing.T) { tt := []struct { args []string diff --git a/go.mod b/go.mod index 1a1c576..6e05b0d 100644 --- a/go.mod +++ b/go.mod @@ -1,29 +1,75 @@ -module github.com/charmbracelet/glow +module github.com/charmbracelet/glow/v2 -go 1.13 +go 1.25.12 + +toolchain go1.26.5 require ( - github.com/charmbracelet/bubbles v0.7.6 - github.com/charmbracelet/bubbletea v0.13.2 - github.com/charmbracelet/charm v0.8.6 - github.com/charmbracelet/glamour v0.2.1-0.20210402234443-abe9cda419ba - github.com/dustin/go-humanize v1.0.1-0.20200219035652-afde56e7acac - github.com/google/uuid v1.1.2 // indirect - github.com/kylelemons/godebug v1.1.0 // indirect - github.com/mattn/go-runewidth v0.0.12 - github.com/meowgorithm/babyenv v1.3.1 + github.com/atotto/clipboard v0.1.4 + github.com/caarlos0/env/v11 v11.3.1 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/glamour v0.10.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/charmbracelet/log v0.4.2 + github.com/charmbracelet/x/editor v0.1.0 + github.com/dustin/go-humanize v1.0.1 + github.com/fsnotify/fsnotify v1.9.0 + github.com/mattn/go-runewidth v0.0.19 github.com/mitchellh/go-homedir v1.1.0 - github.com/muesli/gitcha v0.2.0 - github.com/muesli/go-app-paths v0.2.1 - github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 - github.com/muesli/termenv v0.8.1 - github.com/sahilm/fuzzy v0.1.0 - github.com/segmentio/ksuid v1.0.3 - github.com/spf13/cobra v1.4.0 - github.com/spf13/viper v1.4.0 - golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect - golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc // indirect - golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78 - golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf - golang.org/x/text v0.3.2 + github.com/muesli/gitcha v0.3.0 + github.com/muesli/go-app-paths v0.2.2 + github.com/muesli/mango-cobra v1.3.0 + github.com/muesli/reflow v0.3.0 + github.com/muesli/roff v0.1.0 + github.com/muesli/termenv v0.16.0 + github.com/sahilm/fuzzy v0.1.1 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + golang.org/x/sys v0.43.0 + golang.org/x/term v0.42.0 + golang.org/x/text v0.39.0 + mvdan.cc/sh/v3 v3.13.1 +) + +require ( + github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/mango v0.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.17 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect + golang.org/x/net v0.53.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index b2779fd..0ccd7cd 100644 --- a/go.sum +++ b/go.sum @@ -1,280 +1,177 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= -github.com/alecthomas/chroma v0.8.2 h1:x3zkuE2lUk/RIekyAJ3XRqSCP4zwWDfcw/YJCuCAACg= -github.com/alecthomas/chroma v0.8.2/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= -github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE= -github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY= -github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= -github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY= -github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/calmh/randomart v1.1.0 h1:evl+iwc10LXtHdMZhzLxmsCQVmWnkXs44SbC6Uk0Il8= -github.com/calmh/randomart v1.1.0/go.mod h1:DQUbPVyP+7PAs21w/AnfMKG5NioxS3TbZ2F9MSK/jFM= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/charmbracelet/bubbles v0.7.5/go.mod h1:IRTORFvhEI6OUH7WhN2Ks8Z8miNGimk1BE6cmHijOkM= -github.com/charmbracelet/bubbles v0.7.6 h1:SCAp4ZEUf2tBNEsufo+Xxxu2dvbFhYSDPrX45toQZrM= -github.com/charmbracelet/bubbles v0.7.6/go.mod h1:0D4XRYK0tjo8JMvflz1obpVcOikNZSG46SFauoZj22s= -github.com/charmbracelet/bubbletea v0.12.2/go.mod h1:3gZkYELUOiEUOp0bTInkxguucy/xRbGSOcbMs1geLxg= -github.com/charmbracelet/bubbletea v0.13.2 h1:fSOx3q0/VbA3ChWeiNcUsNeNysD9FFWD1tZypShBuCQ= -github.com/charmbracelet/bubbletea v0.13.2/go.mod h1:okqaA5VF0aSpEZ2HB+L/cxVw2HthIDZ1dmWoRZs8/4g= -github.com/charmbracelet/charm v0.8.6 h1:/U6rxGj4J6zZ1Ex8+wTr4hNMr4ESBzNZbC1UyrJPVbg= -github.com/charmbracelet/charm v0.8.6/go.mod h1:8dE3uX+TYSpa7Q6e/CmjN6WSd7koSAKNQTGWugFREx4= -github.com/charmbracelet/glamour v0.2.1-0.20210402234443-abe9cda419ba h1:smKYYwwVPZyMK2LCirIi2WY25tZZW0IU7GYe1ASGCe4= -github.com/charmbracelet/glamour v0.2.1-0.20210402234443-abe9cda419ba/go.mod h1:nHP5wEbsv2eOJ7XfiScQV3p5dpZSM051R0VkxnOIPgg= -github.com/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU= -github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc= -github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= -github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= -github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/editor v0.1.0 h1:p69/dpvlwRTs9uYiPeAWruwsHqTFzHhTvQOd/WVSX98= +github.com/charmbracelet/x/editor v0.1.0/go.mod h1:oivrEbcP/AYt/Hpvk5pwDXXrQ933gQS6UzL6fxqAGSA= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= -github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= -github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dustin/go-humanize v1.0.1-0.20200219035652-afde56e7acac h1:opbrjaN/L8gg6Xh5D04Tem+8xVcz6ajZlGCs49mQgyg= -github.com/dustin/go-humanize v1.0.1-0.20200219035652-afde56e7acac/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= -github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= -github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= -github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/meowgorithm/babyenv v1.3.0/go.mod h1:lwNX+J6AGBFqNrMZ2PTLkM6SO+W4X8DOg9zBDO4j3Ig= -github.com/meowgorithm/babyenv v1.3.1 h1:18ZEYIgbzoFQfRLF9+lxjRfk/ui6w8U0FWl07CgWvvc= -github.com/meowgorithm/babyenv v1.3.1/go.mod h1:lwNX+J6AGBFqNrMZ2PTLkM6SO+W4X8DOg9zBDO4j3Ig= -github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg= -github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w= -github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE= -github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a/go.mod h1:v8eSC2SMp9/7FTKUncp7fH9IwPfw+ysMObcEz5FWheQ= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/muesli/gitcha v0.2.0 h1:+wOgT2dI9s2Tznj1t1rb/qkK5e0cb6qD8c4IX2TR/YY= -github.com/muesli/gitcha v0.2.0/go.mod h1:Ri8m9TZS4+ORG4JVmVKUQcWZuxDvUW3UKxMdQfzG2zI= -github.com/muesli/go-app-paths v0.2.1 h1:Qi+2igkDX2aPqyRddp7P0sMQIBwBqhkfQfNcjdGjL6Y= -github.com/muesli/go-app-paths v0.2.1/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho= -github.com/muesli/reflow v0.1.0/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I= -github.com/muesli/reflow v0.2.0/go.mod h1:qT22vjVmM9MIUeLgsVYe/Ye7eZlbv9dZjL3dVhUqLX8= -github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9FnmSjdbWUlLGvcxrY0Rw3ATltrxOhk= -github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= -github.com/muesli/sasquatch v0.0.0-20200811221207-66979d92330a h1:Hw/15RYEOUD6T9UCRkUmNBa33kJkH33Fui6hE4sRLKU= -github.com/muesli/sasquatch v0.0.0-20200811221207-66979d92330a/go.mod h1:+XG0ne5zXWBTSbbe7Z3/RWxaT8PZY6zaZ1dX6KjprYY= -github.com/muesli/termenv v0.7.2/go.mod h1:ct2L5N2lmix82RaY3bMWwVu/jUFc9Ule0KGDCiKYPh8= -github.com/muesli/termenv v0.7.4/go.mod h1:pZ7qY9l3F7e5xsAOS0zCew2tME+p7bWeBkotCEcIIcc= -github.com/muesli/termenv v0.8.1 h1:9q230czSP3DHVpkaPDXGp0TOfAwyjyYwXlUCQxQSaBk= -github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= -github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/gitcha v0.3.0 h1:+PJkVKrDXVB0VgRn/yVx2CqSVSDGMSepzvohsCrPYtQ= +github.com/muesli/gitcha v0.3.0/go.mod h1:vX3jFL+XcEUq1uY74RCjLSZfAV+ZuvLg70/NGPdXn84= +github.com/muesli/go-app-paths v0.2.2 h1:NqG4EEZwNIhBq/pREgfBmgDmt3h1Smr1MjZiXbpZUnI= +github.com/muesli/go-app-paths v0.2.2/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho= +github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= +github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec= +github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 h1:G04eS0JkAIVZfaJLjla9dNxkJCPiKIGZlw9AfOhzOD0= github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94/go.mod h1:b18R55ulyQ/h3RaWyloPyER7fWQVZvimKKhnI5OfrJQ= -github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= -github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= -github.com/segmentio/ksuid v1.0.3 h1:FoResxvleQwYiPAVKe1tMUlEirodZqlqglIuFsdDntY= -github.com/segmentio/ksuid v1.0.3/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= -github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= -github.com/spf13/cobra v1.4.0 h1:y+wJpx64xcgO1V+RcnwW0LEHxTKRi2ZDPSBjWnrg88Q= -github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= -github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU= -github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.1 h1:eVwehsLsZlCJCwXyGLgg+Q4iFWE/eTIMG0e8waCmm/I= -github.com/yuin/goldmark v1.3.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= -github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= -go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= -golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc h1:zK/HqS5bZxDptfPJNq8v7vJfXtkU7r9TLIoSr1bXaP4= -golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78 h1:nVuTkr9L6Bq62qpUqKo/RnZCFfzDBL0bYo6w9OJUqZY= -golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= -golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= -golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA= +github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM= +golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= +mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/log.go b/log.go new file mode 100644 index 0000000..a864a71 --- /dev/null +++ b/log.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/charmbracelet/log" + gap "github.com/muesli/go-app-paths" +) + +func getLogFilePath() (string, error) { + dir, err := gap.NewScope(gap.User, "glow").CacheDir() + if err != nil { + return "", fmt.Errorf("unable to get cache dir: %w", err) + } + return filepath.Join(dir, "glow.log"), nil +} + +func setupLog() (func() error, error) { + log.SetOutput(io.Discard) + // Log to file, if set + logFile, err := getLogFilePath() + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(logFile), 0o755); err != nil { //nolint:gosec + // log disabled + return func() error { return nil }, nil //nolint:nilerr + } + f, err := os.OpenFile(logFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644) //nolint:gosec + if err != nil { + // log disabled + return func() error { return nil }, nil //nolint:nilerr + } + log.SetOutput(f) + log.SetLevel(log.DebugLevel) + return f.Close, nil +} diff --git a/main.go b/main.go index 47424e4..b31ca15 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,11 @@ +// Package main provides the entry point for the Glow CLI application. package main import ( "errors" "fmt" "io" - "io/ioutil" + "io/fs" "net/http" "net/url" "os" @@ -12,40 +13,55 @@ import ( "path/filepath" "strings" - "github.com/meowgorithm/babyenv" + "mvdan.cc/sh/v3/shell" + + "github.com/caarlos0/env/v11" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glamour/styles" + "github.com/charmbracelet/glow/v2/ui" + "github.com/charmbracelet/glow/v2/utils" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/log" gap "github.com/muesli/go-app-paths" "github.com/spf13/cobra" "github.com/spf13/viper" "golang.org/x/term" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/charm/ui/common" - "github.com/charmbracelet/glamour" - "github.com/charmbracelet/glow/ui" - "github.com/charmbracelet/glow/utils" ) var ( - Version = "" + // Version as provided by goreleaser. + Version = "" + // CommitSHA as provided by goreleaser. CommitSHA = "" - readmeNames = []string{"README.md", "README"} - configFile string - pager bool - style string - width uint - showAllFiles bool - localOnly bool - mouse bool + readmeNames = []string{"README.md", "README", "Readme.md", "Readme", "readme.md", "readme"} + configFile string + pager bool + tui bool + style string + width uint + showAllFiles bool + showLineNumbers bool + preserveNewLines bool + mouse bool rootCmd = &cobra.Command{ - Use: "glow [SOURCE|DIR]", - Short: "Render markdown on the CLI, with pizzazz!", - Long: formatBlock(fmt.Sprintf("\nRender markdown on the CLI, %s!", common.Keyword("with pizzazz"))), + Use: "glow [SOURCE|DIR]", + Short: "Render markdown on the CLI, with pizzazz!", + Long: paragraph( + fmt.Sprintf("\nRender markdown on the CLI, %s!", keyword("with pizzazz")), + ), SilenceErrors: false, - SilenceUsage: false, + SilenceUsage: true, TraverseChildren: true, - RunE: execute, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveDefault + }, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + return validateOptions(cmd) + }, + RunE: execute, } ) @@ -63,31 +79,22 @@ func sourceFromArg(arg string) (*source, error) { } // a GitHub or GitLab URL (even without the protocol): - if u, ok := isGitHubURL(arg); ok { - src, err := findGitHubREADME(u) - if err != nil { - return nil, err - } - return src, nil - } - if u, ok := isGitLabURL(arg); ok { - src, err := findGitLabREADME(u) - if err != nil { - return nil, err - } + src, err := readmeURL(arg) + if src != nil && err == nil { + // if there's an error, try next methods... return src, nil } // HTTP(S) URLs: - if u, err := url.ParseRequestURI(arg); err == nil && strings.Contains(arg, "://") { + if u, err := url.ParseRequestURI(arg); err == nil && strings.Contains(arg, "://") { //nolint:nestif if u.Scheme != "" { if u.Scheme != "http" && u.Scheme != "https" { return nil, fmt.Errorf("%s is not a supported protocol", u.Scheme) } - - resp, err := http.Get(u.String()) + // consumer of the source is responsible for closing the ReadCloser. + resp, err := http.Get(u.String()) //nolint: noctx,bodyclose if err != nil { - return nil, err + return nil, fmt.Errorf("unable to get url: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP status %d", resp.StatusCode) @@ -102,9 +109,12 @@ func sourceFromArg(arg string) (*source, error) { arg = "." } st, err := os.Stat(arg) - if err == nil && st.IsDir() { + if err == nil && st.IsDir() { //nolint:nestif var src *source - _ = filepath.Walk(arg, func(path string, info os.FileInfo, err error) error { + _ = filepath.Walk(arg, func(path string, _ os.FileInfo, err error) error { + if err != nil { + return err + } for _, v := range readmeNames { if strings.EqualFold(filepath.Base(path), v) { r, err := os.Open(path) @@ -129,28 +139,49 @@ func sourceFromArg(arg string) (*source, error) { return nil, errors.New("missing markdown source") } - // a file: r, err := os.Open(arg) - u, _ := filepath.Abs(arg) - return &source{r, u}, err + if err != nil { + return nil, fmt.Errorf("unable to open file: %w", err) + } + u, err := filepath.Abs(arg) + if err != nil { + return nil, fmt.Errorf("unable to get absolute path: %w", err) + } + return &source{r, u}, nil +} + +// validateStyle checks if the style is a default style, if not, checks that +// the custom style exists. +func validateStyle(style string) error { + if style != "auto" && styles.DefaultStyles[style] == nil { + style = utils.ExpandPath(style) + if _, err := os.Stat(style); errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("specified style does not exist: %s", style) + } else if err != nil { + return fmt.Errorf("unable to stat file: %w", err) + } + } + return nil } func validateOptions(cmd *cobra.Command) error { // grab config values from Viper width = viper.GetUint("width") - localOnly = viper.GetBool("local") mouse = viper.GetBool("mouse") pager = viper.GetBool("pager") + tui = viper.GetBool("tui") + showAllFiles = viper.GetBool("all") + preserveNewLines = viper.GetBool("preserveNewLines") + showLineNumbers = viper.GetBool("showLineNumbers") + + if pager && tui { + return errors.New("cannot use both pager and tui") + } // validate the glamour style style = viper.GetString("style") - if style != "auto" && glamour.DefaultStyles[style] == nil { - style = utils.ExpandPath(style) - if _, err := os.Stat(style); os.IsNotExist(err) { - return fmt.Errorf("Specified style does not exist: %s", style) - } else if err != nil { - return err - } + if err := validateStyle(style); err != nil { + return err } isTerminal := term.IsTerminal(int(os.Stdout.Fd())) @@ -161,18 +192,20 @@ func validateOptions(cmd *cobra.Command) error { } // Detect terminal width - if isTerminal && width == 0 && !cmd.Flags().Changed("width") { - w, _, err := term.GetSize(int(os.Stdout.Fd())) - if err == nil { - width = uint(w) - } + if !cmd.Flags().Changed("width") { //nolint:nestif + if isTerminal && width == 0 { + w, _, err := term.GetSize(int(os.Stdout.Fd())) + if err == nil { + width = uint(w) //nolint:gosec + } - if width > 120 { - width = 120 + if width > 120 { + width = 120 + } + } + if width == 0 { + width = 80 } - } - if width == 0 { - width = 80 } return nil } @@ -180,7 +213,7 @@ func validateOptions(cmd *cobra.Command) error { func stdinIsPipe() (bool, error) { stat, err := os.Stdin.Stat() if err != nil { - return false, err + return false, fmt.Errorf("unable to open file: %w", err) } if stat.Mode()&os.ModeCharDevice == 0 || stat.Size() > 0 { return true, nil @@ -189,26 +222,20 @@ func stdinIsPipe() (bool, error) { } func execute(cmd *cobra.Command, args []string) error { - initConfig() - if err := validateOptions(cmd); err != nil { - return err - } - // if stdin is a pipe then use stdin for input. note that you can also // explicitly use a - to read from stdin. if yes, err := stdinIsPipe(); err != nil { return err } else if yes { src := &source{reader: os.Stdin} - defer src.reader.Close() + defer src.reader.Close() //nolint:errcheck return executeCLI(cmd, src, os.Stdout) } switch len(args) { - // TUI running on cwd case 0: - return runTUI("", false) + return runTUI("", "") // TUI with possible dir argument case 1: @@ -218,7 +245,7 @@ func execute(cmd *cobra.Command, args []string) error { if err == nil && info.IsDir() { p, err := filepath.Abs(args[0]) if err == nil { - return runTUI(p, false) + return runTUI(p, "") } } fallthrough @@ -241,14 +268,14 @@ func executeArg(cmd *cobra.Command, arg string, w io.Writer) error { if err != nil { return err } - defer src.reader.Close() + defer src.reader.Close() //nolint:errcheck return executeCLI(cmd, src, w) } func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error { - b, err := ioutil.ReadAll(src.reader) + b, err := io.ReadAll(src.reader) if err != nil { - return err + return fmt.Errorf("unable to read from reader: %w", err) } b = utils.RemoveFrontmatter(b) @@ -261,110 +288,106 @@ func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error { baseURL = u.String() + "/" } - // initialize glamour - var gs glamour.TermRendererOption - if style == "auto" { - gs = glamour.WithEnvironmentConfig() - } else { - gs = glamour.WithStylePath(style) - } + isCode := !utils.IsMarkdownFile(src.URL) + // initialize glamour r, err := glamour.NewTermRenderer( - gs, - glamour.WithWordWrap(int(width)), + glamour.WithColorProfile(lipgloss.ColorProfile()), + utils.GlamourStyle(style, isCode), + glamour.WithWordWrap(int(width)), //nolint:gosec glamour.WithBaseURL(baseURL), + glamour.WithPreservedNewLines(), ) if err != nil { - return err + return fmt.Errorf("unable to create renderer: %w", err) } - out, err := r.RenderBytes(b) + content := string(b) + ext := filepath.Ext(src.URL) + if isCode { + content = utils.WrapCodeBlock(string(b), ext) + } + + out, err := r.Render(content) if err != nil { - return err - } - - // trim lines - lines := strings.Split(string(out), "\n") - var content string - for i, s := range lines { - content += strings.TrimSpace(s) - - // don't add an artificial newline after the last split - if i+1 < len(lines) { - content += "\n" - } + return fmt.Errorf("unable to render markdown: %w", err) } // display - if pager || cmd.Flags().Changed("pager") { + switch { + case pager || cmd.Flags().Changed("pager"): pagerCmd := os.Getenv("PAGER") if pagerCmd == "" { pagerCmd = "less -r" } - pa := strings.Split(pagerCmd, " ") - c := exec.Command(pa[0], pa[1:]...) - c.Stdin = strings.NewReader(content) + fields, err := shell.Fields(pagerCmd, os.Getenv) + if err != nil || len(fields) == 0 { + return fmt.Errorf("unable to parse PAGER command: %s", pagerCmd) + } + c := exec.Command(fields[0], fields[1:]...) //nolint:gosec + c.Stdin = strings.NewReader(out) c.Stdout = os.Stdout - return c.Run() + if err := c.Run(); err != nil { + return fmt.Errorf("unable to run command: %w", err) + } + return nil + case tui || cmd.Flags().Changed("tui"): + path := "" + if !isURL(src.URL) { + path = src.URL + } + return runTUI(path, content) + default: + if _, err = fmt.Fprint(w, out); err != nil { + return fmt.Errorf("unable to write to writer: %w", err) + } + return nil } - - fmt.Fprint(w, content) - return nil } -func runTUI(workingDirectory string, stashedOnly bool) error { +func runTUI(path string, content string) error { // Read environment to get debugging stuff - var cfg ui.Config - if err := babyenv.Parse(&cfg); err != nil { + cfg, err := env.ParseAs[ui.Config]() + if err != nil { return fmt.Errorf("error parsing config: %v", err) } - // Log to file, if set - if cfg.Logfile != "" { - f, err := tea.LogToFile(cfg.Logfile, "glow") - if err != nil { - return err - } - defer f.Close() + // use style set in env, or auto if unset + if err := validateStyle(cfg.GlamourStyle); err != nil { + cfg.GlamourStyle = style } - cfg.WorkingDirectory = workingDirectory - cfg.DocumentTypes = ui.NewDocTypeSet() + cfg.Path = path cfg.ShowAllFiles = showAllFiles + cfg.ShowLineNumbers = showLineNumbers cfg.GlamourMaxWidth = width - cfg.GlamourStyle = style - - if stashedOnly { - cfg.DocumentTypes.Add(ui.StashedDoc, ui.NewsDoc) - } else if localOnly { - cfg.DocumentTypes.Add(ui.LocalDoc) - } + cfg.EnableMouse = mouse + cfg.PreserveNewLines = preserveNewLines // Run Bubble Tea program - p := ui.NewProgram(cfg) - p.EnterAltScreen() - defer p.ExitAltScreen() - if mouse { - p.EnableMouseCellMotion() - defer p.DisableMouseCellMotion() - } - if err := p.Start(); err != nil { - return err + if _, err := ui.NewProgram(cfg, content).Run(); err != nil { + return fmt.Errorf("unable to run tui program: %w", err) } - // Exit message - fmt.Printf("\n Thanks for using Glow!\n\n") return nil } func main() { - if err := rootCmd.Execute(); err != nil { + closer, err := setupLog() + if err != nil { + fmt.Println(err) os.Exit(1) } + if err := rootCmd.Execute(); err != nil { + _ = closer() + os.Exit(1) + } + _ = closer() } func init() { + tryLoadConfigFromDefaultPlaces() if len(CommitSHA) >= 7 { vt := rootCmd.VersionTemplate() rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n") @@ -373,63 +396,78 @@ func init() { Version = "unknown (built from source)" } rootCmd.Version = Version - - scope := gap.NewScope(gap.User, "glow") - defaultConfigFile, _ := scope.ConfigPath("glow.yml") + rootCmd.InitDefaultCompletionCmd() // "Glow Classic" cli arguments - rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default %s)", defaultConfigFile)) + rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default %s)", viper.GetViper().ConfigFileUsed())) rootCmd.Flags().BoolVarP(&pager, "pager", "p", false, "display with pager") - rootCmd.Flags().StringVarP(&style, "style", "s", "auto", "style name or JSON path") - rootCmd.Flags().UintVarP(&width, "width", "w", 0, "word-wrap at width") + rootCmd.Flags().BoolVarP(&tui, "tui", "t", false, "display with tui") + rootCmd.Flags().StringVarP(&style, "style", "s", styles.AutoStyle, "style name or JSON path") + rootCmd.Flags().UintVarP(&width, "width", "w", 0, "word-wrap at width (set to 0 to disable)") rootCmd.Flags().BoolVarP(&showAllFiles, "all", "a", false, "show system files and directories (TUI-mode only)") - rootCmd.Flags().BoolVarP(&localOnly, "local", "l", false, "show local files only; no network (TUI-mode only)") + rootCmd.Flags().BoolVarP(&showLineNumbers, "line-numbers", "l", false, "show line numbers (TUI-mode only)") + rootCmd.Flags().BoolVarP(&preserveNewLines, "preserve-new-lines", "n", false, "preserve newlines in the output") rootCmd.Flags().BoolVarP(&mouse, "mouse", "m", false, "enable mouse wheel (TUI-mode only)") - rootCmd.Flags().MarkHidden("mouse") + _ = rootCmd.Flags().MarkHidden("mouse") // Config bindings + _ = viper.BindPFlag("pager", rootCmd.Flags().Lookup("pager")) + _ = viper.BindPFlag("tui", rootCmd.Flags().Lookup("tui")) _ = viper.BindPFlag("style", rootCmd.Flags().Lookup("style")) _ = viper.BindPFlag("width", rootCmd.Flags().Lookup("width")) - _ = viper.BindPFlag("local", rootCmd.Flags().Lookup("local")) + _ = viper.BindPFlag("debug", rootCmd.Flags().Lookup("debug")) _ = viper.BindPFlag("mouse", rootCmd.Flags().Lookup("mouse")) - viper.SetDefault("style", "auto") + _ = viper.BindPFlag("preserveNewLines", rootCmd.Flags().Lookup("preserve-new-lines")) + _ = viper.BindPFlag("showLineNumbers", rootCmd.Flags().Lookup("line-numbers")) + _ = viper.BindPFlag("all", rootCmd.Flags().Lookup("all")) + + viper.SetDefault("style", styles.AutoStyle) viper.SetDefault("width", 0) - viper.SetDefault("local", "false") + viper.SetDefault("all", true) - // Stash - stashCmd.PersistentFlags().StringVarP(&memo, "memo", "m", "", "memo/note for stashing") - rootCmd.AddCommand(stashCmd) - - rootCmd.AddCommand(configCmd) + rootCmd.AddCommand(configCmd, manCmd) } -func initConfig() { - if configFile != "" { - viper.SetConfigFile(configFile) - } else { - scope := gap.NewScope(gap.User, "glow") - dirs, err := scope.ConfigDirs() - if err != nil { - fmt.Println("Can't retrieve default config. Please manually pass a config file with '--config'") - os.Exit(1) - } - - for _, v := range dirs { - viper.AddConfigPath(v) - } - viper.SetConfigName("glow") - viper.SetConfigType("yaml") +func tryLoadConfigFromDefaultPlaces() { + scope := gap.NewScope(gap.User, "glow") + dirs, err := scope.ConfigDirs() + if err != nil { + fmt.Println("Could not load find configuration directory.") + os.Exit(1) } + if c := os.Getenv("XDG_CONFIG_HOME"); c != "" { + dirs = append([]string{filepath.Join(c, "glow")}, dirs...) + } + + if c := os.Getenv("GLOW_CONFIG_HOME"); c != "" { + dirs = append([]string{c}, dirs...) + } + + for _, v := range dirs { + viper.AddConfigPath(v) + } + + viper.SetConfigName("glow") + viper.SetConfigType("yaml") viper.SetEnvPrefix("glow") viper.AutomaticEnv() if err := viper.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - fmt.Println("Error parsing config:", err) - os.Exit(1) + log.Warn("Could not parse configuration file", "err", err) } } - // fmt.Println("Using config file:", viper.ConfigFileUsed()) + if used := viper.ConfigFileUsed(); used != "" { + log.Debug("Using configuration file", "path", viper.ConfigFileUsed()) + return + } + + if viper.ConfigFileUsed() == "" { + configFile = filepath.Join(dirs[0], "glow.yml") + } + if err := ensureConfigFile(); err != nil { + log.Error("Could not create default configuration", "error", err) + } } diff --git a/man_cmd.go b/man_cmd.go new file mode 100644 index 0000000..a179ef7 --- /dev/null +++ b/man_cmd.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "os" + + mcobra "github.com/muesli/mango-cobra" + "github.com/muesli/roff" + "github.com/spf13/cobra" +) + +var manCmd = &cobra.Command{ + Use: "man", + Short: "Generates manpages", + SilenceUsage: true, + DisableFlagsInUseLine: true, + Hidden: true, + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { + manPage, err := mcobra.NewManPage(1, rootCmd) + if err != nil { + return fmt.Errorf("unable to instantiate man page: %w", err) + } + if _, err := fmt.Fprint(os.Stdout, manPage.Build(roff.NewDocument())); err != nil { + return fmt.Errorf("unable to build man page: %w", err) + } + return nil + }, +} diff --git a/stash_cmd.go b/stash_cmd.go deleted file mode 100644 index e6b3ea4..0000000 --- a/stash_cmd.go +++ /dev/null @@ -1,83 +0,0 @@ -package main - -import ( - "fmt" - "io/ioutil" - "log" - "os" - "path" - "strings" - - "github.com/charmbracelet/charm" - "github.com/charmbracelet/charm/ui/common" - "github.com/muesli/termenv" - "github.com/spf13/cobra" -) - -var ( - memo string - - stashCmd = &cobra.Command{ - Use: "stash [SOURCE]", - Hidden: false, - Short: "Stash a markdown", - Long: formatBlock(fmt.Sprintf("\nDo %s stuff. Run with no arguments to browse your stash or pass a path to a markdown file to stash it.", common.Keyword("stash"))), - Example: formatBlock("glow stash\nglow stash README.md\nglow stash -m \"secret notes\" path/to/notes.md"), - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - initConfig() - if len(args) == 0 { - return runTUI("", true) - } - - filePath := args[0] - - if memo == "" { - memo = strings.Replace(path.Base(filePath), path.Ext(filePath), "", 1) - } - - cc := initCharmClient() - f, err := os.Open(filePath) - if err != nil { - return fmt.Errorf("bad filename") - } - - defer f.Close() - b, err := ioutil.ReadAll(f) - if err != nil { - return fmt.Errorf("error reading file") - } - - _, err = cc.StashMarkdown(memo, string(b)) - if err != nil { - return fmt.Errorf("error stashing markdown") - } - - dot := termenv.String("•").Foreground(common.Green.Color()).String() - fmt.Println(dot + " Stashed!") - return nil - }, - } -) - -func getCharmConfig() *charm.Config { - cfg, err := charm.ConfigFromEnv() - if err != nil { - log.Fatal(err) - } - - return cfg -} - -func initCharmClient() *charm.Client { - cfg := getCharmConfig() - cc, err := charm.NewClient(cfg) - if err == charm.ErrMissingSSHAuth { - fmt.Println(formatBlock("We had some trouble authenticating via SSH. If this continues to happen the Charm tool may be able to help you. More info at https://github.com/charmbracelet/charm.")) - os.Exit(1) - } else if err != nil { - fmt.Println(err) - os.Exit(1) - } - return cc -} diff --git a/style.go b/style.go new file mode 100644 index 0000000..b688a4e --- /dev/null +++ b/style.go @@ -0,0 +1,14 @@ +package main + +import "github.com/charmbracelet/lipgloss" + +var ( + keyword = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#04B575")). + Render + + paragraph = lipgloss.NewStyle(). + Width(78). + Padding(0, 0, 0, 2). + Render +) diff --git a/ui/config.go b/ui/config.go index 67d94d6..001d5b8 100644 --- a/ui/config.go +++ b/ui/config.go @@ -2,32 +2,19 @@ package ui // Config contains TUI-specific configuration. type Config struct { - ShowAllFiles bool - Gopath string `env:"GOPATH"` - HomeDir string `env:"HOME"` - GlamourMaxWidth uint - GlamourStyle string + ShowAllFiles bool + ShowLineNumbers bool + Gopath string `env:"GOPATH"` + HomeDir string `env:"HOME"` + GlamourMaxWidth uint + GlamourStyle string `env:"GLAMOUR_STYLE"` + EnableMouse bool + PreserveNewLines bool - // Which directory should we start from? - WorkingDirectory string - - // Which document types shall we show? - DocumentTypes DocTypeSet + // Working directory or file path + Path string // For debugging the UI - Logfile string `env:"GLOW_LOGFILE"` - HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" default:"true"` - GlamourEnabled bool `env:"GLOW_ENABLE_GLAMOUR" default:"true"` -} - -func (c Config) showLocalFiles() bool { - return c.DocumentTypes.Contains(LocalDoc) -} - -func (c Config) localOnly() bool { - return c.DocumentTypes.Equals(NewDocTypeSet(LocalDoc)) -} - -func (c Config) stashedOnly() bool { - return c.DocumentTypes.Contains(StashedDoc) && !c.DocumentTypes.Contains(LocalDoc) + HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" envDefault:"true"` + GlamourEnabled bool `env:"GLOW_ENABLE_GLAMOUR" envDefault:"true"` } diff --git a/ui/consts_unix.go b/ui/consts_unix.go deleted file mode 100644 index fd13ad3..0000000 --- a/ui/consts_unix.go +++ /dev/null @@ -1,7 +0,0 @@ -// +build !windows - -package ui - -const ( - pagerStashIcon = "🔒" -) diff --git a/ui/consts_windows.go b/ui/consts_windows.go deleted file mode 100644 index 25f9851..0000000 --- a/ui/consts_windows.go +++ /dev/null @@ -1,7 +0,0 @@ -// +build windows - -package ui - -const ( - pagerStashIcon = "•" -) diff --git a/ui/doctypes.go b/ui/doctypes.go deleted file mode 100644 index 8d2ef14..0000000 --- a/ui/doctypes.go +++ /dev/null @@ -1,90 +0,0 @@ -package ui - -// DocType represents a type of markdown document. -type DocType int - -// Available document types. -const ( - NoDocType DocType = iota - LocalDoc - StashedDoc - ConvertedDoc - NewsDoc -) - -func (d DocType) String() string { - return [...]string{ - "none", - "local", - "stashed", - "converted", - "news", - }[d] -} - -// DocTypeSet is a set (in the mathematic sense) of document types. -type DocTypeSet map[DocType]struct{} - -// NewDocTypeSet returns a set of document types. -func NewDocTypeSet(t ...DocType) DocTypeSet { - d := DocTypeSet(make(map[DocType]struct{})) - if len(t) > 0 { - d.Add(t...) - } - return d -} - -// Add adds a document type of the set. -func (d *DocTypeSet) Add(t ...DocType) int { - for _, v := range t { - (*d)[v] = struct{}{} - } - return len(*d) -} - -// Contains returns whether or not the set contains the given DocTypes. -func (d DocTypeSet) Contains(m ...DocType) bool { - matches := 0 - for _, t := range m { - if _, found := d[t]; found { - matches++ - } - } - return matches == len(m) -} - -// Difference return a DocumentType set that does not contain the given types. -func (d DocTypeSet) Difference(t ...DocType) DocTypeSet { - c := copyDocumentTypes(d) - for k := range c { - for _, docType := range t { - if k == docType { - delete(c, k) - break - } - } - } - return c -} - -// Equals returns whether or not the two sets are equal. -func (d DocTypeSet) Equals(other DocTypeSet) bool { - return d.Contains(other.AsSlice()...) && len(d) == len(other) -} - -// AsSlice returns the set as a slice of document types. -func (d DocTypeSet) AsSlice() (agg []DocType) { - for k := range d { - agg = append(agg, k) - } - return -} - -// Return a copy of the given DoctTypes map. -func copyDocumentTypes(d DocTypeSet) DocTypeSet { - c := make(map[DocType]struct{}) - for k, v := range d { - c[k] = v - } - return c -} diff --git a/ui/doctypes_test.go b/ui/doctypes_test.go deleted file mode 100644 index e882b14..0000000 --- a/ui/doctypes_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package ui - -import ( - "reflect" - "testing" -) - -func TestDocTypeContains(t *testing.T) { - d := NewDocTypeSet(LocalDoc) - - if !d.Contains(LocalDoc) { - t.Error("Contains reported it doesn't contain a value which it absolutely does contain") - } - - if d.Contains(NewsDoc) { - t.Error("Contains reported the set contains a value it certainly does not") - } -} - -func TestDocTypeDifference(t *testing.T) { - original := NewDocTypeSet(LocalDoc, StashedDoc, ConvertedDoc, NewsDoc) - difference := original.Difference(LocalDoc, NewsDoc) - expected := NewDocTypeSet(StashedDoc, ConvertedDoc) - - // Make sure the difference operation worked - if !reflect.DeepEqual(difference, expected) { - t.Errorf("difference returned %+v; expected %+v", difference, expected) - } - - // Make sure original set was not mutated - if reflect.DeepEqual(original, difference) { - t.Errorf("original set was mutated when it should not have been") - } -} - -func TestDocTypeEquality(t *testing.T) { - a := NewDocTypeSet(LocalDoc, StashedDoc) - b := NewDocTypeSet(LocalDoc, StashedDoc) - c := NewDocTypeSet(LocalDoc) - - if !a.Equals(b) { - t.Errorf("Equality test failed for %+v and %+v; expected true, got false", a, b) - } - - if a.Equals(c) { - t.Errorf("Equality test failed for %+v and %+v; expected false, got true", a, c) - } -} diff --git a/ui/editor.go b/ui/editor.go new file mode 100644 index 0000000..a567c59 --- /dev/null +++ b/ui/editor.go @@ -0,0 +1,19 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/editor" +) + +type editorFinishedMsg struct{ err error } + +func openEditor(path string, lineno int) tea.Cmd { + cb := func(err error) tea.Msg { + return editorFinishedMsg{err} + } + cmd, err := editor.Cmd("Glow", path, editor.LineNumber(uint(lineno))) //nolint:gosec + if err != nil { + return func() tea.Msg { return cb(err) } + } + return tea.ExecProcess(cmd, cb) +} diff --git a/ui/ignore_darwin.go b/ui/ignore_darwin.go index cccd305..b69437a 100644 --- a/ui/ignore_darwin.go +++ b/ui/ignore_darwin.go @@ -1,13 +1,14 @@ +//go:build darwin // +build darwin package ui import "path/filepath" -func ignorePatterns(m model) []string { +func ignorePatterns(m commonModel) []string { return []string{ - filepath.Join(m.common.cfg.HomeDir, "Library"), - m.common.cfg.Gopath, + filepath.Join(m.cfg.HomeDir, "Library"), + m.cfg.Gopath, "node_modules", ".*", } diff --git a/ui/ignore_general.go b/ui/ignore_general.go index 21f4734..f4dc8be 100644 --- a/ui/ignore_general.go +++ b/ui/ignore_general.go @@ -1,10 +1,11 @@ +//go:build !darwin // +build !darwin package ui -func ignorePatterns(m model) []string { +func ignorePatterns(m commonModel) []string { return []string{ - m.common.cfg.Gopath, + m.cfg.Gopath, "node_modules", ".*", } diff --git a/ui/keys.go b/ui/keys.go new file mode 100644 index 0000000..8e13f95 --- /dev/null +++ b/ui/keys.go @@ -0,0 +1,6 @@ +package ui + +const ( + keyEnter = "enter" + keyEsc = "esc" +) diff --git a/ui/markdown.go b/ui/markdown.go index f6f8f2c..c67b1dc 100644 --- a/ui/markdown.go +++ b/ui/markdown.go @@ -1,42 +1,19 @@ package ui import ( - "log" + "fmt" "math" - "path" - "strings" "time" "unicode" - "github.com/charmbracelet/charm" + "github.com/charmbracelet/log" "github.com/dustin/go-humanize" - "github.com/segmentio/ksuid" "golang.org/x/text/runes" "golang.org/x/text/transform" "golang.org/x/text/unicode/norm" ) -// markdown wraps charm.Markdown. type markdown struct { - docType DocType - - // Stash identifier. This exists so we can keep track of documents stashed - // in-session as they relate to their original, non-stashed counterparts. - // All documents have a stashID, however when a document is stashed that - // document inherits the stashID of the original. - stashID ksuid.KSUID - - // Unique identifier. Unlike the stash identifier, this value should always - // be unique so we can confidently find it an operate on it (versus stashID, - // which could match both an original or stashed document). - uniqueID ksuid.KSUID - - // Some of the document's original values before this document was stashed. - // These are irrelevant if this document was not stashed in this session. - originalDocType DocType - originalTimestamp time.Time - originalNote string - // Full path of a local markdown file. Only relevant to local documents and // those that have been stashed in this session. localPath string @@ -46,102 +23,24 @@ type markdown struct { // field is ephemeral, and should only be referenced during filtering. filterValue string - charm.Markdown -} - -func (m *markdown) generateIDs() { - if m.stashID.IsNil() { - m.stashID = ksuid.New() - } - m.uniqueID = ksuid.New() -} - -// convertToStashed converts this document into its stashed state. -func (m *markdown) convertToStashed() { - if m.docType == ConvertedDoc { - if debug { - log.Println("not converting already converted document:", m) - } - return - } - - m.originalDocType = m.docType - m.originalTimestamp = m.CreatedAt - m.originalNote = m.Note - - if m.docType == LocalDoc { - m.Note = strings.Replace(path.Base(m.localPath), path.Ext(m.localPath), "", 1) - } - m.CreatedAt = time.Now() - m.docType = ConvertedDoc -} - -// revert reverts this document from its stashed state. -func (m *markdown) revertFromStashed() { - if m.docType != ConvertedDoc { - log.Printf("not reverting document of type %s: %v", m.docType, m) - } - - m.docType = m.originalDocType - m.CreatedAt = m.originalTimestamp - m.Note = m.originalNote + Body string + Note string + Modtime time.Time } // Generate the value we're doing to filter against. func (m *markdown) buildFilterValue() { note, err := normalize(m.Note) if err != nil { - if debug { - log.Printf("error normalizing '%s': %v", m.Note, err) - } + log.Error("error normalizing", "note", m.Note, "error", err) m.filterValue = m.Note } m.filterValue = note } -// shouldSortAsLocal returns whether or not this markdown should be sorted as though -// it's a local markdown document. -func (m markdown) shouldSortAsLocal() bool { - return m.docType == LocalDoc || m.docType == ConvertedDoc -} - -// Sort documents with local files first, then by date. -type markdownsByLocalFirst []*markdown - -func (m markdownsByLocalFirst) Len() int { return len(m) } -func (m markdownsByLocalFirst) Swap(i, j int) { m[i], m[j] = m[j], m[i] } -func (m markdownsByLocalFirst) Less(i, j int) bool { - iIsLocal := m[i].shouldSortAsLocal() - jIsLocal := m[j].shouldSortAsLocal() - - // Local files (and files that used to be local) come first - if iIsLocal && !jIsLocal { - return true - } - if !iIsLocal && jIsLocal { - return false - } - - // If both are local files, sort by filename. Note that we should never - // hit equality here since two files can't have the same path. - if iIsLocal && jIsLocal { - return strings.Compare(m[i].localPath, m[j].localPath) == -1 - } - - // Neither are local files so sort by date descending - if !m[i].CreatedAt.Equal(m[j].CreatedAt) { - return m[i].CreatedAt.After(m[j].CreatedAt) - } - - // If the times also match, sort by unqiue ID. - ids := []ksuid.KSUID{m[i].uniqueID, m[j].uniqueID} - ksuid.Sort(ids) - return ids[0] == m[i].uniqueID -} - func (m markdown) relativeTime() string { - return relativeTime(m.CreatedAt) + return relativeTime(m.Modtime) } // Normalize text to aid in the filtering process. In particular, we remove @@ -150,26 +49,16 @@ func (m markdown) relativeTime() string { func normalize(in string) (string, error) { t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) out, _, err := transform.String(t, in) - return out, err -} - -// wrapMarkdowns wraps a *charm.Markdown with a *markdown in order to add some -// extra metadata. -func wrapMarkdowns(t DocType, md []*charm.Markdown) (m []*markdown) { - for _, v := range md { - m = append(m, &markdown{ - docType: t, - Markdown: *v, - }) + if err != nil { + return "", fmt.Errorf("error normalizing: %w", err) } - return m + return out, nil } // Return the time in a human-readable format relative to the current time. func relativeTime(then time.Time) string { now := time.Now() - ago := now.Sub(then) - if ago < time.Minute { + if ago := now.Sub(then); ago < time.Minute { return "just now" } else if ago < humanize.Week { return humanize.CustomRelTime(then, now, "ago", "from now", magnitudes) diff --git a/ui/pager.go b/ui/pager.go index 7f43019..5c522e7 100644 --- a/ui/pager.go +++ b/ui/pager.go @@ -2,72 +2,98 @@ package ui import ( "fmt" - "log" "math" + "path/filepath" "strings" "time" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/charm" - lib "github.com/charmbracelet/charm/ui/common" "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glow/v2/utils" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/log" + "github.com/fsnotify/fsnotify" runewidth "github.com/mattn/go-runewidth" "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/truncate" - te "github.com/muesli/termenv" + "github.com/muesli/termenv" ) -const statusBarHeight = 1 +const ( + statusBarHeight = 1 + lineNumberWidth = 4 +) var ( pagerHelpHeight int - mintGreen = lib.NewColorPair("#89F0CB", "#89F0CB") - darkGreen = lib.NewColorPair("#1C8760", "#1C8760") + mintGreen = lipgloss.AdaptiveColor{Light: "#89F0CB", Dark: "#89F0CB"} + darkGreen = lipgloss.AdaptiveColor{Light: "#1C8760", Dark: "#1C8760"} - noteHeading = te.String(" Set Memo "). - Foreground(lib.Cream.Color()). - Background(lib.Green.Color()). - String() + lineNumberFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"} - statusBarNoteFg = lib.NewColorPair("#7D7D7D", "#656565") - statusBarBg = lib.NewColorPair("#242424", "#E6E6E6") + statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"} + statusBarBg = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"} - // Styling funcs. - statusBarScrollPosStyle = newStyle(lib.NewColorPair("#5A5A5A", "#949494"), statusBarBg, false) - statusBarNoteStyle = newStyle(statusBarNoteFg, statusBarBg, false) - statusBarHelpStyle = newStyle(statusBarNoteFg, lib.NewColorPair("#323232", "#DCDCDC"), false) - statusBarStashDotStyle = newStyle(lib.Green, statusBarBg, false) - statusBarMessageStyle = newStyle(mintGreen, darkGreen, false) - statusBarMessageStashIconStyle = newStyle(mintGreen, darkGreen, false) - statusBarMessageScrollPosStyle = newStyle(mintGreen, darkGreen, false) - statusBarMessageHelpStyle = newStyle(lib.NewColorPair("#B6FFE4", "#B6FFE4"), lib.Green, false) - helpViewStyle = newStyle(statusBarNoteFg, lib.NewColorPair("#1B1B1B", "#f2f2f2"), false) + statusBarScrollPosStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#949494", Dark: "#5A5A5A"}). + Background(statusBarBg). + Render + + statusBarNoteStyle = lipgloss.NewStyle(). + Foreground(statusBarNoteFg). + Background(statusBarBg). + Render + + statusBarHelpStyle = lipgloss.NewStyle(). + Foreground(statusBarNoteFg). + Background(lipgloss.AdaptiveColor{Light: "#DCDCDC", Dark: "#323232"}). + Render + + statusBarMessageStyle = lipgloss.NewStyle(). + Foreground(mintGreen). + Background(darkGreen). + Render + + statusBarMessageScrollPosStyle = lipgloss.NewStyle(). + Foreground(mintGreen). + Background(darkGreen). + Render + + statusBarMessageHelpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#B6FFE4")). + Background(green). + Render + + helpViewStyle = lipgloss.NewStyle(). + Foreground(statusBarNoteFg). + Background(lipgloss.AdaptiveColor{Light: "#f2f2f2", Dark: "#1B1B1B"}). + Render + + lineNumberStyle = lipgloss.NewStyle(). + Foreground(lineNumberFg). + Render ) -type contentRenderedMsg string -type noteSavedMsg *charm.Markdown +type ( + contentRenderedMsg string + reloadMsg struct{} +) type pagerState int const ( pagerStateBrowse pagerState = iota - pagerStateSetNote - pagerStateStashing - pagerStateStashSuccess pagerStateStatusMessage ) type pagerModel struct { - common *commonModel - viewport viewport.Model - state pagerState - showHelp bool - textInput textinput.Model - spinner spinner.Model + common *commonModel + viewport viewport.Model + state pagerState + showHelp bool statusMessage string statusMessageTimer *time.Timer @@ -76,51 +102,27 @@ type pagerModel struct { // it here so we can re-render it on resize. currentDocument markdown - // Newly stashed markdown. We store it here temporarily so we can replace - // currentDocument above after a stash. - stashedDocument *markdown + watcher *fsnotify.Watcher } func newPagerModel(common *commonModel) pagerModel { // Init viewport - vp := viewport.Model{} + vp := viewport.New(0, 0) vp.YPosition = 0 vp.HighPerformanceRendering = config.HighPerformancePager - // Text input for notes/memos - ti := textinput.NewModel() - ti.Prompt = te.String(" > "). - Foreground(lib.Color(darkGray)). - Background(lib.YellowGreen.Color()). - String() - ti.TextColor = darkGray - ti.BackgroundColor = lib.YellowGreen.String() - ti.CursorColor = lib.Fuschia.String() - ti.CharLimit = noteCharacterLimit - ti.Focus() - - // Text input for search - sp := spinner.NewModel() - sp.ForegroundColor = statusBarNoteFg.String() - sp.BackgroundColor = statusBarBg.String() - sp.HideFor = time.Millisecond * 50 - sp.MinimumLifetime = time.Millisecond * 180 - - return pagerModel{ - common: common, - state: pagerStateBrowse, - textInput: ti, - viewport: vp, - spinner: sp, + m := pagerModel{ + common: common, + state: pagerStateBrowse, + viewport: vp, } + m.initWatcher() + return m } func (m *pagerModel) setSize(w, h int) { m.viewport.Width = w m.viewport.Height = h - statusBarHeight - m.textInput.Width = w - - ansi.PrintableRuneWidth(noteHeading) - - ansi.PrintableRuneWidth(m.textInput.Prompt) - 1 if m.showHelp { if pagerHelpHeight == 0 { @@ -142,13 +144,18 @@ func (m *pagerModel) toggleHelp() { } } +type pagerStatusMessage struct { + message string + isError bool +} + // Perform stuff that needs to happen after a successful markdown stash. Note -// that the the returned command should be sent back the through the pager +// that the returned command should be sent back the through the pager // update function. -func (m *pagerModel) showStatusMessage(statusMessage string) tea.Cmd { +func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd { // Show a success message to the user m.state = pagerStateStatusMessage - m.statusMessage = statusMessage + m.statusMessage = msg.message if m.statusMessageTimer != nil { m.statusMessageTimer.Stop() } @@ -158,6 +165,7 @@ func (m *pagerModel) showStatusMessage(statusMessage string) tea.Cmd { } func (m *pagerModel) unload() { + log.Debug("unload") if m.showHelp { m.toggleHelp() } @@ -167,7 +175,7 @@ func (m *pagerModel) unload() { m.state = pagerStateBrowse m.viewport.SetContent("") m.viewport.YOffset = 0 - m.textInput.Reset() + m.unwatchFile() } func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { @@ -178,156 +186,95 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - switch m.state { - case pagerStateSetNote: - switch msg.String() { - case "esc": + switch msg.String() { + case "q", keyEsc: + if m.state != pagerStateBrowse { m.state = pagerStateBrowse return m, nil - case "enter": - var cmd tea.Cmd - if m.textInput.Value() != m.currentDocument.Note { // don't update if the note didn't change - m.currentDocument.Note = m.textInput.Value() // update optimistically - cmd = saveDocumentNote(m.common.cc, m.currentDocument.ID, m.currentDocument.Note) - } - m.state = pagerStateBrowse - m.textInput.Reset() - return m, cmd } - default: - switch msg.String() { - case "q", "esc": - if m.state != pagerStateBrowse { - m.state = pagerStateBrowse - return m, nil - } - case "home", "g": - m.viewport.GotoTop() - if m.viewport.HighPerformanceRendering { - cmds = append(cmds, viewport.Sync(m.viewport)) - } - case "end", "G": - m.viewport.GotoBottom() - if m.viewport.HighPerformanceRendering { - cmds = append(cmds, viewport.Sync(m.viewport)) - } - case "m": - isStashed := m.currentDocument.docType == StashedDoc || - m.currentDocument.docType == ConvertedDoc - - // Users can only set the note on user-stashed markdown - if !isStashed { - break - } - - m.state = pagerStateSetNote - - // Stop the timer for hiding a status message since changing - // the state above will have cleared it. - if m.statusMessageTimer != nil { - m.statusMessageTimer.Stop() - } - - // Pre-populate note with existing value - if m.textInput.Value() == "" { - m.textInput.SetValue(m.currentDocument.Note) - m.textInput.CursorEnd() - } - - return m, textinput.Blink - case "s": - if m.common.authStatus != authOK { - break - } - - md := m.currentDocument - - _, alreadyStashed := m.common.filesStashed[md.stashID] - if alreadyStashed { - cmds = append(cmds, m.showStatusMessage("Already stashed")) - break - } - - // Stash a local document - if m.state != pagerStateStashing && stashableDocTypes.Contains(md.docType) { - m.state = pagerStateStashing - m.spinner.Start() - cmds = append( - cmds, - stashDocument(m.common.cc, md), - spinner.Tick, - ) - } - case "?": - m.toggleHelp() - if m.viewport.HighPerformanceRendering { - cmds = append(cmds, viewport.Sync(m.viewport)) - } + case "home", "g": + m.viewport.GotoTop() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + case "end", "G": + m.viewport.GotoBottom() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) } - } - case spinner.TickMsg: - if m.state == pagerStateStashing || m.spinner.Visible() { - // If we're still stashing, or if the spinner still needs to - // finish, spin it along. - newSpinnerModel, cmd := m.spinner.Update(msg) - m.spinner = newSpinnerModel - cmds = append(cmds, cmd) - } else if m.state == pagerStateStashSuccess && !m.spinner.Visible() { - // If the spinner's finished and we haven't told the user the - // stash was successful, do that. - m.state = pagerStateBrowse - m.currentDocument = *m.stashedDocument - m.stashedDocument = nil - cmds = append(cmds, m.showStatusMessage("Stashed!")) + case "d": + m.viewport.HalfViewDown() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + + case "u": + m.viewport.HalfViewUp() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + + case "e": + lineno := int(math.RoundToEven(float64(m.viewport.TotalLineCount()) * m.viewport.ScrollPercent())) + if m.viewport.AtTop() { + lineno = 0 + } + log.Info( + "opening editor", + "file", m.currentDocument.localPath, + "line", fmt.Sprintf("%d/%d", lineno, m.viewport.TotalLineCount()), + ) + return m, openEditor(m.currentDocument.localPath, lineno) + + case "c": + // Copy using OSC 52 + termenv.Copy(m.currentDocument.Body) + // Copy using native system clipboard + _ = clipboard.WriteAll(m.currentDocument.Body) + cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Copied contents", false})) + + case "r": + return m, loadLocalMarkdown(&m.currentDocument) + + case "?": + m.toggleHelp() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } } // Glow has rendered the content case contentRenderedMsg: + log.Info("content rendered", "state", m.state) + m.setContent(string(msg)) if m.viewport.HighPerformanceRendering { cmds = append(cmds, viewport.Sync(m.viewport)) } + cmds = append(cmds, m.watchFile) - // We've reveived terminal dimensions, either for the first time or + // The file was changed on disk and we're reloading it + case reloadMsg: + return m, loadLocalMarkdown(&m.currentDocument) + + // We've finished editing the document, potentially making changes. Let's + // retrieve the latest version of the document so that we display + // up-to-date contents. + case editorFinishedMsg: + return m, loadLocalMarkdown(&m.currentDocument) + + // We've received terminal dimensions, either for the first time or // after a resize case tea.WindowSizeMsg: return m, renderWithGlamour(m, m.currentDocument.Body) - case stashSuccessMsg: - // Stashing was successful. Convert the loaded document to a stashed - // one and show a status message. Note that we're also handling this - // message in the main update function where we're adding this stashed - // item to the stash listing. - m.state = pagerStateStashSuccess - if !m.spinner.Visible() { - // The spinner has finished spinning, so tell the user the stash - // was successful. - m.state = pagerStateBrowse - m.currentDocument = markdown(msg) - cmds = append(cmds, m.showStatusMessage("Stashed!")) - } else { - // The spinner is still spinning, so just take note of the newly - // stashed document for now. - md := markdown(msg) - m.stashedDocument = &md - } - - case stashFailMsg: - delete(m.common.filesStashed, msg.markdown.stashID) - case statusMessageTimeoutMsg: m.state = pagerStateBrowse } - switch m.state { - case pagerStateSetNote: - m.textInput, cmd = m.textInput.Update(msg) - cmds = append(cmds, cmd) - default: - m.viewport, cmd = m.viewport.Update(msg) - cmds = append(cmds, cmd) - } + m.viewport, cmd = m.viewport.Update(msg) + cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } @@ -337,12 +284,7 @@ func (m pagerModel) View() string { fmt.Fprint(&b, m.viewport.View()+"\n") // Footer - switch m.state { - case pagerStateSetNote: - m.setNoteView(&b) - default: - m.statusBarView(&b) - } + m.statusBarView(&b) if m.showHelp { fmt.Fprint(&b, "\n"+m.helpView()) @@ -357,13 +299,11 @@ func (m pagerModel) statusBarView(b *strings.Builder) { maxPercent float64 = 1.0 percentToStringMagnitude float64 = 100.0 ) - var ( - isStashed bool = m.currentDocument.docType == StashedDoc || m.currentDocument.docType == ConvertedDoc - showStatusMessage bool = m.state == pagerStateStatusMessage - ) + + showStatusMessage := m.state == pagerStateStatusMessage // Logo - logo := glowLogoView(" Glow ") + logo := glowLogoView() // Scroll percent percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent())) @@ -382,32 +322,16 @@ func (m pagerModel) statusBarView(b *strings.Builder) { helpNote = statusBarHelpStyle(" ? Help ") } - // Status indicator; spinner or stash dot - var statusIndicator string - if m.state == pagerStateStashing || m.state == pagerStateStashSuccess { - if m.spinner.Visible() { - statusIndicator = statusBarNoteStyle(" ") + m.spinner.View() - } - } else if isStashed && showStatusMessage { - statusIndicator = statusBarMessageStashIconStyle(" " + pagerStashIcon) - } else if isStashed { - statusIndicator = statusBarStashDotStyle(" " + pagerStashIcon) - } - // Note var note string if showStatusMessage { note = m.statusMessage } else { note = m.currentDocument.Note - if len(note) == 0 { - note = "(No memo)" - } } - note = truncate.StringWithTail(" "+note+" ", uint(max(0, + note = truncate.StringWithTail(" "+note+" ", uint(max(0, //nolint:gosec m.common.width- ansi.PrintableRuneWidth(logo)- - ansi.PrintableRuneWidth(statusIndicator)- ansi.PrintableRuneWidth(scrollPercent)- ansi.PrintableRuneWidth(helpNote), )), ellipsis) @@ -421,7 +345,6 @@ func (m pagerModel) statusBarView(b *strings.Builder) { padding := max(0, m.common.width- ansi.PrintableRuneWidth(logo)- - ansi.PrintableRuneWidth(statusIndicator)- ansi.PrintableRuneWidth(note)- ansi.PrintableRuneWidth(scrollPercent)- ansi.PrintableRuneWidth(helpNote), @@ -433,9 +356,8 @@ func (m pagerModel) statusBarView(b *strings.Builder) { emptySpace = statusBarNoteStyle(emptySpace) } - fmt.Fprintf(b, "%s%s%s%s%s%s", + fmt.Fprintf(b, "%s%s%s%s%s", logo, - statusIndicator, note, emptySpace, scrollPercent, @@ -443,30 +365,17 @@ func (m pagerModel) statusBarView(b *strings.Builder) { ) } -func (m pagerModel) setNoteView(b *strings.Builder) { - fmt.Fprint(b, noteHeading) - fmt.Fprint(b, m.textInput.View()) -} - func (m pagerModel) helpView() (s string) { - memoOrStash := "m set memo" - if m.common.authStatus == authOK && m.currentDocument.docType == LocalDoc { - memoOrStash = "s stash this document" - } - col1 := []string{ "g/home go to top", "G/end go to bottom", - "", - memoOrStash, + "c copy contents", + "e edit this document", + "r reload this document", "esc back to files", "q quit", } - if m.currentDocument.docType == NewsDoc { - deleteFromStringSlice(col1, 3) - } - s += "\n" s += "k/↑ up " + col1[0] + "\n" s += "j/↓ down " + col1[1] + "\n" @@ -502,9 +411,7 @@ func renderWithGlamour(m pagerModel, md string) tea.Cmd { return func() tea.Msg { s, err := glamourRender(m, md) if err != nil { - if debug { - log.Println("error rendering with Glamour:", err) - } + log.Error("error rendering with Glamour", "error", err) return errMsg{err} } return contentRenderedMsg(s) @@ -513,53 +420,116 @@ func renderWithGlamour(m pagerModel, md string) tea.Cmd { // This is where the magic happens. func glamourRender(m pagerModel, markdown string) (string, error) { + trunc := lipgloss.NewStyle().MaxWidth(m.viewport.Width - lineNumberWidth).Render + if !config.GlamourEnabled { return markdown, nil } - // initialize glamour - var gs glamour.TermRendererOption - if m.common.cfg.GlamourStyle == "auto" { - gs = glamour.WithAutoStyle() - } else { - gs = glamour.WithStylePath(m.common.cfg.GlamourStyle) + isCode := !utils.IsMarkdownFile(m.currentDocument.Note) + width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width)) //nolint:gosec + if isCode { + width = 0 } - width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width)) - r, err := glamour.NewTermRenderer( - gs, + options := []glamour.TermRendererOption{ + utils.GlamourStyle(m.common.cfg.GlamourStyle, isCode), glamour.WithWordWrap(width), - ) + } + + if m.common.cfg.PreserveNewLines { + options = append(options, glamour.WithPreservedNewLines()) + } + r, err := glamour.NewTermRenderer(options...) if err != nil { - return "", err + return "", fmt.Errorf("error creating glamour renderer: %w", err) + } + + if isCode { + markdown = utils.WrapCodeBlock(markdown, filepath.Ext(m.currentDocument.Note)) } out, err := r.Render(markdown) if err != nil { - return "", err + return "", fmt.Errorf("error rendering markdown: %w", err) + } + + if isCode { + out = strings.TrimSpace(out) } // trim lines lines := strings.Split(out, "\n") - var content string + var content strings.Builder for i, s := range lines { - content += strings.TrimSpace(s) + if isCode || m.common.cfg.ShowLineNumbers { + content.WriteString(lineNumberStyle(fmt.Sprintf("%"+fmt.Sprint(lineNumberWidth)+"d", i+1))) + content.WriteString(trunc(s)) + } else { + content.WriteString(s) + } // don't add an artificial newline after the last split if i+1 < len(lines) { - content += "\n" + content.WriteRune('\n') } } - return content, nil + return content.String(), nil } -// ETC - -// Note: this runs in linear time; O(n). -func deleteFromStringSlice(a []string, i int) []string { - copy(a[i:], a[i+1:]) - a[len(a)-1] = "" - return a[:len(a)-1] +func (m *pagerModel) initWatcher() { + var err error + m.watcher, err = fsnotify.NewWatcher() + if err != nil { + log.Error("error creating fsnotify watcher", "error", err) + } +} + +func (m *pagerModel) watchFile() tea.Msg { + dir := m.localDir() + + if err := m.watcher.Add(dir); err != nil { + log.Error("error adding dir to fsnotify watcher", "error", err) + return nil + } + + log.Info("fsnotify watching dir", "dir", dir) + + for { + select { + case event, ok := <-m.watcher.Events: + if !ok || event.Name != m.currentDocument.localPath { + continue + } + + if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) { + continue + } + + log.Debug("fsnotify event", "file", event.Name, "event", event.Op) + return reloadMsg{} + case err, ok := <-m.watcher.Errors: + if !ok { + continue + } + log.Debug("fsnotify error", "dir", dir, "error", err) + } + } +} + +func (m *pagerModel) unwatchFile() { + dir := m.localDir() + + err := m.watcher.Remove(dir) + if err == nil { + log.Debug("fsnotify dir unwatched", "dir", dir) + } else { + log.Error("fsnotify fail to unwatch dir", "dir", dir, "error", err) + } +} + +func (m *pagerModel) localDir() string { + return filepath.Dir(m.currentDocument.localPath) } diff --git a/ui/sort.go b/ui/sort.go new file mode 100644 index 0000000..4f389fb --- /dev/null +++ b/ui/sort.go @@ -0,0 +1,12 @@ +package ui + +import ( + "cmp" + "slices" +) + +func sortMarkdowns(mds []*markdown) { + slices.SortStableFunc(mds, func(a, b *markdown) int { + return cmp.Compare(a.Note, b.Note) + }) +} diff --git a/ui/stash.go b/ui/stash.go index 6e85d58..c3739b2 100644 --- a/ui/stash.go +++ b/ui/stash.go @@ -3,8 +3,7 @@ package ui import ( "errors" "fmt" - "io/ioutil" - "log" + "os" "sort" "strings" "time" @@ -13,11 +12,10 @@ import ( "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/charm" - lib "github.com/charmbracelet/charm/ui/common" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/log" "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/truncate" - te "github.com/muesli/termenv" "github.com/sahilm/fuzzy" ) @@ -29,29 +27,33 @@ const ( stashViewHorizontalPadding = 6 ) -var ( - stashedStatusMessage = statusMessage{normalStatusMessage, "Stashed!"} - alreadyStashedStatusMessage = statusMessage{subtleStatusMessage, "Already stashed"} -) +var stashingStatusMessage = statusMessage{normalStatusMessage, "Stashing..."} var ( - stashTextInputPromptStyle styleFunc = newFgStyle(lib.YellowGreen) - dividerDot string = darkGrayFg(" • ") - dividerBar string = darkGrayFg(" │ ") - offlineHeaderNote string = darkGrayFg("(Offline)") + dividerDot = darkGrayFg.SetString(" • ") + dividerBar = darkGrayFg.SetString(" │ ") + + logoStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ECFD65")). + Background(fuchsia). + Bold(true) + + stashSpinnerStyle = lipgloss.NewStyle(). + Foreground(gray) + stashInputPromptStyle = lipgloss.NewStyle(). + Foreground(yellowGreen). + MarginRight(1) + stashInputCursorStyle = lipgloss.NewStyle(). + Foreground(fuchsia). + MarginRight(1) ) // MSG -type deletedStashedItemMsg int -type filteredMarkdownMsg []*markdown -type fetchedMarkdownMsg *markdown - -type markdownFetchFailedMsg struct { - err error - id int - note string -} +type ( + filteredMarkdownMsg []*markdown + fetchedMarkdownMsg *markdown +) // MODEL @@ -68,9 +70,7 @@ const ( type sectionKey int const ( - localSection = iota - stashedSection - newsSection + documentsSection = iota filterSection ) @@ -78,34 +78,12 @@ const ( // its contents in the file listing view. type section struct { key sectionKey - docTypes DocTypeSet paginator paginator.Model cursor int } // map sections to their associated types. -var sections = map[sectionKey]section{ - localSection: { - key: localSection, - docTypes: NewDocTypeSet(LocalDoc), - paginator: newStashPaginator(), - }, - stashedSection: { - key: stashedSection, - docTypes: NewDocTypeSet(StashedDoc, ConvertedDoc), - paginator: newStashPaginator(), - }, - newsSection: { - key: newsSection, - docTypes: NewDocTypeSet(NewsDoc), - paginator: newStashPaginator(), - }, - filterSection: { - key: filterSection, - docTypes: DocTypeSet{}, - paginator: newStashPaginator(), - }, -} +var sections = map[sectionKey]section{} // filterState is the current filtering state in the file listing. type filterState int @@ -116,15 +94,6 @@ const ( filterApplied // a filter is applied and user is not editing filter ) -// selectionState is the state of the currently selected document. -type selectionState int - -const ( - selectionIdle = iota - selectionSettingNote - selectionPromptingDelete -) - // statusMessageType adds some context to the status message being sent. type statusMessageType int @@ -141,10 +110,23 @@ type statusMessage struct { message string } +func initSections() { + sections = map[sectionKey]section{ + documentsSection: { + key: documentsSection, + paginator: newStashPaginator(), + }, + filterSection: { + key: filterSection, + paginator: newStashPaginator(), + }, + } +} + // String returns a styled version of the status message appropriate for the // given context. func (s statusMessage) String() string { - switch s.status { + switch s.status { //nolint:exhaustive case subtleStatusMessage: return dimGreenFg(s.message) case errorStatusMessage: @@ -158,12 +140,9 @@ type stashModel struct { common *commonModel err error spinner spinner.Model - noteInput textinput.Model filterInput textinput.Model - stashFullyLoaded bool // have we loaded all available stashed documents from the server? viewState stashViewState filterState filterState - selectionState selectionState showFullHelp bool showStatusMessage bool statusMessage statusMessage @@ -176,8 +155,8 @@ type stashModel struct { // Index of the section we're currently looking at sectionIndex int - // Tracks what exactly is loaded between the stash, news and local files - loaded DocTypeSet + // Tracks if docs were loaded + loaded bool // The master set of markdown documents we're working with. markdowns []*markdown @@ -191,28 +170,11 @@ type stashModel struct { // from the local pagination. Generally, the server will return more items // than we can display at a time so we can paginate locally without having // to fetch every time. - serverPage int -} - -func (m stashModel) localOnly() bool { - return m.common.cfg.localOnly() -} - -func (m stashModel) stashedOnly() bool { - return m.common.cfg.stashedOnly() + serverPage int64 } func (m stashModel) loadingDone() bool { - return m.loaded.Equals(m.common.cfg.DocumentTypes.Difference(ConvertedDoc)) -} - -func (m stashModel) hasSection(key sectionKey) bool { - for _, v := range m.sections { - if key == v.key { - return true - } - } - return false + return m.loaded } func (m stashModel) currentSection() *section { @@ -235,43 +197,30 @@ func (m *stashModel) setCursor(i int) { m.currentSection().cursor = i } -// Returns whether or not we're online. That is, when "local-only" mode is -// disabled and we've authenticated successfully. -func (m stashModel) online() bool { - return !m.localOnly() && m.common.authStatus == authOK +// Whether or not the spinner should be spinning. +func (m stashModel) shouldSpin() bool { + loading := !m.loadingDone() + openingDocument := m.viewState == stashStateLoadingDocument + return loading || openingDocument } func (m *stashModel) setSize(width, height int) { m.common.width = width m.common.height = height - m.noteInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth(m.noteInput.Prompt) - m.filterInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth(m.filterInput.Prompt) + m.filterInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth( + m.filterInput.Prompt, + ) m.updatePagination() } -// bakeConvertedDocs turns converted documents into stashed ones. Essentially, -// we're discarding the fact that they were ever converted so we can stop -// treating them like converted documents. -func (m *stashModel) bakeConvertedDocs() { - for _, md := range m.markdowns { - if md.docType == ConvertedDoc { - md.docType = StashedDoc - } - } -} - func (m *stashModel) resetFiltering() { m.filterState = unfiltered m.filterInput.Reset() m.filteredMarkdowns = nil - // Turn converted markdowns into stashed ones so that the next time we - // filter we get both local and stashed results. - m.bakeConvertedDocs() - - sort.Stable(markdownsByLocalFirst(m.markdowns)) + sortMarkdowns(m.markdowns) // If the filtered section is present (it's always at the end) slice it out // of the sections slice to remove it from the UI. @@ -298,7 +247,7 @@ func (m stashModel) filterApplied() bool { func (m stashModel) shouldUpdateFilter() bool { // If we're in the middle of setting a note don't update the filter so that // the focus won't jump around. - return m.filterApplied() && m.selectionState != selectionSettingNote + return m.filterApplied() } // Update pagination according to the amount of markdowns for the current @@ -344,58 +293,16 @@ func (m stashModel) selectedMarkdown() *markdown { // Adds markdown documents to the model. func (m *stashModel) addMarkdowns(mds ...*markdown) { - if len(mds) > 0 { - for _, md := range mds { - md.generateIDs() - } - - m.markdowns = append(m.markdowns, mds...) - if !m.filterApplied() { - sort.Stable(markdownsByLocalFirst(m.markdowns)) - } - m.updatePagination() - } -} - -// Return the number of markdown documents of a given type. -func (m stashModel) countMarkdowns(t DocType) (found int) { - if len(m.markdowns) == 0 { + if len(mds) == 0 { return } - var mds []*markdown - if m.filterState == filtering { - mds = m.getVisibleMarkdowns() - } else { - mds = m.markdowns + m.markdowns = append(m.markdowns, mds...) + if !m.filterApplied() { + sortMarkdowns(m.markdowns) } - for i := 0; i < len(mds); i++ { - if mds[i].docType == t { - found++ - } - } - return -} - -// Sift through the master markdown collection for the specified types. -func (m stashModel) getMarkdownByType(types ...DocType) []*markdown { - var agg []*markdown - - if len(m.markdowns) == 0 { - return agg - } - - for _, t := range types { - for _, md := range m.markdowns { - if md.docType == t { - agg = append(agg, md) - } - } - } - - sort.Stable(markdownsByLocalFirst(agg)) - return agg + m.updatePagination() } // Returns the markdowns that should be currently shown. @@ -404,45 +311,15 @@ func (m stashModel) getVisibleMarkdowns() []*markdown { return m.filteredMarkdowns } - return m.getMarkdownByType(m.currentSection().docTypes.AsSlice()...) -} - -// Return the markdowns eligible to be filtered. -func (m stashModel) getFilterableMarkdowns() (agg []*markdown) { - mds := m.getMarkdownByType(LocalDoc, ConvertedDoc, StashedDoc) - - // Copy values - for _, v := range mds { - p := *v - agg = append(agg, &p) - } - - return + return m.markdowns } // Command for opening a markdown document in the pager. Note that this also // alters the model. func (m *stashModel) openMarkdown(md *markdown) tea.Cmd { - var cmd tea.Cmd m.viewState = stashStateLoadingDocument - - if md.docType == LocalDoc { - cmd = loadLocalMarkdown(md) - } else { - cmd = loadRemoteMarkdown(m.common.cc, md) - } - - return tea.Batch(cmd, spinner.Tick) -} - -func (m *stashModel) newStatusMessage(sm statusMessage) tea.Cmd { - m.showStatusMessage = true - m.statusMessage = sm - if m.statusMessageTimer != nil { - m.statusMessageTimer.Stop() - } - m.statusMessageTimer = time.NewTimer(statusMessageTimeout) - return waitForStatusMessageTimeout(stashContext, m.statusMessageTimer) + cmd := loadLocalMarkdown(md) + return tea.Batch(cmd, m.spinner.Tick) } func (m *stashModel) hideStatusMessage() { @@ -497,50 +374,25 @@ func (m *stashModel) moveCursorDown() { // INIT func newStashModel(common *commonModel) stashModel { - sp := spinner.NewModel() + sp := spinner.New() sp.Spinner = spinner.Line - sp.ForegroundColor = lib.SpinnerColor.String() - sp.HideFor = time.Millisecond * 100 - sp.MinimumLifetime = time.Millisecond * 180 - sp.Start() + sp.Style = stashSpinnerStyle - ni := textinput.NewModel() - ni.Prompt = stashTextInputPromptStyle("Memo: ") - ni.CursorColor = lib.Fuschia.String() - ni.CharLimit = noteCharacterLimit - ni.Focus() - - si := textinput.NewModel() - si.Prompt = stashTextInputPromptStyle("Find: ") - si.CursorColor = lib.Fuschia.String() - si.CharLimit = noteCharacterLimit + si := textinput.New() + si.Prompt = "Find:" + si.PromptStyle = stashInputPromptStyle + si.Cursor.Style = stashInputCursorStyle si.Focus() - var s []section - if common.cfg.localOnly() { - s = []section{ - sections[localSection], - } - } else if common.cfg.stashedOnly() { - s = []section{ - sections[stashedSection], - sections[newsSection], - } - } else { - s = []section{ - sections[localSection], - sections[stashedSection], - sections[newsSection], - } + s := []section{ + sections[documentsSection], } m := stashModel{ common: common, spinner: sp, - noteInput: ni, filterInput: si, serverPage: 1, - loaded: NewDocTypeSet(), sections: s, } @@ -548,10 +400,10 @@ func newStashModel(common *commonModel) stashModel { } func newStashPaginator() paginator.Model { - p := paginator.NewModel() + p := paginator.New() p.Type = paginator.Dots p.ActiveDot = brightGrayFg("•") - p.InactiveDot = darkGrayFg("•") + p.InactiveDot = darkGrayFg.Render("•") return p } @@ -564,109 +416,22 @@ func (m stashModel) update(msg tea.Msg) (stashModel, tea.Cmd) { case errMsg: m.err = msg - case stashLoadErrMsg: - m.err = msg.err - m.loaded.Add(StashedDoc) // still done, albeit unsuccessfully - m.stashFullyLoaded = true - - case newsLoadErrMsg: - m.err = msg.err - m.loaded.Add(NewsDoc) // still done, albeit unsuccessfully - case localFileSearchFinished: // We're finished searching for local files - m.loaded.Add(LocalDoc) - - case gotStashMsg, gotNewsMsg: - // Stash or news results have come in from the server. - // - // With the stash, this doesn't mean the whole stash listing is loaded, - // but now know it can load, at least, so mark the stash as loaded here. - var docs []*markdown - - switch msg := msg.(type) { - case gotStashMsg: - m.loaded.Add(StashedDoc) - docs = wrapMarkdowns(StashedDoc, msg) - - if len(msg) == 0 { - // If the server comes back with nothing then we've got - // everything - m.stashFullyLoaded = true - } else { - // Load the next page - m.serverPage++ - cmds = append(cmds, loadStash(m)) - } - - case gotNewsMsg: - m.loaded.Add(NewsDoc) - docs = wrapMarkdowns(NewsDoc, msg) - } - - // If we're filtering build filter indexes immediately so any - // matching results will show up in the filter. - if m.filterApplied() { - for _, md := range docs { - md.buildFilterValue() - } - } - if m.shouldUpdateFilter() { - cmds = append(cmds, filterMarkdowns(m)) - } - - m.addMarkdowns(docs...) - - case markdownFetchFailedMsg: - s := "Couldn't load markdown" - if msg.note != "" { - s += ": " + msg.note - } - cmd := m.newStatusMessage(statusMessage{ - status: normalStatusMessage, - message: s, - }) - return m, cmd + m.loaded = true case filteredMarkdownMsg: m.filteredMarkdowns = msg + m.setCursor(0) return m, nil case spinner.TickMsg: - loading := !m.loadingDone() - stashing := m.common.isStashing() - openingDocument := m.viewState == stashStateLoadingDocument - spinnerVisible := m.spinner.Visible() - - if loading || stashing || openingDocument || spinnerVisible { - newSpinnerModel, cmd := m.spinner.Update(msg) - m.spinner = newSpinnerModel + if m.shouldSpin() { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) cmds = append(cmds, cmd) } - // A note was set on a document. This may have happened in the pager so - // we'll find the corresponding document here and update accordingly. - case noteSavedMsg: - for i := range m.markdowns { - if m.markdowns[i].ID == msg.ID { - m.markdowns[i].Note = msg.Note - } - } - - // Note: mechanical stuff related to stash success is handled in the parent - // update function. - case stashSuccessMsg: - m.spinner.Finish() - - // Note: mechanical stuff related to stash failure is handled in the parent - // update function. - case stashFailMsg: - m.err = msg.err - cmds = append(cmds, m.newStatusMessage(statusMessage{ - status: errorStatusMessage, - message: fmt.Sprintf("Couldn’t stash ‘%s’", msg.markdown.Note), - })) - case statusMessageTimeoutMsg: if applicationContext(msg) == stashContext { m.hideStatusMessage() @@ -678,17 +443,8 @@ func (m stashModel) update(msg tea.Msg) (stashModel, tea.Cmd) { return m, tea.Batch(cmds...) } - switch m.selectionState { - case selectionSettingNote: - cmds = append(cmds, m.handleNoteInput(msg)) - return m, tea.Batch(cmds...) - case selectionPromptingDelete: - cmds = append(cmds, m.handleDeleteConfirmation(msg)) - return m, tea.Batch(cmds...) - } - // Updates per the current state - switch m.viewState { + switch m.viewState { //nolint:exhaustive case stashStateReady: cmds = append(cmds, m.handleDocumentBrowsing(msg)) case stashStateShowingError: @@ -728,7 +484,7 @@ func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd { m.setCursor(m.paginator().ItemsOnPage(numDocs) - 1) // Clear filter (if applicable) - case "esc": + case keyEsc: if m.filterApplied() { m.resetFiltering() } @@ -755,8 +511,23 @@ func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd { } m.updatePagination() + case "F": + m.loaded = false + return findLocalFiles(*m.common) + + // Edit document in EDITOR + case "e": + md := m.selectedMarkdown() + + // In case no file is available + if md == nil { + return nil + } + + return openEditor(md.localPath, 0) + // Open document - case "enter": + case keyEnter: m.hideStatusMessage() if numDocs == 0 { @@ -771,14 +542,13 @@ func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd { // Filter your notes case "/": m.hideStatusMessage() - m.bakeConvertedDocs() // Build values we'll filter against for _, md := range m.markdowns { md.buildFilterValue() } - m.filteredMarkdowns = m.getFilterableMarkdowns() + m.filteredMarkdowns = m.markdowns m.paginator().Page = 0 m.setCursor(0) @@ -787,103 +557,6 @@ func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd { m.filterInput.Focus() return textinput.Blink - // Set note - case "m": - m.hideStatusMessage() - - if numDocs == 0 { - break - } - - md := m.selectedMarkdown() - isUserMarkdown := md.docType == StashedDoc || md.docType == ConvertedDoc - isSettingNote := m.selectionState == selectionSettingNote - isPromptingDelete := m.selectionState == selectionPromptingDelete - - if isUserMarkdown && !isSettingNote && !isPromptingDelete { - m.selectionState = selectionSettingNote - m.noteInput.SetValue(md.Note) - m.noteInput.CursorEnd() - return textinput.Blink - } - - // Stash - case "s": - if numDocs == 0 || !m.online() || m.selectedMarkdown() == nil { - break - } - - md := m.selectedMarkdown() - - // Is this a document we're allowed to stash? - if !stashableDocTypes.Contains(md.docType) { - break - } - - // Was this document already stashed? - if _, alreadyStashed := m.common.filesStashed[md.stashID]; alreadyStashed { - cmds = append(cmds, m.newStatusMessage(alreadyStashedStatusMessage)) - break - } - - // Is the document missing a stash ID? - if md.stashID.IsNil() { - if debug && md.stashID.IsNil() { - log.Printf("refusing to stash markdown; local ID path is nil: %#v", md) - } - break - } - - // Checks passed; perform the stash. Note that we optimistically - // show the status message. - m.common.filesStashed[md.stashID] = struct{}{} - m.common.filesStashing[md.stashID] = struct{}{} - m.common.latestFileStashed = md.stashID - cmds = append(cmds, - stashDocument(m.common.cc, *md), - m.newStatusMessage(stashedStatusMessage), - ) - - // If we're stashing a filtered item, optimistically convert the - // filtered item into a stashed item. - if m.filterApplied() { - for _, v := range m.filteredMarkdowns { - if v.uniqueID == md.uniqueID { - v.convertToStashed() - } - } - } - - // The spinner subtly shows the stash state in a non-optimistic - // fashion, namely because it was originally implemented this way. - // If this stash succeeds quickly enough, the spinner won't run - // at all. - if m.loadingDone() && !m.spinner.Visible() { - m.spinner.Start() - cmds = append(cmds, spinner.Tick) - } - - // Prompt for deletion - case "x": - m.hideStatusMessage() - - validState := m.viewState == stashStateReady && - m.selectionState == selectionIdle - - if numDocs == 0 && !validState { - break - } - - md := m.selectedMarkdown() - if md == nil { - break - } - - t := md.docType - if t == StashedDoc || t == ConvertedDoc { - m.selectionState = selectionPromptingDelete - } - // Toggle full help case "?": m.showFullHelp = !m.showFullHelp @@ -924,98 +597,17 @@ func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd { return tea.Batch(cmds...) } -// Updates for when a user is being prompted whether or not to delete a -// markdown item. -func (m *stashModel) handleDeleteConfirmation(msg tea.Msg) tea.Cmd { - if msg, ok := msg.(tea.KeyMsg); ok { - switch msg.String() { - case "y": - if m.selectionState != selectionPromptingDelete { - break - } - - smd := m.selectedMarkdown() - - for _, md := range m.markdowns { - if md.uniqueID != smd.uniqueID { - continue - } - - // Remove from the things-we-stashed-this-session set - delete(m.common.filesStashed, md.stashID) - - // Delete optimistically and remove the stashed item before - // we've received a success response. - mds, err := deleteMarkdown(m.markdowns, md) - if err == nil { - m.markdowns = mds - } - - break - } - - // Also optimistically delete from filtered markdowns - if m.filterApplied() { - for _, md := range m.filteredMarkdowns { - if md.uniqueID != smd.uniqueID { - continue - } - - switch md.docType { - - case ConvertedDoc: - // If the document was stashed in this session, convert it - // back to it's original document type - if md.originalDocType == LocalDoc { - md.revertFromStashed() - break - } - - // Other documents fall through and delete as normal - fallthrough - - // Otherwise, remove the document from the listing - default: - mds, err := deleteMarkdown(m.filteredMarkdowns, md) - if err == nil { - m.filteredMarkdowns = mds - } - - } - - break - } - } - - m.selectionState = selectionIdle - m.updatePagination() - - if len(m.filteredMarkdowns) == 0 { - m.resetFiltering() - } - - return deleteStashedItem(m.common.cc, smd.ID) - - // Any other key cancels deletion - default: - m.selectionState = selectionIdle - } - } - - return nil -} - // Updates for when a user is in the filter editing interface. func (m *stashModel) handleFiltering(msg tea.Msg) tea.Cmd { var cmds []tea.Cmd // Handle keys - if msg, ok := msg.(tea.KeyMsg); ok { + if msg, ok := msg.(tea.KeyMsg); ok { //nolint:nestif switch msg.String() { - case "esc": + case keyEsc: // Cancel filtering m.resetFiltering() - case "enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down": + case keyEnter, "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down": m.hideStatusMessage() if len(m.markdowns) == 0 { @@ -1073,53 +665,6 @@ func (m *stashModel) handleFiltering(msg tea.Msg) tea.Cmd { return tea.Batch(cmds...) } -func (m *stashModel) handleNoteInput(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - - if msg, ok := msg.(tea.KeyMsg); ok { - switch msg.String() { - case "esc": - // Cancel note - m.noteInput.Reset() - m.selectionState = selectionIdle - case "enter": - // Set new note - md := m.selectedMarkdown() - - // If the user is issuing a rename on a newly stashed item in a - // filtered listing, there's a small chance the user could try and - // set a note before the stash is complete. - if md.ID == 0 { - if debug { - log.Printf("user attempted to rename, but markdown ID is 0: %v", md) - } - return m.newStatusMessage(statusMessage{ - status: subtleStatusMessage, - message: "Too fast. Try again in a sec.", - }) - } - - newNote := m.noteInput.Value() - cmd := saveDocumentNote(m.common.cc, md.ID, newNote) - md.Note = newNote - m.noteInput.Reset() - m.selectionState = selectionIdle - return cmd - } - } - - if m.shouldUpdateFilter() { - cmds = append(cmds, filterMarkdowns(*m)) - } - - // Update the note text input component - newNoteInputModel, noteInputCmd := m.noteInput.Update(msg) - m.noteInput = newNoteInputModel - cmds = append(cmds, noteInputCmd) - - return tea.Batch(cmds...) -} - // VIEW func (m stashModel) view() string { @@ -1130,25 +675,14 @@ func (m stashModel) view() string { case stashStateLoadingDocument: s += " " + m.spinner.View() + " Loading document..." case stashStateReady: - loadingIndicator := " " - if !m.loadingDone() || m.spinner.Visible() { + if m.shouldSpin() { loadingIndicator = m.spinner.View() } - var header string - switch m.selectionState { - case selectionPromptingDelete: - header = redFg("Delete this item from your stash? ") + faintRedFg("(y/N)") - case selectionSettingNote: - header = yellowFg("Set the memo for this item?") - } - // Only draw the normal header if we're not using the header area for // something else (like a note or delete prompt). - if header == "" { - header = m.headerView() - } + header := m.headerView() // Rules for the logo, filter and status message. logoOrFilter := " " @@ -1157,12 +691,12 @@ func (m stashModel) view() string { } else if m.filterState == filtering { logoOrFilter += m.filterInput.View() } else { - logoOrFilter += glowLogoView(" Glow ") + logoOrFilter += glowLogoView() if m.showStatusMessage { logoOrFilter += " " + m.statusMessage.String() } } - logoOrFilter = truncate.StringWithTail(logoOrFilter, uint(m.common.width-1), ellipsis) + logoOrFilter = truncate.StringWithTail(logoOrFilter, uint(m.common.width-1), ellipsis) //nolint:gosec help, helpHeight := m.helpView() @@ -1193,14 +727,10 @@ func (m stashModel) view() string { // One could argue, in fact, that using pointers in // a functional framework is an antipattern and our use of // pointers in our model should be refactored away. - var p paginator.Model = *(m.paginator()) + p := *(m.paginator()) p.Type = paginator.Arabic - pagination = lib.Subtle(p.View()) + pagination = paginationStyle.Render(p.View()) } - - // We could also look at m.stashFullyLoaded and add an indicator - // showing that we don't actually know how many more pages there - // are. } s += fmt.Sprintf( @@ -1217,50 +747,29 @@ func (m stashModel) view() string { return "\n" + indent(s, stashIndent) } -func glowLogoView(text string) string { - return te.String(text). - Bold(). - Foreground(glowLogoTextColor). - Background(lib.Fuschia.Color()). - String() +func glowLogoView() string { + return logoStyle.Render(" Glow ") } func (m stashModel) headerView() string { - localCount := m.countMarkdowns(LocalDoc) - stashedCount := m.countMarkdowns(StashedDoc) + m.countMarkdowns(ConvertedDoc) - newsCount := m.countMarkdowns(NewsDoc) + localCount := len(m.markdowns) - var sections []string + var sections []string //nolint:prealloc // Filter results if m.filterState == filtering { - if localCount+stashedCount+newsCount == 0 { + if localCount == 0 { return grayFg("Nothing found.") } if localCount > 0 { sections = append(sections, fmt.Sprintf("%d local", localCount)) } - if stashedCount > 0 { - sections = append(sections, fmt.Sprintf("%d stashed", stashedCount)) - } for i := range sections { sections[i] = grayFg(sections[i]) } - return strings.Join(sections, dividerDot) - } - - if m.loadingDone() && len(m.markdowns) == 0 { - var maybeOffline string - if m.common.authStatus == authFailed { - maybeOffline = " " + offlineHeaderNote - } - - if m.stashedOnly() { - return lib.Subtle("Can’t load stash") + maybeOffline - } - return lib.Subtle("No markdown files found") + maybeOffline + return strings.Join(sections, dividerDot.String()) } // Tabs @@ -1268,39 +777,22 @@ func (m stashModel) headerView() string { var s string switch v.key { - case localSection: - if m.stashedOnly() { - continue - } - s = fmt.Sprintf("%d local", localCount) - case stashedSection: - if m.localOnly() { - continue - } - s = fmt.Sprintf("%d stashed", stashedCount) - case newsSection: - if m.localOnly() { - continue - } - s = fmt.Sprintf("%d news", newsCount) + case documentsSection: + s = fmt.Sprintf("%d documents", localCount) + case filterSection: s = fmt.Sprintf("%d “%s”", len(m.filteredMarkdowns), m.filterInput.Value()) } if m.sectionIndex == i && len(m.sections) > 1 { - s = selectedTabColor(s) + s = selectedTabStyle.Render(s) } else { - s = tabColor(s) + s = tabStyle.Render(s) } sections = append(sections, s) } - s := strings.Join(sections, dividerBar) - if m.common.authStatus == authFailed { - s += dividerDot + offlineHeaderNote - } - - return s + return strings.Join(sections, dividerBar.String()) } func (m stashModel) populatedView() string { @@ -1315,28 +807,12 @@ func (m stashModel) populatedView() string { } switch m.sections[m.sectionIndex].key { - case localSection: + case documentsSection: if m.loadingDone() { - f("No local files found.") + f("No files found.") } else { f("Looking for local files...") } - case stashedSection: - if m.common.authStatus == authFailed { - f("Can't load your stash. Are you offline?") - } else if m.loadingDone() { - f("Nothing stashed yet.") - } else { - f("Loading your stash...") - } - case newsSection: - if m.common.authStatus == authFailed { - f("Can't load news. Are you offline?") - } else if m.loadingDone() { - f("No stashed files found.") - } else { - f("Loading your stash...") - } case filterSection: return "" } @@ -1373,39 +849,15 @@ func (m stashModel) populatedView() string { // COMMANDS -// loadRemoteMarkdown is a command for loading markdown from the server. -func loadRemoteMarkdown(cc *charm.Client, md *markdown) tea.Cmd { - return func() tea.Msg { - newMD, err := fetchMarkdown(cc, md.ID, md.docType) - if err != nil { - if debug { - log.Printf("error loading %s markdown (ID %d, Note: '%s'): %v", md.docType, md.ID, md.Note, err) - } - return markdownFetchFailedMsg{ - err: err, - id: md.ID, - note: md.Note, - } - } - newMD.stashID = md.stashID - return fetchedMarkdownMsg(newMD) - } -} - func loadLocalMarkdown(md *markdown) tea.Cmd { return func() tea.Msg { - if md.docType != LocalDoc { - return errMsg{errors.New("could not load local file: not a local file")} - } if md.localPath == "" { return errMsg{errors.New("could not load file: missing path")} } - data, err := ioutil.ReadFile(md.localPath) + data, err := os.ReadFile(md.localPath) if err != nil { - if debug { - log.Println("error reading local markdown:", err) - } + log.Debug("error reading local file", "error", err) return errMsg{err} } md.Body = string(data) @@ -1413,27 +865,14 @@ func loadLocalMarkdown(md *markdown) tea.Cmd { } } -func deleteStashedItem(cc *charm.Client, id int) tea.Cmd { - return func() tea.Msg { - err := cc.DeleteMarkdown(id) - if err != nil { - if debug { - log.Println("could not delete stashed item:", err) - } - return errMsg{err} - } - return deletedStashedItemMsg(id) - } -} - func filterMarkdowns(m stashModel) tea.Cmd { return func() tea.Msg { if m.filterInput.Value() == "" || !m.filterApplied() { - return filteredMarkdownMsg(m.getFilterableMarkdowns()) // return everything + return filteredMarkdownMsg(m.markdowns) // return everything } targets := []string{} - mds := m.getFilterableMarkdowns() + mds := m.markdowns for _, t := range mds { targets = append(targets, t.filterValue) @@ -1450,55 +889,3 @@ func filterMarkdowns(m stashModel) tea.Cmd { return filteredMarkdownMsg(filtered) } } - -// ETC - -// fetchMarkdown performs the actual I/O for loading markdown from the sever. -func fetchMarkdown(cc *charm.Client, id int, t DocType) (*markdown, error) { - var md *charm.Markdown - var err error - - switch t { - case StashedDoc, ConvertedDoc: - md, err = cc.GetStashMarkdown(id) - case NewsDoc: - md, err = cc.GetNewsMarkdown(id) - default: - err = fmt.Errorf("unknown markdown type: %s", t) - } - - if err != nil { - return nil, err - } - - return &markdown{ - docType: t, - Markdown: *md, - }, nil -} - -// Delete a markdown from a slice of markdowns. -func deleteMarkdown(markdowns []*markdown, target *markdown) ([]*markdown, error) { - index := -1 - - // Operate on a copy to avoid any pointer weirdness - mds := make([]*markdown, len(markdowns)) - copy(mds, markdowns) - - for i, v := range mds { - if v.uniqueID == target.uniqueID { - index = i - break - } - } - - if index == -1 { - err := fmt.Errorf("could not find markdown to delete") - if debug { - log.Println(err) - } - return nil, err - } - - return append(mds[:index], mds[index+1:]...), nil -} diff --git a/ui/stashhelp.go b/ui/stashhelp.go index fea1fdf..6c0b474 100644 --- a/ui/stashhelp.go +++ b/ui/stashhelp.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - lib "github.com/charmbracelet/charm/ui/common" "github.com/muesli/reflow/ansi" ) @@ -16,7 +15,7 @@ type helpEntry struct{ key, val string } type helpColumn []helpEntry // newHelpColumn creates a help column from pairs of string arguments -// represeting keys and values. If the arguments are not even (and therein +// representing keys and values. If the arguments are not even (and therein // not every key has a matching value) the function will panic. func newHelpColumn(pairs ...string) (h helpColumn) { if len(pairs)%2 != 0 { @@ -100,30 +99,15 @@ func (m stashModel) helpView() (string, int) { return m.renderHelp(h) } - // Help for when we're interacting with a single document - switch m.selectionState { - case selectionSettingNote: - return m.renderHelp([]string{"enter", "confirm", "esc", "cancel"}, []string{"q", "quit"}) - case selectionPromptingDelete: - return m.renderHelp([]string{"y", "delete", "n", "cancel"}, []string{"q", "quit"}) - } - var ( - isStashed bool - isStashable bool navHelp []string filterHelp []string selectionHelp []string + editHelp []string sectionHelp []string appHelp []string ) - if numDocs > 0 { - md := m.selectedMarkdown() - isStashed = md != nil && md.docType == StashedDoc - isStashable = md != nil && md.docType == LocalDoc && m.online() - } - if numDocs > 0 && m.showFullHelp { navHelp = []string{"enter", "open", "j/k ↑/↓", "choose"} } @@ -141,23 +125,23 @@ func (m stashModel) helpView() (string, int) { } // If we're browsing a filtered set - if m.filterState == filterApplied { - filterHelp = []string{"/", "edit search", "esc", "clear search"} + if m.filterApplied() { + filterHelp = []string{"/", "edit search", "esc", "clear filter"} } else { filterHelp = []string{"/", "find"} } - if isStashed { - selectionHelp = []string{"x", "delete", "m", "set memo"} - } else if isStashable { - selectionHelp = []string{"s", "stash"} - } - // If there are errors if m.err != nil { appHelp = append(appHelp, "!", "errors") } + appHelp = append(appHelp, "r", "refresh") + + if numDocs > 0 { + appHelp = append(appHelp, "e", "edit") + } + appHelp = append(appHelp, "q", "quit") // Detailed help @@ -165,29 +149,31 @@ func (m stashModel) helpView() (string, int) { if m.filterState != filtering { appHelp = append(appHelp, "?", "close help") } - return m.renderHelp(navHelp, filterHelp, selectionHelp, sectionHelp, appHelp) + return m.renderHelp(navHelp, filterHelp, append(selectionHelp, editHelp...), sectionHelp, appHelp) } // Mini help if m.filterState != filtering { appHelp = append(appHelp, "?", "more") } - return m.renderHelp(navHelp, filterHelp, selectionHelp, sectionHelp, appHelp) + return m.renderHelp(navHelp, filterHelp, selectionHelp, editHelp, sectionHelp, appHelp) } +const minHelpViewHeight = 5 + // renderHelp returns the rendered help view and associated line height for // the given groups of help items. func (m stashModel) renderHelp(groups ...[]string) (string, int) { if m.showFullHelp { str := m.fullHelpView(groups...) numLines := strings.Count(str, "\n") + 1 - return str, numLines + return str, max(numLines, minHelpViewHeight) } return m.miniHelpView(concatStringSlices(groups...)...), 1 } // Builds the help view from various sections pieces, truncating it if the view -// would otherwise wrap to two lines. Help view entires should come in as pairs, +// would otherwise wrap to two lines. Help view entries should come in as pairs, // with the first being the key and the second being the help text. func (m stashModel) miniHelpView(entries ...string) string { if len(entries) == 0 { @@ -195,7 +181,7 @@ func (m stashModel) miniHelpView(entries ...string) string { } var ( - truncationChar = lib.Subtle("…") + truncationChar = subtleStyle.Render("…") truncationWidth = ansi.PrintableRuneWidth(truncationChar) ) @@ -213,19 +199,13 @@ func (m stashModel) miniHelpView(entries ...string) string { k := entries[i] v := entries[i+1] - switch k { - case "s": - k = greenFg(k) - v = semiDimGreenFg(v) - default: - k = grayFg(k) - v = midGrayFg(v) - } + k = grayFg(k) + v = midGrayFg(v) next = fmt.Sprintf("%s %s", k, v) if i < len(entries)-2 { - next += dividerDot + next += dividerDot.String() } // Only this (and the following) help text items if we have the @@ -241,11 +221,9 @@ func (m stashModel) miniHelpView(entries ...string) string { } func (m stashModel) fullHelpView(groups ...[]string) string { - var ( - columns []helpColumn - tallestCol int - renderedCols [][]string // final rows grouped by column - ) + var tallestCol int + columns := make([]helpColumn, 0, len(groups)) + renderedCols := make([][]string, 0, len(groups)) // final rows grouped by column // Get key/value pairs for _, g := range groups { diff --git a/ui/stashitem.go b/ui/stashitem.go index 7c5005e..0000928 100644 --- a/ui/stashitem.go +++ b/ui/stashitem.go @@ -2,48 +2,31 @@ package ui import ( "fmt" - "log" "strings" - lib "github.com/charmbracelet/charm/ui/common" - "github.com/muesli/reflow/ansi" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/log" "github.com/muesli/reflow/truncate" - "github.com/muesli/termenv" "github.com/sahilm/fuzzy" ) const ( verticalLine = "│" - noMemoTitle = "No Memo" fileListingStashIcon = "• " ) func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) { var ( - truncateTo = uint(m.common.width - stashViewHorizontalPadding*2) - gutter string - title = md.Note - date = md.relativeTime() - icon = "" + truncateTo = uint(m.common.width - stashViewHorizontalPadding*2) //nolint:gosec + gutter string + title = truncate.StringWithTail(md.Note, truncateTo, ellipsis) + date = md.relativeTime() + editedBy = "" + hasEditedBy = false + icon = "" + separator = "" ) - switch md.docType { - case NewsDoc: - if title == "" { - title = "News" - } else { - title = truncate.StringWithTail(title, truncateTo, ellipsis) - } - case StashedDoc, ConvertedDoc: - icon = fileListingStashIcon - if title == "" { - title = noMemoTitle - } - title = truncate.StringWithTail(title, truncateTo-uint(ansi.PrintableRuneWidth(icon)), ellipsis) - default: - title = truncate.StringWithTail(title, truncateTo, ellipsis) - } - isSelected := index == m.cursor() isFiltering := m.filterState == filtering singleFilteredItem := isFiltering && len(m.getVisibleMarkdowns()) == 1 @@ -51,94 +34,72 @@ func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) { // If there are multiple items being filtered don't highlight a selected // item in the results. If we've filtered down to one item, however, // highlight that first item since pressing return will open it. - if isSelected && !isFiltering || singleFilteredItem { + if isSelected && !isFiltering || singleFilteredItem { //nolint:nestif // Selected item - - switch m.selectionState { - case selectionPromptingDelete: - gutter = faintRedFg(verticalLine) - icon = faintRedFg(icon) - title = redFg(title) - date = faintRedFg(date) - case selectionSettingNote: - gutter = dullYellowFg(verticalLine) - icon = "" - title = m.noteInput.View() - date = dullYellowFg(date) - default: - if m.common.latestFileStashed == md.stashID && - m.statusMessage == stashedStatusMessage { - gutter = greenFg(verticalLine) - icon = dimGreenFg(icon) - title = greenFg(title) - date = semiDimGreenFg(date) - } else { - gutter = dullFuchsiaFg(verticalLine) - icon = dullFuchsiaFg(icon) - if m.currentSection().key == filterSection && - m.filterState == filterApplied || singleFilteredItem { - s := termenv.Style{}.Foreground(lib.Fuschia.Color()) - title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline()) - } else { - title = fuchsiaFg(title) - } - date = dullFuchsiaFg(date) - } - } - } else { - // Regular (non-selected) items - - gutter = " " - - if m.common.latestFileStashed == md.stashID && - m.statusMessage == stashedStatusMessage { + if m.statusMessage == stashingStatusMessage { + gutter = greenFg(verticalLine) icon = dimGreenFg(icon) title = greenFg(title) date = semiDimGreenFg(date) - } else if md.docType == NewsDoc { - if isFiltering && m.filterInput.Value() == "" { - title = dimIndigoFg(title) - date = dimSubtleIndigoFg(date) + editedBy = semiDimGreenFg(editedBy) + separator = semiDimGreenFg(separator) + } else { + gutter = dullFuchsiaFg(verticalLine) + if m.currentSection().key == filterSection && + m.filterState == filterApplied || singleFilteredItem { + s := lipgloss.NewStyle().Foreground(fuchsia) + title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true)) } else { - s := termenv.Style{}.Foreground(lib.Indigo.Color()) - title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline()) - date = subtleIndigoFg(date) + title = fuchsiaFg(title) + icon = fuchsiaFg(icon) } + date = dimFuchsiaFg(date) + editedBy = dimDullFuchsiaFg(editedBy) + separator = dullFuchsiaFg(separator) + } + } else { + gutter = " " + if m.statusMessage == stashingStatusMessage { + icon = dimGreenFg(icon) + title = greenFg(title) + date = semiDimGreenFg(date) + editedBy = semiDimGreenFg(editedBy) + separator = semiDimGreenFg(separator) } else if isFiltering && m.filterInput.Value() == "" { icon = dimGreenFg(icon) - if title == noMemoTitle { - title = dimBrightGrayFg(title) - } else { - title = dimNormalFg(title) - } + title = dimNormalFg(title) date = dimBrightGrayFg(date) + editedBy = dimBrightGrayFg(editedBy) + separator = dimBrightGrayFg(separator) } else { icon = greenFg(icon) - if title == noMemoTitle { - title = brightGrayFg(title) - } else { - s := termenv.Style{}.Foreground(lib.NewColorPair("#dddddd", "#1a1a1a").Color()) - title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline()) - } - date = brightGrayFg(date) + + s := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}) + title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true)) + date = grayFg(date) + editedBy = midGrayFg(editedBy) + separator = brightGrayFg(separator) } } - fmt.Fprintf(b, "%s %s%s\n", gutter, icon, title) + fmt.Fprintf(b, "%s %s%s%s%s\n", gutter, icon, separator, separator, title) fmt.Fprintf(b, "%s %s", gutter, date) + if hasEditedBy { + fmt.Fprintf(b, " %s", editedBy) + } } -func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle termenv.Style) string { +func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle lipgloss.Style) string { b := strings.Builder{} normalizedHay, err := normalize(haystack) - if err != nil && debug { - log.Printf("error normalizing '%s': %v", haystack, err) + if err != nil { + log.Error("error normalizing", "haystack", haystack, "error", err) } matches := fuzzy.Find(needles, []string{normalizedHay}) if len(matches) == 0 { - return defaultStyle.Styled(haystack) + return defaultStyle.Render(haystack) } m := matches[0] // only one match exists @@ -146,12 +107,12 @@ func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle term styled := false for _, mi := range m.MatchedIndexes { if i == mi { - b.WriteString(matchedStyle.Styled(string(rune))) + b.WriteString(matchedStyle.Render(string(rune))) styled = true } } if !styled { - b.WriteString(defaultStyle.Styled(string(rune))) + b.WriteString(defaultStyle.Render(string(rune))) } } diff --git a/ui/styles.go b/ui/styles.go index ed527f4..fd25882 100644 --- a/ui/styles.go +++ b/ui/styles.go @@ -1,63 +1,46 @@ package ui -import ( - lib "github.com/charmbracelet/charm/ui/common" - te "github.com/muesli/termenv" -) - -type styleFunc func(string) string - -const ( - darkGray = "#333333" -) +import "github.com/charmbracelet/lipgloss" +// Colors. var ( - normalFg = newFgStyle(lib.NewColorPair("#dddddd", "#1a1a1a")) - dimNormalFg = newFgStyle(lib.NewColorPair("#777777", "#A49FA5")) - - brightGrayFg = newFgStyle(lib.NewColorPair("#979797", "#847A85")) - dimBrightGrayFg = newFgStyle(lib.NewColorPair("#4D4D4D", "#C2B8C2")) - - grayFg = newFgStyle(lib.NewColorPair("#626262", "#909090")) - midGrayFg = newFgStyle(lib.NewColorPair("#4A4A4A", "#B2B2B2")) - darkGrayFg = newFgStyle(lib.NewColorPair("#3C3C3C", "#DDDADA")) - - greenFg = newFgStyle(lib.NewColorPair("#04B575", "#04B575")) - semiDimGreenFg = newFgStyle(lib.NewColorPair("#036B46", "#35D79C")) - dimGreenFg = newFgStyle(lib.NewColorPair("#0B5137", "#72D2B0")) - - fuchsiaFg = newFgStyle(lib.Fuschia) - dimFuchsiaFg = newFgStyle(lib.NewColorPair("#99519E", "#F1A8FF")) - - dullFuchsiaFg = newFgStyle(lib.NewColorPair("#AD58B4", "#F793FF")) - dimDullFuchsiaFg = newFgStyle(lib.NewColorPair("#6B3A6F", "#F6C9FF")) - - indigoFg = newFgStyle(lib.Indigo) - dimIndigoFg = newFgStyle(lib.NewColorPair("#494690", "#9498FF")) - - subtleIndigoFg = newFgStyle(lib.NewColorPair("#514DC1", "#7D79F6")) - dimSubtleIndigoFg = newFgStyle(lib.NewColorPair("#383584", "#BBBDFF")) - - yellowFg = newFgStyle(lib.YellowGreen) // renders light green on light backgrounds - dullYellowFg = newFgStyle(lib.NewColorPair("#9BA92F", "#6BCB94")) // renders light green on light backgrounds - redFg = newFgStyle(lib.Red) - faintRedFg = newFgStyle(lib.FaintRed) - - // Ultimately, we should transition to named styles - tabColor = newFgStyle(lib.NewColorPair("#626262", "#909090")) - selectedTabColor = newFgStyle(lib.NewColorPair("#979797", "#332F33")) + normalDim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"} + gray = lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"} + midGray = lipgloss.AdaptiveColor{Light: "#B2B2B2", Dark: "#4A4A4A"} + darkGray = lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"} + brightGray = lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"} + dimBrightGray = lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"} + cream = lipgloss.AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"} + yellowGreen = lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"} + fuchsia = lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"} + dimFuchsia = lipgloss.AdaptiveColor{Light: "#F1A8FF", Dark: "#99519E"} + dullFuchsia = lipgloss.AdaptiveColor{Dark: "#AD58B4", Light: "#F793FF"} + dimDullFuchsia = lipgloss.AdaptiveColor{Light: "#F6C9FF", Dark: "#7B4380"} + green = lipgloss.Color("#04B575") + red = lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"} + semiDimGreen = lipgloss.AdaptiveColor{Light: "#35D79C", Dark: "#036B46"} + dimGreen = lipgloss.AdaptiveColor{Light: "#72D2B0", Dark: "#0B5137"} ) -// Returns a termenv style with foreground and background options. -func newStyle(fg, bg lib.ColorPair, bold bool) func(string) string { - s := te.Style{}.Foreground(fg.Color()).Background(bg.Color()) - if bold { - s = s.Bold() - } - return s.Styled -} - -// Returns a new termenv style with background options only. -func newFgStyle(c lib.ColorPair) styleFunc { - return te.Style{}.Foreground(c.Color()).Styled -} +// Ulimately, we'll transition to named styles. +var ( + dimNormalFg = lipgloss.NewStyle().Foreground(normalDim).Render + brightGrayFg = lipgloss.NewStyle().Foreground(brightGray).Render + dimBrightGrayFg = lipgloss.NewStyle().Foreground(dimBrightGray).Render + grayFg = lipgloss.NewStyle().Foreground(gray).Render + midGrayFg = lipgloss.NewStyle().Foreground(midGray).Render + darkGrayFg = lipgloss.NewStyle().Foreground(darkGray) + greenFg = lipgloss.NewStyle().Foreground(green).Render + semiDimGreenFg = lipgloss.NewStyle().Foreground(semiDimGreen).Render + dimGreenFg = lipgloss.NewStyle().Foreground(dimGreen).Render + fuchsiaFg = lipgloss.NewStyle().Foreground(fuchsia).Render + dimFuchsiaFg = lipgloss.NewStyle().Foreground(dimFuchsia).Render + dullFuchsiaFg = lipgloss.NewStyle().Foreground(dullFuchsia).Render + dimDullFuchsiaFg = lipgloss.NewStyle().Foreground(dimDullFuchsia).Render + redFg = lipgloss.NewStyle().Foreground(red).Render + tabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"}) + selectedTabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#333333", Dark: "#979797"}) + errorTitleStyle = lipgloss.NewStyle().Foreground(cream).Background(red).Padding(0, 1) + subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}) + paginationStyle = subtleStyle +) diff --git a/ui/ui.go b/ui/ui.go index 5a15f7c..3537d3f 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -1,87 +1,71 @@ +// Package ui provides the main UI for the glow application. package ui import ( - "errors" "fmt" - "io/ioutil" - "log" "os" "path/filepath" "strings" "time" - "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/charm" - "github.com/charmbracelet/charm/keygen" - "github.com/charmbracelet/charm/ui/common" - lib "github.com/charmbracelet/charm/ui/common" - "github.com/charmbracelet/glow/utils" + "github.com/charmbracelet/glamour/styles" + "github.com/charmbracelet/glow/v2/utils" + "github.com/charmbracelet/log" "github.com/muesli/gitcha" te "github.com/muesli/termenv" - "github.com/segmentio/ksuid" ) const ( - noteCharacterLimit = 256 // should match server - statusMessageTimeout = time.Second * 2 // how long to show status messages like "stashed!" + statusMessageTimeout = time.Second * 3 // how long to show status messages like "stashed!" ellipsis = "…" ) var ( - config Config - glowLogoTextColor = lib.Color("#ECFD65") + config Config markdownExtensions = []string{ "*.md", "*.mdown", "*.mkdn", "*.mkd", "*.markdown", } - - // True if we're logging to a file, in which case we'll log more stuff. - debug = false - - // Types of documents we allow the user to stash. - stashableDocTypes = NewDocTypeSet(LocalDoc, NewsDoc) ) // NewProgram returns a new Tea program. -func NewProgram(cfg Config) *tea.Program { - if cfg.Logfile != "" { - log.Println("-- Starting Glow ----------------") - log.Printf("High performance pager: %v", cfg.HighPerformancePager) - log.Printf("Glamour rendering: %v", cfg.GlamourEnabled) - log.Println("Bubble Tea now initializing...") - debug = true - } +func NewProgram(cfg Config, content string) *tea.Program { + log.Debug( + "Starting glow", + "high_perf_pager", + cfg.HighPerformancePager, + "glamour", + cfg.GlamourEnabled, + ) + config = cfg - return tea.NewProgram(newModel(cfg)) + opts := []tea.ProgramOption{tea.WithAltScreen()} + if cfg.EnableMouse { + opts = append(opts, tea.WithMouseCellMotion()) + } + m := newModel(cfg, content) + return tea.NewProgram(m, opts...) } type errMsg struct{ err error } func (e errMsg) Error() string { return e.err.Error() } -type newCharmClientMsg *charm.Client -type sshAuthErrMsg struct{} -type keygenFailedMsg struct{ err error } -type keygenSuccessMsg struct{} -type initLocalFileSearchMsg struct { - cwd string - ch chan gitcha.SearchResult -} -type foundLocalFileMsg gitcha.SearchResult -type localFileSearchFinished struct{} -type gotStashMsg []*charm.Markdown -type stashLoadErrMsg struct{ err error } -type gotNewsMsg []*charm.Markdown -type statusMessageTimeoutMsg applicationContext -type newsLoadErrMsg struct{ err error } -type stashSuccessMsg markdown -type stashFailMsg struct { - err error - markdown markdown -} +type ( + initLocalFileSearchMsg struct { + cwd string + ch chan gitcha.SearchResult + } +) -// applicationContext indicates the area of the application something appies +type ( + foundLocalFileMsg gitcha.SearchResult + localFileSearchFinished struct{} + statusMessageTimeoutMsg applicationContext +) + +// applicationContext indicates the area of the application something applies // to. Occasionally used as an argument to commands and messages. type applicationContext int @@ -105,60 +89,18 @@ func (s state) String() string { }[s] } -type authStatus int - -const ( - authConnecting authStatus = iota - authOK - authFailed -) - -func (s authStatus) String() string { - return map[authStatus]string{ - authConnecting: "connecting", - authOK: "ok", - authFailed: "failed", - }[s] -} - -type keygenState int - -const ( - keygenUnstarted keygenState = iota - keygenRunning - keygenFinished -) - // Common stuff we'll need to access in all models. type commonModel struct { - cfg Config - cc *charm.Client - cwd string - authStatus authStatus - width int - height int - - // Local IDs of files stashed this session. We treat this like a set, - // ignoring the value portion with an empty struct. - filesStashed map[ksuid.KSUID]struct{} - - // ID of the most recently stashed markdown - latestFileStashed ksuid.KSUID - - // Files currently being stashed. We remove files from this set once - // a stash operation has either succeeded or failed. - filesStashing map[ksuid.KSUID]struct{} -} - -func (c commonModel) isStashing() bool { - return len(c.filesStashing) > 0 + cfg Config + cwd string + width int + height int } type model struct { - common *commonModel - state state - keygenState keygenState - fatalErr error + common *commonModel + state state + fatalErr error // Sub-models stash stashModel @@ -179,57 +121,82 @@ func (m *model) unloadDocument() []tea.Cmd { var batch []tea.Cmd if m.pager.viewport.HighPerformanceRendering { - batch = append(batch, tea.ClearScrollArea) + batch = append(batch, tea.ClearScrollArea) //nolint:staticcheck } - if !m.stash.loadingDone() { - batch = append(batch, spinner.Tick) + if !m.stash.shouldSpin() { + batch = append(batch, m.stash.spinner.Tick) } return batch } -func newModel(cfg Config) tea.Model { - if cfg.GlamourStyle == "auto" { +func newModel(cfg Config, content string) tea.Model { + initSections() + + if cfg.GlamourStyle == styles.AutoStyle { if te.HasDarkBackground() { - cfg.GlamourStyle = "dark" + cfg.GlamourStyle = styles.DarkStyle } else { - cfg.GlamourStyle = "light" + cfg.GlamourStyle = styles.LightStyle } } - if len(cfg.DocumentTypes) == 0 { - cfg.DocumentTypes.Add(LocalDoc, StashedDoc, ConvertedDoc, NewsDoc) - } - common := commonModel{ - cfg: cfg, - authStatus: authConnecting, - filesStashed: make(map[ksuid.KSUID]struct{}), - filesStashing: make(map[ksuid.KSUID]struct{}), + cfg: cfg, } - return model{ - common: &common, - state: stateShowStash, - keygenState: keygenUnstarted, - pager: newPagerModel(&common), - stash: newStashModel(&common), + m := model{ + common: &common, + state: stateShowStash, + pager: newPagerModel(&common), + stash: newStashModel(&common), } + + path := cfg.Path + if path == "" && content != "" { + m.state = stateShowDocument + m.pager.currentDocument = markdown{Body: content} + return m + } + + if path == "" { + path = "." + } + info, err := os.Stat(path) + if err != nil { + log.Error("unable to stat file", "file", path, "error", err) + m.fatalErr = err + return m + } + if info.IsDir() { + m.state = stateShowStash + } else { + cwd, _ := os.Getwd() + m.state = stateShowDocument + m.pager.currentDocument = markdown{ + localPath: path, + Note: stripAbsolutePath(path, cwd), + Modtime: info.ModTime(), + } + } + + return m } func (m model) Init() tea.Cmd { - var cmds []tea.Cmd - d := m.common.cfg.DocumentTypes + cmds := []tea.Cmd{m.stash.spinner.Tick} - if d.Contains(StashedDoc) || d.Contains(NewsDoc) { - cmds = append(cmds, - newCharmClient, - spinner.Tick, - ) - } - - if d.Contains(LocalDoc) { - cmds = append(cmds, findLocalFiles(m)) + switch m.state { + case stateShowStash: + cmds = append(cmds, findLocalFiles(*m.common)) + case stateShowDocument: + content, err := os.ReadFile(m.common.cfg.Path) + if err != nil { + log.Error("unable to read file", "file", m.common.cfg.Path, "error", err) + return func() tea.Msg { return errMsg{err} } + } + body := string(utils.RemoveFrontmatter(content)) + cmds = append(cmds, renderWithGlamour(m.pager, body)) } return tea.Batch(cmds...) @@ -249,43 +216,45 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "esc": - if m.state == stateShowDocument { + if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument { batch := m.unloadDocument() return m, tea.Batch(batch...) } + case "r": + var cmd tea.Cmd + if m.state == stateShowStash { + // pass through all keys if we're editing the filter + if m.stash.filterState == filtering { + m.stash, cmd = m.stash.update(msg) + return m, cmd + } + m.stash.markdowns = nil + return m, m.Init() + } case "q": var cmd tea.Cmd - switch m.state { + switch m.state { //nolint:exhaustive case stateShowStash: // pass through all keys if we're editing the filter - if m.stash.filterState == filtering || m.stash.selectionState == selectionSettingNote { + if m.stash.filterState == filtering { m.stash, cmd = m.stash.update(msg) return m, cmd } - - // Special cases for the pager - case stateShowDocument: - switch m.pager.state { - // If setting a note send all keys straight through - case pagerStateSetNote: - var batch []tea.Cmd - newPagerModel, cmd := m.pager.update(msg) - m.pager = newPagerModel - batch = append(batch, cmd) - return m, tea.Batch(batch...) - } } return m, tea.Quit case "left", "h", "delete": - if m.state == stateShowDocument && m.pager.state != pagerStateSetNote { + if m.state == stateShowDocument { cmds = append(cmds, m.unloadDocument()...) return m, tea.Batch(cmds...) } + case "ctrl+z": + return m, tea.Suspend + // Ctrl+C always quits no matter where in the application you are. case "ctrl+c": return m, tea.Quit @@ -303,66 +272,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.common.cwd = msg.cwd cmds = append(cmds, findNextLocalFile(m)) - case sshAuthErrMsg: - if m.keygenState != keygenFinished { // if we haven't run the keygen yet, do that - m.keygenState = keygenRunning - cmds = append(cmds, generateSSHKeys) - } else { - // The keygen ran but things still didn't work and we can't auth - m.common.authStatus = authFailed - m.stash.err = errors.New("SSH authentication failed; we tried ssh-agent, loading keys from disk, and generating SSH keys") - if debug { - log.Println("entering offline mode;", m.stash.err) - } - - // Even though it failed, news/stash loading is finished - m.stash.loaded.Add(StashedDoc, NewsDoc) - } - - case keygenFailedMsg: - // Keygen failed. That sucks. - m.common.authStatus = authFailed - m.stash.err = errors.New("could not authenticate; could not generate SSH keys") - if debug { - log.Println("entering offline mode;", m.stash.err) - } - - m.keygenState = keygenFinished - - // Even though it failed, news/stash loading is finished - m.stash.loaded.Add(StashedDoc, NewsDoc) - - case keygenSuccessMsg: - // The keygen's done, so let's try initializing the charm client again - m.keygenState = keygenFinished - cmds = append(cmds, newCharmClient) - - case newCharmClientMsg: - m.common.cc = msg - m.common.authStatus = authOK - cmds = append(cmds, loadStash(m.stash), loadNews(m.stash)) - - case stashLoadErrMsg: - m.common.authStatus = authFailed - case fetchedMarkdownMsg: // We've loaded a markdown file's contents for rendering m.pager.currentDocument = *msg - msg.Body = string(utils.RemoveFrontmatter([]byte(msg.Body))) - cmds = append(cmds, renderWithGlamour(m.pager, msg.Body)) + body := string(utils.RemoveFrontmatter([]byte(msg.Body))) + cmds = append(cmds, renderWithGlamour(m.pager, body)) case contentRenderedMsg: m.state = stateShowDocument - case noteSavedMsg: - // A note was saved to a document. This will have been done in the - // pager, so we'll need to find the corresponding note in the stash. - // So, pass the message to the stash for processing. - stashModel, cmd := m.stash.update(msg) - m.stash = stashModel - return m, cmd - - case localFileSearchFinished, gotStashMsg, gotNewsMsg: + case localFileSearchFinished: // Always pass these messages to the stash so we can keep it updated // about network activity, even if the user isn't currently viewing // the stash. @@ -381,35 +300,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } cmds = append(cmds, findNextLocalFile(m)) - case stashSuccessMsg: - // Common handling that should happen regardless of application state - md := markdown(msg) - m.stash.addMarkdowns(&md) - m.common.filesStashed[msg.stashID] = struct{}{} - delete(m.common.filesStashing, md.stashID) - - if m.stash.filterApplied() { - for _, v := range m.stash.filteredMarkdowns { - if v.stashID == msg.stashID && v.docType == ConvertedDoc { - // Add the server-side ID we got back so we can do things - // like rename and stash it. - v.ID = msg.ID - - // Keep the unique ID in sync so we can do things like - // delete. Note that the markdown received a new unique ID - // when it was added to the file listing in - // stash.addMarkdowns. - v.uniqueID = md.uniqueID - break - } - } - } - - case stashFailMsg: - // Common handling that should happen regardless of application state - delete(m.common.filesStashed, msg.markdown.stashID) - delete(m.common.filesStashing, msg.markdown.stashID) - case filteredMarkdownMsg: if m.state == stateShowDocument { newStashModel, cmd := m.stash.update(msg) @@ -439,7 +329,7 @@ func (m model) View() string { return errorView(m.fatalErr, true) } - switch m.state { + switch m.state { //nolint:exhaustive case stateShowDocument: return m.pager.View() default: @@ -455,22 +345,20 @@ func errorView(err error, fatal bool) string { exitMsg += "return" } s := fmt.Sprintf("%s\n\n%v\n\n%s", - te.String(" ERROR "). - Foreground(lib.Cream.Color()). - Background(lib.Red.Color()). - String(), + errorTitleStyle.Render("ERROR"), err, - common.Subtle(exitMsg), + subtleStyle.Render(exitMsg), ) return "\n" + indent(s, 3) } // COMMANDS -func findLocalFiles(m model) tea.Cmd { +func findLocalFiles(m commonModel) tea.Cmd { return func() tea.Msg { + log.Info("findLocalFiles") var ( - cwd = m.common.cfg.WorkingDirectory + cwd = m.cfg.Path err error ) @@ -486,26 +374,22 @@ func findLocalFiles(m model) tea.Cmd { // Note that this is one error check for both cases above if err != nil { - if debug { - log.Println("error finding local files:", err) - } + log.Error("error finding local files", "error", err) return errMsg{err} } - if debug { - log.Println("local directory is:", cwd) + log.Debug("local directory is", "cwd", cwd) + + // Switch between FindFiles and FindAllFiles to bypass .gitignore rules + var ch chan gitcha.SearchResult + if m.cfg.ShowAllFiles { + ch, err = gitcha.FindAllFilesExcept(cwd, markdownExtensions, nil) + } else { + ch, err = gitcha.FindFilesExcept(cwd, markdownExtensions, ignorePatterns(m)) } - var ignore []string - if !m.common.cfg.ShowAllFiles { - ignore = ignorePatterns(m) - } - - ch, err := gitcha.FindFilesExcept(cwd, markdownExtensions, ignore) if err != nil { - if debug { - log.Println("error finding local files:", err) - } + log.Error("error finding local files", "error", err) return errMsg{err} } @@ -522,187 +406,11 @@ func findNextLocalFile(m model) tea.Cmd { return foundLocalFileMsg(res) } // We're done - if debug { - log.Println("local file search finished") - } + log.Debug("local file search finished") return localFileSearchFinished{} } } -func newCharmClient() tea.Msg { - cfg, err := charm.ConfigFromEnv() - if err != nil { - return errMsg{err} - } - - cc, err := charm.NewClient(cfg) - if err == charm.ErrMissingSSHAuth { - if debug { - log.Println("missing SSH auth:", err) - } - return sshAuthErrMsg{} - } else if err != nil { - if debug { - log.Println("error creating new charm client:", err) - } - return errMsg{err} - } - - return newCharmClientMsg(cc) -} - -func loadStash(m stashModel) tea.Cmd { - return func() tea.Msg { - if m.common.cc == nil { - err := errors.New("no charm client") - if debug { - log.Println("error loading stash:", err) - } - return stashLoadErrMsg{err} - } - stash, err := m.common.cc.GetStash(m.serverPage) - if err != nil { - if debug { - if _, ok := err.(charm.ErrAuthFailed); ok { - log.Println("auth failure while loading stash:", err) - } else { - log.Println("error loading stash:", err) - } - } - return stashLoadErrMsg{err} - } - if debug { - log.Println("loaded stash page", m.serverPage) - } - return gotStashMsg(stash) - } -} - -func loadNews(m stashModel) tea.Cmd { - return func() tea.Msg { - if m.common.cc == nil { - err := errors.New("no charm client") - if debug { - log.Println("error loading news:", err) - } - return newsLoadErrMsg{err} - } - news, err := m.common.cc.GetNews(1) // just fetch the first page - if err != nil { - if debug { - log.Println("error loading news:", err) - } - return newsLoadErrMsg{err} - } - if debug { - log.Println("fetched news") - } - return gotNewsMsg(news) - } -} - -func generateSSHKeys() tea.Msg { - if debug { - log.Println("running keygen...") - } - _, err := keygen.NewSSHKeyPair(nil) - if err != nil { - if debug { - log.Println("keygen failed:", err) - } - return keygenFailedMsg{err} - } - if debug { - log.Println("keys generated succcessfully") - } - return keygenSuccessMsg{} -} - -func saveDocumentNote(cc *charm.Client, id int, note string) tea.Cmd { - if cc == nil { - return func() tea.Msg { - err := errors.New("can't set note; no charm client") - if debug { - log.Println("error saving note:", err) - } - return errMsg{err} - } - } - return func() tea.Msg { - if err := cc.SetMarkdownNote(id, note); err != nil { - if debug { - log.Println("error saving note:", err) - } - return errMsg{err} - } - return noteSavedMsg(&charm.Markdown{ID: id, Note: note}) - } -} - -func stashDocument(cc *charm.Client, md markdown) tea.Cmd { - return func() tea.Msg { - if cc == nil { - err := errors.New("can't stash; no charm client") - if debug { - log.Println("error stashing document:", err) - } - return stashFailMsg{err, md} - } - - // Is the document missing a body? If so, it likely means it needs to - // be loaded. But...if it turns out the document body really is empty - // then we'll stash it anyway. - if len(md.Body) == 0 { - switch md.docType { - - case LocalDoc: - data, err := ioutil.ReadFile(md.localPath) - if err != nil { - if debug { - log.Println("error loading document body for stashing:", err) - } - return stashFailMsg{err, md} - } - md.Body = string(data) - - case NewsDoc: - newMD, err := fetchMarkdown(cc, md.ID, md.docType) - if err != nil { - if debug { - log.Println(err) - } - return stashFailMsg{err, md} - } - md.Body = newMD.Body - - default: - err := fmt.Errorf("user is attempting to stash an unsupported markdown type: %s", md.docType) - if debug { - log.Println(err) - } - return stashFailMsg{err, md} - } - } - - newMd, err := cc.StashMarkdown(md.Note, md.Body) - if err != nil { - if debug { - log.Println("error stashing document:", err) - } - return stashFailMsg{err, md} - } - - md.convertToStashed() - - // The server sends the whole stashed document back, but we really just - // need to know the ID so we can operate on this newly stashed - // markdown. - md.ID = newMd.ID - - return stashSuccessMsg(md) - } -} - func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.Cmd { return func() tea.Msg { <-t.C @@ -716,20 +424,17 @@ func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.C // document. Note that we could be doing things like checking if the file is // a directory, but we trust that gitcha has already done that. func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown { - md := &markdown{ - docType: LocalDoc, + return &markdown{ localPath: res.Path, - Markdown: charm.Markdown{ - Note: stripAbsolutePath(res.Path, cwd), - CreatedAt: res.Info.ModTime(), - }, + Note: stripAbsolutePath(res.Path, cwd), + Modtime: res.Info.ModTime(), } - - return md } func stripAbsolutePath(fullPath, cwd string) string { - return strings.Replace(fullPath, cwd+string(os.PathSeparator), "", -1) + fp, _ := filepath.EvalSymlinks(fullPath) + cp, _ := filepath.EvalSymlinks(cwd) + return strings.ReplaceAll(fp, cp+string(os.PathSeparator), "") } // Lightweight version of reflow's indent function. @@ -745,17 +450,3 @@ func indent(s string, n int) string { } return b.String() } - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/url.go b/url.go new file mode 100644 index 0000000..f716681 --- /dev/null +++ b/url.go @@ -0,0 +1,86 @@ +package main + +import ( + "fmt" + "net/url" + "strings" + "sync" +) + +const ( + protoGithub = "github://" + protoGitlab = "gitlab://" + protoHTTPS = "https://" +) + +var ( + githubURL *url.URL + gitlabURL *url.URL + urlsOnce sync.Once +) + +func init() { + urlsOnce.Do(func() { + githubURL, _ = url.Parse("https://github.com") + gitlabURL, _ = url.Parse("https://gitlab.com") + }) +} + +func readmeURL(path string) (*source, error) { + switch { + case strings.HasPrefix(path, protoGithub): + if u := githubReadmeURL(path); u != nil { + return readmeURL(u.String()) + } + return nil, nil + case strings.HasPrefix(path, protoGitlab): + if u := gitlabReadmeURL(path); u != nil { + return readmeURL(u.String()) + } + return nil, nil + } + + if !strings.HasPrefix(path, protoHTTPS) { + path = protoHTTPS + path + } + u, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("unable to parse url: %w", err) + } + + switch { + case u.Hostname() == githubURL.Hostname(): + return findGitHubREADME(u) + case u.Hostname() == gitlabURL.Hostname(): + return findGitLabREADME(u) + } + + return nil, nil +} + +func githubReadmeURL(path string) *url.URL { + path = strings.TrimPrefix(path, protoGithub) + parts := strings.Split(path, "/") + if len(parts) != 2 { + // custom hostnames are not supported yet + return nil + } + u, _ := url.Parse(githubURL.String()) + return u.JoinPath(path) +} + +func gitlabReadmeURL(path string) *url.URL { + path = strings.TrimPrefix(path, protoGitlab) + parts := strings.Split(path, "/") + if len(parts) != 2 { + // custom hostnames are not supported yet + return nil + } + u, _ := url.Parse(gitlabURL.String()) + return u.JoinPath(path) +} + +func isURL(path string) bool { + _, err := url.ParseRequestURI(path) + return err == nil && strings.Contains(path, "://") +} diff --git a/url_test.go b/url_test.go new file mode 100644 index 0000000..9683892 --- /dev/null +++ b/url_test.go @@ -0,0 +1,30 @@ +package main + +import "testing" + +func TestURLParser(t *testing.T) { + for path, url := range map[string]string{ + "github.com/charmbracelet/glow": "https://raw.githubusercontent.com/charmbracelet/glow/master/README.md", + "github://charmbracelet/glow": "https://raw.githubusercontent.com/charmbracelet/glow/master/README.md", + "github://caarlos0/dotfiles.fish": "https://raw.githubusercontent.com/caarlos0/dotfiles.fish/main/README.md", + "github://tj/git-extras": "https://raw.githubusercontent.com/tj/git-extras/main/Readme.md", + "https://github.com/goreleaser/nfpm": "https://raw.githubusercontent.com/goreleaser/nfpm/main/README.md", + "gitlab.com/caarlos0/test": "https://gitlab.com/caarlos0/test/-/raw/master/README.md", + "gitlab://caarlos0/test": "https://gitlab.com/caarlos0/test/-/raw/master/README.md", + "https://gitlab.com/terrakok/gitlab-client": "https://gitlab.com/terrakok/gitlab-client/-/raw/develop/Readme.md", + } { + t.Run(path, func(t *testing.T) { + t.Skip("test uses network, sometimes fails for no reason") + got, err := readmeURL(path) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got == nil { + t.Fatalf("should not be nil") + } + if url != got.URL { + t.Errorf("expected url for %s to be %s, was %s", path, url, got.URL) + } + }) + } +} diff --git a/utils/utils.go b/utils/utils.go index 8146f82..9f33a1e 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -1,12 +1,20 @@ +// Package utils provides utility functions. package utils import ( "os" + "path/filepath" "regexp" + "strings" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glamour/ansi" + "github.com/charmbracelet/glamour/styles" + "github.com/charmbracelet/lipgloss" "github.com/mitchellh/go-homedir" ) +// RemoveFrontmatter removes the front matter header of a markdown file. func RemoveFrontmatter(content []byte) []byte { if frontmatterBoundaries := detectFrontmatter(content); frontmatterBoundaries[0] == 0 { return content[frontmatterBoundaries[1]:] @@ -23,7 +31,7 @@ func detectFrontmatter(c []byte) []int { return []int{-1, -1} } -// Expands tilde and all environment variables from the given path. +// ExpandPath expands tilde and all environment variables from the given path. func ExpandPath(path string) string { s, err := homedir.Expand(path) if err == nil { @@ -31,3 +39,75 @@ func ExpandPath(path string) string { } return os.ExpandEnv(path) } + +// WrapCodeBlock wraps a string in a code block with the given language. +func WrapCodeBlock(s, language string) string { + return "```" + language + "\n" + s + "```" +} + +var markdownExtensions = []string{ + ".md", ".mdown", ".mkdn", ".mkd", ".markdown", +} + +// IsMarkdownFile returns whether the filename has a markdown extension. +func IsMarkdownFile(filename string) bool { + ext := filepath.Ext(filename) + + if ext == "" { + // By default, assume it's a markdown file. + return true + } + + for _, v := range markdownExtensions { + if strings.EqualFold(ext, v) { + return true + } + } + + // Has an extension but not markdown + // so assume this is a code file. + return false +} + +// GlamourStyle returns a glamour.TermRendererOption based on the given style. +func GlamourStyle(style string, isCode bool) glamour.TermRendererOption { + if !isCode { + if style == styles.AutoStyle { + return glamour.WithAutoStyle() + } + return glamour.WithStylePath(style) + } + + // If we are rendering a pure code block, we need to modify the style to + // remove the indentation. + + var styleConfig ansi.StyleConfig + + switch style { + case styles.AutoStyle: + if lipgloss.HasDarkBackground() { + styleConfig = styles.DarkStyleConfig + } else { + styleConfig = styles.LightStyleConfig + } + case styles.DarkStyle: + styleConfig = styles.DarkStyleConfig + case styles.LightStyle: + styleConfig = styles.LightStyleConfig + case styles.PinkStyle: + styleConfig = styles.PinkStyleConfig + case styles.NoTTYStyle: + styleConfig = styles.NoTTYStyleConfig + case styles.DraculaStyle: + styleConfig = styles.DraculaStyleConfig + case styles.TokyoNightStyle: + styleConfig = styles.DraculaStyleConfig + default: + return glamour.WithStylesFromJSONFile(style) + } + + var margin uint + styleConfig.CodeBlock.Margin = &margin + + return glamour.WithStyles(styleConfig) +}