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
index fbc2046..e865c5a 100644
--- a/.github/workflows/goreleaser.yml
+++ b/.github/workflows/goreleaser.yml
@@ -1,25 +1,34 @@
-# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
-
name: goreleaser
on:
+ pull_request:
push:
- tags:
- - v*.*.*
-
-concurrency:
- group: goreleaser
- cancel-in-progress: true
jobs:
goreleaser:
- 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 }}
+ runs-on: ubuntu-latest
+ steps:
+ -
+ name: Checkout
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+ -
+ name: Set up Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: ~1.16
+ -
+ name: Set up Snapcraft
+ env:
+ SNAPCRAFT_LOGIN: ${{ secrets.SNAPCRAFT_LOGIN }}
+ run: |
+ sudo apt-get update
+ sudo apt-get -yq --no-install-suggests --no-install-recommends install snapcraft
+ snapcraft login --with <(echo "$SNAPCRAFT_LOGIN")
+ -
+ name: Run GoReleaser
+ uses: goreleaser/goreleaser-action@v2
+ with:
+ version: latest
+ args: release --snapshot --skip-publish --skip-sign --rm-dist
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..d4530e4 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,44 +1,27 @@
-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
+ - golint
+ - gomnd
+ - goprintffuncname
+ - gosec
+ - misspell
+ - prealloc
+ - rowserrcheck
+ - sqlclosecheck
+ - unconvert
+ - unparam
+ - whitespace
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 0c539b7..04f31e0 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -1,14 +1,95 @@
-# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
+env:
+ - GO111MODULE=on
+ - CGO_ENABLED=0
+before:
+ hooks:
+ - go mod tidy
+builds:
+ -
+ binary: glow
+ ldflags: -s -w -X main.Version={{ .Version }} -X main.CommitSHA={{ .Commit }}
+ goos:
+ - linux
+ - freebsd
+ - openbsd
+ - darwin
+ - windows
+ goarch:
+ - amd64
+ - arm64
+ - 386
+ - arm
+ goarm:
+ - 6
+ - 7
-version: 2
+archives:
+ -
+ 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:
+ -
+ vendor: charmbracelet
+ homepage: "https://charm.sh/"
+ maintainer: "Christian Muehlhaeuser
-
+
-
+
+
+
+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..a90b903 100644
--- a/config_cmd.go
+++ b/config_cmd.go
@@ -3,86 +3,82 @@ 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"
+# show local files only; no network (TUI-mode only)
+local: false
# mouse support (TUI-mode only)
mouse: false
# use pager to display markdown
pager: false
# word-wrap at width
-width: 80
-# 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..7cf29bc 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..5ec89c7 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..b7e6c74 100644
--- a/go.mod
+++ b/go.mod
@@ -1,75 +1,29 @@
-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/charmbracelet/bubbles v0.7.6
+ github.com/charmbracelet/bubbletea v0.13.1
+ github.com/charmbracelet/charm v0.8.6
+ github.com/charmbracelet/glamour v0.2.1-0.20210311152407-2b8307dcb400
+ 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.10
+ github.com/meowgorithm/babyenv v1.3.1
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/muesli/gitcha v0.2.0
+ github.com/muesli/go-app-paths v0.2.1
+ github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68
+ github.com/muesli/termenv v0.8.0
+ github.com/sahilm/fuzzy v0.1.0
+ github.com/segmentio/ksuid v1.0.3
+ github.com/spf13/cobra v1.1.3
+ github.com/spf13/viper v1.7.1
+ golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
+ golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc // indirect
+ golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78
+ golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect
+ golang.org/x/text v0.3.2
)
diff --git a/go.sum b/go.sum
index 0ccd7cd..27c528d 100644
--- a/go.sum
+++ b/go.sum
@@ -1,177 +1,444 @@
-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.8.2 h1:x3zkuE2lUk/RIekyAJ3XRqSCP4zwWDfcw/YJCuCAACg=
+github.com/alecthomas/chroma v0.8.2/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM=
+github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
+github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
+github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE=
+github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY=
+github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
+github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/armon/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.7.5/go.mod h1:IRTORFvhEI6OUH7WhN2Ks8Z8miNGimk1BE6cmHijOkM=
+github.com/charmbracelet/bubbles v0.7.6 h1:SCAp4ZEUf2tBNEsufo+Xxxu2dvbFhYSDPrX45toQZrM=
+github.com/charmbracelet/bubbles v0.7.6/go.mod h1:0D4XRYK0tjo8JMvflz1obpVcOikNZSG46SFauoZj22s=
+github.com/charmbracelet/bubbletea v0.12.2/go.mod h1:3gZkYELUOiEUOp0bTInkxguucy/xRbGSOcbMs1geLxg=
+github.com/charmbracelet/bubbletea v0.13.1 h1:huvX8mPaeMZ8DLulT50iEWRF+iitY5FNEDqDVLu69nM=
+github.com/charmbracelet/bubbletea v0.13.1/go.mod h1:tp9tr9Dadh0PLhgiwchE5zZJXm5543JYjHG9oY+5qSg=
+github.com/charmbracelet/charm v0.8.6 h1:/U6rxGj4J6zZ1Ex8+wTr4hNMr4ESBzNZbC1UyrJPVbg=
+github.com/charmbracelet/charm v0.8.6/go.mod h1:8dE3uX+TYSpa7Q6e/CmjN6WSd7koSAKNQTGWugFREx4=
+github.com/charmbracelet/glamour v0.2.1-0.20210311152407-2b8307dcb400 h1:vj8zwTsLNE37zzcmkkBsG+HBtniUtLdQe6J206C+i30=
+github.com/charmbracelet/glamour v0.2.1-0.20210311152407-2b8307dcb400/go.mod h1:VO5pQW96Vj3qJy9ikwb5Zo8lcvt2ZDFoE3UXl+SduQs=
+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.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/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/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/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg=
+github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/meowgorithm/babyenv v1.3.0/go.mod h1:lwNX+J6AGBFqNrMZ2PTLkM6SO+W4X8DOg9zBDO4j3Ig=
+github.com/meowgorithm/babyenv v1.3.1 h1:18ZEYIgbzoFQfRLF9+lxjRfk/ui6w8U0FWl07CgWvvc=
+github.com/meowgorithm/babyenv v1.3.1/go.mod h1:lwNX+J6AGBFqNrMZ2PTLkM6SO+W4X8DOg9zBDO4j3Ig=
+github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg=
+github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
+github.com/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.2.0 h1:+wOgT2dI9s2Tznj1t1rb/qkK5e0cb6qD8c4IX2TR/YY=
+github.com/muesli/gitcha v0.2.0/go.mod h1:Ri8m9TZS4+ORG4JVmVKUQcWZuxDvUW3UKxMdQfzG2zI=
+github.com/muesli/go-app-paths v0.2.1 h1:Qi+2igkDX2aPqyRddp7P0sMQIBwBqhkfQfNcjdGjL6Y=
+github.com/muesli/go-app-paths v0.2.1/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho=
+github.com/muesli/reflow v0.1.0/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I=
+github.com/muesli/reflow v0.2.0/go.mod h1:qT22vjVmM9MIUeLgsVYe/Ye7eZlbv9dZjL3dVhUqLX8=
+github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9FnmSjdbWUlLGvcxrY0Rw3ATltrxOhk=
+github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
+github.com/muesli/sasquatch v0.0.0-20200811221207-66979d92330a h1:Hw/15RYEOUD6T9UCRkUmNBa33kJkH33Fui6hE4sRLKU=
+github.com/muesli/sasquatch v0.0.0-20200811221207-66979d92330a/go.mod h1:+XG0ne5zXWBTSbbe7Z3/RWxaT8PZY6zaZ1dX6KjprYY=
+github.com/muesli/termenv v0.7.2/go.mod h1:ct2L5N2lmix82RaY3bMWwVu/jUFc9Ule0KGDCiKYPh8=
+github.com/muesli/termenv v0.7.4/go.mod h1:pZ7qY9l3F7e5xsAOS0zCew2tME+p7bWeBkotCEcIIcc=
+github.com/muesli/termenv v0.8.0 h1:HxhDOZbpf9nSUhOiMUNk5jsL3XaKWAbHucoGYZf+nag=
+github.com/muesli/termenv v0.8.0/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
+github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
+github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
+github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
+github.com/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/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/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/segmentio/ksuid v1.0.3 h1:FoResxvleQwYiPAVKe1tMUlEirodZqlqglIuFsdDntY=
+github.com/segmentio/ksuid v1.0.3/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
+github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
+github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
+github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
+github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
+github.com/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.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M=
+github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
+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/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
+github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk=
+github.com/spf13/viper v1.7.1/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/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.3.1 h1:eVwehsLsZlCJCwXyGLgg+Q4iFWE/eTIMG0e8waCmm/I=
+github.com/yuin/goldmark v1.3.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
+github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
+go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
+go.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-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY=
+golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
+golang.org/x/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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78 h1:nVuTkr9L6Bq62qpUqKo/RnZCFfzDBL0bYo6w9OJUqZY=
+golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
+golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M=
+golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.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.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/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..44fd333 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|DIR]",
+ 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,31 @@ 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
-}
-
-// 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
+ u, _ := filepath.Abs(arg)
+ return &source{r, u}, err
}
func validateOptions(cmd *cobra.Command) error {
// grab config values from Viper
width = viper.GetUint("width")
+ localOnly = viper.GetBool("local")
mouse = viper.GetBool("mouse")
pager = viper.GetBool("pager")
- tui = viper.GetBool("tui")
- showAllFiles = viper.GetBool("all")
- preserveNewLines = viper.GetBool("preserveNewLines")
- showLineNumbers = viper.GetBool("showLineNumbers")
-
- if pager && tui {
- return errors.New("cannot use both pager and tui")
- }
// validate the glamour style
style = viper.GetString("style")
- if err := validateStyle(style); err != nil {
- return err
+ if style != "auto" && glamour.DefaultStyles[style] == nil {
+ style = utils.ExpandPath(style)
+ if _, err := os.Stat(style); os.IsNotExist(err) {
+ return fmt.Errorf("Specified style does not exist: %s", style)
+ } else if err != nil {
+ return err
+ }
}
- 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,50 +161,33 @@ 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 isTerminal && width == 0 && !cmd.Flags().Changed("width") {
+ w, _, err := terminal.GetSize(int(os.Stdout.Fd()))
+ if err == nil {
+ width = uint(w)
+ }
- if width > 120 {
- width = 120
- }
- }
- if width == 0 {
- width = 80
+ if width > 120 {
+ width = 120
}
}
+ if width == 0 {
+ width = 80
+ }
return nil
}
-func stdinIsPipe() (bool, error) {
- stat, err := os.Stdin.Stat()
- if err != nil {
- return false, fmt.Errorf("unable to open file: %w", err)
- }
- if stat.Mode()&os.ModeCharDevice == 0 || stat.Size() > 0 {
- return true, nil
- }
- return false, nil
-}
-
func execute(cmd *cobra.Command, args []string) error {
- // if stdin is a pipe then use stdin for input. note that you can also
- // explicitly use a - to read from stdin.
- if yes, err := stdinIsPipe(); err != nil {
+ initConfig()
+ if err := validateOptions(cmd); err != nil {
return err
- } else if yes {
- src := &source{reader: os.Stdin}
- defer src.reader.Close() //nolint:errcheck
- return executeCLI(cmd, src, os.Stdout)
}
switch len(args) {
+
// TUI running on cwd
case 0:
- return runTUI("", "")
+ return runTUI("", false)
// TUI with possible dir argument
case 1:
@@ -245,7 +197,7 @@ func execute(cmd *cobra.Command, args []string) error {
if err == nil && info.IsDir() {
p, err := filepath.Abs(args[0])
if err == nil {
- return runTUI(p, "")
+ return runTUI(p, false)
}
}
fallthrough
@@ -268,14 +220,10 @@ func executeArg(cmd *cobra.Command, arg string, w io.Writer) error {
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 +236,110 @@ 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"):
+ if pager || cmd.Flags().Changed("pager") {
pagerCmd := os.Getenv("PAGER")
if pagerCmd == "" {
pagerCmd = "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(pagerCmd, " ")
+ 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(workingDirectory string, 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.WorkingDirectory = workingDirectory
+ cfg.DocumentTypes = ui.NewDocTypeSet()
cfg.ShowAllFiles = showAllFiles
- cfg.ShowLineNumbers = showLineNumbers
cfg.GlamourMaxWidth = width
- cfg.EnableMouse = mouse
- cfg.PreserveNewLines = preserveNewLines
+ cfg.GlamourStyle = style
+
+ if stashedOnly {
+ cfg.DocumentTypes.Add(ui.StashedDoc, ui.NewsDoc)
+ } else if localOnly {
+ cfg.DocumentTypes.Add(ui.LocalDoc)
+ }
// 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 +348,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..e6b3ea4
--- /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
index 001d5b8..67d94d6 100644
--- a/ui/config.go
+++ b/ui/config.go
@@ -2,19 +2,32 @@ 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
+ ShowAllFiles bool
+ Gopath string `env:"GOPATH"`
+ HomeDir string `env:"HOME"`
+ GlamourMaxWidth uint
+ GlamourStyle string
- // Working directory or file path
- Path string
+ // Which directory should we start from?
+ WorkingDirectory string
+
+ // Which document types shall we show?
+ DocumentTypes DocTypeSet
// For debugging the UI
- HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" envDefault:"true"`
- GlamourEnabled bool `env:"GLOW_ENABLE_GLAMOUR" envDefault:"true"`
+ Logfile string `env:"GLOW_LOGFILE"`
+ HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" default:"true"`
+ GlamourEnabled bool `env:"GLOW_ENABLE_GLAMOUR" default:"true"`
+}
+
+func (c Config) showLocalFiles() bool {
+ return c.DocumentTypes.Contains(LocalDoc)
+}
+
+func (c Config) localOnly() bool {
+ return c.DocumentTypes.Equals(NewDocTypeSet(LocalDoc))
+}
+
+func (c Config) stashedOnly() bool {
+ return c.DocumentTypes.Contains(StashedDoc) && !c.DocumentTypes.Contains(LocalDoc)
}
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/doctypes.go b/ui/doctypes.go
new file mode 100644
index 0000000..8d2ef14
--- /dev/null
+++ b/ui/doctypes.go
@@ -0,0 +1,90 @@
+package ui
+
+// DocType represents a type of markdown document.
+type DocType int
+
+// Available document types.
+const (
+ NoDocType DocType = iota
+ LocalDoc
+ StashedDoc
+ ConvertedDoc
+ NewsDoc
+)
+
+func (d DocType) String() string {
+ return [...]string{
+ "none",
+ "local",
+ "stashed",
+ "converted",
+ "news",
+ }[d]
+}
+
+// DocTypeSet is a set (in the mathematic sense) of document types.
+type DocTypeSet map[DocType]struct{}
+
+// NewDocTypeSet returns a set of document types.
+func NewDocTypeSet(t ...DocType) DocTypeSet {
+ d := DocTypeSet(make(map[DocType]struct{}))
+ if len(t) > 0 {
+ d.Add(t...)
+ }
+ return d
+}
+
+// Add adds a document type of the set.
+func (d *DocTypeSet) Add(t ...DocType) int {
+ for _, v := range t {
+ (*d)[v] = struct{}{}
+ }
+ return len(*d)
+}
+
+// Contains returns whether or not the set contains the given DocTypes.
+func (d DocTypeSet) Contains(m ...DocType) bool {
+ matches := 0
+ for _, t := range m {
+ if _, found := d[t]; found {
+ matches++
+ }
+ }
+ return matches == len(m)
+}
+
+// Difference return a DocumentType set that does not contain the given types.
+func (d DocTypeSet) Difference(t ...DocType) DocTypeSet {
+ c := copyDocumentTypes(d)
+ for k := range c {
+ for _, docType := range t {
+ if k == docType {
+ delete(c, k)
+ break
+ }
+ }
+ }
+ return c
+}
+
+// Equals returns whether or not the two sets are equal.
+func (d DocTypeSet) Equals(other DocTypeSet) bool {
+ return d.Contains(other.AsSlice()...) && len(d) == len(other)
+}
+
+// AsSlice returns the set as a slice of document types.
+func (d DocTypeSet) AsSlice() (agg []DocType) {
+ for k := range d {
+ agg = append(agg, k)
+ }
+ return
+}
+
+// Return a copy of the given DoctTypes map.
+func copyDocumentTypes(d DocTypeSet) DocTypeSet {
+ c := make(map[DocType]struct{})
+ for k, v := range d {
+ c[k] = v
+ }
+ return c
+}
diff --git a/ui/doctypes_test.go b/ui/doctypes_test.go
new file mode 100644
index 0000000..e882b14
--- /dev/null
+++ b/ui/doctypes_test.go
@@ -0,0 +1,48 @@
+package ui
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestDocTypeContains(t *testing.T) {
+ d := NewDocTypeSet(LocalDoc)
+
+ if !d.Contains(LocalDoc) {
+ t.Error("Contains reported it doesn't contain a value which it absolutely does contain")
+ }
+
+ if d.Contains(NewsDoc) {
+ t.Error("Contains reported the set contains a value it certainly does not")
+ }
+}
+
+func TestDocTypeDifference(t *testing.T) {
+ original := NewDocTypeSet(LocalDoc, StashedDoc, ConvertedDoc, NewsDoc)
+ difference := original.Difference(LocalDoc, NewsDoc)
+ expected := NewDocTypeSet(StashedDoc, ConvertedDoc)
+
+ // Make sure the difference operation worked
+ if !reflect.DeepEqual(difference, expected) {
+ t.Errorf("difference returned %+v; expected %+v", difference, expected)
+ }
+
+ // Make sure original set was not mutated
+ if reflect.DeepEqual(original, difference) {
+ t.Errorf("original set was mutated when it should not have been")
+ }
+}
+
+func TestDocTypeEquality(t *testing.T) {
+ a := NewDocTypeSet(LocalDoc, StashedDoc)
+ b := NewDocTypeSet(LocalDoc, StashedDoc)
+ c := NewDocTypeSet(LocalDoc)
+
+ if !a.Equals(b) {
+ t.Errorf("Equality test failed for %+v and %+v; expected true, got false", a, b)
+ }
+
+ if a.Equals(c) {
+ t.Errorf("Equality test failed for %+v and %+v; expected false, got true", a, c)
+ }
+}
diff --git a/ui/editor.go b/ui/editor.go
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..cccd305 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.common.cfg.HomeDir, "Library"),
+ m.common.cfg.Gopath,
"node_modules",
".*",
}
diff --git a/ui/ignore_general.go b/ui/ignore_general.go
index f4dc8be..21f4734 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.common.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..f6f8f2c 100644
--- a/ui/markdown.go
+++ b/ui/markdown.go
@@ -1,19 +1,42 @@
package ui
import (
- "fmt"
+ "log"
"math"
+ "path"
+ "strings"
"time"
"unicode"
- "github.com/charmbracelet/log"
+ "github.com/charmbracelet/charm"
"github.com/dustin/go-humanize"
+ "github.com/segmentio/ksuid"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
+// markdown wraps charm.Markdown.
type markdown struct {
+ docType DocType
+
+ // Stash identifier. This exists so we can keep track of documents stashed
+ // in-session as they relate to their original, non-stashed counterparts.
+ // All documents have a stashID, however when a document is stashed that
+ // document inherits the stashID of the original.
+ stashID ksuid.KSUID
+
+ // Unique identifier. Unlike the stash identifier, this value should always
+ // be unique so we can confidently find it an operate on it (versus stashID,
+ // which could match both an original or stashed document).
+ uniqueID ksuid.KSUID
+
+ // Some of the document's original values before this document was stashed.
+ // These are irrelevant if this document was not stashed in this session.
+ originalDocType DocType
+ originalTimestamp time.Time
+ originalNote string
+
// Full path of a local markdown file. Only relevant to local documents and
// those that have been stashed in this session.
localPath string
@@ -23,24 +46,102 @@ type markdown struct {
// field is ephemeral, and should only be referenced during filtering.
filterValue string
- Body string
- Note string
- Modtime time.Time
+ charm.Markdown
+}
+
+func (m *markdown) generateIDs() {
+ if m.stashID.IsNil() {
+ m.stashID = ksuid.New()
+ }
+ m.uniqueID = ksuid.New()
+}
+
+// convertToStashed converts this document into its stashed state.
+func (m *markdown) convertToStashed() {
+ if m.docType == ConvertedDoc {
+ if debug {
+ log.Println("not converting already converted document:", m)
+ }
+ return
+ }
+
+ m.originalDocType = m.docType
+ m.originalTimestamp = m.CreatedAt
+ m.originalNote = m.Note
+
+ if m.docType == LocalDoc {
+ m.Note = strings.Replace(path.Base(m.localPath), path.Ext(m.localPath), "", 1)
+ }
+ m.CreatedAt = time.Now()
+ m.docType = ConvertedDoc
+}
+
+// revert reverts this document from its stashed state.
+func (m *markdown) revertFromStashed() {
+ if m.docType != ConvertedDoc {
+ log.Printf("not reverting document of type %s: %v", m.docType, m)
+ }
+
+ m.docType = m.originalDocType
+ m.CreatedAt = m.originalTimestamp
+ m.Note = m.originalNote
}
// 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
}
m.filterValue = note
}
+// shouldSortAsLocal returns whether or not this markdown should be sorted as though
+// it's a local markdown document.
+func (m markdown) shouldSortAsLocal() bool {
+ return m.docType == LocalDoc || m.docType == ConvertedDoc
+}
+
+// Sort documents with local files first, then by date.
+type markdownsByLocalFirst []*markdown
+
+func (m markdownsByLocalFirst) Len() int { return len(m) }
+func (m markdownsByLocalFirst) Swap(i, j int) { m[i], m[j] = m[j], m[i] }
+func (m markdownsByLocalFirst) Less(i, j int) bool {
+ iIsLocal := m[i].shouldSortAsLocal()
+ jIsLocal := m[j].shouldSortAsLocal()
+
+ // Local files (and files that used to be local) come first
+ if iIsLocal && !jIsLocal {
+ return true
+ }
+ if !iIsLocal && jIsLocal {
+ return false
+ }
+
+ // If both are local files, sort by filename. Note that we should never
+ // hit equality here since two files can't have the same path.
+ if iIsLocal && jIsLocal {
+ return strings.Compare(m[i].localPath, m[j].localPath) == -1
+ }
+
+ // Neither are local files so sort by date descending
+ if !m[i].CreatedAt.Equal(m[j].CreatedAt) {
+ return m[i].CreatedAt.After(m[j].CreatedAt)
+ }
+
+ // If the times also match, sort by unqiue ID.
+ ids := []ksuid.KSUID{m[i].uniqueID, m[j].uniqueID}
+ ksuid.Sort(ids)
+ return ids[0] == m[i].uniqueID
+}
+
func (m markdown) relativeTime() string {
- return relativeTime(m.Modtime)
+ return relativeTime(m.CreatedAt)
}
// Normalize text to aid in the filtering process. In particular, we remove
@@ -49,16 +150,26 @@ func (m markdown) relativeTime() string {
func normalize(in string) (string, error) {
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
out, _, err := transform.String(t, in)
- if err != nil {
- return "", fmt.Errorf("error normalizing: %w", err)
+ return out, err
+}
+
+// wrapMarkdowns wraps a *charm.Markdown with a *markdown in order to add some
+// extra metadata.
+func wrapMarkdowns(t DocType, md []*charm.Markdown) (m []*markdown) {
+ for _, v := range md {
+ m = append(m, &markdown{
+ docType: t,
+ Markdown: *v,
+ })
}
- return out, nil
+ return m
}
// 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 {
+ 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)
diff --git a/ui/pager.go b/ui/pager.go
index 5c522e7..7f43019 100644
--- a/ui/pager.go
+++ b/ui/pager.go
@@ -2,98 +2,72 @@ 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"
+ lib "github.com/charmbracelet/charm/ui/common"
"github.com/charmbracelet/glamour"
- "github.com/charmbracelet/glow/v2/utils"
- "github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/log"
- "github.com/fsnotify/fsnotify"
runewidth "github.com/mattn/go-runewidth"
"github.com/muesli/reflow/ansi"
"github.com/muesli/reflow/truncate"
- "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 = lib.NewColorPair("#89F0CB", "#89F0CB")
+ darkGreen = lib.NewColorPair("#1C8760", "#1C8760")
- lineNumberFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
+ noteHeading = te.String(" Set Memo ").
+ Foreground(lib.Cream.Color()).
+ Background(lib.Green.Color()).
+ String()
- statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
- statusBarBg = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"}
+ statusBarNoteFg = lib.NewColorPair("#7D7D7D", "#656565")
+ statusBarBg = lib.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(lib.NewColorPair("#5A5A5A", "#949494"), statusBarBg, false)
+ statusBarNoteStyle = newStyle(statusBarNoteFg, statusBarBg, false)
+ statusBarHelpStyle = newStyle(statusBarNoteFg, lib.NewColorPair("#323232", "#DCDCDC"), false)
+ statusBarStashDotStyle = newStyle(lib.Green, statusBarBg, false)
+ statusBarMessageStyle = newStyle(mintGreen, darkGreen, false)
+ statusBarMessageStashIconStyle = newStyle(mintGreen, darkGreen, false)
+ statusBarMessageScrollPosStyle = newStyle(mintGreen, darkGreen, false)
+ statusBarMessageHelpStyle = newStyle(lib.NewColorPair("#B6FFE4", "#B6FFE4"), lib.Green, false)
+ helpViewStyle = newStyle(statusBarNoteFg, lib.NewColorPair("#1B1B1B", "#f2f2f2"), false)
)
-type (
- contentRenderedMsg string
- reloadMsg struct{}
-)
+type contentRenderedMsg string
+type noteSavedMsg *charm.Markdown
type pagerState int
const (
pagerStateBrowse pagerState = iota
+ pagerStateSetNote
+ pagerStateStashing
+ pagerStateStashSuccess
pagerStateStatusMessage
)
type pagerModel struct {
- common *commonModel
- viewport viewport.Model
- state pagerState
- showHelp bool
+ common *commonModel
+ viewport viewport.Model
+ state pagerState
+ showHelp bool
+ textInput textinput.Model
+ spinner spinner.Model
statusMessage string
statusMessageTimer *time.Timer
@@ -102,27 +76,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 {
// 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(lib.Color(darkGray)).
+ Background(lib.YellowGreen.Color()).
+ String()
+ ti.TextColor = darkGray
+ ti.BackgroundColor = lib.YellowGreen.String()
+ ti.CursorColor = lib.Fuschia.String()
+ ti.CharLimit = noteCharacterLimit
+ ti.Focus()
+
+ // Text input for search
+ sp := spinner.NewModel()
+ sp.ForegroundColor = statusBarNoteFg.String()
+ sp.BackgroundColor = statusBarBg.String()
+ sp.HideFor = time.Millisecond * 50
+ sp.MinimumLifetime = time.Millisecond * 180
+
+ return pagerModel{
+ common: common,
+ state: pagerStateBrowse,
+ textInput: ti,
+ viewport: vp,
+ spinner: sp,
}
- m.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 {
@@ -144,18 +142,13 @@ func (m *pagerModel) toggleHelp() {
}
}
-type pagerStatusMessage struct {
- message string
- isError bool
-}
-
// Perform stuff that needs to happen after a successful markdown stash. Note
-// that the 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 +158,6 @@ func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd {
}
func (m *pagerModel) unload() {
- log.Debug("unload")
if m.showHelp {
m.toggleHelp()
}
@@ -175,7 +167,7 @@ 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) {
@@ -186,95 +178,156 @@ 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.common.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.docType == StashedDoc ||
+ m.currentDocument.docType == ConvertedDoc
- 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.common.authStatus != authOK {
+ break
+ }
+
+ md := m.currentDocument
+
+ _, alreadyStashed := m.common.filesStashed[md.stashID]
+ if alreadyStashed {
+ cmds = append(cmds, m.showStatusMessage("Already stashed"))
+ break
+ }
+
+ // Stash a local document
+ if m.state != pagerStateStashing && stashableDocTypes.Contains(md.docType) {
+ m.state = pagerStateStashing
+ m.spinner.Start()
+ cmds = append(
+ cmds,
+ stashDocument(m.common.cc, md),
+ spinner.Tick,
+ )
+ }
+ case "?":
+ m.toggleHelp()
+ if m.viewport.HighPerformanceRendering {
+ cmds = append(cmds, viewport.Sync(m.viewport))
+ }
}
+ }
- case "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() {
+ // If we're still stashing, or if the spinner still needs to
+ // finish, spin it along.
+ newSpinnerModel, cmd := m.spinner.Update(msg)
+ m.spinner = newSpinnerModel
+ cmds = append(cmds, cmd)
+ } else if m.state == pagerStateStashSuccess && !m.spinner.Visible() {
+ // If the spinner's finished and we haven't told the user the
+ // stash was successful, do that.
+ m.state = pagerStateBrowse
+ m.currentDocument = *m.stashedDocument
+ m.stashedDocument = nil
+ cmds = append(cmds, m.showStatusMessage("Stashed!"))
}
// 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() {
+ // The spinner has finished spinning, so tell the user the stash
+ // was successful.
+ m.state = pagerStateBrowse
+ m.currentDocument = markdown(msg)
+ cmds = append(cmds, m.showStatusMessage("Stashed!"))
+ } else {
+ // The spinner is still spinning, so just take note of the newly
+ // stashed document for now.
+ md := markdown(msg)
+ m.stashedDocument = &md
+ }
+
+ case stashFailMsg:
+ delete(m.common.filesStashed, msg.markdown.stashID)
+
case statusMessageTimeoutMsg:
m.state = pagerStateBrowse
}
- 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,7 +337,12 @@ 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())
@@ -299,11 +357,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.docType == StashedDoc || m.currentDocument.docType == ConvertedDoc
+ showStatusMessage bool = m.state == pagerStateStatusMessage
+ )
// Logo
- logo := glowLogoView()
+ logo := glowLogoView(" Glow ")
// Scroll percent
percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent()))
@@ -322,16 +382,32 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
helpNote = statusBarHelpStyle(" ? Help ")
}
+ // Status indicator; spinner or stash dot
+ var statusIndicator string
+ if m.state == pagerStateStashing || m.state == pagerStateStashSuccess {
+ if m.spinner.Visible() {
+ statusIndicator = statusBarNoteStyle(" ") + m.spinner.View()
+ }
+ } else if isStashed && showStatusMessage {
+ statusIndicator = statusBarMessageStashIconStyle(" " + pagerStashIcon)
+ } else if isStashed {
+ statusIndicator = statusBarStashDotStyle(" " + pagerStashIcon)
+ }
+
// Note
var note string
if showStatusMessage {
note = m.statusMessage
} else {
note = m.currentDocument.Note
+ if len(note) == 0 {
+ note = "(No memo)"
+ }
}
- note = truncate.StringWithTail(" "+note+" ", uint(max(0, //nolint:gosec
+ note = truncate.StringWithTail(" "+note+" ", uint(max(0,
m.common.width-
ansi.PrintableRuneWidth(logo)-
+ ansi.PrintableRuneWidth(statusIndicator)-
ansi.PrintableRuneWidth(scrollPercent)-
ansi.PrintableRuneWidth(helpNote),
)), ellipsis)
@@ -345,6 +421,7 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
padding := max(0,
m.common.width-
ansi.PrintableRuneWidth(logo)-
+ ansi.PrintableRuneWidth(statusIndicator)-
ansi.PrintableRuneWidth(note)-
ansi.PrintableRuneWidth(scrollPercent)-
ansi.PrintableRuneWidth(helpNote),
@@ -356,8 +433,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 +443,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.common.authStatus == authOK && m.currentDocument.docType == LocalDoc {
+ 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.docType == NewsDoc {
+ deleteFromStringSlice(col1, 3)
+ }
+
s += "\n"
s += "k/↑ up " + col1[0] + "\n"
s += "j/↓ down " + col1[1] + "\n"
@@ -411,7 +502,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 +513,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.common.cfg.GlamourStyle == "auto" {
+ gs = glamour.WithAutoStyle()
+ } else {
+ gs = glamour.WithStylePath(m.common.cfg.GlamourStyle)
}
- options := []glamour.TermRendererOption{
- utils.GlamourStyle(m.common.cfg.GlamourStyle, isCode),
+ width := max(0, min(int(m.common.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..6e85d58 100644
--- a/ui/stash.go
+++ b/ui/stash.go
@@ -3,7 +3,8 @@ package ui
import (
"errors"
"fmt"
- "os"
+ "io/ioutil"
+ "log"
"sort"
"strings"
"time"
@@ -12,10 +13,11 @@ import (
"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"
+ lib "github.com/charmbracelet/charm/ui/common"
"github.com/muesli/reflow/ansi"
"github.com/muesli/reflow/truncate"
+ te "github.com/muesli/termenv"
"github.com/sahilm/fuzzy"
)
@@ -27,33 +29,29 @@ const (
stashViewHorizontalPadding = 6
)
-var stashingStatusMessage = statusMessage{normalStatusMessage, "Stashing..."}
+var (
+ stashedStatusMessage = statusMessage{normalStatusMessage, "Stashed!"}
+ alreadyStashedStatusMessage = statusMessage{subtleStatusMessage, "Already stashed"}
+)
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)
+ stashTextInputPromptStyle styleFunc = newFgStyle(lib.YellowGreen)
+ dividerDot string = darkGrayFg(" • ")
+ dividerBar string = darkGrayFg(" │ ")
+ offlineHeaderNote string = darkGrayFg("(Offline)")
)
// MSG
-type (
- filteredMarkdownMsg []*markdown
- fetchedMarkdownMsg *markdown
-)
+type deletedStashedItemMsg int
+type filteredMarkdownMsg []*markdown
+type fetchedMarkdownMsg *markdown
+
+type markdownFetchFailedMsg struct {
+ err error
+ id int
+ note string
+}
// MODEL
@@ -70,7 +68,9 @@ const (
type sectionKey int
const (
- documentsSection = iota
+ localSection = iota
+ stashedSection
+ newsSection
filterSection
)
@@ -78,12 +78,34 @@ const (
// its contents in the file listing view.
type section struct {
key sectionKey
+ docTypes DocTypeSet
paginator paginator.Model
cursor int
}
// map sections to their associated types.
-var sections = map[sectionKey]section{}
+var sections = map[sectionKey]section{
+ localSection: {
+ key: localSection,
+ docTypes: NewDocTypeSet(LocalDoc),
+ paginator: newStashPaginator(),
+ },
+ stashedSection: {
+ key: stashedSection,
+ docTypes: NewDocTypeSet(StashedDoc, ConvertedDoc),
+ paginator: newStashPaginator(),
+ },
+ newsSection: {
+ key: newsSection,
+ docTypes: NewDocTypeSet(NewsDoc),
+ paginator: newStashPaginator(),
+ },
+ filterSection: {
+ key: filterSection,
+ docTypes: DocTypeSet{},
+ paginator: newStashPaginator(),
+ },
+}
// filterState is the current filtering state in the file listing.
type filterState int
@@ -94,6 +116,15 @@ const (
filterApplied // a filter is applied and user is not editing filter
)
+// selectionState is the state of the currently selected document.
+type selectionState int
+
+const (
+ selectionIdle = iota
+ selectionSettingNote
+ selectionPromptingDelete
+)
+
// statusMessageType adds some context to the status message being sent.
type statusMessageType int
@@ -110,23 +141,10 @@ type statusMessage struct {
message string
}
-func initSections() {
- sections = map[sectionKey]section{
- documentsSection: {
- key: documentsSection,
- paginator: newStashPaginator(),
- },
- filterSection: {
- key: filterSection,
- paginator: newStashPaginator(),
- },
- }
-}
-
// String returns a styled version of the status message appropriate for the
// given context.
func (s statusMessage) String() string {
- switch s.status { //nolint:exhaustive
+ switch s.status {
case subtleStatusMessage:
return dimGreenFg(s.message)
case errorStatusMessage:
@@ -140,9 +158,12 @@ type stashModel struct {
common *commonModel
err error
spinner spinner.Model
+ noteInput textinput.Model
filterInput textinput.Model
+ stashFullyLoaded bool // have we loaded all available stashed documents from the server?
viewState stashViewState
filterState filterState
+ selectionState selectionState
showFullHelp bool
showStatusMessage bool
statusMessage statusMessage
@@ -155,8 +176,8 @@ type stashModel struct {
// Index of the section we're currently looking at
sectionIndex int
- // Tracks if docs were loaded
- loaded bool
+ // Tracks what exactly is loaded between the stash, news and local files
+ loaded DocTypeSet
// The master set of markdown documents we're working with.
markdowns []*markdown
@@ -170,11 +191,28 @@ type stashModel struct {
// from the local pagination. Generally, the server will return more items
// than we can display at a time so we can paginate locally without having
// to fetch every time.
- serverPage int64
+ serverPage int
+}
+
+func (m stashModel) localOnly() bool {
+ return m.common.cfg.localOnly()
+}
+
+func (m stashModel) stashedOnly() bool {
+ return m.common.cfg.stashedOnly()
}
func (m stashModel) loadingDone() bool {
- return m.loaded
+ return m.loaded.Equals(m.common.cfg.DocumentTypes.Difference(ConvertedDoc))
+}
+
+func (m stashModel) hasSection(key sectionKey) bool {
+ for _, v := range m.sections {
+ if key == v.key {
+ return true
+ }
+ }
+ return false
}
func (m stashModel) currentSection() *section {
@@ -197,30 +235,43 @@ 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
+// Returns whether or not we're online. That is, when "local-only" mode is
+// disabled and we've authenticated successfully.
+func (m stashModel) online() bool {
+ return !m.localOnly() && m.common.authStatus == authOK
}
func (m *stashModel) setSize(width, height int) {
m.common.width = width
m.common.height = height
- m.filterInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth(
- m.filterInput.Prompt,
- )
+ m.noteInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth(m.noteInput.Prompt)
+ m.filterInput.Width = width - stashViewHorizontalPadding*2 - ansi.PrintableRuneWidth(m.filterInput.Prompt)
m.updatePagination()
}
+// bakeConvertedDocs turns converted documents into stashed ones. Essentially,
+// we're discarding the fact that they were ever converted so we can stop
+// treating them like converted documents.
+func (m *stashModel) bakeConvertedDocs() {
+ for _, md := range m.markdowns {
+ if md.docType == ConvertedDoc {
+ md.docType = StashedDoc
+ }
+ }
+}
+
func (m *stashModel) resetFiltering() {
m.filterState = unfiltered
m.filterInput.Reset()
m.filteredMarkdowns = nil
- sortMarkdowns(m.markdowns)
+ // Turn converted markdowns into stashed ones so that the next time we
+ // filter we get both local and stashed results.
+ m.bakeConvertedDocs()
+
+ sort.Stable(markdownsByLocalFirst(m.markdowns))
// 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.
@@ -247,7 +298,7 @@ func (m stashModel) filterApplied() bool {
func (m stashModel) shouldUpdateFilter() bool {
// If we're in the middle of setting a note don't update the filter so that
// the focus won't jump around.
- return m.filterApplied()
+ return m.filterApplied() && m.selectionState != selectionSettingNote
}
// Update pagination according to the amount of markdowns for the current
@@ -293,16 +344,58 @@ func (m stashModel) selectedMarkdown() *markdown {
// Adds markdown documents to the model.
func (m *stashModel) addMarkdowns(mds ...*markdown) {
- if len(mds) == 0 {
+ if len(mds) > 0 {
+ for _, md := range mds {
+ md.generateIDs()
+ }
+
+ m.markdowns = append(m.markdowns, mds...)
+ if !m.filterApplied() {
+ sort.Stable(markdownsByLocalFirst(m.markdowns))
+ }
+ m.updatePagination()
+ }
+}
+
+// Return the number of markdown documents of a given type.
+func (m stashModel) countMarkdowns(t DocType) (found int) {
+ if len(m.markdowns) == 0 {
return
}
- m.markdowns = append(m.markdowns, mds...)
- if !m.filterApplied() {
- sortMarkdowns(m.markdowns)
+ var mds []*markdown
+ if m.filterState == filtering {
+ mds = m.getVisibleMarkdowns()
+ } else {
+ mds = m.markdowns
}
- m.updatePagination()
+ for i := 0; i < len(mds); i++ {
+ if mds[i].docType == t {
+ found++
+ }
+ }
+ return
+}
+
+// Sift through the master markdown collection for the specified types.
+func (m stashModel) getMarkdownByType(types ...DocType) []*markdown {
+ var agg []*markdown
+
+ if len(m.markdowns) == 0 {
+ return agg
+ }
+
+ for _, t := range types {
+ for _, md := range m.markdowns {
+ if md.docType == t {
+ agg = append(agg, md)
+ }
+ }
+ }
+
+ sort.Stable(markdownsByLocalFirst(agg))
+ return agg
}
// Returns the markdowns that should be currently shown.
@@ -311,15 +404,45 @@ func (m stashModel) getVisibleMarkdowns() []*markdown {
return m.filteredMarkdowns
}
- return m.markdowns
+ return m.getMarkdownByType(m.currentSection().docTypes.AsSlice()...)
+}
+
+// Return the markdowns eligible to be filtered.
+func (m stashModel) getFilterableMarkdowns() (agg []*markdown) {
+ mds := m.getMarkdownByType(LocalDoc, ConvertedDoc, StashedDoc)
+
+ // Copy values
+ for _, v := range mds {
+ p := *v
+ agg = append(agg, &p)
+ }
+
+ return
}
// Command for opening a markdown document in the pager. Note that this also
// alters the model.
func (m *stashModel) openMarkdown(md *markdown) tea.Cmd {
+ var cmd tea.Cmd
m.viewState = stashStateLoadingDocument
- cmd := loadLocalMarkdown(md)
- return tea.Batch(cmd, m.spinner.Tick)
+
+ if md.docType == LocalDoc {
+ cmd = loadLocalMarkdown(md)
+ } else {
+ cmd = loadRemoteMarkdown(m.common.cc, md)
+ }
+
+ return tea.Batch(cmd, spinner.Tick)
+}
+
+func (m *stashModel) newStatusMessage(sm statusMessage) tea.Cmd {
+ m.showStatusMessage = true
+ m.statusMessage = sm
+ if m.statusMessageTimer != nil {
+ m.statusMessageTimer.Stop()
+ }
+ m.statusMessageTimer = time.NewTimer(statusMessageTimeout)
+ return waitForStatusMessageTimeout(stashContext, m.statusMessageTimer)
}
func (m *stashModel) hideStatusMessage() {
@@ -374,25 +497,50 @@ func (m *stashModel) moveCursorDown() {
// INIT
func newStashModel(common *commonModel) stashModel {
- sp := spinner.New()
+ sp := spinner.NewModel()
sp.Spinner = spinner.Line
- sp.Style = stashSpinnerStyle
+ sp.ForegroundColor = lib.SpinnerColor.String()
+ sp.HideFor = time.Millisecond * 100
+ sp.MinimumLifetime = time.Millisecond * 180
+ sp.Start()
- si := textinput.New()
- si.Prompt = "Find:"
- si.PromptStyle = stashInputPromptStyle
- si.Cursor.Style = stashInputCursorStyle
+ ni := textinput.NewModel()
+ ni.Prompt = stashTextInputPromptStyle("Memo: ")
+ ni.CursorColor = lib.Fuschia.String()
+ ni.CharLimit = noteCharacterLimit
+ ni.Focus()
+
+ si := textinput.NewModel()
+ si.Prompt = stashTextInputPromptStyle("Find: ")
+ si.CursorColor = lib.Fuschia.String()
+ si.CharLimit = noteCharacterLimit
si.Focus()
- s := []section{
- sections[documentsSection],
+ var s []section
+ if common.cfg.localOnly() {
+ s = []section{
+ sections[localSection],
+ }
+ } else if common.cfg.stashedOnly() {
+ s = []section{
+ sections[stashedSection],
+ sections[newsSection],
+ }
+ } else {
+ s = []section{
+ sections[localSection],
+ sections[stashedSection],
+ sections[newsSection],
+ }
}
m := stashModel{
common: common,
spinner: sp,
+ noteInput: ni,
filterInput: si,
serverPage: 1,
+ loaded: NewDocTypeSet(),
sections: s,
}
@@ -400,10 +548,10 @@ func newStashModel(common *commonModel) stashModel {
}
func newStashPaginator() paginator.Model {
- p := paginator.New()
+ p := paginator.NewModel()
p.Type = paginator.Dots
p.ActiveDot = brightGrayFg("•")
- p.InactiveDot = darkGrayFg.Render("•")
+ p.InactiveDot = darkGrayFg("•")
return p
}
@@ -416,22 +564,109 @@ func (m stashModel) update(msg tea.Msg) (stashModel, tea.Cmd) {
case errMsg:
m.err = msg
+ case stashLoadErrMsg:
+ m.err = msg.err
+ m.loaded.Add(StashedDoc) // still done, albeit unsuccessfully
+ m.stashFullyLoaded = true
+
+ case newsLoadErrMsg:
+ m.err = msg.err
+ m.loaded.Add(NewsDoc) // still done, albeit unsuccessfully
+
case localFileSearchFinished:
// We're finished searching for local files
- m.loaded = true
+ m.loaded.Add(LocalDoc)
+
+ case gotStashMsg, gotNewsMsg:
+ // Stash or news results have come in from the server.
+ //
+ // With the stash, this doesn't mean the whole stash listing is loaded,
+ // but now know it can load, at least, so mark the stash as loaded here.
+ var docs []*markdown
+
+ switch msg := msg.(type) {
+ case gotStashMsg:
+ m.loaded.Add(StashedDoc)
+ docs = wrapMarkdowns(StashedDoc, msg)
+
+ if len(msg) == 0 {
+ // If the server comes back with nothing then we've got
+ // everything
+ m.stashFullyLoaded = true
+ } else {
+ // Load the next page
+ m.serverPage++
+ cmds = append(cmds, loadStash(m))
+ }
+
+ case gotNewsMsg:
+ m.loaded.Add(NewsDoc)
+ docs = wrapMarkdowns(NewsDoc, msg)
+ }
+
+ // If we're filtering build filter indexes immediately so any
+ // matching results will show up in the filter.
+ if m.filterApplied() {
+ for _, md := range docs {
+ md.buildFilterValue()
+ }
+ }
+ if m.shouldUpdateFilter() {
+ cmds = append(cmds, filterMarkdowns(m))
+ }
+
+ m.addMarkdowns(docs...)
+
+ case markdownFetchFailedMsg:
+ s := "Couldn't load markdown"
+ if msg.note != "" {
+ s += ": " + msg.note
+ }
+ cmd := m.newStatusMessage(statusMessage{
+ status: normalStatusMessage,
+ message: s,
+ })
+ return m, cmd
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)
+ loading := !m.loadingDone()
+ stashing := m.common.isStashing()
+ openingDocument := m.viewState == stashStateLoadingDocument
+ spinnerVisible := m.spinner.Visible()
+
+ if loading || stashing || openingDocument || spinnerVisible {
+ newSpinnerModel, cmd := m.spinner.Update(msg)
+ m.spinner = newSpinnerModel
cmds = append(cmds, cmd)
}
+ // A note was set on a document. This may have happened in the pager so
+ // we'll find the corresponding document here and update accordingly.
+ case noteSavedMsg:
+ for i := range m.markdowns {
+ if m.markdowns[i].ID == msg.ID {
+ m.markdowns[i].Note = msg.Note
+ }
+ }
+
+ // Note: mechanical stuff related to stash success is handled in the parent
+ // update function.
+ case stashSuccessMsg:
+ m.spinner.Finish()
+
+ // Note: mechanical stuff related to stash failure is handled in the parent
+ // update function.
+ case stashFailMsg:
+ m.err = msg.err
+ cmds = append(cmds, m.newStatusMessage(statusMessage{
+ status: errorStatusMessage,
+ message: fmt.Sprintf("Couldn’t stash ‘%s’", msg.markdown.Note),
+ }))
+
case statusMessageTimeoutMsg:
if applicationContext(msg) == stashContext {
m.hideStatusMessage()
@@ -443,8 +678,17 @@ func (m stashModel) update(msg tea.Msg) (stashModel, tea.Cmd) {
return m, tea.Batch(cmds...)
}
+ switch m.selectionState {
+ case selectionSettingNote:
+ cmds = append(cmds, m.handleNoteInput(msg))
+ return m, tea.Batch(cmds...)
+ case selectionPromptingDelete:
+ cmds = append(cmds, m.handleDeleteConfirmation(msg))
+ return m, tea.Batch(cmds...)
+ }
+
// Updates per the current state
- switch m.viewState { //nolint:exhaustive
+ switch m.viewState {
case stashStateReady:
cmds = append(cmds, m.handleDocumentBrowsing(msg))
case stashStateShowingError:
@@ -484,7 +728,7 @@ func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd {
m.setCursor(m.paginator().ItemsOnPage(numDocs) - 1)
// Clear filter (if applicable)
- case keyEsc:
+ case "esc":
if m.filterApplied() {
m.resetFiltering()
}
@@ -511,23 +755,8 @@ func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd {
}
m.updatePagination()
- case "F":
- m.loaded = false
- return findLocalFiles(*m.common)
-
- // Edit document in EDITOR
- case "e":
- md := m.selectedMarkdown()
-
- // In case no file is available
- if md == nil {
- return nil
- }
-
- return openEditor(md.localPath, 0)
-
// Open document
- case keyEnter:
+ case "enter":
m.hideStatusMessage()
if numDocs == 0 {
@@ -542,13 +771,14 @@ func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd {
// Filter your notes
case "/":
m.hideStatusMessage()
+ m.bakeConvertedDocs()
// Build values we'll filter against
for _, md := range m.markdowns {
md.buildFilterValue()
}
- m.filteredMarkdowns = m.markdowns
+ m.filteredMarkdowns = m.getFilterableMarkdowns()
m.paginator().Page = 0
m.setCursor(0)
@@ -557,6 +787,103 @@ func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd {
m.filterInput.Focus()
return textinput.Blink
+ // Set note
+ case "m":
+ m.hideStatusMessage()
+
+ if numDocs == 0 {
+ break
+ }
+
+ md := m.selectedMarkdown()
+ isUserMarkdown := md.docType == StashedDoc || md.docType == ConvertedDoc
+ isSettingNote := m.selectionState == selectionSettingNote
+ isPromptingDelete := m.selectionState == selectionPromptingDelete
+
+ if isUserMarkdown && !isSettingNote && !isPromptingDelete {
+ m.selectionState = selectionSettingNote
+ m.noteInput.SetValue(md.Note)
+ m.noteInput.CursorEnd()
+ return textinput.Blink
+ }
+
+ // Stash
+ case "s":
+ if numDocs == 0 || !m.online() || m.selectedMarkdown() == nil {
+ break
+ }
+
+ md := m.selectedMarkdown()
+
+ // Is this a document we're allowed to stash?
+ if !stashableDocTypes.Contains(md.docType) {
+ break
+ }
+
+ // Was this document already stashed?
+ if _, alreadyStashed := m.common.filesStashed[md.stashID]; alreadyStashed {
+ cmds = append(cmds, m.newStatusMessage(alreadyStashedStatusMessage))
+ break
+ }
+
+ // Is the document missing a stash ID?
+ if md.stashID.IsNil() {
+ if debug && md.stashID.IsNil() {
+ log.Printf("refusing to stash markdown; local ID path is nil: %#v", md)
+ }
+ break
+ }
+
+ // Checks passed; perform the stash. Note that we optimistically
+ // show the status message.
+ m.common.filesStashed[md.stashID] = struct{}{}
+ m.common.filesStashing[md.stashID] = struct{}{}
+ m.common.latestFileStashed = md.stashID
+ cmds = append(cmds,
+ stashDocument(m.common.cc, *md),
+ m.newStatusMessage(stashedStatusMessage),
+ )
+
+ // If we're stashing a filtered item, optimistically convert the
+ // filtered item into a stashed item.
+ if m.filterApplied() {
+ for _, v := range m.filteredMarkdowns {
+ if v.uniqueID == md.uniqueID {
+ v.convertToStashed()
+ }
+ }
+ }
+
+ // The spinner subtly shows the stash state in a non-optimistic
+ // fashion, namely because it was originally implemented this way.
+ // If this stash succeeds quickly enough, the spinner won't run
+ // at all.
+ if m.loadingDone() && !m.spinner.Visible() {
+ m.spinner.Start()
+ cmds = append(cmds, spinner.Tick)
+ }
+
+ // Prompt for deletion
+ case "x":
+ m.hideStatusMessage()
+
+ validState := m.viewState == stashStateReady &&
+ m.selectionState == selectionIdle
+
+ if numDocs == 0 && !validState {
+ break
+ }
+
+ md := m.selectedMarkdown()
+ if md == nil {
+ break
+ }
+
+ t := md.docType
+ if t == StashedDoc || t == ConvertedDoc {
+ m.selectionState = selectionPromptingDelete
+ }
+
// Toggle full help
case "?":
m.showFullHelp = !m.showFullHelp
@@ -597,17 +924,98 @@ func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd {
return tea.Batch(cmds...)
}
+// Updates for when a user is being prompted whether or not to delete a
+// markdown item.
+func (m *stashModel) handleDeleteConfirmation(msg tea.Msg) tea.Cmd {
+ if msg, ok := msg.(tea.KeyMsg); ok {
+ switch msg.String() {
+ case "y":
+ if m.selectionState != selectionPromptingDelete {
+ break
+ }
+
+ smd := m.selectedMarkdown()
+
+ for _, md := range m.markdowns {
+ if md.uniqueID != smd.uniqueID {
+ continue
+ }
+
+ // Remove from the things-we-stashed-this-session set
+ delete(m.common.filesStashed, md.stashID)
+
+ // Delete optimistically and remove the stashed item before
+ // we've received a success response.
+ mds, err := deleteMarkdown(m.markdowns, md)
+ if err == nil {
+ m.markdowns = mds
+ }
+
+ break
+ }
+
+ // Also optimistically delete from filtered markdowns
+ if m.filterApplied() {
+ for _, md := range m.filteredMarkdowns {
+ if md.uniqueID != smd.uniqueID {
+ continue
+ }
+
+ switch md.docType {
+
+ case ConvertedDoc:
+ // If the document was stashed in this session, convert it
+ // back to it's original document type
+ if md.originalDocType == LocalDoc {
+ md.revertFromStashed()
+ break
+ }
+
+ // Other documents fall through and delete as normal
+ fallthrough
+
+ // Otherwise, remove the document from the listing
+ default:
+ mds, err := deleteMarkdown(m.filteredMarkdowns, md)
+ if err == nil {
+ m.filteredMarkdowns = mds
+ }
+
+ }
+
+ break
+ }
+ }
+
+ m.selectionState = selectionIdle
+ m.updatePagination()
+
+ if len(m.filteredMarkdowns) == 0 {
+ m.resetFiltering()
+ }
+
+ return deleteStashedItem(m.common.cc, smd.ID)
+
+ // Any other key cancels deletion
+ default:
+ m.selectionState = selectionIdle
+ }
+ }
+
+ return nil
+}
+
// Updates for when a user is in the filter editing interface.
func (m *stashModel) handleFiltering(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
// Handle keys
- if msg, ok := msg.(tea.KeyMsg); ok { //nolint:nestif
+ if msg, ok := msg.(tea.KeyMsg); ok {
switch msg.String() {
- case keyEsc:
+ case "esc":
// Cancel filtering
m.resetFiltering()
- case keyEnter, "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down":
+ case "enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down":
m.hideStatusMessage()
if len(m.markdowns) == 0 {
@@ -665,6 +1073,53 @@ func (m *stashModel) handleFiltering(msg tea.Msg) tea.Cmd {
return tea.Batch(cmds...)
}
+func (m *stashModel) handleNoteInput(msg tea.Msg) tea.Cmd {
+ var cmds []tea.Cmd
+
+ if msg, ok := msg.(tea.KeyMsg); ok {
+ switch msg.String() {
+ case "esc":
+ // Cancel note
+ m.noteInput.Reset()
+ m.selectionState = selectionIdle
+ case "enter":
+ // Set new note
+ md := m.selectedMarkdown()
+
+ // If the user is issuing a rename on a newly stashed item in a
+ // filtered listing, there's a small chance the user could try and
+ // set a note before the stash is complete.
+ if md.ID == 0 {
+ if debug {
+ log.Printf("user attempted to rename, but markdown ID is 0: %v", md)
+ }
+ return m.newStatusMessage(statusMessage{
+ status: subtleStatusMessage,
+ message: "Too fast. Try again in a sec.",
+ })
+ }
+
+ newNote := m.noteInput.Value()
+ cmd := saveDocumentNote(m.common.cc, md.ID, newNote)
+ md.Note = newNote
+ m.noteInput.Reset()
+ m.selectionState = selectionIdle
+ return cmd
+ }
+ }
+
+ if m.shouldUpdateFilter() {
+ cmds = append(cmds, filterMarkdowns(*m))
+ }
+
+ // Update the note text input component
+ newNoteInputModel, noteInputCmd := m.noteInput.Update(msg)
+ m.noteInput = newNoteInputModel
+ cmds = append(cmds, noteInputCmd)
+
+ return tea.Batch(cmds...)
+}
+
// VIEW
func (m stashModel) view() string {
@@ -675,14 +1130,25 @@ func (m stashModel) view() string {
case stashStateLoadingDocument:
s += " " + m.spinner.View() + " Loading document..."
case stashStateReady:
+
loadingIndicator := " "
- if m.shouldSpin() {
+ if !m.loadingDone() || m.spinner.Visible() {
loadingIndicator = m.spinner.View()
}
+ var header string
+ switch m.selectionState {
+ case selectionPromptingDelete:
+ header = redFg("Delete this item from your stash? ") + faintRedFg("(y/N)")
+ case selectionSettingNote:
+ header = yellowFg("Set the memo for this item?")
+ }
+
// Only draw the normal header if we're not using the header area for
// something else (like a note or delete prompt).
- header := m.headerView()
+ if header == "" {
+ header = m.headerView()
+ }
// Rules for the logo, filter and status message.
logoOrFilter := " "
@@ -691,12 +1157,12 @@ func (m stashModel) view() string {
} else if m.filterState == filtering {
logoOrFilter += m.filterInput.View()
} else {
- logoOrFilter += glowLogoView()
+ logoOrFilter += glowLogoView(" Glow ")
if m.showStatusMessage {
logoOrFilter += " " + m.statusMessage.String()
}
}
- logoOrFilter = truncate.StringWithTail(logoOrFilter, uint(m.common.width-1), ellipsis) //nolint:gosec
+ logoOrFilter = truncate.StringWithTail(logoOrFilter, uint(m.common.width-1), ellipsis)
help, helpHeight := m.helpView()
@@ -727,10 +1193,14 @@ func (m stashModel) view() string {
// One could argue, in fact, that using pointers in
// a functional framework is an antipattern and our use of
// pointers in our model should be refactored away.
- p := *(m.paginator())
+ var p paginator.Model = *(m.paginator())
p.Type = paginator.Arabic
- pagination = paginationStyle.Render(p.View())
+ pagination = lib.Subtle(p.View())
}
+
+ // We could also look at m.stashFullyLoaded and add an indicator
+ // showing that we don't actually know how many more pages there
+ // are.
}
s += fmt.Sprintf(
@@ -747,29 +1217,50 @@ func (m stashModel) view() string {
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(lib.Fuschia.Color()).
+ String()
}
func (m stashModel) headerView() string {
- localCount := len(m.markdowns)
+ localCount := m.countMarkdowns(LocalDoc)
+ stashedCount := m.countMarkdowns(StashedDoc) + m.countMarkdowns(ConvertedDoc)
+ newsCount := m.countMarkdowns(NewsDoc)
- var sections []string //nolint:prealloc
+ var sections []string
// Filter results
if m.filterState == filtering {
- if localCount == 0 {
+ if localCount+stashedCount+newsCount == 0 {
return grayFg("Nothing found.")
}
if localCount > 0 {
sections = append(sections, fmt.Sprintf("%d local", localCount))
}
+ if stashedCount > 0 {
+ sections = append(sections, fmt.Sprintf("%d stashed", stashedCount))
+ }
for i := range sections {
sections[i] = grayFg(sections[i])
}
- return strings.Join(sections, dividerDot.String())
+ return strings.Join(sections, dividerDot)
+ }
+
+ if m.loadingDone() && len(m.markdowns) == 0 {
+ var maybeOffline string
+ if m.common.authStatus == authFailed {
+ maybeOffline = " " + offlineHeaderNote
+ }
+
+ if m.stashedOnly() {
+ return lib.Subtle("Can’t load stash") + maybeOffline
+ }
+ return lib.Subtle("No markdown files found") + maybeOffline
}
// Tabs
@@ -777,22 +1268,39 @@ func (m stashModel) headerView() string {
var s string
switch v.key {
- case documentsSection:
- s = fmt.Sprintf("%d documents", localCount)
-
+ case localSection:
+ if m.stashedOnly() {
+ continue
+ }
+ s = fmt.Sprintf("%d local", localCount)
+ case stashedSection:
+ if m.localOnly() {
+ continue
+ }
+ s = fmt.Sprintf("%d stashed", stashedCount)
+ case newsSection:
+ if m.localOnly() {
+ continue
+ }
+ s = fmt.Sprintf("%d news", newsCount)
case filterSection:
s = fmt.Sprintf("%d “%s”", len(m.filteredMarkdowns), m.filterInput.Value())
}
if m.sectionIndex == i && len(m.sections) > 1 {
- s = selectedTabStyle.Render(s)
+ s = selectedTabColor(s)
} else {
- s = tabStyle.Render(s)
+ s = tabColor(s)
}
sections = append(sections, s)
}
- return strings.Join(sections, dividerBar.String())
+ s := strings.Join(sections, dividerBar)
+ if m.common.authStatus == authFailed {
+ s += dividerDot + offlineHeaderNote
+ }
+
+ return s
}
func (m stashModel) populatedView() string {
@@ -807,12 +1315,28 @@ func (m stashModel) populatedView() string {
}
switch m.sections[m.sectionIndex].key {
- case documentsSection:
+ case localSection:
if m.loadingDone() {
- f("No files found.")
+ f("No local files found.")
} else {
f("Looking for local files...")
}
+ case stashedSection:
+ if m.common.authStatus == authFailed {
+ f("Can't load your stash. Are you offline?")
+ } else if m.loadingDone() {
+ f("Nothing stashed yet.")
+ } else {
+ f("Loading your stash...")
+ }
+ case newsSection:
+ if m.common.authStatus == authFailed {
+ f("Can't load news. Are you offline?")
+ } else if m.loadingDone() {
+ f("No stashed files found.")
+ } else {
+ f("Loading your stash...")
+ }
case filterSection:
return ""
}
@@ -849,15 +1373,39 @@ func (m stashModel) populatedView() string {
// COMMANDS
+// loadRemoteMarkdown is a command for loading markdown from the server.
+func loadRemoteMarkdown(cc *charm.Client, md *markdown) tea.Cmd {
+ return func() tea.Msg {
+ newMD, err := fetchMarkdown(cc, md.ID, md.docType)
+ if err != nil {
+ if debug {
+ log.Printf("error loading %s markdown (ID %d, Note: '%s'): %v", md.docType, md.ID, md.Note, err)
+ }
+ return markdownFetchFailedMsg{
+ err: err,
+ id: md.ID,
+ note: md.Note,
+ }
+ }
+ newMD.stashID = md.stashID
+ return fetchedMarkdownMsg(newMD)
+ }
+}
+
func loadLocalMarkdown(md *markdown) tea.Cmd {
return func() tea.Msg {
+ if md.docType != LocalDoc {
+ return errMsg{errors.New("could not load local file: not a local file")}
+ }
if md.localPath == "" {
return errMsg{errors.New("could not load file: missing path")}
}
- data, err := 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,14 +1413,27 @@ func loadLocalMarkdown(md *markdown) tea.Cmd {
}
}
+func deleteStashedItem(cc *charm.Client, id int) tea.Cmd {
+ return func() tea.Msg {
+ err := cc.DeleteMarkdown(id)
+ if err != nil {
+ if debug {
+ log.Println("could not delete stashed item:", err)
+ }
+ return errMsg{err}
+ }
+ return deletedStashedItemMsg(id)
+ }
+}
+
func filterMarkdowns(m stashModel) tea.Cmd {
return func() tea.Msg {
if m.filterInput.Value() == "" || !m.filterApplied() {
- return filteredMarkdownMsg(m.markdowns) // return everything
+ return filteredMarkdownMsg(m.getFilterableMarkdowns()) // return everything
}
targets := []string{}
- mds := m.markdowns
+ mds := m.getFilterableMarkdowns()
for _, t := range mds {
targets = append(targets, t.filterValue)
@@ -889,3 +1450,55 @@ func filterMarkdowns(m stashModel) tea.Cmd {
return filteredMarkdownMsg(filtered)
}
}
+
+// ETC
+
+// fetchMarkdown performs the actual I/O for loading markdown from the sever.
+func fetchMarkdown(cc *charm.Client, id int, t DocType) (*markdown, error) {
+ var md *charm.Markdown
+ var err error
+
+ switch t {
+ case StashedDoc, ConvertedDoc:
+ md, err = cc.GetStashMarkdown(id)
+ case NewsDoc:
+ md, err = cc.GetNewsMarkdown(id)
+ default:
+ err = fmt.Errorf("unknown markdown type: %s", t)
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ return &markdown{
+ docType: t,
+ Markdown: *md,
+ }, nil
+}
+
+// Delete a markdown from a slice of markdowns.
+func deleteMarkdown(markdowns []*markdown, target *markdown) ([]*markdown, error) {
+ index := -1
+
+ // Operate on a copy to avoid any pointer weirdness
+ mds := make([]*markdown, len(markdowns))
+ copy(mds, markdowns)
+
+ for i, v := range mds {
+ if v.uniqueID == target.uniqueID {
+ index = i
+ break
+ }
+ }
+
+ if index == -1 {
+ err := fmt.Errorf("could not find markdown to delete")
+ if debug {
+ log.Println(err)
+ }
+ return nil, err
+ }
+
+ return append(mds[:index], mds[index+1:]...), nil
+}
diff --git a/ui/stashhelp.go b/ui/stashhelp.go
index 6c0b474..fea1fdf 100644
--- a/ui/stashhelp.go
+++ b/ui/stashhelp.go
@@ -4,6 +4,7 @@ import (
"fmt"
"strings"
+ lib "github.com/charmbracelet/charm/ui/common"
"github.com/muesli/reflow/ansi"
)
@@ -15,7 +16,7 @@ type helpEntry struct{ key, val string }
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
+// represeting 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 {
@@ -99,15 +100,30 @@ func (m stashModel) helpView() (string, int) {
return m.renderHelp(h)
}
+ // Help for when we're interacting with a single document
+ switch m.selectionState {
+ case selectionSettingNote:
+ return m.renderHelp([]string{"enter", "confirm", "esc", "cancel"}, []string{"q", "quit"})
+ case selectionPromptingDelete:
+ return m.renderHelp([]string{"y", "delete", "n", "cancel"}, []string{"q", "quit"})
+ }
+
var (
+ isStashed bool
+ isStashable bool
navHelp []string
filterHelp []string
selectionHelp []string
- editHelp []string
sectionHelp []string
appHelp []string
)
+ if numDocs > 0 {
+ md := m.selectedMarkdown()
+ isStashed = md != nil && md.docType == StashedDoc
+ isStashable = md != nil && md.docType == LocalDoc && m.online()
+ }
+
if numDocs > 0 && m.showFullHelp {
navHelp = []string{"enter", "open", "j/k ↑/↓", "choose"}
}
@@ -125,23 +141,23 @@ func (m stashModel) helpView() (string, int) {
}
// If we're browsing a filtered set
- if m.filterApplied() {
- filterHelp = []string{"/", "edit search", "esc", "clear filter"}
+ if m.filterState == filterApplied {
+ filterHelp = []string{"/", "edit search", "esc", "clear search"}
} else {
filterHelp = []string{"/", "find"}
}
+ if isStashed {
+ selectionHelp = []string{"x", "delete", "m", "set memo"}
+ } else if isStashable {
+ selectionHelp = []string{"s", "stash"}
+ }
+
// If there are errors
if m.err != nil {
appHelp = append(appHelp, "!", "errors")
}
- appHelp = append(appHelp, "r", "refresh")
-
- if numDocs > 0 {
- appHelp = append(appHelp, "e", "edit")
- }
-
appHelp = append(appHelp, "q", "quit")
// Detailed help
@@ -149,31 +165,29 @@ func (m stashModel) helpView() (string, int) {
if m.filterState != filtering {
appHelp = append(appHelp, "?", "close help")
}
- return m.renderHelp(navHelp, filterHelp, append(selectionHelp, editHelp...), sectionHelp, appHelp)
+ return m.renderHelp(navHelp, filterHelp, selectionHelp, sectionHelp, appHelp)
}
// Mini help
if m.filterState != filtering {
appHelp = append(appHelp, "?", "more")
}
- return m.renderHelp(navHelp, filterHelp, selectionHelp, editHelp, sectionHelp, appHelp)
+ return m.renderHelp(navHelp, filterHelp, selectionHelp, 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 str, numLines
}
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,
+// would otherwise wrap to two lines. Help view entires 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 {
@@ -181,7 +195,7 @@ func (m stashModel) miniHelpView(entries ...string) string {
}
var (
- truncationChar = subtleStyle.Render("…")
+ truncationChar = lib.Subtle("…")
truncationWidth = ansi.PrintableRuneWidth(truncationChar)
)
@@ -199,13 +213,19 @@ func (m stashModel) miniHelpView(entries ...string) string {
k := entries[i]
v := entries[i+1]
- k = grayFg(k)
- v = midGrayFg(v)
+ switch k {
+ case "s":
+ k = greenFg(k)
+ v = semiDimGreenFg(v)
+ default:
+ k = grayFg(k)
+ v = midGrayFg(v)
+ }
next = fmt.Sprintf("%s %s", k, v)
if i < len(entries)-2 {
- next += dividerDot.String()
+ next += dividerDot
}
// Only this (and the following) help text items if we have the
@@ -221,9 +241,11 @@ func (m stashModel) miniHelpView(entries ...string) string {
}
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
+ var (
+ columns []helpColumn
+ tallestCol int
+ renderedCols [][]string // final rows grouped by column
+ )
// Get key/value pairs
for _, g := range groups {
diff --git a/ui/stashitem.go b/ui/stashitem.go
index 0000928..7c5005e 100644
--- a/ui/stashitem.go
+++ b/ui/stashitem.go
@@ -2,31 +2,48 @@ package ui
import (
"fmt"
+ "log"
"strings"
- "github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/log"
+ lib "github.com/charmbracelet/charm/ui/common"
+ "github.com/muesli/reflow/ansi"
"github.com/muesli/reflow/truncate"
+ "github.com/muesli/termenv"
"github.com/sahilm/fuzzy"
)
const (
verticalLine = "│"
+ noMemoTitle = "No Memo"
fileListingStashIcon = "• "
)
func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) {
var (
- truncateTo = uint(m.common.width - stashViewHorizontalPadding*2) //nolint:gosec
- gutter string
- title = truncate.StringWithTail(md.Note, truncateTo, ellipsis)
- date = md.relativeTime()
- editedBy = ""
- hasEditedBy = false
- icon = ""
- separator = ""
+ truncateTo = uint(m.common.width - stashViewHorizontalPadding*2)
+ gutter string
+ title = md.Note
+ date = md.relativeTime()
+ icon = ""
)
+ switch md.docType {
+ case NewsDoc:
+ if title == "" {
+ title = "News"
+ } else {
+ title = truncate.StringWithTail(title, truncateTo, ellipsis)
+ }
+ case StashedDoc, ConvertedDoc:
+ icon = fileListingStashIcon
+ if title == "" {
+ title = noMemoTitle
+ }
+ title = truncate.StringWithTail(title, truncateTo-uint(ansi.PrintableRuneWidth(icon)), ellipsis)
+ default:
+ title = truncate.StringWithTail(title, truncateTo, ellipsis)
+ }
+
isSelected := index == m.cursor()
isFiltering := m.filterState == filtering
singleFilteredItem := isFiltering && len(m.getVisibleMarkdowns()) == 1
@@ -34,72 +51,94 @@ func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) {
// If there are multiple items being filtered don't highlight a selected
// item in the results. If we've filtered down to one item, however,
// highlight that first item since pressing return will open it.
- if isSelected && !isFiltering || singleFilteredItem { //nolint:nestif
+ if isSelected && !isFiltering || 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 {
- 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))
+
+ switch m.selectionState {
+ case selectionPromptingDelete:
+ gutter = faintRedFg(verticalLine)
+ icon = faintRedFg(icon)
+ title = redFg(title)
+ date = faintRedFg(date)
+ case selectionSettingNote:
+ gutter = dullYellowFg(verticalLine)
+ icon = ""
+ title = m.noteInput.View()
+ date = dullYellowFg(date)
+ default:
+ if m.common.latestFileStashed == md.stashID &&
+ m.statusMessage == stashedStatusMessage {
+ gutter = greenFg(verticalLine)
+ icon = dimGreenFg(icon)
+ title = greenFg(title)
+ date = semiDimGreenFg(date)
} else {
- title = fuchsiaFg(title)
- icon = fuchsiaFg(icon)
+ gutter = dullFuchsiaFg(verticalLine)
+ icon = dullFuchsiaFg(icon)
+ if m.currentSection().key == filterSection &&
+ m.filterState == filterApplied || singleFilteredItem {
+ s := termenv.Style{}.Foreground(lib.Fuschia.Color())
+ title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline())
+ } else {
+ title = fuchsiaFg(title)
+ }
+ date = dullFuchsiaFg(date)
}
- date = dimFuchsiaFg(date)
- editedBy = dimDullFuchsiaFg(editedBy)
- separator = dullFuchsiaFg(separator)
}
} else {
+ // Regular (non-selected) items
+
gutter = " "
- if m.statusMessage == stashingStatusMessage {
+
+ if m.common.latestFileStashed == md.stashID &&
+ m.statusMessage == stashedStatusMessage {
icon = dimGreenFg(icon)
title = greenFg(title)
date = semiDimGreenFg(date)
- editedBy = semiDimGreenFg(editedBy)
- separator = semiDimGreenFg(separator)
+ } else if md.docType == NewsDoc {
+ if isFiltering && m.filterInput.Value() == "" {
+ title = dimIndigoFg(title)
+ date = dimSubtleIndigoFg(date)
+ } else {
+ s := termenv.Style{}.Foreground(lib.Indigo.Color())
+ title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline())
+ date = subtleIndigoFg(date)
+ }
} else if isFiltering && m.filterInput.Value() == "" {
icon = dimGreenFg(icon)
- title = dimNormalFg(title)
+ if title == noMemoTitle {
+ title = dimBrightGrayFg(title)
+ } else {
+ title = dimNormalFg(title)
+ }
date = dimBrightGrayFg(date)
- editedBy = dimBrightGrayFg(editedBy)
- separator = dimBrightGrayFg(separator)
} else {
icon = greenFg(icon)
-
- s := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"})
- title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true))
- date = grayFg(date)
- editedBy = midGrayFg(editedBy)
- separator = brightGrayFg(separator)
+ if title == noMemoTitle {
+ title = brightGrayFg(title)
+ } else {
+ s := termenv.Style{}.Foreground(lib.NewColorPair("#dddddd", "#1a1a1a").Color())
+ title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline())
+ }
+ date = brightGrayFg(date)
}
}
- 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 +146,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..ed527f4 100644
--- a/ui/styles.go
+++ b/ui/styles.go
@@ -1,46 +1,63 @@
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 (
+ lib "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(lib.NewColorPair("#dddddd", "#1a1a1a"))
+ dimNormalFg = newFgStyle(lib.NewColorPair("#777777", "#A49FA5"))
+
+ brightGrayFg = newFgStyle(lib.NewColorPair("#979797", "#847A85"))
+ dimBrightGrayFg = newFgStyle(lib.NewColorPair("#4D4D4D", "#C2B8C2"))
+
+ grayFg = newFgStyle(lib.NewColorPair("#626262", "#909090"))
+ midGrayFg = newFgStyle(lib.NewColorPair("#4A4A4A", "#B2B2B2"))
+ darkGrayFg = newFgStyle(lib.NewColorPair("#3C3C3C", "#DDDADA"))
+
+ greenFg = newFgStyle(lib.NewColorPair("#04B575", "#04B575"))
+ semiDimGreenFg = newFgStyle(lib.NewColorPair("#036B46", "#35D79C"))
+ dimGreenFg = newFgStyle(lib.NewColorPair("#0B5137", "#72D2B0"))
+
+ fuchsiaFg = newFgStyle(lib.Fuschia)
+ dimFuchsiaFg = newFgStyle(lib.NewColorPair("#99519E", "#F1A8FF"))
+
+ dullFuchsiaFg = newFgStyle(lib.NewColorPair("#AD58B4", "#F793FF"))
+ dimDullFuchsiaFg = newFgStyle(lib.NewColorPair("#6B3A6F", "#F6C9FF"))
+
+ indigoFg = newFgStyle(lib.Indigo)
+ dimIndigoFg = newFgStyle(lib.NewColorPair("#494690", "#9498FF"))
+
+ subtleIndigoFg = newFgStyle(lib.NewColorPair("#514DC1", "#7D79F6"))
+ dimSubtleIndigoFg = newFgStyle(lib.NewColorPair("#383584", "#BBBDFF"))
+
+ yellowFg = newFgStyle(lib.YellowGreen) // renders light green on light backgrounds
+ dullYellowFg = newFgStyle(lib.NewColorPair("#9BA92F", "#6BCB94")) // renders light green on light backgrounds
+ redFg = newFgStyle(lib.Red)
+ faintRedFg = newFgStyle(lib.FaintRed)
+
+ // Ultimately, we should transition to named styles
+ tabColor = newFgStyle(lib.NewColorPair("#626262", "#909090"))
+ selectedTabColor = newFgStyle(lib.NewColorPair("#979797", "#332F33"))
+)
+
+// Returns a termenv style with foreground and background options.
+func newStyle(fg, bg lib.ColorPair, bold bool) func(string) string {
+ s := te.Style{}.Foreground(fg.Color()).Background(bg.Color())
+ if bold {
+ s = s.Bold()
+ }
+ return s.Styled
+}
+
+// Returns a new termenv style with background options only.
+func newFgStyle(c lib.ColorPair) styleFunc {
+ return te.Style{}.Foreground(c.Color()).Styled
+}
diff --git a/ui/ui.go b/ui/ui.go
index 3537d3f..5a15f7c 100644
--- a/ui/ui.go
+++ b/ui/ui.go
@@ -1,71 +1,87 @@
-// Package ui provides the main UI for the glow application.
package ui
import (
+ "errors"
"fmt"
+ "io/ioutil"
+ "log"
"os"
"path/filepath"
"strings"
"time"
+ "github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/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"
+ lib "github.com/charmbracelet/charm/ui/common"
+ "github.com/charmbracelet/glow/utils"
"github.com/muesli/gitcha"
te "github.com/muesli/termenv"
+ "github.com/segmentio/ksuid"
)
const (
- statusMessageTimeout = time.Second * 3 // how long to show status messages like "stashed!"
+ noteCharacterLimit = 256 // should match server
+ statusMessageTimeout = time.Second * 2 // how long to show status messages like "stashed!"
ellipsis = "…"
)
var (
- config Config
+ config Config
+ glowLogoTextColor = lib.Color("#ECFD65")
markdownExtensions = []string{
"*.md", "*.mdown", "*.mkdn", "*.mkd", "*.markdown",
}
+
+ // True if we're logging to a file, in which case we'll log more stuff.
+ debug = false
+
+ // Types of documents we allow the user to stash.
+ stashableDocTypes = NewDocTypeSet(LocalDoc, NewsDoc)
)
// NewProgram returns a new Tea program.
-func NewProgram(cfg Config, content string) *tea.Program {
- log.Debug(
- "Starting glow",
- "high_perf_pager",
- cfg.HighPerformancePager,
- "glamour",
- cfg.GlamourEnabled,
- )
-
- config = cfg
- opts := []tea.ProgramOption{tea.WithAltScreen()}
- if cfg.EnableMouse {
- opts = append(opts, tea.WithMouseCellMotion())
+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 }
func (e errMsg) Error() string { return e.err.Error() }
-type (
- initLocalFileSearchMsg struct {
- cwd string
- ch chan gitcha.SearchResult
- }
-)
+type newCharmClientMsg *charm.Client
+type sshAuthErrMsg struct{}
+type keygenFailedMsg struct{ err error }
+type keygenSuccessMsg struct{}
+type initLocalFileSearchMsg struct {
+ cwd string
+ ch chan gitcha.SearchResult
+}
+type foundLocalFileMsg gitcha.SearchResult
+type localFileSearchFinished struct{}
+type gotStashMsg []*charm.Markdown
+type stashLoadErrMsg struct{ err error }
+type gotNewsMsg []*charm.Markdown
+type statusMessageTimeoutMsg applicationContext
+type newsLoadErrMsg struct{ err error }
+type stashSuccessMsg markdown
+type stashFailMsg struct {
+ err error
+ markdown markdown
+}
-type (
- foundLocalFileMsg gitcha.SearchResult
- localFileSearchFinished struct{}
- statusMessageTimeoutMsg applicationContext
-)
-
-// applicationContext indicates the area of the application something applies
+// applicationContext indicates the area of the application something appies
// to. Occasionally used as an argument to commands and messages.
type applicationContext int
@@ -89,18 +105,60 @@ func (s state) String() string {
}[s]
}
+type authStatus int
+
+const (
+ authConnecting authStatus = iota
+ authOK
+ authFailed
+)
+
+func (s authStatus) String() string {
+ return map[authStatus]string{
+ authConnecting: "connecting",
+ authOK: "ok",
+ authFailed: "failed",
+ }[s]
+}
+
+type keygenState int
+
+const (
+ keygenUnstarted keygenState = iota
+ keygenRunning
+ keygenFinished
+)
+
// Common stuff we'll need to access in all models.
type commonModel struct {
- cfg Config
- cwd string
- width int
- height int
+ cfg Config
+ cc *charm.Client
+ cwd string
+ authStatus authStatus
+ width int
+ height int
+
+ // Local IDs of files stashed this session. We treat this like a set,
+ // ignoring the value portion with an empty struct.
+ filesStashed map[ksuid.KSUID]struct{}
+
+ // ID of the most recently stashed markdown
+ latestFileStashed ksuid.KSUID
+
+ // Files currently being stashed. We remove files from this set once
+ // a stash operation has either succeeded or failed.
+ filesStashing map[ksuid.KSUID]struct{}
+}
+
+func (c commonModel) isStashing() bool {
+ return len(c.filesStashing) > 0
}
type model struct {
- common *commonModel
- state state
- fatalErr error
+ common *commonModel
+ state state
+ keygenState keygenState
+ fatalErr error
// Sub-models
stash stashModel
@@ -121,82 +179,57 @@ func (m *model) unloadDocument() []tea.Cmd {
var batch []tea.Cmd
if m.pager.viewport.HighPerformanceRendering {
- batch = append(batch, tea.ClearScrollArea) //nolint:staticcheck
+ batch = append(batch, tea.ClearScrollArea)
}
- if !m.stash.shouldSpin() {
- batch = append(batch, m.stash.spinner.Tick)
+ if !m.stash.loadingDone() {
+ batch = append(batch, spinner.Tick)
}
return batch
}
-func newModel(cfg Config, content string) tea.Model {
- initSections()
-
- if cfg.GlamourStyle == styles.AutoStyle {
+func newModel(cfg Config) tea.Model {
+ if cfg.GlamourStyle == "auto" {
if te.HasDarkBackground() {
- cfg.GlamourStyle = styles.DarkStyle
+ cfg.GlamourStyle = "dark"
} else {
- cfg.GlamourStyle = styles.LightStyle
+ cfg.GlamourStyle = "light"
}
}
+ if len(cfg.DocumentTypes) == 0 {
+ cfg.DocumentTypes.Add(LocalDoc, StashedDoc, ConvertedDoc, NewsDoc)
+ }
+
common := commonModel{
- cfg: cfg,
+ cfg: cfg,
+ authStatus: authConnecting,
+ filesStashed: make(map[ksuid.KSUID]struct{}),
+ filesStashing: make(map[ksuid.KSUID]struct{}),
}
- m := model{
- common: &common,
- state: stateShowStash,
- pager: newPagerModel(&common),
- stash: newStashModel(&common),
+ return model{
+ common: &common,
+ state: stateShowStash,
+ keygenState: keygenUnstarted,
+ pager: newPagerModel(&common),
+ stash: newStashModel(&common),
}
-
- path := cfg.Path
- if path == "" && content != "" {
- m.state = stateShowDocument
- m.pager.currentDocument = markdown{Body: content}
- return m
- }
-
- if path == "" {
- path = "."
- }
- info, err := os.Stat(path)
- if err != nil {
- log.Error("unable to stat file", "file", path, "error", err)
- m.fatalErr = err
- return m
- }
- if info.IsDir() {
- m.state = stateShowStash
- } else {
- cwd, _ := os.Getwd()
- m.state = stateShowDocument
- m.pager.currentDocument = markdown{
- localPath: path,
- Note: stripAbsolutePath(path, cwd),
- Modtime: info.ModTime(),
- }
- }
-
- return m
}
func (m model) Init() tea.Cmd {
- cmds := []tea.Cmd{m.stash.spinner.Tick}
+ var cmds []tea.Cmd
+ d := m.common.cfg.DocumentTypes
- 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 d.Contains(StashedDoc) || d.Contains(NewsDoc) {
+ cmds = append(cmds,
+ newCharmClient,
+ spinner.Tick,
+ )
+ }
+
+ if d.Contains(LocalDoc) {
+ cmds = append(cmds, findLocalFiles(m))
}
return tea.Batch(cmds...)
@@ -216,45 +249,43 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
- if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument {
+ if m.state == stateShowDocument {
batch := m.unloadDocument()
return m, tea.Batch(batch...)
}
- case "r":
- var cmd tea.Cmd
- if m.state == stateShowStash {
- // pass through all keys if we're editing the filter
- if m.stash.filterState == filtering {
- m.stash, cmd = m.stash.update(msg)
- return m, cmd
- }
- m.stash.markdowns = nil
- return m, m.Init()
- }
case "q":
var cmd tea.Cmd
- switch m.state { //nolint:exhaustive
+ switch m.state {
case stateShowStash:
// pass through all keys if we're editing the filter
- if m.stash.filterState == filtering {
+ if m.stash.filterState == filtering || m.stash.selectionState == selectionSettingNote {
m.stash, cmd = m.stash.update(msg)
return m, cmd
}
+
+ // Special cases for the pager
+ case stateShowDocument:
+ switch m.pager.state {
+ // If setting a note send all keys straight through
+ case pagerStateSetNote:
+ var batch []tea.Cmd
+ newPagerModel, cmd := m.pager.update(msg)
+ m.pager = newPagerModel
+ batch = append(batch, cmd)
+ return m, tea.Batch(batch...)
+ }
}
return m, tea.Quit
case "left", "h", "delete":
- if m.state == stateShowDocument {
+ 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
@@ -272,16 +303,66 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.common.cwd = msg.cwd
cmds = append(cmds, findNextLocalFile(m))
+ case sshAuthErrMsg:
+ if m.keygenState != keygenFinished { // if we haven't run the keygen yet, do that
+ m.keygenState = keygenRunning
+ cmds = append(cmds, generateSSHKeys)
+ } else {
+ // The keygen ran but things still didn't work and we can't auth
+ m.common.authStatus = authFailed
+ m.stash.err = errors.New("SSH authentication failed; we tried ssh-agent, loading keys from disk, and generating SSH keys")
+ if debug {
+ log.Println("entering offline mode;", m.stash.err)
+ }
+
+ // Even though it failed, news/stash loading is finished
+ m.stash.loaded.Add(StashedDoc, NewsDoc)
+ }
+
+ case keygenFailedMsg:
+ // Keygen failed. That sucks.
+ m.common.authStatus = authFailed
+ m.stash.err = errors.New("could not authenticate; could not generate SSH keys")
+ if debug {
+ log.Println("entering offline mode;", m.stash.err)
+ }
+
+ m.keygenState = keygenFinished
+
+ // Even though it failed, news/stash loading is finished
+ m.stash.loaded.Add(StashedDoc, NewsDoc)
+
+ case keygenSuccessMsg:
+ // The keygen's done, so let's try initializing the charm client again
+ m.keygenState = keygenFinished
+ cmds = append(cmds, newCharmClient)
+
+ case newCharmClientMsg:
+ m.common.cc = msg
+ m.common.authStatus = authOK
+ cmds = append(cmds, loadStash(m.stash), loadNews(m.stash))
+
+ case stashLoadErrMsg:
+ m.common.authStatus = authFailed
+
case fetchedMarkdownMsg:
// We've loaded a markdown file's contents for rendering
m.pager.currentDocument = *msg
- 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 := m.stash.update(msg)
+ 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.
@@ -300,6 +381,35 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
cmds = append(cmds, findNextLocalFile(m))
+ case stashSuccessMsg:
+ // Common handling that should happen regardless of application state
+ md := markdown(msg)
+ m.stash.addMarkdowns(&md)
+ m.common.filesStashed[msg.stashID] = struct{}{}
+ delete(m.common.filesStashing, md.stashID)
+
+ if m.stash.filterApplied() {
+ for _, v := range m.stash.filteredMarkdowns {
+ if v.stashID == msg.stashID && v.docType == ConvertedDoc {
+ // Add the server-side ID we got back so we can do things
+ // like rename and stash it.
+ v.ID = msg.ID
+
+ // Keep the unique ID in sync so we can do things like
+ // delete. Note that the markdown received a new unique ID
+ // when it was added to the file listing in
+ // stash.addMarkdowns.
+ v.uniqueID = md.uniqueID
+ break
+ }
+ }
+ }
+
+ case stashFailMsg:
+ // Common handling that should happen regardless of application state
+ delete(m.common.filesStashed, msg.markdown.stashID)
+ delete(m.common.filesStashing, msg.markdown.stashID)
+
case filteredMarkdownMsg:
if m.state == stateShowDocument {
newStashModel, cmd := m.stash.update(msg)
@@ -329,7 +439,7 @@ 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:
@@ -345,20 +455,22 @@ 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(lib.Cream.Color()).
+ Background(lib.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
+ cwd = m.common.cfg.WorkingDirectory
err error
)
@@ -374,22 +486,26 @@ func findLocalFiles(m commonModel) tea.Cmd {
// Note that this is one error check for both cases above
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))
+ if debug {
+ log.Println("local directory is:", cwd)
}
+ var ignore []string
+ if !m.common.cfg.ShowAllFiles {
+ ignore = ignorePatterns(m)
+ }
+
+ ch, err := gitcha.FindFilesExcept(cwd, markdownExtensions, 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 +522,187 @@ 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.common.cc == nil {
+ err := errors.New("no charm client")
+ if debug {
+ log.Println("error loading stash:", err)
+ }
+ return stashLoadErrMsg{err}
+ }
+ stash, err := m.common.cc.GetStash(m.serverPage)
+ if err != nil {
+ if debug {
+ if _, ok := err.(charm.ErrAuthFailed); ok {
+ log.Println("auth failure while loading stash:", err)
+ } else {
+ log.Println("error loading stash:", err)
+ }
+ }
+ return stashLoadErrMsg{err}
+ }
+ if debug {
+ log.Println("loaded stash page", m.serverPage)
+ }
+ return gotStashMsg(stash)
+ }
+}
+
+func loadNews(m stashModel) tea.Cmd {
+ return func() tea.Msg {
+ if m.common.cc == nil {
+ err := errors.New("no charm client")
+ if debug {
+ log.Println("error loading news:", err)
+ }
+ return newsLoadErrMsg{err}
+ }
+ news, err := m.common.cc.GetNews(1) // just fetch the first page
+ if err != nil {
+ if debug {
+ log.Println("error loading news:", err)
+ }
+ return newsLoadErrMsg{err}
+ }
+ if debug {
+ log.Println("fetched news")
+ }
+ return gotNewsMsg(news)
+ }
+}
+
+func generateSSHKeys() tea.Msg {
+ if debug {
+ log.Println("running keygen...")
+ }
+ _, err := keygen.NewSSHKeyPair(nil)
+ if err != nil {
+ if debug {
+ log.Println("keygen failed:", err)
+ }
+ return keygenFailedMsg{err}
+ }
+ if debug {
+ log.Println("keys generated succcessfully")
+ }
+ return keygenSuccessMsg{}
+}
+
+func saveDocumentNote(cc *charm.Client, id int, note string) tea.Cmd {
+ if cc == nil {
+ return func() tea.Msg {
+ err := errors.New("can't set note; no charm client")
+ if debug {
+ log.Println("error saving note:", err)
+ }
+ return errMsg{err}
+ }
+ }
+ return func() tea.Msg {
+ if err := cc.SetMarkdownNote(id, note); err != nil {
+ if debug {
+ log.Println("error saving note:", err)
+ }
+ return errMsg{err}
+ }
+ return noteSavedMsg(&charm.Markdown{ID: id, Note: note})
+ }
+}
+
+func stashDocument(cc *charm.Client, md markdown) tea.Cmd {
+ return func() tea.Msg {
+ if cc == nil {
+ err := errors.New("can't stash; no charm client")
+ if debug {
+ log.Println("error stashing document:", err)
+ }
+ return stashFailMsg{err, md}
+ }
+
+ // Is the document missing a body? If so, it likely means it needs to
+ // be loaded. But...if it turns out the document body really is empty
+ // then we'll stash it anyway.
+ if len(md.Body) == 0 {
+ switch md.docType {
+
+ case LocalDoc:
+ data, err := ioutil.ReadFile(md.localPath)
+ if err != nil {
+ if debug {
+ log.Println("error loading document body for stashing:", err)
+ }
+ return stashFailMsg{err, md}
+ }
+ md.Body = string(data)
+
+ case NewsDoc:
+ newMD, err := fetchMarkdown(cc, md.ID, md.docType)
+ if err != nil {
+ if debug {
+ log.Println(err)
+ }
+ return stashFailMsg{err, md}
+ }
+ md.Body = newMD.Body
+
+ default:
+ err := fmt.Errorf("user is attempting to stash an unsupported markdown type: %s", md.docType)
+ if debug {
+ log.Println(err)
+ }
+ return stashFailMsg{err, md}
+ }
+ }
+
+ newMd, err := cc.StashMarkdown(md.Note, md.Body)
+ if err != nil {
+ if debug {
+ log.Println("error stashing document:", err)
+ }
+ return stashFailMsg{err, md}
+ }
+
+ md.convertToStashed()
+
+ // The server sends the whole stashed document back, but we really just
+ // need to know the ID so we can operate on this newly stashed
+ // markdown.
+ md.ID = newMd.ID
+
+ return stashSuccessMsg(md)
+ }
+}
+
func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.Cmd {
return func() tea.Msg {
<-t.C
@@ -424,17 +716,20 @@ func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.C
// document. Note that we could be doing things like checking if the file is
// a directory, but we trust that gitcha has already done that.
func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown {
- return &markdown{
+ md := &markdown{
+ docType: LocalDoc,
localPath: res.Path,
- Note: stripAbsolutePath(res.Path, cwd),
- Modtime: res.Info.ModTime(),
+ 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 +745,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..8146f82 100644
--- a/utils/utils.go
+++ b/utils/utils.go
@@ -1,20 +1,12 @@
-// Package utils provides utility functions.
package utils
import (
"os"
- "path/filepath"
"regexp"
- "strings"
- "github.com/charmbracelet/glamour"
- "github.com/charmbracelet/glamour/ansi"
- "github.com/charmbracelet/glamour/styles"
- "github.com/charmbracelet/lipgloss"
"github.com/mitchellh/go-homedir"
)
-// RemoveFrontmatter removes the front matter header of a markdown file.
func RemoveFrontmatter(content []byte) []byte {
if frontmatterBoundaries := detectFrontmatter(content); frontmatterBoundaries[0] == 0 {
return content[frontmatterBoundaries[1]:]
@@ -31,7 +23,7 @@ func detectFrontmatter(c []byte) []int {
return []int{-1, -1}
}
-// ExpandPath expands tilde and all environment variables from the given path.
+// Expands tilde and all environment variables from the given path.
func ExpandPath(path string) string {
s, err := homedir.Expand(path)
if err == nil {
@@ -39,75 +31,3 @@ func ExpandPath(path string) string {
}
return os.ExpandEnv(path)
}
-
-// WrapCodeBlock wraps a string in a code block with the given language.
-func WrapCodeBlock(s, language string) string {
- return "```" + language + "\n" + s + "```"
-}
-
-var markdownExtensions = []string{
- ".md", ".mdown", ".mkdn", ".mkd", ".markdown",
-}
-
-// IsMarkdownFile returns whether the filename has a markdown extension.
-func IsMarkdownFile(filename string) bool {
- ext := filepath.Ext(filename)
-
- if ext == "" {
- // By default, assume it's a markdown file.
- return true
- }
-
- for _, v := range markdownExtensions {
- if strings.EqualFold(ext, v) {
- return true
- }
- }
-
- // Has an extension but not markdown
- // so assume this is a code file.
- return false
-}
-
-// GlamourStyle returns a glamour.TermRendererOption based on the given style.
-func GlamourStyle(style string, isCode bool) glamour.TermRendererOption {
- if !isCode {
- if style == styles.AutoStyle {
- return glamour.WithAutoStyle()
- }
- return glamour.WithStylePath(style)
- }
-
- // If we are rendering a pure code block, we need to modify the style to
- // remove the indentation.
-
- var styleConfig ansi.StyleConfig
-
- switch style {
- case styles.AutoStyle:
- if lipgloss.HasDarkBackground() {
- styleConfig = styles.DarkStyleConfig
- } else {
- styleConfig = styles.LightStyleConfig
- }
- case styles.DarkStyle:
- styleConfig = styles.DarkStyleConfig
- case styles.LightStyle:
- styleConfig = styles.LightStyleConfig
- case styles.PinkStyle:
- styleConfig = styles.PinkStyleConfig
- case styles.NoTTYStyle:
- styleConfig = styles.NoTTYStyleConfig
- case styles.DraculaStyle:
- styleConfig = styles.DraculaStyleConfig
- case styles.TokyoNightStyle:
- styleConfig = styles.DraculaStyleConfig
- default:
- return glamour.WithStylesFromJSONFile(style)
- }
-
- var margin uint
- styleConfig.CodeBlock.Margin = &margin
-
- return glamour.WithStyles(styleConfig)
-}