diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 5de2df8..0000000 --- a/.editorconfig +++ /dev/null @@ -1,18 +0,0 @@ -# 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 deleted file mode 100644 index c19be87..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -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 deleted file mode 100644 index 897a394..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 11fc491..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -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 deleted file mode 100644 index d944991..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,57 +0,0 @@ -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 adcb449..b0c9f68 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,25 +1,28 @@ name: build - on: [push, pull_request] - jobs: - build: - uses: charmbracelet/meta/.github/workflows/build.yml@main + 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 }} - snapshot: - uses: charmbracelet/meta/.github/workflows/snapshot.yml@main - secrets: - goreleaser_key: ${{ secrets.GORELEASER_KEY }} + - name: Checkout code + uses: actions/checkout@v2 - govulncheck: - uses: charmbracelet/meta/.github/workflows/govulncheck.yml@main - with: - go-version: stable + - name: Download Go modules + run: go mod download - semgrep: - uses: charmbracelet/meta/.github/workflows/semgrep.yml@main + - name: Build + run: go build -v ./... - ruleguard: - uses: charmbracelet/meta/.github/workflows/ruleguard.yml@main - with: - go-version: stable + - name: Test + run: go test ./... diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 7fbd40b..01c10d2 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -3,22 +3,26 @@ on: [push, pull_request] jobs: coverage: - runs-on: ubuntu-latest + strategy: + matrix: + go-version: [^1] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} env: GO111MODULE: "on" steps: - - name: Checkout code - uses: actions/checkout@v6 - - name: Install Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v2 with: - go-version: stable + go-version: ${{ matrix.go-version }} + + - name: Checkout code + uses: actions/checkout@v2 - name: Coverage env: COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | go test -race -covermode atomic -coverprofile=profile.cov ./... - go install github.com/mattn/goveralls@latest - goveralls -coverprofile=profile.cov -service=github + GO111MODULE=off go get github.com/mattn/goveralls + $(go env GOPATH)/bin/goveralls -coverprofile=profile.cov -service=github diff --git a/.github/workflows/dependabot-sync.yml b/.github/workflows/dependabot-sync.yml deleted file mode 100644 index 9b08259..0000000 --- a/.github/workflows/dependabot-sync.yml +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index fbc2046..0000000 --- a/.github/workflows/goreleaser.yml +++ /dev/null @@ -1,25 +0,0 @@ -# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json - -name: goreleaser - -on: - push: - tags: - - v*.*.* - -concurrency: - group: goreleaser - cancel-in-progress: true - -jobs: - goreleaser: - uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main - secrets: - docker_username: ${{ secrets.DOCKERHUB_USERNAME }} - docker_token: ${{ secrets.DOCKERHUB_TOKEN }} - gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - goreleaser_key: ${{ secrets.GORELEASER_KEY }} - fury_token: ${{ secrets.FURY_TOKEN }} - nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }} - nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }} - snapcraft_token: ${{ secrets.SNAPCRAFT_TOKEN }} diff --git a/.github/workflows/lint-sync.yml b/.github/workflows/lint-sync.yml deleted file mode 100644 index 43f380f..0000000 --- a/.github/workflows/lint-sync.yml +++ /dev/null @@ -1,13 +0,0 @@ -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 1e21125..e917f47 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,18 +1,20 @@ name: lint -on: - push: - pull_request: +on: [push, pull_request] jobs: golangci: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v2 - name: golangci-lint - uses: golangci/golangci-lint-action@v9.2.0 + uses: golangci/golangci-lint-action@v2 with: + # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. + version: v1.30 # Optional: golangci-lint command line arguments. args: --issues-exit-code=0 + # Optional: working directory, useful for monorepos + # working-directory: somedir # Optional: show only new issues if it's a pull request. The default value is `false`. only-new-issues: true diff --git a/.gitignore b/.gitignore index 8a5159a..d3003c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ glow dist/ .envrc -completions/ -manpages/ diff --git a/.golangci.yml b/.golangci.yml index 929cb0a..36f9966 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,44 +1,26 @@ -version: "2" run: tests: false -linters: - enable: - - bodyclose - - exhaustive - - goconst - - godot - - gomoddirectives - - goprintffuncname - - gosec - - misspell - - nakedret - - nestif - - nilerr - - noctx - - nolintlint - - prealloc - - revive - - rowserrcheck - - sqlclosecheck - - tparallel - - unconvert - - unparam - - whitespace - - wrapcheck - exclusions: - rules: - - text: '(slog|log)\.\w+' - linters: - - noctx - generated: lax - presets: - - common-false-positives + issues: max-issues-per-linter: 0 max-same-issues: 0 -formatters: + +linters: enable: - - gofumpt + - bodyclose + - dupl + - exportloopref + - goconst + - godot + - godox - goimports - exclusions: - generated: lax + - gomnd + - goprintffuncname + - gosec + - misspell + - prealloc + - rowserrcheck + - sqlclosecheck + - unconvert + - unparam + - whitespace diff --git a/.goreleaser.yml b/.goreleaser.yml index 0c539b7..82f666b 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,14 +1,78 @@ -# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json +env: + - GO111MODULE=on + - CGO_ENABLED=0 +before: + hooks: + - go mod download +builds: + - id: "glow" + 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 -version: 2 +archives: + - id: default + builds: + - glow + format_overrides: + - goos: windows + format: zip + replacements: + windows: Windows + darwin: Darwin + 386: i386 + amd64: x86_64 -includes: - - from_url: - url: charmbracelet/meta/main/goreleaser-glow.yaml +nfpms: + - builds: + - glow -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" + vendor: charmbracelet + homepage: "https://charm.sh/" + maintainer: "Christian Muehlhaeuser " + description: "Render markdown on the CLI" + license: MIT + formats: + - apk + - deb + - rpm + bindir: /usr/bin + +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 + +signs: + - artifacts: checksum + +checksum: + name_template: "checksums.txt" +snapshot: + name_template: "{{ .Tag }}-next" +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 8719b4e..0000000 --- a/Dockerfile +++ /dev/null @@ -1,3 +0,0 @@ -FROM gcr.io/distroless/static -COPY glow /usr/local/bin/glow -ENTRYPOINT [ "/usr/local/bin/glow" ] diff --git a/LICENSE b/LICENSE index 1e27a61..beda40d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-2024 Charmbracelet, Inc +Copyright (c) 2019 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 new file mode 100644 index 0000000..6d80864 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +# 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 cf3bb15..55a350d 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,15 @@ Render markdown on the CLI, with _pizzazz_!

- Glow Logo + Glow Logo Latest Release GoDoc Build Status - Go ReportCard + Go ReportCard

- Glow UI Demo + Glow UI Demo

## What is it? @@ -20,97 +20,45 @@ Glow is a terminal based markdown reader designed from the ground up to bring out the beauty—and power—of the CLI. Use it to discover markdown files, read documentation directly on the command -line. Glow will find local markdown files in subdirectories or a local +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. + ## Installation -### Package Manager +Use your fave package manager: ```bash # macOS or Linux brew install glow -``` -```bash # macOS (with MacPorts) sudo port install glow -``` -```bash # Arch Linux (btw) -pacman -S glow -``` +yay -S glow -```bash # Void Linux xbps-install -S glow -``` -```bash -# Nix shell -nix-shell -p glow --command glow -``` +# Nix +nix-env -iA nixpkgs.glow -```bash # FreeBSD pkg install glow -``` -```bash -# Solus -eopkg install glow -``` - -```bash -# Windows (with Chocolatey, Scoop, or Winget) -choco install glow +# Windows (with Scoop) scoop install glow -winget install charmbracelet.glow -``` - -```bash -# Android (with termux) -pkg install glow -``` - -```bash -# Ubuntu (Snapcraft) -sudo snap install glow -``` - -```bash -# Debian/Ubuntu -sudo mkdir -p /etc/apt/keyrings -curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg -echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list -sudo apt update && sudo apt install glow -``` - -```bash -# Fedora/RHEL -echo '[charm] -name=Charm -baseurl=https://repo.charm.sh/yum/ -enabled=1 -gpgcheck=1 -gpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo -sudo yum install glow ``` Or download a binary from the [releases][releases] page. MacOS, Linux, Windows, -FreeBSD and OpenBSD binaries are available, as well as Debian, RPM, and Alpine -packages. ARM builds are also available for macOS, Linux, FreeBSD and OpenBSD. +FreeBSD, and OpenBSD binaries are available, as well as Debian, RPM, and Alpine +packages. ARM builds are also available for 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+) +Or just build it yourself (requires Go 1.13+): ```bash git clone https://github.com/charmbracelet/glow.git @@ -120,10 +68,11 @@ go build [releases]: https://github.com/charmbracelet/glow/releases + ## The TUI Simply run `glow` without arguments to start the textual user interface and -browse local. Glow will find local markdown files in the +browse local and stashed markdown. 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. @@ -131,6 +80,25 @@ 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`. + +You can also stash 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. + +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 @@ -141,7 +109,7 @@ document use a markdown source as the primary argument: glow README.md # Read from stdin -echo "[Glow](https://github.com/charmbracelet/glow)" | glow - +glow - # Fetch README from GitHub / GitLab glow github.com/charmbracelet/glow @@ -150,6 +118,18 @@ 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: @@ -201,42 +181,32 @@ Here's an example config: ```yaml # style name or JSON path (default "auto") style: "light" -# mouse wheel support (TUI-mode only) -mouse: true -# use pager to display markdown -pager: true -# at which column should we word wrap? +# show local files only; no network (TUI-mode only) +local: true +# word-wrap at width width: 80 -# show all files, including hidden and ignored. -all: false -# show line numbers (TUI-mode only) -showLineNumbers: false -# preserve newlines in the output -preserveNewLines: false ``` -## Contributing +## 🔒 Encryption: How It Works -See [contributing][contribute]. +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. -[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) +[charmlib]: https://github.com/charmbracelet/charm ## 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! -Charm热爱开源 • Charm loves open source diff --git a/Taskfile.yaml b/Taskfile.yaml deleted file mode 100644 index 6d8c844..0000000 --- a/Taskfile.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# 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 390a1db..8d73b1f 100644 --- a/config_cmd.go +++ b/config_cmd.go @@ -3,86 +3,78 @@ package main import ( "errors" "fmt" - "io/fs" "os" + "os/exec" "path" - "path/filepath" - "github.com/charmbracelet/x/editor" + "github.com/charmbracelet/charm/ui/common" + gap "github.com/muesli/go-app-paths" "github.com/spf13/cobra" - "github.com/spf13/viper" ) const defaultConfig = `# style name or JSON path (default "auto") style: "auto" -# mouse support (TUI-mode only) -mouse: false -# use pager to display markdown -pager: false +# show local files only; no network (TUI-mode only) +local: false # word-wrap at width -width: 80 -# show all files, including hidden and ignored. -all: false -` +width: 80` var configCmd = &cobra.Command{ Use: "config", Hidden: false, Short: "Edit the glow config file", - Long: paragraph(fmt.Sprintf("\n%s the glow config file. We’ll use EDITOR to determine which editor to use. If the config file doesn't exist, it will be created.", keyword("Edit"))), - Example: paragraph("glow config\nglow config --config path/to/config.yml"), + 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"), Args: cobra.NoArgs, - RunE: func(*cobra.Command, []string) error { - if err := ensureConfigFile(); err != nil { + 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 return err } - c, err := editor.Cmd("Glow", configFile) - if err != nil { - return fmt.Errorf("unable to set config file: %w", err) - } + c := exec.Command(editor, configFile) c.Stdin = os.Stdin c.Stdout = os.Stdout c.Stderr = os.Stderr if err := c.Run(); err != nil { - return fmt.Errorf("unable to run command: %w", err) + return 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 new file mode 100644 index 0000000..6e3dffa --- /dev/null +++ b/formatting.go @@ -0,0 +1,15 @@ +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 fe862e3..3b944cc 100644 --- a/github.go +++ b/github.go @@ -1,55 +1,45 @@ package main import ( - "encoding/json" "errors" - "fmt" - "io" "net/http" "net/url" "strings" ) -// findGitHubREADME tries to find the correct README filename in a repository using GitHub API. -func findGitHubREADME(u *url.URL) (*source, error) { - owner, repo, ok := strings.Cut(strings.TrimPrefix(u.Path, "/"), "/") - if !ok { - return nil, fmt.Errorf("invalid url: %s", u.String()) +// 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 } - 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 + u, err := url.ParseRequestURI(s) if err != nil { - return nil, fmt.Errorf("unable to get url: %w", err) + return "", false } - body, err := io.ReadAll(res.Body) + 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) if err != nil { - return nil, fmt.Errorf("unable to read http response body: %w", err) + return nil, err } + u.Host = "raw.githubusercontent.com" - var result readme - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("unable to parse json: %w", err) - } + for _, r := range readmeNames { + v := u + v.Path += "/master/" + r - if res.StatusCode == http.StatusOK { - //nolint:bodyclose - // it is closed on the caller - resp, err := http.Get(result.DownloadURL) //nolint: noctx + resp, err := http.Get(v.String()) if err != nil { - return nil, fmt.Errorf("unable to get url: %w", err) + return nil, err } if resp.StatusCode == http.StatusOK { - return &source{resp.Body, result.DownloadURL}, nil + return &source{resp.Body, v.String()}, nil } } diff --git a/gitlab.go b/gitlab.go index 68256be..7bebc93 100644 --- a/gitlab.go +++ b/gitlab.go @@ -1,59 +1,44 @@ package main import ( - "encoding/json" "errors" - "fmt" - "io" "net/http" "net/url" "strings" ) -// findGitLabREADME tries to find the correct README filename in a repository using GitLab API. -func findGitLabREADME(u *url.URL) (*source, error) { - owner, repo, ok := strings.Cut(strings.TrimPrefix(u.Path, "/"), "/") - if !ok { - return nil, fmt.Errorf("invalid url: %s", u.String()) +// 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 } - 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 + u, err := url.ParseRequestURI(s) if err != nil { - return nil, fmt.Errorf("unable to get url: %w", err) + return "", false } - body, err := io.ReadAll(res.Body) + 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) if err != nil { - return nil, fmt.Errorf("unable to read http response body: %w", err) + return nil, err } - var result readme - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("unable to parse json: %w", err) - } + for _, r := range readmeNames { + v := u + v.Path += "/raw/master/" + r - 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 + resp, err := http.Get(v.String()) if err != nil { - return nil, fmt.Errorf("unable to get url: %w", err) + return nil, err } if resp.StatusCode == http.StatusOK { - return &source{resp.Body, readmeRawURL}, nil + return &source{resp.Body, v.String()}, nil } } diff --git a/glow_test.go b/glow_test.go index 8743be2..ed4f0cd 100644 --- a/glow_test.go +++ b/glow_test.go @@ -1,9 +1,31 @@ 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 6e05b0d..a2e7086 100644 --- a/go.mod +++ b/go.mod @@ -1,75 +1,28 @@ -module github.com/charmbracelet/glow/v2 +module github.com/charmbracelet/glow -go 1.25.12 - -toolchain go1.26.5 +go 1.13 require ( - github.com/atotto/clipboard v0.1.4 - github.com/caarlos0/env/v11 v11.3.1 - github.com/charmbracelet/bubbles v0.21.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/glamour v0.10.0 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/charmbracelet/log v0.4.2 - github.com/charmbracelet/x/editor v0.1.0 - github.com/dustin/go-humanize v1.0.1 - github.com/fsnotify/fsnotify v1.9.0 - github.com/mattn/go-runewidth v0.0.19 - github.com/mitchellh/go-homedir v1.1.0 - github.com/muesli/gitcha v0.3.0 - github.com/muesli/go-app-paths v0.2.2 - github.com/muesli/mango-cobra v1.3.0 - github.com/muesli/reflow v0.3.0 - github.com/muesli/roff v0.1.0 - github.com/muesli/termenv v0.16.0 - github.com/sahilm/fuzzy v0.1.1 - github.com/spf13/cobra v1.10.2 - github.com/spf13/viper v1.21.0 - golang.org/x/sys v0.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 + github.com/alecthomas/chroma v0.8.0 // indirect + github.com/charmbracelet/bubbles v0.7.5 + github.com/charmbracelet/bubbletea v0.12.2 + github.com/charmbracelet/charm v0.8.2 + github.com/charmbracelet/glamour v0.2.1-0.20200829234023-6c0e29c4dae5 + 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.9 + github.com/meowgorithm/babyenv v1.3.0 + github.com/microcosm-cc/bluemonday v1.0.4 // indirect + github.com/muesli/gitcha v0.1.2-0.20200908172931-5aa4fdccf2f6 + github.com/muesli/go-app-paths v0.2.1 + github.com/muesli/reflow v0.2.0 + github.com/muesli/termenv v0.7.4 + github.com/sahilm/fuzzy v0.1.0 + github.com/spf13/cobra v1.1.1 + github.com/spf13/viper v1.7.0 + golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 + golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc // indirect + golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a + golang.org/x/text v0.3.2 ) diff --git a/go.sum b/go.sum index 0ccd7cd..ed099fb 100644 --- a/go.sum +++ b/go.sum @@ -1,177 +1,435 @@ -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= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +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.7.3/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM= +github.com/alecthomas/chroma v0.8.0 h1:HS+HE97sgcqjQGu5uVr8jIE55Mmh5UeQ7kckAhHg2pY= +github.com/alecthomas/chroma v0.8.0/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/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY= +github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= -github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= -github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= -github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/editor v0.1.0 h1:p69/dpvlwRTs9uYiPeAWruwsHqTFzHhTvQOd/WVSX98= -github.com/charmbracelet/x/editor v0.1.0/go.mod h1:oivrEbcP/AYt/Hpvk5pwDXXrQ933gQS6UzL6fxqAGSA= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= -github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/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/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +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.6.0/go.mod h1:MxySU+YRGbAhZQJavZlW2os+fIeOW69MI3iXqA+2/WA= +github.com/charmbracelet/bubbles v0.7.5 h1:N6TiahuRt2iGNE5gYxKBQ5/C6Lc8xxSp5LY9QJ09mS4= +github.com/charmbracelet/bubbles v0.7.5/go.mod h1:IRTORFvhEI6OUH7WhN2Ks8Z8miNGimk1BE6cmHijOkM= +github.com/charmbracelet/bubbletea v0.10.3/go.mod h1:fB1bVmlaXBYYv4G0jtuGSP/m8V2sMM97pq7QqQnubWI= +github.com/charmbracelet/bubbletea v0.10.5/go.mod h1:Nay5oWkkSZvc6E/be+W3nDFXAsVytNUNKV9jbXcuae0= +github.com/charmbracelet/bubbletea v0.12.2 h1:y9Yo2Pv8tcm3mAJsWONGsmHhzrbNxJVxpVtemikxE9A= +github.com/charmbracelet/bubbletea v0.12.2/go.mod h1:3gZkYELUOiEUOp0bTInkxguucy/xRbGSOcbMs1geLxg= +github.com/charmbracelet/charm v0.8.2 h1:sYJhP7YopJUvjWE8VKlNrcAHAaRZsEgKn8z2FoIhHOI= +github.com/charmbracelet/charm v0.8.2/go.mod h1:xi3evUxj8hw+dMApaXRczb2SiEzrn8a3NYobj0off+c= +github.com/charmbracelet/glamour v0.2.1-0.20200829234023-6c0e29c4dae5 h1:XgXVfMdJTNTq/ajMvwiB1OW3Tg/TXHtM3tYr/vYF76w= +github.com/charmbracelet/glamour v0.2.1-0.20200829234023-6c0e29c4dae5/go.mod h1:CGH6KT1ovzvGpaQFxkm9ulKx6BlfV4dThCDaOMi54qI= +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/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-semver v0.3.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/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= +github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= -github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= -github.com/go-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/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/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +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-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +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/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +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/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +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/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +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/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +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/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +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/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +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/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.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= -github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +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 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/meowgorithm/babyenv v1.2.1/go.mod h1:lwNX+J6AGBFqNrMZ2PTLkM6SO+W4X8DOg9zBDO4j3Ig= +github.com/meowgorithm/babyenv v1.3.0 h1:klb7ugoZt0/Xlqkd5kLxM7eLZX8waiwxHZWW5nfEZ0Q= +github.com/meowgorithm/babyenv v1.3.0/go.mod h1:lwNX+J6AGBFqNrMZ2PTLkM6SO+W4X8DOg9zBDO4j3Ig= +github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= +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/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +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/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/gitcha v0.3.0 h1:+PJkVKrDXVB0VgRn/yVx2CqSVSDGMSepzvohsCrPYtQ= -github.com/muesli/gitcha v0.3.0/go.mod h1:vX3jFL+XcEUq1uY74RCjLSZfAV+ZuvLg70/NGPdXn84= -github.com/muesli/go-app-paths v0.2.2 h1:NqG4EEZwNIhBq/pREgfBmgDmt3h1Smr1MjZiXbpZUnI= -github.com/muesli/go-app-paths v0.2.2/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho= -github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= -github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= -github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec= -github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E= -github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= -github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= -github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/muesli/gitcha v0.1.2-0.20200908172931-5aa4fdccf2f6 h1:CUOAyrZoFRlGHfl6vljna20J++p8sPRlw9KF7fySgpA= +github.com/muesli/gitcha v0.1.2-0.20200908172931-5aa4fdccf2f6/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 h1:2o0UBJPHHH4fa2GCXU4Rg4DwOtWPMekCeyc5EWbAQp0= +github.com/muesli/reflow v0.2.0/go.mod h1:qT22vjVmM9MIUeLgsVYe/Ye7eZlbv9dZjL3dVhUqLX8= +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.0/go.mod h1:SohX91w6swWA4AYU+QmPx+aSgXhWO0juiyID9UZmbpA= +github.com/muesli/termenv v0.7.2/go.mod h1:ct2L5N2lmix82RaY3bMWwVu/jUFc9Ule0KGDCiKYPh8= +github.com/muesli/termenv v0.7.4 h1:/pBqvU5CpkY53tU0vVn+xgs2ZTX63aH5nY+SSps5Xa8= +github.com/muesli/termenv v0.7.4/go.mod h1:pZ7qY9l3F7e5xsAOS0zCew2tME+p7bWeBkotCEcIIcc= +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.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8= +github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +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/pkg/term v0.0.0-20200520122047-c3ffed290a03/go.mod h1:Z9+Ul5bCbBKnbCvdOWbLqTHhJiYV414CURZJba6L8qA= +github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +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/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 h1:G04eS0JkAIVZfaJLjla9dNxkJCPiKIGZlw9AfOhzOD0= github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94/go.mod h1:b18R55ulyQ/h3RaWyloPyER7fWQVZvimKKhnI5OfrJQ= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +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/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +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.1.1 h1:KfztREH0tPxJJ+geloSLaAkaPkr4ki2Er5quFV1TDo4= +github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= +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/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/spf13/viper v1.7.0 h1:xVKxvI7ouOI5I+U9s2eeiUfMaWBVoXA3AWskkrqK0VM= +github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= 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/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= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +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 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +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-20181029021203-45a5f77698d3/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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/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 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E= +golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/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-20181201002055-351d144fa1fc/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-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/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-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/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-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +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/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +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/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +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-20181026203630-95b1ffbd15a5/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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/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-20200819171115-d785dc25833f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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 h1:e3IU37lwO4aq3uoRKINC7JikojFmE5gO7xhfxs8VC34= +golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/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-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +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-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +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.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= -mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/log.go b/log.go deleted file mode 100644 index a864a71..0000000 --- a/log.go +++ /dev/null @@ -1,40 +0,0 @@ -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 b31ca15..1584de0 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,10 @@ -// Package main provides the entry point for the Glow CLI application. package main import ( "errors" "fmt" "io" - "io/fs" + "io/ioutil" "net/http" "net/url" "os" @@ -13,55 +12,40 @@ import ( "path/filepath" "strings" - "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" + "github.com/meowgorithm/babyenv" gap "github.com/muesli/go-app-paths" "github.com/spf13/cobra" "github.com/spf13/viper" - "golang.org/x/term" + "golang.org/x/crypto/ssh/terminal" + + 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 as provided by goreleaser. - Version = "" - // CommitSHA as provided by goreleaser. + Version = "" CommitSHA = "" - readmeNames = []string{"README.md", "README", "Readme.md", "Readme", "readme.md", "readme"} - configFile string - pager bool - tui bool - style string - width uint - showAllFiles bool - showLineNumbers bool - preserveNewLines bool - mouse bool + readmeNames = []string{"README.md", "README"} + configFile string + pager bool + style string + width uint + showAllFiles bool + localOnly bool + mouse bool rootCmd = &cobra.Command{ - Use: "glow [SOURCE|DIR]", - Short: "Render markdown on the CLI, with pizzazz!", - Long: paragraph( - fmt.Sprintf("\nRender markdown on the CLI, %s!", keyword("with pizzazz")), - ), + Use: "glow SOURCE", + Short: "Render markdown on the CLI, with pizzazz!", + Long: formatBlock(fmt.Sprintf("\nRender markdown on the CLI, %s!", common.Keyword("with pizzazz"))), SilenceErrors: false, - SilenceUsage: true, + SilenceUsage: false, TraverseChildren: true, - Args: cobra.MaximumNArgs(1), - ValidArgsFunction: func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { - return nil, cobra.ShellCompDirectiveDefault - }, - PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { - return validateOptions(cmd) - }, - RunE: execute, + RunE: execute, } ) @@ -79,22 +63,31 @@ func sourceFromArg(arg string) (*source, error) { } // a GitHub or GitLab URL (even without the protocol): - src, err := readmeURL(arg) - if src != nil && err == nil { - // if there's an error, try next methods... + 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 + } return src, nil } // HTTP(S) URLs: - if u, err := url.ParseRequestURI(arg); err == nil && strings.Contains(arg, "://") { //nolint:nestif + if u, err := url.ParseRequestURI(arg); err == nil && strings.Contains(arg, "://") { if u.Scheme != "" { if u.Scheme != "http" && u.Scheme != "https" { return nil, fmt.Errorf("%s is not a supported protocol", u.Scheme) } - // consumer of the source is responsible for closing the ReadCloser. - resp, err := http.Get(u.String()) //nolint: noctx,bodyclose + + resp, err := http.Get(u.String()) if err != nil { - return nil, fmt.Errorf("unable to get url: %w", err) + return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP status %d", resp.StatusCode) @@ -109,12 +102,9 @@ func sourceFromArg(arg string) (*source, error) { arg = "." } st, err := os.Stat(arg) - if err == nil && st.IsDir() { //nolint:nestif + if err == nil && st.IsDir() { var src *source - _ = filepath.Walk(arg, func(path string, _ os.FileInfo, err error) error { - if err != nil { - return err - } + _ = filepath.Walk(arg, func(path string, info os.FileInfo, err error) error { for _, v := range readmeNames { if strings.EqualFold(filepath.Base(path), v) { r, err := os.Open(path) @@ -139,52 +129,20 @@ func sourceFromArg(arg string) (*source, error) { return nil, errors.New("missing markdown source") } + // a file: r, err := os.Open(arg) - if err != nil { - return nil, fmt.Errorf("unable to open file: %w", err) - } - u, err := filepath.Abs(arg) - if err != nil { - return nil, fmt.Errorf("unable to get absolute path: %w", err) - } - return &source{r, u}, nil + u, _ := filepath.Abs(arg) + return &source{r, u}, err } -// validateStyle checks if the style is a default style, if not, checks that -// the custom style exists. -func validateStyle(style string) error { - if style != "auto" && styles.DefaultStyles[style] == nil { - style = utils.ExpandPath(style) - if _, err := os.Stat(style); errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("specified style does not exist: %s", style) - } else if err != nil { - return fmt.Errorf("unable to stat file: %w", err) - } - } - return nil -} - -func validateOptions(cmd *cobra.Command) error { +func validateOptions(cmd *cobra.Command) { // grab config values from Viper - width = viper.GetUint("width") - mouse = viper.GetBool("mouse") - pager = viper.GetBool("pager") - tui = viper.GetBool("tui") - showAllFiles = viper.GetBool("all") - preserveNewLines = viper.GetBool("preserveNewLines") - showLineNumbers = viper.GetBool("showLineNumbers") - - if pager && tui { - return errors.New("cannot use both pager and tui") - } - - // validate the glamour style style = viper.GetString("style") - if err := validateStyle(style); err != nil { - return err - } + width = viper.GetUint("width") + localOnly = viper.GetBool("local") + mouse = viper.GetBool("mouse") - isTerminal := term.IsTerminal(int(os.Stdout.Fd())) + isTerminal := terminal.IsTerminal(int(os.Stdout.Fd())) // We want to use a special no-TTY style, when stdout is not a terminal // and there was no specific style passed by arg if !isTerminal && !cmd.Flags().Changed("style") { @@ -192,90 +150,52 @@ func validateOptions(cmd *cobra.Command) error { } // Detect terminal width - if !cmd.Flags().Changed("width") { //nolint:nestif - if isTerminal && width == 0 { - w, _, err := term.GetSize(int(os.Stdout.Fd())) - if err == nil { - width = uint(w) //nolint:gosec - } - - if width > 120 { - width = 120 - } + if isTerminal && width == 0 && !cmd.Flags().Changed("width") { + w, _, err := terminal.GetSize(int(os.Stdout.Fd())) + if err == nil { + width = uint(w) } - if width == 0 { - width = 80 + + if width > 120 { + width = 120 } } - return nil -} - -func stdinIsPipe() (bool, error) { - stat, err := os.Stdin.Stat() - if err != nil { - return false, fmt.Errorf("unable to open file: %w", err) + if width == 0 { + width = 80 } - if stat.Mode()&os.ModeCharDevice == 0 || stat.Size() > 0 { - return true, nil - } - return false, nil } func execute(cmd *cobra.Command, args []string) error { - // if stdin is a pipe then use stdin for input. note that you can also - // explicitly use a - to read from stdin. - if yes, err := stdinIsPipe(); err != nil { - return err - } else if yes { - src := &source{reader: os.Stdin} - defer src.reader.Close() //nolint:errcheck - return executeCLI(cmd, src, os.Stdout) + initConfig() + validateOptions(cmd) + + if len(args) == 0 { + return executeArg(cmd, "", os.Stdout) } - switch len(args) { - // TUI running on cwd - case 0: - return runTUI("", "") - - // TUI with possible dir argument - case 1: - // Validate that the argument is a directory. If it's not treat it as - // an argument to the non-TUI version of Glow (via fallthrough). - info, err := os.Stat(args[0]) - if err == nil && info.IsDir() { - p, err := filepath.Abs(args[0]) - if err == nil { - return runTUI(p, "") - } - } - fallthrough - - // CLI - default: - for _, arg := range args { - if err := executeArg(cmd, arg, os.Stdout); err != nil { - return err - } + for _, arg := range args { + if err := executeArg(cmd, arg, os.Stdout); err != nil { + return err } } - return nil } func executeArg(cmd *cobra.Command, arg string, w io.Writer) error { + // Only run TUI if there are no arguments (excluding flags) + if arg == "" { + return runTUI(false) + } + // create an io.Reader from the markdown source in cli-args src, err := sourceFromArg(arg) if err != nil { return err } - defer src.reader.Close() //nolint:errcheck - return executeCLI(cmd, src, w) -} - -func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error { - b, err := io.ReadAll(src.reader) + defer src.reader.Close() + b, err := ioutil.ReadAll(src.reader) if err != nil { - return fmt.Errorf("unable to read from reader: %w", err) + return err } b = utils.RemoveFrontmatter(b) @@ -288,106 +208,108 @@ func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error { baseURL = u.String() + "/" } - isCode := !utils.IsMarkdownFile(src.URL) - // initialize glamour + var gs glamour.TermRendererOption + if style == "auto" { + gs = glamour.WithEnvironmentConfig() + } else { + gs = glamour.WithStylePath(style) + } + r, err := glamour.NewTermRenderer( - glamour.WithColorProfile(lipgloss.ColorProfile()), - utils.GlamourStyle(style, isCode), - glamour.WithWordWrap(int(width)), //nolint:gosec + gs, + glamour.WithWordWrap(int(width)), glamour.WithBaseURL(baseURL), - glamour.WithPreservedNewLines(), ) if err != nil { - return fmt.Errorf("unable to create renderer: %w", err) + return err } - content := string(b) - ext := filepath.Ext(src.URL) - if isCode { - content = utils.WrapCodeBlock(string(b), ext) - } - - out, err := r.Render(content) + out, err := r.RenderBytes(b) if err != nil { - return fmt.Errorf("unable to render markdown: %w", err) + 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" + } } // display - switch { - case pager || cmd.Flags().Changed("pager"): - pagerCmd := os.Getenv("PAGER") - if pagerCmd == "" { - pagerCmd = "less -r" + if cmd.Flags().Changed("pager") { + pager := os.Getenv("PAGER") + if pager == "" { + pager = "less -r" } - 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) + pa := strings.Split(pager, " ") + c := exec.Command(pa[0], pa[1:]...) + c.Stdin = strings.NewReader(content) c.Stdout = os.Stdout - if err := c.Run(); err != nil { - return fmt.Errorf("unable to run command: %w", err) - } - return nil - case tui || cmd.Flags().Changed("tui"): - path := "" - if !isURL(src.URL) { - path = src.URL - } - return runTUI(path, content) - default: - if _, err = fmt.Fprint(w, out); err != nil { - return fmt.Errorf("unable to write to writer: %w", err) - } - return nil + return c.Run() } + + fmt.Fprint(w, content) + return nil } -func runTUI(path string, content string) error { +func runTUI(stashedOnly bool) error { // Read environment to get debugging stuff - cfg, err := env.ParseAs[ui.Config]() - if err != nil { + var cfg ui.Config + if err := babyenv.Parse(&cfg); err != nil { return fmt.Errorf("error parsing config: %v", err) } - // use style set in env, or auto if unset - if err := validateStyle(cfg.GlamourStyle); err != nil { - cfg.GlamourStyle = style + // Log to file, if set + if cfg.Logfile != "" { + f, err := tea.LogToFile(cfg.Logfile, "glow") + if err != nil { + return err + } + defer f.Close() } - cfg.Path = path cfg.ShowAllFiles = showAllFiles - cfg.ShowLineNumbers = showLineNumbers cfg.GlamourMaxWidth = width - cfg.EnableMouse = mouse - cfg.PreserveNewLines = preserveNewLines + cfg.GlamourStyle = style + + if stashedOnly { + cfg.DocumentTypes = ui.StashedDocuments | ui.NewsDocuments + } else if localOnly { + cfg.DocumentTypes = ui.LocalDocuments + } // Run Bubble Tea program - if _, err := ui.NewProgram(cfg, content).Run(); err != nil { - return fmt.Errorf("unable to run tui program: %w", err) + p := ui.NewProgram(cfg) + p.EnterAltScreen() + defer p.ExitAltScreen() + if mouse { + p.EnableMouseCellMotion() + defer p.DisableMouseCellMotion() + } + if err := p.Start(); err != nil { + return err } + // Exit message + fmt.Printf("\n Thanks for using Glow!\n\n") return nil } func main() { - closer, err := setupLog() - if err != nil { - fmt.Println(err) - os.Exit(1) - } if err := rootCmd.Execute(); err != nil { - _ = closer() os.Exit(1) } - _ = closer() } func init() { - tryLoadConfigFromDefaultPlaces() if len(CommitSHA) >= 7 { vt := rootCmd.VersionTemplate() rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n") @@ -396,78 +318,63 @@ func init() { Version = "unknown (built from source)" } rootCmd.Version = Version - rootCmd.InitDefaultCompletionCmd() + + scope := gap.NewScope(gap.User, "glow") + defaultConfigFile, _ := scope.ConfigPath("glow.yml") // "Glow Classic" cli arguments - rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default %s)", viper.GetViper().ConfigFileUsed())) + rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default %s)", defaultConfigFile)) rootCmd.Flags().BoolVarP(&pager, "pager", "p", false, "display with pager") - rootCmd.Flags().BoolVarP(&tui, "tui", "t", false, "display with tui") - rootCmd.Flags().StringVarP(&style, "style", "s", styles.AutoStyle, "style name or JSON path") - rootCmd.Flags().UintVarP(&width, "width", "w", 0, "word-wrap at width (set to 0 to disable)") + rootCmd.Flags().StringVarP(&style, "style", "s", "auto", "style name or JSON path") + rootCmd.Flags().UintVarP(&width, "width", "w", 0, "word-wrap at width") rootCmd.Flags().BoolVarP(&showAllFiles, "all", "a", false, "show system files and directories (TUI-mode only)") - rootCmd.Flags().BoolVarP(&showLineNumbers, "line-numbers", "l", false, "show line numbers (TUI-mode only)") - rootCmd.Flags().BoolVarP(&preserveNewLines, "preserve-new-lines", "n", false, "preserve newlines in the output") + rootCmd.Flags().BoolVarP(&localOnly, "local", "l", false, "show local files only; no network (TUI-mode only)") 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("debug", rootCmd.Flags().Lookup("debug")) + _ = viper.BindPFlag("local", rootCmd.Flags().Lookup("local")) _ = viper.BindPFlag("mouse", rootCmd.Flags().Lookup("mouse")) - _ = viper.BindPFlag("preserveNewLines", rootCmd.Flags().Lookup("preserve-new-lines")) - _ = viper.BindPFlag("showLineNumbers", rootCmd.Flags().Lookup("line-numbers")) - _ = viper.BindPFlag("all", rootCmd.Flags().Lookup("all")) - - viper.SetDefault("style", styles.AutoStyle) + viper.SetDefault("style", "auto") viper.SetDefault("width", 0) - viper.SetDefault("all", true) + viper.SetDefault("local", "false") - rootCmd.AddCommand(configCmd, manCmd) + // Stash + stashCmd.PersistentFlags().StringVarP(&memo, "memo", "m", "", "memo/note for stashing") + rootCmd.AddCommand(stashCmd) + + rootCmd.AddCommand(configCmd) } -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) +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") } - if c := os.Getenv("XDG_CONFIG_HOME"); c != "" { - dirs = append([]string{filepath.Join(c, "glow")}, dirs...) - } - - if c := os.Getenv("GLOW_CONFIG_HOME"); c != "" { - dirs = append([]string{c}, dirs...) - } - - for _, v := range dirs { - viper.AddConfigPath(v) - } - - viper.SetConfigName("glow") - viper.SetConfigType("yaml") viper.SetEnvPrefix("glow") viper.AutomaticEnv() if err := viper.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - log.Warn("Could not parse configuration file", "err", err) + fmt.Println("Error parsing config:", err) + os.Exit(1) } } - 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) - } + // fmt.Println("Using config file:", viper.ConfigFileUsed()) } diff --git a/man_cmd.go b/man_cmd.go deleted file mode 100644 index a179ef7..0000000 --- a/man_cmd.go +++ /dev/null @@ -1,29 +0,0 @@ -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 new file mode 100644 index 0000000..e1d69a7 --- /dev/null +++ b/stash_cmd.go @@ -0,0 +1,83 @@ +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 deleted file mode 100644 index b688a4e..0000000 --- a/style.go +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 001d5b8..0000000 --- a/ui/config.go +++ /dev/null @@ -1,20 +0,0 @@ -package ui - -// Config contains TUI-specific configuration. -type Config struct { - ShowAllFiles bool - ShowLineNumbers bool - Gopath string `env:"GOPATH"` - HomeDir string `env:"HOME"` - GlamourMaxWidth uint - GlamourStyle string `env:"GLAMOUR_STYLE"` - EnableMouse bool - PreserveNewLines bool - - // Working directory or file path - Path string - - // For debugging the UI - HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" envDefault:"true"` - GlamourEnabled bool `env:"GLOW_ENABLE_GLAMOUR" envDefault:"true"` -} diff --git a/ui/consts_unix.go b/ui/consts_unix.go new file mode 100644 index 0000000..fd13ad3 --- /dev/null +++ b/ui/consts_unix.go @@ -0,0 +1,7 @@ +// +build !windows + +package ui + +const ( + pagerStashIcon = "🔒" +) diff --git a/ui/consts_windows.go b/ui/consts_windows.go new file mode 100644 index 0000000..25f9851 --- /dev/null +++ b/ui/consts_windows.go @@ -0,0 +1,7 @@ +// +build windows + +package ui + +const ( + pagerStashIcon = "•" +) diff --git a/ui/editor.go b/ui/editor.go deleted file mode 100644 index a567c59..0000000 --- a/ui/editor.go +++ /dev/null @@ -1,19 +0,0 @@ -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 b69437a..6a0b4ef 100644 --- a/ui/ignore_darwin.go +++ b/ui/ignore_darwin.go @@ -1,14 +1,13 @@ -//go:build darwin // +build darwin package ui import "path/filepath" -func ignorePatterns(m commonModel) []string { +func ignorePatterns(m model) []string { return []string{ - filepath.Join(m.cfg.HomeDir, "Library"), - m.cfg.Gopath, + filepath.Join(m.general.cfg.HomeDir, "Library"), + m.general.cfg.Gopath, "node_modules", ".*", } diff --git a/ui/ignore_general.go b/ui/ignore_general.go index f4dc8be..838cea7 100644 --- a/ui/ignore_general.go +++ b/ui/ignore_general.go @@ -1,11 +1,10 @@ -//go:build !darwin // +build !darwin package ui -func ignorePatterns(m commonModel) []string { +func ignorePatterns(m model) []string { return []string{ - m.cfg.Gopath, + m.general.cfg.Gopath, "node_modules", ".*", } diff --git a/ui/keys.go b/ui/keys.go deleted file mode 100644 index 8e13f95..0000000 --- a/ui/keys.go +++ /dev/null @@ -1,6 +0,0 @@ -package ui - -const ( - keyEnter = "enter" - keyEsc = "esc" -) diff --git a/ui/markdown.go b/ui/markdown.go index c67b1dc..8e249c9 100644 --- a/ui/markdown.go +++ b/ui/markdown.go @@ -1,19 +1,27 @@ package ui import ( - "fmt" - "math" - "time" - "unicode" + "log" + "strings" - "github.com/charmbracelet/log" - "github.com/dustin/go-humanize" - "golang.org/x/text/runes" - "golang.org/x/text/transform" - "golang.org/x/text/unicode/norm" + "github.com/charmbracelet/charm" ) +// markdownType allows us to differentiate between the types of markdown +// documents we're dealing with. +type markdownType int + +const ( + stashedMarkdown markdownType = iota + newsMarkdown + localMarkdown + convertedMarkdown // used to be local, now its stashed +) + +// markdown wraps charm.Markdown. type markdown struct { + markdownType markdownType + // Full path of a local markdown file. Only relevant to local documents and // those that have been stashed in this session. localPath string @@ -23,66 +31,60 @@ type markdown struct { // field is ephemeral, and should only be referenced during filtering. filterValue string - Body string - Note string - Modtime time.Time + charm.Markdown } -// Generate the value we're doing to filter against. func (m *markdown) buildFilterValue() { note, err := normalize(m.Note) if err != nil { - log.Error("error normalizing", "note", m.Note, "error", err) + if debug { + log.Printf("error normalizing '%s': %v", m.Note, err) + } m.filterValue = m.Note } + if m.markdownType == newsMarkdown { + m.filterValue = "News: " + note + return + } + m.filterValue = note } -func (m markdown) relativeTime() string { - return relativeTime(m.Modtime) +// sortAsLocal returns whether or not this markdown should be sorted as though +// it's a local markdown document. +func (m markdown) sortAsLocal() bool { + return m.markdownType == localMarkdown || m.markdownType == convertedMarkdown } -// Normalize text to aid in the filtering process. In particular, we remove -// diacritics, "ö" becomes "o". Note that Mn is the unicode key for nonspacing -// marks. -func normalize(in string) (string, error) { - t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) - out, _, err := transform.String(t, in) - if err != nil { - return "", fmt.Errorf("error normalizing: %w", err) +// 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].sortAsLocal() + jIsLocal := m[j].sortAsLocal() + + // Local files (and files that used to be local) come first + if iIsLocal && !jIsLocal { + return true } - return out, nil -} - -// Return the time in a human-readable format relative to the current time. -func relativeTime(then time.Time) string { - now := time.Now() - if ago := now.Sub(then); ago < time.Minute { - return "just now" - } else if ago < humanize.Week { - return humanize.CustomRelTime(then, now, "ago", "from now", magnitudes) + if !iIsLocal && jIsLocal { + return false } - return then.Format("02 Jan 2006 15:04 MST") -} -// Magnitudes for relative time. -var magnitudes = []humanize.RelTimeMagnitude{ - {D: time.Second, Format: "now", DivBy: time.Second}, - {D: 2 * time.Second, Format: "1 second %s", DivBy: 1}, - {D: time.Minute, Format: "%d seconds %s", DivBy: time.Second}, - {D: 2 * time.Minute, Format: "1 minute %s", DivBy: 1}, - {D: time.Hour, Format: "%d minutes %s", DivBy: time.Minute}, - {D: 2 * time.Hour, Format: "1 hour %s", DivBy: 1}, - {D: humanize.Day, Format: "%d hours %s", DivBy: time.Hour}, - {D: 2 * humanize.Day, Format: "1 day %s", DivBy: 1}, - {D: humanize.Week, Format: "%d days %s", DivBy: humanize.Day}, - {D: 2 * humanize.Week, Format: "1 week %s", DivBy: 1}, - {D: humanize.Month, Format: "%d weeks %s", DivBy: humanize.Week}, - {D: 2 * humanize.Month, Format: "1 month %s", DivBy: 1}, - {D: humanize.Year, Format: "%d months %s", DivBy: humanize.Month}, - {D: 18 * humanize.Month, Format: "1 year %s", DivBy: 1}, - {D: 2 * humanize.Year, Format: "2 years %s", DivBy: 1}, - {D: humanize.LongTime, Format: "%d years %s", DivBy: humanize.Year}, - {D: math.MaxInt64, Format: "a long while %s", DivBy: 1}, + // 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 timestamps also match, sort by ID. + return m[i].ID > m[j].ID } diff --git a/ui/pager.go b/ui/pager.go index 5c522e7..bef151f 100644 --- a/ui/pager.go +++ b/ui/pager.go @@ -2,98 +2,75 @@ package ui import ( "fmt" + "log" "math" - "path/filepath" "strings" "time" - "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/charm" + "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" - "github.com/muesli/termenv" + te "github.com/muesli/termenv" ) -const ( - statusBarHeight = 1 - lineNumberWidth = 4 -) +const statusBarHeight = 1 var ( pagerHelpHeight int - mintGreen = lipgloss.AdaptiveColor{Light: "#89F0CB", Dark: "#89F0CB"} - darkGreen = lipgloss.AdaptiveColor{Light: "#1C8760", Dark: "#1C8760"} + mintGreen = common.NewColorPair("#89F0CB", "#89F0CB") + darkGreen = common.NewColorPair("#1C8760", "#1C8760") - lineNumberFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"} + noteHeading = te.String(" Set Memo "). + Foreground(common.Cream.Color()). + Background(common.Green.Color()). + String() - statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"} - statusBarBg = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"} + statusBarNoteFg = common.NewColorPair("#7D7D7D", "#656565") + statusBarBg = common.NewColorPair("#242424", "#E6E6E6") - 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 + // Styling funcs + statusBarScrollPosStyle = newStyle(common.NewColorPair("#5A5A5A", "#949494"), statusBarBg) + statusBarNoteStyle = newStyle(statusBarNoteFg, statusBarBg) + statusBarHelpStyle = newStyle(statusBarNoteFg, common.NewColorPair("#323232", "#DCDCDC")) + statusBarStashDotStyle = newStyle(common.Green, statusBarBg) + statusBarMessageStyle = newStyle(mintGreen, darkGreen) + statusBarMessageStashIconStyle = newStyle(mintGreen, darkGreen) + statusBarMessageScrollPosStyle = newStyle(mintGreen, darkGreen) + statusBarMessageHelpStyle = newStyle(common.NewColorPair("#B6FFE4", "#B6FFE4"), common.Green) + helpViewStyle = newStyle(statusBarNoteFg, common.NewColorPair("#1B1B1B", "#f2f2f2")) ) -type ( - contentRenderedMsg string - reloadMsg struct{} -) +type contentRenderedMsg string +type noteSavedMsg *charm.Markdown +type stashSuccessMsg markdown +type stashErrMsg struct{ err error } + +func (s stashErrMsg) Error() string { return s.err.Error() } type pagerState int const ( pagerStateBrowse pagerState = iota + pagerStateSetNote + pagerStateStashing + pagerStateStashSuccess pagerStateStatusMessage ) type pagerModel struct { - common *commonModel - viewport viewport.Model - state pagerState - showHelp bool + general *general + viewport viewport.Model + state pagerState + showHelp bool + textInput textinput.Model + spinner spinner.Model statusMessage string statusMessageTimer *time.Timer @@ -102,27 +79,51 @@ type pagerModel struct { // it here so we can re-render it on resize. currentDocument markdown - watcher *fsnotify.Watcher + // Newly stashed markdown. We store it here temporarily so we can replace + // currentDocument above after a stash. + stashedDocument *markdown } -func newPagerModel(common *commonModel) pagerModel { +func newPagerModel(general *general) pagerModel { // Init viewport - vp := viewport.New(0, 0) + vp := viewport.Model{} vp.YPosition = 0 vp.HighPerformanceRendering = config.HighPerformancePager - m := pagerModel{ - common: common, - state: pagerStateBrowse, - viewport: vp, + // Text input for notes/memos + ti := textinput.NewModel() + ti.Prompt = te.String(" > "). + Foreground(common.Color(darkGray)). + Background(common.YellowGreen.Color()). + String() + ti.TextColor = darkGray + ti.BackgroundColor = common.YellowGreen.String() + ti.CursorColor = common.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{ + general: general, + state: pagerStateBrowse, + textInput: ti, + viewport: vp, + spinner: sp, } - 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 { @@ -138,24 +139,19 @@ func (m *pagerModel) setContent(s string) { func (m *pagerModel) toggleHelp() { m.showHelp = !m.showHelp - m.setSize(m.common.width, m.common.height) + m.setSize(m.general.width, m.general.height) if m.viewport.PastBottom() { m.viewport.GotoBottom() } } -type pagerStatusMessage struct { - message string - isError bool -} - // Perform stuff that needs to happen after a successful markdown stash. Note -// that the returned command should be sent back the through the pager +// that the the returned command should be sent back the through the pager // update function. -func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd { +func (m *pagerModel) showStatusMessage(statusMessage string) tea.Cmd { // Show a success message to the user m.state = pagerStateStatusMessage - m.statusMessage = msg.message + m.statusMessage = statusMessage if m.statusMessageTimer != nil { m.statusMessageTimer.Stop() } @@ -165,7 +161,6 @@ func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd { } func (m *pagerModel) unload() { - log.Debug("unload") if m.showHelp { m.toggleHelp() } @@ -175,10 +170,10 @@ func (m *pagerModel) unload() { m.state = pagerStateBrowse m.viewport.SetContent("") m.viewport.YOffset = 0 - m.unwatchFile() + m.textInput.Reset() } -func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { +func (m pagerModel) Update(msg tea.Msg) (pagerModel, tea.Cmd) { var ( cmd tea.Cmd cmds []tea.Cmd @@ -186,95 +181,143 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - switch msg.String() { - case "q", keyEsc: - if m.state != pagerStateBrowse { + switch m.state { + case pagerStateSetNote: + switch msg.String() { + case "esc": 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.general.cc, m.currentDocument.ID, m.currentDocument.Note) + } + m.state = pagerStateBrowse + m.textInput.Reset() + return m, cmd } - 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)) - } + 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.markdownType == stashedMarkdown || + m.currentDocument.markdownType == convertedMarkdown - case "d": - m.viewport.HalfViewDown() - if m.viewport.HighPerformanceRendering { - cmds = append(cmds, viewport.Sync(m.viewport)) + // 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.general.authStatus != authOK { + break + } + + // Stash a local document + if m.state != pagerStateStashing && m.currentDocument.markdownType == localMarkdown { + m.state = pagerStateStashing + m.spinner.Start() + cmds = append( + cmds, + stashDocument(m.general.cc, m.currentDocument), + spinner.Tick, + ) + } + case "?": + m.toggleHelp() + 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)) - } + case spinner.TickMsg: + if m.state == pagerStateStashing || m.spinner.Visible() { + newSpinnerModel, cmd := m.spinner.Update(msg) + m.spinner = newSpinnerModel + cmds = append(cmds, cmd) + } else if m.state == pagerStateStashSuccess && !m.spinner.Visible() { + m.state = pagerStateBrowse + m.currentDocument = *m.stashedDocument + m.stashedDocument = nil + cmd := m.showStatusMessage("Stashed!") + cmds = append(cmds, cmd) } // Glow has rendered the content case contentRenderedMsg: - log.Info("content rendered", "state", m.state) - m.setContent(string(msg)) if m.viewport.HighPerformanceRendering { cmds = append(cmds, viewport.Sync(m.viewport)) } - cmds = append(cmds, m.watchFile) - // The file was changed on disk and we're reloading it - case reloadMsg: - return m, loadLocalMarkdown(&m.currentDocument) - - // We've finished editing the document, potentially making changes. Let's - // retrieve the latest version of the document so that we display - // up-to-date contents. - case editorFinishedMsg: - return m, loadLocalMarkdown(&m.currentDocument) - - // We've received terminal dimensions, either for the first time or + // We've reveived 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() { + m.state = pagerStateBrowse + m.currentDocument = markdown(msg) + cmd := m.showStatusMessage("Stashed!") + cmds = append(cmds, cmd) + } else { + md := markdown(msg) + m.stashedDocument = &md + } + + case stashErrMsg: + // TODO + case statusMessageTimeoutMsg: + // Hide the status message bar m.state = pagerStateBrowse } - m.viewport, cmd = m.viewport.Update(msg) - cmds = append(cmds, cmd) + 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) + } return m, tea.Batch(cmds...) } @@ -284,10 +327,15 @@ func (m pagerModel) View() string { fmt.Fprint(&b, m.viewport.View()+"\n") // Footer - m.statusBarView(&b) + switch m.state { + case pagerStateSetNote: + m.setNoteView(&b) + default: + m.statusBarView(&b) + } if m.showHelp { - fmt.Fprint(&b, "\n"+m.helpView()) + fmt.Fprint(&b, m.helpView()) } return b.String() @@ -299,11 +347,13 @@ func (m pagerModel) statusBarView(b *strings.Builder) { maxPercent float64 = 1.0 percentToStringMagnitude float64 = 100.0 ) - - showStatusMessage := m.state == pagerStateStatusMessage + var ( + isStashed bool = m.currentDocument.markdownType == stashedMarkdown || m.currentDocument.markdownType == convertedMarkdown + showStatusMessage bool = m.state == pagerStateStatusMessage + ) // Logo - logo := glowLogoView() + logo := glowLogoView(" Glow ") // Scroll percent percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent())) @@ -322,19 +372,35 @@ 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 + note = "Stashed!" } else { note = m.currentDocument.Note + if len(note) == 0 { + note = "(No memo)" + } } - note = truncate.StringWithTail(" "+note+" ", uint(max(0, //nolint:gosec - m.common.width- + note = truncate(" "+note+" ", max(0, + m.general.width- ansi.PrintableRuneWidth(logo)- + ansi.PrintableRuneWidth(statusIndicator)- ansi.PrintableRuneWidth(scrollPercent)- ansi.PrintableRuneWidth(helpNote), - )), ellipsis) + )) if showStatusMessage { note = statusBarMessageStyle(note) } else { @@ -343,8 +409,9 @@ func (m pagerModel) statusBarView(b *strings.Builder) { // Empty space padding := max(0, - m.common.width- + m.general.width- ansi.PrintableRuneWidth(logo)- + ansi.PrintableRuneWidth(statusIndicator)- ansi.PrintableRuneWidth(note)- ansi.PrintableRuneWidth(scrollPercent)- ansi.PrintableRuneWidth(helpNote), @@ -356,8 +423,9 @@ func (m pagerModel) statusBarView(b *strings.Builder) { emptySpace = statusBarNoteStyle(emptySpace) } - fmt.Fprintf(b, "%s%s%s%s%s", + fmt.Fprintf(b, "%s%s%s%s%s%s", logo, + statusIndicator, note, emptySpace, scrollPercent, @@ -365,17 +433,30 @@ 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.general.authStatus == authOK && m.currentDocument.markdownType == localMarkdown { + memoOrStash = "s stash this document" + } + col1 := []string{ "g/home go to top", "G/end go to bottom", - "c copy contents", - "e edit this document", - "r reload this document", + "", + memoOrStash, "esc back to files", "q quit", } + if m.currentDocument.markdownType == newsMarkdown { + deleteFromStringSlice(col1, 3) + } + s += "\n" s += "k/↑ up " + col1[0] + "\n" s += "j/↓ down " + col1[1] + "\n" @@ -391,11 +472,11 @@ func (m pagerModel) helpView() (s string) { s = indent(s, 2) // Fill up empty cells with spaces for background coloring - if m.common.width > 0 { + if m.general.width > 0 { lines := strings.Split(s, "\n") for i := 0; i < len(lines); i++ { l := runewidth.StringWidth(lines[i]) - n := max(m.common.width-l, 0) + n := max(m.general.width-l, 0) lines[i] += strings.Repeat(" ", n) } @@ -411,7 +492,9 @@ func renderWithGlamour(m pagerModel, md string) tea.Cmd { return func() tea.Msg { s, err := glamourRender(m, md) if err != nil { - log.Error("error rendering with Glamour", "error", err) + if debug { + log.Println("error rendering with Glamour:", err) + } return errMsg{err} } return contentRenderedMsg(s) @@ -420,116 +503,53 @@ 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 } - isCode := !utils.IsMarkdownFile(m.currentDocument.Note) - width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width)) //nolint:gosec - if isCode { - width = 0 + // initialize glamour + var gs glamour.TermRendererOption + if m.general.cfg.GlamourStyle == "auto" { + gs = glamour.WithAutoStyle() + } else { + gs = glamour.WithStylePath(m.general.cfg.GlamourStyle) } - options := []glamour.TermRendererOption{ - utils.GlamourStyle(m.common.cfg.GlamourStyle, isCode), + width := max(0, min(int(m.general.cfg.GlamourMaxWidth), m.viewport.Width)) + r, err := glamour.NewTermRenderer( + gs, glamour.WithWordWrap(width), - } - - if m.common.cfg.PreserveNewLines { - options = append(options, glamour.WithPreservedNewLines()) - } - r, err := glamour.NewTermRenderer(options...) + ) if err != nil { - return "", fmt.Errorf("error creating glamour renderer: %w", err) - } - - if isCode { - markdown = utils.WrapCodeBlock(markdown, filepath.Ext(m.currentDocument.Note)) + return "", err } out, err := r.Render(markdown) if err != nil { - return "", fmt.Errorf("error rendering markdown: %w", err) - } - - if isCode { - out = strings.TrimSpace(out) + return "", err } // trim lines lines := strings.Split(out, "\n") - var content strings.Builder + var content string for i, s := range lines { - if isCode || m.common.cfg.ShowLineNumbers { - content.WriteString(lineNumberStyle(fmt.Sprintf("%"+fmt.Sprint(lineNumberWidth)+"d", i+1))) - content.WriteString(trunc(s)) - } else { - content.WriteString(s) - } + content += strings.TrimSpace(s) // don't add an artificial newline after the last split if i+1 < len(lines) { - content.WriteRune('\n') + content += "\n" } } - return content.String(), nil + return content, nil } -func (m *pagerModel) initWatcher() { - var err error - m.watcher, err = fsnotify.NewWatcher() - if err != nil { - log.Error("error creating fsnotify watcher", "error", err) - } -} - -func (m *pagerModel) watchFile() tea.Msg { - dir := m.localDir() - - if err := m.watcher.Add(dir); err != nil { - log.Error("error adding dir to fsnotify watcher", "error", err) - return nil - } - - log.Info("fsnotify watching dir", "dir", dir) - - for { - select { - case event, ok := <-m.watcher.Events: - if !ok || event.Name != m.currentDocument.localPath { - continue - } - - if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) { - continue - } - - log.Debug("fsnotify event", "file", event.Name, "event", event.Op) - return reloadMsg{} - case err, ok := <-m.watcher.Errors: - if !ok { - continue - } - log.Debug("fsnotify error", "dir", dir, "error", err) - } - } -} - -func (m *pagerModel) unwatchFile() { - dir := m.localDir() - - err := m.watcher.Remove(dir) - if err == nil { - log.Debug("fsnotify dir unwatched", "dir", dir) - } else { - log.Error("fsnotify fail to unwatch dir", "dir", dir, "error", err) - } -} - -func (m *pagerModel) localDir() string { - return filepath.Dir(m.currentDocument.localPath) +// 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] } diff --git a/ui/sort.go b/ui/sort.go deleted file mode 100644 index 4f389fb..0000000 --- a/ui/sort.go +++ /dev/null @@ -1,12 +0,0 @@ -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 c3739b2..fd9e4f0 100644 --- a/ui/stash.go +++ b/ui/stash.go @@ -3,160 +3,82 @@ package ui import ( "errors" "fmt" - "os" + "io/ioutil" + "log" + "math" "sort" "strings" "time" + "unicode" "github.com/charmbracelet/bubbles/paginator" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/log" + "github.com/charmbracelet/charm" + "github.com/charmbracelet/charm/ui/common" + "github.com/dustin/go-humanize" + runewidth "github.com/mattn/go-runewidth" "github.com/muesli/reflow/ansi" - "github.com/muesli/reflow/truncate" + te "github.com/muesli/termenv" "github.com/sahilm/fuzzy" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" ) const ( stashIndent = 1 - stashViewItemHeight = 3 // height of stash entry, including gap - stashViewTopPadding = 5 // logo, status bar, gaps - stashViewBottomPadding = 3 // pagination and gaps, but not help + stashViewItemHeight = 3 + stashViewTopPadding = 5 + stashViewBottomPadding = 4 stashViewHorizontalPadding = 6 ) -var stashingStatusMessage = statusMessage{normalStatusMessage, "Stashing..."} - var ( - dividerDot = darkGrayFg.SetString(" • ") - dividerBar = darkGrayFg.SetString(" │ ") - - logoStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ECFD65")). - Background(fuchsia). - Bold(true) - - stashSpinnerStyle = lipgloss.NewStyle(). - Foreground(gray) - stashInputPromptStyle = lipgloss.NewStyle(). - Foreground(yellowGreen). - MarginRight(1) - stashInputCursorStyle = lipgloss.NewStyle(). - Foreground(fuchsia). - MarginRight(1) + stashHelpItemStyle styleFunc = newFgStyle(common.NewColorPair("#5C5C5C", "#9B9B9B")) + stashTextInputPromptStyle styleFunc = newFgStyle(common.YellowGreen) + dividerDot string = te.String(" • ").Foreground(common.NewColorPair("#3C3C3C", "#DDDADA").Color()).String() + offlineHeaderNote string = te.String("(Offline)").Foreground(common.NewColorPair("#3C3C3C", "#DDDADA").Color()).String() ) // MSG -type ( - filteredMarkdownMsg []*markdown - fetchedMarkdownMsg *markdown -) +type fetchedMarkdownMsg *markdown +type deletedStashedItemMsg int +type filteredMarkdownMsg []*markdown // MODEL -// stashViewState is the high-level state of the file listing. -type stashViewState int +type DocumentType byte const ( - stashStateReady stashViewState = iota + LocalDocuments DocumentType = 1 << iota + StashedDocuments + NewsDocuments +) + +type stashState int + +const ( + stashStateReady stashState = iota + stashStatePromptDelete stashStateLoadingDocument + stashStateSettingNote stashStateShowingError + stashStateFilterNotes + stashStateShowFiltered ) -// The types of documents we are currently showing to the user. -type sectionKey int - -const ( - documentsSection = iota - filterSection -) - -// section contains definitions and state information for displaying a tab and -// its contents in the file listing view. -type section struct { - key sectionKey - paginator paginator.Model - cursor int -} - -// map sections to their associated types. -var sections = map[sectionKey]section{} - -// filterState is the current filtering state in the file listing. -type filterState int - -const ( - unfiltered filterState = iota // no filter set - filtering // user is actively setting a filter - filterApplied // a filter is applied and user is not editing filter -) - -// statusMessageType adds some context to the status message being sent. -type statusMessageType int - -// Types of status messages. -const ( - normalStatusMessage statusMessageType = iota - subtleStatusMessage - errorStatusMessage -) - -// statusMessage is an ephemeral note displayed in the UI. -type statusMessage struct { - status statusMessageType - message string -} - -func initSections() { - sections = map[sectionKey]section{ - documentsSection: { - key: documentsSection, - paginator: newStashPaginator(), - }, - filterSection: { - key: filterSection, - paginator: newStashPaginator(), - }, - } -} - -// String returns a styled version of the status message appropriate for the -// given context. -func (s statusMessage) String() string { - switch s.status { //nolint:exhaustive - case subtleStatusMessage: - return dimGreenFg(s.message) - case errorStatusMessage: - return redFg(s.message) - default: - return greenFg(s.message) - } -} - type stashModel struct { - common *commonModel + general *general + state stashState err error spinner spinner.Model + noteInput textinput.Model filterInput textinput.Model - viewState stashViewState - filterState filterState - showFullHelp bool - showStatusMessage bool - statusMessage statusMessage - statusMessageTimer *time.Timer - - // Available document sections we can cycle through. We use a slice, rather - // than a map, because order is important. - sections []section - - // Index of the section we're currently looking at - sectionIndex int - - // Tracks if docs were loaded - loaded bool + stashFullyLoaded bool // have we loaded all available stashed documents from the server? + loadingFromNetwork bool // are we currently loading something from the network? + loaded DocumentType // load status for news, stash and local files loading; we find out exactly with bitmasking // The master set of markdown documents we're working with. markdowns []*markdown @@ -166,117 +88,100 @@ type stashModel struct { // reason, this field should be considered ephemeral. filteredMarkdowns []*markdown + // Paths to files being stashed. We treat this like a set, ignoring the + // value portion with an empty struct. + filesStashing map[string]struct{} + + // This is just the selected item in relation to the current page in view. + // To get the index of the selected item as it relates to the full set of + // documents we've fetched use the markdownIndex() method on this struct. + index int + + // This handles the local pagination, which is different than the page + // we're fetching from on the server side + paginator paginator.Model + // Page we're fetching stash items from on the server, which is different // from the local pagination. Generally, the server will return more items // than we can display at a time so we can paginate locally without having // to fetch every time. - serverPage int64 + page int + + showStatusMessage bool + statusMessage string + statusMessageTimer *time.Timer +} + +func (m stashModel) localOnly() bool { + return m.general.cfg.DocumentTypes == LocalDocuments +} + +func (m stashModel) stashedOnly() bool { + return m.general.cfg.DocumentTypes&LocalDocuments == 0 } func (m stashModel) loadingDone() bool { - return m.loaded -} - -func (m stashModel) currentSection() *section { - return &m.sections[m.sectionIndex] -} - -func (m stashModel) paginator() *paginator.Model { - return &m.currentSection().paginator -} - -func (m *stashModel) setPaginator(p paginator.Model) { - m.currentSection().paginator = p -} - -func (m stashModel) cursor() int { - return m.currentSection().cursor -} - -func (m *stashModel) setCursor(i int) { - m.currentSection().cursor = i -} - -// Whether or not the spinner should be spinning. -func (m stashModel) shouldSpin() bool { - loading := !m.loadingDone() - openingDocument := m.viewState == stashStateLoadingDocument - return loading || openingDocument + // Do the types loaded match the types we want to have? + return m.loaded == m.general.cfg.DocumentTypes } func (m *stashModel) setSize(width, height int) { - m.common.width = width - m.common.height = height + m.general.width = width + m.general.height = height - m.filterInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth( - m.filterInput.Prompt, - ) + // Update the paginator + m.setTotalPages() - m.updatePagination() + m.noteInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth(m.noteInput.Prompt) + m.filterInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth(m.filterInput.Prompt) } func (m *stashModel) resetFiltering() { - m.filterState = unfiltered m.filterInput.Reset() + sort.Stable(markdownsByLocalFirst(m.markdowns)) m.filteredMarkdowns = nil - - sortMarkdowns(m.markdowns) - - // If the filtered section is present (it's always at the end) slice it out - // of the sections slice to remove it from the UI. - if m.sections[len(m.sections)-1].key == filterSection { - m.sections = m.sections[:len(m.sections)-1] - } - - // If the current section is out of bounds (it would be if we cut down the - // slice above) then return to the first section. - if m.sectionIndex > len(m.sections)-1 { - m.sectionIndex = 0 - } - - // Update pagination after we've switched sections. - m.updatePagination() + m.setTotalPages() } // Is a filter currently being applied? -func (m stashModel) filterApplied() bool { - return m.filterState != unfiltered +func (m stashModel) isFiltering() bool { + switch m.state { + case stashStateFilterNotes, stashStateShowFiltered: + return true + case stashStatePromptDelete, stashStateSettingNote: + return m.filterInput.Value() != "" + default: + return false + } } // Should we be updating the filter? func (m stashModel) shouldUpdateFilter() bool { // If we're in the middle of setting a note don't update the filter so that // the focus won't jump around. - return m.filterApplied() + return m.isFiltering() && m.state != stashStateSettingNote } -// Update pagination according to the amount of markdowns for the current -// state. -func (m *stashModel) updatePagination() { - _, helpHeight := m.helpView() - - availableHeight := m.common.height - - stashViewTopPadding - - helpHeight - - stashViewBottomPadding - - m.paginator().PerPage = max(1, availableHeight/stashViewItemHeight) +// Sets the total paginator pages according to the amount of markdowns for the +// current state. +func (m *stashModel) setTotalPages() { + m.paginator.PerPage = max(1, (m.general.height-stashViewTopPadding-stashViewBottomPadding)/stashViewItemHeight) if pages := len(m.getVisibleMarkdowns()); pages < 1 { - m.paginator().SetTotalPages(1) + m.paginator.SetTotalPages(1) } else { - m.paginator().SetTotalPages(pages) + m.paginator.SetTotalPages(pages) } // Make sure the page stays in bounds - if m.paginator().Page >= m.paginator().TotalPages-1 { - m.paginator().Page = max(0, m.paginator().TotalPages-1) + if m.paginator.Page >= m.paginator.TotalPages-1 { + m.paginator.Page = max(0, m.paginator.TotalPages-1) } } // MarkdownIndex returns the index of the currently selected markdown item. func (m stashModel) markdownIndex() int { - return m.paginator().Page*m.paginator().PerPage + m.cursor() + return m.paginator.Page*m.paginator.PerPage + m.index } // Return the current selected markdown in the stash. @@ -293,533 +198,769 @@ func (m stashModel) selectedMarkdown() *markdown { // Adds markdown documents to the model. func (m *stashModel) addMarkdowns(mds ...*markdown) { + if len(mds) > 0 { + m.markdowns = append(m.markdowns, mds...) + if !m.isFiltering() { + sort.Stable(markdownsByLocalFirst(m.markdowns)) + } + m.setTotalPages() + } +} + +// Find a local markdown by its path and replace it +func (m *stashModel) replaceLocalMarkdown(localPath string, newMarkdown *markdown) error { + var found bool + + // Look for local markdown + for i, md := range m.markdowns { + if md.localPath == localPath { + m.markdowns[i] = newMarkdown + found = true + break + } + } + + if !found { + err := fmt.Errorf("could't find local markdown %s; not removing from stash", localPath) + if debug { + log.Println(err) + } + return err + } + + if m.isFiltering() { + found = false + for i, md := range m.filteredMarkdowns { + if md.localPath == localPath { + m.filteredMarkdowns[i] = newMarkdown + found = true + break + } + } + if !found { + err := fmt.Errorf("warning: found local markdown %s in the master markdown list, but not in the filter results", localPath) + if debug { + log.Println(err) + } + return err + } + } + + return nil +} + +// Return the number of markdown documents of a given type. +func (m stashModel) countMarkdowns(t markdownType) (found int) { + mds := m.getVisibleMarkdowns() if len(mds) == 0 { return } - - m.markdowns = append(m.markdowns, mds...) - if !m.filterApplied() { - sortMarkdowns(m.markdowns) + for i := 0; i < len(mds); i++ { + if mds[i].markdownType == t { + found++ + } } - - m.updatePagination() + return } -// Returns the markdowns that should be currently shown. func (m stashModel) getVisibleMarkdowns() []*markdown { - if m.filterState == filtering || m.currentSection().key == filterSection { + if m.isFiltering() { return m.filteredMarkdowns } - return m.markdowns } // Command for opening a markdown document in the pager. Note that this also // alters the model. func (m *stashModel) openMarkdown(md *markdown) tea.Cmd { - m.viewState = stashStateLoadingDocument - cmd := loadLocalMarkdown(md) - return tea.Batch(cmd, m.spinner.Tick) + var cmd tea.Cmd + m.state = stashStateLoadingDocument + + if md.markdownType == localMarkdown { + cmd = loadLocalMarkdown(md) + } else { + cmd = loadRemoteMarkdown(m.general.cc, md.ID, md.markdownType) + } + + return tea.Batch(cmd, spinner.Tick) } func (m *stashModel) hideStatusMessage() { m.showStatusMessage = false - m.statusMessage = statusMessage{} if m.statusMessageTimer != nil { m.statusMessageTimer.Stop() } } func (m *stashModel) moveCursorUp() { - m.setCursor(m.cursor() - 1) - if m.cursor() < 0 && m.paginator().Page == 0 { + m.index-- + if m.index < 0 && m.paginator.Page == 0 { // Stop - m.setCursor(0) + m.index = 0 return } - if m.cursor() >= 0 { + if m.index >= 0 { return } // Go to previous page - m.paginator().PrevPage() + m.paginator.PrevPage() - m.setCursor(m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns())) - 1) + m.index = m.paginator.ItemsOnPage(len(m.getVisibleMarkdowns())) - 1 } func (m *stashModel) moveCursorDown() { - itemsOnPage := m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns())) + itemsOnPage := m.paginator.ItemsOnPage(len(m.getVisibleMarkdowns())) - m.setCursor(m.cursor() + 1) - if m.cursor() < itemsOnPage { + m.index++ + if m.index < itemsOnPage { return } - if !m.paginator().OnLastPage() { - m.paginator().NextPage() - m.setCursor(0) + if !m.paginator.OnLastPage() { + m.paginator.NextPage() + m.index = 0 return } // During filtering the cursor position can exceed the number of // itemsOnPage. It's more intuitive to start the cursor at the // topmost position when moving it down in this scenario. - if m.cursor() > itemsOnPage { - m.setCursor(0) + if m.index > itemsOnPage { + m.index = 0 return } - m.setCursor(itemsOnPage - 1) + m.index = itemsOnPage - 1 } // INIT -func newStashModel(common *commonModel) stashModel { - sp := spinner.New() +func newStashModel(general *general) stashModel { + sp := spinner.NewModel() sp.Spinner = spinner.Line - sp.Style = stashSpinnerStyle + sp.ForegroundColor = common.SpinnerColor.String() + sp.HideFor = time.Millisecond * 50 + sp.MinimumLifetime = time.Millisecond * 180 + sp.Start() - si := textinput.New() - si.Prompt = "Find:" - si.PromptStyle = stashInputPromptStyle - si.Cursor.Style = stashInputCursorStyle + p := paginator.NewModel() + p.Type = paginator.Dots + p.InactiveDot = common.Subtle("•") + + ni := textinput.NewModel() + ni.Prompt = stashTextInputPromptStyle("Memo: ") + ni.CursorColor = common.Fuschia.String() + ni.CharLimit = noteCharacterLimit + ni.Focus() + + si := textinput.NewModel() + si.Prompt = stashTextInputPromptStyle("Filter: ") + si.CursorColor = common.Fuschia.String() + si.CharLimit = noteCharacterLimit si.Focus() - s := []section{ - sections[documentsSection], - } - m := stashModel{ - common: common, - spinner: sp, - filterInput: si, - serverPage: 1, - sections: s, + general: general, + spinner: sp, + noteInput: ni, + filterInput: si, + page: 1, + paginator: p, + loadingFromNetwork: true, + filesStashing: make(map[string]struct{}), } return m } -func newStashPaginator() paginator.Model { - p := paginator.New() - p.Type = paginator.Dots - p.ActiveDot = brightGrayFg("•") - p.InactiveDot = darkGrayFg.Render("•") - return p -} - // UPDATE -func (m stashModel) update(msg tea.Msg) (stashModel, tea.Cmd) { +func stashUpdate(msg tea.Msg, m stashModel) (stashModel, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case errMsg: m.err = msg + case stashLoadErrMsg: + m.err = msg.err + m.loaded |= StashedDocuments // still done, albeit unsuccessfully + m.stashFullyLoaded = true + m.loadingFromNetwork = false + + case newsLoadErrMsg: + m.err = msg.err + m.loaded |= NewsDocuments // still done, albeit unsuccessfully + case localFileSearchFinished: // We're finished searching for local files - m.loaded = true + m.loaded |= LocalDocuments + + 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 some we've finished checking for the stash, at least, so mark + // the stash as loaded here. + var docs []*markdown + + switch msg := msg.(type) { + case gotStashMsg: + m.loaded |= StashedDocuments + m.loadingFromNetwork = false + docs = wrapMarkdowns(stashedMarkdown, 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.page++ + cmds = append(cmds, loadStash(m)) + } + + case gotNewsMsg: + m.loaded |= NewsDocuments + docs = wrapMarkdowns(newsMarkdown, msg) + } + + // If we're filtering build filter indexes immediately so any + // matching results will show up in the filter. + if m.isFiltering() { + for _, md := range docs { + md.buildFilterValue() + } + } + if m.shouldUpdateFilter() { + cmds = append(cmds, filterMarkdowns(m)) + } + + m.addMarkdowns(docs...) case filteredMarkdownMsg: m.filteredMarkdowns = msg - m.setCursor(0) return m, nil case spinner.TickMsg: - if m.shouldSpin() { - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) + condition := !m.loadingDone() || + m.loadingFromNetwork || + m.state == stashStateLoadingDocument || + len(m.filesStashing) > 0 || + m.spinner.Visible() + + if condition { + newSpinnerModel, cmd := m.spinner.Update(msg) + m.spinner = newSpinnerModel 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 + } + } + + // Something was stashed. Add it to the stash listing. + case stashSuccessMsg: + md := markdown(msg) + delete(m.filesStashing, md.localPath) // remove from the things-we're-stashing list + + _ = m.replaceLocalMarkdown(md.localPath, &md) + + m.showStatusMessage = true + m.statusMessage = "Stashed!" + if m.statusMessageTimer != nil { + m.statusMessageTimer.Stop() + } + m.statusMessageTimer = time.NewTimer(statusMessageTimeout) + cmds = append(cmds, waitForStatusMessageTimeout(stashContext, m.statusMessageTimer)) + case statusMessageTimeoutMsg: if applicationContext(msg) == stashContext { m.hideStatusMessage() } } - if m.filterState == filtering { - cmds = append(cmds, m.handleFiltering(msg)) - return m, tea.Batch(cmds...) - } + switch m.state { + case stashStateReady, stashStateShowFiltered: + pages := len(m.getVisibleMarkdowns()) + + switch msg := msg.(type) { + // Handle keys + case tea.KeyMsg: + switch msg.String() { + case "k", "ctrl+k", "up", "shift+tab": + m.moveCursorUp() + + case "j", "ctrl+j", "down", "tab": + m.moveCursorDown() + + // Go to the very start + case "home", "g": + m.paginator.Page = 0 + m.index = 0 + + // Go to the very end + case "end", "G": + m.paginator.Page = m.paginator.TotalPages - 1 + m.index = m.paginator.ItemsOnPage(pages) - 1 + + // Note: esc is only passed trough in stashStateFilterNotes + case "esc": + m.state = stashStateReady + m.resetFiltering() + + // Open document + case "enter": + m.hideStatusMessage() + + if pages == 0 { + break + } + + // Load the document from the server. We'll handle the message + // that comes back in the main update function. + md := m.selectedMarkdown() + cmds = append(cmds, m.openMarkdown(md)) + + // Filter your notes + case "/": + m.hideStatusMessage() + + // Build values we'll filter against + for _, md := range m.markdowns { + md.buildFilterValue() + } + + m.filteredMarkdowns = m.markdowns + + m.paginator.Page = 0 + m.index = 0 + m.state = stashStateFilterNotes + m.filterInput.CursorEnd() + m.filterInput.Focus() + return m, textinput.Blink + + // Set note + case "m": + m.hideStatusMessage() + + if pages == 0 { + break + } + + md := m.selectedMarkdown() + isUserMarkdown := md.markdownType == stashedMarkdown || md.markdownType == convertedMarkdown + isSettingNote := m.state == stashStateSettingNote + isPromptingDelete := m.state == stashStatePromptDelete + + if isUserMarkdown && !isSettingNote && !isPromptingDelete { + m.state = stashStateSettingNote + m.noteInput.SetValue(md.Note) + m.noteInput.CursorEnd() + return m, textinput.Blink + } + + // Stash + case "s": + if pages == 0 || m.general.authStatus != authOK || m.selectedMarkdown() == nil { + break + } + + md := m.selectedMarkdown() + + _, isBeingStashed := m.filesStashing[md.localPath] + isLocalMarkdown := md.markdownType == localMarkdown + markdownPathMissing := md.localPath == "" + + if isBeingStashed || !isLocalMarkdown || markdownPathMissing { + if debug && isBeingStashed { + log.Printf("refusing to stash markdown; we're already stashing %s", md.localPath) + } else if debug && isLocalMarkdown && markdownPathMissing { + log.Printf("refusing to stash markdown; local path is empty: %#v", md) + } + break + } + + // Checks passed; perform the stash + m.filesStashing[md.localPath] = struct{}{} + cmds = append(cmds, stashDocument(m.general.cc, *md)) + + if m.loadingDone() && !m.spinner.Visible() { + m.spinner.Start() + cmds = append(cmds, spinner.Tick) + } + + // Prompt for deletion + case "x": + m.hideStatusMessage() + + if pages == 0 { + break + } + + t := m.selectedMarkdown().markdownType + isUserMarkdown := t == stashedMarkdown || t == convertedMarkdown + isValidState := m.state != stashStateSettingNote + + if isUserMarkdown && isValidState { + m.state = stashStatePromptDelete + } + + // Show errors + case "!": + if m.err != nil && m.state == stashStateReady { + m.state = stashStateShowingError + return m, nil + } + } + } + + // Update paginator. Pagination key handling is done here, but it could + // also be moved up to this level, in which case we'd use model methods + // like model.PageUp(). + newPaginatorModel, cmd := m.paginator.Update(msg) + m.paginator = newPaginatorModel + cmds = append(cmds, cmd) + + // Extra paginator keystrokes + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "b", "u": + m.paginator.PrevPage() + case "f", "d": + m.paginator.NextPage() + } + } + + // Keep the index in bounds when paginating + itemsOnPage := m.paginator.ItemsOnPage(len(m.getVisibleMarkdowns())) + if m.index > itemsOnPage-1 { + m.index = max(0, itemsOnPage-1) + } + + // If we're on the last page and we haven't loaded everything, get + // more stuff. + if m.paginator.OnLastPage() && !m.loadingFromNetwork && !m.stashFullyLoaded { + m.page++ + m.loadingFromNetwork = true + cmds = append(cmds, loadStash(m)) + } + + case stashStatePromptDelete: + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.String() { + // Confirm deletion + case "y": + if m.state != stashStatePromptDelete { + break + } + + smd := m.selectedMarkdown() + for i, md := range m.markdowns { + if md != smd { + continue + } + + if md.markdownType == convertedMarkdown { + // If document was stashed during this session, convert it + // back to a local file. + md.markdownType = localMarkdown + md.Note = stripAbsolutePath(m.markdowns[i].localPath, m.general.cwd) + } else { + // Delete optimistically and remove the stashed item + // before we've received a success response. + if m.isFiltering() { + mds, _ := deleteMarkdown(m.filteredMarkdowns, m.markdowns[i]) + m.filteredMarkdowns = mds + } + mds, _ := deleteMarkdown(m.markdowns, m.markdowns[i]) + m.markdowns = mds + } + } + + // Set state and delete + if m.isFiltering() { + m.state = stashStateShowFiltered + } else { + m.state = stashStateReady + } + + // Update pagination + m.setTotalPages() + + return m, deleteStashedItem(m.general.cc, smd.ID) + + default: + m.state = stashStateReady + if m.filterInput.Value() != "" { + m.state = stashStateShowFiltered + } + } + } + + case stashStateFilterNotes: + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.String() { + case "esc": + // Cancel filtering + m.state = stashStateReady + m.resetFiltering() + case "enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down": + m.hideStatusMessage() + + if len(m.markdowns) == 0 { + break + } + + h := m.getVisibleMarkdowns() + + // If we've filtered down to nothing, clear the filter + if len(h) == 0 { + m.state = stashStateReady + m.resetFiltering() + break + } + + // When there's only one filtered markdown left we can just + // "open" it directly + if len(h) == 1 { + m.state = stashStateReady + m.resetFiltering() + cmds = append(cmds, m.openMarkdown(h[0])) + break + } + + m.filterInput.Blur() + + m.state = stashStateShowFiltered + if m.filterInput.Value() == "" { + m.state = stashStateReady + m.resetFiltering() + } + } + } + + // Update the filter text input component + newFilterInputModel, inputCmd := m.filterInput.Update(msg) + currentFilterVal := m.filterInput.Value() + newFilterVal := newFilterInputModel.Value() + m.filterInput = newFilterInputModel + cmds = append(cmds, inputCmd) + + // If the filtering input has changed, request updated filtering + if newFilterVal != currentFilterVal { + cmds = append(cmds, filterMarkdowns(m)) + } + + // Update pagination + m.setTotalPages() + + case stashStateSettingNote: + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.String() { + case "esc": + // Cancel note + if m.filterInput.Value() != "" { + m.state = stashStateShowFiltered + } else { + m.state = stashStateReady + } + m.noteInput.Reset() + case "enter": + // Set new note + md := m.selectedMarkdown() + newNote := m.noteInput.Value() + cmd := saveDocumentNote(m.general.cc, md.ID, newNote) + md.Note = newNote + m.noteInput.Reset() + if m.filterInput.Value() != "" { + m.state = stashStateShowFiltered + } else { + m.state = stashStateReady + } + return m, 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) - // Updates per the current state - switch m.viewState { //nolint:exhaustive - case stashStateReady: - cmds = append(cmds, m.handleDocumentBrowsing(msg)) case stashStateShowingError: // Any key exists the error view if _, ok := msg.(tea.KeyMsg); ok { - m.viewState = stashStateReady + m.state = stashStateReady } } + // If an item is being confirmed for delete, any key (other than the key + // used for confirmation above) cancels the deletion return m, tea.Batch(cmds...) } -// Updates for when a user is browsing the markdown listing. -func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - - numDocs := len(m.getVisibleMarkdowns()) - - switch msg := msg.(type) { - // Handle keys - case tea.KeyMsg: - switch msg.String() { - case "k", "ctrl+k", "up": - m.moveCursorUp() - - case "j", "ctrl+j", "down": - m.moveCursorDown() - - // Go to the very start - case "home", "g": - m.paginator().Page = 0 - m.setCursor(0) - - // Go to the very end - case "end", "G": - m.paginator().Page = m.paginator().TotalPages - 1 - m.setCursor(m.paginator().ItemsOnPage(numDocs) - 1) - - // Clear filter (if applicable) - case keyEsc: - if m.filterApplied() { - m.resetFiltering() - } - - // Next section - case "tab", "L": - if len(m.sections) == 0 || m.filterState == filtering { - break - } - m.sectionIndex++ - if m.sectionIndex >= len(m.sections) { - m.sectionIndex = 0 - } - m.updatePagination() - - // Previous section - case "shift+tab", "H": - if len(m.sections) == 0 || m.filterState == filtering { - break - } - m.sectionIndex-- - if m.sectionIndex < 0 { - m.sectionIndex = len(m.sections) - 1 - } - m.updatePagination() - - case "F": - m.loaded = false - return findLocalFiles(*m.common) - - // Edit document in EDITOR - case "e": - md := m.selectedMarkdown() - - // In case no file is available - if md == nil { - return nil - } - - return openEditor(md.localPath, 0) - - // Open document - case keyEnter: - m.hideStatusMessage() - - if numDocs == 0 { - break - } - - // Load the document from the server. We'll handle the message - // that comes back in the main update function. - md := m.selectedMarkdown() - cmds = append(cmds, m.openMarkdown(md)) - - // Filter your notes - case "/": - m.hideStatusMessage() - - // Build values we'll filter against - for _, md := range m.markdowns { - md.buildFilterValue() - } - - m.filteredMarkdowns = m.markdowns - - m.paginator().Page = 0 - m.setCursor(0) - m.filterState = filtering - m.filterInput.CursorEnd() - m.filterInput.Focus() - return textinput.Blink - - // Toggle full help - case "?": - m.showFullHelp = !m.showFullHelp - m.updatePagination() - - // Show errors - case "!": - if m.err != nil && m.viewState == stashStateReady { - m.viewState = stashStateShowingError - return nil - } - } - } - - // Update paginator. Pagination key handling is done here, but it could - // also be moved up to this level, in which case we'd use model methods - // like model.PageUp(). - newPaginatorModel, cmd := m.paginator().Update(msg) - m.setPaginator(newPaginatorModel) - cmds = append(cmds, cmd) - - // Extra paginator keystrokes - if key, ok := msg.(tea.KeyMsg); ok { - switch key.String() { - case "b", "u": - m.paginator().PrevPage() - case "f", "d": - m.paginator().NextPage() - } - } - - // Keep the index in bounds when paginating - itemsOnPage := m.paginator().ItemsOnPage(len(m.getVisibleMarkdowns())) - if m.cursor() > itemsOnPage-1 { - m.setCursor(max(0, itemsOnPage-1)) - } - - return tea.Batch(cmds...) -} - -// Updates for when a user is in the filter editing interface. -func (m *stashModel) handleFiltering(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - - // Handle keys - if msg, ok := msg.(tea.KeyMsg); ok { //nolint:nestif - switch msg.String() { - case keyEsc: - // Cancel filtering - m.resetFiltering() - case keyEnter, "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down": - m.hideStatusMessage() - - if len(m.markdowns) == 0 { - break - } - - h := m.getVisibleMarkdowns() - - // If we've filtered down to nothing, clear the filter - if len(h) == 0 { - m.viewState = stashStateReady - m.resetFiltering() - break - } - - // When there's only one filtered markdown left we can just - // "open" it directly - if len(h) == 1 { - m.viewState = stashStateReady - m.resetFiltering() - cmds = append(cmds, m.openMarkdown(h[0])) - break - } - - // Add new section if it's not present - if m.sections[len(m.sections)-1].key != filterSection { - m.sections = append(m.sections, sections[filterSection]) - } - m.sectionIndex = len(m.sections) - 1 - - m.filterInput.Blur() - - m.filterState = filterApplied - if m.filterInput.Value() == "" { - m.resetFiltering() - } - } - } - - // Update the filter text input component - newFilterInputModel, inputCmd := m.filterInput.Update(msg) - currentFilterVal := m.filterInput.Value() - newFilterVal := newFilterInputModel.Value() - m.filterInput = newFilterInputModel - cmds = append(cmds, inputCmd) - - // If the filtering input has changed, request updated filtering - if newFilterVal != currentFilterVal { - cmds = append(cmds, filterMarkdowns(*m)) - } - - // Update pagination - m.updatePagination() - - return tea.Batch(cmds...) -} - // VIEW -func (m stashModel) view() string { +func stashView(m stashModel) string { var s string - switch m.viewState { + switch m.state { case stashStateShowingError: return errorView(m.err, false) case stashStateLoadingDocument: s += " " + m.spinner.View() + " Loading document..." - case stashStateReady: + case stashStateReady, stashStateSettingNote, stashStatePromptDelete, stashStateFilterNotes, stashStateShowFiltered: + loadingIndicator := " " - if m.shouldSpin() { + if !m.localOnly() && (!m.loadingDone() || m.loadingFromNetwork || m.spinner.Visible()) { loadingIndicator = m.spinner.View() } - // Only draw the normal header if we're not using the header area for - // something else (like a note or delete prompt). - header := m.headerView() - - // Rules for the logo, filter and status message. - logoOrFilter := " " - if m.showStatusMessage && m.filterState == filtering { - logoOrFilter += m.statusMessage.String() - } else if m.filterState == filtering { - logoOrFilter += m.filterInput.View() - } else { - logoOrFilter += glowLogoView() - if m.showStatusMessage { - logoOrFilter += " " + m.statusMessage.String() - } - } - logoOrFilter = truncate.StringWithTail(logoOrFilter, uint(m.common.width-1), ellipsis) //nolint:gosec - - help, helpHeight := m.helpView() - - populatedView := m.populatedView() - populatedViewHeight := strings.Count(populatedView, "\n") + 2 - // We need to fill any empty height with newlines so the footer reaches // the bottom. - availHeight := m.common.height - - stashViewTopPadding - - populatedViewHeight - - helpHeight - - stashViewBottomPadding - blankLines := strings.Repeat("\n", max(0, availHeight)) + numBlankLines := max(0, (m.general.height-stashViewTopPadding-stashViewBottomPadding)%stashViewItemHeight) + blankLines := "" + if numBlankLines > 0 { + blankLines = strings.Repeat("\n", numBlankLines) + } + + var header string + if m.showStatusMessage { + header = greenFg(m.statusMessage) + } else { + switch m.state { + case stashStatePromptDelete: + header = redFg("Delete this item from your stash? ") + faintRedFg("(y/N)") + case stashStateSettingNote: + 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 prompt or status message) + if header == "" { + header = stashHeaderView(m) + } + + logoOrFilter := glowLogoView(" Glow ") + + // If we're filtering we replace the logo with the filter field + if m.state == stashStateFilterNotes || m.state == stashStateShowFiltered { + logoOrFilter = m.filterInput.View() + } var pagination string - if m.paginator().TotalPages > 1 { - pagination = m.paginator().View() + if m.paginator.TotalPages > 1 { + pagination = m.paginator.View() // If the dot pagination is wider than the width of the window - // use the arabic paginator. - if ansi.PrintableRuneWidth(pagination) > m.common.width-stashViewHorizontalPadding { - // Copy the paginator since m.paginator() returns a pointer to - // the active paginator and we don't want to mutate it. In - // normal cases, where the paginator is not a pointer, we could - // safely change the model parameters for rendering here as the - // current model is discarded after reuturning from a View(). - // One could argue, in fact, that using pointers in - // a functional framework is an antipattern and our use of - // pointers in our model should be refactored away. - p := *(m.paginator()) - p.Type = paginator.Arabic - pagination = paginationStyle.Render(p.View()) + // switch to the arabic paginator. + if ansi.PrintableRuneWidth(pagination) > m.general.width-stashViewHorizontalPadding { + m.paginator.Type = paginator.Arabic + pagination = common.Subtle(m.paginator.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( - "%s%s\n\n %s\n\n%s\n\n%s %s\n\n%s", + "%s %s\n\n %s\n\n%s\n\n%s %s\n\n %s", loadingIndicator, logoOrFilter, header, - populatedView, + stashPopulatedView(m), blankLines, pagination, - help, + stashHelpView(m), ) } return "\n" + indent(s, stashIndent) } -func glowLogoView() string { - return logoStyle.Render(" Glow ") +func glowLogoView(text string) string { + return te.String(text). + Bold(). + Foreground(glowLogoTextColor). + Background(common.Fuschia.Color()). + String() } -func (m stashModel) headerView() string { - localCount := len(m.markdowns) +func stashHeaderView(m stashModel) string { + loading := !m.loadingDone() + noMarkdowns := len(m.markdowns) == 0 - var sections []string //nolint:prealloc - - // Filter results - if m.filterState == filtering { - if localCount == 0 { - return grayFg("Nothing found.") - } - if localCount > 0 { - sections = append(sections, fmt.Sprintf("%d local", localCount)) - } - - for i := range sections { - sections[i] = grayFg(sections[i]) - } - - return strings.Join(sections, dividerDot.String()) + if m.general.authStatus == authFailed && m.stashedOnly() { + return common.Subtle("Can’t load stash. Are you offline?") } - // Tabs - for i, v := range m.sections { - var s string + var maybeOffline string + if m.general.authStatus == authFailed { + maybeOffline = " " + offlineHeaderNote + } - switch v.key { - case documentsSection: - s = fmt.Sprintf("%d documents", localCount) - - case filterSection: - s = fmt.Sprintf("%d “%s”", len(m.filteredMarkdowns), m.filterInput.Value()) - } - - if m.sectionIndex == i && len(m.sections) > 1 { - s = selectedTabStyle.Render(s) + // Still loading. We haven't found files, stashed items, or news yet. + if loading && noMarkdowns { + if m.stashedOnly() { + return common.Subtle("Loading your stash...") } else { - s = tabStyle.Render(s) + return common.Subtle("Looking for stuff...") + maybeOffline } - sections = append(sections, s) } - return strings.Join(sections, dividerBar.String()) + localItems := m.countMarkdowns(localMarkdown) + stashedItems := m.countMarkdowns(stashedMarkdown) + m.countMarkdowns(convertedMarkdown) + newsItems := m.countMarkdowns(newsMarkdown) + + // Loading's finished and all we have is news. + if !loading && localItems == 0 && stashedItems == 0 && newsItems == 0 { + if m.stashedOnly() { + return common.Subtle("No stashed markdown files found.") + maybeOffline + } else { + return common.Subtle("No local or stashed markdown files found.") + maybeOffline + } + } + + // There are local and/or stashed files, so display counts. + var s string + if localItems > 0 { + s += common.Subtle(fmt.Sprintf("%d Local", localItems)) + } + if stashedItems > 0 { + var divider string + if localItems > 0 { + divider = dividerDot + } + si := common.Subtle(fmt.Sprintf("%d Stashed", stashedItems)) + s += fmt.Sprintf("%s%s", divider, si) + } + if newsItems > 0 { + var divider string + if localItems > 0 || stashedItems > 0 { + divider = dividerDot + } + si := common.Subtle(fmt.Sprintf("%d News", newsItems)) + + s += fmt.Sprintf("%s%s", divider, si) + } + return common.Subtle(s) + maybeOffline } -func (m stashModel) populatedView() string { - mds := m.getVisibleMarkdowns() - +func stashPopulatedView(m stashModel) string { var b strings.Builder - // Empty states - if len(mds) == 0 { - f := func(s string) { - b.WriteString(" " + grayFg(s)) - } - - switch m.sections[m.sectionIndex].key { - case documentsSection: - if m.loadingDone() { - f("No files found.") - } else { - f("Looking for local files...") - } - case filterSection: - return "" - } - } - + mds := m.getVisibleMarkdowns() if len(mds) > 0 { - start, end := m.paginator().GetSliceBounds(len(mds)) + start, end := m.paginator.GetSliceBounds(len(mds)) docs := mds[start:end] for i, md := range docs { @@ -833,9 +974,9 @@ func (m stashModel) populatedView() string { // If there aren't enough items to fill up this page (always the last page) // then we need to add some newlines to fill up the space where stash items // would have been. - itemsOnPage := m.paginator().ItemsOnPage(len(mds)) - if itemsOnPage < m.paginator().PerPage { - n := (m.paginator().PerPage - itemsOnPage) * stashViewItemHeight + itemsOnPage := m.paginator.ItemsOnPage(len(mds)) + if itemsOnPage < m.paginator.PerPage { + n := (m.paginator.PerPage - itemsOnPage) * stashViewItemHeight if len(mds) == 0 { n -= stashViewItemHeight - 1 } @@ -847,17 +988,141 @@ func (m stashModel) populatedView() string { return b.String() } +func stashHelpView(m stashModel) string { + var ( + h []string + isStashed bool + isLocal bool + numDocs = len(m.getVisibleMarkdowns()) + ) + + if numDocs > 0 { + md := m.selectedMarkdown() + isStashed = md != nil && md.markdownType == stashedMarkdown + isLocal = md != nil && md.markdownType == localMarkdown + } + + if m.state == stashStateSettingNote { + h = append(h, "enter: confirm", "esc: cancel") + } else if m.state == stashStatePromptDelete { + h = append(h, "y: delete", "n: cancel") + } else if m.state == stashStateFilterNotes && numDocs == 1 { + h = append(h, "enter: open", "esc: cancel") + } else if m.state == stashStateFilterNotes && numDocs == 0 { + h = append(h, "enter/esc: cancel") + } else if m.state == stashStateFilterNotes { + h = append(h, "enter: confirm", "esc: cancel", "ctrl+j/ctrl+k, ↑/↓: choose") + } else { + if len(m.markdowns) > 0 { + h = append(h, "enter: open") + } + if m.state == stashStateShowFiltered { + h = append(h, "esc: clear filter") + } + if len(m.markdowns) > 1 { + h = append(h, "j/k, ↑/↓: choose") + } + if m.paginator.TotalPages > 1 { + h = append(h, "h/l, ←/→: page") + } + if isStashed { + h = append(h, "x: delete", "m: set memo") + } else if isLocal && m.general.authStatus == authOK { + h = append(h, "s: stash") + } + if m.err != nil { + h = append(h, "!: errors") + } + h = append(h, "/: filter") + h = append(h, "q: quit") + } + return stashHelpViewBuilder(m.general.width, h...) +} + +// builds the help view from various sections pieces, truncating it if the view +// would otherwise wrap to two lines. +func stashHelpViewBuilder(windowWidth int, sections ...string) string { + if len(sections) == 0 { + return "" + } + + const truncationWidth = 1 // width of "…" + + var ( + s string + next string + maxWidth = windowWidth - stashViewHorizontalPadding - truncationWidth + ) + + for i := 0; i < len(sections); i++ { + // If we need this more often we'll formalize something rather than + // use an if clause/switch here. + switch sections[i] { + case "s: stash": + next = greenFg(sections[i]) + default: + next = stashHelpItemStyle(sections[i]) + } + + if i < len(sections)-1 { + next += dividerDot + } + + // Only this (and the following) help text items if we have the + // horizontal space + if ansi.PrintableRuneWidth(s)+ansi.PrintableRuneWidth(next) >= maxWidth { + s += common.Subtle("…") + break + } + + s += next + } + return s +} + // COMMANDS +func loadRemoteMarkdown(cc *charm.Client, id int, t markdownType) tea.Cmd { + return func() tea.Msg { + var ( + md *charm.Markdown + err error + ) + + if t == stashedMarkdown || t == convertedMarkdown { + md, err = cc.GetStashMarkdown(id) + } else { + md, err = cc.GetNewsMarkdown(id) + } + + if err != nil { + if debug { + log.Println("error loading remote markdown:", err) + } + return errMsg{err} + } + + return fetchedMarkdownMsg(&markdown{ + markdownType: t, + Markdown: *md, + }) + } +} + func loadLocalMarkdown(md *markdown) tea.Cmd { return func() tea.Msg { + if md.markdownType != localMarkdown { + 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 := os.ReadFile(md.localPath) + data, err := ioutil.ReadFile(md.localPath) if err != nil { - log.Debug("error reading local file", "error", err) + if debug { + log.Println("error reading local markdown:", err) + } return errMsg{err} } md.Body = string(data) @@ -865,16 +1130,28 @@ 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() { + if m.filterInput.Value() == "" || !m.isFiltering() { return filteredMarkdownMsg(m.markdowns) // return everything } targets := []string{} - mds := m.markdowns - for _, t := range mds { + for _, t := range m.markdowns { targets = append(targets, t.filterValue) } @@ -883,9 +1160,102 @@ func filterMarkdowns(m stashModel) tea.Cmd { filtered := []*markdown{} for _, r := range ranks { - filtered = append(filtered, mds[r.Index]) + filtered = append(filtered, m.markdowns[r.Index]) } return filteredMarkdownMsg(filtered) } } + +// ETC + +// Delete a markdown from a slice of markdowns +func deleteMarkdown(markdowns []*markdown, target *markdown) ([]*markdown, error) { + index := -1 + + for i, v := range markdowns { + switch target.markdownType { + case localMarkdown, convertedMarkdown: + if v.localPath == target.localPath { + index = i + } + case stashedMarkdown, newsMarkdown: + if v.ID == target.ID { + index = i + } + default: + return nil, errors.New("unknown markdown type") + } + } + + if index == -1 { + err := fmt.Errorf("could not find markdown to delete") + if debug { + log.Println(err) + } + return nil, err + } + + return append(markdowns[:index], markdowns[index+1:]...), nil +} + +// Normalize text to aid in the filtering process. In particular, we remove +// diacritics, "ö" becomes "o". +func normalize(in string) (string, error) { + t := transform.Chain(norm.NFD, transform.RemoveFunc(isMn), norm.NFC) + out, _, err := transform.String(t, in) + return out, err +} + +// Returns whether a given rune is a nonspacing mark (Mn is the key for +// nonspacing marks) +func isMn(r rune) bool { + return unicode.Is(unicode.Mn, r) +} + +// wrapMarkdowns wraps a *charm.Markdown with a *markdown in order to add some +// extra metadata. +func wrapMarkdowns(t markdownType, md []*charm.Markdown) (m []*markdown) { + for _, v := range md { + m = append(m, &markdown{ + markdownType: t, + Markdown: *v, + }) + } + return m +} + +func truncate(str string, num int) string { + return runewidth.Truncate(str, num, "…") +} + +var magnitudes = []humanize.RelTimeMagnitude{ + {D: time.Second, Format: "now", DivBy: time.Second}, + {D: 2 * time.Second, Format: "1 second %s", DivBy: 1}, + {D: time.Minute, Format: "%d seconds %s", DivBy: time.Second}, + {D: 2 * time.Minute, Format: "1 minute %s", DivBy: 1}, + {D: time.Hour, Format: "%d minutes %s", DivBy: time.Minute}, + {D: 2 * time.Hour, Format: "1 hour %s", DivBy: 1}, + {D: humanize.Day, Format: "%d hours %s", DivBy: time.Hour}, + {D: 2 * humanize.Day, Format: "1 day %s", DivBy: 1}, + {D: humanize.Week, Format: "%d days %s", DivBy: humanize.Day}, + {D: 2 * humanize.Week, Format: "1 week %s", DivBy: 1}, + {D: humanize.Month, Format: "%d weeks %s", DivBy: humanize.Week}, + {D: 2 * humanize.Month, Format: "1 month %s", DivBy: 1}, + {D: humanize.Year, Format: "%d months %s", DivBy: humanize.Month}, + {D: 18 * humanize.Month, Format: "1 year %s", DivBy: 1}, + {D: 2 * humanize.Year, Format: "2 years %s", DivBy: 1}, + {D: humanize.LongTime, Format: "%d years %s", DivBy: humanize.Year}, + {D: math.MaxInt64, Format: "a long while %s", DivBy: 1}, +} + +func relativeTime(then time.Time) string { + now := time.Now() + ago := now.Sub(then) + if ago < time.Minute { + return "just now" + } else if ago < humanize.Week { + return humanize.CustomRelTime(then, now, "ago", "from now", magnitudes) + } + return then.Format("02 Jan 2006 15:04 MST") +} diff --git a/ui/stashhelp.go b/ui/stashhelp.go deleted file mode 100644 index 6c0b474..0000000 --- a/ui/stashhelp.go +++ /dev/null @@ -1,297 +0,0 @@ -package ui - -import ( - "fmt" - "strings" - - "github.com/muesli/reflow/ansi" -) - -// helpEntry is a entry in a help menu containing values for a keystroke and -// it's associated action. -type helpEntry struct{ key, val string } - -// helpColumn is a group of helpEntries which will be rendered into a column. -type helpColumn []helpEntry - -// newHelpColumn creates a help column from pairs of string arguments -// representing keys and values. If the arguments are not even (and therein -// not every key has a matching value) the function will panic. -func newHelpColumn(pairs ...string) (h helpColumn) { - if len(pairs)%2 != 0 { - panic("help text group must have an even number of items") - } - - for i := 0; i < len(pairs); i = i + 2 { - h = append(h, helpEntry{key: pairs[i], val: pairs[i+1]}) - } - - return -} - -// render returns styled and formatted rows from keys and values. -func (h helpColumn) render(height int) (rows []string) { - keyWidth, valWidth := h.maxWidths() - - for i := 0; i < height; i++ { - var ( - b = strings.Builder{} - k, v string - ) - if i < len(h) { - k = h[i].key - v = h[i].val - - switch k { - case "s": - k = greenFg(k) - v = semiDimGreenFg(v) - default: - k = grayFg(k) - v = midGrayFg(v) - } - } - b.WriteString(k) - b.WriteString(strings.Repeat(" ", keyWidth-ansi.PrintableRuneWidth(k))) // pad keys - b.WriteString(" ") // gap - b.WriteString(v) - b.WriteString(strings.Repeat(" ", valWidth-ansi.PrintableRuneWidth(v))) // pad vals - rows = append(rows, b.String()) - } - - return -} - -// maxWidths returns the widest key and values in the column, respectively. -func (h helpColumn) maxWidths() (maxKey int, maxVal int) { - for _, v := range h { - kw := ansi.PrintableRuneWidth(v.key) - vw := ansi.PrintableRuneWidth(v.val) - if kw > maxKey { - maxKey = kw - } - if vw > maxVal { - maxVal = vw - } - } - - return -} - -// helpView returns either the mini or full help view depending on the state of -// the model, as well as the total height of the help view. -func (m stashModel) helpView() (string, int) { - numDocs := len(m.getVisibleMarkdowns()) - - // Help for when we're filtering - if m.filterState == filtering { - var h []string - - switch numDocs { - case 0: - h = []string{"enter/esc", "cancel"} - case 1: - h = []string{"enter", "open", "esc", "cancel"} - default: - h = []string{"enter", "confirm", "esc", "cancel", "ctrl+j/ctrl+k ↑/↓", "choose"} - } - - return m.renderHelp(h) - } - - var ( - navHelp []string - filterHelp []string - selectionHelp []string - editHelp []string - sectionHelp []string - appHelp []string - ) - - if numDocs > 0 && m.showFullHelp { - navHelp = []string{"enter", "open", "j/k ↑/↓", "choose"} - } - - if len(m.sections) > 1 { - if m.showFullHelp { - navHelp = append(navHelp, "tab/shift+tab", "section") - } else { - navHelp = append(navHelp, "tab", "section") - } - } - - if m.paginator().TotalPages > 1 { - navHelp = append(navHelp, "h/l ←/→", "page") - } - - // If we're browsing a filtered set - if m.filterApplied() { - filterHelp = []string{"/", "edit search", "esc", "clear filter"} - } else { - filterHelp = []string{"/", "find"} - } - - // If there are errors - if m.err != nil { - appHelp = append(appHelp, "!", "errors") - } - - appHelp = append(appHelp, "r", "refresh") - - if numDocs > 0 { - appHelp = append(appHelp, "e", "edit") - } - - appHelp = append(appHelp, "q", "quit") - - // Detailed help - if m.showFullHelp { - if m.filterState != filtering { - appHelp = append(appHelp, "?", "close help") - } - return m.renderHelp(navHelp, filterHelp, append(selectionHelp, editHelp...), sectionHelp, appHelp) - } - - // Mini help - if m.filterState != filtering { - appHelp = append(appHelp, "?", "more") - } - return m.renderHelp(navHelp, filterHelp, selectionHelp, editHelp, sectionHelp, appHelp) -} - -const minHelpViewHeight = 5 - -// renderHelp returns the rendered help view and associated line height for -// the given groups of help items. -func (m stashModel) renderHelp(groups ...[]string) (string, int) { - if m.showFullHelp { - str := m.fullHelpView(groups...) - numLines := strings.Count(str, "\n") + 1 - return str, max(numLines, minHelpViewHeight) - } - return m.miniHelpView(concatStringSlices(groups...)...), 1 -} - -// Builds the help view from various sections pieces, truncating it if the view -// would otherwise wrap to two lines. Help view entries should come in as pairs, -// with the first being the key and the second being the help text. -func (m stashModel) miniHelpView(entries ...string) string { - if len(entries) == 0 { - return "" - } - - var ( - truncationChar = subtleStyle.Render("…") - truncationWidth = ansi.PrintableRuneWidth(truncationChar) - ) - - var ( - next string - leftGutter = " " - maxWidth = m.common.width - - stashViewHorizontalPadding - - truncationWidth - - ansi.PrintableRuneWidth(leftGutter) - s = leftGutter - ) - - for i := 0; i < len(entries); i = i + 2 { - k := entries[i] - v := entries[i+1] - - k = grayFg(k) - v = midGrayFg(v) - - next = fmt.Sprintf("%s %s", k, v) - - if i < len(entries)-2 { - next += dividerDot.String() - } - - // Only this (and the following) help text items if we have the - // horizontal space - if ansi.PrintableRuneWidth(s)+ansi.PrintableRuneWidth(next) >= maxWidth { - s += truncationChar - break - } - - s += next - } - return s -} - -func (m stashModel) fullHelpView(groups ...[]string) string { - var tallestCol int - columns := make([]helpColumn, 0, len(groups)) - renderedCols := make([][]string, 0, len(groups)) // final rows grouped by column - - // Get key/value pairs - for _, g := range groups { - if len(g) == 0 { - continue // ignore empty columns - } - - columns = append(columns, newHelpColumn(g...)) - } - - // Find the tallest column - for _, c := range columns { - if len(c) > tallestCol { - tallestCol = len(c) - } - } - - // Build columns - for _, c := range columns { - renderedCols = append(renderedCols, c.render(tallestCol)) - } - - // Merge columns - return mergeColumns(renderedCols...) -} - -// Merge columns together to build the help view. -func mergeColumns(cols ...[]string) string { - const minimumHeight = 3 - - // Find the tallest column - var tallestCol int - for _, v := range cols { - n := len(v) - if n > tallestCol { - tallestCol = n - } - } - - // Make sure the tallest column meets the minimum height - if tallestCol < minimumHeight { - tallestCol = minimumHeight - } - - b := strings.Builder{} - for i := 0; i < tallestCol; i++ { - for j, col := range cols { - if i >= len(col) { - continue // skip if we're past the length of this column - } - if j == 0 { - b.WriteString(" ") // gutter - } else if j > 0 { - b.WriteString(" ") // gap - } - b.WriteString(col[i]) - } - if i < tallestCol-1 { - b.WriteRune('\n') - } - } - - return b.String() -} - -func concatStringSlices(s ...[]string) (agg []string) { - for _, v := range s { - agg = append(agg, v...) - } - return -} diff --git a/ui/stashitem.go b/ui/stashitem.go index 0000928..4ee727e 100644 --- a/ui/stashitem.go +++ b/ui/stashitem.go @@ -2,104 +2,136 @@ package ui import ( "fmt" + "log" "strings" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/log" - "github.com/muesli/reflow/truncate" + "github.com/charmbracelet/charm/ui/common" + rw "github.com/mattn/go-runewidth" + "github.com/muesli/termenv" "github.com/sahilm/fuzzy" ) const ( + newsPrefix = "News: " verticalLine = "│" + noMemoTitle = "No Memo" fileListingStashIcon = "• " ) func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) { var ( - truncateTo = uint(m.common.width - stashViewHorizontalPadding*2) //nolint:gosec - gutter string - title = truncate.StringWithTail(md.Note, truncateTo, ellipsis) - date = md.relativeTime() - editedBy = "" - hasEditedBy = false - icon = "" - separator = "" + truncateTo = m.general.width - stashViewHorizontalPadding*2 + gutter string + title = md.Note + date = relativeTime(md.CreatedAt) + icon = "" ) - isSelected := index == m.cursor() - isFiltering := m.filterState == filtering - singleFilteredItem := isFiltering && len(m.getVisibleMarkdowns()) == 1 + switch md.markdownType { + case newsMarkdown: + if title == "" { + title = "News" + } else { + title = newsPrefix + truncate(title, truncateTo-rw.StringWidth(newsPrefix)) + } + case stashedMarkdown, convertedMarkdown: + icon = fileListingStashIcon + if title == "" { + title = noMemoTitle + } + title = truncate(title, truncateTo-rw.StringWidth(icon)) + default: + title = truncate(title, truncateTo) + } - // If there are multiple items being filtered don't highlight a selected + isSelected := index == m.index + isFilteringNotes := m.state == stashStateFilterNotes + + // If there are multiple items being filtered we don't highlight a selected // item in the results. If we've filtered down to one item, however, // highlight that first item since pressing return will open it. - if isSelected && !isFiltering || singleFilteredItem { //nolint:nestif + singleFilteredItem := + m.state == stashStateFilterNotes && len(m.getVisibleMarkdowns()) == 1 + + if isSelected && !isFilteringNotes || singleFilteredItem { // Selected item - if m.statusMessage == stashingStatusMessage { - gutter = greenFg(verticalLine) - icon = dimGreenFg(icon) - title = greenFg(title) - date = semiDimGreenFg(date) - editedBy = semiDimGreenFg(editedBy) - separator = semiDimGreenFg(separator) - } else { + + switch m.state { + case stashStatePromptDelete: + gutter = faintRedFg(verticalLine) + icon = faintRedFg(icon) + title = redFg(title) + date = faintRedFg(date) + case stashStateSettingNote: + gutter = dullYellowFg(verticalLine) + icon = "" + title = m.noteInput.View() + date = dullYellowFg(date) + default: 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)) + icon = dullFuchsiaFg(icon) + if m.state == stashStateShowFiltered || singleFilteredItem { + s := termenv.Style{}.Foreground(common.Fuschia.Color()) + title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline()) } else { title = fuchsiaFg(title) - icon = fuchsiaFg(icon) } - date = dimFuchsiaFg(date) - editedBy = dimDullFuchsiaFg(editedBy) - separator = dullFuchsiaFg(separator) + date = dullFuchsiaFg(date) } } else { - gutter = " " - if m.statusMessage == stashingStatusMessage { + // Regular (non-selected) items + + if md.markdownType == newsMarkdown { + gutter = " " + + if isFilteringNotes && m.filterInput.Value() == "" { + title = dimIndigoFg(title) + date = dimSubtleIndigoFg(date) + } else { + s := termenv.Style{}.Foreground(common.Indigo.Color()) + title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline()) + date = subtleIndigoFg(date) + } + } else if isFilteringNotes && m.filterInput.Value() == "" { icon = dimGreenFg(icon) - title = greenFg(title) - date = semiDimGreenFg(date) - editedBy = semiDimGreenFg(editedBy) - separator = semiDimGreenFg(separator) - } else if isFiltering && m.filterInput.Value() == "" { - icon = dimGreenFg(icon) - title = dimNormalFg(title) - date = dimBrightGrayFg(date) - editedBy = dimBrightGrayFg(editedBy) - separator = dimBrightGrayFg(separator) + if title == noMemoTitle { + title = dimWarmGrayFg(title) + } else { + title = dimNormalFg(title) + } + gutter = " " + date = dimWarmGrayFg(date) + } else { + icon = greenFg(icon) - - s := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}) - title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true)) - date = grayFg(date) - editedBy = midGrayFg(editedBy) - separator = brightGrayFg(separator) + if title == noMemoTitle { + title = warmGrayFg(title) + } else { + s := termenv.Style{}.Foreground(common.NewColorPair("#dddddd", "#1a1a1a").Color()) + title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline()) + } + gutter = " " + date = warmGrayFg(date) } + } - fmt.Fprintf(b, "%s %s%s%s%s\n", gutter, icon, separator, separator, title) + fmt.Fprintf(b, "%s %s%s\n", gutter, icon, title) fmt.Fprintf(b, "%s %s", gutter, date) - if hasEditedBy { - fmt.Fprintf(b, " %s", editedBy) - } } -func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle lipgloss.Style) string { +func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle termenv.Style) string { b := strings.Builder{} normalizedHay, err := normalize(haystack) - if err != nil { - log.Error("error normalizing", "haystack", haystack, "error", err) + if err != nil && debug { + log.Printf("error normalizing '%s': %v", haystack, err) } matches := fuzzy.Find(needles, []string{normalizedHay}) if len(matches) == 0 { - return defaultStyle.Render(haystack) + return defaultStyle.Styled(haystack) } m := matches[0] // only one match exists @@ -107,12 +139,12 @@ func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle lipg styled := false for _, mi := range m.MatchedIndexes { if i == mi { - b.WriteString(matchedStyle.Render(string(rune))) + b.WriteString(matchedStyle.Styled(string(rune))) styled = true } } if !styled { - b.WriteString(defaultStyle.Render(string(rune))) + b.WriteString(defaultStyle.Styled(string(rune))) } } diff --git a/ui/styles.go b/ui/styles.go index fd25882..d8b4971 100644 --- a/ui/styles.go +++ b/ui/styles.go @@ -1,46 +1,53 @@ package ui -import "github.com/charmbracelet/lipgloss" - -// Colors. -var ( - normalDim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"} - gray = lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"} - midGray = lipgloss.AdaptiveColor{Light: "#B2B2B2", Dark: "#4A4A4A"} - darkGray = lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"} - brightGray = lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"} - dimBrightGray = lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"} - cream = lipgloss.AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"} - yellowGreen = lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"} - fuchsia = lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"} - dimFuchsia = lipgloss.AdaptiveColor{Light: "#F1A8FF", Dark: "#99519E"} - dullFuchsia = lipgloss.AdaptiveColor{Dark: "#AD58B4", Light: "#F793FF"} - dimDullFuchsia = lipgloss.AdaptiveColor{Light: "#F6C9FF", Dark: "#7B4380"} - green = lipgloss.Color("#04B575") - red = lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"} - semiDimGreen = lipgloss.AdaptiveColor{Light: "#35D79C", Dark: "#036B46"} - dimGreen = lipgloss.AdaptiveColor{Light: "#72D2B0", Dark: "#0B5137"} +import ( + "github.com/charmbracelet/charm/ui/common" + te "github.com/muesli/termenv" ) -// 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 +type styleFunc func(string) string + +const ( + darkGray = "#333333" ) + +var ( + normalFg = newFgStyle(common.NewColorPair("#dddddd", "#1a1a1a")) + dimNormalFg = newFgStyle(common.NewColorPair("#777777", "#A49FA5")) + + warmGrayFg = newFgStyle(common.NewColorPair("#979797", "#847A85")) + dimWarmGrayFg = newFgStyle(common.NewColorPair("#4D4D4D", "#C2B8C2")) + + grayFg = newFgStyle(common.NewColorPair("#626262", "#000")) + dimGrayFg = newFgStyle(common.NewColorPair("#3F3F3F", "#000")) + + greenFg = newFgStyle(common.NewColorPair("#04B575", "#04B575")) + dimGreenFg = newFgStyle(common.NewColorPair("#0B5137", "#82E1BF")) + + fuchsiaFg = newFgStyle(common.Fuschia) + dimFuchsiaFg = newFgStyle(common.NewColorPair("#99519E", "#F1A8FF")) + + dullFuchsiaFg = newFgStyle(common.NewColorPair("#AD58B4", "#F793FF")) + dimDullFuchsiaFg = newFgStyle(common.NewColorPair("#6B3A6F", "#F6C9FF")) + + indigoFg = newFgStyle(common.Indigo) + dimIndigoFg = newFgStyle(common.NewColorPair("#494690", "#9498FF")) + + subtleIndigoFg = newFgStyle(common.NewColorPair("#514DC1", "#7D79F6")) + dimSubtleIndigoFg = newFgStyle(common.NewColorPair("#383584", "#BBBDFF")) + + yellowFg = newFgStyle(common.YellowGreen) // renders light green on light backgrounds + dullYellowFg = newFgStyle(common.NewColorPair("#9BA92F", "#6BCB94")) // renders light green on light backgrounds + redFg = newFgStyle(common.Red) + faintRedFg = newFgStyle(common.FaintRed) +) + +// Returns a termenv style with foreground and background options. +func newStyle(fg, bg common.ColorPair) func(string) string { + return te.Style{}.Foreground(fg.Color()).Background(bg.Color()).Styled +} + +// Returns a new termenv style with background options only. +func newFgStyle(c common.ColorPair) styleFunc { + return te.Style{}.Foreground(c.Color()).Styled +} diff --git a/ui/ui.go b/ui/ui.go index 3537d3f..12c31df 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -1,72 +1,94 @@ -// Package ui provides the main UI for the glow application. package ui import ( + "errors" "fmt" + "io/ioutil" + "log" "os" - "path/filepath" + "path" "strings" "time" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/glamour/styles" - "github.com/charmbracelet/glow/v2/utils" - "github.com/charmbracelet/log" + "github.com/charmbracelet/charm" + "github.com/charmbracelet/charm/keygen" + "github.com/charmbracelet/charm/ui/common" + "github.com/charmbracelet/glow/utils" "github.com/muesli/gitcha" te "github.com/muesli/termenv" ) const ( - statusMessageTimeout = time.Second * 3 // how long to show status messages like "stashed!" - ellipsis = "…" + noteCharacterLimit = 256 // should match server + statusMessageTimeout = time.Second * 2 // how long to show status messages like "stashed!" ) var ( - config Config - - markdownExtensions = []string{ - "*.md", "*.mdown", "*.mkdn", "*.mkd", "*.markdown", - } + config Config + glowLogoTextColor = common.Color("#ECFD65") + debug = false // true if we're logging to a file, in which case we'll log more stuff ) -// NewProgram returns a new Tea program. -func NewProgram(cfg Config, content string) *tea.Program { - log.Debug( - "Starting glow", - "high_perf_pager", - cfg.HighPerformancePager, - "glamour", - cfg.GlamourEnabled, - ) +// Config contains TUI-specific configuration. +type Config struct { + ShowAllFiles bool + Gopath string `env:"GOPATH"` + HomeDir string `env:"HOME"` + GlamourMaxWidth uint + GlamourStyle string - config = cfg - opts := []tea.ProgramOption{tea.WithAltScreen()} - if cfg.EnableMouse { - opts = append(opts, tea.WithMouseCellMotion()) + // Which document types shall we show? We work though this with bitmasking. + DocumentTypes DocumentType + + // 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"` +} + +// 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 } - m := newModel(cfg, content) - return tea.NewProgram(m, opts...) + config = cfg + return tea.NewProgram(newModel(cfg)) } type errMsg struct{ 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 } -func (e errMsg) Error() string { return e.err.Error() } +func (e errMsg) Error() string { return e.err.Error() } +func (e errMsg) Unwrap() error { return e.err } +func (k keygenFailedMsg) Error() string { return k.err.Error() } +func (k keygenFailedMsg) Unwrap() error { return k.err } +func (s stashLoadErrMsg) Error() string { return s.err.Error() } +func (s stashLoadErrMsg) Unwrap() error { return s.err } +func (s newsLoadErrMsg) Error() string { return s.err.Error() } +func (s newsLoadErrMsg) Unwrap() error { return s.err } -type ( - initLocalFileSearchMsg struct { - cwd string - ch chan gitcha.SearchResult - } -) - -type ( - foundLocalFileMsg gitcha.SearchResult - localFileSearchFinished struct{} - statusMessageTimeoutMsg applicationContext -) - -// applicationContext indicates the area of the application something applies -// to. Occasionally used as an argument to commands and messages. +// Which part of the application something appies to. Occasionally used as an +// argument to commands and messages. type applicationContext int const ( @@ -74,7 +96,6 @@ const ( pagerContext ) -// state is the top-level application state. type state int const ( @@ -83,24 +104,51 @@ const ( ) func (s state) String() string { - return map[state]string{ - stateShowStash: "showing file listing", - stateShowDocument: "showing document", + return [...]string{ + "showing stash", + "showing document", }[s] } -// Common stuff we'll need to access in all models. -type commonModel struct { - cfg Config - cwd string - width int - height int +type 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 +) + +// General stuff we'll need to access in all models +type general struct { + cfg Config + cc *charm.Client + cwd string + authStatus authStatus + width int + height int } type model struct { - common *commonModel - state state - fatalErr error + general *general + keygenState keygenState + state state + fatalErr error // Sub-models stash stashModel @@ -115,88 +163,66 @@ type model struct { // method alters the model we also need to send along any commands returned. func (m *model) unloadDocument() []tea.Cmd { m.state = stateShowStash - m.stash.viewState = stashStateReady m.pager.unload() m.pager.showHelp = false - var batch []tea.Cmd - if m.pager.viewport.HighPerformanceRendering { - batch = append(batch, tea.ClearScrollArea) //nolint:staticcheck + if m.stash.filterInput.Value() == "" { + m.stash.state = stashStateReady + } else { + m.stash.state = stashStateShowFiltered } - if !m.stash.shouldSpin() { - batch = append(batch, m.stash.spinner.Tick) + var batch []tea.Cmd + if m.pager.viewport.HighPerformanceRendering { + batch = append(batch, tea.ClearScrollArea) + } + + if !m.stash.loadingDone() || m.stash.loadingFromNetwork { + batch = append(batch, spinner.Tick) } return batch } -func newModel(cfg Config, content string) tea.Model { - initSections() - - if cfg.GlamourStyle == styles.AutoStyle { - if te.HasDarkBackground() { - cfg.GlamourStyle = styles.DarkStyle +func newModel(cfg Config) tea.Model { + if cfg.GlamourStyle == "auto" { + dbg := te.HasDarkBackground() + if dbg { + cfg.GlamourStyle = "dark" } else { - cfg.GlamourStyle = styles.LightStyle + cfg.GlamourStyle = "light" } } - common := commonModel{ - cfg: cfg, + if cfg.DocumentTypes == 0 { + cfg.DocumentTypes = LocalDocuments | StashedDocuments | NewsDocuments } - m := model{ - common: &common, - state: stateShowStash, - pager: newPagerModel(&common), - stash: newStashModel(&common), + general := general{ + cfg: cfg, + authStatus: authConnecting, } - path := cfg.Path - if path == "" && content != "" { - m.state = stateShowDocument - m.pager.currentDocument = markdown{Body: content} - return m + return model{ + general: &general, + state: stateShowStash, + keygenState: keygenUnstarted, + pager: newPagerModel(&general), + stash: newStashModel(&general), } - - if path == "" { - path = "." - } - info, err := os.Stat(path) - if err != nil { - log.Error("unable to stat file", "file", path, "error", err) - m.fatalErr = err - return m - } - if info.IsDir() { - m.state = stateShowStash - } else { - cwd, _ := os.Getwd() - m.state = stateShowDocument - m.pager.currentDocument = markdown{ - localPath: path, - Note: stripAbsolutePath(path, cwd), - Modtime: info.ModTime(), - } - } - - return m } func (m model) Init() tea.Cmd { - cmds := []tea.Cmd{m.stash.spinner.Tick} + var cmds []tea.Cmd - 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)) + if m.general.cfg.DocumentTypes&StashedDocuments != 0 || m.general.cfg.DocumentTypes&NewsDocuments != 0 { + cmds = append(cmds, + newCharmClient, + spinner.Tick, + ) + } + + if m.general.cfg.DocumentTypes&LocalDocuments != 0 { + cmds = append(cmds, findLocalFiles(m)) } return tea.Batch(cmds...) @@ -215,84 +241,158 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { - case "esc": - if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument { - batch := m.unloadDocument() - return m, tea.Batch(batch...) - } - case "r": + case "q", "esc": 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) + + // Send q/esc through to stash + switch m.state { + case stateShowStash: + + switch m.stash.state { + + // Send q/esc through in these cases + case stashStateSettingNote, stashStatePromptDelete, + stashStateShowingError, stashStateFilterNotes, + stashStateShowFiltered: + + // If we're fitering, only send esc through so we can clear + // the filter results. Q quits as normal. + if m.stash.state == stashStateShowFiltered && msg.String() == "q" { + return m, tea.Quit + } + + m.stash, cmd = stashUpdate(msg, m.stash) return m, cmd } - m.stash.markdowns = nil - return m, m.Init() - } - case "q": - var cmd tea.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...) - switch m.state { //nolint:exhaustive - case stateShowStash: - // pass through all keys if we're editing the filter - if m.stash.filterState == filtering { - m.stash, cmd = m.stash.update(msg) - return m, cmd + // Otherwise let the user exit the view or application as + // normal. + default: + switch msg.String() { + case "q": + return m, tea.Quit + case "esc": + var batch []tea.Cmd + batch = m.unloadDocument() + return m, tea.Batch(batch...) + } } } return m, tea.Quit case "left", "h", "delete": - if m.state == stateShowDocument { + if m.state == stateShowDocument && m.pager.state != pagerStateSetNote { 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 + + // Repaint + case "ctrl+l": + // TODO + return m, nil } // Window size is received when starting up and on every resize case tea.WindowSizeMsg: - m.common.width = msg.Width - m.common.height = msg.Height + m.general.width = msg.Width + m.general.height = msg.Height m.stash.setSize(msg.Width, msg.Height) m.pager.setSize(msg.Width, msg.Height) case initLocalFileSearchMsg: m.localFileFinder = msg.ch - m.common.cwd = msg.cwd + m.general.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.general.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 |= StashedDocuments | NewsDocuments + m.stash.loadingFromNetwork = false + } + + case keygenFailedMsg: + // Keygen failed. That sucks. + m.general.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 |= StashedDocuments | NewsDocuments + m.stash.loadingFromNetwork = false + + 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.general.cc = msg + m.general.authStatus = authOK + cmds = append(cmds, loadStash(m.stash), loadNews(m.stash)) + + case stashLoadErrMsg: + m.general.authStatus = authFailed + case fetchedMarkdownMsg: // We've loaded a markdown file's contents for rendering m.pager.currentDocument = *msg - body := string(utils.RemoveFrontmatter([]byte(msg.Body))) - cmds = append(cmds, renderWithGlamour(m.pager, body)) + msg.Body = string(utils.RemoveFrontmatter([]byte(msg.Body))) + cmds = append(cmds, renderWithGlamour(m.pager, msg.Body)) case contentRenderedMsg: m.state = stateShowDocument - case localFileSearchFinished: + 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 := stashUpdate(msg, m.stash) + m.stash = stashModel + return m, cmd + + case localFileSearchFinished, gotStashMsg, gotNewsMsg: // Always pass these messages to the stash so we can keep it updated // about network activity, even if the user isn't currently viewing // the stash. - stashModel, cmd := m.stash.update(msg) + stashModel, cmd := stashUpdate(msg, m.stash) m.stash = stashModel return m, cmd case foundLocalFileMsg: - newMd := localFileToMarkdown(m.common.cwd, gitcha.SearchResult(msg)) + newMd := localFileToMarkdown(m.general.cwd, gitcha.SearchResult(msg)) m.stash.addMarkdowns(newMd) - if m.stash.filterApplied() { + if m.stash.isFiltering() { newMd.buildFilterValue() } if m.stash.shouldUpdateFilter() { @@ -300,23 +400,25 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } cmds = append(cmds, findNextLocalFile(m)) - case filteredMarkdownMsg: + case stashSuccessMsg: + // Something was stashed. Update the stash listing but don't run an + // actual update on the stash since we don't want to trigger the status + // message and generally don't want any other effects. if m.state == stateShowDocument { - newStashModel, cmd := m.stash.update(msg) - m.stash = newStashModel - cmds = append(cmds, cmd) + md := markdown(msg) + _ = m.stash.replaceLocalMarkdown(md.localPath, &md) } } // Process children switch m.state { case stateShowStash: - newStashModel, cmd := m.stash.update(msg) + newStashModel, cmd := stashUpdate(msg, m.stash) m.stash = newStashModel cmds = append(cmds, cmd) case stateShowDocument: - newPagerModel, cmd := m.pager.update(msg) + newPagerModel, cmd := m.pager.Update(msg) m.pager = newPagerModel cmds = append(cmds, cmd) } @@ -329,11 +431,11 @@ func (m model) View() string { return errorView(m.fatalErr, true) } - switch m.state { //nolint:exhaustive + switch m.state { case stateShowDocument: return m.pager.View() default: - return m.stash.view() + return stashView(m.stash) } } @@ -345,51 +447,38 @@ func errorView(err error, fatal bool) string { exitMsg += "return" } s := fmt.Sprintf("%s\n\n%v\n\n%s", - errorTitleStyle.Render("ERROR"), + te.String(" ERROR "). + Foreground(common.Cream.Color()). + Background(common.Red.Color()). + String(), err, - subtleStyle.Render(exitMsg), + common.Subtle(exitMsg), ) return "\n" + indent(s, 3) } // COMMANDS -func findLocalFiles(m commonModel) tea.Cmd { +func findLocalFiles(m model) tea.Cmd { return func() tea.Msg { - log.Info("findLocalFiles") - var ( - cwd = m.cfg.Path - err error - ) - - if cwd == "" { - cwd, err = os.Getwd() - } else { - var info os.FileInfo - info, err = os.Stat(cwd) - if err == nil && info.IsDir() { - cwd, err = filepath.Abs(cwd) - } - } - - // Note that this is one error check for both cases above + cwd, err := os.Getwd() if err != nil { - log.Error("error finding local files", "error", err) + if debug { + log.Println("error finding local files:", err) + } return errMsg{err} } - log.Debug("local directory is", "cwd", cwd) - - // Switch between FindFiles and FindAllFiles to bypass .gitignore rules - var ch chan gitcha.SearchResult - if m.cfg.ShowAllFiles { - ch, err = gitcha.FindAllFilesExcept(cwd, markdownExtensions, nil) - } else { - ch, err = gitcha.FindFilesExcept(cwd, markdownExtensions, ignorePatterns(m)) + var ignore []string + if !m.general.cfg.ShowAllFiles { + ignore = ignorePatterns(m) } + ch, err := gitcha.FindFilesExcept(cwd, []string{"*.md"}, ignore) if err != nil { - log.Error("error finding local files", "error", err) + if debug { + log.Println("error finding local files:", err) + } return errMsg{err} } @@ -406,11 +495,166 @@ func findNextLocalFile(m model) tea.Cmd { return foundLocalFileMsg(res) } // We're done - log.Debug("local file search finished") + if debug { + log.Println("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.general.cc == nil { + err := errors.New("no charm client") + if debug { + log.Println("error loading stash:", err) + } + return stashLoadErrMsg{err} + } + stash, err := m.general.cc.GetStash(m.page) + 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} + } + return gotStashMsg(stash) + } +} + +func loadNews(m stashModel) tea.Cmd { + return func() tea.Msg { + if m.general.cc == nil { + err := errors.New("no charm client") + if debug { + log.Println("error loading news:", err) + } + return newsLoadErrMsg{err} + } + news, err := m.general.cc.GetNews(1) // just fetch the first page + if err != nil { + if debug { + log.Println("error loading news:", err) + } + return newsLoadErrMsg{err} + } + 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 { + return func() tea.Msg { + err := errors.New("can't stash; no charm client") + if debug { + log.Println("error stashing document:", err) + } + return stashErrMsg{err} + } + } + + // Is the document missing a body? If so, it likely means it needs to + // be loaded. If the document body is really empty then we'll still + // stash it. + if len(md.Body) == 0 { + data, err := ioutil.ReadFile(md.localPath) + if err != nil { + if debug { + log.Println("error loading doucument body for stashing:", err) + } + return stashErrMsg{err} + } + md.Body = string(data) + } + + // Turn local markdown into a newly stashed (converted) markdown + md.markdownType = convertedMarkdown + md.CreatedAt = time.Now() + + // Set the note as the filename without the extension + p := md.localPath + md.Note = strings.Replace(path.Base(p), path.Ext(p), "", 1) + + newMd, err := cc.StashMarkdown(md.Note, md.Body) + if err != nil { + if debug { + log.Println("error stashing document:", err) + } + return stashErrMsg{err} + } + + // 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 @@ -420,21 +664,24 @@ func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.C // ETC -// Convert a Gitcha result to an internal representation of a markdown -// document. Note that we could be doing things like checking if the file is -// a directory, but we trust that gitcha has already done that. +// Convert local file path to Markdown. Note that we could be doing things +// like checking if the file is a directory, but we trust that gitcha has +// already done that. func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown { - return &markdown{ - localPath: res.Path, - Note: stripAbsolutePath(res.Path, cwd), - Modtime: res.Info.ModTime(), + md := &markdown{ + markdownType: localMarkdown, + localPath: res.Path, + Markdown: charm.Markdown{ + Note: stripAbsolutePath(res.Path, cwd), + CreatedAt: res.Info.ModTime(), + }, } + + return md } func stripAbsolutePath(fullPath, cwd string) string { - fp, _ := filepath.EvalSymlinks(fullPath) - cp, _ := filepath.EvalSymlinks(cwd) - return strings.ReplaceAll(fp, cp+string(os.PathSeparator), "") + return strings.Replace(fullPath, cwd+string(os.PathSeparator), "", -1) } // Lightweight version of reflow's indent function. @@ -450,3 +697,17 @@ 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 deleted file mode 100644 index f716681..0000000 --- a/url.go +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 9683892..0000000 --- a/url_test.go +++ /dev/null @@ -1,30 +0,0 @@ -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 9f33a1e..e03298c 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -1,20 +1,7 @@ -// Package utils provides utility functions. package utils -import ( - "os" - "path/filepath" - "regexp" - "strings" +import "regexp" - "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]:] @@ -30,84 +17,3 @@ func detectFrontmatter(c []byte) []int { } return []int{-1, -1} } - -// ExpandPath expands tilde and all environment variables from the given path. -func ExpandPath(path string) string { - s, err := homedir.Expand(path) - if err == nil { - return os.ExpandEnv(s) - } - return os.ExpandEnv(path) -} - -// WrapCodeBlock wraps a string in a code block with the given language. -func WrapCodeBlock(s, language string) string { - return "```" + language + "\n" + s + "```" -} - -var markdownExtensions = []string{ - ".md", ".mdown", ".mkdn", ".mkd", ".markdown", -} - -// IsMarkdownFile returns whether the filename has a markdown extension. -func IsMarkdownFile(filename string) bool { - ext := filepath.Ext(filename) - - if ext == "" { - // By default, assume it's a markdown file. - return true - } - - for _, v := range markdownExtensions { - if strings.EqualFold(ext, v) { - return true - } - } - - // Has an extension but not markdown - // so assume this is a code file. - return false -} - -// GlamourStyle returns a glamour.TermRendererOption based on the given style. -func GlamourStyle(style string, isCode bool) glamour.TermRendererOption { - if !isCode { - if style == styles.AutoStyle { - return glamour.WithAutoStyle() - } - return glamour.WithStylePath(style) - } - - // If we are rendering a pure code block, we need to modify the style to - // remove the indentation. - - var styleConfig ansi.StyleConfig - - switch style { - case styles.AutoStyle: - if lipgloss.HasDarkBackground() { - styleConfig = styles.DarkStyleConfig - } else { - styleConfig = styles.LightStyleConfig - } - case styles.DarkStyle: - styleConfig = styles.DarkStyleConfig - case styles.LightStyle: - styleConfig = styles.LightStyleConfig - case styles.PinkStyle: - styleConfig = styles.PinkStyleConfig - case styles.NoTTYStyle: - styleConfig = styles.NoTTYStyleConfig - case styles.DraculaStyle: - styleConfig = styles.DraculaStyleConfig - case styles.TokyoNightStyle: - styleConfig = styles.DraculaStyleConfig - default: - return glamour.WithStylesFromJSONFile(style) - } - - var margin uint - styleConfig.CodeBlock.Margin = &margin - - return glamour.WithStyles(styleConfig) -}