mirror of
https://github.com/charmbracelet/glow.git
synced 2026-08-09 17:59:10 +02:00
Compare commits
No commits in common. "master" and "revert" have entirely different histories.
51 changed files with 3694 additions and 1594 deletions
|
|
@ -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
|
||||
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -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.
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
5
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,5 +0,0 @@
|
|||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Discord
|
||||
url: https://charm.sh/discord
|
||||
about: Chat on our Discord.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
|
@ -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.
|
||||
43
.github/dependabot.yml
vendored
43
.github/dependabot.yml
vendored
|
|
@ -1,57 +1,20 @@
|
|||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "05:00"
|
||||
timezone: "America/New_York"
|
||||
interval: "daily"
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
prefix: "feat"
|
||||
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"
|
||||
interval: "daily"
|
||||
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:
|
||||
- "*"
|
||||
|
|
|
|||
13
.github/workflows/build.yml
vendored
13
.github/workflows/build.yml
vendored
|
|
@ -10,16 +10,3 @@ jobs:
|
|||
uses: charmbracelet/meta/.github/workflows/snapshot.yml@main
|
||||
secrets:
|
||||
goreleaser_key: ${{ secrets.GORELEASER_KEY }}
|
||||
|
||||
govulncheck:
|
||||
uses: charmbracelet/meta/.github/workflows/govulncheck.yml@main
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
semgrep:
|
||||
uses: charmbracelet/meta/.github/workflows/semgrep.yml@main
|
||||
|
||||
ruleguard:
|
||||
uses: charmbracelet/meta/.github/workflows/ruleguard.yml@main
|
||||
with:
|
||||
go-version: stable
|
||||
|
|
|
|||
20
.github/workflows/coverage.yml
vendored
20
.github/workflows/coverage.yml
vendored
|
|
@ -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@v3
|
||||
with:
|
||||
go-version: stable
|
||||
go-version: ${{ matrix.go-version }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- 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
|
||||
|
|
|
|||
17
.github/workflows/dependabot-sync.yml
vendored
17
.github/workflows/dependabot-sync.yml
vendored
|
|
@ -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 }}
|
||||
4
.github/workflows/goreleaser.yml
vendored
4
.github/workflows/goreleaser.yml
vendored
|
|
@ -1,5 +1,3 @@
|
|||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
|
||||
name: goreleaser
|
||||
|
||||
on:
|
||||
|
|
@ -23,3 +21,5 @@ jobs:
|
|||
nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }}
|
||||
nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }}
|
||||
snapcraft_token: ${{ secrets.SNAPCRAFT_TOKEN }}
|
||||
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
|
|
|
|||
13
.github/workflows/lint-sync.yml
vendored
13
.github/workflows/lint-sync.yml
vendored
|
|
@ -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
|
||||
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
|
|
@ -8,9 +8,9 @@ jobs:
|
|||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v3
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9.2.0
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
# Optional: golangci-lint command line arguments.
|
||||
args: --issues-exit-code=0
|
||||
|
|
|
|||
12
.github/workflows/soft-serve.yml
vendored
Normal file
12
.github/workflows/soft-serve.yml
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
name: soft-serve
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
soft-serve:
|
||||
uses: charmbracelet/meta/.github/workflows/soft-serve.yml@main
|
||||
secrets:
|
||||
ssh-key: "${{ secrets.CHARM_SOFT_SERVE_KEY }}"
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,4 +2,4 @@ glow
|
|||
dist/
|
||||
.envrc
|
||||
completions/
|
||||
manpages/
|
||||
manpages/
|
||||
|
|
@ -1,44 +1,34 @@
|
|||
version: "2"
|
||||
run:
|
||||
tests: false
|
||||
|
||||
issues:
|
||||
include:
|
||||
- EXC0001
|
||||
- EXC0005
|
||||
- EXC0011
|
||||
- EXC0012
|
||||
- EXC0013
|
||||
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- bodyclose
|
||||
- exhaustive
|
||||
- dupl
|
||||
- exportloopref
|
||||
- goconst
|
||||
- godot
|
||||
- gomoddirectives
|
||||
- godox
|
||||
- goimports
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- ifshort
|
||||
- misspell
|
||||
- nakedret
|
||||
- nestif
|
||||
- nilerr
|
||||
- noctx
|
||||
- nolintlint
|
||||
- prealloc
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
- tparallel
|
||||
- unconvert
|
||||
- unparam
|
||||
- whitespace
|
||||
- wrapcheck
|
||||
exclusions:
|
||||
rules:
|
||||
- text: '(slog|log)\.\w+'
|
||||
linters:
|
||||
- noctx
|
||||
generated: lax
|
||||
presets:
|
||||
- common-false-positives
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
formatters:
|
||||
enable:
|
||||
- gofumpt
|
||||
- goimports
|
||||
exclusions:
|
||||
generated: lax
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
|
||||
|
||||
version: 2
|
||||
|
||||
includes:
|
||||
- from_url:
|
||||
url: charmbracelet/meta/main/goreleaser-glow.yaml
|
||||
|
||||
variables:
|
||||
main: "."
|
||||
binary_name: glow
|
||||
description: "Render markdown on the CLI, with pizzazz!"
|
||||
github_url: "https://github.com/charmbracelet/glow"
|
||||
github_url: "https://github.com/charmbracelet/soft-serve"
|
||||
maintainer: "Christian Muehlhaeuser <muesli@charm.sh>"
|
||||
brew_commit_author_name: "Christian Muehlhaeuser"
|
||||
brew_commit_author_email: "muesli@charm.sh"
|
||||
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
|
||||
|
|
|
|||
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2019-2024 Charmbracelet, Inc
|
||||
Copyright (c) 2019-2022 Charmbracelet, Inc
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
|||
20
Makefile
Normal file
20
Makefile
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# This Makefile is just for development purposes
|
||||
|
||||
.PHONY: default clean glow run log
|
||||
|
||||
LOGFILE := debug.log
|
||||
|
||||
default: glow
|
||||
|
||||
clean:
|
||||
rm -f ./glow
|
||||
|
||||
glow:
|
||||
go build
|
||||
|
||||
run: clean glow
|
||||
GLOW_LOGFILE=$(LOGFILE) ./glow
|
||||
|
||||
log:
|
||||
> $(LOGFILE)
|
||||
tail -f $(LOGFILE)
|
||||
104
README.md
104
README.md
|
|
@ -11,7 +11,7 @@ Render markdown on the CLI, with _pizzazz_!
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/user-attachments/assets/c2246366-f84b-4847-b431-32a61ca07b74" width="800" alt="Glow UI Demo">
|
||||
<img src="https://stuff.charm.sh/glow/glow-1.3-trailer-github.gif" width="600" alt="Glow UI Demo">
|
||||
</p>
|
||||
|
||||
## What is it?
|
||||
|
|
@ -20,9 +20,13 @@ Glow is a terminal based markdown reader designed from the ground up to bring
|
|||
out the beauty—and power—of the CLI.
|
||||
|
||||
Use it to discover markdown files, read documentation directly on the command
|
||||
line. Glow will find local markdown files in subdirectories or a local
|
||||
line and stash markdown files to your own private collection, so you can read
|
||||
them anywhere. Glow will find local markdown files in subdirectories or a local
|
||||
Git repository.
|
||||
|
||||
By the way, all data stashed is encrypted end-to-end: only you can decrypt it.
|
||||
More on that below.
|
||||
|
||||
## Installation
|
||||
|
||||
### Package Manager
|
||||
|
|
@ -30,64 +34,38 @@ Git repository.
|
|||
```bash
|
||||
# macOS or Linux
|
||||
brew install glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# macOS (with MacPorts)
|
||||
sudo port install glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Arch Linux (btw)
|
||||
pacman -S glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Void Linux
|
||||
xbps-install -S glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Nix shell
|
||||
nix-shell -p glow --command glow
|
||||
```
|
||||
# Nix
|
||||
nix-env -iA nixpkgs.glow
|
||||
|
||||
```bash
|
||||
# FreeBSD
|
||||
pkg install glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Solus
|
||||
eopkg install glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Windows (with Chocolatey, Scoop, or Winget)
|
||||
choco install glow
|
||||
# Windows (with Scoop or Chocolatey)
|
||||
scoop install glow
|
||||
winget install charmbracelet.glow
|
||||
```
|
||||
choco install glow
|
||||
|
||||
```bash
|
||||
# Android (with termux)
|
||||
pkg install glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Ubuntu (Snapcraft)
|
||||
sudo snap install glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list
|
||||
sudo apt update && sudo apt install glow
|
||||
```
|
||||
|
||||
```bash
|
||||
# Fedora/RHEL
|
||||
echo '[charm]
|
||||
name=Charm
|
||||
|
|
@ -105,12 +83,11 @@ packages. ARM builds are also available for macOS, Linux, FreeBSD and OpenBSD.
|
|||
### Go
|
||||
|
||||
Or just install it with `go`:
|
||||
|
||||
```bash
|
||||
go install github.com/charmbracelet/glow/v2@latest
|
||||
go install github.com/charmbracelet/glow@latest
|
||||
```
|
||||
|
||||
### Build (requires Go 1.21+)
|
||||
### Build (requires Go 1.13+)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/charmbracelet/glow.git
|
||||
|
|
@ -120,10 +97,11 @@ go build
|
|||
|
||||
[releases]: https://github.com/charmbracelet/glow/releases
|
||||
|
||||
|
||||
## The TUI
|
||||
|
||||
Simply run `glow` without arguments to start the textual user interface and
|
||||
browse local. Glow will find local markdown files in the
|
||||
browse local and stashed markdown. Glow will find local markdown files in the
|
||||
current directory and below or, if you’re in a Git repository, Glow will search
|
||||
the repo.
|
||||
|
||||
|
|
@ -131,6 +109,15 @@ Markdown files can be read with Glow's high-performance pager. Most of the
|
|||
keystrokes you know from `less` are the same, but you can press `?` to list
|
||||
the hotkeys.
|
||||
|
||||
### Stashing
|
||||
|
||||
Glow works with the Charm Cloud to allow you to store any markdown files in
|
||||
your own private collection. You can stash a local document from the Glow TUI by
|
||||
pressing `s`.
|
||||
|
||||
Stashing is private, its contents will not be exposed publicly, and it's
|
||||
encrypted end-to-end. More on encryption below.
|
||||
|
||||
## The CLI
|
||||
|
||||
In addition to a TUI, Glow has a CLI for working with Markdown. To format a
|
||||
|
|
@ -150,6 +137,18 @@ glow github.com/charmbracelet/glow
|
|||
glow https://host.tld/file.md
|
||||
```
|
||||
|
||||
### Stashing
|
||||
|
||||
You can also stash documents from the CLI:
|
||||
|
||||
```bash
|
||||
glow stash README.md
|
||||
```
|
||||
|
||||
Then, when you run `glow` without arguments will you can browse through your
|
||||
stashed documents. This is a great way to keep track of things that you need to
|
||||
reference often.
|
||||
|
||||
### Word Wrapping
|
||||
|
||||
The `-w` flag lets you set a maximum width at which the output will be wrapped:
|
||||
|
|
@ -201,39 +200,42 @@ Here's an example config:
|
|||
```yaml
|
||||
# style name or JSON path (default "auto")
|
||||
style: "light"
|
||||
# mouse wheel support (TUI-mode only)
|
||||
# show local files only; no network (TUI-mode only)
|
||||
local: true
|
||||
# mouse support (TUI-mode only)
|
||||
mouse: true
|
||||
# use pager to display markdown
|
||||
pager: true
|
||||
# at which column should we word wrap?
|
||||
# word-wrap at width
|
||||
width: 80
|
||||
# show all files, including hidden and ignored.
|
||||
all: false
|
||||
# show line numbers (TUI-mode only)
|
||||
showLineNumbers: false
|
||||
# preserve newlines in the output
|
||||
preserveNewLines: false
|
||||
```
|
||||
|
||||
## Contributing
|
||||
## 🔒 Encryption: How It Works
|
||||
|
||||
See [contributing][contribute].
|
||||
Encryption works by issuing symmetric keys (basically a generated password) and
|
||||
encrypting it with the local SSH public key generated by the open-source
|
||||
[charm][charmlib] library. That encrypted key is then sent up to our server.
|
||||
We can’t read it since we don’t have your private key. When you want to decrypt
|
||||
something or view your stash, that key is downloaded from our server and
|
||||
decrypted locally using the SSH private key. When you link accounts, the
|
||||
symmetric key is encrypted for each new public key. This happens on your
|
||||
machine and not our server, so we never see any unencrypted data.
|
||||
|
||||
[contribute]: https://github.com/charmbracelet/glow/contribute
|
||||
[charmlib]: https://github.com/charmbracelet/charm
|
||||
|
||||
## Feedback
|
||||
|
||||
We’d love to hear your thoughts on this project. Feel free to drop us a note!
|
||||
|
||||
- [Twitter](https://twitter.com/charmcli)
|
||||
- [The Fediverse](https://mastodon.social/@charmcli)
|
||||
- [Discord](https://charm.sh/chat)
|
||||
* [Twitter](https://twitter.com/charmcli)
|
||||
* [The Fediverse](https://mastodon.social/@charmcli)
|
||||
* [Discord](https://charm.sh/chat)
|
||||
|
||||
## License
|
||||
|
||||
[MIT](https://github.com/charmbracelet/glow/raw/master/LICENSE)
|
||||
|
||||
---
|
||||
***
|
||||
|
||||
Part of [Charm](https://charm.sh).
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -1,29 +1,26 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/charmbracelet/x/editor"
|
||||
"github.com/charmbracelet/glow/editor"
|
||||
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",
|
||||
|
|
@ -32,57 +29,50 @@ var configCmd = &cobra.Command{
|
|||
Long: paragraph(fmt.Sprintf("\n%s the glow config file. We’ll use EDITOR to determine which editor to use. If the config file doesn't exist, it will be created.", keyword("Edit"))),
|
||||
Example: paragraph("glow config\nglow config --config path/to/config.yml"),
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
if err := ensureConfigFile(); err != nil {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
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'", 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(filepath.Dir(configFile), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Create(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = 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 := editor.Cmd(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
|
||||
}
|
||||
|
|
|
|||
27
editor/editor.go
Normal file
27
editor/editor.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package editor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultEditor = "nano"
|
||||
|
||||
// Cmd returns a *exec.Cmd editing the given path with $EDITOR or nano if no
|
||||
// $EDITOR is set.
|
||||
func Cmd(path string) *exec.Cmd {
|
||||
editor, args := getEditor()
|
||||
return exec.Command(editor, append(args, path)...)
|
||||
}
|
||||
|
||||
func getEditor() (string, []string) {
|
||||
editor := strings.Fields(os.Getenv("EDITOR"))
|
||||
if len(editor) > 1 {
|
||||
return editor[0], editor[1:]
|
||||
}
|
||||
if len(editor) == 1 {
|
||||
return editor[0], []string{}
|
||||
}
|
||||
return defaultEditor, []string{}
|
||||
}
|
||||
26
editor/editor_test.go
Normal file
26
editor/editor_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package editor
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEditor(t *testing.T) {
|
||||
filename := "README.md"
|
||||
for k, v := range map[string][]string{
|
||||
"": {"nano", filename},
|
||||
"nvim": {"nvim", filename},
|
||||
"vim": {"vim", filename},
|
||||
"vscode --foo": {"vscode", "--foo", filename},
|
||||
"nvim -a -b": {"nvim", "-a", "-b", filename},
|
||||
} {
|
||||
t.Run(k, func(t *testing.T) {
|
||||
t.Setenv("EDITOR", k)
|
||||
cmd := Cmd("README.md")
|
||||
got := cmd.Args
|
||||
if !reflect.DeepEqual(got, v) {
|
||||
t.Fatalf("expected %v; got %v", v, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
50
github.go
50
github.go
|
|
@ -1,55 +1,47 @@
|
|||
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
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
53
gitlab.go
53
gitlab.go
|
|
@ -1,59 +1,46 @@
|
|||
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
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
22
glow_test.go
22
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
|
||||
|
|
|
|||
86
go.mod
86
go.mod
|
|
@ -1,75 +1,27 @@
|
|||
module github.com/charmbracelet/glow/v2
|
||||
module github.com/charmbracelet/glow
|
||||
|
||||
go 1.25.12
|
||||
|
||||
toolchain go1.26.5
|
||||
go 1.16
|
||||
|
||||
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.15.0
|
||||
github.com/charmbracelet/bubbletea v0.23.1
|
||||
github.com/charmbracelet/charm v0.8.6
|
||||
github.com/charmbracelet/glamour v0.6.0
|
||||
github.com/charmbracelet/lipgloss v0.6.0
|
||||
github.com/dustin/go-humanize v1.0.1-0.20200219035652-afde56e7acac
|
||||
github.com/mattn/go-runewidth v0.0.14
|
||||
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/gitcha v0.2.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/termenv v0.13.0
|
||||
github.com/sahilm/fuzzy v0.1.0
|
||||
github.com/segmentio/ksuid v1.0.4
|
||||
github.com/spf13/cobra v1.6.1
|
||||
github.com/spf13/viper v1.14.0
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
|
||||
golang.org/x/text v0.6.0
|
||||
)
|
||||
|
|
|
|||
40
log.go
40
log.go
|
|
@ -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
|
||||
}
|
||||
349
main.go
349
main.go
|
|
@ -1,11 +1,9 @@
|
|||
// Package main provides the entry point for the Glow CLI application.
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
|
@ -13,15 +11,11 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"mvdan.cc/sh/v3/shell"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"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/charmbracelet/glow/ui"
|
||||
"github.com/charmbracelet/glow/utils"
|
||||
"github.com/meowgorithm/babyenv"
|
||||
gap "github.com/muesli/go-app-paths"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
|
@ -34,34 +28,23 @@ var (
|
|||
// CommitSHA as provided by goreleaser.
|
||||
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: paragraph(fmt.Sprintf("\nRender markdown on the CLI, %s!", 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 +62,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()) // nolint:bodyclose
|
||||
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 +101,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,49 +128,28 @@ 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()))
|
||||
|
|
@ -192,28 +160,26 @@ 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 := term.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)
|
||||
return false, err
|
||||
}
|
||||
if stat.Mode()&os.ModeCharDevice == 0 || stat.Size() > 0 {
|
||||
return true, nil
|
||||
|
|
@ -222,6 +188,11 @@ func stdinIsPipe() (bool, error) {
|
|||
}
|
||||
|
||||
func execute(cmd *cobra.Command, args []string) error {
|
||||
initConfig()
|
||||
if err := validateOptions(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if stdin is a pipe then use stdin for input. note that you can also
|
||||
// explicitly use a - to read from stdin.
|
||||
if yes, err := stdinIsPipe(); err != nil {
|
||||
|
|
@ -235,7 +206,7 @@ func execute(cmd *cobra.Command, args []string) error {
|
|||
switch len(args) {
|
||||
// TUI running on cwd
|
||||
case 0:
|
||||
return runTUI("", "")
|
||||
return runTUI("", false)
|
||||
|
||||
// TUI with possible dir argument
|
||||
case 1:
|
||||
|
|
@ -245,7 +216,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
|
||||
|
|
@ -275,7 +246,7 @@ func executeArg(cmd *cobra.Command, arg string, w io.Writer) error {
|
|||
func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error {
|
||||
b, err := io.ReadAll(src.reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read from reader: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
b = utils.RemoveFrontmatter(b)
|
||||
|
|
@ -288,106 +259,103 @@ 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:]...) // nolint:gosec
|
||||
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() //nolint:errcheck
|
||||
}
|
||||
|
||||
cfg.Path = path
|
||||
cfg.WorkingDirectory = workingDirectory
|
||||
cfg.DocumentTypes = ui.NewDocTypeSet()
|
||||
cfg.ShowAllFiles = showAllFiles
|
||||
cfg.ShowLineNumbers = showLineNumbers
|
||||
cfg.GlamourMaxWidth = width
|
||||
cfg.GlamourStyle = style
|
||||
cfg.EnableMouse = mouse
|
||||
cfg.PreserveNewLines = preserveNewLines
|
||||
|
||||
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)
|
||||
if _, err := ui.NewProgram(cfg).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 +364,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")
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
|
|
|||
29
man_cmd.go
29
man_cmd.go
|
|
@ -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
|
||||
},
|
||||
}
|
||||
82
stash_cmd.go
Normal file
82
stash_cmd.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/charm"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
memo string
|
||||
dot = lipgloss.NewStyle().Foreground(lipgloss.Color("#04B575")).Render("•")
|
||||
|
||||
stashCmd = &cobra.Command{
|
||||
Use: "stash [SOURCE]",
|
||||
Hidden: false,
|
||||
Short: "Stash a markdown",
|
||||
Long: paragraph(fmt.Sprintf("\nDo %s stuff. Run with no arguments to browse your stash or pass a path to a markdown file to stash it.", keyword("stash"))),
|
||||
Example: paragraph("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() //nolint:errcheck
|
||||
b, err := io.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")
|
||||
}
|
||||
|
||||
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(paragraph("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
|
||||
}
|
||||
8
style.go
8
style.go
|
|
@ -1,13 +1,13 @@
|
|||
package main
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
import . "github.com/charmbracelet/lipgloss" //nolint:revive
|
||||
|
||||
var (
|
||||
keyword = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#04B575")).
|
||||
keyword = NewStyle().
|
||||
Foreground(AdaptiveColor{Light: "#04B575", Dark: "#04B575"}).
|
||||
Render
|
||||
|
||||
paragraph = lipgloss.NewStyle().
|
||||
paragraph = NewStyle().
|
||||
Width(78).
|
||||
Padding(0, 0, 0, 2).
|
||||
Render
|
||||
|
|
|
|||
34
ui/config.go
34
ui/config.go
|
|
@ -2,19 +2,29 @@ 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
|
||||
EnableMouse bool
|
||||
|
||||
// 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) localOnly() bool {
|
||||
return c.DocumentTypes.Equals(NewDocTypeSet(LocalDoc))
|
||||
}
|
||||
|
||||
func (c Config) stashedOnly() bool {
|
||||
return c.DocumentTypes.Contains(StashedDoc) && !c.DocumentTypes.Contains(LocalDoc)
|
||||
}
|
||||
|
|
|
|||
8
ui/consts_unix.go
Normal file
8
ui/consts_unix.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package ui
|
||||
|
||||
const (
|
||||
pagerStashIcon = "🔒"
|
||||
)
|
||||
7
ui/consts_windows.go
Normal file
7
ui/consts_windows.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// +build windows
|
||||
|
||||
package ui
|
||||
|
||||
const (
|
||||
pagerStashIcon = "•"
|
||||
)
|
||||
90
ui/doctypes.go
Normal file
90
ui/doctypes.go
Normal file
|
|
@ -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
|
||||
}
|
||||
48
ui/doctypes_test.go
Normal file
48
ui/doctypes_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
10
ui/editor.go
10
ui/editor.go
|
|
@ -2,18 +2,14 @@ package ui
|
|||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/x/editor"
|
||||
"github.com/charmbracelet/glow/editor"
|
||||
)
|
||||
|
||||
type editorFinishedMsg struct{ err error }
|
||||
|
||||
func openEditor(path string, lineno int) tea.Cmd {
|
||||
func openEditor(path string) 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)
|
||||
return tea.ExecProcess(editor.Cmd(path), cb)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
".*",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
|
||||
package ui
|
||||
|
||||
func ignorePatterns(m commonModel) []string {
|
||||
func ignorePatterns(m model) []string {
|
||||
return []string{
|
||||
m.cfg.Gopath,
|
||||
m.common.cfg.Gopath,
|
||||
"node_modules",
|
||||
".*",
|
||||
}
|
||||
|
|
|
|||
130
ui/markdown.go
130
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,10 +150,19 @@ 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.
|
||||
|
|
|
|||
493
ui/pager.go
493
ui/pager.go
|
|
@ -2,29 +2,25 @@ package ui
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/charm"
|
||||
"github.com/charmbracelet/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"
|
||||
)
|
||||
|
||||
const (
|
||||
statusBarHeight = 1
|
||||
lineNumberWidth = 4
|
||||
)
|
||||
const statusBarHeight = 1
|
||||
|
||||
var (
|
||||
pagerHelpHeight int
|
||||
|
|
@ -32,7 +28,11 @@ var (
|
|||
mintGreen = lipgloss.AdaptiveColor{Light: "#89F0CB", Dark: "#89F0CB"}
|
||||
darkGreen = lipgloss.AdaptiveColor{Light: "#1C8760", Dark: "#1C8760"}
|
||||
|
||||
lineNumberFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
|
||||
noteHeading = lipgloss.NewStyle().
|
||||
Foreground(cream).
|
||||
Background(green).
|
||||
Padding(0, 1).
|
||||
Render("Set Memo")
|
||||
|
||||
statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
|
||||
statusBarBg = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"}
|
||||
|
|
@ -52,11 +52,21 @@ var (
|
|||
Background(lipgloss.AdaptiveColor{Light: "#DCDCDC", Dark: "#323232"}).
|
||||
Render
|
||||
|
||||
statusBarStashDotStyle = lipgloss.NewStyle().
|
||||
Foreground(green).
|
||||
Background(statusBarBg).
|
||||
Render
|
||||
|
||||
statusBarMessageStyle = lipgloss.NewStyle().
|
||||
Foreground(mintGreen).
|
||||
Background(darkGreen).
|
||||
Render
|
||||
|
||||
statusBarMessageStashIconStyle = lipgloss.NewStyle().
|
||||
Foreground(mintGreen).
|
||||
Background(darkGreen).
|
||||
Render
|
||||
|
||||
statusBarMessageScrollPosStyle = lipgloss.NewStyle().
|
||||
Foreground(mintGreen).
|
||||
Background(darkGreen).
|
||||
|
|
@ -72,28 +82,47 @@ var (
|
|||
Background(lipgloss.AdaptiveColor{Light: "#f2f2f2", Dark: "#1B1B1B"}).
|
||||
Render
|
||||
|
||||
lineNumberStyle = lipgloss.NewStyle().
|
||||
Foreground(lineNumberFg).
|
||||
Render
|
||||
spinnerStyle = lipgloss.NewStyle().
|
||||
Foreground(statusBarNoteFg).
|
||||
Background(statusBarBg)
|
||||
|
||||
pagerNoteInputPromptStyle = lipgloss.NewStyle().
|
||||
Foreground(darkGray).
|
||||
Background(yellowGreen).
|
||||
Padding(0, 1)
|
||||
|
||||
pagerNoteInputStyle = lipgloss.NewStyle().
|
||||
Foreground(darkGray).
|
||||
Background(yellowGreen)
|
||||
|
||||
pagerNoteInputCursorStyle = lipgloss.NewStyle().
|
||||
Foreground(fuschia)
|
||||
)
|
||||
|
||||
type (
|
||||
contentRenderedMsg string
|
||||
reloadMsg struct{}
|
||||
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
|
||||
spinnerStart time.Time
|
||||
|
||||
statusMessage string
|
||||
statusMessageTimer *time.Timer
|
||||
|
|
@ -102,7 +131,9 @@ 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 {
|
||||
|
|
@ -111,18 +142,34 @@ func newPagerModel(common *commonModel) pagerModel {
|
|||
vp.YPosition = 0
|
||||
vp.HighPerformanceRendering = config.HighPerformancePager
|
||||
|
||||
m := pagerModel{
|
||||
common: common,
|
||||
state: pagerStateBrowse,
|
||||
viewport: vp,
|
||||
// Text input for notes/memos
|
||||
ti := textinput.New()
|
||||
ti.Prompt = " > "
|
||||
ti.PromptStyle = pagerNoteInputPromptStyle
|
||||
ti.TextStyle = pagerNoteInputStyle
|
||||
ti.CursorStyle = pagerNoteInputCursorStyle
|
||||
ti.CharLimit = noteCharacterLimit
|
||||
ti.Focus()
|
||||
|
||||
// Text input for search
|
||||
sp := spinner.New()
|
||||
sp.Style = spinnerStyle
|
||||
|
||||
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 +191,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 +207,6 @@ func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd {
|
|||
}
|
||||
|
||||
func (m *pagerModel) unload() {
|
||||
log.Debug("unload")
|
||||
if m.showHelp {
|
||||
m.toggleHelp()
|
||||
}
|
||||
|
|
@ -175,7 +216,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,77 +227,134 @@ 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 keyEsc:
|
||||
m.state = pagerStateBrowse
|
||||
return m, nil
|
||||
case keyEnter:
|
||||
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", keyEsc:
|
||||
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 "e":
|
||||
if m.currentDocument.docType == LocalDoc {
|
||||
return m, openEditor(m.currentDocument.localPath)
|
||||
}
|
||||
|
||||
case "c":
|
||||
err := clipboard.WriteAll(m.currentDocument.Body)
|
||||
if err != nil {
|
||||
cmds = append(cmds, m.showStatusMessage("Unable to copy contents"))
|
||||
} else {
|
||||
cmds = append(cmds, m.showStatusMessage("Copied contents"))
|
||||
}
|
||||
|
||||
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.spinnerStart = time.Now()
|
||||
cmds = append(
|
||||
cmds,
|
||||
stashDocument(m.common.cc, md),
|
||||
m.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 spinner.TickMsg:
|
||||
spinnerMinTimeout := m.spinnerStart.
|
||||
Add(spinnerVisibilityTimeout).
|
||||
Add(spinnerMinLifetime)
|
||||
|
||||
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))
|
||||
}
|
||||
if m.state == pagerStateStashing || time.Now().Before(spinnerMinTimeout) {
|
||||
// We're either still stashing or we haven't reached the spinner's
|
||||
// full lifetime. In either case we need to spin the spinner
|
||||
// irrespective of it's more fine-grained visibility rules.
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
} else if m.state == pagerStateStashSuccess {
|
||||
// Successful stash. Stop spinning and update accordingly.
|
||||
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
|
||||
|
|
@ -264,27 +362,69 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
|
|||
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.spinnerVisible() {
|
||||
// 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...)
|
||||
}
|
||||
|
||||
// spinnerVisible returns whether or not the spinner should be drawn.
|
||||
func (m pagerModel) spinnerVisible() bool {
|
||||
windowStart := m.spinnerStart.Add(spinnerVisibilityTimeout)
|
||||
windowEnd := windowStart.Add(spinnerMinLifetime)
|
||||
now := time.Now()
|
||||
return now.After(windowStart) && now.Before(windowEnd)
|
||||
}
|
||||
|
||||
func (m pagerModel) View() string {
|
||||
var b strings.Builder
|
||||
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())
|
||||
|
|
@ -300,10 +440,11 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
|
|||
percentToStringMagnitude float64 = 100.0
|
||||
)
|
||||
|
||||
isStashed := m.currentDocument.docType == StashedDoc || m.currentDocument.docType == ConvertedDoc
|
||||
showStatusMessage := m.state == pagerStateStatusMessage
|
||||
|
||||
// Logo
|
||||
logo := glowLogoView()
|
||||
logo := glowLogoView(" Glow ")
|
||||
|
||||
// Scroll percent
|
||||
percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent()))
|
||||
|
|
@ -322,16 +463,34 @@ 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 {
|
||||
var spinner string
|
||||
if m.spinnerVisible() {
|
||||
spinner = m.spinner.View()
|
||||
}
|
||||
statusIndicator = statusBarNoteStyle(" ") + spinner
|
||||
} 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 +504,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 +516,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 +526,36 @@ 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"
|
||||
}
|
||||
|
||||
editOrBlank := "e edit this document"
|
||||
if m.currentDocument.docType != LocalDoc || m.currentDocument.localPath == "" {
|
||||
editOrBlank = ""
|
||||
}
|
||||
|
||||
col1 := []string{
|
||||
"g/home go to top",
|
||||
"G/end go to bottom",
|
||||
"c copy contents",
|
||||
"e edit this document",
|
||||
"r reload this document",
|
||||
editOrBlank,
|
||||
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 +591,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 +602,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]
|
||||
}
|
||||
|
|
|
|||
12
ui/sort.go
12
ui/sort.go
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
776
ui/stash.go
776
ui/stash.go
File diff suppressed because it is too large
Load diff
|
|
@ -15,7 +15,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,7 +99,18 @@ 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
|
||||
isEditable bool
|
||||
navHelp []string
|
||||
filterHelp []string
|
||||
selectionHelp []string
|
||||
|
|
@ -108,6 +119,13 @@ func (m stashModel) helpView() (string, int) {
|
|||
appHelp []string
|
||||
)
|
||||
|
||||
if numDocs > 0 {
|
||||
md := m.selectedMarkdown()
|
||||
isStashed = md != nil && md.docType == StashedDoc
|
||||
isStashable = md != nil && md.docType == LocalDoc && m.online()
|
||||
isEditable = md != nil && md.docType == LocalDoc && md.localPath != ""
|
||||
}
|
||||
|
||||
if numDocs > 0 && m.showFullHelp {
|
||||
navHelp = []string{"enter", "open", "j/k ↑/↓", "choose"}
|
||||
}
|
||||
|
|
@ -125,23 +143,27 @@ 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 isEditable {
|
||||
editHelp = []string{"e", "edit"}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -159,21 +181,19 @@ func (m stashModel) helpView() (string, int) {
|
|||
return m.renderHelp(navHelp, filterHelp, selectionHelp, editHelp, sectionHelp, appHelp)
|
||||
}
|
||||
|
||||
const minHelpViewHeight = 5
|
||||
|
||||
// renderHelp returns the rendered help view and associated line height for
|
||||
// the given groups of help items.
|
||||
func (m stashModel) renderHelp(groups ...[]string) (string, int) {
|
||||
if m.showFullHelp {
|
||||
str := m.fullHelpView(groups...)
|
||||
numLines := strings.Count(str, "\n") + 1
|
||||
return str, max(numLines, minHelpViewHeight)
|
||||
return 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 {
|
||||
|
|
@ -199,13 +219,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
|
||||
|
|
|
|||
130
ui/stashitem.go
130
ui/stashitem.go
|
|
@ -2,31 +2,47 @@ package ui
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/muesli/reflow/ansi"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"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,67 +50,89 @@ 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 == stashingStatusMessage {
|
||||
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 := lipgloss.NewStyle().Foreground(fuschia)
|
||||
title = styleFilteredText(title, m.filterInput.Value(), s, s.Copy().Underline(true))
|
||||
} 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 == stashingStatusMessage {
|
||||
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 := lipgloss.NewStyle().Foreground(indigo)
|
||||
title = styleFilteredText(title, m.filterInput.Value(), s, s.Copy().Underline(true))
|
||||
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 := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"})
|
||||
title = styleFilteredText(title, m.filterInput.Value(), s, s.Copy().Underline(true))
|
||||
}
|
||||
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 {
|
||||
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})
|
||||
|
|
|
|||
110
ui/styles.go
110
ui/styles.go
|
|
@ -1,46 +1,84 @@
|
|||
package ui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
import . "github.com/charmbracelet/lipgloss" //nolint: revive
|
||||
|
||||
// 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"}
|
||||
normal = AdaptiveColor{Light: "#1A1A1A", Dark: "#dddddd"}
|
||||
normalDim = AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}
|
||||
gray = AdaptiveColor{Light: "#909090", Dark: "#626262"}
|
||||
midGray = AdaptiveColor{Light: "#B2B2B2", Dark: "#4A4A4A"}
|
||||
darkGray = AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}
|
||||
brightGray = AdaptiveColor{Light: "#847A85", Dark: "#979797"}
|
||||
dimBrightGray = AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"}
|
||||
indigo = AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"}
|
||||
dimIndigo = AdaptiveColor{Light: "#9498FF", Dark: "#494690"}
|
||||
subtleIndigo = AdaptiveColor{Light: "#7D79F6", Dark: "#514DC1"}
|
||||
dimSubtleIndigo = AdaptiveColor{Light: "#BBBDFF", Dark: "#383584"}
|
||||
cream = AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"}
|
||||
yellowGreen = AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}
|
||||
dullYellowGreen = AdaptiveColor{Light: "#6BCB94", Dark: "#9BA92F"}
|
||||
fuschia = AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}
|
||||
dimFuchsia = AdaptiveColor{Light: "#F1A8FF", Dark: "#99519E"}
|
||||
dullFuchsia = AdaptiveColor{Dark: "#AD58B4", Light: "#F793FF"}
|
||||
dimDullFuchsia = AdaptiveColor{Light: "#F6C9FF", Dark: "#6B3A6F"}
|
||||
green = Color("#04B575")
|
||||
red = AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"}
|
||||
faintRed = AdaptiveColor{Light: "#FF6F91", Dark: "#C74665"}
|
||||
|
||||
semiDimGreen = AdaptiveColor{Light: "#35D79C", Dark: "#036B46"}
|
||||
dimGreen = AdaptiveColor{Light: "#72D2B0", Dark: "#0B5137"}
|
||||
)
|
||||
|
||||
// Ulimately, we'll transition to named styles.
|
||||
// nolint:deadcode,unused,varcheck
|
||||
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
|
||||
normalFg = NewStyle().Foreground(normal).Render
|
||||
dimNormalFg = NewStyle().Foreground(normalDim).Render
|
||||
|
||||
brightGrayFg = NewStyle().Foreground(brightGray).Render
|
||||
dimBrightGrayFg = NewStyle().Foreground(dimBrightGray).Render
|
||||
|
||||
grayFg = NewStyle().Foreground(gray).Render
|
||||
midGrayFg = NewStyle().Foreground(midGray).Render
|
||||
darkGrayFg = NewStyle().Foreground(darkGray).Render
|
||||
|
||||
greenFg = NewStyle().Foreground(green).Render
|
||||
semiDimGreenFg = NewStyle().Foreground(semiDimGreen).Render
|
||||
dimGreenFg = NewStyle().Foreground(dimGreen).Render
|
||||
|
||||
fuchsiaFg = NewStyle().Foreground(fuschia).Render
|
||||
dimFuchsiaFg = NewStyle().Foreground(dimFuchsia).Render
|
||||
|
||||
dullFuchsiaFg = NewStyle().Foreground(dullFuchsia).Render
|
||||
dimDullFuchsiaFg = NewStyle().Foreground(dimDullFuchsia).Render
|
||||
|
||||
indigoFg = NewStyle().Foreground(fuschia).Render
|
||||
dimIndigoFg = NewStyle().Foreground(dimIndigo).Render
|
||||
|
||||
subtleIndigoFg = NewStyle().Foreground(subtleIndigo).Render
|
||||
dimSubtleIndigoFg = NewStyle().Foreground(dimSubtleIndigo).Render
|
||||
|
||||
yellowFg = NewStyle().Foreground(yellowGreen).Render // renders light green on light backgrounds
|
||||
dullYellowFg = NewStyle().Foreground(dullYellowGreen).Render // renders light green on light backgrounds
|
||||
redFg = NewStyle().Foreground(red).Render
|
||||
faintRedFg = NewStyle().Foreground(faintRed).Render
|
||||
)
|
||||
|
||||
var (
|
||||
tabStyle = NewStyle().
|
||||
Foreground(AdaptiveColor{Light: "#909090", Dark: "#626262"})
|
||||
|
||||
selectedTabStyle = NewStyle().
|
||||
Foreground(AdaptiveColor{Light: "#333333", Dark: "#979797"})
|
||||
|
||||
errorTitleStyle = NewStyle().
|
||||
Foreground(cream).
|
||||
Background(red).
|
||||
Padding(0, 1)
|
||||
|
||||
subtleStyle = NewStyle().
|
||||
Foreground(AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"})
|
||||
|
||||
paginationStyle = subtleStyle.Copy()
|
||||
)
|
||||
|
|
|
|||
557
ui/ui.go
557
ui/ui.go
|
|
@ -1,24 +1,33 @@
|
|||
// Package ui provides the main UI for the glow application.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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/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 = "…"
|
||||
|
||||
// Only show the spinner if it spins for at least this amount of time.
|
||||
spinnerVisibilityTimeout = time.Millisecond * 140
|
||||
|
||||
// Minimum amount of time the spinner should be visible once it starts.
|
||||
spinnerMinLifetime = time.Millisecond * 550
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -27,25 +36,29 @@ var (
|
|||
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,
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
config = cfg
|
||||
opts := []tea.ProgramOption{tea.WithAltScreen()}
|
||||
if cfg.EnableMouse {
|
||||
opts = append(opts, tea.WithMouseCellMotion())
|
||||
}
|
||||
m := newModel(cfg, content)
|
||||
return tea.NewProgram(m, opts...)
|
||||
return tea.NewProgram(newModel(cfg), opts...)
|
||||
}
|
||||
|
||||
type errMsg struct{ err error }
|
||||
|
|
@ -53,6 +66,10 @@ type errMsg struct{ err error }
|
|||
func (e errMsg) Error() string { return e.err.Error() }
|
||||
|
||||
type (
|
||||
newCharmClientMsg *charm.Client
|
||||
sshAuthErrMsg struct{}
|
||||
keygenFailedMsg struct{ err error }
|
||||
keygenSuccessMsg struct{}
|
||||
initLocalFileSearchMsg struct {
|
||||
cwd string
|
||||
ch chan gitcha.SearchResult
|
||||
|
|
@ -62,10 +79,19 @@ type (
|
|||
type (
|
||||
foundLocalFileMsg gitcha.SearchResult
|
||||
localFileSearchFinished struct{}
|
||||
gotStashMsg []*charm.Markdown
|
||||
stashLoadErrMsg struct{ err error }
|
||||
gotNewsMsg []*charm.Markdown
|
||||
statusMessageTimeoutMsg applicationContext
|
||||
newsLoadErrMsg struct{ err error }
|
||||
stashSuccessMsg markdown
|
||||
stashFailMsg struct {
|
||||
err error
|
||||
markdown markdown
|
||||
}
|
||||
)
|
||||
|
||||
// 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 +115,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,7 +189,7 @@ 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() {
|
||||
|
|
@ -130,73 +198,48 @@ func (m *model) unloadDocument() []tea.Cmd {
|
|||
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,
|
||||
m.stash.spinner.Tick,
|
||||
)
|
||||
}
|
||||
|
||||
if d.Contains(LocalDoc) {
|
||||
cmds = append(cmds, findLocalFiles(m))
|
||||
}
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
|
|
@ -216,45 +259,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 +313,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 +391,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 +449,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:
|
||||
|
|
@ -354,11 +474,10 @@ func errorView(err error, fatal bool) string {
|
|||
|
||||
// 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 +493,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 +529,186 @@ 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 := os.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 +722,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 +751,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
|
||||
}
|
||||
|
|
|
|||
86
url.go
86
url.go
|
|
@ -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, "://")
|
||||
}
|
||||
30
url_test.go
30
url_test.go
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,9 @@
|
|||
// 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"
|
||||
)
|
||||
|
||||
|
|
@ -31,7 +24,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 +32,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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue