mirror of
https://github.com/charmbracelet/glow.git
synced 2026-08-09 09:49:09 +02:00
feat!: cleanup and updated (#619)
* feat!: cleanup Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * more cleanup Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: more cleanup Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: more cleanup --------- Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
This commit is contained in:
parent
d2e7742a6a
commit
fce3edf7db
27 changed files with 692 additions and 2962 deletions
|
|
@ -24,7 +24,7 @@ linters:
|
||||||
- goimports
|
- goimports
|
||||||
- goprintffuncname
|
- goprintffuncname
|
||||||
- gosec
|
- gosec
|
||||||
- ifshort
|
# - ifshort
|
||||||
- misspell
|
- misspell
|
||||||
- prealloc
|
- prealloc
|
||||||
- revive
|
- revive
|
||||||
|
|
|
||||||
7
Makefile
7
Makefile
|
|
@ -2,8 +2,6 @@
|
||||||
|
|
||||||
.PHONY: default clean glow run log
|
.PHONY: default clean glow run log
|
||||||
|
|
||||||
LOGFILE := debug.log
|
|
||||||
|
|
||||||
default: glow
|
default: glow
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
|
|
@ -13,8 +11,7 @@ glow:
|
||||||
go build
|
go build
|
||||||
|
|
||||||
run: clean glow
|
run: clean glow
|
||||||
GLOW_LOGFILE=$(LOGFILE) ./glow
|
./glow
|
||||||
|
|
||||||
log:
|
log:
|
||||||
> $(LOGFILE)
|
tail -f ~/.cache/glow/glow.log
|
||||||
tail -f $(LOGFILE)
|
|
||||||
|
|
|
||||||
52
README.md
52
README.md
|
|
@ -20,13 +20,9 @@ Glow is a terminal based markdown reader designed from the ground up to bring
|
||||||
out the beauty—and power—of the CLI.
|
out the beauty—and power—of the CLI.
|
||||||
|
|
||||||
Use it to discover markdown files, read documentation directly on the command
|
Use it to discover markdown files, read documentation directly on the command
|
||||||
line and stash markdown files to your own private collection, so you can read
|
line. Glow will find local markdown files in subdirectories or a local
|
||||||
them anywhere. Glow will find local markdown files in subdirectories or a local
|
|
||||||
Git repository.
|
Git repository.
|
||||||
|
|
||||||
By the way, all data stashed is encrypted end-to-end: only you can decrypt it.
|
|
||||||
More on that below.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Package Manager
|
### Package Manager
|
||||||
|
|
@ -84,6 +80,7 @@ packages. ARM builds are also available for macOS, Linux, FreeBSD and OpenBSD.
|
||||||
### Go
|
### Go
|
||||||
|
|
||||||
Or just install it with `go`:
|
Or just install it with `go`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go install github.com/charmbracelet/glow@latest
|
go install github.com/charmbracelet/glow@latest
|
||||||
```
|
```
|
||||||
|
|
@ -98,11 +95,10 @@ go build
|
||||||
|
|
||||||
[releases]: https://github.com/charmbracelet/glow/releases
|
[releases]: https://github.com/charmbracelet/glow/releases
|
||||||
|
|
||||||
|
|
||||||
## The TUI
|
## The TUI
|
||||||
|
|
||||||
Simply run `glow` without arguments to start the textual user interface and
|
Simply run `glow` without arguments to start the textual user interface and
|
||||||
browse local and stashed markdown. Glow will find local markdown files in the
|
browse local. Glow will find local markdown files in the
|
||||||
current directory and below or, if you’re in a Git repository, Glow will search
|
current directory and below or, if you’re in a Git repository, Glow will search
|
||||||
the repo.
|
the repo.
|
||||||
|
|
||||||
|
|
@ -110,15 +106,6 @@ Markdown files can be read with Glow's high-performance pager. Most of the
|
||||||
keystrokes you know from `less` are the same, but you can press `?` to list
|
keystrokes you know from `less` are the same, but you can press `?` to list
|
||||||
the hotkeys.
|
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
|
## The CLI
|
||||||
|
|
||||||
In addition to a TUI, Glow has a CLI for working with Markdown. To format a
|
In addition to a TUI, Glow has a CLI for working with Markdown. To format a
|
||||||
|
|
@ -138,18 +125,6 @@ glow github.com/charmbracelet/glow
|
||||||
glow https://host.tld/file.md
|
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
|
### Word Wrapping
|
||||||
|
|
||||||
The `-w` flag lets you set a maximum width at which the output will be wrapped:
|
The `-w` flag lets you set a maximum width at which the output will be wrapped:
|
||||||
|
|
@ -211,32 +186,19 @@ pager: true
|
||||||
width: 80
|
width: 80
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔒 Encryption: How It Works
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
[charmlib]: https://github.com/charmbracelet/charm
|
|
||||||
|
|
||||||
## Feedback
|
## Feedback
|
||||||
|
|
||||||
We’d love to hear your thoughts on this project. Feel free to drop us a note!
|
We’d love to hear your thoughts on this project. Feel free to drop us a note!
|
||||||
|
|
||||||
* [Twitter](https://twitter.com/charmcli)
|
- [Twitter](https://twitter.com/charmcli)
|
||||||
* [The Fediverse](https://mastodon.social/@charmcli)
|
- [The Fediverse](https://mastodon.social/@charmcli)
|
||||||
* [Discord](https://charm.sh/chat)
|
- [Discord](https://charm.sh/chat)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](https://github.com/charmbracelet/glow/raw/master/LICENSE)
|
[MIT](https://github.com/charmbracelet/glow/raw/master/LICENSE)
|
||||||
|
|
||||||
***
|
---
|
||||||
|
|
||||||
Part of [Charm](https://charm.sh).
|
Part of [Charm](https://charm.sh).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,61 +13,35 @@ import (
|
||||||
|
|
||||||
const defaultConfig = `# style name or JSON path (default "auto")
|
const defaultConfig = `# style name or JSON path (default "auto")
|
||||||
style: "auto"
|
style: "auto"
|
||||||
# show local files only; no network (TUI-mode only)
|
|
||||||
local: false
|
|
||||||
# mouse support (TUI-mode only)
|
# mouse support (TUI-mode only)
|
||||||
mouse: false
|
mouse: false
|
||||||
# use pager to display markdown
|
# use pager to display markdown
|
||||||
pager: false
|
pager: false
|
||||||
# word-wrap at width
|
# word-wrap at width
|
||||||
width: 80`
|
width: 80
|
||||||
|
`
|
||||||
|
|
||||||
|
func defaultConfigFile() string {
|
||||||
|
scope := gap.NewScope(gap.User, "glow")
|
||||||
|
path, _ := scope.ConfigPath("glow.yml")
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
var configCmd = &cobra.Command{
|
var configCmd = &cobra.Command{
|
||||||
Use: "config",
|
Use: "config",
|
||||||
Hidden: false,
|
Hidden: false,
|
||||||
Short: "Edit the glow config file",
|
Short: "Edit the glow config file",
|
||||||
Long: paragraph(fmt.Sprintf("\n%s the glow config file. We’ll use EDITOR to determine which editor to use. If the config file doesn't exist, it will be created.", keyword("Edit"))),
|
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"),
|
Example: paragraph("glow config\nglow config --config path/to/config.yml"),
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
SilenceUsage: true,
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
if err := ensureConfigFile(); err != nil {
|
||||||
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
c, err := editor.Cmd("Glow", configFile)
|
c, err := editor.Cmd("Glow", configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not edit %s: %w", configFile, err)
|
return err
|
||||||
}
|
}
|
||||||
c.Stdin = os.Stdin
|
c.Stdin = os.Stdin
|
||||||
c.Stdout = os.Stdout
|
c.Stdout = os.Stdout
|
||||||
|
|
@ -80,3 +54,37 @@ var configCmd = &cobra.Command{
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureConfigFile() error {
|
||||||
|
if configFile == "" {
|
||||||
|
configFile = defaultConfigFile()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(configFile), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("Could not write config file: %w", 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
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
77
go.mod
77
go.mod
|
|
@ -1,72 +1,73 @@
|
||||||
module github.com/charmbracelet/glow
|
module github.com/charmbracelet/glow
|
||||||
|
|
||||||
go 1.17
|
go 1.21.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/adrg/xdg v0.4.0
|
||||||
github.com/atotto/clipboard v0.1.4
|
github.com/atotto/clipboard v0.1.4
|
||||||
github.com/charmbracelet/bubbles v0.15.0
|
github.com/caarlos0/env/v11 v11.0.1
|
||||||
github.com/charmbracelet/bubbletea v0.25.0
|
github.com/charmbracelet/bubbles v0.18.0
|
||||||
github.com/charmbracelet/charm v0.8.7
|
github.com/charmbracelet/bubbletea v0.26.4
|
||||||
github.com/charmbracelet/glamour v0.6.0
|
github.com/charmbracelet/glamour v0.6.1-0.20230519131405-7528eaad6620
|
||||||
github.com/charmbracelet/lipgloss v0.6.0
|
github.com/charmbracelet/lipgloss v0.11.1-0.20240608174255-33b3263db7dd
|
||||||
github.com/charmbracelet/x/editor v0.0.0-20231116172829-450eedbca1ab
|
github.com/charmbracelet/log v0.4.0
|
||||||
|
github.com/charmbracelet/x/editor v0.0.0-20240625164403-2627ec16405d
|
||||||
github.com/dustin/go-humanize v1.0.1
|
github.com/dustin/go-humanize v1.0.1
|
||||||
github.com/mattn/go-runewidth v0.0.15
|
github.com/mattn/go-runewidth v0.0.15
|
||||||
github.com/meowgorithm/babyenv v1.3.1
|
|
||||||
github.com/mitchellh/go-homedir v1.1.0
|
github.com/mitchellh/go-homedir v1.1.0
|
||||||
github.com/muesli/gitcha v0.2.0
|
github.com/muesli/gitcha v0.2.0
|
||||||
github.com/muesli/go-app-paths v0.2.2
|
github.com/muesli/go-app-paths v0.2.2
|
||||||
github.com/muesli/reflow v0.3.0
|
github.com/muesli/reflow v0.3.0
|
||||||
github.com/muesli/termenv v0.15.2
|
github.com/muesli/termenv v0.15.2
|
||||||
github.com/sahilm/fuzzy v0.1.0
|
github.com/sahilm/fuzzy v0.1.1
|
||||||
github.com/segmentio/ksuid v1.0.4
|
github.com/spf13/cobra v1.7.0
|
||||||
github.com/spf13/cobra v1.8.0
|
github.com/spf13/viper v1.15.0
|
||||||
github.com/spf13/viper v1.14.0
|
golang.org/x/sys v0.21.0
|
||||||
golang.org/x/sys v0.17.0
|
golang.org/x/term v0.21.0
|
||||||
golang.org/x/term v0.15.0
|
golang.org/x/text v0.16.0
|
||||||
golang.org/x/text v0.14.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alecthomas/chroma v0.10.0 // indirect
|
github.com/alecthomas/chroma/v2 v2.8.0 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/calmh/randomart v1.1.0 // indirect
|
github.com/charmbracelet/x/ansi v0.1.2 // indirect
|
||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
|
github.com/charmbracelet/x/input v0.1.2 // indirect
|
||||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
|
github.com/charmbracelet/x/term v0.1.1 // indirect
|
||||||
|
github.com/charmbracelet/x/windows v0.1.2 // indirect
|
||||||
github.com/dlclark/regexp2 v1.4.0 // indirect
|
github.com/dlclark/regexp2 v1.4.0 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||||
github.com/gorilla/css v1.0.0 // indirect
|
github.com/gorilla/css v1.0.0 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/magiconair/properties v1.8.6 // indirect
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.18 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/microcosm-cc/bluemonday v1.0.21 // indirect
|
github.com/microcosm-cc/bluemonday v1.0.25 // indirect
|
||||||
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/sasquatch v0.0.0-20200811221207-66979d92330a // indirect
|
|
||||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.0.5 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/rivo/uniseg v0.2.0 // indirect
|
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||||
github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 // indirect
|
github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 // indirect
|
||||||
github.com/spf13/afero v1.9.2 // indirect
|
github.com/spf13/afero v1.9.3 // indirect
|
||||||
github.com/spf13/cast v1.5.0 // indirect
|
github.com/spf13/cast v1.5.0 // indirect
|
||||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
github.com/subosito/gotenv v1.4.1 // indirect
|
github.com/subosito/gotenv v1.4.2 // indirect
|
||||||
github.com/yuin/goldmark v1.5.2 // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.1 // indirect
|
github.com/yuin/goldmark v1.5.4 // indirect
|
||||||
golang.org/x/crypto v0.17.0 // indirect
|
github.com/yuin/goldmark-emoji v1.0.2 // indirect
|
||||||
golang.org/x/net v0.17.0 // indirect
|
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect
|
||||||
golang.org/x/sync v0.1.0 // indirect
|
golang.org/x/net v0.26.0 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
golang.org/x/sync v0.7.0 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
27
log.go
Normal file
27
log.go
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/adrg/xdg"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getLogFilePath() (string, error) {
|
||||||
|
return xdg.CacheFile("glow/glow.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupLog() (func() error, error) {
|
||||||
|
// Log to file, if set
|
||||||
|
logFile, err := getLogFilePath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(logFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.SetOutput(f)
|
||||||
|
log.SetLevel(log.DebugLevel)
|
||||||
|
return f.Close, nil
|
||||||
|
}
|
||||||
160
main.go
160
main.go
|
|
@ -11,11 +11,11 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
"github.com/caarlos0/env/v11"
|
||||||
"github.com/charmbracelet/glamour"
|
"github.com/charmbracelet/glamour"
|
||||||
"github.com/charmbracelet/glow/ui"
|
"github.com/charmbracelet/glow/ui"
|
||||||
"github.com/charmbracelet/glow/utils"
|
"github.com/charmbracelet/glow/utils"
|
||||||
"github.com/meowgorithm/babyenv"
|
"github.com/charmbracelet/log"
|
||||||
gap "github.com/muesli/go-app-paths"
|
gap "github.com/muesli/go-app-paths"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
@ -34,17 +34,21 @@ var (
|
||||||
style string
|
style string
|
||||||
width uint
|
width uint
|
||||||
showAllFiles bool
|
showAllFiles bool
|
||||||
localOnly bool
|
|
||||||
mouse bool
|
mouse bool
|
||||||
|
|
||||||
rootCmd = &cobra.Command{
|
rootCmd = &cobra.Command{
|
||||||
Use: "glow [SOURCE|DIR]",
|
Use: "glow [SOURCE|DIR]",
|
||||||
Short: "Render markdown on the CLI, with pizzazz!",
|
Short: "Render markdown on the CLI, with pizzazz!",
|
||||||
Long: paragraph(fmt.Sprintf("\nRender markdown on the CLI, %s!", keyword("with pizzazz"))),
|
Long: paragraph(
|
||||||
|
fmt.Sprintf("\nRender markdown on the CLI, %s!", keyword("with pizzazz")),
|
||||||
|
),
|
||||||
SilenceErrors: false,
|
SilenceErrors: false,
|
||||||
SilenceUsage: false,
|
SilenceUsage: true,
|
||||||
TraverseChildren: true,
|
TraverseChildren: true,
|
||||||
RunE: execute,
|
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return validateOptions(cmd)
|
||||||
|
},
|
||||||
|
RunE: execute,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -103,7 +107,10 @@ func sourceFromArg(arg string) (*source, error) {
|
||||||
st, err := os.Stat(arg)
|
st, err := os.Stat(arg)
|
||||||
if err == nil && st.IsDir() {
|
if err == nil && st.IsDir() {
|
||||||
var src *source
|
var src *source
|
||||||
_ = filepath.Walk(arg, func(path string, info os.FileInfo, err error) error {
|
_ = filepath.Walk(arg, func(path string, _ os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
for _, v := range readmeNames {
|
for _, v := range readmeNames {
|
||||||
if strings.EqualFold(filepath.Base(path), v) {
|
if strings.EqualFold(filepath.Base(path), v) {
|
||||||
r, err := os.Open(path)
|
r, err := os.Open(path)
|
||||||
|
|
@ -137,13 +144,12 @@ func sourceFromArg(arg string) (*source, error) {
|
||||||
func validateOptions(cmd *cobra.Command) error {
|
func validateOptions(cmd *cobra.Command) error {
|
||||||
// grab config values from Viper
|
// grab config values from Viper
|
||||||
width = viper.GetUint("width")
|
width = viper.GetUint("width")
|
||||||
localOnly = viper.GetBool("local")
|
|
||||||
mouse = viper.GetBool("mouse")
|
mouse = viper.GetBool("mouse")
|
||||||
pager = viper.GetBool("pager")
|
pager = viper.GetBool("pager")
|
||||||
|
|
||||||
// validate the glamour style
|
// validate the glamour style
|
||||||
style = viper.GetString("style")
|
style = viper.GetString("style")
|
||||||
if style != "auto" && glamour.DefaultStyles[style] == nil {
|
if style != glamour.AutoStyle && glamour.DefaultStyles[style] == nil {
|
||||||
style = utils.ExpandPath(style)
|
style = utils.ExpandPath(style)
|
||||||
if _, err := os.Stat(style); os.IsNotExist(err) {
|
if _, err := os.Stat(style); os.IsNotExist(err) {
|
||||||
return fmt.Errorf("Specified style does not exist: %s", style)
|
return fmt.Errorf("Specified style does not exist: %s", style)
|
||||||
|
|
@ -188,11 +194,6 @@ func stdinIsPipe() (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func execute(cmd *cobra.Command, args []string) 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
|
// if stdin is a pipe then use stdin for input. note that you can also
|
||||||
// explicitly use a - to read from stdin.
|
// explicitly use a - to read from stdin.
|
||||||
if yes, err := stdinIsPipe(); err != nil {
|
if yes, err := stdinIsPipe(); err != nil {
|
||||||
|
|
@ -206,7 +207,7 @@ func execute(cmd *cobra.Command, args []string) error {
|
||||||
switch len(args) {
|
switch len(args) {
|
||||||
// TUI running on cwd
|
// TUI running on cwd
|
||||||
case 0:
|
case 0:
|
||||||
return runTUI("", false)
|
return runTUI("")
|
||||||
|
|
||||||
// TUI with possible dir argument
|
// TUI with possible dir argument
|
||||||
case 1:
|
case 1:
|
||||||
|
|
@ -216,7 +217,7 @@ func execute(cmd *cobra.Command, args []string) error {
|
||||||
if err == nil && info.IsDir() {
|
if err == nil && info.IsDir() {
|
||||||
p, err := filepath.Abs(args[0])
|
p, err := filepath.Abs(args[0])
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return runTUI(p, false)
|
return runTUI(p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fallthrough
|
fallthrough
|
||||||
|
|
@ -259,16 +260,11 @@ func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error {
|
||||||
baseURL = u.String() + "/"
|
baseURL = u.String() + "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialize glamour
|
isCode := !utils.IsMarkdownFile(src.URL)
|
||||||
var gs glamour.TermRendererOption
|
|
||||||
if style == "auto" {
|
|
||||||
gs = glamour.WithEnvironmentConfig()
|
|
||||||
} else {
|
|
||||||
gs = glamour.WithStylePath(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// initialize glamour
|
||||||
r, err := glamour.NewTermRenderer(
|
r, err := glamour.NewTermRenderer(
|
||||||
gs,
|
utils.GlamourStyle(style, isCode),
|
||||||
glamour.WithWordWrap(int(width)),
|
glamour.WithWordWrap(int(width)),
|
||||||
glamour.WithBaseURL(baseURL),
|
glamour.WithBaseURL(baseURL),
|
||||||
glamour.WithPreservedNewLines(),
|
glamour.WithPreservedNewLines(),
|
||||||
|
|
@ -277,23 +273,28 @@ func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := r.RenderBytes(b)
|
s := string(b)
|
||||||
|
ext := filepath.Ext(src.URL)
|
||||||
|
if isCode {
|
||||||
|
s = utils.WrapCodeBlock(string(b), ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := r.Render(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// trim lines
|
// trim lines
|
||||||
lines := strings.Split(string(out), "\n")
|
lines := strings.Split(out, "\n")
|
||||||
var cb strings.Builder
|
var content strings.Builder
|
||||||
for i, s := range lines {
|
for i, s := range lines {
|
||||||
cb.WriteString(strings.TrimSpace(s))
|
content.WriteString(strings.TrimSpace(s))
|
||||||
|
|
||||||
// don't add an artificial newline after the last split
|
// don't add an artificial newline after the last split
|
||||||
if i+1 < len(lines) {
|
if i+1 < len(lines) {
|
||||||
cb.WriteString("\n")
|
content.WriteByte('\n')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
content := cb.String()
|
|
||||||
|
|
||||||
// display
|
// display
|
||||||
if pager || cmd.Flags().Changed("pager") {
|
if pager || cmd.Flags().Changed("pager") {
|
||||||
|
|
@ -304,44 +305,29 @@ func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error {
|
||||||
|
|
||||||
pa := strings.Split(pagerCmd, " ")
|
pa := strings.Split(pagerCmd, " ")
|
||||||
c := exec.Command(pa[0], pa[1:]...) // nolint:gosec
|
c := exec.Command(pa[0], pa[1:]...) // nolint:gosec
|
||||||
c.Stdin = strings.NewReader(content)
|
c.Stdin = strings.NewReader(content.String())
|
||||||
c.Stdout = os.Stdout
|
c.Stdout = os.Stdout
|
||||||
return c.Run()
|
return c.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprint(w, content)
|
fmt.Fprint(w, content.String()) //nolint: errcheck
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runTUI(workingDirectory string, stashedOnly bool) error {
|
func runTUI(workingDirectory string) error {
|
||||||
// Read environment to get debugging stuff
|
// Read environment to get debugging stuff
|
||||||
var cfg ui.Config
|
cfg, err := env.ParseAs[ui.Config]()
|
||||||
if err := babyenv.Parse(&cfg); err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error parsing config: %v", err)
|
return fmt.Errorf("error parsing config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log to file, if set
|
|
||||||
if cfg.Logfile != "" {
|
|
||||||
f, err := tea.LogToFile(cfg.Logfile, "glow")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close() //nolint:errcheck
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.WorkingDirectory = workingDirectory
|
cfg.WorkingDirectory = workingDirectory
|
||||||
cfg.DocumentTypes = ui.NewDocTypeSet()
|
|
||||||
cfg.ShowAllFiles = showAllFiles
|
cfg.ShowAllFiles = showAllFiles
|
||||||
cfg.GlamourMaxWidth = width
|
cfg.GlamourMaxWidth = width
|
||||||
cfg.GlamourStyle = style
|
cfg.GlamourStyle = style
|
||||||
cfg.EnableMouse = mouse
|
cfg.EnableMouse = mouse
|
||||||
|
|
||||||
if stashedOnly {
|
|
||||||
cfg.DocumentTypes.Add(ui.StashedDoc, ui.NewsDoc)
|
|
||||||
} else if localOnly {
|
|
||||||
cfg.DocumentTypes.Add(ui.LocalDoc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run Bubble Tea program
|
// Run Bubble Tea program
|
||||||
if _, err := ui.NewProgram(cfg).Run(); err != nil {
|
if _, err := ui.NewProgram(cfg).Run(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -351,12 +337,20 @@ func runTUI(workingDirectory string, stashedOnly bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := rootCmd.Execute(); err != nil {
|
closer, err := setupLog()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
_ = closer()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
_ = closer()
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
tryLoadConfigFromDefaultPlaces()
|
||||||
if len(CommitSHA) >= 7 {
|
if len(CommitSHA) >= 7 {
|
||||||
vt := rootCmd.VersionTemplate()
|
vt := rootCmd.VersionTemplate()
|
||||||
rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n")
|
rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n")
|
||||||
|
|
@ -366,62 +360,56 @@ func init() {
|
||||||
}
|
}
|
||||||
rootCmd.Version = Version
|
rootCmd.Version = Version
|
||||||
|
|
||||||
scope := gap.NewScope(gap.User, "glow")
|
|
||||||
defaultConfigFile, _ := scope.ConfigPath("glow.yml")
|
|
||||||
|
|
||||||
// "Glow Classic" cli arguments
|
// "Glow Classic" cli arguments
|
||||||
rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default %s)", defaultConfigFile))
|
rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default %s)", defaultConfigFile()))
|
||||||
rootCmd.Flags().BoolVarP(&pager, "pager", "p", false, "display with pager")
|
rootCmd.Flags().BoolVarP(&pager, "pager", "p", false, "display with pager")
|
||||||
rootCmd.Flags().StringVarP(&style, "style", "s", "auto", "style name or JSON path")
|
rootCmd.Flags().StringVarP(&style, "style", "s", glamour.AutoStyle, "style name or JSON path")
|
||||||
rootCmd.Flags().UintVarP(&width, "width", "w", 0, "word-wrap at width")
|
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(&showAllFiles, "all", "a", false, "show system files and directories (TUI-mode only)")
|
||||||
rootCmd.Flags().BoolVarP(&localOnly, "local", "l", false, "show local files only; no network (TUI-mode only)")
|
|
||||||
rootCmd.Flags().BoolVarP(&mouse, "mouse", "m", false, "enable mouse wheel (TUI-mode only)")
|
rootCmd.Flags().BoolVarP(&mouse, "mouse", "m", false, "enable mouse wheel (TUI-mode only)")
|
||||||
_ = rootCmd.Flags().MarkHidden("mouse")
|
_ = rootCmd.Flags().MarkHidden("mouse")
|
||||||
|
|
||||||
// Config bindings
|
// Config bindings
|
||||||
_ = viper.BindPFlag("style", rootCmd.Flags().Lookup("style"))
|
_ = viper.BindPFlag("style", rootCmd.Flags().Lookup("style"))
|
||||||
_ = viper.BindPFlag("width", rootCmd.Flags().Lookup("width"))
|
_ = viper.BindPFlag("width", rootCmd.Flags().Lookup("width"))
|
||||||
_ = viper.BindPFlag("local", rootCmd.Flags().Lookup("local"))
|
_ = viper.BindPFlag("debug", rootCmd.Flags().Lookup("debug"))
|
||||||
_ = viper.BindPFlag("mouse", rootCmd.Flags().Lookup("mouse"))
|
_ = viper.BindPFlag("mouse", rootCmd.Flags().Lookup("mouse"))
|
||||||
viper.SetDefault("style", "auto")
|
viper.SetDefault("style", glamour.AutoStyle)
|
||||||
viper.SetDefault("width", 0)
|
viper.SetDefault("width", 0)
|
||||||
viper.SetDefault("local", "false")
|
|
||||||
|
|
||||||
// Stash
|
|
||||||
stashCmd.PersistentFlags().StringVarP(&memo, "memo", "m", "", "memo/note for stashing")
|
|
||||||
rootCmd.AddCommand(stashCmd)
|
|
||||||
|
|
||||||
rootCmd.AddCommand(configCmd)
|
rootCmd.AddCommand(configCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func initConfig() {
|
func tryLoadConfigFromDefaultPlaces() {
|
||||||
if configFile != "" {
|
scope := gap.NewScope(gap.User, "glow")
|
||||||
viper.SetConfigFile(configFile)
|
dirs, err := scope.ConfigDirs()
|
||||||
} else {
|
if err != nil {
|
||||||
scope := gap.NewScope(gap.User, "glow")
|
fmt.Println("Could not load find config directory.")
|
||||||
dirs, err := scope.ConfigDirs()
|
os.Exit(1)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, v := range dirs {
|
||||||
|
viper.AddConfigPath(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
viper.SetConfigName("glow")
|
||||||
|
viper.SetConfigType("yaml")
|
||||||
viper.SetEnvPrefix("glow")
|
viper.SetEnvPrefix("glow")
|
||||||
viper.AutomaticEnv()
|
viper.AutomaticEnv()
|
||||||
|
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||||
fmt.Println("Error parsing config:", err)
|
log.Warn("Could not parse configuration file", "err", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fmt.Println("Using config file:", viper.ConfigFileUsed())
|
if used := viper.ConfigFileUsed(); used != "" {
|
||||||
|
log.Debug("Using configuration file", "path", viper.ConfigFileUsed())
|
||||||
|
} else {
|
||||||
|
if err := ensureConfigFile(); err != nil {
|
||||||
|
fmt.Println("Could not create default config.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
82
stash_cmd.go
82
stash_cmd.go
|
|
@ -1,82 +0,0 @@
|
||||||
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
|
package main
|
||||||
|
|
||||||
import . "github.com/charmbracelet/lipgloss" //nolint:revive
|
import "github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
keyword = NewStyle().
|
keyword = lipgloss.NewStyle().
|
||||||
Foreground(Color("#04B575")).
|
Foreground(lipgloss.Color("#04B575")).
|
||||||
Render
|
Render
|
||||||
|
|
||||||
paragraph = NewStyle().
|
paragraph = lipgloss.NewStyle().
|
||||||
Width(78).
|
Width(78).
|
||||||
Padding(0, 0, 0, 2).
|
Padding(0, 0, 0, 2).
|
||||||
Render
|
Render
|
||||||
|
|
|
||||||
16
ui/config.go
16
ui/config.go
|
|
@ -12,19 +12,7 @@ type Config struct {
|
||||||
// Which directory should we start from?
|
// Which directory should we start from?
|
||||||
WorkingDirectory string
|
WorkingDirectory string
|
||||||
|
|
||||||
// Which document types shall we show?
|
|
||||||
DocumentTypes DocTypeSet
|
|
||||||
|
|
||||||
// For debugging the UI
|
// For debugging the UI
|
||||||
Logfile string `env:"GLOW_LOGFILE"`
|
HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" envDefault:"true"`
|
||||||
HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" default:"true"`
|
GlamourEnabled bool `env:"GLOW_ENABLE_GLAMOUR" envDefault:"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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
//go:build !windows
|
|
||||||
// +build !windows
|
|
||||||
|
|
||||||
package ui
|
|
||||||
|
|
||||||
const (
|
|
||||||
pagerStashIcon = "🔒"
|
|
||||||
)
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
// +build windows
|
|
||||||
|
|
||||||
package ui
|
|
||||||
|
|
||||||
const (
|
|
||||||
pagerStashIcon = "•"
|
|
||||||
)
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
// DocType represents a type of markdown document.
|
|
||||||
type DocType int
|
|
||||||
|
|
||||||
// Available document types.
|
|
||||||
const (
|
|
||||||
NoDocType DocType = iota
|
|
||||||
LocalDoc
|
|
||||||
StashedDoc
|
|
||||||
ConvertedDoc
|
|
||||||
NewsDoc
|
|
||||||
)
|
|
||||||
|
|
||||||
func (d DocType) String() string {
|
|
||||||
return [...]string{
|
|
||||||
"none",
|
|
||||||
"local",
|
|
||||||
"stashed",
|
|
||||||
"converted",
|
|
||||||
"news",
|
|
||||||
}[d]
|
|
||||||
}
|
|
||||||
|
|
||||||
// DocTypeSet is a set (in the mathematic sense) of document types.
|
|
||||||
type DocTypeSet map[DocType]struct{}
|
|
||||||
|
|
||||||
// NewDocTypeSet returns a set of document types.
|
|
||||||
func NewDocTypeSet(t ...DocType) DocTypeSet {
|
|
||||||
d := DocTypeSet(make(map[DocType]struct{}))
|
|
||||||
if len(t) > 0 {
|
|
||||||
d.Add(t...)
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add adds a document type of the set.
|
|
||||||
func (d *DocTypeSet) Add(t ...DocType) int {
|
|
||||||
for _, v := range t {
|
|
||||||
(*d)[v] = struct{}{}
|
|
||||||
}
|
|
||||||
return len(*d)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contains returns whether or not the set contains the given DocTypes.
|
|
||||||
func (d DocTypeSet) Contains(m ...DocType) bool {
|
|
||||||
matches := 0
|
|
||||||
for _, t := range m {
|
|
||||||
if _, found := d[t]; found {
|
|
||||||
matches++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches == len(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Difference return a DocumentType set that does not contain the given types.
|
|
||||||
func (d DocTypeSet) Difference(t ...DocType) DocTypeSet {
|
|
||||||
c := copyDocumentTypes(d)
|
|
||||||
for k := range c {
|
|
||||||
for _, docType := range t {
|
|
||||||
if k == docType {
|
|
||||||
delete(c, k)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// Equals returns whether or not the two sets are equal.
|
|
||||||
func (d DocTypeSet) Equals(other DocTypeSet) bool {
|
|
||||||
return d.Contains(other.AsSlice()...) && len(d) == len(other)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AsSlice returns the set as a slice of document types.
|
|
||||||
func (d DocTypeSet) AsSlice() (agg []DocType) {
|
|
||||||
for k := range d {
|
|
||||||
agg = append(agg, k)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return a copy of the given DoctTypes map.
|
|
||||||
func copyDocumentTypes(d DocTypeSet) DocTypeSet {
|
|
||||||
c := make(map[DocType]struct{})
|
|
||||||
for k, v := range d {
|
|
||||||
c[k] = v
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDocTypeContains(t *testing.T) {
|
|
||||||
d := NewDocTypeSet(LocalDoc)
|
|
||||||
|
|
||||||
if !d.Contains(LocalDoc) {
|
|
||||||
t.Error("Contains reported it doesn't contain a value which it absolutely does contain")
|
|
||||||
}
|
|
||||||
|
|
||||||
if d.Contains(NewsDoc) {
|
|
||||||
t.Error("Contains reported the set contains a value it certainly does not")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDocTypeDifference(t *testing.T) {
|
|
||||||
original := NewDocTypeSet(LocalDoc, StashedDoc, ConvertedDoc, NewsDoc)
|
|
||||||
difference := original.Difference(LocalDoc, NewsDoc)
|
|
||||||
expected := NewDocTypeSet(StashedDoc, ConvertedDoc)
|
|
||||||
|
|
||||||
// Make sure the difference operation worked
|
|
||||||
if !reflect.DeepEqual(difference, expected) {
|
|
||||||
t.Errorf("difference returned %+v; expected %+v", difference, expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure original set was not mutated
|
|
||||||
if reflect.DeepEqual(original, difference) {
|
|
||||||
t.Errorf("original set was mutated when it should not have been")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDocTypeEquality(t *testing.T) {
|
|
||||||
a := NewDocTypeSet(LocalDoc, StashedDoc)
|
|
||||||
b := NewDocTypeSet(LocalDoc, StashedDoc)
|
|
||||||
c := NewDocTypeSet(LocalDoc)
|
|
||||||
|
|
||||||
if !a.Equals(b) {
|
|
||||||
t.Errorf("Equality test failed for %+v and %+v; expected true, got false", a, b)
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.Equals(c) {
|
|
||||||
t.Errorf("Equality test failed for %+v and %+v; expected false, got true", a, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -11,12 +11,9 @@ func openEditor(path string) tea.Cmd {
|
||||||
cb := func(err error) tea.Msg {
|
cb := func(err error) tea.Msg {
|
||||||
return editorFinishedMsg{err}
|
return editorFinishedMsg{err}
|
||||||
}
|
}
|
||||||
|
cmd, err := editor.Cmd("Glow", path)
|
||||||
editor, err := editor.Cmd("Glow", path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg { return cb(err) }
|
||||||
return errMsg{err}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return tea.ExecProcess(editor, cb)
|
return tea.ExecProcess(cmd, cb)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
|
//go:build darwin
|
||||||
// +build darwin
|
// +build darwin
|
||||||
|
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import "path/filepath"
|
import "path/filepath"
|
||||||
|
|
||||||
func ignorePatterns(m model) []string {
|
func ignorePatterns(m commonModel) []string {
|
||||||
return []string{
|
return []string{
|
||||||
filepath.Join(m.common.cfg.HomeDir, "Library"),
|
filepath.Join(m.cfg.HomeDir, "Library"),
|
||||||
m.common.cfg.Gopath,
|
m.cfg.Gopath,
|
||||||
"node_modules",
|
"node_modules",
|
||||||
".*",
|
".*",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
|
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
func ignorePatterns(m model) []string {
|
func ignorePatterns(m commonModel) []string {
|
||||||
return []string{
|
return []string{
|
||||||
m.common.cfg.Gopath,
|
m.cfg.Gopath,
|
||||||
"node_modules",
|
"node_modules",
|
||||||
".*",
|
".*",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
126
ui/markdown.go
126
ui/markdown.go
|
|
@ -1,42 +1,18 @@
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"math"
|
"math"
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/charmbracelet/charm"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/segmentio/ksuid"
|
|
||||||
"golang.org/x/text/runes"
|
"golang.org/x/text/runes"
|
||||||
"golang.org/x/text/transform"
|
"golang.org/x/text/transform"
|
||||||
"golang.org/x/text/unicode/norm"
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// markdown wraps charm.Markdown.
|
|
||||||
type markdown struct {
|
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
|
// Full path of a local markdown file. Only relevant to local documents and
|
||||||
// those that have been stashed in this session.
|
// those that have been stashed in this session.
|
||||||
localPath string
|
localPath string
|
||||||
|
|
@ -46,102 +22,24 @@ type markdown struct {
|
||||||
// field is ephemeral, and should only be referenced during filtering.
|
// field is ephemeral, and should only be referenced during filtering.
|
||||||
filterValue string
|
filterValue string
|
||||||
|
|
||||||
charm.Markdown
|
Body string
|
||||||
}
|
Note string
|
||||||
|
Modtime time.Time
|
||||||
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.
|
// Generate the value we're doing to filter against.
|
||||||
func (m *markdown) buildFilterValue() {
|
func (m *markdown) buildFilterValue() {
|
||||||
note, err := normalize(m.Note)
|
note, err := normalize(m.Note)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if debug {
|
log.Error("error normalizing", "note", m.Note, "error", err)
|
||||||
log.Printf("error normalizing '%s': %v", m.Note, err)
|
|
||||||
}
|
|
||||||
m.filterValue = m.Note
|
m.filterValue = m.Note
|
||||||
}
|
}
|
||||||
|
|
||||||
m.filterValue = 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 unique ID.
|
|
||||||
ids := []ksuid.KSUID{m[i].uniqueID, m[j].uniqueID}
|
|
||||||
ksuid.Sort(ids)
|
|
||||||
return ids[0] == m[i].uniqueID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m markdown) relativeTime() string {
|
func (m markdown) relativeTime() string {
|
||||||
return relativeTime(m.CreatedAt)
|
return relativeTime(m.Modtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize text to aid in the filtering process. In particular, we remove
|
// Normalize text to aid in the filtering process. In particular, we remove
|
||||||
|
|
@ -153,18 +51,6 @@ func normalize(in string) (string, error) {
|
||||||
return out, 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 m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the time in a human-readable format relative to the current time.
|
// Return the time in a human-readable format relative to the current time.
|
||||||
func relativeTime(then time.Time) string {
|
func relativeTime(then time.Time) string {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
|
||||||
390
ui/pager.go
390
ui/pager.go
|
|
@ -2,25 +2,28 @@ package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"math"
|
"math"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/atotto/clipboard"
|
"github.com/atotto/clipboard"
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/charm"
|
|
||||||
"github.com/charmbracelet/glamour"
|
"github.com/charmbracelet/glamour"
|
||||||
|
"github.com/charmbracelet/glow/utils"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
runewidth "github.com/mattn/go-runewidth"
|
runewidth "github.com/mattn/go-runewidth"
|
||||||
"github.com/muesli/reflow/ansi"
|
"github.com/muesli/reflow/ansi"
|
||||||
"github.com/muesli/reflow/truncate"
|
"github.com/muesli/reflow/truncate"
|
||||||
|
"github.com/muesli/termenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
const statusBarHeight = 1
|
const (
|
||||||
|
statusBarHeight = 1
|
||||||
|
lineNumberWidth = 4
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
pagerHelpHeight int
|
pagerHelpHeight int
|
||||||
|
|
@ -28,11 +31,7 @@ var (
|
||||||
mintGreen = lipgloss.AdaptiveColor{Light: "#89F0CB", Dark: "#89F0CB"}
|
mintGreen = lipgloss.AdaptiveColor{Light: "#89F0CB", Dark: "#89F0CB"}
|
||||||
darkGreen = lipgloss.AdaptiveColor{Light: "#1C8760", Dark: "#1C8760"}
|
darkGreen = lipgloss.AdaptiveColor{Light: "#1C8760", Dark: "#1C8760"}
|
||||||
|
|
||||||
noteHeading = lipgloss.NewStyle().
|
lineNumberFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
|
||||||
Foreground(cream).
|
|
||||||
Background(green).
|
|
||||||
Padding(0, 1).
|
|
||||||
SetString("Set Memo")
|
|
||||||
|
|
||||||
statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
|
statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
|
||||||
statusBarBg = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"}
|
statusBarBg = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"}
|
||||||
|
|
@ -52,21 +51,11 @@ var (
|
||||||
Background(lipgloss.AdaptiveColor{Light: "#DCDCDC", Dark: "#323232"}).
|
Background(lipgloss.AdaptiveColor{Light: "#DCDCDC", Dark: "#323232"}).
|
||||||
Render
|
Render
|
||||||
|
|
||||||
statusBarStashDotStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(green).
|
|
||||||
Background(statusBarBg).
|
|
||||||
Render
|
|
||||||
|
|
||||||
statusBarMessageStyle = lipgloss.NewStyle().
|
statusBarMessageStyle = lipgloss.NewStyle().
|
||||||
Foreground(mintGreen).
|
Foreground(mintGreen).
|
||||||
Background(darkGreen).
|
Background(darkGreen).
|
||||||
Render
|
Render
|
||||||
|
|
||||||
statusBarMessageStashIconStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(mintGreen).
|
|
||||||
Background(darkGreen).
|
|
||||||
Render
|
|
||||||
|
|
||||||
statusBarMessageScrollPosStyle = lipgloss.NewStyle().
|
statusBarMessageScrollPosStyle = lipgloss.NewStyle().
|
||||||
Foreground(mintGreen).
|
Foreground(mintGreen).
|
||||||
Background(darkGreen).
|
Background(darkGreen).
|
||||||
|
|
@ -82,47 +71,27 @@ var (
|
||||||
Background(lipgloss.AdaptiveColor{Light: "#f2f2f2", Dark: "#1B1B1B"}).
|
Background(lipgloss.AdaptiveColor{Light: "#f2f2f2", Dark: "#1B1B1B"}).
|
||||||
Render
|
Render
|
||||||
|
|
||||||
spinnerStyle = lipgloss.NewStyle().
|
lineNumberStyle = lipgloss.NewStyle().
|
||||||
Foreground(statusBarNoteFg).
|
Foreground(lineNumberFg).
|
||||||
Background(statusBarBg)
|
Render
|
||||||
|
|
||||||
pagerNoteInputPromptStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(darkGray).
|
|
||||||
Background(yellowGreen).
|
|
||||||
Padding(0, 1)
|
|
||||||
|
|
||||||
pagerNoteInputStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(darkGray).
|
|
||||||
Background(yellowGreen)
|
|
||||||
|
|
||||||
pagerNoteInputCursorStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(fuchsia)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
contentRenderedMsg string
|
contentRenderedMsg string
|
||||||
noteSavedMsg *charm.Markdown
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type pagerState int
|
type pagerState int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
pagerStateBrowse pagerState = iota
|
pagerStateBrowse pagerState = iota
|
||||||
pagerStateSetNote
|
|
||||||
pagerStateStashing
|
|
||||||
pagerStateStashSuccess
|
|
||||||
pagerStateStatusMessage
|
pagerStateStatusMessage
|
||||||
)
|
)
|
||||||
|
|
||||||
type pagerModel struct {
|
type pagerModel struct {
|
||||||
common *commonModel
|
common *commonModel
|
||||||
viewport viewport.Model
|
viewport viewport.Model
|
||||||
state pagerState
|
state pagerState
|
||||||
showHelp bool
|
showHelp bool
|
||||||
textInput textinput.Model
|
|
||||||
|
|
||||||
spinner spinner.Model
|
|
||||||
spinnerStart time.Time
|
|
||||||
|
|
||||||
statusMessage string
|
statusMessage string
|
||||||
statusMessageTimer *time.Timer
|
statusMessageTimer *time.Timer
|
||||||
|
|
@ -130,10 +99,6 @@ type pagerModel struct {
|
||||||
// Current document being rendered, sans-glamour rendering. We cache
|
// Current document being rendered, sans-glamour rendering. We cache
|
||||||
// it here so we can re-render it on resize.
|
// it here so we can re-render it on resize.
|
||||||
currentDocument markdown
|
currentDocument markdown
|
||||||
|
|
||||||
// Newly stashed markdown. We store it here temporarily so we can replace
|
|
||||||
// currentDocument above after a stash.
|
|
||||||
stashedDocument *markdown
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPagerModel(common *commonModel) pagerModel {
|
func newPagerModel(common *commonModel) pagerModel {
|
||||||
|
|
@ -142,34 +107,16 @@ func newPagerModel(common *commonModel) pagerModel {
|
||||||
vp.YPosition = 0
|
vp.YPosition = 0
|
||||||
vp.HighPerformanceRendering = config.HighPerformancePager
|
vp.HighPerformanceRendering = config.HighPerformancePager
|
||||||
|
|
||||||
// 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{
|
return pagerModel{
|
||||||
common: common,
|
common: common,
|
||||||
state: pagerStateBrowse,
|
state: pagerStateBrowse,
|
||||||
textInput: ti,
|
viewport: vp,
|
||||||
viewport: vp,
|
|
||||||
spinner: sp,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *pagerModel) setSize(w, h int) {
|
func (m *pagerModel) setSize(w, h int) {
|
||||||
m.viewport.Width = w
|
m.viewport.Width = w
|
||||||
m.viewport.Height = h - statusBarHeight
|
m.viewport.Height = h - statusBarHeight
|
||||||
m.textInput.Width = w -
|
|
||||||
ansi.PrintableRuneWidth(noteHeading.String()) -
|
|
||||||
ansi.PrintableRuneWidth(m.textInput.Prompt) - 1
|
|
||||||
|
|
||||||
if m.showHelp {
|
if m.showHelp {
|
||||||
if pagerHelpHeight == 0 {
|
if pagerHelpHeight == 0 {
|
||||||
|
|
@ -191,13 +138,18 @@ func (m *pagerModel) toggleHelp() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type pagerStatusMessage struct {
|
||||||
|
message string
|
||||||
|
isError bool
|
||||||
|
}
|
||||||
|
|
||||||
// Perform stuff that needs to happen after a successful markdown stash. Note
|
// Perform stuff that needs to happen after a successful markdown stash. Note
|
||||||
// that the the returned command should be sent back the through the pager
|
// that the the returned command should be sent back the through the pager
|
||||||
// update function.
|
// update function.
|
||||||
func (m *pagerModel) showStatusMessage(statusMessage string) tea.Cmd {
|
func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd {
|
||||||
// Show a success message to the user
|
// Show a success message to the user
|
||||||
m.state = pagerStateStatusMessage
|
m.state = pagerStateStatusMessage
|
||||||
m.statusMessage = statusMessage
|
m.statusMessage = msg.message
|
||||||
if m.statusMessageTimer != nil {
|
if m.statusMessageTimer != nil {
|
||||||
m.statusMessageTimer.Stop()
|
m.statusMessageTimer.Stop()
|
||||||
}
|
}
|
||||||
|
|
@ -216,7 +168,6 @@ func (m *pagerModel) unload() {
|
||||||
m.state = pagerStateBrowse
|
m.state = pagerStateBrowse
|
||||||
m.viewport.SetContent("")
|
m.viewport.SetContent("")
|
||||||
m.viewport.YOffset = 0
|
m.viewport.YOffset = 0
|
||||||
m.textInput.Reset()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
|
func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
|
||||||
|
|
@ -227,126 +178,38 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch m.state {
|
switch msg.String() {
|
||||||
case pagerStateSetNote:
|
case "q", keyEsc:
|
||||||
switch msg.String() {
|
if m.state != pagerStateBrowse {
|
||||||
case keyEsc:
|
|
||||||
m.state = pagerStateBrowse
|
m.state = pagerStateBrowse
|
||||||
return m, nil
|
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
|
|
||||||
}
|
}
|
||||||
default:
|
case "home", "g":
|
||||||
switch msg.String() {
|
m.viewport.GotoTop()
|
||||||
case "q", keyEsc:
|
if m.viewport.HighPerformanceRendering {
|
||||||
if m.state != pagerStateBrowse {
|
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||||
m.state = pagerStateBrowse
|
}
|
||||||
return m, nil
|
case "end", "G":
|
||||||
}
|
m.viewport.GotoBottom()
|
||||||
case "home", "g":
|
if m.viewport.HighPerformanceRendering {
|
||||||
m.viewport.GotoTop()
|
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||||
if m.viewport.HighPerformanceRendering {
|
|
||||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
|
||||||
}
|
|
||||||
case "end", "G":
|
|
||||||
m.viewport.GotoBottom()
|
|
||||||
if m.viewport.HighPerformanceRendering {
|
|
||||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
|
||||||
}
|
|
||||||
case "m":
|
|
||||||
isStashed := m.currentDocument.docType == StashedDoc ||
|
|
||||||
m.currentDocument.docType == ConvertedDoc
|
|
||||||
|
|
||||||
// Users can only set the note on user-stashed markdown
|
|
||||||
if !isStashed {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
m.state = pagerStateSetNote
|
|
||||||
|
|
||||||
// Stop the timer for hiding a status message since changing
|
|
||||||
// the state above will have cleared it.
|
|
||||||
if m.statusMessageTimer != nil {
|
|
||||||
m.statusMessageTimer.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-populate note with existing value
|
|
||||||
if m.textInput.Value() == "" {
|
|
||||||
m.textInput.SetValue(m.currentDocument.Note)
|
|
||||||
m.textInput.CursorEnd()
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, textinput.Blink
|
|
||||||
|
|
||||||
case "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 spinner.TickMsg:
|
case "e":
|
||||||
spinnerMinTimeout := m.spinnerStart.
|
return m, openEditor(m.currentDocument.localPath)
|
||||||
Add(spinnerVisibilityTimeout).
|
|
||||||
Add(spinnerMinLifetime)
|
|
||||||
|
|
||||||
if m.state == pagerStateStashing || time.Now().Before(spinnerMinTimeout) {
|
case "c":
|
||||||
// We're either still stashing or we haven't reached the spinner's
|
// Copy using OSC 52
|
||||||
// full lifetime. In either case we need to spin the spinner
|
termenv.Copy(m.currentDocument.Body)
|
||||||
// irrespective of it's more fine-grained visibility rules.
|
// Copy using native system clipboard
|
||||||
var cmd tea.Cmd
|
_ = clipboard.WriteAll(m.currentDocument.Body)
|
||||||
m.spinner, cmd = m.spinner.Update(msg)
|
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Copied contents", false}))
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
} else if m.state == pagerStateStashSuccess {
|
case "?":
|
||||||
// Successful stash. Stop spinning and update accordingly.
|
m.toggleHelp()
|
||||||
m.state = pagerStateBrowse
|
if m.viewport.HighPerformanceRendering {
|
||||||
m.currentDocument = *m.stashedDocument
|
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||||
m.stashedDocument = nil
|
}
|
||||||
cmds = append(cmds, m.showStatusMessage("Stashed!"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Glow has rendered the content
|
// Glow has rendered the content
|
||||||
|
|
@ -356,75 +219,36 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
|
||||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case editMardownMsg:
|
||||||
|
return m, openEditor(msg.md.localPath)
|
||||||
|
|
||||||
// We've finished editing the document, potentially making changes. Let's
|
// We've finished editing the document, potentially making changes. Let's
|
||||||
// retrieve the latest version of the document so that we display
|
// retrieve the latest version of the document so that we display
|
||||||
// up-to-date contents.
|
// up-to-date contents.
|
||||||
case editorFinishedMsg:
|
case editorFinishedMsg:
|
||||||
return m, loadLocalMarkdown(&m.currentDocument)
|
return m, loadLocalMarkdown(&m.currentDocument)
|
||||||
|
|
||||||
// We've reveived terminal dimensions, either for the first time or
|
// We've received terminal dimensions, either for the first time or
|
||||||
// after a resize
|
// after a resize
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
return m, renderWithGlamour(m, m.currentDocument.Body)
|
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:
|
case statusMessageTimeoutMsg:
|
||||||
m.state = pagerStateBrowse
|
m.state = pagerStateBrowse
|
||||||
}
|
}
|
||||||
|
|
||||||
switch m.state {
|
m.viewport, cmd = m.viewport.Update(msg)
|
||||||
case pagerStateSetNote:
|
cmds = append(cmds, cmd)
|
||||||
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...)
|
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 {
|
func (m pagerModel) View() string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
fmt.Fprint(&b, m.viewport.View()+"\n")
|
fmt.Fprint(&b, m.viewport.View()+"\n")
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
switch m.state {
|
m.statusBarView(&b)
|
||||||
case pagerStateSetNote:
|
|
||||||
m.setNoteView(&b)
|
|
||||||
default:
|
|
||||||
m.statusBarView(&b)
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.showHelp {
|
if m.showHelp {
|
||||||
fmt.Fprint(&b, "\n"+m.helpView())
|
fmt.Fprint(&b, "\n"+m.helpView())
|
||||||
|
|
@ -440,11 +264,10 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
|
||||||
percentToStringMagnitude float64 = 100.0
|
percentToStringMagnitude float64 = 100.0
|
||||||
)
|
)
|
||||||
|
|
||||||
isStashed := m.currentDocument.docType == StashedDoc || m.currentDocument.docType == ConvertedDoc
|
|
||||||
showStatusMessage := m.state == pagerStateStatusMessage
|
showStatusMessage := m.state == pagerStateStatusMessage
|
||||||
|
|
||||||
// Logo
|
// Logo
|
||||||
logo := glowLogoView(" Glow ")
|
logo := glowLogoView()
|
||||||
|
|
||||||
// Scroll percent
|
// Scroll percent
|
||||||
percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent()))
|
percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent()))
|
||||||
|
|
@ -463,34 +286,16 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
|
||||||
helpNote = statusBarHelpStyle(" ? Help ")
|
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
|
// Note
|
||||||
var note string
|
var note string
|
||||||
if showStatusMessage {
|
if showStatusMessage {
|
||||||
note = m.statusMessage
|
note = m.statusMessage
|
||||||
} else {
|
} else {
|
||||||
note = m.currentDocument.Note
|
note = m.currentDocument.Note
|
||||||
if len(note) == 0 {
|
|
||||||
note = "(No memo)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
note = truncate.StringWithTail(" "+note+" ", uint(max(0,
|
note = truncate.StringWithTail(" "+note+" ", uint(max(0,
|
||||||
m.common.width-
|
m.common.width-
|
||||||
ansi.PrintableRuneWidth(logo)-
|
ansi.PrintableRuneWidth(logo)-
|
||||||
ansi.PrintableRuneWidth(statusIndicator)-
|
|
||||||
ansi.PrintableRuneWidth(scrollPercent)-
|
ansi.PrintableRuneWidth(scrollPercent)-
|
||||||
ansi.PrintableRuneWidth(helpNote),
|
ansi.PrintableRuneWidth(helpNote),
|
||||||
)), ellipsis)
|
)), ellipsis)
|
||||||
|
|
@ -504,7 +309,6 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
|
||||||
padding := max(0,
|
padding := max(0,
|
||||||
m.common.width-
|
m.common.width-
|
||||||
ansi.PrintableRuneWidth(logo)-
|
ansi.PrintableRuneWidth(logo)-
|
||||||
ansi.PrintableRuneWidth(statusIndicator)-
|
|
||||||
ansi.PrintableRuneWidth(note)-
|
ansi.PrintableRuneWidth(note)-
|
||||||
ansi.PrintableRuneWidth(scrollPercent)-
|
ansi.PrintableRuneWidth(scrollPercent)-
|
||||||
ansi.PrintableRuneWidth(helpNote),
|
ansi.PrintableRuneWidth(helpNote),
|
||||||
|
|
@ -516,9 +320,8 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
|
||||||
emptySpace = statusBarNoteStyle(emptySpace)
|
emptySpace = statusBarNoteStyle(emptySpace)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(b, "%s%s%s%s%s%s",
|
fmt.Fprintf(b, "%s%s%s%s%s",
|
||||||
logo,
|
logo,
|
||||||
statusIndicator,
|
|
||||||
note,
|
note,
|
||||||
emptySpace,
|
emptySpace,
|
||||||
scrollPercent,
|
scrollPercent,
|
||||||
|
|
@ -526,36 +329,18 @@ 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) {
|
func (m pagerModel) helpView() (s string) {
|
||||||
memoOrStash := "m set memo"
|
edit := "e edit this document"
|
||||||
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{
|
col1 := []string{
|
||||||
"g/home go to top",
|
"g/home go to top",
|
||||||
"G/end go to bottom",
|
"G/end go to bottom",
|
||||||
"c copy contents",
|
"c copy contents",
|
||||||
editOrBlank,
|
edit,
|
||||||
memoOrStash,
|
|
||||||
"esc back to files",
|
"esc back to files",
|
||||||
"q quit",
|
"q quit",
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.currentDocument.docType == NewsDoc {
|
|
||||||
deleteFromStringSlice(col1, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
s += "\n"
|
s += "\n"
|
||||||
s += "k/↑ up " + col1[0] + "\n"
|
s += "k/↑ up " + col1[0] + "\n"
|
||||||
s += "j/↓ down " + col1[1] + "\n"
|
s += "j/↓ down " + col1[1] + "\n"
|
||||||
|
|
@ -591,9 +376,7 @@ func renderWithGlamour(m pagerModel, md string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
s, err := glamourRender(m, md)
|
s, err := glamourRender(m, md)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if debug {
|
log.Error("error rendering with Glamour", "error", err)
|
||||||
log.Println("error rendering with Glamour:", err)
|
|
||||||
}
|
|
||||||
return errMsg{err}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
return contentRenderedMsg(s)
|
return contentRenderedMsg(s)
|
||||||
|
|
@ -602,53 +385,58 @@ func renderWithGlamour(m pagerModel, md string) tea.Cmd {
|
||||||
|
|
||||||
// This is where the magic happens.
|
// This is where the magic happens.
|
||||||
func glamourRender(m pagerModel, markdown string) (string, error) {
|
func glamourRender(m pagerModel, markdown string) (string, error) {
|
||||||
|
trunc := lipgloss.NewStyle().MaxWidth(m.viewport.Width - lineNumberWidth).Render
|
||||||
|
|
||||||
if !config.GlamourEnabled {
|
if !config.GlamourEnabled {
|
||||||
return markdown, nil
|
return markdown, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialize glamour
|
isCode := !utils.IsMarkdownFile(m.currentDocument.Note)
|
||||||
var gs glamour.TermRendererOption
|
width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width))
|
||||||
if m.common.cfg.GlamourStyle == "auto" {
|
if isCode {
|
||||||
gs = glamour.WithAutoStyle()
|
width = 0
|
||||||
} else {
|
|
||||||
gs = glamour.WithStylePath(m.common.cfg.GlamourStyle)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width))
|
|
||||||
r, err := glamour.NewTermRenderer(
|
r, err := glamour.NewTermRenderer(
|
||||||
gs,
|
utils.GlamourStyle(m.common.cfg.GlamourStyle, isCode),
|
||||||
glamour.WithWordWrap(width),
|
glamour.WithWordWrap(width),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isCode {
|
||||||
|
markdown = utils.WrapCodeBlock(markdown, filepath.Ext(m.currentDocument.Note))
|
||||||
|
}
|
||||||
|
|
||||||
out, err := r.Render(markdown)
|
out, err := r.Render(markdown)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isCode {
|
||||||
|
out = strings.TrimSpace(out)
|
||||||
|
}
|
||||||
|
|
||||||
// trim lines
|
// trim lines
|
||||||
lines := strings.Split(out, "\n")
|
lines := strings.Split(out, "\n")
|
||||||
|
|
||||||
var content string
|
var content strings.Builder
|
||||||
for i, s := range lines {
|
for i, s := range lines {
|
||||||
content += strings.TrimSpace(s)
|
if isCode {
|
||||||
|
content.WriteString(lineNumberStyle(fmt.Sprintf("%"+fmt.Sprint(lineNumberWidth)+"d", i+1)))
|
||||||
|
content.WriteString(trunc(s))
|
||||||
|
} else {
|
||||||
|
content.WriteString(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
|
||||||
// don't add an artificial newline after the last split
|
// don't add an artificial newline after the last split
|
||||||
if i+1 < len(lines) {
|
if i+1 < len(lines) {
|
||||||
content += "\n"
|
content.WriteRune('\n')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return content, nil
|
return content.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ETC
|
type editMardownMsg struct{ md *markdown }
|
||||||
|
|
||||||
// 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
Normal file
12
ui/sort.go
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sortMarkdowns(mds []*markdown) {
|
||||||
|
slices.SortStableFunc(mds, func(a, b *markdown) int {
|
||||||
|
return cmp.Compare(a.Note, b.Note)
|
||||||
|
})
|
||||||
|
}
|
||||||
732
ui/stash.go
732
ui/stash.go
File diff suppressed because it is too large
Load diff
|
|
@ -99,17 +99,7 @@ func (m stashModel) helpView() (string, int) {
|
||||||
return m.renderHelp(h)
|
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 (
|
var (
|
||||||
isStashed bool
|
|
||||||
isStashable bool
|
|
||||||
isEditable bool
|
isEditable bool
|
||||||
navHelp []string
|
navHelp []string
|
||||||
filterHelp []string
|
filterHelp []string
|
||||||
|
|
@ -119,13 +109,6 @@ func (m stashModel) helpView() (string, int) {
|
||||||
appHelp []string
|
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 {
|
if numDocs > 0 && m.showFullHelp {
|
||||||
navHelp = []string{"enter", "open", "j/k ↑/↓", "choose"}
|
navHelp = []string{"enter", "open", "j/k ↑/↓", "choose"}
|
||||||
}
|
}
|
||||||
|
|
@ -143,16 +126,13 @@ func (m stashModel) helpView() (string, int) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're browsing a filtered set
|
// If we're browsing a filtered set
|
||||||
if m.filterState == filterApplied {
|
if m.filterApplied() {
|
||||||
filterHelp = []string{"/", "edit search", "esc", "clear search"}
|
filterHelp = []string{"/", "edit search", "esc", "clear filter"}
|
||||||
} else {
|
} else {
|
||||||
filterHelp = []string{"/", "find"}
|
filterHelp = []string{"/", "find"}
|
||||||
}
|
if m.stashFullyLoaded {
|
||||||
|
filterHelp = append(filterHelp, "t", "team filter")
|
||||||
if isStashed {
|
}
|
||||||
selectionHelp = []string{"x", "delete", "m", "set memo"}
|
|
||||||
} else if isStashable {
|
|
||||||
selectionHelp = []string{"s", "stash"}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if isEditable {
|
if isEditable {
|
||||||
|
|
@ -181,13 +161,15 @@ func (m stashModel) helpView() (string, int) {
|
||||||
return m.renderHelp(navHelp, filterHelp, selectionHelp, editHelp, sectionHelp, appHelp)
|
return m.renderHelp(navHelp, filterHelp, selectionHelp, editHelp, sectionHelp, appHelp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const minHelpViewHeight = 5
|
||||||
|
|
||||||
// renderHelp returns the rendered help view and associated line height for
|
// renderHelp returns the rendered help view and associated line height for
|
||||||
// the given groups of help items.
|
// the given groups of help items.
|
||||||
func (m stashModel) renderHelp(groups ...[]string) (string, int) {
|
func (m stashModel) renderHelp(groups ...[]string) (string, int) {
|
||||||
if m.showFullHelp {
|
if m.showFullHelp {
|
||||||
str := m.fullHelpView(groups...)
|
str := m.fullHelpView(groups...)
|
||||||
numLines := strings.Count(str, "\n") + 1
|
numLines := strings.Count(str, "\n") + 1
|
||||||
return str, numLines
|
return str, max(numLines, minHelpViewHeight)
|
||||||
}
|
}
|
||||||
return m.miniHelpView(concatStringSlices(groups...)...), 1
|
return m.miniHelpView(concatStringSlices(groups...)...), 1
|
||||||
}
|
}
|
||||||
|
|
@ -219,14 +201,8 @@ func (m stashModel) miniHelpView(entries ...string) string {
|
||||||
k := entries[i]
|
k := entries[i]
|
||||||
v := entries[i+1]
|
v := entries[i+1]
|
||||||
|
|
||||||
switch k {
|
k = grayFg(k)
|
||||||
case "s":
|
v = midGrayFg(v)
|
||||||
k = greenFg(k)
|
|
||||||
v = semiDimGreenFg(v)
|
|
||||||
default:
|
|
||||||
k = grayFg(k)
|
|
||||||
v = midGrayFg(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
next = fmt.Sprintf("%s %s", k, v)
|
next = fmt.Sprintf("%s %s", k, v)
|
||||||
|
|
||||||
|
|
|
||||||
134
ui/stashitem.go
134
ui/stashitem.go
|
|
@ -2,47 +2,31 @@ package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/muesli/reflow/ansi"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/muesli/reflow/truncate"
|
"github.com/muesli/reflow/truncate"
|
||||||
"github.com/sahilm/fuzzy"
|
"github.com/sahilm/fuzzy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
verticalLine = "│"
|
verticalLine = "│"
|
||||||
noMemoTitle = "No Memo"
|
|
||||||
fileListingStashIcon = "• "
|
fileListingStashIcon = "• "
|
||||||
)
|
)
|
||||||
|
|
||||||
func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) {
|
func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) {
|
||||||
var (
|
var (
|
||||||
truncateTo = uint(m.common.width - stashViewHorizontalPadding*2)
|
truncateTo = uint(m.common.width - stashViewHorizontalPadding*2)
|
||||||
gutter string
|
gutter string
|
||||||
title = md.Note
|
title = truncate.StringWithTail(md.Note, truncateTo, ellipsis)
|
||||||
date = md.relativeTime()
|
date = md.relativeTime()
|
||||||
icon = ""
|
editedBy = ""
|
||||||
|
hasEditedBy = false
|
||||||
|
icon = ""
|
||||||
|
separator = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
switch md.docType {
|
|
||||||
case NewsDoc:
|
|
||||||
if title == "" {
|
|
||||||
title = "News"
|
|
||||||
} else {
|
|
||||||
title = truncate.StringWithTail(title, truncateTo, ellipsis)
|
|
||||||
}
|
|
||||||
case StashedDoc, ConvertedDoc:
|
|
||||||
icon = fileListingStashIcon
|
|
||||||
if title == "" {
|
|
||||||
title = noMemoTitle
|
|
||||||
}
|
|
||||||
title = truncate.StringWithTail(title, truncateTo-uint(ansi.PrintableRuneWidth(icon)), ellipsis)
|
|
||||||
default:
|
|
||||||
title = truncate.StringWithTail(title, truncateTo, ellipsis)
|
|
||||||
}
|
|
||||||
|
|
||||||
isSelected := index == m.cursor()
|
isSelected := index == m.cursor()
|
||||||
isFiltering := m.filterState == filtering
|
isFiltering := m.filterState == filtering
|
||||||
singleFilteredItem := isFiltering && len(m.getVisibleMarkdowns()) == 1
|
singleFilteredItem := isFiltering && len(m.getVisibleMarkdowns()) == 1
|
||||||
|
|
@ -52,87 +36,65 @@ func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) {
|
||||||
// highlight that first item since pressing return will open it.
|
// highlight that first item since pressing return will open it.
|
||||||
if isSelected && !isFiltering || singleFilteredItem {
|
if isSelected && !isFiltering || singleFilteredItem {
|
||||||
// Selected item
|
// Selected item
|
||||||
|
if m.statusMessage == stashingStatusMessage {
|
||||||
switch m.selectionState {
|
gutter = greenFg(verticalLine)
|
||||||
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 {
|
|
||||||
gutter = dullFuchsiaFg(verticalLine)
|
|
||||||
icon = dullFuchsiaFg(icon)
|
|
||||||
if m.currentSection().key == filterSection &&
|
|
||||||
m.filterState == filterApplied || singleFilteredItem {
|
|
||||||
s := lipgloss.NewStyle().Foreground(fuchsia)
|
|
||||||
title = styleFilteredText(title, m.filterInput.Value(), s, s.Copy().Underline(true))
|
|
||||||
} else {
|
|
||||||
title = fuchsiaFg(title)
|
|
||||||
}
|
|
||||||
date = dullFuchsiaFg(date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Regular (non-selected) items
|
|
||||||
|
|
||||||
gutter = " "
|
|
||||||
|
|
||||||
if m.common.latestFileStashed == md.stashID &&
|
|
||||||
m.statusMessage == stashingStatusMessage {
|
|
||||||
icon = dimGreenFg(icon)
|
icon = dimGreenFg(icon)
|
||||||
title = greenFg(title)
|
title = greenFg(title)
|
||||||
date = semiDimGreenFg(date)
|
date = semiDimGreenFg(date)
|
||||||
} else if md.docType == NewsDoc {
|
editedBy = semiDimGreenFg(editedBy)
|
||||||
if isFiltering && m.filterInput.Value() == "" {
|
separator = semiDimGreenFg(separator)
|
||||||
title = dimIndigoFg(title)
|
} else {
|
||||||
date = dimSubtleIndigoFg(date)
|
gutter = dullFuchsiaFg(verticalLine)
|
||||||
|
if m.currentSection().key == filterSection &&
|
||||||
|
m.filterState == filterApplied || singleFilteredItem {
|
||||||
|
s := lipgloss.NewStyle().Foreground(fuchsia)
|
||||||
|
title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true))
|
||||||
} else {
|
} else {
|
||||||
s := lipgloss.NewStyle().Foreground(indigo)
|
title = fuchsiaFg(title)
|
||||||
title = styleFilteredText(title, m.filterInput.Value(), s, s.Copy().Underline(true))
|
icon = fuchsiaFg(icon)
|
||||||
date = subtleIndigoFg(date)
|
|
||||||
}
|
}
|
||||||
|
date = dimFuchsiaFg(date)
|
||||||
|
editedBy = dimDullFuchsiaFg(editedBy)
|
||||||
|
separator = dullFuchsiaFg(separator)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
gutter = " "
|
||||||
|
if m.statusMessage == stashingStatusMessage {
|
||||||
|
icon = dimGreenFg(icon)
|
||||||
|
title = greenFg(title)
|
||||||
|
date = semiDimGreenFg(date)
|
||||||
|
editedBy = semiDimGreenFg(editedBy)
|
||||||
|
separator = semiDimGreenFg(separator)
|
||||||
} else if isFiltering && m.filterInput.Value() == "" {
|
} else if isFiltering && m.filterInput.Value() == "" {
|
||||||
icon = dimGreenFg(icon)
|
icon = dimGreenFg(icon)
|
||||||
if title == noMemoTitle {
|
title = dimNormalFg(title)
|
||||||
title = dimBrightGrayFg(title)
|
|
||||||
} else {
|
|
||||||
title = dimNormalFg(title)
|
|
||||||
}
|
|
||||||
date = dimBrightGrayFg(date)
|
date = dimBrightGrayFg(date)
|
||||||
|
editedBy = dimBrightGrayFg(editedBy)
|
||||||
|
separator = dimBrightGrayFg(separator)
|
||||||
} else {
|
} else {
|
||||||
icon = greenFg(icon)
|
icon = greenFg(icon)
|
||||||
if title == noMemoTitle {
|
|
||||||
title = brightGrayFg(title)
|
s := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"})
|
||||||
} else {
|
title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true))
|
||||||
s := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"})
|
date = grayFg(date)
|
||||||
title = styleFilteredText(title, m.filterInput.Value(), s, s.Copy().Underline(true))
|
editedBy = midGrayFg(editedBy)
|
||||||
}
|
separator = brightGrayFg(separator)
|
||||||
date = brightGrayFg(date)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(b, "%s %s%s\n", gutter, icon, title)
|
fmt.Fprintf(b, "%s %s%s%s%s\n", gutter, icon, separator, separator, title)
|
||||||
fmt.Fprintf(b, "%s %s", gutter, date)
|
fmt.Fprintf(b, "%s %s", gutter, date)
|
||||||
|
if hasEditedBy {
|
||||||
|
fmt.Fprintf(b, " %s", editedBy)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle lipgloss.Style) string {
|
func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle lipgloss.Style) string {
|
||||||
b := strings.Builder{}
|
b := strings.Builder{}
|
||||||
|
|
||||||
normalizedHay, err := normalize(haystack)
|
normalizedHay, err := normalize(haystack)
|
||||||
if err != nil && debug {
|
if err != nil {
|
||||||
log.Printf("error normalizing '%s': %v", haystack, err)
|
log.Error("error normalizing", "haystack", haystack, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
matches := fuzzy.Find(needles, []string{normalizedHay})
|
matches := fuzzy.Find(needles, []string{normalizedHay})
|
||||||
|
|
|
||||||
110
ui/styles.go
110
ui/styles.go
|
|
@ -1,84 +1,46 @@
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import . "github.com/charmbracelet/lipgloss" //nolint: revive
|
import "github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
// Colors.
|
// Colors.
|
||||||
var (
|
var (
|
||||||
normal = AdaptiveColor{Light: "#1A1A1A", Dark: "#dddddd"}
|
normalDim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}
|
||||||
normalDim = AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}
|
gray = lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"}
|
||||||
gray = AdaptiveColor{Light: "#909090", Dark: "#626262"}
|
midGray = lipgloss.AdaptiveColor{Light: "#B2B2B2", Dark: "#4A4A4A"}
|
||||||
midGray = AdaptiveColor{Light: "#B2B2B2", Dark: "#4A4A4A"}
|
darkGray = lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}
|
||||||
darkGray = AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}
|
brightGray = lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}
|
||||||
brightGray = AdaptiveColor{Light: "#847A85", Dark: "#979797"}
|
dimBrightGray = lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"}
|
||||||
dimBrightGray = AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"}
|
cream = lipgloss.AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"}
|
||||||
indigo = AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"}
|
yellowGreen = lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}
|
||||||
dimIndigo = AdaptiveColor{Light: "#9498FF", Dark: "#494690"}
|
fuchsia = lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}
|
||||||
subtleIndigo = AdaptiveColor{Light: "#7D79F6", Dark: "#514DC1"}
|
dimFuchsia = lipgloss.AdaptiveColor{Light: "#F1A8FF", Dark: "#99519E"}
|
||||||
dimSubtleIndigo = AdaptiveColor{Light: "#BBBDFF", Dark: "#383584"}
|
dullFuchsia = lipgloss.AdaptiveColor{Dark: "#AD58B4", Light: "#F793FF"}
|
||||||
cream = AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"}
|
dimDullFuchsia = lipgloss.AdaptiveColor{Light: "#F6C9FF", Dark: "#7B4380"}
|
||||||
yellowGreen = AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}
|
green = lipgloss.Color("#04B575")
|
||||||
dullYellowGreen = AdaptiveColor{Light: "#6BCB94", Dark: "#9BA92F"}
|
red = lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"}
|
||||||
fuchsia = AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}
|
semiDimGreen = lipgloss.AdaptiveColor{Light: "#35D79C", Dark: "#036B46"}
|
||||||
dimFuchsia = AdaptiveColor{Light: "#F1A8FF", Dark: "#99519E"}
|
dimGreen = lipgloss.AdaptiveColor{Light: "#72D2B0", Dark: "#0B5137"}
|
||||||
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.
|
// Ulimately, we'll transition to named styles.
|
||||||
// nolint:deadcode,unused,varcheck
|
|
||||||
var (
|
var (
|
||||||
normalFg = NewStyle().Foreground(normal).Render
|
dimNormalFg = lipgloss.NewStyle().Foreground(normalDim).Render
|
||||||
dimNormalFg = NewStyle().Foreground(normalDim).Render
|
brightGrayFg = lipgloss.NewStyle().Foreground(brightGray).Render
|
||||||
|
dimBrightGrayFg = lipgloss.NewStyle().Foreground(dimBrightGray).Render
|
||||||
brightGrayFg = NewStyle().Foreground(brightGray).Render
|
grayFg = lipgloss.NewStyle().Foreground(gray).Render
|
||||||
dimBrightGrayFg = NewStyle().Foreground(dimBrightGray).Render
|
midGrayFg = lipgloss.NewStyle().Foreground(midGray).Render
|
||||||
|
darkGrayFg = lipgloss.NewStyle().Foreground(darkGray)
|
||||||
grayFg = NewStyle().Foreground(gray).Render
|
greenFg = lipgloss.NewStyle().Foreground(green).Render
|
||||||
midGrayFg = NewStyle().Foreground(midGray).Render
|
semiDimGreenFg = lipgloss.NewStyle().Foreground(semiDimGreen).Render
|
||||||
darkGrayFg = NewStyle().Foreground(darkGray)
|
dimGreenFg = lipgloss.NewStyle().Foreground(dimGreen).Render
|
||||||
|
fuchsiaFg = lipgloss.NewStyle().Foreground(fuchsia).Render
|
||||||
greenFg = NewStyle().Foreground(green).Render
|
dimFuchsiaFg = lipgloss.NewStyle().Foreground(dimFuchsia).Render
|
||||||
semiDimGreenFg = NewStyle().Foreground(semiDimGreen).Render
|
dullFuchsiaFg = lipgloss.NewStyle().Foreground(dullFuchsia).Render
|
||||||
dimGreenFg = NewStyle().Foreground(dimGreen).Render
|
dimDullFuchsiaFg = lipgloss.NewStyle().Foreground(dimDullFuchsia).Render
|
||||||
|
redFg = lipgloss.NewStyle().Foreground(red).Render
|
||||||
fuchsiaFg = NewStyle().Foreground(fuchsia).Render
|
tabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"})
|
||||||
dimFuchsiaFg = NewStyle().Foreground(dimFuchsia).Render
|
selectedTabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#333333", Dark: "#979797"})
|
||||||
|
errorTitleStyle = lipgloss.NewStyle().Foreground(cream).Background(red).Padding(0, 1)
|
||||||
dullFuchsiaFg = NewStyle().Foreground(dullFuchsia).Render
|
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"})
|
||||||
dimDullFuchsiaFg = NewStyle().Foreground(dimDullFuchsia).Render
|
paginationStyle = subtleStyle
|
||||||
|
|
||||||
indigoFg = NewStyle().Foreground(fuchsia).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()
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
466
ui/ui.go
466
ui/ui.go
|
|
@ -1,33 +1,25 @@
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/list"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/charm"
|
"github.com/charmbracelet/glamour"
|
||||||
"github.com/charmbracelet/charm/keygen"
|
|
||||||
"github.com/charmbracelet/glow/utils"
|
"github.com/charmbracelet/glow/utils"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
"github.com/muesli/gitcha"
|
"github.com/muesli/gitcha"
|
||||||
te "github.com/muesli/termenv"
|
te "github.com/muesli/termenv"
|
||||||
"github.com/segmentio/ksuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
noteCharacterLimit = 256 // should match server
|
statusMessageTimeout = time.Second * 3 // how long to show status messages like "stashed!"
|
||||||
statusMessageTimeout = time.Second * 2 // how long to show status messages like "stashed!"
|
|
||||||
ellipsis = "…"
|
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 (
|
var (
|
||||||
|
|
@ -36,29 +28,25 @@ var (
|
||||||
markdownExtensions = []string{
|
markdownExtensions = []string{
|
||||||
"*.md", "*.mdown", "*.mkdn", "*.mkd", "*.markdown",
|
"*.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.
|
// NewProgram returns a new Tea program.
|
||||||
func NewProgram(cfg Config) *tea.Program {
|
func NewProgram(cfg Config) *tea.Program {
|
||||||
if cfg.Logfile != "" {
|
log.Debug(
|
||||||
log.Println("-- Starting Glow ----------------")
|
"Starting glow",
|
||||||
log.Printf("High performance pager: %v", cfg.HighPerformancePager)
|
"high_perf_pager",
|
||||||
log.Printf("Glamour rendering: %v", cfg.GlamourEnabled)
|
cfg.HighPerformancePager,
|
||||||
log.Println("Bubble Tea now initializing...")
|
"glamour",
|
||||||
debug = true
|
cfg.GlamourEnabled,
|
||||||
}
|
)
|
||||||
|
|
||||||
config = cfg
|
config = cfg
|
||||||
opts := []tea.ProgramOption{tea.WithAltScreen()}
|
opts := []tea.ProgramOption{tea.WithAltScreen()}
|
||||||
if cfg.EnableMouse {
|
if cfg.EnableMouse {
|
||||||
opts = append(opts, tea.WithMouseCellMotion())
|
opts = append(opts, tea.WithMouseCellMotion())
|
||||||
}
|
}
|
||||||
return tea.NewProgram(newModel(cfg), opts...)
|
m := newModel(cfg)
|
||||||
|
return tea.NewProgram(m, opts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
type errMsg struct{ err error }
|
type errMsg struct{ err error }
|
||||||
|
|
@ -66,10 +54,6 @@ type errMsg struct{ err error }
|
||||||
func (e errMsg) Error() string { return e.err.Error() }
|
func (e errMsg) Error() string { return e.err.Error() }
|
||||||
|
|
||||||
type (
|
type (
|
||||||
newCharmClientMsg *charm.Client
|
|
||||||
sshAuthErrMsg struct{}
|
|
||||||
keygenFailedMsg struct{ err error }
|
|
||||||
keygenSuccessMsg struct{}
|
|
||||||
initLocalFileSearchMsg struct {
|
initLocalFileSearchMsg struct {
|
||||||
cwd string
|
cwd string
|
||||||
ch chan gitcha.SearchResult
|
ch chan gitcha.SearchResult
|
||||||
|
|
@ -79,16 +63,7 @@ type (
|
||||||
type (
|
type (
|
||||||
foundLocalFileMsg gitcha.SearchResult
|
foundLocalFileMsg gitcha.SearchResult
|
||||||
localFileSearchFinished struct{}
|
localFileSearchFinished struct{}
|
||||||
gotStashMsg []*charm.Markdown
|
|
||||||
stashLoadErrMsg struct{ err error }
|
|
||||||
gotNewsMsg []*charm.Markdown
|
|
||||||
statusMessageTimeoutMsg applicationContext
|
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 applies
|
||||||
|
|
@ -115,60 +90,18 @@ func (s state) String() string {
|
||||||
}[s]
|
}[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.
|
// Common stuff we'll need to access in all models.
|
||||||
type commonModel struct {
|
type commonModel struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
cc *charm.Client
|
cwd string
|
||||||
cwd string
|
width int
|
||||||
authStatus authStatus
|
height int
|
||||||
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 {
|
type model struct {
|
||||||
common *commonModel
|
common *commonModel
|
||||||
state state
|
state state
|
||||||
keygenState keygenState
|
fatalErr error
|
||||||
fatalErr error
|
|
||||||
|
|
||||||
// Sub-models
|
// Sub-models
|
||||||
stash stashModel
|
stash stashModel
|
||||||
|
|
@ -201,49 +134,37 @@ func (m *model) unloadDocument() []tea.Cmd {
|
||||||
func newModel(cfg Config) tea.Model {
|
func newModel(cfg Config) tea.Model {
|
||||||
initSections()
|
initSections()
|
||||||
|
|
||||||
if cfg.GlamourStyle == "auto" {
|
if cfg.GlamourStyle == glamour.AutoStyle {
|
||||||
if te.HasDarkBackground() {
|
if te.HasDarkBackground() {
|
||||||
cfg.GlamourStyle = "dark"
|
cfg.GlamourStyle = glamour.DarkStyle
|
||||||
} else {
|
} else {
|
||||||
cfg.GlamourStyle = "light"
|
cfg.GlamourStyle = glamour.LightStyle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.DocumentTypes) == 0 {
|
teamList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
|
||||||
cfg.DocumentTypes.Add(LocalDoc, StashedDoc, ConvertedDoc, NewsDoc)
|
teamList.Styles.Title = lipgloss.NewStyle().Foreground(yellowGreen)
|
||||||
}
|
teamList.SetStatusBarItemName("team", "teams")
|
||||||
|
teamList.SetShowHelp(true)
|
||||||
|
|
||||||
|
// We use the team list status message as a permanent placeholder.
|
||||||
|
teamList.StatusMessageLifetime = time.Hour
|
||||||
|
|
||||||
common := commonModel{
|
common := commonModel{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
authStatus: authConnecting,
|
|
||||||
filesStashed: make(map[ksuid.KSUID]struct{}),
|
|
||||||
filesStashing: make(map[ksuid.KSUID]struct{}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return model{
|
return model{
|
||||||
common: &common,
|
common: &common,
|
||||||
state: stateShowStash,
|
state: stateShowStash,
|
||||||
keygenState: keygenUnstarted,
|
pager: newPagerModel(&common),
|
||||||
pager: newPagerModel(&common),
|
stash: newStashModel(&common),
|
||||||
stash: newStashModel(&common),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd {
|
func (m model) Init() tea.Cmd {
|
||||||
var cmds []tea.Cmd
|
cmds := []tea.Cmd{m.stash.spinner.Tick}
|
||||||
d := m.common.cfg.DocumentTypes
|
cmds = append(cmds, m.stash.loadDocs())
|
||||||
|
|
||||||
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...)
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,7 +182,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc":
|
case "esc":
|
||||||
if m.state == stateShowDocument {
|
if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument {
|
||||||
batch := m.unloadDocument()
|
batch := m.unloadDocument()
|
||||||
return m, tea.Batch(batch...)
|
return m, tea.Batch(batch...)
|
||||||
}
|
}
|
||||||
|
|
@ -272,28 +193,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch m.state {
|
switch m.state {
|
||||||
case stateShowStash:
|
case stateShowStash:
|
||||||
// pass through all keys if we're editing the filter
|
// pass through all keys if we're editing the filter
|
||||||
if m.stash.filterState == filtering || m.stash.selectionState == selectionSettingNote {
|
if m.stash.filterState == filtering {
|
||||||
m.stash, cmd = m.stash.update(msg)
|
m.stash, cmd = m.stash.update(msg)
|
||||||
return m, cmd
|
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
|
return m, tea.Quit
|
||||||
|
|
||||||
case "left", "h", "delete":
|
case "left", "h", "delete":
|
||||||
if m.state == stateShowDocument && m.pager.state != pagerStateSetNote {
|
if m.state == stateShowDocument {
|
||||||
cmds = append(cmds, m.unloadDocument()...)
|
cmds = append(cmds, m.unloadDocument()...)
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
@ -315,66 +224,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
m.common.cwd = msg.cwd
|
m.common.cwd = msg.cwd
|
||||||
cmds = append(cmds, findNextLocalFile(m))
|
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:
|
case fetchedMarkdownMsg:
|
||||||
// We've loaded a markdown file's contents for rendering
|
// We've loaded a markdown file's contents for rendering
|
||||||
m.pager.currentDocument = *msg
|
m.pager.currentDocument = *msg
|
||||||
msg.Body = string(utils.RemoveFrontmatter([]byte(msg.Body)))
|
body := string(utils.RemoveFrontmatter([]byte(msg.Body)))
|
||||||
cmds = append(cmds, renderWithGlamour(m.pager, msg.Body))
|
cmds = append(cmds, renderWithGlamour(m.pager, body))
|
||||||
|
|
||||||
case contentRenderedMsg:
|
case contentRenderedMsg:
|
||||||
m.state = stateShowDocument
|
m.state = stateShowDocument
|
||||||
|
|
||||||
case noteSavedMsg:
|
case localFileSearchFinished:
|
||||||
// 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
|
// Always pass these messages to the stash so we can keep it updated
|
||||||
// about network activity, even if the user isn't currently viewing
|
// about network activity, even if the user isn't currently viewing
|
||||||
// the stash.
|
// the stash.
|
||||||
|
|
@ -393,35 +252,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
cmds = append(cmds, findNextLocalFile(m))
|
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:
|
case filteredMarkdownMsg:
|
||||||
if m.state == stateShowDocument {
|
if m.state == stateShowDocument {
|
||||||
newStashModel, cmd := m.stash.update(msg)
|
newStashModel, cmd := m.stash.update(msg)
|
||||||
|
|
@ -476,10 +306,11 @@ func errorView(err error, fatal bool) string {
|
||||||
|
|
||||||
// COMMANDS
|
// COMMANDS
|
||||||
|
|
||||||
func findLocalFiles(m model) tea.Cmd {
|
func findLocalFiles(m commonModel) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
|
log.Info("findLocalFiles")
|
||||||
var (
|
var (
|
||||||
cwd = m.common.cfg.WorkingDirectory
|
cwd = m.cfg.WorkingDirectory
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -495,26 +326,19 @@ func findLocalFiles(m model) tea.Cmd {
|
||||||
|
|
||||||
// Note that this is one error check for both cases above
|
// Note that this is one error check for both cases above
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if debug {
|
log.Error("error finding local files", "error", err)
|
||||||
log.Println("error finding local files:", err)
|
|
||||||
}
|
|
||||||
return errMsg{err}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
|
|
||||||
if debug {
|
log.Debug("local directory is", "cwd", cwd)
|
||||||
log.Println("local directory is:", cwd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var ignore []string
|
var ignore []string
|
||||||
if !m.common.cfg.ShowAllFiles {
|
if !m.cfg.ShowAllFiles {
|
||||||
ignore = ignorePatterns(m)
|
ignore = ignorePatterns(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
ch, err := gitcha.FindFilesExcept(cwd, markdownExtensions, ignore)
|
ch, err := gitcha.FindFilesExcept(cwd, markdownExtensions, ignore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if debug {
|
log.Error("error finding local files", "error", err)
|
||||||
log.Println("error finding local files:", err)
|
|
||||||
}
|
|
||||||
return errMsg{err}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -531,186 +355,11 @@ func findNextLocalFile(m model) tea.Cmd {
|
||||||
return foundLocalFileMsg(res)
|
return foundLocalFileMsg(res)
|
||||||
}
|
}
|
||||||
// We're done
|
// We're done
|
||||||
if debug {
|
log.Debug("local file search finished")
|
||||||
log.Println("local file search finished")
|
|
||||||
}
|
|
||||||
return localFileSearchFinished{}
|
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 successfully")
|
|
||||||
}
|
|
||||||
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 {
|
func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
<-t.C
|
<-t.C
|
||||||
|
|
@ -725,19 +374,16 @@ func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.C
|
||||||
// a directory, but we trust that gitcha has already done that.
|
// a directory, but we trust that gitcha has already done that.
|
||||||
func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown {
|
func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown {
|
||||||
md := &markdown{
|
md := &markdown{
|
||||||
docType: LocalDoc,
|
|
||||||
localPath: res.Path,
|
localPath: res.Path,
|
||||||
Markdown: charm.Markdown{
|
Note: stripAbsolutePath(res.Path, cwd),
|
||||||
Note: stripAbsolutePath(res.Path, cwd),
|
Modtime: res.Info.ModTime(),
|
||||||
CreatedAt: res.Info.ModTime(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return md
|
return md
|
||||||
}
|
}
|
||||||
|
|
||||||
func stripAbsolutePath(fullPath, cwd string) string {
|
func stripAbsolutePath(fullPath, cwd string) string {
|
||||||
return strings.Replace(fullPath, cwd+string(os.PathSeparator), "", -1)
|
return strings.ReplaceAll(fullPath, cwd+string(os.PathSeparator), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lightweight version of reflow's indent function.
|
// Lightweight version of reflow's indent function.
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,13 @@ package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/glamour"
|
||||||
|
"github.com/charmbracelet/glamour/ansi"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/mitchellh/go-homedir"
|
"github.com/mitchellh/go-homedir"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -32,3 +37,73 @@ func ExpandPath(path string) string {
|
||||||
}
|
}
|
||||||
return os.ExpandEnv(path)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func GlamourStyle(style string, isCode bool) glamour.TermRendererOption {
|
||||||
|
if !isCode {
|
||||||
|
if style == glamour.AutoStyle {
|
||||||
|
return glamour.WithAutoStyle()
|
||||||
|
} else {
|
||||||
|
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 glamour.AutoStyle:
|
||||||
|
if lipgloss.HasDarkBackground() {
|
||||||
|
styleConfig = glamour.DarkStyleConfig
|
||||||
|
} else {
|
||||||
|
styleConfig = glamour.LightStyleConfig
|
||||||
|
}
|
||||||
|
case glamour.DarkStyle:
|
||||||
|
styleConfig = glamour.DarkStyleConfig
|
||||||
|
case glamour.LightStyle:
|
||||||
|
styleConfig = glamour.LightStyleConfig
|
||||||
|
case glamour.PinkStyle:
|
||||||
|
styleConfig = glamour.PinkStyleConfig
|
||||||
|
case glamour.NoTTYStyle:
|
||||||
|
styleConfig = glamour.NoTTYStyleConfig
|
||||||
|
case glamour.DraculaStyle:
|
||||||
|
styleConfig = glamour.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