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
parent d2e7742a6a
commit fce3edf7db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 696 additions and 2966 deletions

View file

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

View file

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

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

View file

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

77
go.mod
View file

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

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

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
import . "github.com/charmbracelet/lipgloss" //nolint:revive
import "github.com/charmbracelet/lipgloss"
var (
keyword = NewStyle().
Foreground(Color("#04B575")).
keyword = lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575")).
Render
paragraph = NewStyle().
paragraph = lipgloss.NewStyle().
Width(78).
Padding(0, 0, 0, 2).
Render

View file

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

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 {
return editorFinishedMsg{err}
}
editor, err := editor.Cmd("Glow", path)
cmd, err := editor.Cmd("Glow", path)
if err != nil {
return func() tea.Msg {
return errMsg{err}
}
return func() tea.Msg { return cb(err) }
}
return tea.ExecProcess(editor, cb)
return tea.ExecProcess(cmd, cb)
}

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

466
ui/ui.go
View file

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

View file

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