diff --git a/.gitignore b/.gitignore index 1e25190..6ad181f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ _testmain.go .DS_Store gomodoro coverage.out +dist/ diff --git a/Makefile b/Makefile index 22587b3..e87eeab 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ VERSION ?= $(shell git describe --tags --dirty) GOFILES = *.go go.mod go.sum APP_NAME = gomodoro # 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)) # # Default make target will run tests diff --git a/go.mod b/go.mod index dbc5ab5..26832e1 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,10 @@ module git.iamthefij.com/iamthefij/gomodoro go 1.21.4 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/bubbletea v1.1.1 - github.com/charmbracelet/harmonica v0.2.0 github.com/charmbracelet/lipgloss v0.13.0 github.com/urfave/cli/v2 v2.27.5 ) diff --git a/main.go b/main.go index b14d04c..16c053f 100644 --- a/main.go +++ b/main.go @@ -1,14 +1,16 @@ package main import ( + "errors" "fmt" "os" - "os/exec" "os/signal" "strconv" "strings" "time" + shellrunner "git.iamthefij.com/iamthefij/go-shell-runner" + "git.iamthefij.com/iamthefij/slog" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -18,7 +20,9 @@ import ( type screen int -var version = "dev" +var ( + version = "dev" +) const ( inputScreen screen = iota @@ -31,13 +35,12 @@ type model struct { progressBar progress.Model state string intervalNum int - timer *time.Timer remaining time.Duration - totalTime time.Duration currentScreen screen intervals int breakTime time.Duration focusTime time.Duration + startTime time.Time onFocusStart []string onFocusEnd []string onIntervalEnd []string @@ -46,6 +49,7 @@ type model struct { width int height int err error + shellrunner *shellrunner.ShellRunner } func initialModel(fullscreen bool, colorLeft string, colorRight string) model { @@ -92,10 +96,10 @@ func initialModel(fullscreen bool, colorLeft string, colorRight string) model { state: "stopped", intervalNum: 1, remaining: 0, - totalTime: 0, currentScreen: inputScreen, // Start on input screen isFocus: true, fullscreen: fullscreen, + shellrunner: shellrunner.NewShellRunner(), } } @@ -106,13 +110,24 @@ func (m model) Init() tea.Cmd { go func() { <-c + m.shellrunner.KillWithTimeout(10 * time.Second) fmt.Println("\nExiting...") os.Exit(0) }() + m.shellrunner.Start() + 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) { switch msg := msg.(type) { case tea.KeyMsg: @@ -166,13 +181,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.focusTime = focusTime m.breakTime = breakTime m.remaining = focusTime - m.totalTime = focusTime m.state = "Focus" - m.timer = time.NewTimer(time.Second) m.currentScreen = timerScreen + m.startTime = time.Now() // Run onFocusStart commands - m.runCommands(m.onFocusStart) + m.startCommands(m.onFocusStart) return m, tick() } else { @@ -186,34 +200,32 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case timeMsg: // Handle timer update for each second // 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.isFocus { // Focus period ends, switch to break m.isFocus = false - m.remaining = m.remaining + m.breakTime - m.totalTime = m.breakTime m.state = "Break" + m.startTime = time.Now() // Run onFocusEnd commands - m.runCommands(m.onFocusEnd) + m.startCommands(m.onFocusEnd) } else { // Break ends, switch back to focus or next interval m.isFocus = true m.intervalNum++ - m.runCommands(m.onIntervalEnd) + m.startCommands(m.onIntervalEnd) if m.intervalNum > m.intervals { // All intervals completed return m, tea.Quit } - m.remaining = m.remaining + m.focusTime - m.totalTime = m.focusTime m.state = "Focus" + m.startTime = time.Now() // Run onFocusStart commands - m.runCommands(m.onFocusStart) + m.startCommands(m.onFocusStart) } 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) } + 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...) } @@ -290,10 +317,10 @@ func (m model) timerScreenView() string { 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)) 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) @@ -305,25 +332,23 @@ func (m model) timerScreenView() string { } // Helper to update input focus -func (m *model) updateFocus() { - for i := range m.inputs { - if i == m.focusIndex { - m.inputs[i].Focus() - } else { - m.inputs[i].Blur() - } - } -} +/* + * func (m *model) updateFocus() { + * for i := range m.inputs { + * if i == m.focusIndex { + * m.inputs[i].Focus() + * } else { + * m.inputs[i].Blur() + * } + * } + * } + */ // Helper to run shell commands -func (m *model) runCommands(commands []string) { +func (m *model) startCommands(commands []string) { for _, cmdStr := range commands { - cmd := exec.Command("sh", "-c", cmdStr) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { - fmt.Printf("Error running command: %v\n", err) + if err := m.shellrunner.AddCommand(cmdStr, nil); err != nil { + m.err = fmt.Errorf("error adding command: %w", err) } } } @@ -400,9 +425,6 @@ func main() { 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")) // Collect command flags diff --git a/main_test.go b/main_test.go index ebaea3d..5cba99b 100644 --- a/main_test.go +++ b/main_test.go @@ -1,16 +1,22 @@ package main import ( - "bytes" - "io" - "os" - "strings" "testing" "time" 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. func assertEqual(t *testing.T, actual, expected interface{}, msg string) { t.Helper() @@ -54,37 +60,12 @@ func TestParseDuration(t *testing.T) { // TestRunCommands tests the runCommands function func TestRunCommands(t *testing.T) { - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } + m := initialModel(false, "#ffdd57", "#57ddff") + m.shellrunner.Start() - // Redirect stdout and stderr to the pipe - oldStdout := os.Stdout - oldStderr := os.Stderr - os.Stdout = w - os.Stderr = w + m.startCommands([]string{"echo Hello, World!"}) - t.Cleanup(func() { - 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!'") + m.shellrunner.Stop() } // 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") assertNotEqual(t, resultCmd, nil, "Expected resultCmd to be not nil") 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 m.View() @@ -136,21 +117,18 @@ func TestInputView(t *testing.T) { m = updatedModel.(model) 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 - m.remaining = 0 + m.startTime = m.startTime.Add(-10 * time.Minute) updatedModel, _ = m.Update(oneSec) m = updatedModel.(model) 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 - m.remaining = 0 + m.startTime = m.startTime.Add(-10 * time.Minute) updatedModel, _ = m.Update(oneSec) m = updatedModel.(model) 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") }