Download
119 lines 3.5 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
package sandbox

import (
	"fmt"
	"path/filepath"
	"strconv"
	"strings"
	"unicode/utf8"

	"blinker/internal/diff"
)

// maxShown is the most of a file shown as text, before or after.
const maxShown = 1 << 20

// Describe shows a preview's changes, for the user to decide on: text
// files as a unified diff, with paths relative to project where they are
// in it, and the rest, with modes, in words. Names are quoted where they
// hold what could pass for something else, but the text is the command's:
// the caller strips terminal control sequences from it.
func (p *Preview) Describe(project string) (diffs, about string, err error) {
	var d, notes []string
	note := func(format string, args ...any) { notes = append(notes, fmt.Sprintf(format, args...)) }
	for _, c := range p.Changes {
		name := c.Path
		if rel, err := filepath.Rel(project, c.Path); err == nil && filepath.IsLocal(rel) {
			name = rel
		}
		name = quoted(name)
		was, wasMode := c.Before.Type, c.Before.Mode
		switch c.Kind {
		case Write:
			if c.Size > maxShown || c.Before.Size > maxShown {
				note("%s: a file too large to show here, now %d bytes", name, c.Size)
			} else {
				old, err := c.Old()
				if err != nil {
					return "", "", err
				}
				data, err := c.Contents()
				if err != nil {
					return "", "", err
				}
				switch {
				case !isText(string(old)) || !isText(string(data)):
					note("%s: a binary file, now %d bytes", name, len(data))
				case was == "" && len(data) == 0:
					note("%s: a new empty file", name)
				default:
					d = append(d, diff.Unified(name, string(old), string(data), 3))
				}
			}
			switch {
			case was == "file" && wasMode != c.Mode:
				note("%s: mode %o → %o", name, wasMode, c.Mode)
			case was == "" && c.Mode&0o111 != 0:
				note("%s: a new executable file (mode %o)", name, c.Mode)
			case was != "file" && was != "":
				note("%s: a file (mode %o), in place of %s", name, c.Mode, replaced(was))
			}
		case Mkdir:
			switch {
			case was == "":
				note("%s/: a new directory (mode %o)", name, c.Mode)
			case c.Opaque && was == "dir":
				note("%s/: emptied, and holding only what is shown here", name)
			case c.Opaque:
				note("%s/: a directory, in place of %s", name, replaced(was))
			}
			if was == "dir" && wasMode != c.Mode {
				note("%s/: mode %o → %o", name, wasMode, c.Mode)
			}
		case Symlink:
			if was == "" || was == "symlink" {
				note("%s: a symlink to %s", name, quoted(c.Target))
			} else {
				note("%s: a symlink to %s, in place of %s", name, quoted(c.Target), replaced(was))
			}
		case Remove:
			if was == "dir" {
				note("%s/: removed, with everything in it", name)
				continue
			}
			note("%s: removed", name)
			if was != "file" || c.Before.Size > maxShown {
				continue
			}
			if old, err := c.Old(); err != nil {
				return "", "", err
			} else if isText(string(old)) {
				d = append(d, diff.Unified(name, string(old), "", 3))
			}
		}
	}
	return strings.Join(d, ""), strings.Join(notes, "\n"), nil
}

// replaced names what a change replaced.
func replaced(was string) string {
	switch was {
	case "dir":
		return "the directory there, and everything in it"
	case "other":
		return "the special file there"
	}
	return "the " + was + " there"
}

// quoted gives a name as it is, or quoted if it holds what could pass for
// part of the prompt: a newline starting a diff header, say.
func quoted(name string) string {
	if q := strconv.Quote(name); q[1:len(q)-1] != name {
		return q
	}
	return name
}

// isText reports whether data can be shown as text.
func isText(s string) bool { return utf8.ValidString(s) && !strings.ContainsRune(s, 0) }