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:
Carlos Alexandro Becker 2024-07-03 12:11:29 -03:00 committed by GitHub
commit fce3edf7db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 692 additions and 2962 deletions

View file

@ -24,7 +24,7 @@ linters:
- goimports - goimports
- goprintffuncname - goprintffuncname
- gosec - gosec
- ifshort # - ifshort
- misspell - misspell
- prealloc - prealloc
- revive - revive

View file

@ -2,8 +2,6 @@
.PHONY: default clean glow run log .PHONY: default clean glow run log
LOGFILE := debug.log
default: glow default: glow
clean: clean:
@ -13,8 +11,7 @@ glow:
go build go build
run: clean glow run: clean glow
GLOW_LOGFILE=$(LOGFILE) ./glow ./glow
log: log:
> $(LOGFILE) tail -f ~/.cache/glow/glow.log
tail -f $(LOGFILE)

View file

@ -20,13 +20,9 @@ Glow is a terminal based markdown reader designed from the ground up to bring
out the beauty—and power—of the CLI. out the beauty—and power—of the CLI.
Use it to discover markdown files, read documentation directly on the command Use it to discover markdown files, read documentation directly on the command
line and stash markdown files to your own private collection, so you can read line. Glow will find local markdown files in subdirectories or a local
them anywhere. Glow will find local markdown files in subdirectories or a local
Git repository. Git repository.
By the way, all data stashed is encrypted end-to-end: only you can decrypt it.
More on that below.
## Installation ## Installation
### Package Manager ### Package Manager
@ -84,6 +80,7 @@ packages. ARM builds are also available for macOS, Linux, FreeBSD and OpenBSD.
### Go ### Go
Or just install it with `go`: Or just install it with `go`:
```bash ```bash
go install github.com/charmbracelet/glow@latest go install github.com/charmbracelet/glow@latest
``` ```
@ -98,11 +95,10 @@ go build
[releases]: https://github.com/charmbracelet/glow/releases [releases]: https://github.com/charmbracelet/glow/releases
## The TUI ## The TUI
Simply run `glow` without arguments to start the textual user interface and Simply run `glow` without arguments to start the textual user interface and
browse local and stashed markdown. Glow will find local markdown files in the browse local. Glow will find local markdown files in the
current directory and below or, if youre in a Git repository, Glow will search current directory and below or, if youre in a Git repository, Glow will search
the repo. the repo.
@ -110,15 +106,6 @@ Markdown files can be read with Glow's high-performance pager. Most of the
keystrokes you know from `less` are the same, but you can press `?` to list keystrokes you know from `less` are the same, but you can press `?` to list
the hotkeys. the hotkeys.
### Stashing
Glow works with the Charm Cloud to allow you to store any markdown files in
your own private collection. You can stash a local document from the Glow TUI by
pressing `s`.
Stashing is private, its contents will not be exposed publicly, and it's
encrypted end-to-end. More on encryption below.
## The CLI ## The CLI
In addition to a TUI, Glow has a CLI for working with Markdown. To format a In addition to a TUI, Glow has a CLI for working with Markdown. To format a
@ -138,18 +125,6 @@ glow github.com/charmbracelet/glow
glow https://host.tld/file.md glow https://host.tld/file.md
``` ```
### Stashing
You can also stash documents from the CLI:
```bash
glow stash README.md
```
Then, when you run `glow` without arguments will you can browse through your
stashed documents. This is a great way to keep track of things that you need to
reference often.
### Word Wrapping ### Word Wrapping
The `-w` flag lets you set a maximum width at which the output will be wrapped: The `-w` flag lets you set a maximum width at which the output will be wrapped:
@ -211,32 +186,19 @@ pager: true
width: 80 width: 80
``` ```
## 🔒 Encryption: How It Works
Encryption works by issuing symmetric keys (basically a generated password) and
encrypting it with the local SSH public key generated by the open-source
[charm][charmlib] library. That encrypted key is then sent up to our server.
We cant read it since we dont have your private key. When you want to decrypt
something or view your stash, that key is downloaded from our server and
decrypted locally using the SSH private key. When you link accounts, the
symmetric key is encrypted for each new public key. This happens on your
machine and not our server, so we never see any unencrypted data.
[charmlib]: https://github.com/charmbracelet/charm
## Feedback ## Feedback
Wed love to hear your thoughts on this project. Feel free to drop us a note! Wed love to hear your thoughts on this project. Feel free to drop us a note!
* [Twitter](https://twitter.com/charmcli) - [Twitter](https://twitter.com/charmcli)
* [The Fediverse](https://mastodon.social/@charmcli) - [The Fediverse](https://mastodon.social/@charmcli)
* [Discord](https://charm.sh/chat) - [Discord](https://charm.sh/chat)
## License ## License
[MIT](https://github.com/charmbracelet/glow/raw/master/LICENSE) [MIT](https://github.com/charmbracelet/glow/raw/master/LICENSE)
*** ---
Part of [Charm](https://charm.sh). Part of [Charm](https://charm.sh).

View file

@ -13,61 +13,35 @@ import (
const defaultConfig = `# style name or JSON path (default "auto") const defaultConfig = `# style name or JSON path (default "auto")
style: "auto" style: "auto"
# show local files only; no network (TUI-mode only)
local: false
# mouse support (TUI-mode only) # mouse support (TUI-mode only)
mouse: false mouse: false
# use pager to display markdown # use pager to display markdown
pager: false pager: false
# word-wrap at width # word-wrap at width
width: 80` width: 80
`
func defaultConfigFile() string {
scope := gap.NewScope(gap.User, "glow")
path, _ := scope.ConfigPath("glow.yml")
return path
}
var configCmd = &cobra.Command{ var configCmd = &cobra.Command{
Use: "config", Use: "config",
Hidden: false, Hidden: false,
Short: "Edit the glow config file", Short: "Edit the glow config file",
Long: paragraph(fmt.Sprintf("\n%s the glow config file. Well use EDITOR to determine which editor to use. If the config file doesn't exist, it will be created.", keyword("Edit"))), Long: paragraph(fmt.Sprintf("\n%s the glow config file. Well use EDITOR to determine which editor to use. If the config file doesn't exist, it will be created.", keyword("Edit"))),
Example: paragraph("glow config\nglow config --config path/to/config.yml"), Example: paragraph("glow config\nglow config --config path/to/config.yml"),
Args: cobra.NoArgs, Args: cobra.NoArgs,
SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error {
RunE: func(_ *cobra.Command, _ []string) error { if err := ensureConfigFile(); err != nil {
if configFile == "" {
scope := gap.NewScope(gap.User, "glow")
var err error
configFile, err = scope.ConfigPath("glow.yml")
if err != nil {
return err
}
}
if ext := path.Ext(configFile); ext != ".yaml" && ext != ".yml" {
return fmt.Errorf("'%s' is not a supported config type: use '%s' or '%s'", ext, ".yaml", ".yml")
}
if _, err := os.Stat(configFile); os.IsNotExist(err) {
// File doesn't exist yet, create all necessary directories and
// write the default config file
if err := os.MkdirAll(filepath.Dir(configFile), 0o700); err != nil {
return err
}
f, err := os.Create(configFile)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
if _, err := f.WriteString(defaultConfig); err != nil {
return err
}
} else if err != nil { // some other error occurred
return err return err
} }
c, err := editor.Cmd("Glow", configFile) c, err := editor.Cmd("Glow", configFile)
if err != nil { if err != nil {
return fmt.Errorf("could not edit %s: %w", configFile, err) return err
} }
c.Stdin = os.Stdin c.Stdin = os.Stdin
c.Stdout = os.Stdout c.Stdout = os.Stdout
@ -80,3 +54,37 @@ var configCmd = &cobra.Command{
return nil return nil
}, },
} }
func ensureConfigFile() error {
if configFile == "" {
configFile = defaultConfigFile()
if err := os.MkdirAll(filepath.Dir(configFile), 0o755); err != nil {
return fmt.Errorf("Could not write config file: %w", err)
}
}
if ext := path.Ext(configFile); ext != ".yaml" && ext != ".yml" {
return fmt.Errorf("'%s' is not a supported config type: use '%s' or '%s'", ext, ".yaml", ".yml")
}
if _, err := os.Stat(configFile); os.IsNotExist(err) {
// File doesn't exist yet, create all necessary directories and
// write the default config file
if err := os.MkdirAll(filepath.Dir(configFile), 0o700); err != nil {
return err
}
f, err := os.Create(configFile)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
if _, err := f.WriteString(defaultConfig); err != nil {
return err
}
} else if err != nil { // some other error occurred
return err
}
return nil
}

77
go.mod
View file

@ -1,72 +1,73 @@
module github.com/charmbracelet/glow module github.com/charmbracelet/glow
go 1.17 go 1.21.4
require ( require (
github.com/adrg/xdg v0.4.0
github.com/atotto/clipboard v0.1.4 github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v0.15.0 github.com/caarlos0/env/v11 v11.0.1
github.com/charmbracelet/bubbletea v0.25.0 github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/charm v0.8.7 github.com/charmbracelet/bubbletea v0.26.4
github.com/charmbracelet/glamour v0.6.0 github.com/charmbracelet/glamour v0.6.1-0.20230519131405-7528eaad6620
github.com/charmbracelet/lipgloss v0.6.0 github.com/charmbracelet/lipgloss v0.11.1-0.20240608174255-33b3263db7dd
github.com/charmbracelet/x/editor v0.0.0-20231116172829-450eedbca1ab github.com/charmbracelet/log v0.4.0
github.com/charmbracelet/x/editor v0.0.0-20240625164403-2627ec16405d
github.com/dustin/go-humanize v1.0.1 github.com/dustin/go-humanize v1.0.1
github.com/mattn/go-runewidth v0.0.15 github.com/mattn/go-runewidth v0.0.15
github.com/meowgorithm/babyenv v1.3.1
github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-homedir v1.1.0
github.com/muesli/gitcha v0.2.0 github.com/muesli/gitcha v0.2.0
github.com/muesli/go-app-paths v0.2.2 github.com/muesli/go-app-paths v0.2.2
github.com/muesli/reflow v0.3.0 github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.15.2 github.com/muesli/termenv v0.15.2
github.com/sahilm/fuzzy v0.1.0 github.com/sahilm/fuzzy v0.1.1
github.com/segmentio/ksuid v1.0.4 github.com/spf13/cobra v1.7.0
github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.15.0
github.com/spf13/viper v1.14.0 golang.org/x/sys v0.21.0
golang.org/x/sys v0.17.0 golang.org/x/term v0.21.0
golang.org/x/term v0.15.0 golang.org/x/text v0.16.0
golang.org/x/text v0.14.0
) )
require ( require (
github.com/alecthomas/chroma v0.10.0 // indirect github.com/alecthomas/chroma/v2 v2.8.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/calmh/randomart v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.1.2 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/charmbracelet/x/input v0.1.2 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect github.com/charmbracelet/x/term v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.1.2 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/google/uuid v1.3.0 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/gorilla/css v1.0.0 // indirect github.com/gorilla/css v1.0.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/microcosm-cc/bluemonday v1.0.21 // indirect github.com/microcosm-cc/bluemonday v1.0.25 // indirect
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/sasquatch v0.0.0-20200811221207-66979d92330a // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.0.6 // indirect
github.com/pelletier/go-toml/v2 v2.0.5 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/rivo/uniseg v0.2.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 // indirect github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 // indirect
github.com/spf13/afero v1.9.2 // indirect github.com/spf13/afero v1.9.3 // indirect
github.com/spf13/cast v1.5.0 // indirect github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.4.1 // indirect github.com/subosito/gotenv v1.4.2 // indirect
github.com/yuin/goldmark v1.5.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark-emoji v1.0.1 // indirect github.com/yuin/goldmark v1.5.4 // indirect
golang.org/x/crypto v0.17.0 // indirect github.com/yuin/goldmark-emoji v1.0.2 // indirect
golang.org/x/net v0.17.0 // indirect golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect
golang.org/x/sync v0.1.0 // indirect golang.org/x/net v0.26.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect golang.org/x/sync v0.7.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

875
go.sum

File diff suppressed because it is too large Load diff

27
log.go Normal file
View 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
View file

@ -11,11 +11,11 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" "github.com/caarlos0/env/v11"
"github.com/charmbracelet/glamour" "github.com/charmbracelet/glamour"
"github.com/charmbracelet/glow/ui" "github.com/charmbracelet/glow/ui"
"github.com/charmbracelet/glow/utils" "github.com/charmbracelet/glow/utils"
"github.com/meowgorithm/babyenv" "github.com/charmbracelet/log"
gap "github.com/muesli/go-app-paths" gap "github.com/muesli/go-app-paths"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
@ -34,17 +34,21 @@ var (
style string style string
width uint width uint
showAllFiles bool showAllFiles bool
localOnly bool
mouse bool mouse bool
rootCmd = &cobra.Command{ rootCmd = &cobra.Command{
Use: "glow [SOURCE|DIR]", Use: "glow [SOURCE|DIR]",
Short: "Render markdown on the CLI, with pizzazz!", Short: "Render markdown on the CLI, with pizzazz!",
Long: paragraph(fmt.Sprintf("\nRender markdown on the CLI, %s!", keyword("with pizzazz"))), Long: paragraph(
fmt.Sprintf("\nRender markdown on the CLI, %s!", keyword("with pizzazz")),
),
SilenceErrors: false, SilenceErrors: false,
SilenceUsage: false, SilenceUsage: true,
TraverseChildren: true, TraverseChildren: true,
RunE: execute, PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
return validateOptions(cmd)
},
RunE: execute,
} }
) )
@ -103,7 +107,10 @@ func sourceFromArg(arg string) (*source, error) {
st, err := os.Stat(arg) st, err := os.Stat(arg)
if err == nil && st.IsDir() { if err == nil && st.IsDir() {
var src *source var src *source
_ = filepath.Walk(arg, func(path string, info os.FileInfo, err error) error { _ = filepath.Walk(arg, func(path string, _ os.FileInfo, err error) error {
if err != nil {
return err
}
for _, v := range readmeNames { for _, v := range readmeNames {
if strings.EqualFold(filepath.Base(path), v) { if strings.EqualFold(filepath.Base(path), v) {
r, err := os.Open(path) r, err := os.Open(path)
@ -137,13 +144,12 @@ func sourceFromArg(arg string) (*source, error) {
func validateOptions(cmd *cobra.Command) error { func validateOptions(cmd *cobra.Command) error {
// grab config values from Viper // grab config values from Viper
width = viper.GetUint("width") width = viper.GetUint("width")
localOnly = viper.GetBool("local")
mouse = viper.GetBool("mouse") mouse = viper.GetBool("mouse")
pager = viper.GetBool("pager") pager = viper.GetBool("pager")
// validate the glamour style // validate the glamour style
style = viper.GetString("style") style = viper.GetString("style")
if style != "auto" && glamour.DefaultStyles[style] == nil { if style != glamour.AutoStyle && glamour.DefaultStyles[style] == nil {
style = utils.ExpandPath(style) style = utils.ExpandPath(style)
if _, err := os.Stat(style); os.IsNotExist(err) { if _, err := os.Stat(style); os.IsNotExist(err) {
return fmt.Errorf("Specified style does not exist: %s", style) return fmt.Errorf("Specified style does not exist: %s", style)
@ -188,11 +194,6 @@ func stdinIsPipe() (bool, error) {
} }
func execute(cmd *cobra.Command, args []string) error { func execute(cmd *cobra.Command, args []string) error {
initConfig()
if err := validateOptions(cmd); err != nil {
return err
}
// if stdin is a pipe then use stdin for input. note that you can also // if stdin is a pipe then use stdin for input. note that you can also
// explicitly use a - to read from stdin. // explicitly use a - to read from stdin.
if yes, err := stdinIsPipe(); err != nil { if yes, err := stdinIsPipe(); err != nil {
@ -206,7 +207,7 @@ func execute(cmd *cobra.Command, args []string) error {
switch len(args) { switch len(args) {
// TUI running on cwd // TUI running on cwd
case 0: case 0:
return runTUI("", false) return runTUI("")
// TUI with possible dir argument // TUI with possible dir argument
case 1: case 1:
@ -216,7 +217,7 @@ func execute(cmd *cobra.Command, args []string) error {
if err == nil && info.IsDir() { if err == nil && info.IsDir() {
p, err := filepath.Abs(args[0]) p, err := filepath.Abs(args[0])
if err == nil { if err == nil {
return runTUI(p, false) return runTUI(p)
} }
} }
fallthrough fallthrough
@ -259,16 +260,11 @@ func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error {
baseURL = u.String() + "/" baseURL = u.String() + "/"
} }
// initialize glamour isCode := !utils.IsMarkdownFile(src.URL)
var gs glamour.TermRendererOption
if style == "auto" {
gs = glamour.WithEnvironmentConfig()
} else {
gs = glamour.WithStylePath(style)
}
// initialize glamour
r, err := glamour.NewTermRenderer( r, err := glamour.NewTermRenderer(
gs, utils.GlamourStyle(style, isCode),
glamour.WithWordWrap(int(width)), glamour.WithWordWrap(int(width)),
glamour.WithBaseURL(baseURL), glamour.WithBaseURL(baseURL),
glamour.WithPreservedNewLines(), glamour.WithPreservedNewLines(),
@ -277,23 +273,28 @@ func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error {
return err return err
} }
out, err := r.RenderBytes(b) s := string(b)
ext := filepath.Ext(src.URL)
if isCode {
s = utils.WrapCodeBlock(string(b), ext)
}
out, err := r.Render(s)
if err != nil { if err != nil {
return err return err
} }
// trim lines // trim lines
lines := strings.Split(string(out), "\n") lines := strings.Split(out, "\n")
var cb strings.Builder var content strings.Builder
for i, s := range lines { for i, s := range lines {
cb.WriteString(strings.TrimSpace(s)) content.WriteString(strings.TrimSpace(s))
// don't add an artificial newline after the last split // don't add an artificial newline after the last split
if i+1 < len(lines) { if i+1 < len(lines) {
cb.WriteString("\n") content.WriteByte('\n')
} }
} }
content := cb.String()
// display // display
if pager || cmd.Flags().Changed("pager") { if pager || cmd.Flags().Changed("pager") {
@ -304,44 +305,29 @@ func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error {
pa := strings.Split(pagerCmd, " ") pa := strings.Split(pagerCmd, " ")
c := exec.Command(pa[0], pa[1:]...) // nolint:gosec c := exec.Command(pa[0], pa[1:]...) // nolint:gosec
c.Stdin = strings.NewReader(content) c.Stdin = strings.NewReader(content.String())
c.Stdout = os.Stdout c.Stdout = os.Stdout
return c.Run() return c.Run()
} }
fmt.Fprint(w, content) fmt.Fprint(w, content.String()) //nolint: errcheck
return nil return nil
} }
func runTUI(workingDirectory string, stashedOnly bool) error { func runTUI(workingDirectory string) error {
// Read environment to get debugging stuff // Read environment to get debugging stuff
var cfg ui.Config cfg, err := env.ParseAs[ui.Config]()
if err := babyenv.Parse(&cfg); err != nil { if err != nil {
return fmt.Errorf("error parsing config: %v", err) return fmt.Errorf("error parsing config: %v", err)
} }
// Log to file, if set
if cfg.Logfile != "" {
f, err := tea.LogToFile(cfg.Logfile, "glow")
if err != nil {
return err
}
defer f.Close() //nolint:errcheck
}
cfg.WorkingDirectory = workingDirectory cfg.WorkingDirectory = workingDirectory
cfg.DocumentTypes = ui.NewDocTypeSet()
cfg.ShowAllFiles = showAllFiles cfg.ShowAllFiles = showAllFiles
cfg.GlamourMaxWidth = width cfg.GlamourMaxWidth = width
cfg.GlamourStyle = style cfg.GlamourStyle = style
cfg.EnableMouse = mouse cfg.EnableMouse = mouse
if stashedOnly {
cfg.DocumentTypes.Add(ui.StashedDoc, ui.NewsDoc)
} else if localOnly {
cfg.DocumentTypes.Add(ui.LocalDoc)
}
// Run Bubble Tea program // Run Bubble Tea program
if _, err := ui.NewProgram(cfg).Run(); err != nil { if _, err := ui.NewProgram(cfg).Run(); err != nil {
return err return err
@ -351,12 +337,20 @@ func runTUI(workingDirectory string, stashedOnly bool) error {
} }
func main() { func main() {
if err := rootCmd.Execute(); err != nil { closer, err := setupLog()
if err != nil {
fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
if err := rootCmd.Execute(); err != nil {
_ = closer()
os.Exit(1)
}
_ = closer()
} }
func init() { func init() {
tryLoadConfigFromDefaultPlaces()
if len(CommitSHA) >= 7 { if len(CommitSHA) >= 7 {
vt := rootCmd.VersionTemplate() vt := rootCmd.VersionTemplate()
rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n") rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n")
@ -366,62 +360,56 @@ func init() {
} }
rootCmd.Version = Version rootCmd.Version = Version
scope := gap.NewScope(gap.User, "glow")
defaultConfigFile, _ := scope.ConfigPath("glow.yml")
// "Glow Classic" cli arguments // "Glow Classic" cli arguments
rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default %s)", defaultConfigFile)) rootCmd.PersistentFlags().StringVar(&configFile, "config", "", fmt.Sprintf("config file (default %s)", defaultConfigFile()))
rootCmd.Flags().BoolVarP(&pager, "pager", "p", false, "display with pager") rootCmd.Flags().BoolVarP(&pager, "pager", "p", false, "display with pager")
rootCmd.Flags().StringVarP(&style, "style", "s", "auto", "style name or JSON path") rootCmd.Flags().StringVarP(&style, "style", "s", glamour.AutoStyle, "style name or JSON path")
rootCmd.Flags().UintVarP(&width, "width", "w", 0, "word-wrap at width") rootCmd.Flags().UintVarP(&width, "width", "w", 0, "word-wrap at width")
rootCmd.Flags().BoolVarP(&showAllFiles, "all", "a", false, "show system files and directories (TUI-mode only)") rootCmd.Flags().BoolVarP(&showAllFiles, "all", "a", false, "show system files and directories (TUI-mode only)")
rootCmd.Flags().BoolVarP(&localOnly, "local", "l", false, "show local files only; no network (TUI-mode only)")
rootCmd.Flags().BoolVarP(&mouse, "mouse", "m", false, "enable mouse wheel (TUI-mode only)") rootCmd.Flags().BoolVarP(&mouse, "mouse", "m", false, "enable mouse wheel (TUI-mode only)")
_ = rootCmd.Flags().MarkHidden("mouse") _ = rootCmd.Flags().MarkHidden("mouse")
// Config bindings // Config bindings
_ = viper.BindPFlag("style", rootCmd.Flags().Lookup("style")) _ = viper.BindPFlag("style", rootCmd.Flags().Lookup("style"))
_ = viper.BindPFlag("width", rootCmd.Flags().Lookup("width")) _ = viper.BindPFlag("width", rootCmd.Flags().Lookup("width"))
_ = viper.BindPFlag("local", rootCmd.Flags().Lookup("local")) _ = viper.BindPFlag("debug", rootCmd.Flags().Lookup("debug"))
_ = viper.BindPFlag("mouse", rootCmd.Flags().Lookup("mouse")) _ = viper.BindPFlag("mouse", rootCmd.Flags().Lookup("mouse"))
viper.SetDefault("style", "auto") viper.SetDefault("style", glamour.AutoStyle)
viper.SetDefault("width", 0) viper.SetDefault("width", 0)
viper.SetDefault("local", "false")
// Stash
stashCmd.PersistentFlags().StringVarP(&memo, "memo", "m", "", "memo/note for stashing")
rootCmd.AddCommand(stashCmd)
rootCmd.AddCommand(configCmd) rootCmd.AddCommand(configCmd)
} }
func initConfig() { func tryLoadConfigFromDefaultPlaces() {
if configFile != "" { scope := gap.NewScope(gap.User, "glow")
viper.SetConfigFile(configFile) dirs, err := scope.ConfigDirs()
} else { if err != nil {
scope := gap.NewScope(gap.User, "glow") fmt.Println("Could not load find config directory.")
dirs, err := scope.ConfigDirs() os.Exit(1)
if err != nil {
fmt.Println("Can't retrieve default config. Please manually pass a config file with '--config'")
os.Exit(1)
}
for _, v := range dirs {
viper.AddConfigPath(v)
}
viper.SetConfigName("glow")
viper.SetConfigType("yaml")
} }
for _, v := range dirs {
viper.AddConfigPath(v)
}
viper.SetConfigName("glow")
viper.SetConfigType("yaml")
viper.SetEnvPrefix("glow") viper.SetEnvPrefix("glow")
viper.AutomaticEnv() viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok { if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
fmt.Println("Error parsing config:", err) log.Warn("Could not parse configuration file", "err", err)
os.Exit(1)
} }
} }
// fmt.Println("Using config file:", viper.ConfigFileUsed()) if used := viper.ConfigFileUsed(); used != "" {
log.Debug("Using configuration file", "path", viper.ConfigFileUsed())
} else {
if err := ensureConfigFile(); err != nil {
fmt.Println("Could not create default config.")
os.Exit(1)
}
}
} }

View file

@ -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
}

View file

@ -1,13 +1,13 @@
package main package main
import . "github.com/charmbracelet/lipgloss" //nolint:revive import "github.com/charmbracelet/lipgloss"
var ( var (
keyword = NewStyle(). keyword = lipgloss.NewStyle().
Foreground(Color("#04B575")). Foreground(lipgloss.Color("#04B575")).
Render Render
paragraph = NewStyle(). paragraph = lipgloss.NewStyle().
Width(78). Width(78).
Padding(0, 0, 0, 2). Padding(0, 0, 0, 2).
Render Render

View file

@ -12,19 +12,7 @@ type Config struct {
// Which directory should we start from? // Which directory should we start from?
WorkingDirectory string WorkingDirectory string
// Which document types shall we show?
DocumentTypes DocTypeSet
// For debugging the UI // For debugging the UI
Logfile string `env:"GLOW_LOGFILE"` HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" envDefault:"true"`
HighPerformancePager bool `env:"GLOW_HIGH_PERFORMANCE_PAGER" default:"true"` GlamourEnabled bool `env:"GLOW_ENABLE_GLAMOUR" envDefault:"true"`
GlamourEnabled bool `env:"GLOW_ENABLE_GLAMOUR" default:"true"`
}
func (c Config) localOnly() bool {
return c.DocumentTypes.Equals(NewDocTypeSet(LocalDoc))
}
func (c Config) stashedOnly() bool {
return c.DocumentTypes.Contains(StashedDoc) && !c.DocumentTypes.Contains(LocalDoc)
} }

View file

@ -1,8 +0,0 @@
//go:build !windows
// +build !windows
package ui
const (
pagerStashIcon = "🔒"
)

View file

@ -1,7 +0,0 @@
// +build windows
package ui
const (
pagerStashIcon = "•"
)

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -11,12 +11,9 @@ func openEditor(path string) tea.Cmd {
cb := func(err error) tea.Msg { cb := func(err error) tea.Msg {
return editorFinishedMsg{err} return editorFinishedMsg{err}
} }
cmd, err := editor.Cmd("Glow", path)
editor, err := editor.Cmd("Glow", path)
if err != nil { if err != nil {
return func() tea.Msg { return func() tea.Msg { return cb(err) }
return errMsg{err}
}
} }
return tea.ExecProcess(editor, cb) return tea.ExecProcess(cmd, cb)
} }

View file

@ -1,13 +1,14 @@
//go:build darwin
// +build darwin // +build darwin
package ui package ui
import "path/filepath" import "path/filepath"
func ignorePatterns(m model) []string { func ignorePatterns(m commonModel) []string {
return []string{ return []string{
filepath.Join(m.common.cfg.HomeDir, "Library"), filepath.Join(m.cfg.HomeDir, "Library"),
m.common.cfg.Gopath, m.cfg.Gopath,
"node_modules", "node_modules",
".*", ".*",
} }

View file

@ -3,9 +3,9 @@
package ui package ui
func ignorePatterns(m model) []string { func ignorePatterns(m commonModel) []string {
return []string{ return []string{
m.common.cfg.Gopath, m.cfg.Gopath,
"node_modules", "node_modules",
".*", ".*",
} }

View file

@ -1,42 +1,18 @@
package ui package ui
import ( import (
"log"
"math" "math"
"path"
"strings"
"time" "time"
"unicode" "unicode"
"github.com/charmbracelet/charm" "github.com/charmbracelet/log"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/segmentio/ksuid"
"golang.org/x/text/runes" "golang.org/x/text/runes"
"golang.org/x/text/transform" "golang.org/x/text/transform"
"golang.org/x/text/unicode/norm" "golang.org/x/text/unicode/norm"
) )
// markdown wraps charm.Markdown.
type markdown struct { type markdown struct {
docType DocType
// Stash identifier. This exists so we can keep track of documents stashed
// in-session as they relate to their original, non-stashed counterparts.
// All documents have a stashID, however when a document is stashed that
// document inherits the stashID of the original.
stashID ksuid.KSUID
// Unique identifier. Unlike the stash identifier, this value should always
// be unique so we can confidently find it an operate on it (versus stashID,
// which could match both an original or stashed document).
uniqueID ksuid.KSUID
// Some of the document's original values before this document was stashed.
// These are irrelevant if this document was not stashed in this session.
originalDocType DocType
originalTimestamp time.Time
originalNote string
// Full path of a local markdown file. Only relevant to local documents and // Full path of a local markdown file. Only relevant to local documents and
// those that have been stashed in this session. // those that have been stashed in this session.
localPath string localPath string
@ -46,102 +22,24 @@ type markdown struct {
// field is ephemeral, and should only be referenced during filtering. // field is ephemeral, and should only be referenced during filtering.
filterValue string filterValue string
charm.Markdown Body string
} Note string
Modtime time.Time
func (m *markdown) generateIDs() {
if m.stashID.IsNil() {
m.stashID = ksuid.New()
}
m.uniqueID = ksuid.New()
}
// convertToStashed converts this document into its stashed state.
func (m *markdown) convertToStashed() {
if m.docType == ConvertedDoc {
if debug {
log.Println("not converting already converted document:", m)
}
return
}
m.originalDocType = m.docType
m.originalTimestamp = m.CreatedAt
m.originalNote = m.Note
if m.docType == LocalDoc {
m.Note = strings.Replace(path.Base(m.localPath), path.Ext(m.localPath), "", 1)
}
m.CreatedAt = time.Now()
m.docType = ConvertedDoc
}
// revert reverts this document from its stashed state.
func (m *markdown) revertFromStashed() {
if m.docType != ConvertedDoc {
log.Printf("not reverting document of type %s: %v", m.docType, m)
}
m.docType = m.originalDocType
m.CreatedAt = m.originalTimestamp
m.Note = m.originalNote
} }
// Generate the value we're doing to filter against. // Generate the value we're doing to filter against.
func (m *markdown) buildFilterValue() { func (m *markdown) buildFilterValue() {
note, err := normalize(m.Note) note, err := normalize(m.Note)
if err != nil { if err != nil {
if debug { log.Error("error normalizing", "note", m.Note, "error", err)
log.Printf("error normalizing '%s': %v", m.Note, err)
}
m.filterValue = m.Note m.filterValue = m.Note
} }
m.filterValue = note m.filterValue = note
} }
// shouldSortAsLocal returns whether or not this markdown should be sorted as though
// it's a local markdown document.
func (m markdown) shouldSortAsLocal() bool {
return m.docType == LocalDoc || m.docType == ConvertedDoc
}
// Sort documents with local files first, then by date.
type markdownsByLocalFirst []*markdown
func (m markdownsByLocalFirst) Len() int { return len(m) }
func (m markdownsByLocalFirst) Swap(i, j int) { m[i], m[j] = m[j], m[i] }
func (m markdownsByLocalFirst) Less(i, j int) bool {
iIsLocal := m[i].shouldSortAsLocal()
jIsLocal := m[j].shouldSortAsLocal()
// Local files (and files that used to be local) come first
if iIsLocal && !jIsLocal {
return true
}
if !iIsLocal && jIsLocal {
return false
}
// If both are local files, sort by filename. Note that we should never
// hit equality here since two files can't have the same path.
if iIsLocal && jIsLocal {
return strings.Compare(m[i].localPath, m[j].localPath) == -1
}
// Neither are local files so sort by date descending
if !m[i].CreatedAt.Equal(m[j].CreatedAt) {
return m[i].CreatedAt.After(m[j].CreatedAt)
}
// If the times also match, sort by unique ID.
ids := []ksuid.KSUID{m[i].uniqueID, m[j].uniqueID}
ksuid.Sort(ids)
return ids[0] == m[i].uniqueID
}
func (m markdown) relativeTime() string { func (m markdown) relativeTime() string {
return relativeTime(m.CreatedAt) return relativeTime(m.Modtime)
} }
// Normalize text to aid in the filtering process. In particular, we remove // Normalize text to aid in the filtering process. In particular, we remove
@ -153,18 +51,6 @@ func normalize(in string) (string, error) {
return out, err return out, err
} }
// wrapMarkdowns wraps a *charm.Markdown with a *markdown in order to add some
// extra metadata.
func wrapMarkdowns(t DocType, md []*charm.Markdown) (m []*markdown) {
for _, v := range md {
m = append(m, &markdown{
docType: t,
Markdown: *v,
})
}
return m
}
// Return the time in a human-readable format relative to the current time. // Return the time in a human-readable format relative to the current time.
func relativeTime(then time.Time) string { func relativeTime(then time.Time) string {
now := time.Now() now := time.Now()

View file

@ -2,25 +2,28 @@ package ui
import ( import (
"fmt" "fmt"
"log"
"math" "math"
"path/filepath"
"strings" "strings"
"time" "time"
"github.com/atotto/clipboard" "github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/charm"
"github.com/charmbracelet/glamour" "github.com/charmbracelet/glamour"
"github.com/charmbracelet/glow/utils"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
runewidth "github.com/mattn/go-runewidth" runewidth "github.com/mattn/go-runewidth"
"github.com/muesli/reflow/ansi" "github.com/muesli/reflow/ansi"
"github.com/muesli/reflow/truncate" "github.com/muesli/reflow/truncate"
"github.com/muesli/termenv"
) )
const statusBarHeight = 1 const (
statusBarHeight = 1
lineNumberWidth = 4
)
var ( var (
pagerHelpHeight int pagerHelpHeight int
@ -28,11 +31,7 @@ var (
mintGreen = lipgloss.AdaptiveColor{Light: "#89F0CB", Dark: "#89F0CB"} mintGreen = lipgloss.AdaptiveColor{Light: "#89F0CB", Dark: "#89F0CB"}
darkGreen = lipgloss.AdaptiveColor{Light: "#1C8760", Dark: "#1C8760"} darkGreen = lipgloss.AdaptiveColor{Light: "#1C8760", Dark: "#1C8760"}
noteHeading = lipgloss.NewStyle(). lineNumberFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
Foreground(cream).
Background(green).
Padding(0, 1).
SetString("Set Memo")
statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"} statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
statusBarBg = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"} statusBarBg = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"}
@ -52,21 +51,11 @@ var (
Background(lipgloss.AdaptiveColor{Light: "#DCDCDC", Dark: "#323232"}). Background(lipgloss.AdaptiveColor{Light: "#DCDCDC", Dark: "#323232"}).
Render Render
statusBarStashDotStyle = lipgloss.NewStyle().
Foreground(green).
Background(statusBarBg).
Render
statusBarMessageStyle = lipgloss.NewStyle(). statusBarMessageStyle = lipgloss.NewStyle().
Foreground(mintGreen). Foreground(mintGreen).
Background(darkGreen). Background(darkGreen).
Render Render
statusBarMessageStashIconStyle = lipgloss.NewStyle().
Foreground(mintGreen).
Background(darkGreen).
Render
statusBarMessageScrollPosStyle = lipgloss.NewStyle(). statusBarMessageScrollPosStyle = lipgloss.NewStyle().
Foreground(mintGreen). Foreground(mintGreen).
Background(darkGreen). Background(darkGreen).
@ -82,47 +71,27 @@ var (
Background(lipgloss.AdaptiveColor{Light: "#f2f2f2", Dark: "#1B1B1B"}). Background(lipgloss.AdaptiveColor{Light: "#f2f2f2", Dark: "#1B1B1B"}).
Render Render
spinnerStyle = lipgloss.NewStyle(). lineNumberStyle = lipgloss.NewStyle().
Foreground(statusBarNoteFg). Foreground(lineNumberFg).
Background(statusBarBg) Render
pagerNoteInputPromptStyle = lipgloss.NewStyle().
Foreground(darkGray).
Background(yellowGreen).
Padding(0, 1)
pagerNoteInputStyle = lipgloss.NewStyle().
Foreground(darkGray).
Background(yellowGreen)
pagerNoteInputCursorStyle = lipgloss.NewStyle().
Foreground(fuchsia)
) )
type ( type (
contentRenderedMsg string contentRenderedMsg string
noteSavedMsg *charm.Markdown
) )
type pagerState int type pagerState int
const ( const (
pagerStateBrowse pagerState = iota pagerStateBrowse pagerState = iota
pagerStateSetNote
pagerStateStashing
pagerStateStashSuccess
pagerStateStatusMessage pagerStateStatusMessage
) )
type pagerModel struct { type pagerModel struct {
common *commonModel common *commonModel
viewport viewport.Model viewport viewport.Model
state pagerState state pagerState
showHelp bool showHelp bool
textInput textinput.Model
spinner spinner.Model
spinnerStart time.Time
statusMessage string statusMessage string
statusMessageTimer *time.Timer statusMessageTimer *time.Timer
@ -130,10 +99,6 @@ type pagerModel struct {
// Current document being rendered, sans-glamour rendering. We cache // Current document being rendered, sans-glamour rendering. We cache
// it here so we can re-render it on resize. // it here so we can re-render it on resize.
currentDocument markdown currentDocument markdown
// Newly stashed markdown. We store it here temporarily so we can replace
// currentDocument above after a stash.
stashedDocument *markdown
} }
func newPagerModel(common *commonModel) pagerModel { func newPagerModel(common *commonModel) pagerModel {
@ -142,34 +107,16 @@ func newPagerModel(common *commonModel) pagerModel {
vp.YPosition = 0 vp.YPosition = 0
vp.HighPerformanceRendering = config.HighPerformancePager vp.HighPerformanceRendering = config.HighPerformancePager
// Text input for notes/memos
ti := textinput.New()
ti.Prompt = " > "
ti.PromptStyle = pagerNoteInputPromptStyle
ti.TextStyle = pagerNoteInputStyle
ti.CursorStyle = pagerNoteInputCursorStyle
ti.CharLimit = noteCharacterLimit
ti.Focus()
// Text input for search
sp := spinner.New()
sp.Style = spinnerStyle
return pagerModel{ return pagerModel{
common: common, common: common,
state: pagerStateBrowse, state: pagerStateBrowse,
textInput: ti, viewport: vp,
viewport: vp,
spinner: sp,
} }
} }
func (m *pagerModel) setSize(w, h int) { func (m *pagerModel) setSize(w, h int) {
m.viewport.Width = w m.viewport.Width = w
m.viewport.Height = h - statusBarHeight m.viewport.Height = h - statusBarHeight
m.textInput.Width = w -
ansi.PrintableRuneWidth(noteHeading.String()) -
ansi.PrintableRuneWidth(m.textInput.Prompt) - 1
if m.showHelp { if m.showHelp {
if pagerHelpHeight == 0 { if pagerHelpHeight == 0 {
@ -191,13 +138,18 @@ func (m *pagerModel) toggleHelp() {
} }
} }
type pagerStatusMessage struct {
message string
isError bool
}
// Perform stuff that needs to happen after a successful markdown stash. Note // Perform stuff that needs to happen after a successful markdown stash. Note
// that the the returned command should be sent back the through the pager // that the the returned command should be sent back the through the pager
// update function. // update function.
func (m *pagerModel) showStatusMessage(statusMessage string) tea.Cmd { func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd {
// Show a success message to the user // Show a success message to the user
m.state = pagerStateStatusMessage m.state = pagerStateStatusMessage
m.statusMessage = statusMessage m.statusMessage = msg.message
if m.statusMessageTimer != nil { if m.statusMessageTimer != nil {
m.statusMessageTimer.Stop() m.statusMessageTimer.Stop()
} }
@ -216,7 +168,6 @@ func (m *pagerModel) unload() {
m.state = pagerStateBrowse m.state = pagerStateBrowse
m.viewport.SetContent("") m.viewport.SetContent("")
m.viewport.YOffset = 0 m.viewport.YOffset = 0
m.textInput.Reset()
} }
func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
@ -227,126 +178,38 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
switch m.state { switch msg.String() {
case pagerStateSetNote: case "q", keyEsc:
switch msg.String() { if m.state != pagerStateBrowse {
case keyEsc:
m.state = pagerStateBrowse m.state = pagerStateBrowse
return m, nil return m, nil
case keyEnter:
var cmd tea.Cmd
if m.textInput.Value() != m.currentDocument.Note { // don't update if the note didn't change
m.currentDocument.Note = m.textInput.Value() // update optimistically
cmd = saveDocumentNote(m.common.cc, m.currentDocument.ID, m.currentDocument.Note)
}
m.state = pagerStateBrowse
m.textInput.Reset()
return m, cmd
} }
default: case "home", "g":
switch msg.String() { m.viewport.GotoTop()
case "q", keyEsc: if m.viewport.HighPerformanceRendering {
if m.state != pagerStateBrowse { cmds = append(cmds, viewport.Sync(m.viewport))
m.state = pagerStateBrowse }
return m, nil case "end", "G":
} m.viewport.GotoBottom()
case "home", "g": if m.viewport.HighPerformanceRendering {
m.viewport.GotoTop() cmds = append(cmds, viewport.Sync(m.viewport))
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case "end", "G":
m.viewport.GotoBottom()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case "m":
isStashed := m.currentDocument.docType == StashedDoc ||
m.currentDocument.docType == ConvertedDoc
// Users can only set the note on user-stashed markdown
if !isStashed {
break
}
m.state = pagerStateSetNote
// Stop the timer for hiding a status message since changing
// the state above will have cleared it.
if m.statusMessageTimer != nil {
m.statusMessageTimer.Stop()
}
// Pre-populate note with existing value
if m.textInput.Value() == "" {
m.textInput.SetValue(m.currentDocument.Note)
m.textInput.CursorEnd()
}
return m, textinput.Blink
case "e":
if m.currentDocument.docType == LocalDoc {
return m, openEditor(m.currentDocument.localPath)
}
case "c":
err := clipboard.WriteAll(m.currentDocument.Body)
if err != nil {
cmds = append(cmds, m.showStatusMessage("Unable to copy contents"))
} else {
cmds = append(cmds, m.showStatusMessage("Copied contents"))
}
case "s":
if m.common.authStatus != authOK {
break
}
md := m.currentDocument
_, alreadyStashed := m.common.filesStashed[md.stashID]
if alreadyStashed {
cmds = append(cmds, m.showStatusMessage("Already stashed"))
break
}
// Stash a local document
if m.state != pagerStateStashing && stashableDocTypes.Contains(md.docType) {
m.state = pagerStateStashing
m.spinnerStart = time.Now()
cmds = append(
cmds,
stashDocument(m.common.cc, md),
m.spinner.Tick,
)
}
case "?":
m.toggleHelp()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
} }
}
case spinner.TickMsg: case "e":
spinnerMinTimeout := m.spinnerStart. return m, openEditor(m.currentDocument.localPath)
Add(spinnerVisibilityTimeout).
Add(spinnerMinLifetime)
if m.state == pagerStateStashing || time.Now().Before(spinnerMinTimeout) { case "c":
// We're either still stashing or we haven't reached the spinner's // Copy using OSC 52
// full lifetime. In either case we need to spin the spinner termenv.Copy(m.currentDocument.Body)
// irrespective of it's more fine-grained visibility rules. // Copy using native system clipboard
var cmd tea.Cmd _ = clipboard.WriteAll(m.currentDocument.Body)
m.spinner, cmd = m.spinner.Update(msg) cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Copied contents", false}))
cmds = append(cmds, cmd)
} else if m.state == pagerStateStashSuccess { case "?":
// Successful stash. Stop spinning and update accordingly. m.toggleHelp()
m.state = pagerStateBrowse if m.viewport.HighPerformanceRendering {
m.currentDocument = *m.stashedDocument cmds = append(cmds, viewport.Sync(m.viewport))
m.stashedDocument = nil }
cmds = append(cmds, m.showStatusMessage("Stashed!"))
} }
// Glow has rendered the content // Glow has rendered the content
@ -356,75 +219,36 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
cmds = append(cmds, viewport.Sync(m.viewport)) cmds = append(cmds, viewport.Sync(m.viewport))
} }
case editMardownMsg:
return m, openEditor(msg.md.localPath)
// We've finished editing the document, potentially making changes. Let's // We've finished editing the document, potentially making changes. Let's
// retrieve the latest version of the document so that we display // retrieve the latest version of the document so that we display
// up-to-date contents. // up-to-date contents.
case editorFinishedMsg: case editorFinishedMsg:
return m, loadLocalMarkdown(&m.currentDocument) return m, loadLocalMarkdown(&m.currentDocument)
// We've reveived terminal dimensions, either for the first time or // We've received terminal dimensions, either for the first time or
// after a resize // after a resize
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
return m, renderWithGlamour(m, m.currentDocument.Body) return m, renderWithGlamour(m, m.currentDocument.Body)
case stashSuccessMsg:
// Stashing was successful. Convert the loaded document to a stashed
// one and show a status message. Note that we're also handling this
// message in the main update function where we're adding this stashed
// item to the stash listing.
m.state = pagerStateStashSuccess
if !m.spinnerVisible() {
// The spinner has finished spinning, so tell the user the stash
// was successful.
m.state = pagerStateBrowse
m.currentDocument = markdown(msg)
cmds = append(cmds, m.showStatusMessage("Stashed!"))
} else {
// The spinner is still spinning, so just take note of the newly
// stashed document for now.
md := markdown(msg)
m.stashedDocument = &md
}
case stashFailMsg:
delete(m.common.filesStashed, msg.markdown.stashID)
case statusMessageTimeoutMsg: case statusMessageTimeoutMsg:
m.state = pagerStateBrowse m.state = pagerStateBrowse
} }
switch m.state { m.viewport, cmd = m.viewport.Update(msg)
case pagerStateSetNote: cmds = append(cmds, cmd)
m.textInput, cmd = m.textInput.Update(msg)
cmds = append(cmds, cmd)
default:
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
// spinnerVisible returns whether or not the spinner should be drawn.
func (m pagerModel) spinnerVisible() bool {
windowStart := m.spinnerStart.Add(spinnerVisibilityTimeout)
windowEnd := windowStart.Add(spinnerMinLifetime)
now := time.Now()
return now.After(windowStart) && now.Before(windowEnd)
}
func (m pagerModel) View() string { func (m pagerModel) View() string {
var b strings.Builder var b strings.Builder
fmt.Fprint(&b, m.viewport.View()+"\n") fmt.Fprint(&b, m.viewport.View()+"\n")
// Footer // Footer
switch m.state { m.statusBarView(&b)
case pagerStateSetNote:
m.setNoteView(&b)
default:
m.statusBarView(&b)
}
if m.showHelp { if m.showHelp {
fmt.Fprint(&b, "\n"+m.helpView()) fmt.Fprint(&b, "\n"+m.helpView())
@ -440,11 +264,10 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
percentToStringMagnitude float64 = 100.0 percentToStringMagnitude float64 = 100.0
) )
isStashed := m.currentDocument.docType == StashedDoc || m.currentDocument.docType == ConvertedDoc
showStatusMessage := m.state == pagerStateStatusMessage showStatusMessage := m.state == pagerStateStatusMessage
// Logo // Logo
logo := glowLogoView(" Glow ") logo := glowLogoView()
// Scroll percent // Scroll percent
percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent())) percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent()))
@ -463,34 +286,16 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
helpNote = statusBarHelpStyle(" ? Help ") helpNote = statusBarHelpStyle(" ? Help ")
} }
// Status indicator; spinner or stash dot
var statusIndicator string
if m.state == pagerStateStashing || m.state == pagerStateStashSuccess {
var spinner string
if m.spinnerVisible() {
spinner = m.spinner.View()
}
statusIndicator = statusBarNoteStyle(" ") + spinner
} else if isStashed && showStatusMessage {
statusIndicator = statusBarMessageStashIconStyle(" " + pagerStashIcon)
} else if isStashed {
statusIndicator = statusBarStashDotStyle(" " + pagerStashIcon)
}
// Note // Note
var note string var note string
if showStatusMessage { if showStatusMessage {
note = m.statusMessage note = m.statusMessage
} else { } else {
note = m.currentDocument.Note note = m.currentDocument.Note
if len(note) == 0 {
note = "(No memo)"
}
} }
note = truncate.StringWithTail(" "+note+" ", uint(max(0, note = truncate.StringWithTail(" "+note+" ", uint(max(0,
m.common.width- m.common.width-
ansi.PrintableRuneWidth(logo)- ansi.PrintableRuneWidth(logo)-
ansi.PrintableRuneWidth(statusIndicator)-
ansi.PrintableRuneWidth(scrollPercent)- ansi.PrintableRuneWidth(scrollPercent)-
ansi.PrintableRuneWidth(helpNote), ansi.PrintableRuneWidth(helpNote),
)), ellipsis) )), ellipsis)
@ -504,7 +309,6 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
padding := max(0, padding := max(0,
m.common.width- m.common.width-
ansi.PrintableRuneWidth(logo)- ansi.PrintableRuneWidth(logo)-
ansi.PrintableRuneWidth(statusIndicator)-
ansi.PrintableRuneWidth(note)- ansi.PrintableRuneWidth(note)-
ansi.PrintableRuneWidth(scrollPercent)- ansi.PrintableRuneWidth(scrollPercent)-
ansi.PrintableRuneWidth(helpNote), ansi.PrintableRuneWidth(helpNote),
@ -516,9 +320,8 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
emptySpace = statusBarNoteStyle(emptySpace) emptySpace = statusBarNoteStyle(emptySpace)
} }
fmt.Fprintf(b, "%s%s%s%s%s%s", fmt.Fprintf(b, "%s%s%s%s%s",
logo, logo,
statusIndicator,
note, note,
emptySpace, emptySpace,
scrollPercent, scrollPercent,
@ -526,36 +329,18 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
) )
} }
func (m pagerModel) setNoteView(b *strings.Builder) {
fmt.Fprint(b, noteHeading)
fmt.Fprint(b, m.textInput.View())
}
func (m pagerModel) helpView() (s string) { func (m pagerModel) helpView() (s string) {
memoOrStash := "m set memo" edit := "e edit this document"
if m.common.authStatus == authOK && m.currentDocument.docType == LocalDoc {
memoOrStash = "s stash this document"
}
editOrBlank := "e edit this document"
if m.currentDocument.docType != LocalDoc || m.currentDocument.localPath == "" {
editOrBlank = ""
}
col1 := []string{ col1 := []string{
"g/home go to top", "g/home go to top",
"G/end go to bottom", "G/end go to bottom",
"c copy contents", "c copy contents",
editOrBlank, edit,
memoOrStash,
"esc back to files", "esc back to files",
"q quit", "q quit",
} }
if m.currentDocument.docType == NewsDoc {
deleteFromStringSlice(col1, 3)
}
s += "\n" s += "\n"
s += "k/↑ up " + col1[0] + "\n" s += "k/↑ up " + col1[0] + "\n"
s += "j/↓ down " + col1[1] + "\n" s += "j/↓ down " + col1[1] + "\n"
@ -591,9 +376,7 @@ func renderWithGlamour(m pagerModel, md string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
s, err := glamourRender(m, md) s, err := glamourRender(m, md)
if err != nil { if err != nil {
if debug { log.Error("error rendering with Glamour", "error", err)
log.Println("error rendering with Glamour:", err)
}
return errMsg{err} return errMsg{err}
} }
return contentRenderedMsg(s) return contentRenderedMsg(s)
@ -602,53 +385,58 @@ func renderWithGlamour(m pagerModel, md string) tea.Cmd {
// This is where the magic happens. // This is where the magic happens.
func glamourRender(m pagerModel, markdown string) (string, error) { func glamourRender(m pagerModel, markdown string) (string, error) {
trunc := lipgloss.NewStyle().MaxWidth(m.viewport.Width - lineNumberWidth).Render
if !config.GlamourEnabled { if !config.GlamourEnabled {
return markdown, nil return markdown, nil
} }
// initialize glamour isCode := !utils.IsMarkdownFile(m.currentDocument.Note)
var gs glamour.TermRendererOption width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width))
if m.common.cfg.GlamourStyle == "auto" { if isCode {
gs = glamour.WithAutoStyle() width = 0
} else {
gs = glamour.WithStylePath(m.common.cfg.GlamourStyle)
} }
width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width))
r, err := glamour.NewTermRenderer( r, err := glamour.NewTermRenderer(
gs, utils.GlamourStyle(m.common.cfg.GlamourStyle, isCode),
glamour.WithWordWrap(width), glamour.WithWordWrap(width),
) )
if err != nil { if err != nil {
return "", err return "", err
} }
if isCode {
markdown = utils.WrapCodeBlock(markdown, filepath.Ext(m.currentDocument.Note))
}
out, err := r.Render(markdown) out, err := r.Render(markdown)
if err != nil { if err != nil {
return "", err return "", err
} }
if isCode {
out = strings.TrimSpace(out)
}
// trim lines // trim lines
lines := strings.Split(out, "\n") lines := strings.Split(out, "\n")
var content string var content strings.Builder
for i, s := range lines { for i, s := range lines {
content += strings.TrimSpace(s) if isCode {
content.WriteString(lineNumberStyle(fmt.Sprintf("%"+fmt.Sprint(lineNumberWidth)+"d", i+1)))
content.WriteString(trunc(s))
} else {
content.WriteString(strings.TrimSpace(s))
}
// don't add an artificial newline after the last split // don't add an artificial newline after the last split
if i+1 < len(lines) { if i+1 < len(lines) {
content += "\n" content.WriteRune('\n')
} }
} }
return content, nil return content.String(), nil
} }
// ETC type editMardownMsg struct{ md *markdown }
// Note: this runs in linear time; O(n).
func deleteFromStringSlice(a []string, i int) []string {
copy(a[i:], a[i+1:])
a[len(a)-1] = ""
return a[:len(a)-1]
}

12
ui/sort.go Normal file
View 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)
})
}

File diff suppressed because it is too large Load diff

View file

@ -99,17 +99,7 @@ func (m stashModel) helpView() (string, int) {
return m.renderHelp(h) return m.renderHelp(h)
} }
// Help for when we're interacting with a single document
switch m.selectionState {
case selectionSettingNote:
return m.renderHelp([]string{"enter", "confirm", "esc", "cancel"}, []string{"q", "quit"})
case selectionPromptingDelete:
return m.renderHelp([]string{"y", "delete", "n", "cancel"}, []string{"q", "quit"})
}
var ( var (
isStashed bool
isStashable bool
isEditable bool isEditable bool
navHelp []string navHelp []string
filterHelp []string filterHelp []string
@ -119,13 +109,6 @@ func (m stashModel) helpView() (string, int) {
appHelp []string appHelp []string
) )
if numDocs > 0 {
md := m.selectedMarkdown()
isStashed = md != nil && md.docType == StashedDoc
isStashable = md != nil && md.docType == LocalDoc && m.online()
isEditable = md != nil && md.docType == LocalDoc && md.localPath != ""
}
if numDocs > 0 && m.showFullHelp { if numDocs > 0 && m.showFullHelp {
navHelp = []string{"enter", "open", "j/k ↑/↓", "choose"} navHelp = []string{"enter", "open", "j/k ↑/↓", "choose"}
} }
@ -143,16 +126,13 @@ func (m stashModel) helpView() (string, int) {
} }
// If we're browsing a filtered set // If we're browsing a filtered set
if m.filterState == filterApplied { if m.filterApplied() {
filterHelp = []string{"/", "edit search", "esc", "clear search"} filterHelp = []string{"/", "edit search", "esc", "clear filter"}
} else { } else {
filterHelp = []string{"/", "find"} filterHelp = []string{"/", "find"}
} if m.stashFullyLoaded {
filterHelp = append(filterHelp, "t", "team filter")
if isStashed { }
selectionHelp = []string{"x", "delete", "m", "set memo"}
} else if isStashable {
selectionHelp = []string{"s", "stash"}
} }
if isEditable { if isEditable {
@ -181,13 +161,15 @@ func (m stashModel) helpView() (string, int) {
return m.renderHelp(navHelp, filterHelp, selectionHelp, editHelp, sectionHelp, appHelp) return m.renderHelp(navHelp, filterHelp, selectionHelp, editHelp, sectionHelp, appHelp)
} }
const minHelpViewHeight = 5
// renderHelp returns the rendered help view and associated line height for // renderHelp returns the rendered help view and associated line height for
// the given groups of help items. // the given groups of help items.
func (m stashModel) renderHelp(groups ...[]string) (string, int) { func (m stashModel) renderHelp(groups ...[]string) (string, int) {
if m.showFullHelp { if m.showFullHelp {
str := m.fullHelpView(groups...) str := m.fullHelpView(groups...)
numLines := strings.Count(str, "\n") + 1 numLines := strings.Count(str, "\n") + 1
return str, numLines return str, max(numLines, minHelpViewHeight)
} }
return m.miniHelpView(concatStringSlices(groups...)...), 1 return m.miniHelpView(concatStringSlices(groups...)...), 1
} }
@ -219,14 +201,8 @@ func (m stashModel) miniHelpView(entries ...string) string {
k := entries[i] k := entries[i]
v := entries[i+1] v := entries[i+1]
switch k { k = grayFg(k)
case "s": v = midGrayFg(v)
k = greenFg(k)
v = semiDimGreenFg(v)
default:
k = grayFg(k)
v = midGrayFg(v)
}
next = fmt.Sprintf("%s %s", k, v) next = fmt.Sprintf("%s %s", k, v)

View file

@ -2,47 +2,31 @@ package ui
import ( import (
"fmt" "fmt"
"log"
"strings" "strings"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/ansi" "github.com/charmbracelet/log"
"github.com/muesli/reflow/truncate" "github.com/muesli/reflow/truncate"
"github.com/sahilm/fuzzy" "github.com/sahilm/fuzzy"
) )
const ( const (
verticalLine = "│" verticalLine = "│"
noMemoTitle = "No Memo"
fileListingStashIcon = "• " fileListingStashIcon = "• "
) )
func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) { func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) {
var ( var (
truncateTo = uint(m.common.width - stashViewHorizontalPadding*2) truncateTo = uint(m.common.width - stashViewHorizontalPadding*2)
gutter string gutter string
title = md.Note title = truncate.StringWithTail(md.Note, truncateTo, ellipsis)
date = md.relativeTime() date = md.relativeTime()
icon = "" editedBy = ""
hasEditedBy = false
icon = ""
separator = ""
) )
switch md.docType {
case NewsDoc:
if title == "" {
title = "News"
} else {
title = truncate.StringWithTail(title, truncateTo, ellipsis)
}
case StashedDoc, ConvertedDoc:
icon = fileListingStashIcon
if title == "" {
title = noMemoTitle
}
title = truncate.StringWithTail(title, truncateTo-uint(ansi.PrintableRuneWidth(icon)), ellipsis)
default:
title = truncate.StringWithTail(title, truncateTo, ellipsis)
}
isSelected := index == m.cursor() isSelected := index == m.cursor()
isFiltering := m.filterState == filtering isFiltering := m.filterState == filtering
singleFilteredItem := isFiltering && len(m.getVisibleMarkdowns()) == 1 singleFilteredItem := isFiltering && len(m.getVisibleMarkdowns()) == 1
@ -52,87 +36,65 @@ func stashItemView(b *strings.Builder, m stashModel, index int, md *markdown) {
// highlight that first item since pressing return will open it. // highlight that first item since pressing return will open it.
if isSelected && !isFiltering || singleFilteredItem { if isSelected && !isFiltering || singleFilteredItem {
// Selected item // Selected item
if m.statusMessage == stashingStatusMessage {
switch m.selectionState { gutter = greenFg(verticalLine)
case selectionPromptingDelete:
gutter = faintRedFg(verticalLine)
icon = faintRedFg(icon)
title = redFg(title)
date = faintRedFg(date)
case selectionSettingNote:
gutter = dullYellowFg(verticalLine)
icon = ""
title = m.noteInput.View()
date = dullYellowFg(date)
default:
if m.common.latestFileStashed == md.stashID &&
m.statusMessage == stashingStatusMessage {
gutter = greenFg(verticalLine)
icon = dimGreenFg(icon)
title = greenFg(title)
date = semiDimGreenFg(date)
} else {
gutter = dullFuchsiaFg(verticalLine)
icon = dullFuchsiaFg(icon)
if m.currentSection().key == filterSection &&
m.filterState == filterApplied || singleFilteredItem {
s := lipgloss.NewStyle().Foreground(fuchsia)
title = styleFilteredText(title, m.filterInput.Value(), s, s.Copy().Underline(true))
} else {
title = fuchsiaFg(title)
}
date = dullFuchsiaFg(date)
}
}
} else {
// Regular (non-selected) items
gutter = " "
if m.common.latestFileStashed == md.stashID &&
m.statusMessage == stashingStatusMessage {
icon = dimGreenFg(icon) icon = dimGreenFg(icon)
title = greenFg(title) title = greenFg(title)
date = semiDimGreenFg(date) date = semiDimGreenFg(date)
} else if md.docType == NewsDoc { editedBy = semiDimGreenFg(editedBy)
if isFiltering && m.filterInput.Value() == "" { separator = semiDimGreenFg(separator)
title = dimIndigoFg(title) } else {
date = dimSubtleIndigoFg(date) gutter = dullFuchsiaFg(verticalLine)
if m.currentSection().key == filterSection &&
m.filterState == filterApplied || singleFilteredItem {
s := lipgloss.NewStyle().Foreground(fuchsia)
title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true))
} else { } else {
s := lipgloss.NewStyle().Foreground(indigo) title = fuchsiaFg(title)
title = styleFilteredText(title, m.filterInput.Value(), s, s.Copy().Underline(true)) icon = fuchsiaFg(icon)
date = subtleIndigoFg(date)
} }
date = dimFuchsiaFg(date)
editedBy = dimDullFuchsiaFg(editedBy)
separator = dullFuchsiaFg(separator)
}
} else {
gutter = " "
if m.statusMessage == stashingStatusMessage {
icon = dimGreenFg(icon)
title = greenFg(title)
date = semiDimGreenFg(date)
editedBy = semiDimGreenFg(editedBy)
separator = semiDimGreenFg(separator)
} else if isFiltering && m.filterInput.Value() == "" { } else if isFiltering && m.filterInput.Value() == "" {
icon = dimGreenFg(icon) icon = dimGreenFg(icon)
if title == noMemoTitle { title = dimNormalFg(title)
title = dimBrightGrayFg(title)
} else {
title = dimNormalFg(title)
}
date = dimBrightGrayFg(date) date = dimBrightGrayFg(date)
editedBy = dimBrightGrayFg(editedBy)
separator = dimBrightGrayFg(separator)
} else { } else {
icon = greenFg(icon) icon = greenFg(icon)
if title == noMemoTitle {
title = brightGrayFg(title) s := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"})
} else { title = styleFilteredText(title, m.filterInput.Value(), s, s.Underline(true))
s := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}) date = grayFg(date)
title = styleFilteredText(title, m.filterInput.Value(), s, s.Copy().Underline(true)) editedBy = midGrayFg(editedBy)
} separator = brightGrayFg(separator)
date = brightGrayFg(date)
} }
} }
fmt.Fprintf(b, "%s %s%s\n", gutter, icon, title) fmt.Fprintf(b, "%s %s%s%s%s\n", gutter, icon, separator, separator, title)
fmt.Fprintf(b, "%s %s", gutter, date) fmt.Fprintf(b, "%s %s", gutter, date)
if hasEditedBy {
fmt.Fprintf(b, " %s", editedBy)
}
} }
func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle lipgloss.Style) string { func styleFilteredText(haystack, needles string, defaultStyle, matchedStyle lipgloss.Style) string {
b := strings.Builder{} b := strings.Builder{}
normalizedHay, err := normalize(haystack) normalizedHay, err := normalize(haystack)
if err != nil && debug { if err != nil {
log.Printf("error normalizing '%s': %v", haystack, err) log.Error("error normalizing", "haystack", haystack, "error", err)
} }
matches := fuzzy.Find(needles, []string{normalizedHay}) matches := fuzzy.Find(needles, []string{normalizedHay})

View file

@ -1,84 +1,46 @@
package ui package ui
import . "github.com/charmbracelet/lipgloss" //nolint: revive import "github.com/charmbracelet/lipgloss"
// Colors. // Colors.
var ( var (
normal = AdaptiveColor{Light: "#1A1A1A", Dark: "#dddddd"} normalDim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}
normalDim = AdaptiveColor{Light: "#A49FA5", Dark: "#777777"} gray = lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"}
gray = AdaptiveColor{Light: "#909090", Dark: "#626262"} midGray = lipgloss.AdaptiveColor{Light: "#B2B2B2", Dark: "#4A4A4A"}
midGray = AdaptiveColor{Light: "#B2B2B2", Dark: "#4A4A4A"} darkGray = lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}
darkGray = AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"} brightGray = lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}
brightGray = AdaptiveColor{Light: "#847A85", Dark: "#979797"} dimBrightGray = lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"}
dimBrightGray = AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"} cream = lipgloss.AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"}
indigo = AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"} yellowGreen = lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}
dimIndigo = AdaptiveColor{Light: "#9498FF", Dark: "#494690"} fuchsia = lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}
subtleIndigo = AdaptiveColor{Light: "#7D79F6", Dark: "#514DC1"} dimFuchsia = lipgloss.AdaptiveColor{Light: "#F1A8FF", Dark: "#99519E"}
dimSubtleIndigo = AdaptiveColor{Light: "#BBBDFF", Dark: "#383584"} dullFuchsia = lipgloss.AdaptiveColor{Dark: "#AD58B4", Light: "#F793FF"}
cream = AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"} dimDullFuchsia = lipgloss.AdaptiveColor{Light: "#F6C9FF", Dark: "#7B4380"}
yellowGreen = AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"} green = lipgloss.Color("#04B575")
dullYellowGreen = AdaptiveColor{Light: "#6BCB94", Dark: "#9BA92F"} red = lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"}
fuchsia = AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"} semiDimGreen = lipgloss.AdaptiveColor{Light: "#35D79C", Dark: "#036B46"}
dimFuchsia = AdaptiveColor{Light: "#F1A8FF", Dark: "#99519E"} dimGreen = lipgloss.AdaptiveColor{Light: "#72D2B0", Dark: "#0B5137"}
dullFuchsia = AdaptiveColor{Dark: "#AD58B4", Light: "#F793FF"}
dimDullFuchsia = AdaptiveColor{Light: "#F6C9FF", Dark: "#6B3A6F"}
green = Color("#04B575")
red = AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"}
faintRed = AdaptiveColor{Light: "#FF6F91", Dark: "#C74665"}
semiDimGreen = AdaptiveColor{Light: "#35D79C", Dark: "#036B46"}
dimGreen = AdaptiveColor{Light: "#72D2B0", Dark: "#0B5137"}
) )
// Ulimately, we'll transition to named styles. // Ulimately, we'll transition to named styles.
// nolint:deadcode,unused,varcheck
var ( var (
normalFg = NewStyle().Foreground(normal).Render dimNormalFg = lipgloss.NewStyle().Foreground(normalDim).Render
dimNormalFg = NewStyle().Foreground(normalDim).Render brightGrayFg = lipgloss.NewStyle().Foreground(brightGray).Render
dimBrightGrayFg = lipgloss.NewStyle().Foreground(dimBrightGray).Render
brightGrayFg = NewStyle().Foreground(brightGray).Render grayFg = lipgloss.NewStyle().Foreground(gray).Render
dimBrightGrayFg = NewStyle().Foreground(dimBrightGray).Render midGrayFg = lipgloss.NewStyle().Foreground(midGray).Render
darkGrayFg = lipgloss.NewStyle().Foreground(darkGray)
grayFg = NewStyle().Foreground(gray).Render greenFg = lipgloss.NewStyle().Foreground(green).Render
midGrayFg = NewStyle().Foreground(midGray).Render semiDimGreenFg = lipgloss.NewStyle().Foreground(semiDimGreen).Render
darkGrayFg = NewStyle().Foreground(darkGray) dimGreenFg = lipgloss.NewStyle().Foreground(dimGreen).Render
fuchsiaFg = lipgloss.NewStyle().Foreground(fuchsia).Render
greenFg = NewStyle().Foreground(green).Render dimFuchsiaFg = lipgloss.NewStyle().Foreground(dimFuchsia).Render
semiDimGreenFg = NewStyle().Foreground(semiDimGreen).Render dullFuchsiaFg = lipgloss.NewStyle().Foreground(dullFuchsia).Render
dimGreenFg = NewStyle().Foreground(dimGreen).Render dimDullFuchsiaFg = lipgloss.NewStyle().Foreground(dimDullFuchsia).Render
redFg = lipgloss.NewStyle().Foreground(red).Render
fuchsiaFg = NewStyle().Foreground(fuchsia).Render tabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"})
dimFuchsiaFg = NewStyle().Foreground(dimFuchsia).Render selectedTabStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#333333", Dark: "#979797"})
errorTitleStyle = lipgloss.NewStyle().Foreground(cream).Background(red).Padding(0, 1)
dullFuchsiaFg = NewStyle().Foreground(dullFuchsia).Render subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"})
dimDullFuchsiaFg = NewStyle().Foreground(dimDullFuchsia).Render paginationStyle = subtleStyle
indigoFg = NewStyle().Foreground(fuchsia).Render
dimIndigoFg = NewStyle().Foreground(dimIndigo).Render
subtleIndigoFg = NewStyle().Foreground(subtleIndigo).Render
dimSubtleIndigoFg = NewStyle().Foreground(dimSubtleIndigo).Render
yellowFg = NewStyle().Foreground(yellowGreen).Render // renders light green on light backgrounds
dullYellowFg = NewStyle().Foreground(dullYellowGreen).Render // renders light green on light backgrounds
redFg = NewStyle().Foreground(red).Render
faintRedFg = NewStyle().Foreground(faintRed).Render
)
var (
tabStyle = NewStyle().
Foreground(AdaptiveColor{Light: "#909090", Dark: "#626262"})
selectedTabStyle = NewStyle().
Foreground(AdaptiveColor{Light: "#333333", Dark: "#979797"})
errorTitleStyle = NewStyle().
Foreground(cream).
Background(red).
Padding(0, 1)
subtleStyle = NewStyle().
Foreground(AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"})
paginationStyle = subtleStyle.Copy()
) )

466
ui/ui.go
View file

@ -1,33 +1,25 @@
package ui package ui
import ( import (
"errors"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/charm" "github.com/charmbracelet/glamour"
"github.com/charmbracelet/charm/keygen"
"github.com/charmbracelet/glow/utils" "github.com/charmbracelet/glow/utils"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"github.com/muesli/gitcha" "github.com/muesli/gitcha"
te "github.com/muesli/termenv" te "github.com/muesli/termenv"
"github.com/segmentio/ksuid"
) )
const ( const (
noteCharacterLimit = 256 // should match server statusMessageTimeout = time.Second * 3 // how long to show status messages like "stashed!"
statusMessageTimeout = time.Second * 2 // how long to show status messages like "stashed!"
ellipsis = "…" ellipsis = "…"
// Only show the spinner if it spins for at least this amount of time.
spinnerVisibilityTimeout = time.Millisecond * 140
// Minimum amount of time the spinner should be visible once it starts.
spinnerMinLifetime = time.Millisecond * 550
) )
var ( var (
@ -36,29 +28,25 @@ var (
markdownExtensions = []string{ markdownExtensions = []string{
"*.md", "*.mdown", "*.mkdn", "*.mkd", "*.markdown", "*.md", "*.mdown", "*.mkdn", "*.mkd", "*.markdown",
} }
// True if we're logging to a file, in which case we'll log more stuff.
debug = false
// Types of documents we allow the user to stash.
stashableDocTypes = NewDocTypeSet(LocalDoc, NewsDoc)
) )
// NewProgram returns a new Tea program. // NewProgram returns a new Tea program.
func NewProgram(cfg Config) *tea.Program { func NewProgram(cfg Config) *tea.Program {
if cfg.Logfile != "" { log.Debug(
log.Println("-- Starting Glow ----------------") "Starting glow",
log.Printf("High performance pager: %v", cfg.HighPerformancePager) "high_perf_pager",
log.Printf("Glamour rendering: %v", cfg.GlamourEnabled) cfg.HighPerformancePager,
log.Println("Bubble Tea now initializing...") "glamour",
debug = true cfg.GlamourEnabled,
} )
config = cfg config = cfg
opts := []tea.ProgramOption{tea.WithAltScreen()} opts := []tea.ProgramOption{tea.WithAltScreen()}
if cfg.EnableMouse { if cfg.EnableMouse {
opts = append(opts, tea.WithMouseCellMotion()) opts = append(opts, tea.WithMouseCellMotion())
} }
return tea.NewProgram(newModel(cfg), opts...) m := newModel(cfg)
return tea.NewProgram(m, opts...)
} }
type errMsg struct{ err error } type errMsg struct{ err error }
@ -66,10 +54,6 @@ type errMsg struct{ err error }
func (e errMsg) Error() string { return e.err.Error() } func (e errMsg) Error() string { return e.err.Error() }
type ( type (
newCharmClientMsg *charm.Client
sshAuthErrMsg struct{}
keygenFailedMsg struct{ err error }
keygenSuccessMsg struct{}
initLocalFileSearchMsg struct { initLocalFileSearchMsg struct {
cwd string cwd string
ch chan gitcha.SearchResult ch chan gitcha.SearchResult
@ -79,16 +63,7 @@ type (
type ( type (
foundLocalFileMsg gitcha.SearchResult foundLocalFileMsg gitcha.SearchResult
localFileSearchFinished struct{} localFileSearchFinished struct{}
gotStashMsg []*charm.Markdown
stashLoadErrMsg struct{ err error }
gotNewsMsg []*charm.Markdown
statusMessageTimeoutMsg applicationContext statusMessageTimeoutMsg applicationContext
newsLoadErrMsg struct{ err error }
stashSuccessMsg markdown
stashFailMsg struct {
err error
markdown markdown
}
) )
// applicationContext indicates the area of the application something applies // applicationContext indicates the area of the application something applies
@ -115,60 +90,18 @@ func (s state) String() string {
}[s] }[s]
} }
type authStatus int
const (
authConnecting authStatus = iota
authOK
authFailed
)
func (s authStatus) String() string {
return map[authStatus]string{
authConnecting: "connecting",
authOK: "ok",
authFailed: "failed",
}[s]
}
type keygenState int
const (
keygenUnstarted keygenState = iota
keygenRunning
keygenFinished
)
// Common stuff we'll need to access in all models. // Common stuff we'll need to access in all models.
type commonModel struct { type commonModel struct {
cfg Config cfg Config
cc *charm.Client cwd string
cwd string width int
authStatus authStatus height int
width int
height int
// Local IDs of files stashed this session. We treat this like a set,
// ignoring the value portion with an empty struct.
filesStashed map[ksuid.KSUID]struct{}
// ID of the most recently stashed markdown
latestFileStashed ksuid.KSUID
// Files currently being stashed. We remove files from this set once
// a stash operation has either succeeded or failed.
filesStashing map[ksuid.KSUID]struct{}
}
func (c commonModel) isStashing() bool {
return len(c.filesStashing) > 0
} }
type model struct { type model struct {
common *commonModel common *commonModel
state state state state
keygenState keygenState fatalErr error
fatalErr error
// Sub-models // Sub-models
stash stashModel stash stashModel
@ -201,49 +134,37 @@ func (m *model) unloadDocument() []tea.Cmd {
func newModel(cfg Config) tea.Model { func newModel(cfg Config) tea.Model {
initSections() initSections()
if cfg.GlamourStyle == "auto" { if cfg.GlamourStyle == glamour.AutoStyle {
if te.HasDarkBackground() { if te.HasDarkBackground() {
cfg.GlamourStyle = "dark" cfg.GlamourStyle = glamour.DarkStyle
} else { } else {
cfg.GlamourStyle = "light" cfg.GlamourStyle = glamour.LightStyle
} }
} }
if len(cfg.DocumentTypes) == 0 { teamList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
cfg.DocumentTypes.Add(LocalDoc, StashedDoc, ConvertedDoc, NewsDoc) teamList.Styles.Title = lipgloss.NewStyle().Foreground(yellowGreen)
} teamList.SetStatusBarItemName("team", "teams")
teamList.SetShowHelp(true)
// We use the team list status message as a permanent placeholder.
teamList.StatusMessageLifetime = time.Hour
common := commonModel{ common := commonModel{
cfg: cfg, cfg: cfg,
authStatus: authConnecting,
filesStashed: make(map[ksuid.KSUID]struct{}),
filesStashing: make(map[ksuid.KSUID]struct{}),
} }
return model{ return model{
common: &common, common: &common,
state: stateShowStash, state: stateShowStash,
keygenState: keygenUnstarted, pager: newPagerModel(&common),
pager: newPagerModel(&common), stash: newStashModel(&common),
stash: newStashModel(&common),
} }
} }
func (m model) Init() tea.Cmd { func (m model) Init() tea.Cmd {
var cmds []tea.Cmd cmds := []tea.Cmd{m.stash.spinner.Tick}
d := m.common.cfg.DocumentTypes cmds = append(cmds, m.stash.loadDocs())
if d.Contains(StashedDoc) || d.Contains(NewsDoc) {
cmds = append(cmds,
newCharmClient,
m.stash.spinner.Tick,
)
}
if d.Contains(LocalDoc) {
cmds = append(cmds, findLocalFiles(m))
}
return tea.Batch(cmds...) return tea.Batch(cmds...)
} }
@ -261,7 +182,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "esc": case "esc":
if m.state == stateShowDocument { if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument {
batch := m.unloadDocument() batch := m.unloadDocument()
return m, tea.Batch(batch...) return m, tea.Batch(batch...)
} }
@ -272,28 +193,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.state { switch m.state {
case stateShowStash: case stateShowStash:
// pass through all keys if we're editing the filter // pass through all keys if we're editing the filter
if m.stash.filterState == filtering || m.stash.selectionState == selectionSettingNote { if m.stash.filterState == filtering {
m.stash, cmd = m.stash.update(msg) m.stash, cmd = m.stash.update(msg)
return m, cmd return m, cmd
} }
// Special cases for the pager
case stateShowDocument:
switch m.pager.state {
// If setting a note send all keys straight through
case pagerStateSetNote:
var batch []tea.Cmd
newPagerModel, cmd := m.pager.update(msg)
m.pager = newPagerModel
batch = append(batch, cmd)
return m, tea.Batch(batch...)
}
} }
return m, tea.Quit return m, tea.Quit
case "left", "h", "delete": case "left", "h", "delete":
if m.state == stateShowDocument && m.pager.state != pagerStateSetNote { if m.state == stateShowDocument {
cmds = append(cmds, m.unloadDocument()...) cmds = append(cmds, m.unloadDocument()...)
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
@ -315,66 +224,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.common.cwd = msg.cwd m.common.cwd = msg.cwd
cmds = append(cmds, findNextLocalFile(m)) cmds = append(cmds, findNextLocalFile(m))
case sshAuthErrMsg:
if m.keygenState != keygenFinished { // if we haven't run the keygen yet, do that
m.keygenState = keygenRunning
cmds = append(cmds, generateSSHKeys)
} else {
// The keygen ran but things still didn't work and we can't auth
m.common.authStatus = authFailed
m.stash.err = errors.New("SSH authentication failed; we tried ssh-agent, loading keys from disk, and generating SSH keys")
if debug {
log.Println("entering offline mode;", m.stash.err)
}
// Even though it failed, news/stash loading is finished
m.stash.loaded.Add(StashedDoc, NewsDoc)
}
case keygenFailedMsg:
// Keygen failed. That sucks.
m.common.authStatus = authFailed
m.stash.err = errors.New("could not authenticate; could not generate SSH keys")
if debug {
log.Println("entering offline mode;", m.stash.err)
}
m.keygenState = keygenFinished
// Even though it failed, news/stash loading is finished
m.stash.loaded.Add(StashedDoc, NewsDoc)
case keygenSuccessMsg:
// The keygen's done, so let's try initializing the charm client again
m.keygenState = keygenFinished
cmds = append(cmds, newCharmClient)
case newCharmClientMsg:
m.common.cc = msg
m.common.authStatus = authOK
cmds = append(cmds, loadStash(m.stash), loadNews(m.stash))
case stashLoadErrMsg:
m.common.authStatus = authFailed
case fetchedMarkdownMsg: case fetchedMarkdownMsg:
// We've loaded a markdown file's contents for rendering // We've loaded a markdown file's contents for rendering
m.pager.currentDocument = *msg m.pager.currentDocument = *msg
msg.Body = string(utils.RemoveFrontmatter([]byte(msg.Body))) body := string(utils.RemoveFrontmatter([]byte(msg.Body)))
cmds = append(cmds, renderWithGlamour(m.pager, msg.Body)) cmds = append(cmds, renderWithGlamour(m.pager, body))
case contentRenderedMsg: case contentRenderedMsg:
m.state = stateShowDocument m.state = stateShowDocument
case noteSavedMsg: case localFileSearchFinished:
// A note was saved to a document. This will have been done in the
// pager, so we'll need to find the corresponding note in the stash.
// So, pass the message to the stash for processing.
stashModel, cmd := m.stash.update(msg)
m.stash = stashModel
return m, cmd
case localFileSearchFinished, gotStashMsg, gotNewsMsg:
// Always pass these messages to the stash so we can keep it updated // Always pass these messages to the stash so we can keep it updated
// about network activity, even if the user isn't currently viewing // about network activity, even if the user isn't currently viewing
// the stash. // the stash.
@ -393,35 +252,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
cmds = append(cmds, findNextLocalFile(m)) cmds = append(cmds, findNextLocalFile(m))
case stashSuccessMsg:
// Common handling that should happen regardless of application state
md := markdown(msg)
m.stash.addMarkdowns(&md)
m.common.filesStashed[msg.stashID] = struct{}{}
delete(m.common.filesStashing, md.stashID)
if m.stash.filterApplied() {
for _, v := range m.stash.filteredMarkdowns {
if v.stashID == msg.stashID && v.docType == ConvertedDoc {
// Add the server-side ID we got back so we can do things
// like rename and stash it.
v.ID = msg.ID
// Keep the unique ID in sync so we can do things like
// delete. Note that the markdown received a new unique ID
// when it was added to the file listing in
// stash.addMarkdowns.
v.uniqueID = md.uniqueID
break
}
}
}
case stashFailMsg:
// Common handling that should happen regardless of application state
delete(m.common.filesStashed, msg.markdown.stashID)
delete(m.common.filesStashing, msg.markdown.stashID)
case filteredMarkdownMsg: case filteredMarkdownMsg:
if m.state == stateShowDocument { if m.state == stateShowDocument {
newStashModel, cmd := m.stash.update(msg) newStashModel, cmd := m.stash.update(msg)
@ -476,10 +306,11 @@ func errorView(err error, fatal bool) string {
// COMMANDS // COMMANDS
func findLocalFiles(m model) tea.Cmd { func findLocalFiles(m commonModel) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
log.Info("findLocalFiles")
var ( var (
cwd = m.common.cfg.WorkingDirectory cwd = m.cfg.WorkingDirectory
err error err error
) )
@ -495,26 +326,19 @@ func findLocalFiles(m model) tea.Cmd {
// Note that this is one error check for both cases above // Note that this is one error check for both cases above
if err != nil { if err != nil {
if debug { log.Error("error finding local files", "error", err)
log.Println("error finding local files:", err)
}
return errMsg{err} return errMsg{err}
} }
if debug { log.Debug("local directory is", "cwd", cwd)
log.Println("local directory is:", cwd)
}
var ignore []string var ignore []string
if !m.common.cfg.ShowAllFiles { if !m.cfg.ShowAllFiles {
ignore = ignorePatterns(m) ignore = ignorePatterns(m)
} }
ch, err := gitcha.FindFilesExcept(cwd, markdownExtensions, ignore) ch, err := gitcha.FindFilesExcept(cwd, markdownExtensions, ignore)
if err != nil { if err != nil {
if debug { log.Error("error finding local files", "error", err)
log.Println("error finding local files:", err)
}
return errMsg{err} return errMsg{err}
} }
@ -531,186 +355,11 @@ func findNextLocalFile(m model) tea.Cmd {
return foundLocalFileMsg(res) return foundLocalFileMsg(res)
} }
// We're done // We're done
if debug { log.Debug("local file search finished")
log.Println("local file search finished")
}
return localFileSearchFinished{} return localFileSearchFinished{}
} }
} }
func newCharmClient() tea.Msg {
cfg, err := charm.ConfigFromEnv()
if err != nil {
return errMsg{err}
}
cc, err := charm.NewClient(cfg)
if err == charm.ErrMissingSSHAuth {
if debug {
log.Println("missing SSH auth:", err)
}
return sshAuthErrMsg{}
} else if err != nil {
if debug {
log.Println("error creating new charm client:", err)
}
return errMsg{err}
}
return newCharmClientMsg(cc)
}
func loadStash(m stashModel) tea.Cmd {
return func() tea.Msg {
if m.common.cc == nil {
err := errors.New("no charm client")
if debug {
log.Println("error loading stash:", err)
}
return stashLoadErrMsg{err}
}
stash, err := m.common.cc.GetStash(m.serverPage)
if err != nil {
if debug {
if _, ok := err.(charm.ErrAuthFailed); ok {
log.Println("auth failure while loading stash:", err)
} else {
log.Println("error loading stash:", err)
}
}
return stashLoadErrMsg{err}
}
if debug {
log.Println("loaded stash page", m.serverPage)
}
return gotStashMsg(stash)
}
}
func loadNews(m stashModel) tea.Cmd {
return func() tea.Msg {
if m.common.cc == nil {
err := errors.New("no charm client")
if debug {
log.Println("error loading news:", err)
}
return newsLoadErrMsg{err}
}
news, err := m.common.cc.GetNews(1) // just fetch the first page
if err != nil {
if debug {
log.Println("error loading news:", err)
}
return newsLoadErrMsg{err}
}
if debug {
log.Println("fetched news")
}
return gotNewsMsg(news)
}
}
func generateSSHKeys() tea.Msg {
if debug {
log.Println("running keygen...")
}
_, err := keygen.NewSSHKeyPair(nil)
if err != nil {
if debug {
log.Println("keygen failed:", err)
}
return keygenFailedMsg{err}
}
if debug {
log.Println("keys generated successfully")
}
return keygenSuccessMsg{}
}
func saveDocumentNote(cc *charm.Client, id int, note string) tea.Cmd {
if cc == nil {
return func() tea.Msg {
err := errors.New("can't set note; no charm client")
if debug {
log.Println("error saving note:", err)
}
return errMsg{err}
}
}
return func() tea.Msg {
if err := cc.SetMarkdownNote(id, note); err != nil {
if debug {
log.Println("error saving note:", err)
}
return errMsg{err}
}
return noteSavedMsg(&charm.Markdown{ID: id, Note: note})
}
}
func stashDocument(cc *charm.Client, md markdown) tea.Cmd {
return func() tea.Msg {
if cc == nil {
err := errors.New("can't stash; no charm client")
if debug {
log.Println("error stashing document:", err)
}
return stashFailMsg{err, md}
}
// Is the document missing a body? If so, it likely means it needs to
// be loaded. But...if it turns out the document body really is empty
// then we'll stash it anyway.
if len(md.Body) == 0 {
switch md.docType {
case LocalDoc:
data, err := os.ReadFile(md.localPath)
if err != nil {
if debug {
log.Println("error loading document body for stashing:", err)
}
return stashFailMsg{err, md}
}
md.Body = string(data)
case NewsDoc:
newMD, err := fetchMarkdown(cc, md.ID, md.docType)
if err != nil {
if debug {
log.Println(err)
}
return stashFailMsg{err, md}
}
md.Body = newMD.Body
default:
err := fmt.Errorf("user is attempting to stash an unsupported markdown type: %s", md.docType)
if debug {
log.Println(err)
}
return stashFailMsg{err, md}
}
}
newMd, err := cc.StashMarkdown(md.Note, md.Body)
if err != nil {
if debug {
log.Println("error stashing document:", err)
}
return stashFailMsg{err, md}
}
md.convertToStashed()
// The server sends the whole stashed document back, but we really just
// need to know the ID so we can operate on this newly stashed
// markdown.
md.ID = newMd.ID
return stashSuccessMsg(md)
}
}
func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.Cmd { func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
<-t.C <-t.C
@ -725,19 +374,16 @@ func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.C
// a directory, but we trust that gitcha has already done that. // a directory, but we trust that gitcha has already done that.
func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown { func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown {
md := &markdown{ md := &markdown{
docType: LocalDoc,
localPath: res.Path, localPath: res.Path,
Markdown: charm.Markdown{ Note: stripAbsolutePath(res.Path, cwd),
Note: stripAbsolutePath(res.Path, cwd), Modtime: res.Info.ModTime(),
CreatedAt: res.Info.ModTime(),
},
} }
return md return md
} }
func stripAbsolutePath(fullPath, cwd string) string { func stripAbsolutePath(fullPath, cwd string) string {
return strings.Replace(fullPath, cwd+string(os.PathSeparator), "", -1) return strings.ReplaceAll(fullPath, cwd+string(os.PathSeparator), "")
} }
// Lightweight version of reflow's indent function. // Lightweight version of reflow's indent function.

View file

@ -2,8 +2,13 @@ package utils
import ( import (
"os" "os"
"path/filepath"
"regexp" "regexp"
"strings"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/ansi"
"github.com/charmbracelet/lipgloss"
"github.com/mitchellh/go-homedir" "github.com/mitchellh/go-homedir"
) )
@ -32,3 +37,73 @@ func ExpandPath(path string) string {
} }
return os.ExpandEnv(path) return os.ExpandEnv(path)
} }
// WrapCodeBlock wraps a string in a code block with the given language.
func WrapCodeBlock(s, language string) string {
return "```" + language + "\n" + s + "```"
}
var markdownExtensions = []string{
".md", ".mdown", ".mkdn", ".mkd", ".markdown",
}
// IsMarkdownFile returns whether the filename has a markdown extension.
func IsMarkdownFile(filename string) bool {
ext := filepath.Ext(filename)
if ext == "" {
// By default, assume it's a markdown file.
return true
}
for _, v := range markdownExtensions {
if strings.EqualFold(ext, v) {
return true
}
}
// Has an extension but not markdown
// so assume this is a code file.
return false
}
func GlamourStyle(style string, isCode bool) glamour.TermRendererOption {
if !isCode {
if style == glamour.AutoStyle {
return glamour.WithAutoStyle()
} else {
return glamour.WithStylePath(style)
}
}
// If we are rendering a pure code block, we need to modify the style to
// remove the indentation.
var styleConfig ansi.StyleConfig
switch style {
case glamour.AutoStyle:
if lipgloss.HasDarkBackground() {
styleConfig = glamour.DarkStyleConfig
} else {
styleConfig = glamour.LightStyleConfig
}
case glamour.DarkStyle:
styleConfig = glamour.DarkStyleConfig
case glamour.LightStyle:
styleConfig = glamour.LightStyleConfig
case glamour.PinkStyle:
styleConfig = glamour.PinkStyleConfig
case glamour.NoTTYStyle:
styleConfig = glamour.NoTTYStyleConfig
case glamour.DraculaStyle:
styleConfig = glamour.DraculaStyleConfig
default:
return glamour.WithStylesFromJSONFile(style)
}
var margin uint
styleConfig.CodeBlock.Margin = &margin
return glamour.WithStyles(styleConfig)
}