2020-05-11 20:13:16 +00:00
|
|
|
package ui
|
|
|
|
|
|
|
|
import (
|
|
|
|
"errors"
|
2020-05-22 18:03:21 +00:00
|
|
|
"fmt"
|
2020-08-24 20:48:10 +00:00
|
|
|
"io/ioutil"
|
2020-06-20 23:54:01 +00:00
|
|
|
"log"
|
2020-07-15 18:18:46 +00:00
|
|
|
"os"
|
2020-05-27 15:55:00 +00:00
|
|
|
"strings"
|
2020-08-21 17:39:59 +00:00
|
|
|
"time"
|
2020-05-11 20:13:16 +00:00
|
|
|
|
2020-07-15 20:41:41 +00:00
|
|
|
"github.com/charmbracelet/bubbles/spinner"
|
2020-05-26 17:42:25 +00:00
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
2020-05-11 20:13:16 +00:00
|
|
|
"github.com/charmbracelet/charm"
|
2020-08-09 14:37:37 +00:00
|
|
|
"github.com/charmbracelet/charm/keygen"
|
2020-05-11 20:13:16 +00:00
|
|
|
"github.com/charmbracelet/charm/ui/common"
|
2020-12-11 01:43:31 +00:00
|
|
|
lib "github.com/charmbracelet/charm/ui/common"
|
2020-10-30 15:14:36 +00:00
|
|
|
"github.com/charmbracelet/glow/utils"
|
2020-07-15 18:18:46 +00:00
|
|
|
"github.com/muesli/gitcha"
|
2020-05-14 19:06:13 +00:00
|
|
|
te "github.com/muesli/termenv"
|
2020-12-10 16:26:26 +00:00
|
|
|
"github.com/segmentio/ksuid"
|
2020-05-14 19:06:13 +00:00
|
|
|
)
|
|
|
|
|
2020-08-21 20:41:06 +00:00
|
|
|
const (
|
|
|
|
noteCharacterLimit = 256 // should match server
|
|
|
|
statusMessageTimeout = time.Second * 2 // how long to show status messages like "stashed!"
|
2020-12-14 23:42:33 +00:00
|
|
|
ellipsis = "…"
|
2020-08-21 20:41:06 +00:00
|
|
|
)
|
2020-08-13 19:37:53 +00:00
|
|
|
|
|
|
|
var (
|
|
|
|
config Config
|
2020-12-11 01:43:31 +00:00
|
|
|
glowLogoTextColor = lib.Color("#ECFD65")
|
2020-12-10 01:12:01 +00:00
|
|
|
|
2020-12-11 01:35:00 +00:00
|
|
|
// 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.
|
2020-12-10 01:12:01 +00:00
|
|
|
stashableDocTypes = NewDocTypeSet(LocalDoc, NewsDoc)
|
2020-05-22 02:29:46 +00:00
|
|
|
)
|
|
|
|
|
2020-08-13 19:37:53 +00:00
|
|
|
// NewProgram returns a new Tea program.
|
2020-10-24 03:30:09 +00:00
|
|
|
func NewProgram(cfg Config) *tea.Program {
|
2020-08-07 16:34:48 +00:00
|
|
|
if cfg.Logfile != "" {
|
2020-06-20 23:54:01 +00:00
|
|
|
log.Println("-- Starting Glow ----------------")
|
|
|
|
log.Printf("High performance pager: %v", cfg.HighPerformancePager)
|
2020-07-17 16:25:40 +00:00
|
|
|
log.Printf("Glamour rendering: %v", cfg.GlamourEnabled)
|
2020-06-20 23:54:01 +00:00
|
|
|
log.Println("Bubble Tea now initializing...")
|
2020-08-18 19:41:57 +00:00
|
|
|
debug = true
|
2020-06-20 23:54:01 +00:00
|
|
|
}
|
2020-08-07 18:05:05 +00:00
|
|
|
config = cfg
|
2020-10-24 03:30:09 +00:00
|
|
|
return tea.NewProgram(newModel(cfg))
|
2020-05-11 20:13:16 +00:00
|
|
|
}
|
|
|
|
|
2020-08-21 20:13:38 +00:00
|
|
|
type errMsg struct{ err error }
|
2020-12-11 01:24:51 +00:00
|
|
|
|
|
|
|
func (e errMsg) Error() string { return e.err.Error() }
|
|
|
|
|
2020-05-11 20:13:16 +00:00
|
|
|
type newCharmClientMsg *charm.Client
|
|
|
|
type sshAuthErrMsg struct{}
|
2020-08-21 17:39:59 +00:00
|
|
|
type keygenFailedMsg struct{ err error }
|
2020-07-15 22:29:58 +00:00
|
|
|
type keygenSuccessMsg struct{}
|
2020-07-15 18:18:46 +00:00
|
|
|
type initLocalFileSearchMsg struct {
|
|
|
|
cwd string
|
2020-07-20 17:48:09 +00:00
|
|
|
ch chan gitcha.SearchResult
|
2020-07-15 18:18:46 +00:00
|
|
|
}
|
2020-07-20 17:48:09 +00:00
|
|
|
type foundLocalFileMsg gitcha.SearchResult
|
2020-07-15 18:18:46 +00:00
|
|
|
type localFileSearchFinished struct{}
|
2020-07-15 19:51:51 +00:00
|
|
|
type gotStashMsg []*charm.Markdown
|
2020-07-17 16:25:40 +00:00
|
|
|
type stashLoadErrMsg struct{ err error }
|
2020-07-15 19:51:51 +00:00
|
|
|
type gotNewsMsg []*charm.Markdown
|
2020-08-21 20:10:39 +00:00
|
|
|
type statusMessageTimeoutMsg applicationContext
|
2020-07-17 16:25:40 +00:00
|
|
|
type newsLoadErrMsg struct{ err error }
|
2020-12-11 00:26:24 +00:00
|
|
|
type stashSuccessMsg markdown
|
|
|
|
type stashFailMsg struct {
|
|
|
|
err error
|
|
|
|
markdown markdown
|
|
|
|
}
|
|
|
|
|
2020-12-11 01:35:00 +00:00
|
|
|
// applicationContext indicates the area of the application something appies
|
|
|
|
// to. Occasionally used as an argument to commands and messages.
|
2020-08-21 17:39:59 +00:00
|
|
|
type applicationContext int
|
|
|
|
|
|
|
|
const (
|
|
|
|
stashContext applicationContext = iota
|
|
|
|
pagerContext
|
|
|
|
)
|
|
|
|
|
2020-12-11 01:35:00 +00:00
|
|
|
// state is the top-level application state.
|
2020-05-11 20:13:16 +00:00
|
|
|
type state int
|
|
|
|
|
|
|
|
const (
|
2020-07-15 19:51:51 +00:00
|
|
|
stateShowStash state = iota
|
2020-05-14 02:08:17 +00:00
|
|
|
stateShowDocument
|
2020-05-11 20:13:16 +00:00
|
|
|
)
|
|
|
|
|
2020-05-22 02:29:46 +00:00
|
|
|
func (s state) String() string {
|
2020-12-11 01:35:00 +00:00
|
|
|
return map[state]string{
|
|
|
|
stateShowStash: "showing file listing",
|
|
|
|
stateShowDocument: "showing document",
|
2020-05-22 02:29:46 +00:00
|
|
|
}[s]
|
|
|
|
}
|
|
|
|
|
2020-09-10 17:32:57 +00:00
|
|
|
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]
|
|
|
|
}
|
|
|
|
|
2020-07-20 18:03:27 +00:00
|
|
|
type keygenState int
|
|
|
|
|
|
|
|
const (
|
|
|
|
keygenUnstarted keygenState = iota
|
|
|
|
keygenRunning
|
|
|
|
keygenFinished
|
|
|
|
)
|
|
|
|
|
2020-12-11 01:43:31 +00:00
|
|
|
// Common stuff we'll need to access in all models.
|
|
|
|
type commonModel struct {
|
2020-11-17 22:37:52 +00:00
|
|
|
cfg Config
|
|
|
|
cc *charm.Client
|
|
|
|
cwd string
|
|
|
|
authStatus authStatus
|
|
|
|
width int
|
|
|
|
height int
|
2020-12-10 16:26:26 +00:00
|
|
|
|
|
|
|
// 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{}
|
2020-12-11 01:20:43 +00:00
|
|
|
|
|
|
|
// Files currently being stashed. We remove files from this set once
|
|
|
|
// a stash operation has either succeeded or failed.
|
|
|
|
filesStashing map[ksuid.KSUID]struct{}
|
|
|
|
}
|
|
|
|
|
2020-12-11 01:43:31 +00:00
|
|
|
func (c commonModel) isStashing() bool {
|
|
|
|
return len(c.filesStashing) > 0
|
2020-11-17 22:37:52 +00:00
|
|
|
}
|
|
|
|
|
2020-05-11 20:13:16 +00:00
|
|
|
type model struct {
|
2020-12-11 01:43:31 +00:00
|
|
|
common *commonModel
|
2020-11-17 22:37:52 +00:00
|
|
|
state state
|
2020-11-26 02:33:44 +00:00
|
|
|
keygenState keygenState
|
2020-11-17 22:37:52 +00:00
|
|
|
fatalErr error
|
|
|
|
|
|
|
|
// Sub-models
|
|
|
|
stash stashModel
|
|
|
|
pager pagerModel
|
2020-07-15 18:18:46 +00:00
|
|
|
|
|
|
|
// Channel that receives paths to local markdown files
|
|
|
|
// (via the github.com/muesli/gitcha package)
|
2020-07-20 17:48:09 +00:00
|
|
|
localFileFinder chan gitcha.SearchResult
|
2020-05-14 02:08:17 +00:00
|
|
|
}
|
|
|
|
|
2020-07-20 23:22:12 +00:00
|
|
|
// unloadDocument unloads a document from the pager. Note that while this
|
|
|
|
// method alters the model we also need to send along any commands returned.
|
|
|
|
func (m *model) unloadDocument() []tea.Cmd {
|
2020-05-14 02:08:17 +00:00
|
|
|
m.state = stateShowStash
|
2020-11-28 23:30:51 +00:00
|
|
|
m.stash.viewState = stashStateReady
|
2020-05-19 17:20:39 +00:00
|
|
|
m.pager.unload()
|
2020-06-03 18:17:41 +00:00
|
|
|
m.pager.showHelp = false
|
2020-07-20 23:22:12 +00:00
|
|
|
|
|
|
|
var batch []tea.Cmd
|
|
|
|
if m.pager.viewport.HighPerformanceRendering {
|
|
|
|
batch = append(batch, tea.ClearScrollArea)
|
|
|
|
}
|
2020-10-18 01:46:02 +00:00
|
|
|
|
2020-12-11 01:20:43 +00:00
|
|
|
if !m.stash.loadingDone() {
|
2020-11-13 21:17:50 +00:00
|
|
|
batch = append(batch, spinner.Tick)
|
2020-07-20 23:22:12 +00:00
|
|
|
}
|
|
|
|
return batch
|
2020-05-11 20:13:16 +00:00
|
|
|
}
|
|
|
|
|
2020-10-24 03:30:09 +00:00
|
|
|
func newModel(cfg Config) tea.Model {
|
|
|
|
if cfg.GlamourStyle == "auto" {
|
2020-11-28 23:30:51 +00:00
|
|
|
if te.HasDarkBackground() {
|
2020-10-24 03:30:09 +00:00
|
|
|
cfg.GlamourStyle = "dark"
|
2020-10-19 00:29:45 +00:00
|
|
|
} else {
|
2020-10-24 03:30:09 +00:00
|
|
|
cfg.GlamourStyle = "light"
|
2020-10-18 01:46:02 +00:00
|
|
|
}
|
2020-10-19 00:29:45 +00:00
|
|
|
}
|
2020-10-18 01:46:02 +00:00
|
|
|
|
2020-11-28 02:52:51 +00:00
|
|
|
if len(cfg.DocumentTypes) == 0 {
|
2020-11-28 23:30:51 +00:00
|
|
|
cfg.DocumentTypes.Add(LocalDoc, StashedDoc, ConvertedDoc, NewsDoc)
|
2020-10-19 00:29:45 +00:00
|
|
|
}
|
2020-09-07 23:14:59 +00:00
|
|
|
|
2020-12-11 01:43:31 +00:00
|
|
|
common := commonModel{
|
2020-12-11 01:20:43 +00:00
|
|
|
cfg: cfg,
|
|
|
|
authStatus: authConnecting,
|
|
|
|
filesStashed: make(map[ksuid.KSUID]struct{}),
|
|
|
|
filesStashing: make(map[ksuid.KSUID]struct{}),
|
2020-11-17 22:37:52 +00:00
|
|
|
}
|
|
|
|
|
2020-10-19 00:29:45 +00:00
|
|
|
return model{
|
2020-12-11 01:43:31 +00:00
|
|
|
common: &common,
|
2020-10-19 00:29:45 +00:00
|
|
|
state: stateShowStash,
|
|
|
|
keygenState: keygenUnstarted,
|
2020-12-11 01:43:31 +00:00
|
|
|
pager: newPagerModel(&common),
|
|
|
|
stash: newStashModel(&common),
|
2020-05-15 17:09:46 +00:00
|
|
|
}
|
2020-05-11 20:13:16 +00:00
|
|
|
}
|
|
|
|
|
2020-10-19 00:29:45 +00:00
|
|
|
func (m model) Init() tea.Cmd {
|
|
|
|
var cmds []tea.Cmd
|
2020-12-11 01:43:31 +00:00
|
|
|
d := m.common.cfg.DocumentTypes
|
2020-10-19 00:29:45 +00:00
|
|
|
|
2020-11-28 02:52:51 +00:00
|
|
|
if d.Contains(StashedDoc) || d.Contains(NewsDoc) {
|
2020-10-19 00:29:45 +00:00
|
|
|
cmds = append(cmds,
|
|
|
|
newCharmClient,
|
2020-11-13 21:17:50 +00:00
|
|
|
spinner.Tick,
|
2020-10-19 00:29:45 +00:00
|
|
|
)
|
|
|
|
}
|
2020-05-11 20:13:16 +00:00
|
|
|
|
2020-11-28 02:52:51 +00:00
|
|
|
if d.Contains(LocalDoc) {
|
2020-10-19 00:29:45 +00:00
|
|
|
cmds = append(cmds, findLocalFiles(m))
|
2020-05-11 20:13:16 +00:00
|
|
|
}
|
|
|
|
|
2020-10-19 00:29:45 +00:00
|
|
|
return tea.Batch(cmds...)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
2020-06-05 20:57:27 +00:00
|
|
|
// If there's been an error, any key exits
|
2020-07-16 19:19:59 +00:00
|
|
|
if m.fatalErr != nil {
|
2020-06-05 20:57:27 +00:00
|
|
|
if _, ok := msg.(tea.KeyMsg); ok {
|
|
|
|
return m, tea.Quit
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-07-15 23:19:44 +00:00
|
|
|
var cmds []tea.Cmd
|
2020-05-13 20:00:27 +00:00
|
|
|
|
2020-05-11 20:13:16 +00:00
|
|
|
switch msg := msg.(type) {
|
2020-05-26 17:42:25 +00:00
|
|
|
case tea.KeyMsg:
|
2020-05-11 20:13:16 +00:00
|
|
|
switch msg.String() {
|
2020-07-20 23:22:12 +00:00
|
|
|
case "q", "esc":
|
2020-05-26 17:42:25 +00:00
|
|
|
var cmd tea.Cmd
|
2020-05-22 02:29:46 +00:00
|
|
|
|
2020-11-13 02:59:33 +00:00
|
|
|
// Send q/esc through to stash
|
2020-05-22 02:29:46 +00:00
|
|
|
switch m.state {
|
|
|
|
case stateShowStash:
|
2020-05-22 13:38:34 +00:00
|
|
|
|
2020-11-26 01:15:21 +00:00
|
|
|
// Q quits if we're filtering, but we still send esc though.
|
2020-12-15 18:37:11 +00:00
|
|
|
if m.stash.filterApplied() {
|
2020-11-26 01:15:21 +00:00
|
|
|
if msg.String() == "q" {
|
|
|
|
return m, tea.Quit
|
|
|
|
}
|
|
|
|
m.stash, cmd = m.stash.update(msg)
|
|
|
|
return m, cmd
|
|
|
|
}
|
2020-11-13 02:59:33 +00:00
|
|
|
|
|
|
|
// Send q/esc through in these cases
|
2020-11-28 23:30:51 +00:00
|
|
|
switch m.stash.viewState {
|
|
|
|
case stashStateReady:
|
2020-11-13 02:59:33 +00:00
|
|
|
|
2020-11-26 01:15:21 +00:00
|
|
|
// Q also quits glow when displaying only newsitems. Esc
|
|
|
|
// still passes through.
|
2020-11-28 23:30:51 +00:00
|
|
|
if msg.String() == "q" {
|
2020-11-19 23:56:46 +00:00
|
|
|
return m, tea.Quit
|
|
|
|
}
|
2020-11-13 02:59:33 +00:00
|
|
|
|
2020-11-25 16:40:24 +00:00
|
|
|
m.stash, cmd = m.stash.update(msg)
|
2020-05-22 02:29:46 +00:00
|
|
|
return m, cmd
|
2020-11-28 23:30:51 +00:00
|
|
|
|
|
|
|
case stashStateShowingError:
|
|
|
|
m.stash, cmd = m.stash.update(msg)
|
|
|
|
return m, cmd
|
2020-05-22 02:29:46 +00:00
|
|
|
}
|
2020-05-22 13:38:34 +00:00
|
|
|
|
2020-06-23 19:11:54 +00:00
|
|
|
// Special cases for the pager
|
2020-05-22 02:29:46 +00:00
|
|
|
case stateShowDocument:
|
2020-07-21 01:29:03 +00:00
|
|
|
switch m.pager.state {
|
2020-08-25 03:26:37 +00:00
|
|
|
// If setting a note send all keys straight through
|
|
|
|
case pagerStateSetNote:
|
|
|
|
var batch []tea.Cmd
|
2020-11-25 16:40:24 +00:00
|
|
|
newPagerModel, cmd := m.pager.update(msg)
|
2020-08-25 03:26:37 +00:00
|
|
|
m.pager = newPagerModel
|
|
|
|
batch = append(batch, cmd)
|
|
|
|
return m, tea.Batch(batch...)
|
|
|
|
|
|
|
|
// Otherwise let the user exit the view or application as
|
|
|
|
// normal.
|
|
|
|
default:
|
2020-07-21 01:29:03 +00:00
|
|
|
switch msg.String() {
|
|
|
|
case "q":
|
|
|
|
return m, tea.Quit
|
|
|
|
case "esc":
|
|
|
|
var batch []tea.Cmd
|
|
|
|
batch = m.unloadDocument()
|
|
|
|
return m, tea.Batch(batch...)
|
|
|
|
}
|
2020-05-20 19:18:59 +00:00
|
|
|
}
|
2020-05-14 02:08:17 +00:00
|
|
|
}
|
2020-05-22 02:29:46 +00:00
|
|
|
|
2020-05-26 17:42:25 +00:00
|
|
|
return m, tea.Quit
|
2020-05-14 02:08:17 +00:00
|
|
|
|
2020-07-20 23:22:12 +00:00
|
|
|
case "left", "h", "delete":
|
2020-08-25 03:26:37 +00:00
|
|
|
if m.state == stateShowDocument && m.pager.state != pagerStateSetNote {
|
2020-07-20 23:22:12 +00:00
|
|
|
cmds = append(cmds, m.unloadDocument()...)
|
2020-07-21 21:52:16 +00:00
|
|
|
return m, tea.Batch(cmds...)
|
2020-07-20 23:22:12 +00:00
|
|
|
}
|
|
|
|
|
2020-06-23 19:11:54 +00:00
|
|
|
// Ctrl+C always quits no matter where in the application you are.
|
2020-05-11 20:13:16 +00:00
|
|
|
case "ctrl+c":
|
2020-05-26 17:42:25 +00:00
|
|
|
return m, tea.Quit
|
2020-05-11 20:13:16 +00:00
|
|
|
}
|
|
|
|
|
2020-07-15 18:18:46 +00:00
|
|
|
// Window size is received when starting up and on every resize
|
2020-06-19 18:56:30 +00:00
|
|
|
case tea.WindowSizeMsg:
|
2020-12-11 01:43:31 +00:00
|
|
|
m.common.width = msg.Width
|
|
|
|
m.common.height = msg.Height
|
2020-06-19 18:56:30 +00:00
|
|
|
m.stash.setSize(msg.Width, msg.Height)
|
|
|
|
m.pager.setSize(msg.Width, msg.Height)
|
2020-05-18 22:58:19 +00:00
|
|
|
|
2020-07-15 18:18:46 +00:00
|
|
|
case initLocalFileSearchMsg:
|
|
|
|
m.localFileFinder = msg.ch
|
2020-12-11 01:43:31 +00:00
|
|
|
m.common.cwd = msg.cwd
|
2020-07-15 18:18:46 +00:00
|
|
|
cmds = append(cmds, findNextLocalFile(m))
|
|
|
|
|
2020-05-11 20:13:16 +00:00
|
|
|
case sshAuthErrMsg:
|
2020-10-18 01:46:02 +00:00
|
|
|
if m.keygenState != keygenFinished { // if we haven't run the keygen yet, do that
|
2020-07-15 19:51:51 +00:00
|
|
|
m.keygenState = keygenRunning
|
2020-07-15 22:29:58 +00:00
|
|
|
cmds = append(cmds, generateSSHKeys)
|
2020-05-19 17:20:39 +00:00
|
|
|
} else {
|
2020-07-15 22:29:58 +00:00
|
|
|
// The keygen ran but things still didn't work and we can't auth
|
2020-12-11 01:43:31 +00:00
|
|
|
m.common.authStatus = authFailed
|
2020-07-16 19:19:59 +00:00
|
|
|
m.stash.err = errors.New("SSH authentication failed; we tried ssh-agent, loading keys from disk, and generating SSH keys")
|
2020-09-09 23:46:10 +00:00
|
|
|
if debug {
|
2020-09-10 17:32:57 +00:00
|
|
|
log.Println("entering offline mode;", m.stash.err)
|
2020-09-09 23:46:10 +00:00
|
|
|
}
|
2020-07-15 22:29:58 +00:00
|
|
|
|
|
|
|
// Even though it failed, news/stash loading is finished
|
2020-11-28 02:52:51 +00:00
|
|
|
m.stash.loaded.Add(StashedDoc, NewsDoc)
|
2020-05-11 20:13:16 +00:00
|
|
|
}
|
|
|
|
|
2020-07-15 22:29:58 +00:00
|
|
|
case keygenFailedMsg:
|
|
|
|
// Keygen failed. That sucks.
|
2020-12-11 01:43:31 +00:00
|
|
|
m.common.authStatus = authFailed
|
2020-07-16 19:19:59 +00:00
|
|
|
m.stash.err = errors.New("could not authenticate; could not generate SSH keys")
|
2020-09-09 23:46:10 +00:00
|
|
|
if debug {
|
2020-09-10 17:32:57 +00:00
|
|
|
log.Println("entering offline mode;", m.stash.err)
|
2020-09-09 23:46:10 +00:00
|
|
|
}
|
2020-09-10 17:32:57 +00:00
|
|
|
|
2020-07-15 22:29:58 +00:00
|
|
|
m.keygenState = keygenFinished
|
|
|
|
|
|
|
|
// Even though it failed, news/stash loading is finished
|
2020-11-28 02:52:51 +00:00
|
|
|
m.stash.loaded.Add(StashedDoc, NewsDoc)
|
2020-07-15 22:29:58 +00:00
|
|
|
|
|
|
|
case keygenSuccessMsg:
|
2020-07-15 18:18:46 +00:00
|
|
|
// The keygen's done, so let's try initializing the charm client again
|
2020-07-15 19:51:51 +00:00
|
|
|
m.keygenState = keygenFinished
|
2020-09-07 15:59:41 +00:00
|
|
|
cmds = append(cmds, newCharmClient)
|
2020-05-11 20:13:16 +00:00
|
|
|
|
|
|
|
case newCharmClientMsg:
|
2020-12-11 01:43:31 +00:00
|
|
|
m.common.cc = msg
|
|
|
|
m.common.authStatus = authOK
|
2020-07-15 18:18:46 +00:00
|
|
|
cmds = append(cmds, loadStash(m.stash), loadNews(m.stash))
|
2020-05-14 02:08:17 +00:00
|
|
|
|
2020-09-10 17:32:57 +00:00
|
|
|
case stashLoadErrMsg:
|
2020-12-11 01:43:31 +00:00
|
|
|
m.common.authStatus = authFailed
|
2020-09-10 17:32:57 +00:00
|
|
|
|
2020-05-22 19:31:54 +00:00
|
|
|
case fetchedMarkdownMsg:
|
2020-11-20 03:26:31 +00:00
|
|
|
// We've loaded a markdown file's contents for rendering
|
2020-08-21 00:21:52 +00:00
|
|
|
m.pager.currentDocument = *msg
|
2020-10-30 15:14:36 +00:00
|
|
|
msg.Body = string(utils.RemoveFrontmatter([]byte(msg.Body)))
|
2020-05-19 17:20:39 +00:00
|
|
|
cmds = append(cmds, renderWithGlamour(m.pager, msg.Body))
|
2020-05-15 19:08:45 +00:00
|
|
|
|
|
|
|
case contentRenderedMsg:
|
|
|
|
m.state = stateShowDocument
|
|
|
|
|
2020-07-15 23:11:48 +00:00
|
|
|
case noteSavedMsg:
|
2020-11-16 23:32:14 +00:00
|
|
|
// A note was saved to a document. This will have been done in the
|
2020-07-15 23:11:48 +00:00
|
|
|
// pager, so we'll need to find the corresponding note in the stash.
|
|
|
|
// So, pass the message to the stash for processing.
|
2020-11-25 16:40:24 +00:00
|
|
|
stashModel, cmd := m.stash.update(msg)
|
2020-07-15 23:11:48 +00:00
|
|
|
m.stash = stashModel
|
|
|
|
return m, cmd
|
|
|
|
|
|
|
|
case localFileSearchFinished, gotStashMsg, gotNewsMsg:
|
2020-11-23 21:56:44 +00:00
|
|
|
// 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.
|
2020-11-25 16:40:24 +00:00
|
|
|
stashModel, cmd := m.stash.update(msg)
|
2020-07-15 23:11:48 +00:00
|
|
|
m.stash = stashModel
|
|
|
|
return m, cmd
|
|
|
|
|
2020-11-23 21:56:44 +00:00
|
|
|
case foundLocalFileMsg:
|
2020-12-11 01:43:31 +00:00
|
|
|
newMd := localFileToMarkdown(m.common.cwd, gitcha.SearchResult(msg))
|
2020-11-23 21:56:44 +00:00
|
|
|
m.stash.addMarkdowns(newMd)
|
2020-12-15 18:37:11 +00:00
|
|
|
if m.stash.filterApplied() {
|
2020-11-23 21:56:44 +00:00
|
|
|
newMd.buildFilterValue()
|
|
|
|
}
|
|
|
|
if m.stash.shouldUpdateFilter() {
|
|
|
|
cmds = append(cmds, filterMarkdowns(m.stash))
|
|
|
|
}
|
|
|
|
cmds = append(cmds, findNextLocalFile(m))
|
|
|
|
|
2020-08-21 00:21:52 +00:00
|
|
|
case stashSuccessMsg:
|
2020-12-11 01:20:43 +00:00
|
|
|
// Common handling that should happen regardless of application state
|
|
|
|
md := markdown(msg)
|
|
|
|
m.stash.addMarkdowns(&md)
|
2020-12-15 18:37:11 +00:00
|
|
|
m.common.filesStashed[msg.stashID] = struct{}{}
|
|
|
|
delete(m.common.filesStashing, md.stashID)
|
2020-12-09 20:24:24 +00:00
|
|
|
|
2020-12-15 18:37:11 +00:00
|
|
|
if m.stash.filterApplied() {
|
2020-12-15 20:57:27 +00:00
|
|
|
for _, v := range m.stash.filteredMarkdowns {
|
2020-12-16 20:12:48 +00:00
|
|
|
if v.stashID == msg.stashID && v.docType == ConvertedDoc {
|
2020-12-15 20:57:27 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
}
|
2020-12-09 20:24:24 +00:00
|
|
|
}
|
|
|
|
|
2020-12-11 00:26:24 +00:00
|
|
|
case stashFailMsg:
|
2020-12-11 01:20:43 +00:00
|
|
|
// Common handling that should happen regardless of application state
|
2020-12-15 18:37:11 +00:00
|
|
|
delete(m.common.filesStashed, msg.markdown.stashID)
|
|
|
|
delete(m.common.filesStashing, msg.markdown.stashID)
|
2020-12-11 00:26:24 +00:00
|
|
|
|
2020-12-09 20:24:24 +00:00
|
|
|
case filteredMarkdownMsg:
|
|
|
|
if m.state == stateShowDocument {
|
|
|
|
newStashModel, cmd := m.stash.update(msg)
|
|
|
|
m.stash = newStashModel
|
|
|
|
cmds = append(cmds, cmd)
|
2020-08-21 17:39:59 +00:00
|
|
|
}
|
2020-05-13 20:00:27 +00:00
|
|
|
}
|
2020-05-11 20:13:16 +00:00
|
|
|
|
2020-07-15 19:51:51 +00:00
|
|
|
// Process children
|
|
|
|
switch m.state {
|
2020-05-13 20:00:27 +00:00
|
|
|
case stateShowStash:
|
2020-11-25 16:40:24 +00:00
|
|
|
newStashModel, cmd := m.stash.update(msg)
|
2020-07-15 23:19:44 +00:00
|
|
|
m.stash = newStashModel
|
2020-05-19 17:20:39 +00:00
|
|
|
cmds = append(cmds, cmd)
|
2020-05-14 02:08:17 +00:00
|
|
|
|
|
|
|
case stateShowDocument:
|
2020-11-25 16:40:24 +00:00
|
|
|
newPagerModel, cmd := m.pager.update(msg)
|
2020-07-15 23:19:44 +00:00
|
|
|
m.pager = newPagerModel
|
2020-05-19 17:20:39 +00:00
|
|
|
cmds = append(cmds, cmd)
|
2020-05-11 20:13:16 +00:00
|
|
|
}
|
2020-05-13 16:53:58 +00:00
|
|
|
|
2020-05-26 17:42:25 +00:00
|
|
|
return m, tea.Batch(cmds...)
|
2020-05-11 20:13:16 +00:00
|
|
|
}
|
|
|
|
|
2020-10-19 00:29:45 +00:00
|
|
|
func (m model) View() string {
|
2020-07-16 19:19:59 +00:00
|
|
|
if m.fatalErr != nil {
|
2020-07-16 19:41:16 +00:00
|
|
|
return errorView(m.fatalErr, true)
|
2020-05-11 20:13:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
switch m.state {
|
2020-05-14 02:08:17 +00:00
|
|
|
case stateShowDocument:
|
2020-10-19 00:10:28 +00:00
|
|
|
return m.pager.View()
|
2020-08-13 19:26:34 +00:00
|
|
|
default:
|
2020-11-25 16:40:24 +00:00
|
|
|
return m.stash.view()
|
2020-05-11 20:13:16 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-07-16 19:41:16 +00:00
|
|
|
func errorView(err error, fatal bool) string {
|
|
|
|
exitMsg := "press any key to "
|
|
|
|
if fatal {
|
|
|
|
exitMsg += "exit"
|
|
|
|
} else {
|
|
|
|
exitMsg += "return"
|
|
|
|
}
|
2020-07-16 19:19:59 +00:00
|
|
|
s := fmt.Sprintf("%s\n\n%v\n\n%s",
|
2020-06-05 20:57:27 +00:00
|
|
|
te.String(" ERROR ").
|
2020-12-11 01:43:31 +00:00
|
|
|
Foreground(lib.Cream.Color()).
|
|
|
|
Background(lib.Red.Color()).
|
2020-06-05 20:57:27 +00:00
|
|
|
String(),
|
|
|
|
err,
|
2020-07-16 19:41:16 +00:00
|
|
|
common.Subtle(exitMsg),
|
2020-06-05 20:57:27 +00:00
|
|
|
)
|
2020-07-16 19:37:48 +00:00
|
|
|
return "\n" + indent(s, 3)
|
2020-06-05 20:57:27 +00:00
|
|
|
}
|
|
|
|
|
2020-05-11 20:13:16 +00:00
|
|
|
// COMMANDS
|
2020-05-18 21:53:46 +00:00
|
|
|
|
2020-09-07 22:25:04 +00:00
|
|
|
func findLocalFiles(m model) tea.Cmd {
|
|
|
|
return func() tea.Msg {
|
|
|
|
cwd, err := os.Getwd()
|
|
|
|
if err != nil {
|
|
|
|
if debug {
|
|
|
|
log.Println("error finding local files:", err)
|
|
|
|
}
|
|
|
|
return errMsg{err}
|
2020-08-18 19:41:57 +00:00
|
|
|
}
|
2020-07-15 18:18:46 +00:00
|
|
|
|
2020-09-07 22:25:04 +00:00
|
|
|
var ignore []string
|
2020-12-11 01:43:31 +00:00
|
|
|
if !m.common.cfg.ShowAllFiles {
|
2020-09-07 22:25:04 +00:00
|
|
|
ignore = ignorePatterns(m)
|
|
|
|
}
|
|
|
|
|
|
|
|
ch, err := gitcha.FindFilesExcept(cwd, []string{"*.md"}, ignore)
|
|
|
|
if err != nil {
|
|
|
|
if debug {
|
|
|
|
log.Println("error finding local files:", err)
|
|
|
|
}
|
|
|
|
return errMsg{err}
|
2020-08-22 08:16:01 +00:00
|
|
|
}
|
|
|
|
|
2020-09-07 22:25:04 +00:00
|
|
|
return initLocalFileSearchMsg{ch: ch, cwd: cwd}
|
|
|
|
}
|
2020-07-15 18:18:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func findNextLocalFile(m model) tea.Cmd {
|
|
|
|
return func() tea.Msg {
|
2020-09-07 16:37:00 +00:00
|
|
|
res, ok := <-m.localFileFinder
|
|
|
|
|
2020-07-15 18:18:46 +00:00
|
|
|
if ok {
|
|
|
|
// Okay now find the next one
|
2020-09-07 16:37:00 +00:00
|
|
|
return foundLocalFileMsg(res)
|
2020-07-15 18:18:46 +00:00
|
|
|
}
|
|
|
|
// We're done
|
2020-08-21 15:56:40 +00:00
|
|
|
if debug {
|
2020-09-07 16:37:00 +00:00
|
|
|
log.Println("local file search finished")
|
2020-08-21 15:56:40 +00:00
|
|
|
}
|
2020-07-15 18:18:46 +00:00
|
|
|
return localFileSearchFinished{}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-09-07 15:59:41 +00:00
|
|
|
func newCharmClient() tea.Msg {
|
|
|
|
cfg, err := charm.ConfigFromEnv()
|
|
|
|
if err != nil {
|
|
|
|
return errMsg{err}
|
|
|
|
}
|
2020-05-11 20:13:16 +00:00
|
|
|
|
2020-09-07 15:59:41 +00:00
|
|
|
cc, err := charm.NewClient(cfg)
|
|
|
|
if err == charm.ErrMissingSSHAuth {
|
|
|
|
if debug {
|
|
|
|
log.Println("missing SSH auth:", err)
|
2020-08-07 16:34:48 +00:00
|
|
|
}
|
2020-09-07 15:59:41 +00:00
|
|
|
return sshAuthErrMsg{}
|
|
|
|
} else if err != nil {
|
|
|
|
if debug {
|
|
|
|
log.Println("error creating new charm client:", err)
|
2020-08-07 16:34:48 +00:00
|
|
|
}
|
2020-09-07 15:59:41 +00:00
|
|
|
return errMsg{err}
|
2020-08-07 16:34:48 +00:00
|
|
|
}
|
2020-09-07 15:59:41 +00:00
|
|
|
|
|
|
|
return newCharmClientMsg(cc)
|
2020-05-11 20:13:16 +00:00
|
|
|
}
|
2020-05-14 00:21:37 +00:00
|
|
|
|
2020-07-15 18:18:46 +00:00
|
|
|
func loadStash(m stashModel) tea.Cmd {
|
|
|
|
return func() tea.Msg {
|
2020-12-11 01:43:31 +00:00
|
|
|
if m.common.cc == nil {
|
2020-09-09 21:00:25 +00:00
|
|
|
err := errors.New("no charm client")
|
|
|
|
if debug {
|
|
|
|
log.Println("error loading stash:", err)
|
|
|
|
}
|
|
|
|
return stashLoadErrMsg{err}
|
|
|
|
}
|
2020-12-11 01:43:31 +00:00
|
|
|
stash, err := m.common.cc.GetStash(m.serverPage)
|
2020-07-15 18:18:46 +00:00
|
|
|
if err != nil {
|
2020-08-18 19:41:57 +00:00
|
|
|
if debug {
|
2020-09-10 17:32:57 +00:00
|
|
|
if _, ok := err.(charm.ErrAuthFailed); ok {
|
|
|
|
log.Println("auth failure while loading stash:", err)
|
|
|
|
} else {
|
|
|
|
log.Println("error loading stash:", err)
|
|
|
|
}
|
2020-08-18 19:41:57 +00:00
|
|
|
}
|
2020-08-13 19:37:53 +00:00
|
|
|
return stashLoadErrMsg{err}
|
2020-07-15 18:18:46 +00:00
|
|
|
}
|
2020-12-03 23:11:06 +00:00
|
|
|
if debug {
|
2020-12-08 23:38:10 +00:00
|
|
|
log.Println("loaded stash page", m.serverPage)
|
2020-12-03 23:11:06 +00:00
|
|
|
}
|
2020-07-15 18:18:46 +00:00
|
|
|
return gotStashMsg(stash)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func loadNews(m stashModel) tea.Cmd {
|
|
|
|
return func() tea.Msg {
|
2020-12-11 01:43:31 +00:00
|
|
|
if m.common.cc == nil {
|
2020-09-09 21:00:25 +00:00
|
|
|
err := errors.New("no charm client")
|
|
|
|
if debug {
|
|
|
|
log.Println("error loading news:", err)
|
|
|
|
}
|
|
|
|
return newsLoadErrMsg{err}
|
|
|
|
}
|
2020-12-11 01:43:31 +00:00
|
|
|
news, err := m.common.cc.GetNews(1) // just fetch the first page
|
2020-07-15 18:18:46 +00:00
|
|
|
if err != nil {
|
2020-08-18 19:41:57 +00:00
|
|
|
if debug {
|
|
|
|
log.Println("error loading news:", err)
|
|
|
|
}
|
2020-08-13 19:37:53 +00:00
|
|
|
return newsLoadErrMsg{err}
|
2020-07-15 18:18:46 +00:00
|
|
|
}
|
2020-12-03 23:11:06 +00:00
|
|
|
if debug {
|
|
|
|
log.Println("fetched news")
|
|
|
|
}
|
2020-07-15 18:18:46 +00:00
|
|
|
return gotNewsMsg(news)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-07-15 22:29:58 +00:00
|
|
|
func generateSSHKeys() tea.Msg {
|
2020-09-09 23:46:10 +00:00
|
|
|
if debug {
|
|
|
|
log.Println("running keygen...")
|
|
|
|
}
|
2020-08-18 15:08:39 +00:00
|
|
|
_, err := keygen.NewSSHKeyPair(nil)
|
2020-07-15 22:29:58 +00:00
|
|
|
if err != nil {
|
2020-08-18 19:41:57 +00:00
|
|
|
if debug {
|
|
|
|
log.Println("keygen failed:", err)
|
|
|
|
}
|
|
|
|
return keygenFailedMsg{err}
|
2020-07-15 22:29:58 +00:00
|
|
|
}
|
2020-09-09 23:46:10 +00:00
|
|
|
if debug {
|
|
|
|
log.Println("keys generated succcessfully")
|
|
|
|
}
|
2020-07-15 22:29:58 +00:00
|
|
|
return keygenSuccessMsg{}
|
|
|
|
}
|
|
|
|
|
2020-08-24 20:48:10 +00:00
|
|
|
func saveDocumentNote(cc *charm.Client, id int, note string) tea.Cmd {
|
|
|
|
if cc == nil {
|
|
|
|
return func() tea.Msg {
|
2020-09-09 21:00:25 +00:00
|
|
|
err := errors.New("can't set note; no charm client")
|
|
|
|
if debug {
|
|
|
|
log.Println("error saving note:", err)
|
|
|
|
}
|
|
|
|
return errMsg{err}
|
2020-08-24 20:48:10 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
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 {
|
|
|
|
return func() tea.Msg {
|
|
|
|
err := errors.New("can't stash; no charm client")
|
|
|
|
if debug {
|
2020-09-09 21:00:25 +00:00
|
|
|
log.Println("error stashing document:", err)
|
2020-08-24 20:48:10 +00:00
|
|
|
}
|
2020-12-11 00:26:24 +00:00
|
|
|
return stashFailMsg{err, md}
|
2020-08-24 20:48:10 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Is the document missing a body? If so, it likely means it needs to
|
2020-12-10 01:12:01 +00:00
|
|
|
// be loaded. But...if it turnsout the document body really is empty
|
|
|
|
// then we'll stash it anyway.
|
2020-08-24 20:48:10 +00:00
|
|
|
if len(md.Body) == 0 {
|
2020-12-16 20:12:48 +00:00
|
|
|
switch md.docType {
|
2020-12-10 01:12:01 +00:00
|
|
|
|
|
|
|
case LocalDoc:
|
|
|
|
data, err := ioutil.ReadFile(md.localPath)
|
|
|
|
if err != nil {
|
|
|
|
if debug {
|
|
|
|
log.Println("error loading document body for stashing:", err)
|
|
|
|
}
|
2020-12-11 00:26:24 +00:00
|
|
|
return stashFailMsg{err, md}
|
2020-12-10 01:12:01 +00:00
|
|
|
}
|
|
|
|
md.Body = string(data)
|
|
|
|
|
|
|
|
case NewsDoc:
|
2020-12-16 20:12:48 +00:00
|
|
|
newMD, err := fetchMarkdown(cc, md.ID, md.docType)
|
2020-12-10 01:12:01 +00:00
|
|
|
if err != nil {
|
2020-12-11 00:26:24 +00:00
|
|
|
return stashFailMsg{err, md}
|
2020-12-10 01:12:01 +00:00
|
|
|
}
|
|
|
|
md.Body = newMD.Body
|
|
|
|
|
|
|
|
default:
|
2020-12-16 20:12:48 +00:00
|
|
|
err := fmt.Errorf("user is attempting to stash an unsupported markdown type: %s", md.docType)
|
2020-08-24 20:48:10 +00:00
|
|
|
if debug {
|
2020-12-11 00:26:24 +00:00
|
|
|
log.Println(err)
|
2020-08-24 20:48:10 +00:00
|
|
|
}
|
2020-12-11 00:26:24 +00:00
|
|
|
return stashFailMsg{err, md}
|
2020-08-24 20:48:10 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-12-16 22:49:48 +00:00
|
|
|
md.convertToStashed()
|
2020-08-24 20:48:10 +00:00
|
|
|
|
|
|
|
newMd, err := cc.StashMarkdown(md.Note, md.Body)
|
|
|
|
if err != nil {
|
|
|
|
if debug {
|
|
|
|
log.Println("error stashing document:", err)
|
|
|
|
}
|
2020-12-11 00:26:24 +00:00
|
|
|
return stashFailMsg{err, md}
|
2020-08-24 20:48:10 +00:00
|
|
|
}
|
|
|
|
|
2020-12-10 01:12:01 +00:00
|
|
|
// 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.
|
2020-08-24 20:48:10 +00:00
|
|
|
md.ID = newMd.ID
|
2020-12-10 01:12:01 +00:00
|
|
|
|
2020-08-24 20:48:10 +00:00
|
|
|
return stashSuccessMsg(md)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-08-21 17:39:59 +00:00
|
|
|
func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.Cmd {
|
|
|
|
return func() tea.Msg {
|
|
|
|
<-t.C
|
|
|
|
return statusMessageTimeoutMsg(appCtx)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-07-15 18:18:46 +00:00
|
|
|
// ETC
|
|
|
|
|
2020-12-09 20:24:24 +00:00
|
|
|
// Convert a Gitcha result to an internal representation of a markdown
|
|
|
|
// document. Note that we could be doing things like checking if the file is
|
|
|
|
// a directory, but we trust that gitcha has already done that.
|
2020-08-24 20:10:06 +00:00
|
|
|
func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown {
|
2020-07-15 19:51:51 +00:00
|
|
|
md := &markdown{
|
2020-12-16 22:49:48 +00:00
|
|
|
docType: LocalDoc,
|
|
|
|
localPath: res.Path,
|
2020-11-16 23:32:14 +00:00
|
|
|
Markdown: charm.Markdown{
|
|
|
|
Note: stripAbsolutePath(res.Path, cwd),
|
|
|
|
CreatedAt: res.Info.ModTime(),
|
|
|
|
},
|
2020-07-15 19:51:51 +00:00
|
|
|
}
|
|
|
|
|
2020-08-24 20:10:06 +00:00
|
|
|
return md
|
2020-07-15 19:51:51 +00:00
|
|
|
}
|
|
|
|
|
2020-11-16 23:32:14 +00:00
|
|
|
func stripAbsolutePath(fullPath, cwd string) string {
|
|
|
|
return strings.Replace(fullPath, cwd+string(os.PathSeparator), "", -1)
|
|
|
|
}
|
|
|
|
|
2020-08-24 21:00:27 +00:00
|
|
|
// Lightweight version of reflow's indent function.
|
2020-05-27 15:55:00 +00:00
|
|
|
func indent(s string, n int) string {
|
|
|
|
if n <= 0 || s == "" {
|
|
|
|
return s
|
|
|
|
}
|
|
|
|
l := strings.Split(s, "\n")
|
|
|
|
b := strings.Builder{}
|
|
|
|
i := strings.Repeat(" ", n)
|
|
|
|
for _, v := range l {
|
|
|
|
fmt.Fprintf(&b, "%s%s\n", i, v)
|
|
|
|
}
|
|
|
|
return b.String()
|
|
|
|
}
|
|
|
|
|
2020-05-14 00:21:37 +00:00
|
|
|
func min(a, b int) int {
|
|
|
|
if a < b {
|
|
|
|
return a
|
|
|
|
}
|
|
|
|
return b
|
|
|
|
}
|
|
|
|
|
|
|
|
func max(a, b int) int {
|
|
|
|
if a > b {
|
|
|
|
return a
|
|
|
|
}
|
|
|
|
return b
|
|
|
|
}
|