Download
157 lines 5.2 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
package tui

import (
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"

	tea "charm.land/bubbletea/v2"

	"blinker/internal/tools"
)

// diffEdited is the end of a vimdiff session editing a question's changes.
type diffEdited struct {
	dir      string   // the temporary directory, to remove
	proposed []string // the files the user edited, one per change
	err      error
}

// editDiff opens the pending question's changes in vimdiff, one tab each:
// the current contents on the left, read-only, and the proposed ones on
// the right for the user to edit. The files go in a private directory
// under blinker's state directory, never the project.
func (m *Model) editDiff(edits []tools.Edit) tea.Cmd {
	fail := func(dir string, err error) tea.Cmd {
		return func() tea.Msg { return diffEdited{dir: dir, err: err} }
	}
	if len(edits) == 0 {
		return fail("", errors.New("there is nothing to edit"))
	}
	argv, err := diffEditor()
	if err != nil {
		return fail("", err)
	}
	if err := os.MkdirAll(m.d.StateDir, 0o700); err != nil {
		return fail("", err)
	}
	dir, err := os.MkdirTemp(m.d.StateDir, "edit-")
	if err != nil {
		return fail("", err)
	}
	if strings.ContainsAny(dir, "\n\r") {
		return fail(dir, fmt.Errorf("the state directory %q has a line break in its name", dir))
	}
	// A script opens the tabs: vim takes only one pair of files to diff.
	var script strings.Builder
	var proposed []string
	for i, e := range edits {
		name := editorName(e.Name)
		currentPath := filepath.Join(dir, strconv.Itoa(i), "current", name)
		proposedPath := filepath.Join(dir, strconv.Itoa(i), "proposed", name)
		for path, text := range map[string]string{currentPath: e.Old, proposedPath: e.New} {
			if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
				return fail(dir, err)
			}
			if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
				return fail(dir, err)
			}
		}
		if i > 0 {
			script.WriteString("tabnew\n")
		}
		// -R below makes every file read-only; the proposed one is made
		// writable again, and the current one opened to its left.
		fmt.Fprintf(&script, "execute 'edit' fnameescape(%s)\nsetlocal noreadonly\n", vimString(proposedPath))
		fmt.Fprintf(&script, "execute 'leftabove vertical diffsplit' fnameescape(%s)\nwincmd l\n", vimString(currentPath))
		proposed = append(proposed, proposedPath)
	}
	script.WriteString("tabfirst\n")
	scriptPath := filepath.Join(dir, "open.vim")
	if err := os.WriteFile(scriptPath, []byte(script.String()), 0o600); err != nil {
		return fail(dir, err)
	}
	argv = append(append(argv, editorFlags...), "-R", "-S", scriptPath)
	cmd := exec.Command(argv[0], argv[1:]...)
	// Not the project, where an editor with 'exrc' set would run a .exrc
	// the model may have written.
	cmd.Dir = dir
	return tea.ExecProcess(cmd, func(err error) tea.Msg { return diffEdited{dir: dir, proposed: proposed, err: err} })
}

// editorFlags keep vim from acting on the files it shows, which may come
// from the model: modelines, which let a file set editor options, are
// off, and so are the plugins that run programs on a file by its name
// (tar and unzip on a .tar or .zip, gzip, vimball, netrw).
var editorFlags = []string{
	"--cmd", "set nomodeline",
	"--cmd", "let g:loaded_tarPlugin = 1 | let g:loaded_zipPlugin = 1 | let g:loaded_gzip = 1 | let g:loaded_vimballPlugin = 1 | let g:loaded_netrwPlugin = 1",
}

// editorName is a file's name for the editor, only for its syntax
// highlighting. It may come from the model or a repository, so it is cut
// to its base and to characters that mean nothing to vim or a file system.
func editorName(name string) string {
	name = strings.Map(func(r rune) rune {
		if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '-' || r == '_' {
			return r
		}
		return '_'
	}, filepath.Base(name))
	if strings.Trim(name, ".") == "" {
		return "file"
	}
	return name
}

// vimString quotes a string for a vim script, which must not have a line
// break in it.
func vimString(s string) string {
	return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}

// diffEditor finds vimdiff, or Neovim's equivalent.
func diffEditor() ([]string, error) {
	if p, err := exec.LookPath("vimdiff"); err == nil {
		return []string{p}, nil
	}
	if p, err := exec.LookPath("nvim"); err == nil {
		return []string{p, "-d"}, nil
	}
	return nil, errors.New("neither vimdiff nor nvim is installed")
}

// diffEditDone answers the pending question with the edited contents, or,
// if the editor failed or quit with an error (:cq), leaves it pending.
func (m *Model) diffEditDone(msg diffEdited) {
	var edited []string
	err := msg.err
	for _, path := range msg.proposed {
		if err != nil {
			break
		}
		var data []byte
		data, err = os.ReadFile(path)
		edited = append(edited, string(data))
	}
	if msg.dir != "" {
		if rmErr := os.RemoveAll(msg.dir); rmErr != nil {
			m.print(errStyle.Render("Could not remove " + msg.dir + ": " + rmErr.Error()))
		}
	}
	q := m.question
	if q == nil {
		return // the question was withdrawn while the editor was open
	}
	if err != nil {
		m.print(errStyle.Render("Not edited: " + Sanitize(err.Error())))
		return
	}
	q.reply <- tools.Answer{Option: q.q.Opts[q.sel], Edited: edited}
	m.print(okStyle.Render("  → " + q.q.Opts[q.sel].Label))
	m.closeQuestion()
}