mAi: #5 - tmux-window preview for generate
Adds an optional `imagen generate` post-step that opens a sibling tmux window running tmux-img --hold <path>. - internal/preview: Mode (auto|on|off), Resolve, and a Spawner that shells out to tmux new-window. Typed errors for missing tmux, missing tmux-img, and "preview forced on outside $TMUX". - cmd/imagen/generate: --preview / --no-preview flags plus $IMAGEN_PREVIEW. Resolution chain: config -> env -> flag. auto requires both stdout-is-tty and $TMUX. Failures are warnings - the image is already on disk. - internal/config: output.preview field, validated to auto|on|off, threaded into the sample. - Tests for ParseMode, Resolve, Spawn argv (incl. shell quoting of paths with apostrophes), missing-binary errors, and the CLI resolution table. - Docs (usage + architecture) updated. /imagine SKILL.md edit lives in dotfiles - deferred to coordinate with #4.
This commit is contained in:
@@ -11,21 +11,24 @@ import (
|
|||||||
"mgit.msbls.de/m/ImaGen/internal/backend"
|
"mgit.msbls.de/m/ImaGen/internal/backend"
|
||||||
"mgit.msbls.de/m/ImaGen/internal/config"
|
"mgit.msbls.de/m/ImaGen/internal/config"
|
||||||
"mgit.msbls.de/m/ImaGen/internal/output"
|
"mgit.msbls.de/m/ImaGen/internal/output"
|
||||||
|
"mgit.msbls.de/m/ImaGen/internal/preview"
|
||||||
"mgit.msbls.de/m/ImaGen/internal/prompt"
|
"mgit.msbls.de/m/ImaGen/internal/prompt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runGenerate(ctx context.Context, args []string) error {
|
func runGenerate(ctx context.Context, args []string) error {
|
||||||
fs := flag.NewFlagSet("generate", flag.ContinueOnError)
|
fs := flag.NewFlagSet("generate", flag.ContinueOnError)
|
||||||
var (
|
var (
|
||||||
backendName string
|
backendName string
|
||||||
size string
|
size string
|
||||||
outPath string
|
outPath string
|
||||||
seed int64
|
seed int64
|
||||||
steps int
|
steps int
|
||||||
style string
|
style string
|
||||||
negative string
|
negative string
|
||||||
configPath string
|
configPath string
|
||||||
noSidecar bool
|
noSidecar bool
|
||||||
|
previewOn bool
|
||||||
|
previewOff bool
|
||||||
)
|
)
|
||||||
fs.StringVar(&backendName, "backend", "", "backend instance name (default: config.default_backend)")
|
fs.StringVar(&backendName, "backend", "", "backend instance name (default: config.default_backend)")
|
||||||
fs.StringVar(&size, "size", "1024x1024", "WxH, e.g. 1024x1024")
|
fs.StringVar(&size, "size", "1024x1024", "WxH, e.g. 1024x1024")
|
||||||
@@ -36,6 +39,8 @@ func runGenerate(ctx context.Context, args []string) error {
|
|||||||
fs.StringVar(&negative, "negative", "", "negative prompt (ignored by backends that don't support it)")
|
fs.StringVar(&negative, "negative", "", "negative prompt (ignored by backends that don't support it)")
|
||||||
fs.StringVar(&configPath, "config", "", "config file path (default: ~/.config/imagen.yaml)")
|
fs.StringVar(&configPath, "config", "", "config file path (default: ~/.config/imagen.yaml)")
|
||||||
fs.BoolVar(&noSidecar, "no-sidecar", false, "skip the JSON sidecar even if config enables it")
|
fs.BoolVar(&noSidecar, "no-sidecar", false, "skip the JSON sidecar even if config enables it")
|
||||||
|
fs.BoolVar(&previewOn, "preview", false, "force tmux preview window on (errors outside $TMUX)")
|
||||||
|
fs.BoolVar(&previewOff, "no-preview", false, "skip the tmux preview window")
|
||||||
fs.Usage = func() {
|
fs.Usage = func() {
|
||||||
fmt.Fprintln(fs.Output(), `Usage: imagen generate "<prompt>" [flags]`)
|
fmt.Fprintln(fs.Output(), `Usage: imagen generate "<prompt>" [flags]`)
|
||||||
fs.PrintDefaults()
|
fs.PrintDefaults()
|
||||||
@@ -118,9 +123,70 @@ func runGenerate(ctx context.Context, args []string) error {
|
|||||||
if paths.SidecarPath != "" {
|
if paths.SidecarPath != "" {
|
||||||
fmt.Fprintln(os.Stderr, "sidecar:", paths.SidecarPath)
|
fmt.Fprintln(os.Stderr, "sidecar:", paths.SidecarPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := maybePreview(cfg, previewOn, previewOff, paths.ImagePath, rawPrompt); err != nil {
|
||||||
|
// preview failures are warnings — the image already wrote.
|
||||||
|
fmt.Fprintln(os.Stderr, "imagen: preview:", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolvePreviewMode applies the precedence chain config -> env -> flag.
|
||||||
|
// Flags win, env beats config, config beats the implicit auto default.
|
||||||
|
func resolvePreviewMode(cfg *config.Config, flagOn, flagOff bool, env string) (preview.Mode, error) {
|
||||||
|
mode := preview.ModeAuto
|
||||||
|
if cfg != nil && cfg.Output.Preview != "" {
|
||||||
|
m, err := preview.ParseMode(cfg.Output.Preview)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("config output.preview: %w", err)
|
||||||
|
}
|
||||||
|
mode = m
|
||||||
|
}
|
||||||
|
if env != "" {
|
||||||
|
m, err := preview.ParseMode(env)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("$IMAGEN_PREVIEW: %w", err)
|
||||||
|
}
|
||||||
|
mode = m
|
||||||
|
}
|
||||||
|
if flagOn && flagOff {
|
||||||
|
return "", userErr("--preview and --no-preview are mutually exclusive")
|
||||||
|
}
|
||||||
|
if flagOn {
|
||||||
|
mode = preview.ModeOn
|
||||||
|
}
|
||||||
|
if flagOff {
|
||||||
|
mode = preview.ModeOff
|
||||||
|
}
|
||||||
|
return mode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybePreview resolves the effective preview mode and, if it says yes,
|
||||||
|
// spawns a tmux window via tmux-img. Always non-fatal.
|
||||||
|
func maybePreview(cfg *config.Config, flagOn, flagOff bool, imagePath, rawPrompt string) error {
|
||||||
|
mode, err := resolvePreviewMode(cfg, flagOn, flagOff, os.Getenv("IMAGEN_PREVIEW"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
decision, err := preview.Resolve(mode, os.Getenv("TMUX") != "", stdoutIsTTY())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !decision.ShouldPreview {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
spawner := &preview.Spawner{}
|
||||||
|
return spawner.Spawn(imagePath, output.Slug(rawPrompt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func stdoutIsTTY() bool {
|
||||||
|
fi, err := os.Stdout.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return fi.Mode()&os.ModeCharDevice != 0
|
||||||
|
}
|
||||||
|
|
||||||
// splitLeadingPositional separates the positional args at the start of args
|
// splitLeadingPositional separates the positional args at the start of args
|
||||||
// from the rest (which begins with the first flag). A literal "--" terminator
|
// from the rest (which begins with the first flag). A literal "--" terminator
|
||||||
// pushes everything after it into the positional list and out of flag parsing.
|
// pushes everything after it into the positional list and out of flag parsing.
|
||||||
|
|||||||
50
cmd/imagen/generate_test.go
Normal file
50
cmd/imagen/generate_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/ImaGen/internal/config"
|
||||||
|
"mgit.msbls.de/m/ImaGen/internal/preview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolvePreviewMode(t *testing.T) {
|
||||||
|
type tc struct {
|
||||||
|
name string
|
||||||
|
cfg *config.Config
|
||||||
|
flagOn bool
|
||||||
|
flagOff bool
|
||||||
|
env string
|
||||||
|
want preview.Mode
|
||||||
|
wantError bool
|
||||||
|
}
|
||||||
|
cases := []tc{
|
||||||
|
{name: "all-empty-defaults-to-auto", want: preview.ModeAuto},
|
||||||
|
{name: "config-on", cfg: &config.Config{Output: config.OutputConfig{Preview: "on"}}, want: preview.ModeOn},
|
||||||
|
{name: "config-off", cfg: &config.Config{Output: config.OutputConfig{Preview: "off"}}, want: preview.ModeOff},
|
||||||
|
{name: "config-auto-explicit", cfg: &config.Config{Output: config.OutputConfig{Preview: "auto"}}, want: preview.ModeAuto},
|
||||||
|
{name: "env-overrides-config", cfg: &config.Config{Output: config.OutputConfig{Preview: "on"}}, env: "off", want: preview.ModeOff},
|
||||||
|
{name: "flag-on-overrides-env-off", env: "off", flagOn: true, want: preview.ModeOn},
|
||||||
|
{name: "flag-off-overrides-env-on", env: "on", flagOff: true, want: preview.ModeOff},
|
||||||
|
{name: "flag-off-overrides-config-on", cfg: &config.Config{Output: config.OutputConfig{Preview: "on"}}, flagOff: true, want: preview.ModeOff},
|
||||||
|
{name: "both-flags-error", flagOn: true, flagOff: true, wantError: true},
|
||||||
|
{name: "bad-env-errors", env: "yes", wantError: true},
|
||||||
|
{name: "bad-config-errors", cfg: &config.Config{Output: config.OutputConfig{Preview: "yes"}}, wantError: true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got, err := resolvePreviewMode(c.cfg, c.flagOn, c.flagOff, c.env)
|
||||||
|
if c.wantError {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got mode %q", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("mode = %q, want %q", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ upstream API. Each adapter only ever sees its own slice of `imagen.yaml`.
|
|||||||
│ internal/prompt │ style preset → prompt suffix
|
│ internal/prompt │ style preset → prompt suffix
|
||||||
│ internal/output │ filename templating, sidecar
|
│ internal/output │ filename templating, sidecar
|
||||||
│ internal/config │ YAML loader, validation
|
│ internal/config │ YAML loader, validation
|
||||||
|
│ internal/preview │ tmux-img window spawner
|
||||||
└──────────┬────────────┘
|
└──────────┬────────────┘
|
||||||
│
|
│
|
||||||
┌──────────▼────────────┐
|
┌──────────▼────────────┐
|
||||||
|
|||||||
@@ -24,8 +24,28 @@ imagen version print version
|
|||||||
| `--negative` | empty | Negative prompt (ignored by some adapters) |
|
| `--negative` | empty | Negative prompt (ignored by some adapters) |
|
||||||
| `--output` | empty (= use naming template) | Explicit path |
|
| `--output` | empty (= use naming template) | Explicit path |
|
||||||
| `--no-sidecar` | `false` | Skip the JSON sidecar even if config enables it |
|
| `--no-sidecar` | `false` | Skip the JSON sidecar even if config enables it |
|
||||||
|
| `--preview` | (auto) | Force open a tmux preview window via `tmux-img` |
|
||||||
|
| `--no-preview` | (auto) | Suppress the preview window (use for batch / CI callers) |
|
||||||
| `--config` | `~/.config/imagen.yaml` | Override config path |
|
| `--config` | `~/.config/imagen.yaml` | Override config path |
|
||||||
|
|
||||||
|
### Preview window
|
||||||
|
|
||||||
|
After a successful generate, imagen optionally opens a sibling tmux window
|
||||||
|
named `img:<slug>` running `tmux-img --hold <path>`. The new window is
|
||||||
|
spawned in the background (`tmux new-window -d`) so the generating pane
|
||||||
|
keeps focus and its terminal output.
|
||||||
|
|
||||||
|
Resolution order is **config → `$IMAGEN_PREVIEW` → flag** (later wins):
|
||||||
|
|
||||||
|
- `output.preview` in `imagen.yaml`: `auto` (default) | `on` | `off`
|
||||||
|
- `IMAGEN_PREVIEW=auto|on|off` overrides config
|
||||||
|
- `--preview` / `--no-preview` override env
|
||||||
|
|
||||||
|
`auto` previews iff stdout is a TTY *and* `$TMUX` is set. `on` previews
|
||||||
|
unconditionally and errors outside a tmux session. `off` never previews.
|
||||||
|
|
||||||
|
Preview failures are non-fatal — the image already wrote.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -19,11 +19,16 @@ type Config struct {
|
|||||||
Backends map[string]BackendSpec `yaml:"backends"`
|
Backends map[string]BackendSpec `yaml:"backends"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OutputConfig controls where generated images and metadata sidecars land.
|
// OutputConfig controls where generated images and metadata sidecars land,
|
||||||
|
// and whether `imagen generate` opens a tmux preview window.
|
||||||
type OutputConfig struct {
|
type OutputConfig struct {
|
||||||
Directory string `yaml:"directory"`
|
Directory string `yaml:"directory"`
|
||||||
Naming string `yaml:"naming"`
|
Naming string `yaml:"naming"`
|
||||||
WriteMetadataJSON bool `yaml:"write_metadata_json"`
|
WriteMetadataJSON bool `yaml:"write_metadata_json"`
|
||||||
|
// Preview is the tri-state preview mode: "auto" (default), "on", "off".
|
||||||
|
// Empty / unset is treated as "auto". $IMAGEN_PREVIEW and the
|
||||||
|
// --preview/--no-preview flags override this in turn.
|
||||||
|
Preview string `yaml:"preview"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackendSpec is one entry under `backends:`. Type identifies the adapter;
|
// BackendSpec is one entry under `backends:`. Type identifies the adapter;
|
||||||
@@ -78,6 +83,11 @@ func (c *Config) Validate() error {
|
|||||||
return fmt.Errorf("default_backend %q is not defined under backends:", c.DefaultBackend)
|
return fmt.Errorf("default_backend %q is not defined under backends:", c.DefaultBackend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
switch c.Output.Preview {
|
||||||
|
case "", "auto", "on", "off":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("output.preview = %q (must be auto|on|off)", c.Output.Preview)
|
||||||
|
}
|
||||||
for name, spec := range c.Backends {
|
for name, spec := range c.Backends {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return errors.New("empty backend name")
|
return errors.New("empty backend name")
|
||||||
@@ -101,6 +111,11 @@ output:
|
|||||||
directory: ~/Pictures/imagen
|
directory: ~/Pictures/imagen
|
||||||
naming: "{date}-{slug}-{seed}.png"
|
naming: "{date}-{slug}-{seed}.png"
|
||||||
write_metadata_json: true
|
write_metadata_json: true
|
||||||
|
# Open a tmux window with tmux-img after a successful generation.
|
||||||
|
# auto (default): preview iff stdout is a TTY and $TMUX is set.
|
||||||
|
# on: always preview (errors outside a tmux session).
|
||||||
|
# off: never preview (use this for batch / CI callers).
|
||||||
|
preview: auto
|
||||||
|
|
||||||
backends:
|
backends:
|
||||||
flux-schnell-local:
|
flux-schnell-local:
|
||||||
|
|||||||
@@ -60,6 +60,34 @@ func TestValidateRejectsMissingType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidatePreviewMode(t *testing.T) {
|
||||||
|
for _, mode := range []string{"", "auto", "on", "off"} {
|
||||||
|
c := &Config{Output: OutputConfig{Preview: mode}}
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
t.Errorf("preview=%q: unexpected error %v", mode, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bad := &Config{Output: OutputConfig{Preview: "yes"}}
|
||||||
|
if err := bad.Validate(); err == nil {
|
||||||
|
t.Errorf("expected error for invalid preview value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSampleParsesPreviewAuto(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "imagen.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(Sample), 0o644); err != nil {
|
||||||
|
t.Fatalf("write sample: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Output.Preview != "auto" {
|
||||||
|
t.Errorf("Output.Preview = %q, want auto", cfg.Output.Preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExpandPath(t *testing.T) {
|
func TestExpandPath(t *testing.T) {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
|
|||||||
119
internal/preview/tmux.go
Normal file
119
internal/preview/tmux.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// 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:<slug> running tmux-img --hold
|
||||||
|
// <imagePath>. -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, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
170
internal/preview/tmux_test.go
Normal file
170
internal/preview/tmux_test.go
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package preview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseMode(t *testing.T) {
|
||||||
|
cases := map[string]Mode{
|
||||||
|
"": ModeAuto,
|
||||||
|
"auto": ModeAuto,
|
||||||
|
"AUTO": ModeAuto,
|
||||||
|
"on": ModeOn,
|
||||||
|
" on ": ModeOn,
|
||||||
|
"off": ModeOff,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
got, err := ParseMode(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseMode(%q) err = %v", in, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("ParseMode(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := ParseMode("nope"); err == nil {
|
||||||
|
t.Errorf("ParseMode(nope) should have errored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve(t *testing.T) {
|
||||||
|
type tc struct {
|
||||||
|
mode Mode
|
||||||
|
inTmux bool
|
||||||
|
stdoutTTY bool
|
||||||
|
want bool
|
||||||
|
wantErr error
|
||||||
|
}
|
||||||
|
cases := map[string]tc{
|
||||||
|
"off-anywhere": {ModeOff, false, false, false, nil},
|
||||||
|
"off-in-tmux-tty": {ModeOff, true, true, false, nil},
|
||||||
|
"on-in-tmux": {ModeOn, true, false, true, nil},
|
||||||
|
"on-outside-tmux-errs": {ModeOn, false, true, false, ErrNoTmuxForced},
|
||||||
|
"auto-no-tmux": {ModeAuto, false, true, false, nil},
|
||||||
|
"auto-tmux-no-tty": {ModeAuto, true, false, false, nil},
|
||||||
|
"auto-tmux-and-tty": {ModeAuto, true, true, true, nil},
|
||||||
|
}
|
||||||
|
for name, c := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
d, err := Resolve(c.mode, c.inTmux, c.stdoutTTY)
|
||||||
|
if c.wantErr != nil {
|
||||||
|
if !errors.Is(err, c.wantErr) {
|
||||||
|
t.Fatalf("err = %v, want %v", err, c.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err = %v", err)
|
||||||
|
}
|
||||||
|
if d.ShouldPreview != c.want {
|
||||||
|
t.Errorf("ShouldPreview = %v, want %v (reason: %s)", d.ShouldPreview, c.want, d.Reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawn_BuildsCorrectCommand(t *testing.T) {
|
||||||
|
var captured *exec.Cmd
|
||||||
|
s := &Spawner{
|
||||||
|
LookPath: func(name string) (string, error) {
|
||||||
|
switch name {
|
||||||
|
case "tmux":
|
||||||
|
return "/usr/bin/tmux", nil
|
||||||
|
case "tmux-img":
|
||||||
|
return "/home/m/.local/bin/tmux-img", nil
|
||||||
|
}
|
||||||
|
return "", exec.ErrNotFound
|
||||||
|
},
|
||||||
|
Run: func(c *exec.Cmd) error {
|
||||||
|
captured = c
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := s.Spawn("/tmp/imagen/cat.png", "cat-in-a-fishbowl"); err != nil {
|
||||||
|
t.Fatalf("Spawn: %v", err)
|
||||||
|
}
|
||||||
|
if captured == nil {
|
||||||
|
t.Fatal("Run was not called")
|
||||||
|
}
|
||||||
|
if captured.Path != "/usr/bin/tmux" {
|
||||||
|
t.Errorf("Path = %q, want /usr/bin/tmux", captured.Path)
|
||||||
|
}
|
||||||
|
args := captured.Args
|
||||||
|
if len(args) < 6 {
|
||||||
|
t.Fatalf("args = %v (need at least 6)", args)
|
||||||
|
}
|
||||||
|
// tmux new-window -d -n img:<slug> '<shell-cmd>'
|
||||||
|
if args[1] != "new-window" {
|
||||||
|
t.Errorf("args[1] = %q, want new-window", args[1])
|
||||||
|
}
|
||||||
|
if args[2] != "-d" {
|
||||||
|
t.Errorf("args[2] = %q, want -d", args[2])
|
||||||
|
}
|
||||||
|
if args[3] != "-n" {
|
||||||
|
t.Errorf("args[3] = %q, want -n", args[3])
|
||||||
|
}
|
||||||
|
if args[4] != "img:cat-in-a-fishbowl" {
|
||||||
|
t.Errorf("args[4] = %q, want img:cat-in-a-fishbowl", args[4])
|
||||||
|
}
|
||||||
|
shellCmd := args[5]
|
||||||
|
if !strings.Contains(shellCmd, "tmux-img") || !strings.Contains(shellCmd, "--hold") || !strings.Contains(shellCmd, "/tmp/imagen/cat.png") {
|
||||||
|
t.Errorf("shell cmd %q missing expected pieces", shellCmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawn_PathWithSpacesAndQuotes(t *testing.T) {
|
||||||
|
var captured *exec.Cmd
|
||||||
|
s := &Spawner{
|
||||||
|
LookPath: func(name string) (string, error) {
|
||||||
|
if name == "tmux" {
|
||||||
|
return "/usr/bin/tmux", nil
|
||||||
|
}
|
||||||
|
if name == "tmux-img" {
|
||||||
|
return "/usr/local/bin/tmux-img", nil
|
||||||
|
}
|
||||||
|
return "", exec.ErrNotFound
|
||||||
|
},
|
||||||
|
Run: func(c *exec.Cmd) error { captured = c; return nil },
|
||||||
|
}
|
||||||
|
weird := "/tmp/imagen/o'malley's cat.png"
|
||||||
|
if err := s.Spawn(weird, "slug"); err != nil {
|
||||||
|
t.Fatalf("Spawn: %v", err)
|
||||||
|
}
|
||||||
|
shellCmd := captured.Args[5]
|
||||||
|
// Single-quoted with the embedded apostrophe escaped via the
|
||||||
|
// '\'' shell idiom — confirm we did not just splice the raw path.
|
||||||
|
if strings.Contains(shellCmd, "o'malley's") {
|
||||||
|
t.Errorf("shell cmd %q contains unescaped apostrophes", shellCmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawn_MissingTmux(t *testing.T) {
|
||||||
|
s := &Spawner{
|
||||||
|
LookPath: func(string) (string, error) { return "", exec.ErrNotFound },
|
||||||
|
Run: func(*exec.Cmd) error { return nil },
|
||||||
|
}
|
||||||
|
err := s.Spawn("/x.png", "s")
|
||||||
|
if !errors.Is(err, ErrTmuxMissing) {
|
||||||
|
t.Errorf("err = %v, want ErrTmuxMissing", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawn_MissingTmuxImg(t *testing.T) {
|
||||||
|
s := &Spawner{
|
||||||
|
LookPath: func(name string) (string, error) {
|
||||||
|
if name == "tmux" {
|
||||||
|
return "/usr/bin/tmux", nil
|
||||||
|
}
|
||||||
|
return "", exec.ErrNotFound
|
||||||
|
},
|
||||||
|
Run: func(*exec.Cmd) error { return nil },
|
||||||
|
}
|
||||||
|
err := s.Spawn("/x.png", "s")
|
||||||
|
if !errors.Is(err, ErrTmuxImgMissing) {
|
||||||
|
t.Errorf("err = %v, want ErrTmuxImgMissing", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user