Vectrum
Sign In
488 lines · 16.1 KB · Go
package tui

import (
	"bufio"
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"github.com/charmbracelet/bubbles/textarea"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"

	"github.com/johannSo/codey/internal/agent"
	"github.com/johannSo/codey/internal/llm"
)

// /worktree resume is the other half of /worktree: one makes a tree, this one
// goes back to a tree that already exists. What it opens there is a new chat,
// not the old one — a worktree outlives the conversation that made it, and the
// conversation you want in it a week later is rarely the one you left behind.
// (/resume is still how you pick up a specific conversation; a session that had
// moved into a tree takes you back to it.)

// resumeWord is the one argument /worktree does not read as a name. A tree
// called "resume" has to be made by hand, which is a price worth paying for a
// subcommand you can guess.
const resumeWord = "resume"

// worktreeListTimeout bounds the git call behind the picker. Listing trees is
// metadata only and returns in milliseconds; anything longer is a repository
// that is not going to answer, and the picker should say so rather than hang.
const worktreeListTimeout = 5 * time.Second

// worktreeInfo is one tree of this repository: where it is, what it is called,
// and what is checked out in it.
type worktreeInfo struct {
	Name   string
	Path   string
	Branch string
	// Main marks the checkout Codey was started in, which git lists alongside
	// the trees made from it. It is offered too, because once the work has
	// moved into a tree there has to be a way back.
	Main bool
	// Touched is the tree directory's own timestamp. It is only used to put
	// the trees in a sensible order and to say roughly how stale each one is.
	Touched time.Time
}

// resumeWorktree answers /worktree resume. Bare, it opens the chooser; with a
// name, it goes straight there, which is the faster path once you know what
// the tree is called.
func (m *model) resumeWorktree(name string) tea.Cmd {
	if why, ok := m.worktreeSwitchBlocked(); !ok {
		m.tr.addError(why)
		return m.sync()
	}
	trees, err := listWorktrees(m.parent, m.opts.HomeRoot)
	if err != nil {
		m.tr.addError("could not list the worktrees of " + shortenHome(m.opts.HomeRoot) + ": " + err.Error())
		return m.sync()
	}
	// git always reports the checkout itself, so "nothing to offer" means
	// nothing but where the work already is — a one-row chooser saying "here"
	// is not a choice.
	if len(trees) == 0 || (len(trees) == 1 && samePath(trees[0].Path, m.opts.Root)) {
		m.tr.addNotice("no other worktree of " + shortenHome(m.opts.HomeRoot) +
			" — /worktree <name> makes one")
		return m.sync()
	}

	if name != "" {
		w, ok := findWorktree(trees, name)
		if !ok {
			m.tr.addError("no worktree called " + name + " — there is " + worktreeNames(trees))
			return m.sync()
		}
		m.openWorktreeChat(w)
		return m.sync()
	}

	m.worktrees = trees
	m.worktreeFilter = ""
	m.refilterWorktrees()
	m.worktreeIdx = worktreeIndex(m.worktreeShown, m.opts.Root)
	m.phase = phaseWorktrees
	m.closeMenu()
	return m.sync()
}

// worktreeSwitchBlocked says why the work cannot move right now. Moving
// rebuilds every tool against another directory and empties the conversation,
// so anything in flight has to finish or be interrupted first — the same rule
// /worktree and /resume already keep.
func (m *model) worktreeSwitchBlocked() (string, bool) {
	switch {
	case m.opts.NewTools == nil:
		return "/worktree resume is not available in this session — it has no way to move the tools", false
	case m.opts.Agent == nil:
		return "no agent to open a chat in", false
	case m.phase == phaseRunning:
		return "/worktree resume would move the ground under the running turn — interrupt first", false
	case m.compacting:
		return "/worktree resume would replace the history being compacted — interrupt first", false
	case m.worktreeing:
		return "/worktree resume would land in the middle of making a worktree — interrupt first", false
	}
	return "", true
}

// findWorktree resolves what was typed after "resume": a name, or a path.
func findWorktree(trees []worktreeInfo, want string) (worktreeInfo, bool) {
	for _, w := range trees {
		if w.Name == want || w.Path == want || w.Branch == want {
			return w, true
		}
	}
	// A slug is what /worktree would have made of it, so "dark mode" finds the
	// tree "dark-mode" rather than being refused over a space.
	if slug := slugify(want); slug != "" {
		for _, w := range trees {
			if w.Name == slug {
				return w, true
			}
		}
	}
	return worktreeInfo{}, false
}

// worktreeNames lists what there is, for the error raised when a name misses.
func worktreeNames(trees []worktreeInfo) string {
	names := make([]string, 0, len(trees))
	for _, w := range trees {
		names = append(names, w.Name)
	}
	return strings.Join(names, ", ")
}

// worktreeIndex opens the chooser on the tree the work is already in, so the
// list says where you are rather than starting at the top.
func worktreeIndex(trees []worktreeInfo, root string) int {
	for i, w := range trees {
		if samePath(w.Path, root) {
			return i
		}
	}
	return 0
}

func samePath(a, b string) bool {
	if a == "" || b == "" {
		return false
	}
	return filepath.Clean(a) == filepath.Clean(b)
}

// refilterWorktrees recomputes what the picker is showing. The filtered list is
// held rather than derived at each use, so the keys, the view and a mouse click
// are all looking at the same rows.
func (m *model) refilterWorktrees() {
	m.worktreeShown = fuzzyRank(m.worktrees, m.worktreeFilter, worktreeHaystack)
	m.worktreeIdx = maxInt(0, minInt(m.worktreeIdx, len(m.worktreeShown)-1))
}

// worktreeHaystack is what the filter matches against: the name, and the
// branch when it says something the name does not. Typing part of a branch is
// as natural a way to find a tree as typing part of its directory.
func worktreeHaystack(w worktreeInfo) string {
	if w.Branch != "" && w.Branch != w.Name {
		return w.Name + " " + w.Branch
	}
	return w.Name
}

// onWorktreeKey drives the chooser. Unlike the other pickers this one takes
// typing, so there are no digit shortcuts: a number is a character of the
// filter, which is what a filter is for.
func (m *model) onWorktreeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
	key := msg.String()
	switch key {
	case "esc", "ctrl+c":
		m.closeWorktreePicker()
		return m, tea.Batch(m.sync(), textarea.Blink)

	case "up", "ctrl+p":
		m.worktreeIdx = maxInt(0, m.worktreeIdx-1)
	case "down", "ctrl+n":
		m.worktreeIdx = minInt(len(m.worktreeShown)-1, m.worktreeIdx+1)
	case "home":
		m.worktreeIdx = 0
	case "end":
		m.worktreeIdx = maxInt(0, len(m.worktreeShown)-1)

	case "enter", "tab":
		if len(m.worktreeShown) == 0 {
			return m, nil
		}
		w := m.worktreeShown[m.worktreeIdx]
		m.closeWorktreePicker()
		m.openWorktreeChat(w)
		return m, tea.Batch(m.sync(), textarea.Blink)

	case "backspace":
		if r := []rune(m.worktreeFilter); len(r) > 0 {
			m.worktreeFilter = string(r[:len(r)-1])
			m.refilterWorktrees()
		}
	case "ctrl+u":
		m.worktreeFilter = ""
		m.refilterWorktrees()

	case "space":
		// Bubble Tea reports the space bar by name, not as a rune.
		m.worktreeFilter += " "
		m.refilterWorktrees()

	default:
		if msg.Type == tea.KeyRunes && !msg.Alt {
			m.worktreeFilter += string(msg.Runes)
			m.refilterWorktrees()
		}
	}
	return m, m.sync()
}

// closeWorktreePicker puts the prompt back and drops the list.
func (m *model) closeWorktreePicker() {
	m.phase = phaseIdle
	m.worktrees, m.worktreeShown, m.worktreeFilter, m.worktreeIdx = nil, nil, "", 0
}

// openWorktreeChat moves the work into an existing tree and starts a fresh
// conversation there. The session being left is written out first: the new one
// is filed under the same home root, with the tree recorded on it, so /resume
// still lists both and reopening either comes back to the right directory.
func (m *model) openWorktreeChat(w worktreeInfo) {
	if why, ok := m.worktreeSwitchBlocked(); !ok {
		m.tr.addError(why)
		return
	}
	if fi, err := os.Stat(w.Path); err != nil || !fi.IsDir() {
		m.tr.addError("the worktree " + shortenHome(w.Path) + " is gone — git worktree prune tidies the record")
		return
	}

	// A turn already saves on the way out, but a compaction or a subagent's
	// report since then has not been — and this is the last moment it can be.
	if m.opts.SessionID != "" && m.turns > 0 {
		if err := m.saveSession(); err != nil {
			m.tr.addError("could not save session " + m.opts.SessionID + ": " + err.Error())
		}
	}

	moved := !samePath(w.Path, m.opts.Root)
	m.switchRoot(w.Path)
	m.newChat()

	where := "working in " + shortenHome(w.Path)
	if !moved {
		where = "already in " + shortenHome(w.Path)
	}
	if w.Branch != "" {
		where += " on " + w.Branch
	}
	m.tr.addNotice(where + " — new chat " + m.opts.SessionID +
		", filed in " + shortenHome(m.opts.HomeRoot) + " like every other")
}

// newChat empties the conversation and files whatever follows under a new
// session. The old one is left on disk untouched, so /resume can go back to it.
//
// Nothing is written for the new session yet: an empty file would be a row in
// every future chooser standing for a conversation that never happened. The
// first turn saves it, tree and all.
func (m *model) newChat() {
	m.opts.Agent.Restore(agent.Session{})
	m.opts.SessionID = newSessionID()

	// The bill for the process carries on; what this session has spent starts
	// again from nothing, because it has said nothing yet.
	m.usage, m.turns, m.priced = llm.Usage{}, 0, llm.Usage{}
	m.ctxTokens = 0

	m.clearSelection()
	m.tr.clear()
	m.tr.addRaw(splash(m.opts, maxInt(20, m.mainWidth())))
}

// newSessionID names a session the way main.go does, by the second it began.
// Sessions are listed by their id, so a name that sorts is worth more than a
// name that is unique to the microsecond.
func newSessionID() string { return time.Now().UTC().Format("20060102T150405Z") }

// listWorktrees asks git what trees this repository has. git is the authority
// rather than the worktrees directory on disk: the script that makes them is
// the user's own and may put them anywhere, and a directory sitting in that
// folder from some other checkout is not this repository's business.
func listWorktrees(ctx context.Context, repoRoot string) ([]worktreeInfo, error) {
	ctx, cancel := context.WithTimeout(ctx, worktreeListTimeout)
	defer cancel()

	cmd := exec.CommandContext(ctx, "git", "worktree", "list", "--porcelain")
	cmd.Dir = repoRoot
	var stderr strings.Builder
	cmd.Stderr = &stderr
	out, err := cmd.Output()
	if err != nil {
		if detail := lastLine(stderr.String()); detail != "" {
			return nil, fmt.Errorf("%s", detail)
		}
		return nil, err
	}
	return parseWorktreeList(string(out)), nil
}

// parseWorktreeList reads git's porcelain format: a stanza per tree, keyed
// lines within it, blank line between. Only the path and the branch are of any
// use here; a bare repository has no working tree to move into and is dropped,
// as is a tree whose directory has been deleted without git being told.
func parseWorktreeList(out string) []worktreeInfo {
	var trees []worktreeInfo
	var cur worktreeInfo
	var bare, first bool
	first = true

	flush := func() {
		if cur.Path == "" {
			cur, bare = worktreeInfo{}, false
			return
		}
		// git lists the main checkout first, and it is the only one that is
		// not a worktree made from something else. The position is claimed by
		// whatever git listed there even when we drop it — a bare repository
		// is listed first and is nobody's checkout, and a checkout whose
		// directory has been deleted is still the one that was first. Without
		// this the tree after it would inherit a title that belongs to
		// nothing, and be pinned to the top of the chooser calling itself the
		// way home.
		wasFirst := first
		first = false

		if bare {
			cur, bare = worktreeInfo{}, false
			return
		}
		fi, err := os.Stat(cur.Path)
		if err != nil || !fi.IsDir() {
			cur, bare = worktreeInfo{}, false
			return
		}
		cur.Name = filepath.Base(cur.Path)
		cur.Touched = fi.ModTime()
		cur.Main = wasFirst
		trees = append(trees, cur)
		cur, bare = worktreeInfo{}, false
	}

	sc := bufio.NewScanner(strings.NewReader(out))
	sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
	for sc.Scan() {
		line := sc.Text()
		if strings.TrimSpace(line) == "" {
			flush()
			continue
		}
		key, value, _ := strings.Cut(line, " ")
		switch key {
		case "worktree":
			cur.Path = value
		case "branch":
			cur.Branch = strings.TrimPrefix(value, "refs/heads/")
		case "detached":
			cur.Branch = "detached"
		case "bare":
			bare = true
		}
	}
	flush()

	// Most recently touched first, which is nearly always the order someone is
	// looking in — except for the main checkout, which stays at the top as the
	// way home rather than drifting about by its own mtime.
	sort.SliceStable(trees, func(i, j int) bool {
		if trees[i].Main != trees[j].Main {
			return trees[i].Main
		}
		return trees[i].Touched.After(trees[j].Touched)
	})
	return trees
}

// worktreeView is the chooser: a heading, what has been typed to narrow the
// list, and a row per tree with its name, how long since it was touched, and
// what is checked out in it.
func (m *model) worktreeView() string {
	width := maxInt(1, m.mainWidth())
	head := " " + styHeading.Render("Open a worktree")

	from := styDim.Render("of " + shortenHome(m.opts.HomeRoot))
	// The filter stands where the invitation to type it was, so the line does
	// not grow as it is used.
	if m.worktreeFilter == "" {
		from += styFaint.Render(" · type to filter")
	} else {
		from += styFaint.Render(" · ") + styKey.Render(m.worktreeFilter+"▏")
	}
	hint := " · ↑↓ move · enter new chat · esc cancel"

	var b strings.Builder
	b.WriteString(head + "\n")
	b.WriteString(truncateStyled(" "+from+styFaint.Render(hint), width) + "\n")

	if len(m.worktreeShown) == 0 {
		return b.String() + truncateStyled("   "+styFaint.Render("nothing matches "+m.worktreeFilter), width)
	}

	start, end := m.worktreeWindow()

	// A column for the names and one for the ages, so what each tree is and
	// when it was last touched line up under each other.
	names := make([]string, len(m.worktreeShown))
	ages := make([]string, len(m.worktreeShown))
	nameCol, ageCol := 0, 0
	for i, w := range m.worktreeShown {
		names[i], ages[i] = w.Name, ago(time.Since(w.Touched))
		if i >= start && i < end {
			nameCol = maxInt(nameCol, lipgloss.Width(names[i]))
			ageCol = maxInt(ageCol, lipgloss.Width(ages[i]))
		}
	}
	nameCol = minInt(nameCol, maxInt(12, width/3))

	for i := start; i < end; i++ {
		w := m.worktreeShown[i]
		marker, name := "  ", styDim.Render(pad(truncateStyled(names[i], nameCol), nameCol))
		if i == m.worktreeIdx {
			marker, name = styKey.Render("❯ "), styKey.Render(pad(truncateStyled(names[i], nameCol), nameCol))
		}
		detail := w.Branch
		if w.Main {
			// What it is comes before what is checked out in it: the way home
			// is the thing worth spotting in the list.
			detail = strings.TrimSuffix("main checkout · "+detail, " · ")
		}
		line := marker + name + " " + styDim.Render(pad(ages[i], ageCol)) + " " + styFaint.Render(detail)
		if samePath(w.Path, m.opts.Root) {
			line += styOK.Render(" ✓")
		}
		b.WriteString(truncateStyled(" "+line, width) + "\n")
	}
	if n := len(m.worktreeShown) - (end - start); n > 0 {
		b.WriteString(truncateStyled("   "+styFaint.Render(fmt.Sprintf("… +%d more", n)), width) + "\n")
	}
	return strings.TrimRight(b.String(), "\n")
}

// worktreeHeadRows is how many rows come before the list: the heading, and the
// line carrying the filter.
const worktreeHeadRows = 2

// worktreeWindow is the slice of the list that is on screen, shared by the view
// and by the hit test, so a click lands on the tree it looks like it is on.
func (m *model) worktreeWindow() (start, end int) {
	rows := m.worktreeRows()
	shown := rows
	if len(m.worktreeShown) > rows {
		shown = maxInt(1, rows-1)
	}
	if m.worktreeIdx >= shown {
		start = m.worktreeIdx - shown + 1
	}
	return start, minInt(len(m.worktreeShown), start+shown)
}

// worktreeRows is how many tree rows fit, by the same arithmetic the session
// picker does.
func (m *model) worktreeRows() int {
	room := m.height - 6
	return maxInt(1, minInt(worktreeMaxRows, room))
}

// worktreeMaxRows caps the list. Past a dozen the filter is the faster way in,
// which is what it is there for.
const worktreeMaxRows = 12