// Package preview opens a tmux window showing a generated image via tmux-img. // Mode resolution and the actual spawn are kept separate so the CLI can // decide-then-act and tests can drive each half independently. package preview import ( "errors" "fmt" "os/exec" "strings" ) // Mode is the tri-state preview setting: auto (default), on (force), off. type Mode string const ( ModeAuto Mode = "auto" ModeOn Mode = "on" ModeOff Mode = "off" ) // ParseMode normalises a string into a Mode. Empty parses to ModeAuto so // callers can pass through unset config / env values. func ParseMode(s string) (Mode, error) { switch strings.ToLower(strings.TrimSpace(s)) { case "", "auto": return ModeAuto, nil case "on": return ModeOn, nil case "off": return ModeOff, nil } return "", fmt.Errorf("invalid preview mode %q (auto|on|off)", s) } // Decision is the answer to "should we preview, and why". type Decision struct { ShouldPreview bool Reason string } // Resolve maps (mode, runtime context) to a Decision. // // - off -> never preview // - on -> preview, but error if not in tmux (forced on outside tmux) // - auto -> preview iff inTmux && stdoutTTY func Resolve(mode Mode, inTmux, stdoutTTY bool) (Decision, error) { switch mode { case ModeOff: return Decision{ShouldPreview: false, Reason: "preview=off"}, nil case ModeOn: if !inTmux { return Decision{}, ErrNoTmuxForced } return Decision{ShouldPreview: true, Reason: "preview=on"}, nil case ModeAuto, "": if !inTmux { return Decision{ShouldPreview: false, Reason: "auto: $TMUX unset"}, nil } if !stdoutTTY { return Decision{ShouldPreview: false, Reason: "auto: stdout not a tty"}, nil } return Decision{ShouldPreview: true, Reason: "auto"}, nil } return Decision{}, fmt.Errorf("invalid preview mode %q", mode) } // Errors returned by Spawn and Resolve. Each names the missing piece and, // where relevant, where to install it. var ( ErrTmuxMissing = errors.New("tmux: binary not found on $PATH (required for image preview)") ErrTmuxImgMissing = errors.New("tmux-img: binary not found on $PATH (install at ~/.local/bin/tmux-img)") ErrNoTmuxForced = errors.New("--preview requires $TMUX (are you in a tmux session?)") ) // Spawner spawns the tmux preview window. The exec.LookPath / cmd.Run hooks // exist so tests can inject fakes without touching $PATH. type Spawner struct { LookPath func(string) (string, error) Run func(*exec.Cmd) error } // Spawn opens a new tmux window named img: running tmux-img --hold // . -d keeps focus in the current pane. Caller is expected to // have already verified that we are inside a tmux session. func (s *Spawner) Spawn(imagePath, slug string) error { look := s.LookPath if look == nil { look = exec.LookPath } run := s.Run if run == nil { run = func(c *exec.Cmd) error { return c.Run() } } tmuxBin, err := look("tmux") if err != nil { return ErrTmuxMissing } tmuxImgBin, err := look("tmux-img") if err != nil { return ErrTmuxImgMissing } name := "img:" + slug shellCmd := fmt.Sprintf("%s --hold %s", shellQuote(tmuxImgBin), shellQuote(imagePath)) cmd := exec.Command(tmuxBin, "new-window", "-d", "-n", name, shellCmd) if err := run(cmd); err != nil { return fmt.Errorf("tmux new-window: %w", err) } return nil } // shellQuote single-quotes s for /bin/sh — tmux passes the trailing arg of // new-window through a shell. func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }