Use shell runner

This commit is contained in:
IamTheFij 2024-10-21 13:35:53 -07:00
parent 4e05f18091
commit 10c345bc96
5 changed files with 80 additions and 78 deletions

1
.gitignore vendored
View File

@ -27,3 +27,4 @@ _testmain.go
.DS_Store .DS_Store
gomodoro gomodoro
coverage.out coverage.out
dist/

View File

@ -2,7 +2,7 @@ VERSION ?= $(shell git describe --tags --dirty)
GOFILES = *.go go.mod go.sum GOFILES = *.go go.mod go.sum
APP_NAME = gomodoro APP_NAME = gomodoro
# Multi-arch targets are generated from this # Multi-arch targets are generated from this
TARGET_ALIAS = $(APP_NAME)-linux-amd64 $(APP_NAME)-linux-arm $(APP_NAME)-linux-arm64 $(APP_NAME)-darwin-amd64 TARGET_ALIAS = $(APP_NAME)-linux-amd64 $(APP_NAME)-linux-arm $(APP_NAME)-linux-arm64 $(APP_NAME)-darwin-amd64 $(APP_NAME)-darwin-arm64
TARGETS = $(addprefix dist/,$(TARGET_ALIAS)) TARGETS = $(addprefix dist/,$(TARGET_ALIAS))
# #
# Default make target will run tests # Default make target will run tests

3
go.mod
View File

@ -3,9 +3,10 @@ module git.iamthefij.com/iamthefij/gomodoro
go 1.21.4 go 1.21.4
require ( require (
git.iamthefij.com/iamthefij/go-shell-runner v0.1.0
git.iamthefij.com/iamthefij/slog v1.3.0
github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.1.1 github.com/charmbracelet/bubbletea v1.1.1
github.com/charmbracelet/harmonica v0.2.0
github.com/charmbracelet/lipgloss v0.13.0 github.com/charmbracelet/lipgloss v0.13.0
github.com/urfave/cli/v2 v2.27.5 github.com/urfave/cli/v2 v2.27.5
) )

96
main.go
View File

@ -1,14 +1,16 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"strconv" "strconv"
"strings" "strings"
"time" "time"
shellrunner "git.iamthefij.com/iamthefij/go-shell-runner"
"git.iamthefij.com/iamthefij/slog"
"github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@ -18,7 +20,9 @@ import (
type screen int type screen int
var version = "dev" var (
version = "dev"
)
const ( const (
inputScreen screen = iota inputScreen screen = iota
@ -31,13 +35,12 @@ type model struct {
progressBar progress.Model progressBar progress.Model
state string state string
intervalNum int intervalNum int
timer *time.Timer
remaining time.Duration remaining time.Duration
totalTime time.Duration
currentScreen screen currentScreen screen
intervals int intervals int
breakTime time.Duration breakTime time.Duration
focusTime time.Duration focusTime time.Duration
startTime time.Time
onFocusStart []string onFocusStart []string
onFocusEnd []string onFocusEnd []string
onIntervalEnd []string onIntervalEnd []string
@ -46,6 +49,7 @@ type model struct {
width int width int
height int height int
err error err error
shellrunner *shellrunner.ShellRunner
} }
func initialModel(fullscreen bool, colorLeft string, colorRight string) model { func initialModel(fullscreen bool, colorLeft string, colorRight string) model {
@ -92,10 +96,10 @@ func initialModel(fullscreen bool, colorLeft string, colorRight string) model {
state: "stopped", state: "stopped",
intervalNum: 1, intervalNum: 1,
remaining: 0, remaining: 0,
totalTime: 0,
currentScreen: inputScreen, // Start on input screen currentScreen: inputScreen, // Start on input screen
isFocus: true, isFocus: true,
fullscreen: fullscreen, fullscreen: fullscreen,
shellrunner: shellrunner.NewShellRunner(),
} }
} }
@ -106,13 +110,24 @@ func (m model) Init() tea.Cmd {
go func() { go func() {
<-c <-c
m.shellrunner.KillWithTimeout(10 * time.Second)
fmt.Println("\nExiting...") fmt.Println("\nExiting...")
os.Exit(0) os.Exit(0)
}() }()
m.shellrunner.Start()
return tea.Batch(tea.WindowSize(), textinput.Blink) return tea.Batch(tea.WindowSize(), textinput.Blink)
} }
func (m model) totalTime() time.Duration {
if m.isFocus {
return m.focusTime
}
return m.breakTime
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
@ -166,13 +181,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.focusTime = focusTime m.focusTime = focusTime
m.breakTime = breakTime m.breakTime = breakTime
m.remaining = focusTime m.remaining = focusTime
m.totalTime = focusTime
m.state = "Focus" m.state = "Focus"
m.timer = time.NewTimer(time.Second)
m.currentScreen = timerScreen m.currentScreen = timerScreen
m.startTime = time.Now()
// Run onFocusStart commands // Run onFocusStart commands
m.runCommands(m.onFocusStart) m.startCommands(m.onFocusStart)
return m, tick() return m, tick()
} else { } else {
@ -186,34 +200,32 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case timeMsg: case timeMsg:
// Handle timer update for each second // Handle timer update for each second
// TODO: Use absolute times to tick down remaining time rather than incrementing seconds // TODO: Use absolute times to tick down remaining time rather than incrementing seconds
m.remaining -= time.Second m.remaining = m.totalTime() - time.Since(m.startTime)
if m.remaining < 0 { if m.remaining < 0 {
if m.isFocus { if m.isFocus {
// Focus period ends, switch to break // Focus period ends, switch to break
m.isFocus = false m.isFocus = false
m.remaining = m.remaining + m.breakTime
m.totalTime = m.breakTime
m.state = "Break" m.state = "Break"
m.startTime = time.Now()
// Run onFocusEnd commands // Run onFocusEnd commands
m.runCommands(m.onFocusEnd) m.startCommands(m.onFocusEnd)
} else { } else {
// Break ends, switch back to focus or next interval // Break ends, switch back to focus or next interval
m.isFocus = true m.isFocus = true
m.intervalNum++ m.intervalNum++
m.runCommands(m.onIntervalEnd) m.startCommands(m.onIntervalEnd)
if m.intervalNum > m.intervals { if m.intervalNum > m.intervals {
// All intervals completed // All intervals completed
return m, tea.Quit return m, tea.Quit
} }
m.remaining = m.remaining + m.focusTime
m.totalTime = m.focusTime
m.state = "Focus" m.state = "Focus"
m.startTime = time.Now()
// Run onFocusStart commands // Run onFocusStart commands
m.runCommands(m.onFocusStart) m.startCommands(m.onFocusStart)
} }
return m, tick() return m, tick()
@ -232,6 +244,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.inputs[i], cmds[i] = m.inputs[i].Update(msg) m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
} }
for {
result := m.shellrunner.GetResults()
if result == nil {
break
}
if result.ReturnCode != 0 {
m.err = errors.Join(m.err, fmt.Errorf("error running command: %s", result.Command))
// TODO: Create an error view
slog.Errorf("Error running command: %s", result.Command)
slog.Errorf("Error: %s", result.Output)
slog.Errorf("Error: %s", result.ErrOutput)
}
}
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
@ -290,10 +317,10 @@ func (m model) timerScreenView() string {
m.progressBar.Width = m.width - progressBarPadding m.progressBar.Width = m.width - progressBarPadding
} }
progressView := m.progressBar.ViewAs(float64(m.totalTime-m.remaining) / float64(m.totalTime)) progressView := m.progressBar.ViewAs(float64(m.totalTime()-m.remaining) / float64(m.totalTime()))
status := lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("State: %s", m.state)) status := lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("State: %s", m.state))
intervalInfo := fmt.Sprintf("Interval: %d / %d", m.intervalNum, m.intervals) intervalInfo := fmt.Sprintf("Interval: %d / %d", m.intervalNum, m.intervals)
timeLeft := fmt.Sprintf("Time left: %s", m.remaining.String()) timeLeft := fmt.Sprintf("Time left: %s", m.remaining.Round(time.Second).String())
timerView := fmt.Sprintf("%s\n%s\n%s\n\n%s", status, intervalInfo, timeLeft, progressView) timerView := fmt.Sprintf("%s\n%s\n%s\n\n%s", status, intervalInfo, timeLeft, progressView)
@ -305,25 +332,23 @@ func (m model) timerScreenView() string {
} }
// Helper to update input focus // Helper to update input focus
func (m *model) updateFocus() { /*
for i := range m.inputs { * func (m *model) updateFocus() {
if i == m.focusIndex { * for i := range m.inputs {
m.inputs[i].Focus() * if i == m.focusIndex {
} else { * m.inputs[i].Focus()
m.inputs[i].Blur() * } else {
} * m.inputs[i].Blur()
} * }
} * }
* }
*/
// Helper to run shell commands // Helper to run shell commands
func (m *model) runCommands(commands []string) { func (m *model) startCommands(commands []string) {
for _, cmdStr := range commands { for _, cmdStr := range commands {
cmd := exec.Command("sh", "-c", cmdStr) if err := m.shellrunner.AddCommand(cmdStr, nil); err != nil {
cmd.Stdout = os.Stdout m.err = fmt.Errorf("error adding command: %w", err)
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("Error running command: %v\n", err)
} }
} }
} }
@ -400,9 +425,6 @@ func main() {
return nil return nil
} }
fmt.Printf("color-left: %s\n", c.String("color-left"))
fmt.Printf("color-right: %s\n", c.String("color-right"))
m := initialModel(c.Bool("fullscreen"), c.String("color-left"), c.String("color-right")) m := initialModel(c.Bool("fullscreen"), c.String("color-left"), c.String("color-right"))
// Collect command flags // Collect command flags

View File

@ -1,16 +1,22 @@
package main package main
import ( import (
"bytes"
"io"
"os"
"strings"
"testing" "testing"
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
type compareFunc[T comparable] func(T, T) bool
func assertFunc[T comparable](t *testing.T, compare compareFunc[T], actual, expected T, msg string) {
t.Helper()
if !compare(actual, expected) {
t.Errorf("%s: expected %v, got %v", msg, expected, actual)
}
}
// assertEqual checks if two values are equal and reports an error if they are not. // assertEqual checks if two values are equal and reports an error if they are not.
func assertEqual(t *testing.T, actual, expected interface{}, msg string) { func assertEqual(t *testing.T, actual, expected interface{}, msg string) {
t.Helper() t.Helper()
@ -54,37 +60,12 @@ func TestParseDuration(t *testing.T) {
// TestRunCommands tests the runCommands function // TestRunCommands tests the runCommands function
func TestRunCommands(t *testing.T) { func TestRunCommands(t *testing.T) {
r, w, err := os.Pipe() m := initialModel(false, "#ffdd57", "#57ddff")
if err != nil { m.shellrunner.Start()
t.Fatal(err)
}
// Redirect stdout and stderr to the pipe m.startCommands([]string{"echo Hello, World!"})
oldStdout := os.Stdout
oldStderr := os.Stderr
os.Stdout = w
os.Stderr = w
t.Cleanup(func() { m.shellrunner.Stop()
os.Stdout = oldStdout
os.Stderr = oldStderr
w.Close()
})
m := model{}
m.runCommands([]string{"echo Hello, World!"})
w.Close()
var buf bytes.Buffer
if _, err = io.Copy(&buf, r); err != nil {
t.Fatal(err)
}
output := buf.String()
assertEqual(t, strings.Contains(output, "Hello, World!"), true, "Expected output to contain 'Hello, World!'")
} }
// TestInputView tests the Update method of the model for the input view // TestInputView tests the Update method of the model for the input view
@ -126,7 +107,7 @@ func TestInputView(t *testing.T) {
assertEqual(t, m.err, nil, "Expected no error") assertEqual(t, m.err, nil, "Expected no error")
assertNotEqual(t, resultCmd, nil, "Expected resultCmd to be not nil") assertNotEqual(t, resultCmd, nil, "Expected resultCmd to be not nil")
assertEqual(t, m.currentScreen, timerScreen, "Expected currentScreen to be timerScreen") assertEqual(t, m.currentScreen, timerScreen, "Expected currentScreen to be timerScreen")
assertEqual(t, m.remaining, 10*time.Minute, "Expected remaining to be 10 minutes") assertEqual(t, m.remaining.Round(time.Second), 10*time.Minute, "Expected remaining to be 10 minutes")
// Test timer view // Test timer view
m.View() m.View()
@ -136,21 +117,18 @@ func TestInputView(t *testing.T) {
m = updatedModel.(model) m = updatedModel.(model)
assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'") assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'")
assertEqual(t, m.remaining, 9*time.Minute+59*time.Second, "Expected remaining to be 9 minutes 59 seconds")
// Test switch to break time // Test switch to break time
m.remaining = 0 m.startTime = m.startTime.Add(-10 * time.Minute)
updatedModel, _ = m.Update(oneSec) updatedModel, _ = m.Update(oneSec)
m = updatedModel.(model) m = updatedModel.(model)
assertEqual(t, m.state, "Break", "Expected state to be 'Break'") assertEqual(t, m.state, "Break", "Expected state to be 'Break'")
assertEqual(t, m.remaining, 9*time.Minute+59*time.Second, "Expected remaining to be 9 minutes 59 seconds")
// Switch back to focus time // Switch back to focus time
m.remaining = 0 m.startTime = m.startTime.Add(-10 * time.Minute)
updatedModel, _ = m.Update(oneSec) updatedModel, _ = m.Update(oneSec)
m = updatedModel.(model) m = updatedModel.(model)
assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'") assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'")
assertEqual(t, m.remaining, 9*time.Minute+59*time.Second, "Expected remaining to be 9 minutes 59 seconds")
} }