WIP: buffers and command history [ci skip]
continuous-integration/drone/pr Build is failing Details
continuous-integration/drone/push Build is failing Details

This commit is contained in:
decentral1se 2024-01-10 11:26:26 +01:00
parent 6db960bb78
commit 60d3c0bf97
Signed by: decentral1se
GPG Key ID: 03789458B3D0C410
8 changed files with 326 additions and 158 deletions

View File

@ -91,7 +91,7 @@ func main() {
cairdeHomeDir := path.Join(user.HomeDir, ".cairde") cairdeHomeDir := path.Join(user.HomeDir, ".cairde")
cairdeLogsDir := path.Join(cairdeHomeDir, "logs") cairdeLogsDir := path.Join(cairdeHomeDir, "logs")
for _, baseDir := range []string{cairdeHomeDir, cairdeLogsDir} { for _, baseDir := range []string{cairdeHomeDir, cairdeLogsDir} {
if err := os.Mkdir(baseDir, 0764); err != nil { if err := os.Mkdir(baseDir, 0760); err != nil {
if !os.IsExist(err) { if !os.IsExist(err) {
log.Fatalf("main: unable to create directory %s: %s", baseDir, err) log.Fatalf("main: unable to create directory %s: %s", baseDir, err)
} }

View File

@ -16,11 +16,7 @@ check:
ci: ci:
@golangci-lint run ./... @golangci-lint run ./...
clean: build:
@go clean && \
find ~/.cairde -type f -name "*.log" -exec rm '{}' \;
build: clean
@go build -ldflags=$(DIST_LDFLAGS) ./cmd/cairde @go build -ldflags=$(DIST_LDFLAGS) ./cmd/cairde
run: build run: build

View File

@ -103,7 +103,7 @@ func turnAcnOn(m *model) tea.Msg {
func turnAcnOff(m *model, quitProgram bool) tea.Msg { func turnAcnOff(m *model, quitProgram bool) tea.Msg {
if m.acnState != offline { if m.acnState != offline {
logToFile(*m, "Shutting down application and ACN now") logDebug(*m, "Shutting down application and ACN now")
m.app.Shutdown() m.app.Shutdown()
m.acn.Close() m.acn.Close()
} }

205
ui/buffer.go Normal file
View File

@ -0,0 +1,205 @@
package ui
import (
"bufio"
"fmt"
"os"
"path"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)
const (
// Status is the status buffer.
Status = iota
// Profile is the profile buffer.
Profile
)
// BufferHandler is the concrete functionality of a Buffer.
type BufferHandler interface {
open(m *model) error // gracefully open a new buffer
validate(m model, input string) error // validate incoming input
handle(input, hiddenInput string) tea.Msg // handle and dispatch messages from input
persist(m *model, input string) // store buffer input for persistence
close(m model) error // gracefully destroy a buffer
}
// Buffer is a user-facing interactive buffer in the TUI.
type Buffer struct {
name string // user-friendly buffer name for the menu
inputChannel chan string // channel for passing input to handlers
viewport viewport.Model // viewport for rendering lines
viewportLines []string // lines for viewport renderer
viewportIsReady bool // whether or not the viewport is ready to render
inputHistory []string // buffer input history for optional persistence
inputHistoryCursor int // where we are in the input history
persistence bool // whether or not to persist buffer input to file
BufferHandler
}
// StatusBuffer is the privileged status buffer where e.g. you input
// program-wide commands. It can also report general status information, e.g.
// the status of ACN connectivity.
type StatusBufferHandler Buffer
func (s StatusBufferHandler) open(m *model) error {
buffer := m.buffers[m.menuState]
cairdeLogsDir := path.Join(m.userDir, "logs")
bufferLogsPath := path.Join(cairdeLogsDir, buffer.name)
f, err := os.OpenFile(bufferLogsPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0664)
if err != nil {
return fmt.Errorf("unable to create/open file %s: %s", bufferLogsPath, err)
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
buffer.inputHistory = append(buffer.inputHistory, scanner.Text())
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("unable to read lines from %s : %s", bufferLogsPath, err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("unable to close %s file handler: %s", bufferLogsPath, err)
}
buffer.inputHistoryCursor = 0
if len(buffer.inputHistory) > 0 {
buffer.inputHistoryCursor = len(buffer.inputHistory) - 1
}
m.buffers[m.menuState] = buffer
m.statusBuffer = buffer
return nil
}
func (s StatusBufferHandler) validate(m model, input string) error {
if string(input[0]) != "/" {
return fmt.Errorf("Woops, this is not a chat buffer. Only commands are allowed")
}
return nil
}
func (s StatusBufferHandler) handle(input, hiddenInput string) tea.Msg {
cmds := strings.Split(input, " ")
switch {
case cmds[0] == "help":
return cmdMsg{output: strings.Split(cmdHelp, "\n")}
case cmds[0] == "quit":
return turnAcnOffMsg{quitProgram: true}
case cmds[0] == "start":
return showGettingStartedGuideMsg{}
case cmds[0] == "profile":
if cmds[1] != "unlock" && cmds[1] != "info" && cmds[1] != "create" {
profileHelp := `Unknown "profile" command?
/profile create <name> <password> <password> | Create a new profile
/profile info | Show profile information
/profile unlock <password> | Unlock profile(s)`
return cmdMsg{output: strings.Split(profileHelp, "\n")}
}
switch {
case cmds[1] == "unlock":
if len(cmds) != 3 {
return cmdMsg{output: strings.Split(cmdHelp, "\n")}
}
return profileUnlockMsg{
password: strings.TrimSpace(hiddenInput),
}
case cmds[1] == "info":
return profileInfoMsg{}
case cmds[1] == "create":
if len(cmds) != 5 {
return cmdMsg{output: strings.Split(cmdHelp, "\n")}
}
passwords := strings.Split(strings.TrimSpace(hiddenInput), " ")
if len(passwords) != 2 {
return cmdMsg{output: []string{"Profile create: unable to parse hidden input"}}
}
if passwords[0] != passwords[1] {
return cmdMsg{output: []string{"Profile create: passwords do not match?"}}
}
return profileCreateMsg{
name: cmds[2],
password: passwords[1],
}
}
case cmds[0] == "acn":
if cmds[1] != "on" && cmds[1] != "off" {
acnHelp := `Unknown "acn" command?
/acn on | Turn on the Tor ACN
/acn off | Turn off the Tor ACN`
return cmdMsg{output: strings.Split(acnHelp, "\n")}
}
switch {
case cmds[1] == "on":
return turnAcnOnMsg{}
case cmds[1] == "off":
return turnAcnOffMsg{}
}
case cmds[0] == "clear":
return clearScreenMsg{}
}
return cmdMsg{output: []string{unknownCmdHelp}}
}
func (s StatusBufferHandler) persist(m *model, input string) {
buffer := m.buffers[m.menuState]
if buffer.persistence {
buffer.inputHistory = append(buffer.inputHistory, input)
buffer.inputHistoryCursor++
m.buffers[m.menuState] = buffer
}
}
func (s StatusBufferHandler) close(m model) error {
buffer := m.buffers[m.menuState]
cairdeLogsDir := path.Join(m.userDir, "logs")
bufferLogsPath := path.Join(cairdeLogsDir, buffer.name)
if !buffer.persistence {
return nil
}
f, err := os.OpenFile(bufferLogsPath, os.O_RDWR|os.O_CREATE, 0664)
if err != nil {
return fmt.Errorf("unable to create/open file %s: %s", bufferLogsPath, err)
}
for _, cmd := range buffer.inputHistory {
if _, err := f.WriteString(cmd + "\n"); err != nil {
return fmt.Errorf("unable to append input to %s: %s", bufferLogsPath, err)
}
}
if err := f.Close(); err != nil {
return fmt.Errorf("unable to close %s file handler: %s", bufferLogsPath, err)
}
return nil
}
// ProfileBuffer is the profile buffer. There can be several profile buffers
// which correspond to several profiles. Profile buffers take input and report
// output for profile-specific information, e.g. invites from a new contact.
type ProfileBuffer Buffer

View File

@ -2,8 +2,6 @@ package ui
import ( import (
"strings" "strings"
tea "github.com/charmbracelet/bubbletea"
) )
const unknownCmdHelp = "Unknown command? Run /help to see available commands" const unknownCmdHelp = "Unknown command? Run /help to see available commands"
@ -18,85 +16,8 @@ const cmdHelp = `/acn on | Turn on the Tor
/start | Show getting started guide /start | Show getting started guide
/quit | Quit the program` /quit | Quit the program`
func handleCmd(cmd, hiddenInput string) tea.Msg {
cmds := strings.Split(cmd, " ")
switch {
case cmds[0] == "help":
return cmdMsg{output: strings.Split(cmdHelp, "\n")}
case cmds[0] == "quit":
return turnAcnOffMsg{quitProgram: true}
case cmds[0] == "start":
return showGettingStartedGuideMsg{}
case cmds[0] == "profile":
if cmds[1] != "unlock" && cmds[1] != "info" && cmds[1] != "create" {
profileHelp := `Unknown "profile" command?
/profile create <name> <password> <password> | Create a new profile
/profile info | Show profile information
/profile unlock <password> | Unlock profile(s)`
return cmdMsg{output: strings.Split(profileHelp, "\n")}
}
switch {
case cmds[1] == "unlock":
if len(cmds) != 3 {
return cmdMsg{output: strings.Split(cmdHelp, "\n")}
}
return profileUnlockMsg{
password: strings.TrimSpace(hiddenInput),
}
case cmds[1] == "info":
return profileInfoMsg{}
case cmds[1] == "create":
if len(cmds) != 5 {
return cmdMsg{output: strings.Split(cmdHelp, "\n")}
}
passwords := strings.Split(strings.TrimSpace(hiddenInput), " ")
if len(passwords) != 2 {
return cmdMsg{output: []string{"Profile create: unable to parse hidden input"}}
}
if passwords[0] != passwords[1] {
return cmdMsg{output: []string{"Profile create: passwords do not match?"}}
}
return profileCreateMsg{
name: cmds[2],
password: passwords[1],
}
}
case cmds[0] == "acn":
if cmds[1] != "on" && cmds[1] != "off" {
acnHelp := `Unknown "acn" command?
/acn on | Turn on the Tor ACN
/acn off | Turn off the Tor ACN`
return cmdMsg{output: strings.Split(acnHelp, "\n")}
}
switch {
case cmds[1] == "on":
return turnAcnOnMsg{}
case cmds[1] == "off":
return turnAcnOffMsg{}
}
case cmds[0] == "clear":
return clearScreenMsg{}
}
return cmdMsg{output: []string{unknownCmdHelp}}
}
func hidePasswordInput(m *model) { func hidePasswordInput(m *model) {
val := strings.TrimSpace(m.input.Value()) val := m.input.Value()
if len(val) == 0 { if len(val) == 0 {
return return
@ -114,8 +35,8 @@ func hidePasswordInput(m *model) {
if cmds[1] == "create" && len(cmds) > 3 || if cmds[1] == "create" && len(cmds) > 3 ||
cmds[1] == "unlock" && len(cmds) > 2 { cmds[1] == "unlock" && len(cmds) > 2 {
lastChar := string(val[len(val)-1]) lastChar := string(val[len(val)-1])
m.hiddenInput += lastChar
if lastChar != " " { if lastChar != " " {
m.hiddenInput += lastChar
newCmd := []rune(val) newCmd := []rune(val)
newCmd[len(val)-1] = '*' newCmd[len(val)-1] = '*'
m.input.SetValue(string(newCmd)) m.input.SetValue(string(newCmd))

View File

@ -4,8 +4,8 @@ import (
"log" "log"
) )
// LogToFile logs a message to a log file. // logDebug logs debug messages.
func logToFile(m model, msg string) { func logDebug(m model, msg string) {
if m.debug { if m.debug {
log.Printf("debug: %s", msg) log.Printf("debug: %s", msg)
} }

View File

@ -68,3 +68,5 @@ type renderStatusMsg struct{ line string }
type cmdMsg struct{ output []string } type cmdMsg struct{ output []string }
type clearScreenMsg struct{} type clearScreenMsg struct{}
type gracefulShutdownMsg struct{}

View File

@ -56,38 +56,26 @@ network, initialise Cwtch and create your first profile. It's simple!
` `
) )
// model is the core data structure for the entire programme
type model struct { type model struct {
username string username string // system username
userDir string userDir string // system user home directory
acnState int // ACN state - online, offline (or in between...)
acnState int menuState int // which buffer is currently selected
menuBar []string // list of buffers in use (e.g. status, profile1)
menuState int app app.Application // cwtch application manager
menuBar []string acn connectivity.ACN // cwtch ACN connectivity manager
width int // width of the TUI
app app.Application height int // height of the TUI
acn connectivity.ACN showWelcomeMessage bool // whether or not we show the welcome message when turning on
buffers []Buffer // all buffers
width int statusBuffer Buffer // the central status buffer
height int profiles profiles // all unlocked cwtch profiles
profileState int // which profile is currently selected
showWelcomeMessage bool input textinput.Model // the central user input buffer
hiddenInput string // the hidden input storage for e.g. passwords
statusViewport viewport.Model version string // the version of cairde
statusViewportLines []string debug bool // whether or not to run under debug mode
statusViewportReady bool
profiles profiles
profileState int
input textinput.Model
hiddenInput string
version string
statusBuffer chan string
debug bool
} }
func NewModel(username, homeDir, version string, debug bool) model { // nolint:revive func NewModel(username, homeDir, version string, debug bool) model { // nolint:revive
@ -99,24 +87,28 @@ func NewModel(username, homeDir, version string, debug bool) model { // nolint:r
input.TextStyle = inputTextStyle input.TextStyle = inputTextStyle
input.Focus() input.Focus()
statusBuffer := Buffer{
BufferHandler: StatusBufferHandler{},
name: "status",
inputChannel: make(chan string),
viewportIsReady: false,
persistence: true, // TODO: configurable
}
return model{ return model{
username: username, username: username,
userDir: path.Join(homeDir, "/.cairde/"), userDir: path.Join(homeDir, "/.cairde/"),
acnState: offline, acnState: offline,
menuBar: []string{
menuBar: []string{"STATUS"}, strings.ToUpper(statusBuffer.name),
menuState: 0, },
menuState: 0,
input: input, statusBuffer: statusBuffer,
buffers: []Buffer{statusBuffer},
version: version, input: input,
version: version,
statusBuffer: make(chan string),
showWelcomeMessage: true, showWelcomeMessage: true,
debug: debug,
debug: debug,
} }
} }
@ -124,7 +116,7 @@ func NewModel(username, homeDir, version string, debug bool) model { // nolint:r
// the statusViewport. This command is repeatedly called by the logic which // the statusViewport. This command is repeatedly called by the logic which
// responds to a renderStatusMsg in Update. // responds to a renderStatusMsg in Update.
func (m model) receiveStatusCmd() tea.Msg { func (m model) receiveStatusCmd() tea.Msg {
return renderStatusMsg{line: <-m.statusBuffer} return renderStatusMsg{line: <-m.statusBuffer.inputChannel}
} }
// sendStatusCmd delivers a tea.Msg to bubbletea for rendering lines from the // sendStatusCmd delivers a tea.Msg to bubbletea for rendering lines from the
@ -141,8 +133,8 @@ func (m model) sendStatusCmd(lines ...string) tea.Cmd {
// we want to render messages to the status buffer but still have work to do. // we want to render messages to the status buffer but still have work to do.
func (m model) sendStatus(lines ...string) { func (m model) sendStatus(lines ...string) {
for _, line := range lines { for _, line := range lines {
logToFile(m, line) logDebug(m, line)
m.statusBuffer <- line m.statusBuffer.inputChannel <- line
} }
} }
@ -179,6 +171,12 @@ func (m model) Init() tea.Cmd {
return tea.Batch( return tea.Batch(
textinput.Blink, textinput.Blink,
m.receiveStatusCmd, m.receiveStatusCmd,
func() tea.Msg {
if err := m.statusBuffer.open(&m); err != nil {
return cmdMsg{output: []string{err.Error()}}
}
return nil
},
) )
} }
@ -191,7 +189,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.input, cmd = m.input.Update(msg) m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
m.statusViewport, cmd = m.statusViewport.Update(msg) m.statusBuffer.viewport, cmd = m.statusBuffer.viewport.Update(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
for _, p := range m.profiles { for _, p := range m.profiles {
@ -218,12 +216,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.input.Width = m.width - 5 m.input.Width = m.width - 5
if !m.statusViewportReady { if !m.statusBuffer.viewportIsReady {
m.statusViewport = newViewport(m.width, m.height-3) m.statusBuffer.viewport = newViewport(m.width, m.height-3)
m.statusViewportReady = true m.statusBuffer.viewportIsReady = true
} else { } else {
m.statusViewport.Width = m.width m.statusBuffer.viewport.Width = m.width
m.statusViewport.Height = m.height - 3 m.statusBuffer.viewport.Height = m.height - 3
} }
for _, p := range m.profiles { for _, p := range m.profiles {
@ -233,6 +231,41 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "down":
buffer := m.buffers[m.menuState]
if len(buffer.inputHistory) == 0 {
break
}
m.input.Reset()
if buffer.inputHistoryCursor < len(buffer.inputHistory)-1 {
buffer.inputHistoryCursor++
}
m.buffers[m.menuState] = buffer
lastCmd := buffer.inputHistory[buffer.inputHistoryCursor]
m.input.SetValue(lastCmd)
case "up":
buffer := m.buffers[m.menuState]
if len(buffer.inputHistory) == 0 {
break
}
m.input.Reset()
if buffer.inputHistoryCursor > 0 {
buffer.inputHistoryCursor--
}
m.buffers[m.menuState] = buffer
lastCmd := buffer.inputHistory[buffer.inputHistoryCursor]
m.input.SetValue(lastCmd)
case "ctrl+c": case "ctrl+c":
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
return turnAcnOff(&m, true) return turnAcnOff(&m, true)
@ -244,16 +277,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
break break
} }
if string(cmdInput[0]) != "/" && m.menuState == 0 { buffer := m.buffers[Status]
if err := buffer.validate(m, cmdInput); err != nil {
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
errMsg := "Woops, this is not a chat buffer. Only commands are allowed" return cmdMsg{output: []string{err.Error()}}
return cmdMsg{output: []string{errMsg}}
}) })
break break
} }
buffer.persist(&m, cmdInput)
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
return handleCmd(cmdInput[1:], m.hiddenInput) return buffer.handle(cmdInput[1:], m.hiddenInput)
}) })
case "ctrl+n": case "ctrl+n":
@ -298,9 +334,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return turnAcnOff(&m, msg.quitProgram) return turnAcnOff(&m, msg.quitProgram)
}) })
case gracefulShutdownMsg:
if err := m.statusBuffer.close(m); err != nil {
panic(err) // TODO: ???
}
return m, tea.Quit
case acnOffMsg: case acnOffMsg:
if msg.quitProgram { if msg.quitProgram {
return m, tea.Quit return m, func() tea.Msg {
return gracefulShutdownMsg{}
}
} }
cmds = append(cmds, m.sendStatusCmd("ACN successfully turned off")) cmds = append(cmds, m.sendStatusCmd("ACN successfully turned off"))
m.acnState = offline m.acnState = offline
@ -431,23 +475,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case sendStatusMsg: case sendStatusMsg:
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
for _, line := range msg.lines { for _, line := range msg.lines {
logToFile(m, line) logDebug(m, line)
m.statusBuffer <- line m.statusBuffer.inputChannel <- line
} }
return nil return nil
}) })
case renderStatusMsg: case renderStatusMsg:
m.statusViewportLines = append(m.statusViewportLines, renderLines(m.width, msg.line)...) m.statusBuffer.viewportLines = append(m.statusBuffer.viewportLines, renderLines(m.width, msg.line)...)
m.statusViewport.SetContent(strings.Join(m.statusViewportLines, "\n")) m.statusBuffer.viewport.SetContent(strings.Join(m.statusBuffer.viewportLines, "\n"))
m.statusViewport.GotoBottom() m.statusBuffer.viewport.GotoBottom()
cmds = append(cmds, m.receiveStatusCmd) cmds = append(cmds, m.receiveStatusCmd)
case clearScreenMsg: case clearScreenMsg:
m.input.Reset() m.input.Reset()
m.statusViewportLines = []string{} m.statusBuffer.viewportLines = []string{}
m.statusViewport.SetContent("") m.statusBuffer.viewport.SetContent("")
m.statusViewport.GotoTop() m.statusBuffer.viewport.GotoTop()
} }
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
@ -459,10 +503,10 @@ func (m model) View() string {
var chosenViewport viewport.Model var chosenViewport viewport.Model
switch m.menuState { switch m.menuState {
case 0: case 0:
chosenViewport = m.statusViewport chosenViewport = m.statusBuffer.viewport
default: default:
if m.menuState > len(m.profiles) { if m.menuState > len(m.profiles) {
chosenViewport = m.statusViewport chosenViewport = m.statusBuffer.viewport
break break
} }
chosenViewport = m.profiles[m.menuState-1].statusViewport chosenViewport = m.profiles[m.menuState-1].statusViewport