mirror of
https://github.com/charmbracelet/glow
synced 2024-09-20 06:22:02 +00: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 696 additions and 2966 deletions
|
@ -24,7 +24,7 @@ linters:
|
|||
- goimports
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- ifshort
|
||||
# - ifshort
|
||||
- misspell
|
||||
- prealloc
|
||||
- revive
|
||||
|
|
7
Makefile
7
Makefile
|
@ -2,8 +2,6 @@
|
|||
|
||||
.PHONY: default clean glow run log
|
||||
|
||||
LOGFILE := debug.log
|
||||
|
||||
default: glow
|
||||
|
||||
clean:
|
||||
|
@ -13,8 +11,7 @@ glow:
|
|||
go build
|
||||
|
||||
run: clean glow
|
||||
GLOW_LOGFILE=$(LOGFILE) ./glow
|
||||
./glow
|
||||
|
||||
log:
|
||||
> $(LOGFILE)
|
||||
tail -f $(LOGFILE)
|
||||
tail -f ~/.cache/glow/glow.log
|
||||
|
|
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.
|
||||
|
||||
Use it to discover markdown files, read documentation directly on the command
|
||||
line and stash markdown files to your own private collection, so you can read
|
||||
them anywhere. Glow will find local markdown files in subdirectories or a local
|
||||
line. Glow will find local markdown files in subdirectories or a local
|
||||
Git repository.
|
||||
|
||||
By the way, all data stashed is encrypted end-to-end: only you can decrypt it.
|
||||
More on that below.
|
||||
|
||||
## Installation
|
||||
|
||||
### Package Manager
|
||||
|
@ -84,6 +80,7 @@ packages. ARM builds are also available for macOS, Linux, FreeBSD and OpenBSD.
|
|||
### Go
|
||||
|
||||
Or just install it with `go`:
|
||||
|
||||
```bash
|
||||
go install github.com/charmbracelet/glow@latest
|
||||
```
|
||||
|
@ -98,11 +95,10 @@ go build
|
|||
|
||||
[releases]: https://github.com/charmbracelet/glow/releases
|
||||
|
||||
|
||||
## The TUI
|
||||
|
||||
Simply run `glow` without arguments to start the textual user interface and
|
||||
browse local and stashed markdown. Glow will find local markdown files in the
|
||||
browse local. Glow will find local markdown files in the
|
||||
current directory and below or, if you’re in a Git repository, Glow will search
|
||||
the repo.
|
||||
|
||||
|
@ -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
|
||||
the hotkeys.
|
||||
|
||||
### Stashing
|
||||
|
||||
Glow works with the Charm Cloud to allow you to store any markdown files in
|
||||
your own private collection. You can stash a local document from the Glow TUI by
|
||||
pressing `s`.
|
||||
|
||||
Stashing is private, its contents will not be exposed publicly, and it's
|
||||
encrypted end-to-end. More on encryption below.
|
||||
|
||||
## The CLI
|
||||
|
||||
In addition to a TUI, Glow has a CLI for working with Markdown. To format a
|
||||
|
@ -138,18 +125,6 @@ glow github.com/charmbracelet/glow
|
|||
glow https://host.tld/file.md
|
||||
```
|
||||
|
||||
### Stashing
|
||||
|
||||
You can also stash documents from the CLI:
|
||||
|
||||
```bash
|
||||
glow stash README.md
|
||||
```
|
||||
|
||||
Then, when you run `glow` without arguments will you can browse through your
|
||||
stashed documents. This is a great way to keep track of things that you need to
|
||||
reference often.
|
||||
|
||||
### Word Wrapping
|
||||
|
||||
The `-w` flag lets you set a maximum width at which the output will be wrapped:
|
||||
|
@ -211,32 +186,19 @@ pager: true
|
|||
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
|
||||
|
||||
We’d love to hear your thoughts on this project. Feel free to drop us a note!
|
||||
|
||||
* [Twitter](https://twitter.com/charmcli)
|
||||
* [The Fediverse](https://mastodon.social/@charmcli)
|
||||
* [Discord](https://charm.sh/chat)
|
||||
- [Twitter](https://twitter.com/charmcli)
|
||||
- [The Fediverse](https://mastodon.social/@charmcli)
|
||||
- [Discord](https://charm.sh/chat)
|
||||
|
||||
## License
|
||||
|
||||
[MIT](https://github.com/charmbracelet/glow/raw/master/LICENSE)
|
||||
|
||||
***
|
||||
---
|
||||
|
||||
Part of [Charm](https://charm.sh).
|
||||
|
||||
|
|
|
@ -13,61 +13,35 @@ import (
|
|||
|
||||
const defaultConfig = `# style name or JSON path (default "auto")
|
||||
style: "auto"
|
||||
# show local files only; no network (TUI-mode only)
|
||||
local: false
|
||||
# mouse support (TUI-mode only)
|
||||
mouse: false
|
||||
# use pager to display markdown
|
||||
pager: false
|
||||
# word-wrap at width
|
||||
width: 80`
|
||||
width: 80
|
||||
`
|
||||
|
||||
func defaultConfigFile() string {
|
||||
scope := gap.NewScope(gap.User, "glow")
|
||||
path, _ := scope.ConfigPath("glow.yml")
|
||||
return path
|
||||
}
|
||||
|
||||
var configCmd = &cobra.Command{
|
||||
Use: "config",
|
||||
Hidden: false,
|
||||
Short: "Edit the glow config file",
|
||||
Long: paragraph(fmt.Sprintf("\n%s the glow config file. We’ll use EDITOR to determine which editor to use. If the config file doesn't exist, it will be created.", keyword("Edit"))),
|
||||
Example: paragraph("glow config\nglow config --config path/to/config.yml"),
|
||||
Args: cobra.NoArgs,
|
||||
SilenceUsage: true,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
if configFile == "" {
|
||||
scope := gap.NewScope(gap.User, "glow")
|
||||
|
||||
var err error
|
||||
configFile, err = scope.ConfigPath("glow.yml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if ext := path.Ext(configFile); ext != ".yaml" && ext != ".yml" {
|
||||
return fmt.Errorf("'%s' is not a supported config type: use '%s' or '%s'", ext, ".yaml", ".yml")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configFile); os.IsNotExist(err) {
|
||||
// File doesn't exist yet, create all necessary directories and
|
||||
// write the default config file
|
||||
if err := os.MkdirAll(filepath.Dir(configFile), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Create(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
if _, err := f.WriteString(defaultConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil { // some other error occurred
|
||||
Use: "config",
|
||||
Hidden: false,
|
||||
Short: "Edit the glow config file",
|
||||
Long: paragraph(fmt.Sprintf("\n%s the glow config file. We’ll use EDITOR to determine which editor to use. If the config file doesn't exist, it will be created.", keyword("Edit"))),
|
||||
Example: paragraph("glow config\nglow config --config path/to/config.yml"),
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := ensureConfigFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c, err := editor.Cmd("Glow", configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not edit %s: %w", configFile, err)
|
||||
return err
|
||||
}
|
||||
c.Stdin = os.Stdin
|
||||
c.Stdout = os.Stdout
|
||||
|
@ -80,3 +54,37 @@ var configCmd = &cobra.Command{
|
|||
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
|
||||
|
||||
go 1.17
|
||||
go 1.21.4
|
||||
|
||||
require (
|
||||
github.com/adrg/xdg v0.4.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/bubbles v0.15.0
|
||||
github.com/charmbracelet/bubbletea v0.25.0
|
||||
github.com/charmbracelet/charm v0.8.7
|
||||
github.com/charmbracelet/glamour v0.6.0
|
||||
github.com/charmbracelet/lipgloss v0.6.0
|
||||
github.com/charmbracelet/x/editor v0.0.0-20231116172829-450eedbca1ab
|
||||
github.com/caarlos0/env/v11 v11.0.1
|
||||
github.com/charmbracelet/bubbles v0.18.0
|
||||
github.com/charmbracelet/bubbletea v0.26.4
|
||||
github.com/charmbracelet/glamour v0.6.1-0.20230519131405-7528eaad6620
|
||||
github.com/charmbracelet/lipgloss v0.11.1-0.20240608174255-33b3263db7dd
|
||||
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/mattn/go-runewidth v0.0.15
|
||||
github.com/meowgorithm/babyenv v1.3.1
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/muesli/gitcha v0.2.0
|
||||
github.com/muesli/go-app-paths v0.2.2
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/muesli/termenv v0.15.2
|
||||
github.com/sahilm/fuzzy v0.1.0
|
||||
github.com/segmentio/ksuid v1.0.4
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/spf13/viper v1.14.0
|
||||
golang.org/x/sys v0.17.0
|
||||
golang.org/x/term v0.15.0
|
||||
golang.org/x/text v0.14.0
|
||||
github.com/sahilm/fuzzy v0.1.1
|
||||
github.com/spf13/cobra v1.7.0
|
||||
github.com/spf13/viper v1.15.0
|
||||
golang.org/x/sys v0.21.0
|
||||
golang.org/x/term v0.21.0
|
||||
golang.org/x/text v0.16.0
|
||||
)
|
||||
|
||||
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/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/calmh/randomart v1.1.0 // indirect
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
|
||||
github.com/charmbracelet/x/ansi v0.1.2 // indirect
|
||||
github.com/charmbracelet/x/input v0.1.2 // 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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/hashicorp/hcl v1.0.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/magiconair/properties v1.8.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.18 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.21 // indirect
|
||||
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.25 // 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/sasquatch v0.0.0-20200811221207-66979d92330a // 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.5 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // 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/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.4.1 // indirect
|
||||
github.com/yuin/goldmark v1.5.2 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.17.0 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
github.com/subosito/gotenv v1.4.2 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.5.4 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect
|
||||
golang.org/x/net v0.26.0 // 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/yaml.v2 v2.4.0 // 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"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/caarlos0/env/v11"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glow/ui"
|
||||
"github.com/charmbracelet/glow/utils"
|
||||
"github.com/meowgorithm/babyenv"
|
||||
"github.com/charmbracelet/log"
|
||||
gap "github.com/muesli/go-app-paths"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
@ -34,17 +34,21 @@ var (
|
|||
style string
|
||||
width uint
|
||||
showAllFiles bool
|
||||
localOnly bool
|
||||
mouse bool
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "glow [SOURCE|DIR]",
|
||||
Short: "Render markdown on the CLI, with pizzazz!",
|
||||
Long: paragraph(fmt.Sprintf("\nRender markdown on the CLI, %s!", keyword("with pizzazz"))),
|
||||
Use: "glow [SOURCE|DIR]",
|
||||
Short: "Render markdown on the CLI, with pizzazz!",
|
||||
Long: paragraph(
|
||||
fmt.Sprintf("\nRender markdown on the CLI, %s!", keyword("with pizzazz")),
|
||||
),
|
||||
SilenceErrors: false,
|
||||
SilenceUsage: false,
|
||||
SilenceUsage: 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)
|
||||
if err == nil && st.IsDir() {
|
||||
var src *source
|
||||
_ = filepath.Walk(arg, func(path string, info os.FileInfo, err error) error {
|
||||
_ = filepath.Walk(arg, func(path string, _ os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, v := range readmeNames {
|
||||
if strings.EqualFold(filepath.Base(path), v) {
|
||||
r, err := os.Open(path)
|
||||
|
@ -137,13 +144,12 @@ func sourceFromArg(arg string) (*source, error) {
|
|||
func validateOptions(cmd *cobra.Command) error {
|
||||
// grab config values from Viper
|
||||
width = viper.GetUint("width")
|
||||
localOnly = viper.GetBool("local")
|
||||
mouse = viper.GetBool("mouse")
|
||||
pager = viper.GetBool("pager")
|
||||
|
||||
// validate the glamour style
|
||||
style = viper.GetString("style")
|
||||
if style != "auto" && glamour.DefaultStyles[style] == nil {
|
||||
if style != glamour.AutoStyle && glamour.DefaultStyles[style] == nil {
|
||||
style = utils.ExpandPath(style)
|
||||
if _, err := os.Stat(style); os.IsNotExist(err) {
|
||||
return fmt.Errorf("Specified style does not exist: %s", style)
|
||||
|
@ -188,11 +194,6 @@ func stdinIsPipe() (bool, error) {
|
|||
}
|
||||
|
||||
func execute(cmd *cobra.Command, args []string) error {
|
||||
initConfig()
|
||||
if err := validateOptions(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if stdin is a pipe then use stdin for input. note that you can also
|
||||
// explicitly use a - to read from stdin.
|
||||
if yes, err := stdinIsPipe(); err != nil {
|
||||
|
@ -206,7 +207,7 @@ func execute(cmd *cobra.Command, args []string) error {
|
|||
switch len(args) {
|
||||
// TUI running on cwd
|
||||
case 0:
|
||||
return runTUI("", false)
|
||||
return runTUI("")
|
||||
|
||||
// TUI with possible dir argument
|
||||
case 1:
|
||||
|
@ -216,7 +217,7 @@ func execute(cmd *cobra.Command, args []string) error {
|
|||
if err == nil && info.IsDir() {
|
||||
p, err := filepath.Abs(args[0])
|
||||
if err == nil {
|
||||
return runTUI(p, false)
|
||||
return runTUI(p)
|
||||
}
|
||||
}
|
||||
fallthrough
|
||||
|
@ -259,16 +260,11 @@ func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error {
|
|||
baseURL = u.String() + "/"
|
||||
}
|
||||
|
||||
// initialize glamour
|
||||
var gs glamour.TermRendererOption
|
||||
if style == "auto" {
|
||||
gs = glamour.WithEnvironmentConfig()
|
||||
} else {
|
||||
gs = glamour.WithStylePath(style)
|
||||
}
|
||||
isCode := !utils.IsMarkdownFile(src.URL)
|
||||
|
||||
// initialize glamour
|
||||
r, err := glamour.NewTermRenderer(
|
||||
gs,
|
||||
utils.GlamourStyle(style, isCode),
|
||||
glamour.WithWordWrap(int(width)),
|
||||
glamour.WithBaseURL(baseURL),
|
||||
glamour.WithPreservedNewLines(),
|
||||
|
@ -277,23 +273,28 @@ func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error {
|
|||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
// trim lines
|
||||
lines := strings.Split(string(out), "\n")
|
||||
var cb strings.Builder
|
||||
lines := strings.Split(out, "\n")
|
||||
var content strings.Builder
|
||||
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
|
||||
if i+1 < len(lines) {
|
||||
cb.WriteString("\n")
|
||||
content.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
content := cb.String()
|
||||
|
||||
// display
|
||||
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, " ")
|
||||
c := exec.Command(pa[0], pa[1:]...) // nolint:gosec
|
||||
c.Stdin = strings.NewReader(content)
|
||||
c.Stdin = strings.NewReader(content.String())
|
||||
c.Stdout = os.Stdout
|
||||
return c.Run()
|
||||
}
|
||||
|
||||
fmt.Fprint(w, content)
|
||||
fmt.Fprint(w, content.String()) //nolint: errcheck
|
||||
return nil
|
||||
}
|
||||
|
||||
func runTUI(workingDirectory string, stashedOnly bool) error {
|
||||
func runTUI(workingDirectory string) error {
|
||||
// Read environment to get debugging stuff
|
||||
var cfg ui.Config
|
||||
if err := babyenv.Parse(&cfg); err != nil {
|
||||
cfg, err := env.ParseAs[ui.Config]()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing config: %v", err)
|
||||
}
|
||||
|
||||
// Log to file, if set
|
||||
if cfg.Logfile != "" {
|
||||
f, err := tea.LogToFile(cfg.Logfile, "glow")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close() //nolint:errcheck
|
||||
}
|
||||
|
||||
cfg.WorkingDirectory = workingDirectory
|
||||
cfg.DocumentTypes = ui.NewDocTypeSet()
|
||||
|
||||
cfg.ShowAllFiles = showAllFiles
|
||||
cfg.GlamourMaxWidth = width
|
||||
cfg.GlamourStyle = style
|
||||
cfg.EnableMouse = mouse
|
||||
|
||||
if stashedOnly {
|
||||
cfg.DocumentTypes.Add(ui.StashedDoc, ui.NewsDoc)
|
||||
} else if localOnly {
|
||||
cfg.DocumentTypes.Add(ui.LocalDoc)
|
||||
}
|
||||
|
||||
// Run Bubble Tea program
|
||||
if _, err := ui.NewProgram(cfg).Run(); err != nil {
|
||||
return err
|
||||
|
@ -351,12 +337,20 @@ func runTUI(workingDirectory string, stashedOnly bool) error {
|
|||
}
|
||||
|
||||
func main() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
closer, err := setupLog()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
_ = closer()
|
||||
os.Exit(1)
|
||||
}
|
||||
_ = closer()
|
||||
}
|
||||
|
||||
func init() {
|
||||
tryLoadConfigFromDefaultPlaces()
|
||||
if len(CommitSHA) >= 7 {
|
||||
vt := rootCmd.VersionTemplate()
|
||||
rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n")
|
||||
|
@ -366,62 +360,56 @@ func init() {
|
|||
}
|
||||
rootCmd.Version = Version
|
||||
|
||||
scope := gap.NewScope(gap.User, "glow")
|
||||
defaultConfigFile, _ := scope.ConfigPath("glow.yml")
|
||||
|
||||
// "Glow Classic" cli arguments
|
||||
rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default %s)", 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().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().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().MarkHidden("mouse")
|
||||
|
||||
// Config bindings
|
||||
_ = viper.BindPFlag("style", rootCmd.Flags().Lookup("style"))
|
||||
_ = viper.BindPFlag("width", rootCmd.Flags().Lookup("width"))
|
||||
_ = viper.BindPFlag("local", rootCmd.Flags().Lookup("local"))
|
||||
_ = viper.BindPFlag("debug", rootCmd.Flags().Lookup("debug"))
|
||||
_ = viper.BindPFlag("mouse", rootCmd.Flags().Lookup("mouse"))
|
||||
viper.SetDefault("style", "auto")
|
||||
viper.SetDefault("style", glamour.AutoStyle)
|
||||
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)
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
if configFile != "" {
|
||||
viper.SetConfigFile(configFile)
|
||||
} else {
|
||||
scope := gap.NewScope(gap.User, "glow")
|
||||
dirs, err := scope.ConfigDirs()
|
||||
if err != nil {
|
||||
fmt.Println("Can't retrieve default config. Please manually pass a config file with '--config'")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, v := range dirs {
|
||||
viper.AddConfigPath(v)
|
||||
}
|
||||
viper.SetConfigName("glow")
|
||||
viper.SetConfigType("yaml")
|
||||
func tryLoadConfigFromDefaultPlaces() {
|
||||
scope := gap.NewScope(gap.User, "glow")
|
||||
dirs, err := scope.ConfigDirs()
|
||||
if err != nil {
|
||||
fmt.Println("Could not load find config directory.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, v := range dirs {
|
||||
viper.AddConfigPath(v)
|
||||
}
|
||||
|
||||
viper.SetConfigName("glow")
|
||||
viper.SetConfigType("yaml")
|
||||
viper.SetEnvPrefix("glow")
|
||||
viper.AutomaticEnv()
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
fmt.Println("Error parsing config:", err)
|
||||
os.Exit(1)
|
||||
log.Warn("Could not parse configuration file", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fmt.Println("Using config file:", viper.ConfigFileUsed())
|
||||
if used := viper.ConfigFileUsed(); used != "" {
|
||||
log.Debug("Using configuration file", "path", viper.ConfigFileUsed())
|
||||
} 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
|
||||
|
||||
import . "github.com/charmbracelet/lipgloss" //nolint:revive
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
var (
|
||||
keyword = NewStyle().
|
||||
Foreground(Color("#04B575")).
|
||||
keyword = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#04B575")).
|
||||
Render
|
||||
|
||||
paragraph = NewStyle().
|
||||
paragraph = lipgloss.NewStyle().
|
||||
Width(78).
|
||||
Padding(0, 0, 0, 2).
|
||||
Render
|
||||
|
|
16
ui/config.go
16
ui/config.go
|
@ -12,19 +12,7 @@ type Config struct {
|
|||
// Which directory should we start from?
|
||||
WorkingDirectory string
|
||||
|
||||
// Which document types shall we show?
|
||||
DocumentTypes DocTypeSet
|
||||
|
||||
// For debugging the UI
|
||||
Logfile string `env:"GLOW_LOGFILE"`
|
||||
HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" default:"true"`
|
||||
GlamourEnabled bool `env:"GLOW_ENABLE_GLAMOUR" default:"true"`
|
||||
}
|
||||
|
||||
func (c Config) localOnly() bool {
|
||||
return c.DocumentTypes.Equals(NewDocTypeSet(LocalDoc))
|
||||
}
|
||||
|
||||
func (c Config) stashedOnly() bool {
|
||||
return c.DocumentTypes.Contains(StashedDoc) && !c.DocumentTypes.Contains(LocalDoc)
|
||||
HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" envDefault:"true"`
|
||||
GlamourEnabled bool `env:"GLOW_ENABLE_GLAMOUR" envDefault:"true"`
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
return editorFinishedMsg{err}
|
||||
}
|
||||
|
||||
editor, err := editor.Cmd("Glow", path)
|
||||
cmd, err := editor.Cmd("Glow", path)
|
||||
if err != nil {
|
||||
return func() tea.Msg {
|
||||
return errMsg{err}
|
||||
}
|
||||
return func() tea.Msg { return cb(err) }
|
||||
}
|
||||
return tea.ExecProcess(editor, cb)
|
||||
return tea.ExecProcess(cmd, cb)
|
||||
}
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
//go:build darwin
|
||||
// +build darwin
|
||||
|
||||
package ui
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
func ignorePatterns(m model) []string {
|
||||
func ignorePatterns(m commonModel) []string {
|
||||
return []string{
|
||||
filepath.Join(m.common.cfg.HomeDir, "Library"),
|
||||
m.common.cfg.Gopath,
|
||||
filepath.Join(m.cfg.HomeDir, "Library"),
|
||||
m.cfg.Gopath,
|
||||
"node_modules",
|
||||
".*",
|
||||
}
|
||||
|
|
|
@ -3,9 +3,9 @@
|
|||
|
||||
package ui
|
||||
|
||||
func ignorePatterns(m model) []string {
|
||||
func ignorePatterns(m commonModel) []string {
|
||||
return []string{
|
||||
m.common.cfg.Gopath,
|
||||
m.cfg.Gopath,
|
||||
"node_modules",
|
||||
".*",
|
||||
}
|
||||
|
|
126
ui/markdown.go
126
ui/markdown.go
|
@ -1,42 +1,18 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"log"
|
||||
"math"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/charmbracelet/charm"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/segmentio/ksuid"
|
||||
"golang.org/x/text/runes"
|
||||
"golang.org/x/text/transform"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// markdown wraps charm.Markdown.
|
||||
type markdown struct {
|
||||
docType DocType
|
||||
|
||||
// Stash identifier. This exists so we can keep track of documents stashed
|
||||
// in-session as they relate to their original, non-stashed counterparts.
|
||||
// All documents have a stashID, however when a document is stashed that
|
||||
// document inherits the stashID of the original.
|
||||
stashID ksuid.KSUID
|
||||
|
||||
// Unique identifier. Unlike the stash identifier, this value should always
|
||||
// be unique so we can confidently find it an operate on it (versus stashID,
|
||||
// which could match both an original or stashed document).
|
||||
uniqueID ksuid.KSUID
|
||||
|
||||
// Some of the document's original values before this document was stashed.
|
||||
// These are irrelevant if this document was not stashed in this session.
|
||||
originalDocType DocType
|
||||
originalTimestamp time.Time
|
||||
originalNote string
|
||||
|
||||
// Full path of a local markdown file. Only relevant to local documents and
|
||||
// those that have been stashed in this session.
|
||||
localPath string
|
||||
|
@ -46,102 +22,24 @@ type markdown struct {
|
|||
// field is ephemeral, and should only be referenced during filtering.
|
||||
filterValue string
|
||||
|
||||
charm.Markdown
|
||||
}
|
||||
|
||||
func (m *markdown) generateIDs() {
|
||||
if m.stashID.IsNil() {
|
||||
m.stashID = ksuid.New()
|
||||
}
|
||||
m.uniqueID = ksuid.New()
|
||||
}
|
||||
|
||||
// convertToStashed converts this document into its stashed state.
|
||||
func (m *markdown) convertToStashed() {
|
||||
if m.docType == ConvertedDoc {
|
||||
if debug {
|
||||
log.Println("not converting already converted document:", m)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
m.originalDocType = m.docType
|
||||
m.originalTimestamp = m.CreatedAt
|
||||
m.originalNote = m.Note
|
||||
|
||||
if m.docType == LocalDoc {
|
||||
m.Note = strings.Replace(path.Base(m.localPath), path.Ext(m.localPath), "", 1)
|
||||
}
|
||||
m.CreatedAt = time.Now()
|
||||
m.docType = ConvertedDoc
|
||||
}
|
||||
|
||||
// revert reverts this document from its stashed state.
|
||||
func (m *markdown) revertFromStashed() {
|
||||
if m.docType != ConvertedDoc {
|
||||
log.Printf("not reverting document of type %s: %v", m.docType, m)
|
||||
}
|
||||
|
||||
m.docType = m.originalDocType
|
||||
m.CreatedAt = m.originalTimestamp
|
||||
m.Note = m.originalNote
|
||||
Body string
|
||||
Note string
|
||||
Modtime time.Time
|
||||
}
|
||||
|
||||
// Generate the value we're doing to filter against.
|
||||
func (m *markdown) buildFilterValue() {
|
||||
note, err := normalize(m.Note)
|
||||
if err != nil {
|
||||
if debug {
|
||||
log.Printf("error normalizing '%s': %v", m.Note, err)
|
||||
}
|
||||
log.Error("error normalizing", "note", m.Note, "error", err)
|
||||
m.filterValue = m.Note
|
||||
}
|
||||
|
||||
m.filterValue = note
|
||||
}
|
||||
|
||||
// shouldSortAsLocal returns whether or not this markdown should be sorted as though
|
||||
// it's a local markdown document.
|
||||
func (m markdown) shouldSortAsLocal() bool {
|
||||
return m.docType == LocalDoc || m.docType == ConvertedDoc
|
||||
}
|
||||
|
||||
// Sort documents with local files first, then by date.
|
||||
type markdownsByLocalFirst []*markdown
|
||||
|
||||
func (m markdownsByLocalFirst) Len() int { return len(m) }
|
||||
func (m markdownsByLocalFirst) Swap(i, j int) { m[i], m[j] = m[j], m[i] }
|
||||
func (m markdownsByLocalFirst) Less(i, j int) bool {
|
||||
iIsLocal := m[i].shouldSortAsLocal()
|
||||
jIsLocal := m[j].shouldSortAsLocal()
|
||||
|
||||
// Local files (and files that used to be local) come first
|
||||
if iIsLocal && !jIsLocal {
|
||||
return true
|
||||
}
|
||||
if !iIsLocal && jIsLocal {
|
||||
return false
|
||||
}
|
||||
|
||||
// If both are local files, sort by filename. Note that we should never
|
||||
// hit equality here since two files can't have the same path.
|
||||
if iIsLocal && jIsLocal {
|
||||
return strings.Compare(m[i].localPath, m[j].localPath) == -1
|
||||
}
|
||||
|
||||
// Neither are local files so sort by date descending
|
||||
if !m[i].CreatedAt.Equal(m[j].CreatedAt) {
|
||||
return m[i].CreatedAt.After(m[j].CreatedAt)
|
||||
}
|
||||
|
||||
// If the times also match, sort by 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 {
|
||||
return relativeTime(m.CreatedAt)
|
||||
return relativeTime(m.Modtime)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func relativeTime(then time.Time) string {
|
||||
now := time.Now()
|
||||
|
|
390
ui/pager.go
390
ui/pager.go
|
@ -2,25 +2,28 @@ package ui
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/charm"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glow/utils"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/log"
|
||||
runewidth "github.com/mattn/go-runewidth"
|
||||
"github.com/muesli/reflow/ansi"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
const statusBarHeight = 1
|
||||
const (
|
||||
statusBarHeight = 1
|
||||
lineNumberWidth = 4
|
||||
)
|
||||
|
||||
var (
|
||||
pagerHelpHeight int
|
||||
|
@ -28,11 +31,7 @@ var (
|
|||
mintGreen = lipgloss.AdaptiveColor{Light: "#89F0CB", Dark: "#89F0CB"}
|
||||
darkGreen = lipgloss.AdaptiveColor{Light: "#1C8760", Dark: "#1C8760"}
|
||||
|
||||
noteHeading = lipgloss.NewStyle().
|
||||
Foreground(cream).
|
||||
Background(green).
|
||||
Padding(0, 1).
|
||||
SetString("Set Memo")
|
||||
lineNumberFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
|
||||
|
||||
statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
|
||||
statusBarBg = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"}
|
||||
|
@ -52,21 +51,11 @@ var (
|
|||
Background(lipgloss.AdaptiveColor{Light: "#DCDCDC", Dark: "#323232"}).
|
||||
Render
|
||||
|
||||
statusBarStashDotStyle = lipgloss.NewStyle().
|
||||
Foreground(green).
|
||||
Background(statusBarBg).
|
||||
Render
|
||||
|
||||
statusBarMessageStyle = lipgloss.NewStyle().
|
||||
Foreground(mintGreen).
|
||||
Background(darkGreen).
|
||||
Render
|
||||
|
||||
statusBarMessageStashIconStyle = lipgloss.NewStyle().
|
||||
Foreground(mintGreen).
|
||||
Background(darkGreen).
|
||||
Render
|
||||
|
||||
statusBarMessageScrollPosStyle = lipgloss.NewStyle().
|
||||
Foreground(mintGreen).
|
||||
Background(darkGreen).
|
||||
|
@ -82,47 +71,27 @@ var (
|
|||
Background(lipgloss.AdaptiveColor{Light: "#f2f2f2", Dark: "#1B1B1B"}).
|
||||
Render
|
||||
|
||||
spinnerStyle = lipgloss.NewStyle().
|
||||
Foreground(statusBarNoteFg).
|
||||
Background(statusBarBg)
|
||||
|
||||
pagerNoteInputPromptStyle = lipgloss.NewStyle().
|
||||
Foreground(darkGray).
|
||||
Background(yellowGreen).
|
||||
Padding(0, 1)
|
||||
|
||||
pagerNoteInputStyle = lipgloss.NewStyle().
|
||||
Foreground(darkGray).
|
||||
Background(yellowGreen)
|
||||
|
||||
pagerNoteInputCursorStyle = lipgloss.NewStyle().
|
||||
Foreground(fuchsia)
|
||||
lineNumberStyle = lipgloss.NewStyle().
|
||||
Foreground(lineNumberFg).
|
||||
Render
|
||||
)
|
||||
|
||||
type (
|
||||
contentRenderedMsg string
|
||||
noteSavedMsg *charm.Markdown
|
||||
)
|
||||
|
||||
type pagerState int
|
||||
|
||||
const (
|
||||
pagerStateBrowse pagerState = iota
|
||||
pagerStateSetNote
|
||||
pagerStateStashing
|
||||
pagerStateStashSuccess
|
||||
pagerStateStatusMessage
|
||||
)
|
||||
|
||||
type pagerModel struct {
|
||||
common *commonModel
|
||||
viewport viewport.Model
|
||||
state pagerState
|
||||
showHelp bool
|
||||
textInput textinput.Model
|
||||
|
||||
spinner spinner.Model
|
||||
spinnerStart time.Time
|
||||
common *commonModel
|
||||
viewport viewport.Model
|
||||
state pagerState
|
||||
showHelp bool
|
||||
|
||||
statusMessage string
|
||||
statusMessageTimer *time.Timer
|
||||
|
@ -130,10 +99,6 @@ type pagerModel struct {
|
|||
// Current document being rendered, sans-glamour rendering. We cache
|
||||
// it here so we can re-render it on resize.
|
||||
currentDocument markdown
|
||||
|
||||
// Newly stashed markdown. We store it here temporarily so we can replace
|
||||
// currentDocument above after a stash.
|
||||
stashedDocument *markdown
|
||||
}
|
||||
|
||||
func newPagerModel(common *commonModel) pagerModel {
|
||||
|
@ -142,34 +107,16 @@ func newPagerModel(common *commonModel) pagerModel {
|
|||
vp.YPosition = 0
|
||||
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{
|
||||
common: common,
|
||||
state: pagerStateBrowse,
|
||||
textInput: ti,
|
||||
viewport: vp,
|
||||
spinner: sp,
|
||||
common: common,
|
||||
state: pagerStateBrowse,
|
||||
viewport: vp,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *pagerModel) setSize(w, h int) {
|
||||
m.viewport.Width = w
|
||||
m.viewport.Height = h - statusBarHeight
|
||||
m.textInput.Width = w -
|
||||
ansi.PrintableRuneWidth(noteHeading.String()) -
|
||||
ansi.PrintableRuneWidth(m.textInput.Prompt) - 1
|
||||
|
||||
if m.showHelp {
|
||||
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
|
||||
// that the the returned command should be sent back the through the pager
|
||||
// update function.
|
||||
func (m *pagerModel) showStatusMessage(statusMessage string) tea.Cmd {
|
||||
func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd {
|
||||
// Show a success message to the user
|
||||
m.state = pagerStateStatusMessage
|
||||
m.statusMessage = statusMessage
|
||||
m.statusMessage = msg.message
|
||||
if m.statusMessageTimer != nil {
|
||||
m.statusMessageTimer.Stop()
|
||||
}
|
||||
|
@ -216,7 +168,6 @@ func (m *pagerModel) unload() {
|
|||
m.state = pagerStateBrowse
|
||||
m.viewport.SetContent("")
|
||||
m.viewport.YOffset = 0
|
||||
m.textInput.Reset()
|
||||
}
|
||||
|
||||
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) {
|
||||
case tea.KeyMsg:
|
||||
switch m.state {
|
||||
case pagerStateSetNote:
|
||||
switch msg.String() {
|
||||
case keyEsc:
|
||||
switch msg.String() {
|
||||
case "q", keyEsc:
|
||||
if m.state != pagerStateBrowse {
|
||||
m.state = pagerStateBrowse
|
||||
return m, nil
|
||||
case keyEnter:
|
||||
var cmd tea.Cmd
|
||||
if m.textInput.Value() != m.currentDocument.Note { // don't update if the note didn't change
|
||||
m.currentDocument.Note = m.textInput.Value() // update optimistically
|
||||
cmd = saveDocumentNote(m.common.cc, m.currentDocument.ID, m.currentDocument.Note)
|
||||
}
|
||||
m.state = pagerStateBrowse
|
||||
m.textInput.Reset()
|
||||
return m, cmd
|
||||
}
|
||||
default:
|
||||
switch msg.String() {
|
||||
case "q", keyEsc:
|
||||
if m.state != pagerStateBrowse {
|
||||
m.state = pagerStateBrowse
|
||||
return m, nil
|
||||
}
|
||||
case "home", "g":
|
||||
m.viewport.GotoTop()
|
||||
if m.viewport.HighPerformanceRendering {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
case "end", "G":
|
||||
m.viewport.GotoBottom()
|
||||
if m.viewport.HighPerformanceRendering {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
case "m":
|
||||
isStashed := m.currentDocument.docType == StashedDoc ||
|
||||
m.currentDocument.docType == ConvertedDoc
|
||||
|
||||
// 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 "home", "g":
|
||||
m.viewport.GotoTop()
|
||||
if m.viewport.HighPerformanceRendering {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
case "end", "G":
|
||||
m.viewport.GotoBottom()
|
||||
if m.viewport.HighPerformanceRendering {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
}
|
||||
|
||||
case spinner.TickMsg:
|
||||
spinnerMinTimeout := m.spinnerStart.
|
||||
Add(spinnerVisibilityTimeout).
|
||||
Add(spinnerMinLifetime)
|
||||
case "e":
|
||||
return m, openEditor(m.currentDocument.localPath)
|
||||
|
||||
if m.state == pagerStateStashing || time.Now().Before(spinnerMinTimeout) {
|
||||
// We're either still stashing or we haven't reached the spinner's
|
||||
// full lifetime. In either case we need to spin the spinner
|
||||
// irrespective of it's more fine-grained visibility rules.
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
} else if m.state == pagerStateStashSuccess {
|
||||
// Successful stash. Stop spinning and update accordingly.
|
||||
m.state = pagerStateBrowse
|
||||
m.currentDocument = *m.stashedDocument
|
||||
m.stashedDocument = nil
|
||||
cmds = append(cmds, m.showStatusMessage("Stashed!"))
|
||||
case "c":
|
||||
// Copy using OSC 52
|
||||
termenv.Copy(m.currentDocument.Body)
|
||||
// Copy using native system clipboard
|
||||
_ = clipboard.WriteAll(m.currentDocument.Body)
|
||||
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Copied contents", false}))
|
||||
|
||||
case "?":
|
||||
m.toggleHelp()
|
||||
if m.viewport.HighPerformanceRendering {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
case editMardownMsg:
|
||||
return m, openEditor(msg.md.localPath)
|
||||
|
||||
// We've finished editing the document, potentially making changes. Let's
|
||||
// retrieve the latest version of the document so that we display
|
||||
// up-to-date contents.
|
||||
case editorFinishedMsg:
|
||||
return m, loadLocalMarkdown(&m.currentDocument)
|
||||
|
||||
// We've reveived terminal dimensions, either for the first time or
|
||||
// We've received terminal dimensions, either for the first time or
|
||||
// after a resize
|
||||
case tea.WindowSizeMsg:
|
||||
return m, renderWithGlamour(m, m.currentDocument.Body)
|
||||
|
||||
case stashSuccessMsg:
|
||||
// Stashing was successful. Convert the loaded document to a stashed
|
||||
// one and show a status message. Note that we're also handling this
|
||||
// message in the main update function where we're adding this stashed
|
||||
// item to the stash listing.
|
||||
m.state = pagerStateStashSuccess
|
||||
|
||||
if !m.spinnerVisible() {
|
||||
// The spinner has finished spinning, so tell the user the stash
|
||||
// was successful.
|
||||
m.state = pagerStateBrowse
|
||||
m.currentDocument = markdown(msg)
|
||||
cmds = append(cmds, m.showStatusMessage("Stashed!"))
|
||||
} else {
|
||||
// The spinner is still spinning, so just take note of the newly
|
||||
// stashed document for now.
|
||||
md := markdown(msg)
|
||||
m.stashedDocument = &md
|
||||
}
|
||||
|
||||
case stashFailMsg:
|
||||
delete(m.common.filesStashed, msg.markdown.stashID)
|
||||
|
||||
case statusMessageTimeoutMsg:
|
||||
m.state = pagerStateBrowse
|
||||
}
|
||||
|
||||
switch m.state {
|
||||
case pagerStateSetNote:
|
||||
m.textInput, cmd = m.textInput.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
default:
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// spinnerVisible returns whether or not the spinner should be drawn.
|
||||
func (m pagerModel) spinnerVisible() bool {
|
||||
windowStart := m.spinnerStart.Add(spinnerVisibilityTimeout)
|
||||
windowEnd := windowStart.Add(spinnerMinLifetime)
|
||||
now := time.Now()
|
||||
return now.After(windowStart) && now.Before(windowEnd)
|
||||
}
|
||||
|
||||
func (m pagerModel) View() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprint(&b, m.viewport.View()+"\n")
|
||||
|
||||
// Footer
|
||||
switch m.state {
|
||||
case pagerStateSetNote:
|
||||
m.setNoteView(&b)
|
||||
default:
|
||||
m.statusBarView(&b)
|
||||
}
|
||||
m.statusBarView(&b)
|
||||
|
||||
if m.showHelp {
|
||||
fmt.Fprint(&b, "\n"+m.helpView())
|
||||
|
@ -440,11 +264,10 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
|
|||
percentToStringMagnitude float64 = 100.0
|
||||
)
|
||||
|
||||
isStashed := m.currentDocument.docType == StashedDoc || m.currentDocument.docType == ConvertedDoc
|
||||
showStatusMessage := m.state == pagerStateStatusMessage
|
||||
|
||||
// Logo
|
||||
logo := glowLogoView(" Glow ")
|
||||
logo := glowLogoView()
|
||||
|
||||
// Scroll percent
|
||||
percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent()))
|
||||
|
@ -463,34 +286,16 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
|
|||
helpNote = statusBarHelpStyle(" ? Help ")
|
||||
}
|
||||
|
||||
// Status indicator; spinner or stash dot
|
||||
var statusIndicator string
|
||||
if m.state == pagerStateStashing || m.state == pagerStateStashSuccess {
|
||||
var spinner string
|
||||
if m.spinnerVisible() {
|
||||
spinner = m.spinner.View()
|
||||
}
|
||||
statusIndicator = statusBarNoteStyle(" ") + spinner
|
||||
} else if isStashed && showStatusMessage {
|
||||
statusIndicator = statusBarMessageStashIconStyle(" " + pagerStashIcon)
|
||||
} else if isStashed {
|
||||
statusIndicator = statusBarStashDotStyle(" " + pagerStashIcon)
|
||||
}
|
||||
|
||||
// Note
|
||||
var note string
|
||||
if showStatusMessage {
|
||||
note = m.statusMessage
|
||||
} else {
|
||||
note = m.currentDocument.Note
|
||||
if len(note) == 0 {
|
||||
note = "(No memo)"
|
||||
}
|
||||
}
|
||||
note = truncate.StringWithTail(" "+note+" ", uint(max(0,
|
||||
m.common.width-
|
||||
ansi.PrintableRuneWidth(logo)-
|
||||
ansi.PrintableRuneWidth(statusIndicator)-
|
||||
ansi.PrintableRuneWidth(scrollPercent)-
|
||||
ansi.PrintableRuneWidth(helpNote),
|
||||
)), ellipsis)
|
||||
|
@ -504,7 +309,6 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
|
|||
padding := max(0,
|
||||
m.common.width-
|
||||
ansi.PrintableRuneWidth(logo)-
|
||||
ansi.PrintableRuneWidth(statusIndicator)-
|
||||
ansi.PrintableRuneWidth(note)-
|
||||
ansi.PrintableRuneWidth(scrollPercent)-
|
||||
ansi.PrintableRuneWidth(helpNote),
|
||||
|
@ -516,9 +320,8 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
|
|||
emptySpace = statusBarNoteStyle(emptySpace)
|
||||
}
|
||||
|
||||
fmt.Fprintf(b, "%s%s%s%s%s%s",
|
||||
fmt.Fprintf(b, "%s%s%s%s%s",
|
||||
logo,
|
||||
statusIndicator,
|
||||
note,
|
||||
emptySpace,
|
||||
scrollPercent,
|
||||
|
@ -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) {
|
||||
memoOrStash := "m set memo"
|
||||
if m.common.authStatus == authOK && m.currentDocument.docType == LocalDoc {
|
||||
memoOrStash = "s stash this document"
|
||||
}
|
||||
|
||||
editOrBlank := "e edit this document"
|
||||
if m.currentDocument.docType != LocalDoc || m.currentDocument.localPath == "" {
|
||||
editOrBlank = ""
|
||||
}
|
||||
edit := "e edit this document"
|
||||
|
||||
col1 := []string{
|
||||
"g/home go to top",
|
||||
"G/end go to bottom",
|
||||
"c copy contents",
|
||||
editOrBlank,
|
||||
memoOrStash,
|
||||
edit,
|
||||
"esc back to files",
|
||||
"q quit",
|
||||
}
|
||||
|
||||
if m.currentDocument.docType == NewsDoc {
|
||||
deleteFromStringSlice(col1, 3)
|
||||
}
|
||||
|
||||
s += "\n"
|
||||
s += "k/↑ up " + col1[0] + "\n"
|
||||
s += "j/↓ down " + col1[1] + "\n"
|
||||
|
@ -591,9 +376,7 @@ func renderWithGlamour(m pagerModel, md string) tea.Cmd {
|
|||
return func() tea.Msg {
|
||||
s, err := glamourRender(m, md)
|
||||
if err != nil {
|
||||
if debug {
|
||||
log.Println("error rendering with Glamour:", err)
|
||||
}
|
||||
log.Error("error rendering with Glamour", "error", err)
|
||||
return errMsg{err}
|
||||
}
|
||||
return contentRenderedMsg(s)
|
||||
|
@ -602,53 +385,58 @@ func renderWithGlamour(m pagerModel, md string) tea.Cmd {
|
|||
|
||||
// This is where the magic happens.
|
||||
func glamourRender(m pagerModel, markdown string) (string, error) {
|
||||
trunc := lipgloss.NewStyle().MaxWidth(m.viewport.Width - lineNumberWidth).Render
|
||||
|
||||
if !config.GlamourEnabled {
|
||||
return markdown, nil
|
||||
}
|
||||
|
||||
// initialize glamour
|
||||
var gs glamour.TermRendererOption
|
||||
if m.common.cfg.GlamourStyle == "auto" {
|
||||
gs = glamour.WithAutoStyle()
|
||||
} else {
|
||||
gs = glamour.WithStylePath(m.common.cfg.GlamourStyle)
|
||||
isCode := !utils.IsMarkdownFile(m.currentDocument.Note)
|
||||
width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width))
|
||||
if isCode {
|
||||
width = 0
|
||||
}
|
||||
|
||||
width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width))
|
||||
r, err := glamour.NewTermRenderer(
|
||||
gs,
|
||||
utils.GlamourStyle(m.common.cfg.GlamourStyle, isCode),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if isCode {
|
||||
markdown = utils.WrapCodeBlock(markdown, filepath.Ext(m.currentDocument.Note))
|
||||
}
|
||||
|
||||
out, err := r.Render(markdown)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if isCode {
|
||||
out = strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// trim lines
|
||||
lines := strings.Split(out, "\n")
|
||||
|
||||
var content string
|
||||
var content strings.Builder
|
||||
for i, s := range lines {
|
||||
content += strings.TrimSpace(s)
|
||||
if isCode {
|
||||
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
|
||||
if i+1 < len(lines) {
|
||||
content += "\n"
|
||||
content.WriteRune('\n')
|
||||
}
|
||||
}
|
||||
|
||||
return content, nil
|
||||
return content.String(), nil
|
||||
}
|
||||
|
||||
// ETC
|
||||
|
||||
// Note: this runs in linear time; O(n).
|
||||
func deleteFromStringSlice(a []string, i int) []string {
|
||||
copy(a[i:], a[i+1:])
|
||||
a[len(a)-1] = ""
|
||||
return a[:len(a)-1]
|
||||
}
|
||||
type editMardownMsg struct{ md *markdown }
|
||||
|
|
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)
|
||||
}
|
||||
|
||||
// Help for when we're interacting with a single document
|
||||
switch m.selectionState {
|
||||
case selectionSettingNote:
|
||||
return m.renderHelp([]string{"enter", "confirm", "esc", "cancel"}, []string{"q", "quit"})
|
||||
case selectionPromptingDelete:
|
||||
return m.renderHelp([]string{"y", "delete", "n", "cancel"}, []string{"q", "quit"})
|
||||
}
|
||||
|
||||
var (
|
||||
isStashed bool
|
||||
isStashable bool
|
||||
isEditable bool
|
||||
navHelp []string
|
||||
filterHelp []string
|
||||
|
@ -119,13 +109,6 @@ func (m stashModel) helpView() (string, int) {
|
|||
appHelp []string
|
||||
)
|
||||
|
||||
if numDocs > 0 {
|
||||
md := m.selectedMarkdown()
|
||||
isStashed = md != nil && md.docType == StashedDoc
|
||||
isStashable = md != nil && md.docType == LocalDoc && m.online()
|
||||
isEditable = md != nil && md.docType == LocalDoc && md.localPath != ""
|
||||
}
|
||||
|
||||
if numDocs > 0 && m.showFullHelp {
|
||||
navHelp = []string{"enter", "open", "j/k ↑/↓", "choose"}
|
||||
}
|
||||
|
@ -143,16 +126,13 @@ func (m stashModel) helpView() (string, int) {
|
|||
}
|
||||
|
||||
// If we're browsing a filtered set
|
||||
if m.filterState == filterApplied {
|
||||
filterHelp = []string{"/", "edit search", "esc", "clear search"}
|
||||
if m.filterApplied() {
|
||||
filterHelp = []string{"/", "edit search", "esc", "clear filter"}
|
||||
} else {
|
||||
filterHelp = []string{"/", "find"}
|
||||
}
|
||||
|
||||
if isStashed {
|
||||
selectionHelp = []string{"x", "delete", "m", "set memo"}
|
||||
} else if isStashable {
|
||||
selectionHelp = []string{"s", "stash"}
|
||||
if m.stashFullyLoaded {
|
||||
filterHelp = append(filterHelp, "t", "team filter")
|
||||
}
|
||||
}
|
||||
|
||||
if isEditable {
|
||||
|
@ -181,13 +161,15 @@ func (m stashModel) helpView() (string, int) {
|
|||
return m.renderHelp(navHelp, filterHelp, selectionHelp, editHelp, sectionHelp, appHelp)
|
||||
}
|
||||
|
||||
const minHelpViewHeight = 5
|
||||
|
||||
// renderHelp returns the rendered help view and associated line height for
|
||||
// the given groups of help items.
|
||||
func (m stashModel) renderHelp(groups ...[]string) (string, int) {
|
||||
if m.showFullHelp {
|
||||
str := m.fullHelpView(groups...)
|
||||
numLines := strings.Count(str, "\n") + 1
|
||||
return str, numLines
|
||||
return str, max(numLines, minHelpViewHeight)
|
||||
}
|
||||
return m.miniHelpView(concatStringSlices(groups...)...), 1
|
||||
}
|
||||
|
@ -219,14 +201,8 @@ func (m stashModel) miniHelpView(entries ...string) string {
|
|||
k := entries[i]
|
||||
v := entries[i+1]
|
||||
|
||||
switch k {
|
||||
case "s":
|
||||
k = greenFg(k)
|
||||
v = semiDimGreenFg(v)
|
||||
default:
|
||||
k = grayFg(k)
|
||||
v = midGrayFg(v)
|
||||
}
|
||||
k = grayFg(k)
|
||||
v = midGrayFg(v)
|
||||
|
||||
next = fmt.Sprintf("%s %s", k, v)
|
||||
|
||||
|
|
134
ui/stashitem.go
134
ui/stashitem.go
|
@ -2,47 +2,31 @@ package ui
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/reflow/ansi"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sahilm/fuzzy"
|
||||
)
|
||||
|
||||
const (
|
||||
verticalLine = "│"
|
||||
noMemoTitle = "No Memo"
|
||||
fileListingStashIcon = "• "
|
||||
)
|
||||
|
||||
func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) {
|
||||
var (
|
||||
truncateTo = uint(m.common.width - stashViewHorizontalPadding*2)
|
||||
gutter string
|
||||
title = md.Note
|
||||
date = md.relativeTime()
|
||||
icon = ""
|
||||
truncateTo = uint(m.common.width - stashViewHorizontalPadding*2)
|
||||
gutter string
|
||||
title = truncate.StringWithTail(md.Note, truncateTo, ellipsis)
|
||||
date = md.relativeTime()
|
||||
editedBy = ""
|
||||
hasEditedBy = false
|
||||
icon = ""
|
||||
separator = ""
|
||||
)
|
||||
|
||||
switch md.docType {
|
||||
case NewsDoc:
|
||||
if title == "" {
|
||||
title = "News"
|
||||
} else {
|
||||
title = truncate.StringWithTail(title, truncateTo, ellipsis)
|
||||
}
|
||||
case StashedDoc, ConvertedDoc:
|
||||
icon = fileListingStashIcon
|
||||
if title == "" {
|
||||
title = noMemoTitle
|
||||
}
|
||||
title = truncate.StringWithTail(title, truncateTo-uint(ansi.PrintableRuneWidth(icon)), ellipsis)
|
||||
default:
|
||||
title = truncate.StringWithTail(title, truncateTo, ellipsis)
|
||||
}
|
||||
|
||||
isSelected := index == m.cursor()
|
||||
isFiltering := m.filterState == filtering
|
||||
singleFilteredItem := isFiltering && len(m.getVisibleMarkdowns()) == 1
|
||||
|
@ -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.
|
||||
if isSelected && !isFiltering || singleFilteredItem {
|
||||
// Selected item
|
||||
|
||||
switch m.selectionState {
|
||||
case selectionPromptingDelete:
|
||||
gutter = faintRedFg(verticalLine)
|
||||
icon = faintRedFg(icon)
|
||||
title = redFg(title)
|
||||
date = faintRedFg(date)
|
||||
case selectionSettingNote:
|
||||
gutter = dullYellowFg(verticalLine)
|
||||
icon = ""
|
||||
title = m.noteInput.View()
|
||||
date = dullYellowFg(date)
|
||||
default:
|
||||
if m.common.latestFileStashed == md.stashID &&
|
||||
m.statusMessage == 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 {
|
||||
if m.statusMessage == stashingStatusMessage {
|
||||
gutter = greenFg(verticalLine)
|
||||
icon = dimGreenFg(icon)
|
||||
title = greenFg(title)
|
||||
date = semiDimGreenFg(date)
|
||||
} else if md.docType == NewsDoc {
|
||||
if isFiltering && m.filterInput.Value() == "" {
|
||||
title = dimIndigoFg(title)
|
||||
date = dimSubtleIndigoFg(date)
|
||||
editedBy = semiDimGreenFg(editedBy)
|
||||
separator = semiDimGreenFg(separator)
|
||||
} else {
|
||||
gutter = dullFuchsiaFg(verticalLine)
|
||||
if m.currentSection().key == filterSection &&
|
||||
m.filterState == filterApplied || singleFilteredItem {
|
||||
s := lipgloss.NewStyle().Foreground(fuchsia)
|
||||
title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true))
|
||||
} else {
|
||||
s := lipgloss.NewStyle().Foreground(indigo)
|
||||
title = styleFilteredText(title, m.filterInput.Value(), s, s.Copy().Underline(true))
|
||||
date = subtleIndigoFg(date)
|
||||
title = fuchsiaFg(title)
|
||||
icon = fuchsiaFg(icon)
|
||||
}
|
||||
date = dimFuchsiaFg(date)
|
||||
editedBy = dimDullFuchsiaFg(editedBy)
|
||||
separator = dullFuchsiaFg(separator)
|
||||
}
|
||||
} else {
|
||||
gutter = " "
|
||||
if m.statusMessage == stashingStatusMessage {
|
||||
icon = dimGreenFg(icon)
|
||||
title = greenFg(title)
|
||||
date = semiDimGreenFg(date)
|
||||
editedBy = semiDimGreenFg(editedBy)
|
||||
separator = semiDimGreenFg(separator)
|
||||
} else if isFiltering && m.filterInput.Value() == "" {
|
||||
icon = dimGreenFg(icon)
|
||||
if title == noMemoTitle {
|
||||
title = dimBrightGrayFg(title)
|
||||
} else {
|
||||
title = dimNormalFg(title)
|
||||
}
|
||||
title = dimNormalFg(title)
|
||||
date = dimBrightGrayFg(date)
|
||||
editedBy = dimBrightGrayFg(editedBy)
|
||||
separator = dimBrightGrayFg(separator)
|
||||
} else {
|
||||
icon = greenFg(icon)
|
||||
if title == noMemoTitle {
|
||||
title = brightGrayFg(title)
|
||||
} else {
|
||||
s := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"})
|
||||
title = styleFilteredText(title, m.filterInput.Value(), s, s.Copy().Underline(true))
|
||||
}
|
||||
date = brightGrayFg(date)
|
||||
|
||||
s := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"})
|
||||
title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true))
|
||||
date = grayFg(date)
|
||||
editedBy = midGrayFg(editedBy)
|
||||
separator = brightGrayFg(separator)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(b, "%s %s%s\n", gutter, icon, title)
|
||||
fmt.Fprintf(b, "%s %s%s%s%s\n", gutter, icon, separator, separator, title)
|
||||
fmt.Fprintf(b, "%s %s", gutter, date)
|
||||
if hasEditedBy {
|
||||
fmt.Fprintf(b, " %s", editedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle lipgloss.Style) string {
|
||||
b := strings.Builder{}
|
||||
|
||||
normalizedHay, err := normalize(haystack)
|
||||
if err != nil && debug {
|
||||
log.Printf("error normalizing '%s': %v", haystack, err)
|
||||
if err != nil {
|
||||
log.Error("error normalizing", "haystack", haystack, "error", err)
|
||||
}
|
||||
|
||||
matches := fuzzy.Find(needles, []string{normalizedHay})
|
||||
|
|
110
ui/styles.go
110
ui/styles.go
|
@ -1,84 +1,46 @@
|
|||
package ui
|
||||
|
||||
import . "github.com/charmbracelet/lipgloss" //nolint: revive
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Colors.
|
||||
var (
|
||||
normal = AdaptiveColor{Light: "#1A1A1A", Dark: "#dddddd"}
|
||||
normalDim = AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}
|
||||
gray = AdaptiveColor{Light: "#909090", Dark: "#626262"}
|
||||
midGray = AdaptiveColor{Light: "#B2B2B2", Dark: "#4A4A4A"}
|
||||
darkGray = AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}
|
||||
brightGray = AdaptiveColor{Light: "#847A85", Dark: "#979797"}
|
||||
dimBrightGray = AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"}
|
||||
indigo = AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"}
|
||||
dimIndigo = AdaptiveColor{Light: "#9498FF", Dark: "#494690"}
|
||||
subtleIndigo = AdaptiveColor{Light: "#7D79F6", Dark: "#514DC1"}
|
||||
dimSubtleIndigo = AdaptiveColor{Light: "#BBBDFF", Dark: "#383584"}
|
||||
cream = AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"}
|
||||
yellowGreen = AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}
|
||||
dullYellowGreen = AdaptiveColor{Light: "#6BCB94", Dark: "#9BA92F"}
|
||||
fuchsia = AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}
|
||||
dimFuchsia = AdaptiveColor{Light: "#F1A8FF", Dark: "#99519E"}
|
||||
dullFuchsia = AdaptiveColor{Dark: "#AD58B4", Light: "#F793FF"}
|
||||
dimDullFuchsia = AdaptiveColor{Light: "#F6C9FF", Dark: "#6B3A6F"}
|
||||
green = Color("#04B575")
|
||||
red = AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"}
|
||||
faintRed = AdaptiveColor{Light: "#FF6F91", Dark: "#C74665"}
|
||||
|
||||
semiDimGreen = AdaptiveColor{Light: "#35D79C", Dark: "#036B46"}
|
||||
dimGreen = AdaptiveColor{Light: "#72D2B0", Dark: "#0B5137"}
|
||||
normalDim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}
|
||||
gray = lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"}
|
||||
midGray = lipgloss.AdaptiveColor{Light: "#B2B2B2", Dark: "#4A4A4A"}
|
||||
darkGray = lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}
|
||||
brightGray = lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}
|
||||
dimBrightGray = lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"}
|
||||
cream = lipgloss.AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"}
|
||||
yellowGreen = lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}
|
||||
fuchsia = lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}
|
||||
dimFuchsia = lipgloss.AdaptiveColor{Light: "#F1A8FF", Dark: "#99519E"}
|
||||
dullFuchsia = lipgloss.AdaptiveColor{Dark: "#AD58B4", Light: "#F793FF"}
|
||||
dimDullFuchsia = lipgloss.AdaptiveColor{Light: "#F6C9FF", Dark: "#7B4380"}
|
||||
green = lipgloss.Color("#04B575")
|
||||
red = lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"}
|
||||
semiDimGreen = lipgloss.AdaptiveColor{Light: "#35D79C", Dark: "#036B46"}
|
||||
dimGreen = lipgloss.AdaptiveColor{Light: "#72D2B0", Dark: "#0B5137"}
|
||||
)
|
||||
|
||||
// Ulimately, we'll transition to named styles.
|
||||
// nolint:deadcode,unused,varcheck
|
||||
var (
|
||||
normalFg = NewStyle().Foreground(normal).Render
|
||||
dimNormalFg = NewStyle().Foreground(normalDim).Render
|
||||
|
||||
brightGrayFg = NewStyle().Foreground(brightGray).Render
|
||||
dimBrightGrayFg = NewStyle().Foreground(dimBrightGray).Render
|
||||
|
||||
grayFg = NewStyle().Foreground(gray).Render
|
||||
midGrayFg = NewStyle().Foreground(midGray).Render
|
||||
darkGrayFg = NewStyle().Foreground(darkGray)
|
||||
|
||||
greenFg = NewStyle().Foreground(green).Render
|
||||
semiDimGreenFg = NewStyle().Foreground(semiDimGreen).Render
|
||||
dimGreenFg = NewStyle().Foreground(dimGreen).Render
|
||||
|
||||
fuchsiaFg = NewStyle().Foreground(fuchsia).Render
|
||||
dimFuchsiaFg = NewStyle().Foreground(dimFuchsia).Render
|
||||
|
||||
dullFuchsiaFg = NewStyle().Foreground(dullFuchsia).Render
|
||||
dimDullFuchsiaFg = NewStyle().Foreground(dimDullFuchsia).Render
|
||||
|
||||
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()
|
||||
dimNormalFg = lipgloss.NewStyle().Foreground(normalDim).Render
|
||||
brightGrayFg = lipgloss.NewStyle().Foreground(brightGray).Render
|
||||
dimBrightGrayFg = lipgloss.NewStyle().Foreground(dimBrightGray).Render
|
||||
grayFg = lipgloss.NewStyle().Foreground(gray).Render
|
||||
midGrayFg = lipgloss.NewStyle().Foreground(midGray).Render
|
||||
darkGrayFg = lipgloss.NewStyle().Foreground(darkGray)
|
||||
greenFg = lipgloss.NewStyle().Foreground(green).Render
|
||||
semiDimGreenFg = lipgloss.NewStyle().Foreground(semiDimGreen).Render
|
||||
dimGreenFg = lipgloss.NewStyle().Foreground(dimGreen).Render
|
||||
fuchsiaFg = lipgloss.NewStyle().Foreground(fuchsia).Render
|
||||
dimFuchsiaFg = lipgloss.NewStyle().Foreground(dimFuchsia).Render
|
||||
dullFuchsiaFg = lipgloss.NewStyle().Foreground(dullFuchsia).Render
|
||||
dimDullFuchsiaFg = lipgloss.NewStyle().Foreground(dimDullFuchsia).Render
|
||||
redFg = lipgloss.NewStyle().Foreground(red).Render
|
||||
tabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"})
|
||||
selectedTabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#333333", Dark: "#979797"})
|
||||
errorTitleStyle = lipgloss.NewStyle().Foreground(cream).Background(red).Padding(0, 1)
|
||||
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"})
|
||||
paginationStyle = subtleStyle
|
||||
)
|
||||
|
|
466
ui/ui.go
466
ui/ui.go
|
@ -1,33 +1,25 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/charm"
|
||||
"github.com/charmbracelet/charm/keygen"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glow/utils"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/muesli/gitcha"
|
||||
te "github.com/muesli/termenv"
|
||||
"github.com/segmentio/ksuid"
|
||||
)
|
||||
|
||||
const (
|
||||
noteCharacterLimit = 256 // should match server
|
||||
statusMessageTimeout = time.Second * 2 // how long to show status messages like "stashed!"
|
||||
statusMessageTimeout = time.Second * 3 // how long to show status messages like "stashed!"
|
||||
ellipsis = "…"
|
||||
|
||||
// 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 (
|
||||
|
@ -36,29 +28,25 @@ var (
|
|||
markdownExtensions = []string{
|
||||
"*.md", "*.mdown", "*.mkdn", "*.mkd", "*.markdown",
|
||||
}
|
||||
|
||||
// True if we're logging to a file, in which case we'll log more stuff.
|
||||
debug = false
|
||||
|
||||
// Types of documents we allow the user to stash.
|
||||
stashableDocTypes = NewDocTypeSet(LocalDoc, NewsDoc)
|
||||
)
|
||||
|
||||
// NewProgram returns a new Tea program.
|
||||
func NewProgram(cfg Config) *tea.Program {
|
||||
if cfg.Logfile != "" {
|
||||
log.Println("-- Starting Glow ----------------")
|
||||
log.Printf("High performance pager: %v", cfg.HighPerformancePager)
|
||||
log.Printf("Glamour rendering: %v", cfg.GlamourEnabled)
|
||||
log.Println("Bubble Tea now initializing...")
|
||||
debug = true
|
||||
}
|
||||
log.Debug(
|
||||
"Starting glow",
|
||||
"high_perf_pager",
|
||||
cfg.HighPerformancePager,
|
||||
"glamour",
|
||||
cfg.GlamourEnabled,
|
||||
)
|
||||
|
||||
config = cfg
|
||||
opts := []tea.ProgramOption{tea.WithAltScreen()}
|
||||
if cfg.EnableMouse {
|
||||
opts = append(opts, tea.WithMouseCellMotion())
|
||||
}
|
||||
return tea.NewProgram(newModel(cfg), opts...)
|
||||
m := newModel(cfg)
|
||||
return tea.NewProgram(m, opts...)
|
||||
}
|
||||
|
||||
type errMsg struct{ err error }
|
||||
|
@ -66,10 +54,6 @@ type errMsg struct{ err error }
|
|||
func (e errMsg) Error() string { return e.err.Error() }
|
||||
|
||||
type (
|
||||
newCharmClientMsg *charm.Client
|
||||
sshAuthErrMsg struct{}
|
||||
keygenFailedMsg struct{ err error }
|
||||
keygenSuccessMsg struct{}
|
||||
initLocalFileSearchMsg struct {
|
||||
cwd string
|
||||
ch chan gitcha.SearchResult
|
||||
|
@ -79,16 +63,7 @@ type (
|
|||
type (
|
||||
foundLocalFileMsg gitcha.SearchResult
|
||||
localFileSearchFinished struct{}
|
||||
gotStashMsg []*charm.Markdown
|
||||
stashLoadErrMsg struct{ err error }
|
||||
gotNewsMsg []*charm.Markdown
|
||||
statusMessageTimeoutMsg applicationContext
|
||||
newsLoadErrMsg struct{ err error }
|
||||
stashSuccessMsg markdown
|
||||
stashFailMsg struct {
|
||||
err error
|
||||
markdown markdown
|
||||
}
|
||||
)
|
||||
|
||||
// applicationContext indicates the area of the application something applies
|
||||
|
@ -115,60 +90,18 @@ func (s state) String() string {
|
|||
}[s]
|
||||
}
|
||||
|
||||
type authStatus int
|
||||
|
||||
const (
|
||||
authConnecting authStatus = iota
|
||||
authOK
|
||||
authFailed
|
||||
)
|
||||
|
||||
func (s authStatus) String() string {
|
||||
return map[authStatus]string{
|
||||
authConnecting: "connecting",
|
||||
authOK: "ok",
|
||||
authFailed: "failed",
|
||||
}[s]
|
||||
}
|
||||
|
||||
type keygenState int
|
||||
|
||||
const (
|
||||
keygenUnstarted keygenState = iota
|
||||
keygenRunning
|
||||
keygenFinished
|
||||
)
|
||||
|
||||
// Common stuff we'll need to access in all models.
|
||||
type commonModel struct {
|
||||
cfg Config
|
||||
cc *charm.Client
|
||||
cwd string
|
||||
authStatus authStatus
|
||||
width int
|
||||
height int
|
||||
|
||||
// Local IDs of files stashed this session. We treat this like a set,
|
||||
// ignoring the value portion with an empty struct.
|
||||
filesStashed map[ksuid.KSUID]struct{}
|
||||
|
||||
// ID of the most recently stashed markdown
|
||||
latestFileStashed ksuid.KSUID
|
||||
|
||||
// Files currently being stashed. We remove files from this set once
|
||||
// a stash operation has either succeeded or failed.
|
||||
filesStashing map[ksuid.KSUID]struct{}
|
||||
}
|
||||
|
||||
func (c commonModel) isStashing() bool {
|
||||
return len(c.filesStashing) > 0
|
||||
cfg Config
|
||||
cwd string
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
type model struct {
|
||||
common *commonModel
|
||||
state state
|
||||
keygenState keygenState
|
||||
fatalErr error
|
||||
common *commonModel
|
||||
state state
|
||||
fatalErr error
|
||||
|
||||
// Sub-models
|
||||
stash stashModel
|
||||
|
@ -201,49 +134,37 @@ func (m *model) unloadDocument() []tea.Cmd {
|
|||
func newModel(cfg Config) tea.Model {
|
||||
initSections()
|
||||
|
||||
if cfg.GlamourStyle == "auto" {
|
||||
if cfg.GlamourStyle == glamour.AutoStyle {
|
||||
if te.HasDarkBackground() {
|
||||
cfg.GlamourStyle = "dark"
|
||||
cfg.GlamourStyle = glamour.DarkStyle
|
||||
} else {
|
||||
cfg.GlamourStyle = "light"
|
||||
cfg.GlamourStyle = glamour.LightStyle
|
||||
}
|
||||
}
|
||||
|
||||
if len(cfg.DocumentTypes) == 0 {
|
||||
cfg.DocumentTypes.Add(LocalDoc, StashedDoc, ConvertedDoc, NewsDoc)
|
||||
}
|
||||
teamList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
|
||||
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{
|
||||
cfg: cfg,
|
||||
authStatus: authConnecting,
|
||||
filesStashed: make(map[ksuid.KSUID]struct{}),
|
||||
filesStashing: make(map[ksuid.KSUID]struct{}),
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
return model{
|
||||
common: &common,
|
||||
state: stateShowStash,
|
||||
keygenState: keygenUnstarted,
|
||||
pager: newPagerModel(&common),
|
||||
stash: newStashModel(&common),
|
||||
common: &common,
|
||||
state: stateShowStash,
|
||||
pager: newPagerModel(&common),
|
||||
stash: newStashModel(&common),
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
d := m.common.cfg.DocumentTypes
|
||||
|
||||
if d.Contains(StashedDoc) || d.Contains(NewsDoc) {
|
||||
cmds = append(cmds,
|
||||
newCharmClient,
|
||||
m.stash.spinner.Tick,
|
||||
)
|
||||
}
|
||||
|
||||
if d.Contains(LocalDoc) {
|
||||
cmds = append(cmds, findLocalFiles(m))
|
||||
}
|
||||
|
||||
cmds := []tea.Cmd{m.stash.spinner.Tick}
|
||||
cmds = append(cmds, m.stash.loadDocs())
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
|
@ -261,7 +182,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
if m.state == stateShowDocument {
|
||||
if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument {
|
||||
batch := m.unloadDocument()
|
||||
return m, tea.Batch(batch...)
|
||||
}
|
||||
|
@ -272,28 +193,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
switch m.state {
|
||||
case stateShowStash:
|
||||
// pass through all keys if we're editing the filter
|
||||
if m.stash.filterState == filtering || m.stash.selectionState == selectionSettingNote {
|
||||
if m.stash.filterState == filtering {
|
||||
m.stash, cmd = m.stash.update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// Special cases for the pager
|
||||
case stateShowDocument:
|
||||
switch m.pager.state {
|
||||
// If setting a note send all keys straight through
|
||||
case pagerStateSetNote:
|
||||
var batch []tea.Cmd
|
||||
newPagerModel, cmd := m.pager.update(msg)
|
||||
m.pager = newPagerModel
|
||||
batch = append(batch, cmd)
|
||||
return m, tea.Batch(batch...)
|
||||
}
|
||||
}
|
||||
|
||||
return m, tea.Quit
|
||||
|
||||
case "left", "h", "delete":
|
||||
if m.state == stateShowDocument && m.pager.state != pagerStateSetNote {
|
||||
if m.state == stateShowDocument {
|
||||
cmds = append(cmds, m.unloadDocument()...)
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
@ -315,66 +224,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.common.cwd = msg.cwd
|
||||
cmds = append(cmds, findNextLocalFile(m))
|
||||
|
||||
case sshAuthErrMsg:
|
||||
if m.keygenState != keygenFinished { // if we haven't run the keygen yet, do that
|
||||
m.keygenState = keygenRunning
|
||||
cmds = append(cmds, generateSSHKeys)
|
||||
} else {
|
||||
// The keygen ran but things still didn't work and we can't auth
|
||||
m.common.authStatus = authFailed
|
||||
m.stash.err = errors.New("SSH authentication failed; we tried ssh-agent, loading keys from disk, and generating SSH keys")
|
||||
if debug {
|
||||
log.Println("entering offline mode;", m.stash.err)
|
||||
}
|
||||
|
||||
// Even though it failed, news/stash loading is finished
|
||||
m.stash.loaded.Add(StashedDoc, NewsDoc)
|
||||
}
|
||||
|
||||
case keygenFailedMsg:
|
||||
// Keygen failed. That sucks.
|
||||
m.common.authStatus = authFailed
|
||||
m.stash.err = errors.New("could not authenticate; could not generate SSH keys")
|
||||
if debug {
|
||||
log.Println("entering offline mode;", m.stash.err)
|
||||
}
|
||||
|
||||
m.keygenState = keygenFinished
|
||||
|
||||
// Even though it failed, news/stash loading is finished
|
||||
m.stash.loaded.Add(StashedDoc, NewsDoc)
|
||||
|
||||
case keygenSuccessMsg:
|
||||
// The keygen's done, so let's try initializing the charm client again
|
||||
m.keygenState = keygenFinished
|
||||
cmds = append(cmds, newCharmClient)
|
||||
|
||||
case newCharmClientMsg:
|
||||
m.common.cc = msg
|
||||
m.common.authStatus = authOK
|
||||
cmds = append(cmds, loadStash(m.stash), loadNews(m.stash))
|
||||
|
||||
case stashLoadErrMsg:
|
||||
m.common.authStatus = authFailed
|
||||
|
||||
case fetchedMarkdownMsg:
|
||||
// We've loaded a markdown file's contents for rendering
|
||||
m.pager.currentDocument = *msg
|
||||
msg.Body = string(utils.RemoveFrontmatter([]byte(msg.Body)))
|
||||
cmds = append(cmds, renderWithGlamour(m.pager, msg.Body))
|
||||
body := string(utils.RemoveFrontmatter([]byte(msg.Body)))
|
||||
cmds = append(cmds, renderWithGlamour(m.pager, body))
|
||||
|
||||
case contentRenderedMsg:
|
||||
m.state = stateShowDocument
|
||||
|
||||
case noteSavedMsg:
|
||||
// A note was saved to a document. This will have been done in the
|
||||
// pager, so we'll need to find the corresponding note in the stash.
|
||||
// So, pass the message to the stash for processing.
|
||||
stashModel, cmd := m.stash.update(msg)
|
||||
m.stash = stashModel
|
||||
return m, cmd
|
||||
|
||||
case localFileSearchFinished, gotStashMsg, gotNewsMsg:
|
||||
case localFileSearchFinished:
|
||||
// Always pass these messages to the stash so we can keep it updated
|
||||
// about network activity, even if the user isn't currently viewing
|
||||
// the stash.
|
||||
|
@ -393,35 +252,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
cmds = append(cmds, findNextLocalFile(m))
|
||||
|
||||
case stashSuccessMsg:
|
||||
// Common handling that should happen regardless of application state
|
||||
md := markdown(msg)
|
||||
m.stash.addMarkdowns(&md)
|
||||
m.common.filesStashed[msg.stashID] = struct{}{}
|
||||
delete(m.common.filesStashing, md.stashID)
|
||||
|
||||
if m.stash.filterApplied() {
|
||||
for _, v := range m.stash.filteredMarkdowns {
|
||||
if v.stashID == msg.stashID && v.docType == ConvertedDoc {
|
||||
// Add the server-side ID we got back so we can do things
|
||||
// like rename and stash it.
|
||||
v.ID = msg.ID
|
||||
|
||||
// Keep the unique ID in sync so we can do things like
|
||||
// delete. Note that the markdown received a new unique ID
|
||||
// when it was added to the file listing in
|
||||
// stash.addMarkdowns.
|
||||
v.uniqueID = md.uniqueID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case stashFailMsg:
|
||||
// Common handling that should happen regardless of application state
|
||||
delete(m.common.filesStashed, msg.markdown.stashID)
|
||||
delete(m.common.filesStashing, msg.markdown.stashID)
|
||||
|
||||
case filteredMarkdownMsg:
|
||||
if m.state == stateShowDocument {
|
||||
newStashModel, cmd := m.stash.update(msg)
|
||||
|
@ -476,10 +306,11 @@ func errorView(err error, fatal bool) string {
|
|||
|
||||
// COMMANDS
|
||||
|
||||
func findLocalFiles(m model) tea.Cmd {
|
||||
func findLocalFiles(m commonModel) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
log.Info("findLocalFiles")
|
||||
var (
|
||||
cwd = m.common.cfg.WorkingDirectory
|
||||
cwd = m.cfg.WorkingDirectory
|
||||
err error
|
||||
)
|
||||
|
||||
|
@ -495,26 +326,19 @@ func findLocalFiles(m model) tea.Cmd {
|
|||
|
||||
// Note that this is one error check for both cases above
|
||||
if err != nil {
|
||||
if debug {
|
||||
log.Println("error finding local files:", err)
|
||||
}
|
||||
log.Error("error finding local files", "error", err)
|
||||
return errMsg{err}
|
||||
}
|
||||
|
||||
if debug {
|
||||
log.Println("local directory is:", cwd)
|
||||
}
|
||||
|
||||
log.Debug("local directory is", "cwd", cwd)
|
||||
var ignore []string
|
||||
if !m.common.cfg.ShowAllFiles {
|
||||
if !m.cfg.ShowAllFiles {
|
||||
ignore = ignorePatterns(m)
|
||||
}
|
||||
|
||||
ch, err := gitcha.FindFilesExcept(cwd, markdownExtensions, ignore)
|
||||
if err != nil {
|
||||
if debug {
|
||||
log.Println("error finding local files:", err)
|
||||
}
|
||||
log.Error("error finding local files", "error", err)
|
||||
return errMsg{err}
|
||||
}
|
||||
|
||||
|
@ -531,186 +355,11 @@ func findNextLocalFile(m model) tea.Cmd {
|
|||
return foundLocalFileMsg(res)
|
||||
}
|
||||
// We're done
|
||||
if debug {
|
||||
log.Println("local file search finished")
|
||||
}
|
||||
log.Debug("local file search finished")
|
||||
return localFileSearchFinished{}
|
||||
}
|
||||
}
|
||||
|
||||
func newCharmClient() tea.Msg {
|
||||
cfg, err := charm.ConfigFromEnv()
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
|
||||
cc, err := charm.NewClient(cfg)
|
||||
if err == charm.ErrMissingSSHAuth {
|
||||
if debug {
|
||||
log.Println("missing SSH auth:", err)
|
||||
}
|
||||
return sshAuthErrMsg{}
|
||||
} else if err != nil {
|
||||
if debug {
|
||||
log.Println("error creating new charm client:", err)
|
||||
}
|
||||
return errMsg{err}
|
||||
}
|
||||
|
||||
return newCharmClientMsg(cc)
|
||||
}
|
||||
|
||||
func loadStash(m stashModel) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.common.cc == nil {
|
||||
err := errors.New("no charm client")
|
||||
if debug {
|
||||
log.Println("error loading stash:", err)
|
||||
}
|
||||
return stashLoadErrMsg{err}
|
||||
}
|
||||
stash, err := m.common.cc.GetStash(m.serverPage)
|
||||
if err != nil {
|
||||
if debug {
|
||||
if _, ok := err.(charm.ErrAuthFailed); ok {
|
||||
log.Println("auth failure while loading stash:", err)
|
||||
} else {
|
||||
log.Println("error loading stash:", err)
|
||||
}
|
||||
}
|
||||
return stashLoadErrMsg{err}
|
||||
}
|
||||
if debug {
|
||||
log.Println("loaded stash page", m.serverPage)
|
||||
}
|
||||
return gotStashMsg(stash)
|
||||
}
|
||||
}
|
||||
|
||||
func loadNews(m stashModel) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.common.cc == nil {
|
||||
err := errors.New("no charm client")
|
||||
if debug {
|
||||
log.Println("error loading news:", err)
|
||||
}
|
||||
return newsLoadErrMsg{err}
|
||||
}
|
||||
news, err := m.common.cc.GetNews(1) // just fetch the first page
|
||||
if err != nil {
|
||||
if debug {
|
||||
log.Println("error loading news:", err)
|
||||
}
|
||||
return newsLoadErrMsg{err}
|
||||
}
|
||||
if debug {
|
||||
log.Println("fetched news")
|
||||
}
|
||||
return gotNewsMsg(news)
|
||||
}
|
||||
}
|
||||
|
||||
func generateSSHKeys() tea.Msg {
|
||||
if debug {
|
||||
log.Println("running keygen...")
|
||||
}
|
||||
_, err := keygen.NewSSHKeyPair(nil)
|
||||
if err != nil {
|
||||
if debug {
|
||||
log.Println("keygen failed:", err)
|
||||
}
|
||||
return keygenFailedMsg{err}
|
||||
}
|
||||
if debug {
|
||||
log.Println("keys generated 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 {
|
||||
return func() tea.Msg {
|
||||
<-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.
|
||||
func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown {
|
||||
md := &markdown{
|
||||
docType: LocalDoc,
|
||||
localPath: res.Path,
|
||||
Markdown: charm.Markdown{
|
||||
Note: stripAbsolutePath(res.Path, cwd),
|
||||
CreatedAt: res.Info.ModTime(),
|
||||
},
|
||||
Note: stripAbsolutePath(res.Path, cwd),
|
||||
Modtime: res.Info.ModTime(),
|
||||
}
|
||||
|
||||
return md
|
||||
}
|
||||
|
||||
func stripAbsolutePath(fullPath, cwd string) string {
|
||||
return strings.Replace(fullPath, cwd+string(os.PathSeparator), "", -1)
|
||||
return strings.ReplaceAll(fullPath, cwd+string(os.PathSeparator), "")
|
||||
}
|
||||
|
||||
// Lightweight version of reflow's indent function.
|
||||
|
|
|
@ -2,8 +2,13 @@ package utils
|
|||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glamour/ansi"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/mitchellh/go-homedir"
|
||||
)
|
||||
|
||||
|
@ -32,3 +37,73 @@ func ExpandPath(path string) string {
|
|||
}
|
||||
return os.ExpandEnv(path)
|
||||
}
|
||||
|
||||
// WrapCodeBlock wraps a string in a code block with the given language.
|
||||
func WrapCodeBlock(s, language string) string {
|
||||
return "```" + language + "\n" + s + "```"
|
||||
}
|
||||
|
||||
var markdownExtensions = []string{
|
||||
".md", ".mdown", ".mkdn", ".mkd", ".markdown",
|
||||
}
|
||||
|
||||
// IsMarkdownFile returns whether the filename has a markdown extension.
|
||||
func IsMarkdownFile(filename string) bool {
|
||||
ext := filepath.Ext(filename)
|
||||
|
||||
if ext == "" {
|
||||
// By default, assume it's a markdown file.
|
||||
return true
|
||||
}
|
||||
|
||||
for _, v := range markdownExtensions {
|
||||
if strings.EqualFold(ext, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Has an extension but not markdown
|
||||
// so assume this is a code file.
|
||||
return false
|
||||
}
|
||||
|
||||
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…
Reference in a new issue