diff --git a/AGENTS.md b/AGENTS.md index 08c652b0..9022ddc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,25 @@ make lint # Linter make check # All checks (what bin/ci runs) ``` +**Dead code**: `make deadcode` reports unreachable functions whole-program, rooted +at the shipped binary and again with `-tags dev`. It is a report, not a gate, and +is deliberately outside `make check`. The linter's `unused` cannot do this job — +it runs per-package and counts every exported identifier in a non-main package as +used, which is how 600 lines of exported, zero-caller `internal/tui` API survived +it. Root it at the binary, not `./...`: only main packages are roots, so `./...` +reports everything no main reaches — mostly the `dev`-tagged workspace tree — as +unreachable. + +It also analyzes one GOOS/GOARCH at a time, while we release five. A host run says +nothing about the others: code behind another platform's build tag is never loaded, +and a function whose only caller sits behind one looks unreachable. Before deleting +anything platform-adjacent, check the other targets — install the tool for the host +and set GOOS for the analysis (`GOOS=windows deadcode ./cmd/basecamp`), since +`GOOS=windows go run` cross-compiles the tool itself and fails. + +Read the output before acting on it: a zero-caller exported symbol is a candidate, +not a verdict, and the `dev`-tagged tree is legitimately partial. + Requirements: Go 1.26+, [bats-core](https://github.com/bats-core/bats-core) for e2e tests. ## OAuth Development diff --git a/Makefile b/Makefile index 1ac2349e..371ef099 100644 --- a/Makefile +++ b/Makefile @@ -190,6 +190,56 @@ bench-compare: @command -v benchstat >/dev/null 2>&1 || go install golang.org/x/perf/cmd/benchstat@latest benchstat benchmarks-baseline.txt benchmarks-current.txt +# Report unreachable code, whole-program, from the production binary's main. +# +# NOT a gate, and not wired into `make check`. golangci's `unused` (staticcheck +# U1000) runs per-package and treats every exported identifier in a non-main +# package as used by definition, which is why 602 lines of exported, zero-caller +# internal/tui API survived it — no configuration fixes that, the analysis +# cannot see across package boundaries. deadcode can, because it starts from a +# main and follows the call graph. +# +# Which main matters. Only main packages are roots, so `deadcode ./...` loads +# every package and then reports everything no main reaches as unreachable — +# 1100+ of its 1250 lines here are the dev-tagged workspace tree, which the +# untagged build genuinely cannot reach. True, and useless. Naming the binary +# gives two answers worth reading instead: +# +# deadcode ./cmd/basecamp what a released basecamp can never run +# deadcode -tags=dev ./cmd/basecamp the same, with the dev `basecamp tui` tree +# +# `go run` the pinned version rather than trusting whatever `deadcode` happens +# to be on PATH; an unpinned tool makes the output depend on the machine. +# +# ONE PLATFORM AT A TIME. deadcode analyzes the current GOOS/GOARCH, and this +# repo releases darwin, linux, windows, freebsd and openbsd. So a host run is +# evidence about the host and nothing else, in both directions: code behind +# another platform's build tag is never loaded, and a function whose only caller +# sits behind one looks unreachable here. Measured on this tree: 78 findings for +# linux and darwin, 77 for windows, with harden_unix.go's ownedByUID present +# only in the unix reports. +# +# To check another target, install the tool for the host and set GOOS for the +# analysis — `GOOS=windows go run ...` cross-compiles the tool itself and dies +# with an exec format error: +# +# go install golang.org/x/tools/cmd/deadcode@v0.40.0 +# GOOS=windows deadcode ./cmd/basecamp +# +# Read the output before acting on it. A zero-caller exported symbol is a +# candidate, not a verdict — some are a deliberate API surface, the -tags dev +# tree is legitimately partial, and a cross-platform deletion needs a run per +# target. Wiring a noisy check into CI is how a control nobody sized gets built. +DEADCODE := golang.org/x/tools/cmd/deadcode@v0.40.0 + +.PHONY: deadcode +deadcode: check-toolchain + @echo "== shipped binary ==" + $(GOCMD) run $(DEADCODE) ./cmd/basecamp + @echo + @echo "== with -tags dev ==" + $(GOCMD) run $(DEADCODE) -tags=dev ./cmd/basecamp + # Guard against Go toolchain mismatch (mise environment) .PHONY: check-toolchain check-toolchain: @@ -558,6 +608,7 @@ help: @echo " fmt-check Check code formatting" @echo " lint Run golangci-lint" @echo " lint-actions Lint GitHub Actions workflows (actionlint + zizmor)" + @echo " deadcode Report unreachable code from the binary (report, not a gate)" @echo " tidy-check Verify go.mod/go.sum are tidy" @echo " check Run all checks (local CI gate)" @echo " check-surface Verify committed .surface matches the command tree (TestSurfaceSnapshot)" diff --git a/internal/tui/forms_test.go b/internal/tui/forms_test.go index 1315c81b..13f51e32 100644 --- a/internal/tui/forms_test.go +++ b/internal/tui/forms_test.go @@ -1,7 +1,6 @@ package tui import ( - "context" "errors" "go/ast" "go/parser" @@ -202,65 +201,6 @@ func TestPickerFloorRefusesNonInteractiveStdio(t *testing.T) { assert.Zero(t, loaderCalls, "a refused picker must not fetch items it cannot show") } -// TestDeadLaunchersStillRefuse covers Spinner and PaginatedPicker. Both are -// exported, both have zero callers today, and both are slated for deletion — -// but "nobody calls it" is not a gate. Adding a caller would not add a -// tea.NewProgram, so the structural backstop would still pass while the hang -// came straight back. They hold the floor until they are gone. -func TestDeadLaunchersStillRefuse(t *testing.T) { - for _, kind := range stdinKinds { - t.Run("spinner/"+kind, func(t *testing.T) { - terminalStdout(t) - nonInteractiveStdin(t, kind) - - var ran bool - done := make(chan error, 1) - go func() { - _, err := NewSpinner("working").Run(func() (string, error) { - ran = true - return "", nil - }) - done <- err - }() - - select { - case err := <-done: - assert.True(t, errors.Is(err, ErrNotInteractive), - "spinner should return ErrNotInteractive on %s stdin, got %v", kind, err) - assert.False(t, ran, "a refused spinner must not run the work it was wrapping") - case <-time.After(5 * time.Second): - t.Fatalf("spinner blocked on %s stdin instead of refusing", kind) - } - }) - - t.Run("paginated_picker/"+kind, func(t *testing.T) { - terminalStdout(t) - nonInteractiveStdin(t, kind) - - var fetched bool - fetcher := func(context.Context, string) (*PageResult, error) { - fetched = true - return &PageResult{}, nil - } - - done := make(chan error, 1) - go func() { - _, err := NewPaginatedPicker(context.Background(), fetcher).Run() - done <- err - }() - - select { - case err := <-done: - assert.True(t, errors.Is(err, ErrNotInteractive), - "paginated picker should return ErrNotInteractive on %s stdin, got %v", kind, err) - assert.False(t, fetched, "a refused picker must not fetch a page it cannot show") - case <-time.After(5 * time.Second): - t.Fatalf("paginated picker blocked on %s stdin instead of refusing", kind) - } - }) - } -} - // TestAutoSelectSingleWorksWithoutATerminal pins the one path through // Picker.Run that is deliberately outside the floor. With WithAutoSelectSingle // and exactly one static item, Run resolves without constructing a bubbletea diff --git a/internal/tui/launchers_test.go b/internal/tui/launchers_test.go index 7bd88934..0526c417 100644 --- a/internal/tui/launchers_test.go +++ b/internal/tui/launchers_test.go @@ -36,15 +36,7 @@ var ( // bubbleteaLaunchers maps a file that may call tea.NewProgram to the sole // function within it that may do so. bubbleteaLaunchers = map[string]string{ - "internal/tui/picker.go": "runPicker", - - // Zero callers today and slated for deletion, but "dead" is not a gate: - // adding a caller would not add a NewProgram, so the check would still - // pass while the hang came back. Both launch from their own Run, and - // both apply the floor there. - "internal/tui/spinner.go": "Run", - "internal/tui/paginated_picker.go": "Run", - + "internal/tui/picker.go": "runPicker", "internal/commands/tui.go": "", // dev build tag; not in a shipped binary } diff --git a/internal/tui/paginated_picker.go b/internal/tui/paginated_picker.go deleted file mode 100644 index bc288ceb..00000000 --- a/internal/tui/paginated_picker.go +++ /dev/null @@ -1,420 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "strings" - - "charm.land/bubbles/v2/spinner" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -// PageResult represents a page of items from a paginated API. -type PageResult struct { - Items []PickerItem - HasMore bool - // NextCursor can be used by the PageFetcher to track pagination state. - // The paginated picker doesn't interpret this value; it just passes it back. - NextCursor string -} - -// PageFetcher fetches a page of items. It receives the cursor from the previous -// page (empty string for the first page) and returns the items and pagination info. -type PageFetcher func(ctx context.Context, cursor string) (*PageResult, error) - -// paginatedPickerModel is the bubbletea model for a paginated fuzzy picker. -type paginatedPickerModel struct { - items []PickerItem - filtered []PickerItem - textInput textinput.Model - cursor int - selected *PickerItem - quitting bool - styles *Styles - title string - maxVisible int - scrollOffset int - - // Pagination state - fetcher PageFetcher - ctx context.Context - nextCursor string - hasMore bool - loadingMore bool - totalLoaded int - fetchError error - - // Initial loading state - initialLoading bool - spinner spinner.Model - loadingMsg string - - // Threshold for triggering next page fetch (items from bottom) - fetchThreshold int -} - -// PaginatedPickerOption configures a paginated picker. -type PaginatedPickerOption func(*paginatedPickerModel) - -// WithPaginatedPickerTitle sets the picker title. -func WithPaginatedPickerTitle(title string) PaginatedPickerOption { - return func(m *paginatedPickerModel) { - m.title = title - } -} - -// WithPaginatedMaxVisible sets the maximum number of visible items. -func WithPaginatedMaxVisible(n int) PaginatedPickerOption { - return func(m *paginatedPickerModel) { - m.maxVisible = n - } -} - -// WithFetchThreshold sets how many items from the bottom triggers a fetch. -func WithFetchThreshold(n int) PaginatedPickerOption { - return func(m *paginatedPickerModel) { - m.fetchThreshold = n - } -} - -// WithLoadingMessage sets the loading message. -func WithLoadingMessage(msg string) PaginatedPickerOption { - return func(m *paginatedPickerModel) { - m.loadingMsg = msg - } -} - -func newPaginatedPickerModel(ctx context.Context, fetcher PageFetcher, opts ...PaginatedPickerOption) paginatedPickerModel { - ti := textinput.New() - ti.Placeholder = "Type to filter..." - ti.SetWidth(40) - ti.Focus() - - s := spinner.New() - s.Spinner = spinner.Dot - styles := NewStyles() - s.Style = lipgloss.NewStyle().Foreground(styles.theme.Primary) - - m := paginatedPickerModel{ - textInput: ti, - styles: styles, - title: "Select an item", - maxVisible: 10, - fetcher: fetcher, - ctx: ctx, - hasMore: true, - initialLoading: true, - spinner: s, - loadingMsg: "Loading…", - fetchThreshold: 3, // Fetch when 3 items from bottom - } - - for _, opt := range opts { - opt(&m) - } - - return m -} - -// pageLoadedMsg is sent when a page of items has been loaded. -type pageLoadedMsg struct { - items []PickerItem - hasMore bool - nextCursor string - err error - isInitial bool -} - -func (m paginatedPickerModel) fetchPage(isInitial bool) tea.Cmd { - return func() tea.Msg { - result, err := m.fetcher(m.ctx, m.nextCursor) - if err != nil { - return pageLoadedMsg{err: err, isInitial: isInitial} - } - return pageLoadedMsg{ - items: result.Items, - hasMore: result.HasMore, - nextCursor: result.NextCursor, - isInitial: isInitial, - } - } -} - -func (m paginatedPickerModel) Init() tea.Cmd { - return tea.Batch(m.spinner.Tick, m.fetchPage(true)) -} - -func (m paginatedPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case pageLoadedMsg: - if msg.err != nil { - m.fetchError = msg.err - if msg.isInitial { - m.initialLoading = false - } - m.loadingMore = false - // Don't quit on error, let user see error message and cancel - return m, nil - } - - m.items = append(m.items, msg.items...) - m.totalLoaded = len(m.items) - m.hasMore = msg.hasMore - m.nextCursor = msg.nextCursor - m.loadingMore = false - m.fetchError = nil // Clear any previous transient error on success - - if msg.isInitial { - m.initialLoading = false - m.filtered = m.filter(m.textInput.Value()) - return m, textinput.Blink - } - - // Re-filter with new items - m.filtered = m.filter(m.textInput.Value()) - - // If filter still yields no results and more pages exist, continue fetching - // This auto-drains pages until matches are found or no more pages - query := strings.TrimSpace(m.textInput.Value()) - if m.hasMore && len(m.filtered) == 0 && query != "" { - m.loadingMore = true - return m, tea.Batch(m.spinner.Tick, m.fetchPage(false)) - } - return m, nil - - case spinner.TickMsg: - if m.initialLoading || m.loadingMore { - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } - - case tea.KeyPressMsg: - // In initial loading state, only allow cancel - if m.initialLoading { - if msg.String() == "ctrl+c" || msg.String() == "esc" { - m.quitting = true - return m, tea.Quit - } - return m, nil - } - - switch msg.String() { - case "ctrl+c", "esc": - m.quitting = true - return m, tea.Quit - case "enter": - if len(m.filtered) > 0 && m.cursor < len(m.filtered) { - m.selected = &m.filtered[m.cursor] - } - return m, tea.Quit - case "up", "ctrl+p": - if m.cursor > 0 { - m.cursor-- - if m.cursor < m.scrollOffset { - m.scrollOffset = m.cursor - } - } - case "down", "ctrl+n": - if m.cursor < len(m.filtered)-1 { - m.cursor++ - if m.cursor >= m.scrollOffset+m.maxVisible { - m.scrollOffset = m.cursor - m.maxVisible + 1 - } - } - - // Check if we need to fetch more items - // Trigger when cursor is within fetchThreshold of the end of filtered results - // This works both with and without a filter - new items will be filtered on arrival - if m.hasMore && !m.loadingMore { - itemsFromEnd := len(m.filtered) - 1 - m.cursor - if itemsFromEnd < m.fetchThreshold { - m.loadingMore = true - return m, tea.Batch(m.spinner.Tick, m.fetchPage(false)) - } - } - case "tab": - // Tab to select first match - if len(m.filtered) > 0 { - m.selected = &m.filtered[0] - } - return m, tea.Quit - default: - var cmd tea.Cmd - m.textInput, cmd = m.textInput.Update(msg) - m.filtered = m.filter(m.textInput.Value()) - m.cursor = 0 - m.scrollOffset = 0 - - // If filter yields no results but more pages exist, fetch more - // This allows discovering matches beyond the initial page - if m.hasMore && !m.loadingMore && len(m.filtered) == 0 && len(m.items) > 0 { - m.loadingMore = true - return m, tea.Batch(cmd, m.spinner.Tick, m.fetchPage(false)) - } - return m, cmd - } - } - - return m, nil -} - -func (m paginatedPickerModel) filter(query string) []PickerItem { - if query == "" { - return m.items - } - - query = strings.ToLower(query) - var result []PickerItem - for _, item := range m.items { - if strings.Contains(strings.ToLower(item.FilterValue()), query) { - result = append(result, item) - } - } - return result -} - -func (m paginatedPickerModel) View() tea.View { - if m.quitting { - return tea.NewView("") - } - - var b strings.Builder - - // Title - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(m.styles.theme.Primary). - MarginBottom(1) - b.WriteString(titleStyle.Render(m.title) + "\n\n") - - // Initial loading state - if m.initialLoading { - b.WriteString(m.spinner.View() + " " + m.styles.Muted.Render(m.loadingMsg) + "\n") - return tea.NewView(b.String()) - } - - // Error state - if m.fetchError != nil && len(m.items) == 0 { - b.WriteString(m.styles.Error.Render("Error: "+m.fetchError.Error()) + "\n") - b.WriteString(m.styles.Muted.Render("Press esc to cancel")) - return tea.NewView(b.String()) - } - - // Input - b.WriteString(m.textInput.View() + "\n\n") - - // Items - if len(m.filtered) == 0 { - // Show loading indicator during auto-drain, otherwise "No matches found" - if m.loadingMore { - b.WriteString(m.spinner.View() + " " + m.styles.Muted.Render("Searching...")) - } else { - b.WriteString(m.styles.Muted.Render("No matches found")) - } - } else { - // Calculate visible range - start := m.scrollOffset - end := min(start+m.maxVisible, len(m.filtered)) - - for i := start; i < end; i++ { - item := m.filtered[i] - cursor := " " - style := m.styles.Body - - if i == m.cursor { - cursor = m.styles.Cursor.Render("> ") - style = m.styles.Selected - } - - line := cursor + style.Render(item.Title) - if item.Description != "" { - line += m.styles.Muted.Render(" - " + item.Description) - } - b.WriteString(line + "\n") - } - - // Show status line - var statusParts []string - - // Scroll/count indicator - if len(m.filtered) > m.maxVisible || m.hasMore { - if m.hasMore { - statusParts = append(statusParts, fmt.Sprintf("Showing %d-%d of %d+", start+1, end, m.totalLoaded)) - } else { - statusParts = append(statusParts, fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.filtered))) - } - } - - // Loading more indicator - if m.loadingMore { - statusParts = append(statusParts, m.spinner.View()+" Loading more...") - } - - // Error during pagination (but we have some items) - if m.fetchError != nil && len(m.items) > 0 { - statusParts = append(statusParts, m.styles.Error.Render("(error loading more)")) - } - - if len(statusParts) > 0 { - b.WriteString("\n" + m.styles.Muted.Render(strings.Join(statusParts, " "))) - } - } - - // Help - helpStyle := m.styles.Muted.Padding(1, 0, 0, 0) - b.WriteString("\n" + helpStyle.Render("↑↓ navigate • enter select • esc cancel")) - - return tea.NewView(b.String()) -} - -// PaginatedPicker shows a fuzzy-search picker with progressive pagination. -type PaginatedPicker struct { - fetcher PageFetcher - opts []PaginatedPickerOption - ctx context.Context -} - -// NewPaginatedPicker creates a new paginated picker. -func NewPaginatedPicker(ctx context.Context, fetcher PageFetcher, opts ...PaginatedPickerOption) *PaginatedPicker { - return &PaginatedPicker{ - fetcher: fetcher, - opts: opts, - ctx: ctx, - } -} - -// Run shows the picker and returns the selected item. -// Returns nil if the user canceled, and ErrNotInteractive when stdio cannot -// drive a TUI at all. -func (p *PaginatedPicker) Run() (*PickerItem, error) { - if !canPick() { - return nil, ErrNotInteractive - } - - m := newPaginatedPickerModel(p.ctx, p.fetcher, p.opts...) - program := tea.NewProgram(m) - - finalModel, err := program.Run() - if err != nil { - return nil, err - } - - final := finalModel.(paginatedPickerModel) //nolint:errcheck // type assertion always succeeds here - if final.quitting { - return nil, nil - } - if final.fetchError != nil && len(final.items) == 0 { - return nil, final.fetchError - } - return final.selected, nil -} - -// PickPaginated is a convenience function for paginated picking. -func PickPaginated(ctx context.Context, title string, fetcher PageFetcher) (*PickerItem, error) { - return NewPaginatedPicker(ctx, fetcher, - WithPaginatedPickerTitle(title), - ).Run() -} diff --git a/internal/tui/spinner.go b/internal/tui/spinner.go deleted file mode 100644 index 4c13eec9..00000000 --- a/internal/tui/spinner.go +++ /dev/null @@ -1,191 +0,0 @@ -package tui - -import ( - "fmt" - "image/color" - "time" - - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -// SpinnerStyle defines the visual style of a spinner. -type SpinnerStyle int - -const ( - SpinnerDots SpinnerStyle = iota - SpinnerLine - SpinnerPulse - SpinnerPoints - SpinnerGlobe - SpinnerMoon - SpinnerMonkey - SpinnerMeter - SpinnerHamburger -) - -// spinnerModel is the bubbletea model for a spinner. -type spinnerModel struct { - spinner spinner.Model - message string - done bool - result string - err error - styles *Styles - quitting bool -} - -// SpinnerOption configures a spinner. -type SpinnerOption func(*spinnerModel) - -// WithSpinnerStyle sets the spinner animation style. -func WithSpinnerStyle(style SpinnerStyle) SpinnerOption { - return func(m *spinnerModel) { - switch style { - case SpinnerDots: - m.spinner.Spinner = spinner.Dot - case SpinnerLine: - m.spinner.Spinner = spinner.Line - case SpinnerPulse: - m.spinner.Spinner = spinner.Pulse - case SpinnerPoints: - m.spinner.Spinner = spinner.Points - case SpinnerGlobe: - m.spinner.Spinner = spinner.Globe - case SpinnerMoon: - m.spinner.Spinner = spinner.Moon - case SpinnerMonkey: - m.spinner.Spinner = spinner.Monkey - case SpinnerMeter: - m.spinner.Spinner = spinner.Meter - case SpinnerHamburger: - m.spinner.Spinner = spinner.Hamburger - } - } -} - -// WithSpinnerColor sets the spinner color. -func WithSpinnerColor(c color.Color) SpinnerOption { - return func(m *spinnerModel) { - m.spinner.Style = lipgloss.NewStyle().Foreground(c) - } -} - -func newSpinnerModel(message string, opts ...SpinnerOption) spinnerModel { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(DefaultTheme(true).Primary) - - m := spinnerModel{ - spinner: s, - message: message, - styles: NewStyles(), - } - - for _, opt := range opts { - opt(&m) - } - - return m -} - -func (m spinnerModel) Init() tea.Cmd { - return m.spinner.Tick -} - -type spinnerDoneMsg struct { - result string - err error -} - -func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch msg.String() { - case "q", "ctrl+c": - m.quitting = true - return m, tea.Quit - } - case spinnerDoneMsg: - m.done = true - m.result = msg.result - m.err = msg.err - return m, tea.Quit - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } - return m, nil -} - -func (m spinnerModel) View() tea.View { - if m.quitting { - return tea.NewView("") - } - var content string - if m.done { - if m.err != nil { - content = m.styles.Error.Render("✗ "+m.err.Error()) + "\n" - } else { - content = m.styles.Success.Render("✓ "+m.result) + "\n" - } - } else { - content = fmt.Sprintf("%s %s\n", m.spinner.View(), m.message) - } - return tea.NewView(content) -} - -// Spinner runs a spinner while a function executes. -type Spinner struct { - message string - opts []SpinnerOption -} - -// NewSpinner creates a new spinner with a message. -func NewSpinner(message string, opts ...SpinnerOption) *Spinner { - return &Spinner{ - message: message, - opts: opts, - } -} - -// Run executes the given function while displaying a spinner. -// Returns the result and any error from the function. -func (s *Spinner) Run(fn func() (string, error)) (string, error) { - if !canPick() { - return "", ErrNotInteractive - } - - m := newSpinnerModel(s.message, s.opts...) - - p := tea.NewProgram(m) - - // Run the function in a goroutine - go func() { - result, err := fn() - time.Sleep(100 * time.Millisecond) // Brief pause so spinner is visible - p.Send(spinnerDoneMsg{result: result, err: err}) - }() - - finalModel, err := p.Run() - if err != nil { - return "", err - } - - final := finalModel.(spinnerModel) //nolint:errcheck // type assertion always succeeds here - if final.quitting { - return "", fmt.Errorf("canceled") - } - return final.result, final.err -} - -// RunSimple executes a function while displaying a spinner. -// Use this for functions that don't return a result message. -func (s *Spinner) RunSimple(fn func() error) error { - _, err := s.Run(func() (string, error) { - return "Done", fn() - }) - return err -}