git/intake/main
internal/tui/editor.go
187 lines5 KB last change raw · history
package tui

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

	tea "charm.land/bubbletea/v2"

	"intake/internal/sheet"
)

type editorFinishedMsg struct {
	err    error
	before sheet.Sheet
}

func editorCommand() string {
	for _, k := range []string{"VISUAL", "EDITOR"} {
		if v := os.Getenv(k); v != "" {
			return v
		}
	}
	return "vi"
}

// openSheetEditor hands the whole album to $EDITOR.
//
// It is available from every screen that has a plan loaded except the one that
// is filing it: deciding on a cover is exactly when somebody notices that the
// album artist is wrong, and having to back out of the picker first, or quit and
// start over, would make the editor useless at the moment it is most wanted.
func (m Module) openSheetEditor() (tea.Model, tea.Cmd) {
	if len(m.pl.Tracks) == 0 {
		m.status = "nothing to edit yet"
		return m, nil
	}
	current := m.pl.Sheet()
	current.Cover = m.coverDescription()

	path := filepath.Join(m.opts.Config.StateDir, "sheet.txt")
	if m.opts.Config.StateDir == "" {
		dir, err := os.MkdirTemp("", "intake-sheet-")
		if err != nil {
			m.status = "could not create a temporary file: " + err.Error()
			return m, nil
		}
		path = filepath.Join(dir, "sheet.txt")
	} else if err := os.MkdirAll(m.opts.Config.StateDir, 0o755); err != nil {
		m.status = "could not create " + m.opts.Config.StateDir + ": " + err.Error()
		return m, nil
	}
	if err := os.WriteFile(path, []byte(sheet.Render(current)), 0o644); err != nil {
		m.status = "could not write " + path + ": " + err.Error()
		return m, nil
	}
	m.sheetPath = path
	m.sheetBefore = &current

	cmd := exec.Command("sh", "-c", editorCommand()+" "+shellQuote(path))
	return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
		return editorFinishedMsg{err: err, before: current}
	})
}

// sheetStore is where an album's edits are kept between runs.
func (m Module) sheetStore() sheet.Store {
	return sheet.Store{Dir: filepath.Join(m.opts.Config.StateDir, "review")}
}

// restoreSavedEdits applies the sheet saved for this album, if there is one, so
// that quitting in the middle of a review does not throw the corrections away.
func (m *Module) restoreSavedEdits() bool {
	text, ok, err := m.sheetStore().Load(m.current.Dir)
	if err != nil || !ok {
		return false
	}
	before := m.pl.Sheet()
	next, err := sheet.Parse(text, before)
	if err != nil {
		// A saved sheet that no longer fits the album -- the files changed under
		// it -- is dropped rather than left to fail on every open.
		m.sheetStore().Delete(m.current.Dir)
		m.status = "could not restore your saved edits: " + err.Error()
		return false
	}
	if err := m.pl.ApplySheet(next); err != nil {
		m.sheetStore().Delete(m.current.Dir)
		m.status = "could not restore your saved edits: " + err.Error()
		return false
	}
	m.restored = true
	if next.ChangedFrom(before).Cover {
		if err := m.applySheetCover(next.Cover); err != nil {
			m.status = err.Error()
		}
	}
	return true
}

// coverDescription is what the sheet's cover field shows: the cover already
// chosen, or nothing.
func (m Module) coverDescription() string {
	switch {
	case m.hasCover && m.cover.Placeholder:
		return "placeholder"
	case m.hasCover && m.cover.Path != "":
		return m.cover.Path
	default:
		return ""
	}
}

func (m *Module) applyEditedSheet(msg editorFinishedMsg) {
	path, before := m.sheetPath, msg.before
	if path == "" {
		return
	}
	defer func() {
		os.Remove(path)
		m.sheetPath, m.sheetBefore = "", nil
	}()

	text, err := os.ReadFile(path)
	if err != nil {
		m.status = "could not read the edited sheet: " + err.Error()
		return
	}
	next, err := sheet.Parse(string(text), before)
	if err != nil {
		m.status = err.Error()
		return
	}
	if err := m.pl.ApplySheet(next); err != nil {
		m.status = err.Error()
		return
	}
	// Kept so that quitting, or opening another album and coming back, does not
	// lose what was just typed.
	if _, err := m.sheetStore().Save(m.current.Dir, string(text)); err != nil {
		m.status = "the album was updated but your edits could not be saved: " + err.Error()
	}

	// A cover typed into the sheet replaces whatever was chosen and one set to
	// "none" clears it; a cover left alone is left alone.
	if next.ChangedFrom(before).Cover {
		if err := m.applySheetCover(next.Cover); err != nil {
			m.status = err.Error()
			return
		}
	}

	m.status = fmt.Sprintf("album updated: %d track%s", len(m.pl.Tracks), plural(len(m.pl.Tracks)))
}

func (m *Module) applySheetCover(value string) error {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "":
		return nil
	case "none":
		m.cover, m.hasCover = CoverOption{}, false
		return nil
	case "placeholder":
		m.cover = CoverOption{Source: "placeholder", Label: "placeholder", Placeholder: true}
		m.hasCover = true
		return nil
	}
	path := strings.TrimSpace(value)
	if strings.HasPrefix(path, "~/") {
		if home, err := os.UserHomeDir(); err == nil {
			path = filepath.Join(home, path[2:])
		}
	}
	if _, err := os.Stat(path); err != nil {
		return fmt.Errorf("the cover in the sheet cannot be read: %v", err)
	}
	m.cover = CoverOption{Path: path, Source: "file", Label: filepath.Base(path)}
	m.hasCover = true
	return nil
}

func shellQuote(s string) string {
	return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}