diff --git a/internal/tui/theme.go b/internal/theme/core.go similarity index 92% rename from internal/tui/theme.go rename to internal/theme/core.go index 88559cf..ca7a486 100644 --- a/internal/tui/theme.go +++ b/internal/theme/core.go @@ -1,8 +1,8 @@ -package tui +package theme import ( + "os" "encoding/json" - "os" "path/filepath" "github.com/charmbracelet/lipgloss" @@ -16,7 +16,7 @@ type Theme struct { DividerColor string `json:"dividerColor"` // e.g. "240" } -func defaultTheme() Theme { +func DefaultTheme() Theme { return Theme{ AddColor: "34", DelColor: "196", @@ -26,8 +26,8 @@ func defaultTheme() Theme { } // loadThemeFromRepo tries .diffium/theme.json at repoRoot. -func loadThemeFromRepo(repoRoot string) Theme { - t := defaultTheme() +func LoadThemeFromRepo(repoRoot string) Theme { + t := DefaultTheme() path := filepath.Join(repoRoot, ".diffium", "theme.json") b, err := os.ReadFile(path) if err != nil { @@ -64,4 +64,3 @@ func (t Theme) DelText(s string) string { func (t Theme) DividerText(s string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color(t.DividerColor)).Render(s) } - diff --git a/internal/tui/ansi/escape.go b/internal/tui/ansi/escape.go new file mode 100644 index 0000000..4759693 --- /dev/null +++ b/internal/tui/ansi/escape.go @@ -0,0 +1,78 @@ +package ansi + +import "unicode/utf8" + +// ConsumeEscape consumes an ANSI escape sequence starting at position i. +// Returns the position after the escape sequence. +func ConsumeEscape(s string, i int) int { + if i >= len(s) || s[i] != 0x1b { + if i+1 > len(s) { + return len(s) + } + return i + 1 + } + + j := i + 1 + if j >= len(s) { + return j + } + + switch s[j] { + case '[': // CSI + j++ + for j < len(s) { + c := s[j] + if c >= 0x40 && c <= 0x7e { + j++ + break + } + j++ + } + case ']': // OSC + j++ + for j < len(s) && s[j] != 0x07 { + j++ + } + if j < len(s) { + j++ + } + case 'P', 'X', '^', '_': // DCS, SOS, PM, APC + j++ + for j < len(s) { + if s[j] == 0x1b { + j++ + break + } + j++ + } + default: + j++ + } + + if j <= i { + return i + 1 + } + return j +} + +// Strip removes all ANSI escape sequences from the string. +func Strip(s string) string { + var result []byte + i := 0 + for i < len(s) { + if s[i] == 0x1b { + next := ConsumeEscape(s, i) + i = next + continue + } + result = append(result, s[i]) + i++ + } + return string(result) +} + +// VisualWidth returns the visual width of a string (rune count, excluding ANSI codes). +func VisualWidth(s string) int { + plain := Strip(s) + return utf8.RuneCountInString(plain) +} diff --git a/internal/tui/ansi/slice.go b/internal/tui/ansi/slice.go new file mode 100644 index 0000000..d60d77a --- /dev/null +++ b/internal/tui/ansi/slice.go @@ -0,0 +1,39 @@ +package ansi + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" +) + +// SliceHorizontal returns a substring starting at visual column start with at most width columns. +// Preserves ANSI escape sequences. +func SliceHorizontal(s string, start, width int) string { + if start <= 0 { + return ansi.Truncate(s, width, "") + } + head := ansi.Truncate(s, start+width, "") + return ansi.TruncateLeft(head, start, "") +} + +// ClipToWidth truncates string to at most w visual columns without ellipsis. +func ClipToWidth(s string, w int) string { + if w <= 0 { + return "" + } + return ansi.Truncate(s, w, "") +} + +// PadExact pads string with spaces to exactly width w (ANSI-aware). +func PadExact(s string, w int) string { + vw := VisualWidth(s) + if vw >= w { + return s + } + return s + strings.Repeat(" ", w-vw) +} + +// TruncateToWidth truncates to width with ellipsis if needed. +func TruncateToWidth(s string, width int) string { + return ansi.Truncate(s, width, "…") +} diff --git a/internal/tui/ansi/wrap.go b/internal/tui/ansi/wrap.go new file mode 100644 index 0000000..71455cf --- /dev/null +++ b/internal/tui/ansi/wrap.go @@ -0,0 +1,26 @@ +package ansi + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" +) + +// WrapLine wraps a single line to the given width, preserving ANSI codes. +func WrapLine(s string, width int) []string { + if width <= 0 { + return []string{""} + } + wrapped := ansi.Hardwrap(s, width, false) + return strings.Split(wrapped, "\n") +} + +// WrapLines wraps multiple lines. +func WrapLines(lines []string, width int) []string { + result := make([]string, 0, len(lines)*2) + for _, line := range lines { + wrapped := WrapLine(line, width) + result = append(result, wrapped...) + } + return result +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go new file mode 100644 index 0000000..cbcc5b2 --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,90 @@ +package tui + +import ( + "sort" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/interpretive-systems/diffium/internal/diffview" + "github.com/interpretive-systems/diffium/internal/gitx" + "github.com/interpretive-systems/diffium/internal/prefs" +) + +// loadFiles loads the changed files list. +func loadFiles(repoRoot, diffMode string) tea.Cmd { + return func() tea.Msg { + allFiles, err := gitx.ChangedFiles(repoRoot) + if err != nil { + return filesMsg{files: nil, err: err} + } + + var filtered []gitx.FileChange + for _, file := range allFiles { + if diffMode == "staged" { + if file.Staged { + filtered = append(filtered, file) + } + } else { + if file.Unstaged || file.Untracked { + filtered = append(filtered, file) + } + } + } + + // Stable sort for deterministic UI + sort.Slice(filtered, func(i, j int) bool { + return filtered[i].Path < filtered[j].Path + }) + + return filesMsg{files: filtered, err: nil} + } +} + +// loadDiff loads the diff for a specific file. +func loadDiff(repoRoot, path, diffMode string) tea.Cmd { + return func() tea.Msg { + var d string + var err error + if diffMode == "staged" { + d, err = gitx.DiffStaged(repoRoot, path) + } else { + d, err = gitx.DiffHEAD(repoRoot, path) + } + if err != nil { + return diffMsg{path: path, err: err} + } + rows := diffview.BuildRowsFromUnified(d) + return diffMsg{path: path, rows: rows} + } +} + +// loadLastCommit loads the last commit summary. +func loadLastCommit(repoRoot string) tea.Cmd { + return func() tea.Msg { + s, err := gitx.LastCommitSummary(repoRoot) + return lastCommitMsg{summary: s, err: err} + } +} + +// loadCurrentBranch loads the current branch name. +func loadCurrentBranch(repoRoot string) tea.Cmd { + return func() tea.Msg { + name, err := gitx.CurrentBranch(repoRoot) + return currentBranchMsg{name: name, err: err} + } +} + +// loadPrefs loads user preferences. +func loadPrefs(repoRoot string) tea.Cmd { + return func() tea.Msg { + p := prefs.Load(repoRoot) + return prefsMsg{p: p, err: nil} + } +} + +// tickOnce schedules a single tick after 1 second. +func tickOnce() tea.Cmd { + return tea.Tick(time.Second, func(time.Time) tea.Msg { + return tickMsg{} + }) +} diff --git a/internal/tui/components/diffview.go b/internal/tui/components/diffview.go new file mode 100644 index 0000000..1b1fa0e --- /dev/null +++ b/internal/tui/components/diffview.go @@ -0,0 +1,355 @@ +package components + +import ( + "strings" + + "github.com/charmbracelet/bubbles/viewport" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/interpretive-systems/diffium/internal/diffview" + "github.com/interpretive-systems/diffium/internal/theme" + tuiansi "github.com/interpretive-systems/diffium/internal/tui/ansi" +) + +// DiffView manages the right pane diff viewer. +type DiffView struct { + rows []diffview.Row + viewport viewport.Model + xOffset int + sideBySide bool + wrapLines bool + curTheme theme.Theme + content []string // Cached rendered content +} + +// NewDiffView creates a new diff viewer. +func NewDiffView(defaultTheme theme.Theme) *DiffView { + return &DiffView{ + curTheme: defaultTheme, + sideBySide: true, + } +} + +// SetRows updates the diff rows. +func (d *DiffView) SetRows(rows []diffview.Row) { + d.rows = rows +} + +// SetSize updates the viewport dimensions. +func (d *DiffView) SetSize(width, height int) { + d.viewport.Width = width + d.viewport.Height = height +} + +func (d *DiffView) GetSideBySide() bool{ + return d.sideBySide +} + +// SetSideBySide sets the display mode. +func (d *DiffView) SetSideBySide(sideBySide bool) { + d.sideBySide = sideBySide +} + +func (d *DiffView) GetWrap() bool{ + return d.wrapLines +} + +// SetWrap sets line wrapping. +func (d *DiffView) SetWrap(wrap bool) { + d.wrapLines = wrap + if wrap { + d.xOffset = 0 + } +} + +// SetXOffset sets horizontal scroll offset. +func (d *DiffView) SetXOffset(offset int) { + if offset < 0 { + offset = 0 + } + d.xOffset = offset +} + +// XOffset returns the current horizontal offset. +func (d *DiffView) XOffset() int { + return d.xOffset +} + +// ScrollLeft scrolls left by delta. +func (d *DiffView) ScrollLeft(delta int) { + if d.wrapLines { + return + } + d.xOffset -= delta + if d.xOffset < 0 { + d.xOffset = 0 + } +} + +// ScrollRight scrolls right by delta. +func (d *DiffView) ScrollRight(delta int) { + if d.wrapLines { + return + } + d.xOffset += delta +} + +// ScrollHome resets horizontal scroll. +func (d *DiffView) ScrollHome() { + d.xOffset = 0 +} + +// RenderContent generates the full content and caches it. +func (d *DiffView) RenderContent(width int, binary bool) []string { + if binary { + d.content = []string{lipgloss.NewStyle().Faint(true).Render("(Binary file; no text diff)")} + return d.content + } + + if d.rows == nil { + d.content = []string{"Loading diff…"} + return d.content + } + + if d.sideBySide { + d.content = d.renderSideBySide(width) + } else { + d.content = d.renderInline(width) + } + + return d.content +} + +// Content returns the cached content. +func (d *DiffView) Content() []string { + return d.content +} + +// SetContent updates the viewport content from rendered lines. +func (d *DiffView) SetContent(lines []string) { + d.content = lines + d.viewport.SetContent(strings.Join(lines, "\n")) +} + +// View returns the viewport view. +func (d *DiffView) View() string { + return d.viewport.View() +} + +// Viewport returns the underlying viewport for direct manipulation. +func (d *DiffView) Viewport() *viewport.Model { + return &d.viewport +} + +func (d *DiffView) renderSideBySide(width int) []string { + lines := make([]string, 0, len(d.rows)) + colsW := (width - 1) / 2 + if colsW < 10 { + colsW = 10 + } + mid := d.curTheme.DividerText("│") + + for _, r := range d.rows { + switch r.Kind { + case diffview.RowHunk: + lines = append(lines, lipgloss.NewStyle().Faint(true). + Render(strings.Repeat("·", width))) + case diffview.RowMeta: + // skip + default: + if d.wrapLines { + lLines := d.renderSideCellWrap(r, "left", colsW) + rLines := d.renderSideCellWrap(r, "right", colsW) + n := len(lLines) + if len(rLines) > n { + n = len(rLines) + } + for i := 0; i < n; i++ { + var l, rr string + if i < len(lLines) { + l = lLines[i] + } else { + l = strings.Repeat(" ", colsW) + } + if i < len(rLines) { + rr = rLines[i] + } else { + rr = strings.Repeat(" ", colsW) + } + lines = append(lines, l+mid+rr) + } + } else { + l := d.renderSideCell(r, "left", colsW) + rr := d.renderSideCell(r, "right", colsW) + l = tuiansi.PadExact(l, colsW) + rr = tuiansi.PadExact(rr, colsW) + lines = append(lines, l+mid+rr) + } + } + } + + return lines +} + +func (d *DiffView) renderInline(width int) []string { + lines := make([]string, 0, len(d.rows)) + + for _, r := range d.rows { + switch r.Kind { + case diffview.RowHunk: + lines = append(lines, lipgloss.NewStyle().Faint(true). + Render(strings.Repeat("·", width))) + case diffview.RowContext: + base := " " + r.Left + if d.wrapLines { + wrapped := tuiansi.WrapLine(base, width) + lines = append(lines, wrapped...) + } else { + line := base + if d.xOffset > 0 { + line = tuiansi.SliceHorizontal(line, d.xOffset, width) + line = tuiansi.PadExact(line, width) + } + lines = append(lines, line) + } + case diffview.RowAdd: + base := d.curTheme.AddText("+ " + r.Right) + if d.wrapLines { + wrapped := tuiansi.WrapLine(base, width) + lines = append(lines, wrapped...) + } else { + line := base + if d.xOffset > 0 { + line = tuiansi.SliceHorizontal(line, d.xOffset, width) + line = tuiansi.PadExact(line, width) + } + lines = append(lines, line) + } + case diffview.RowDel: + base := d.curTheme.DelText("- " + r.Left) + if d.wrapLines { + wrapped := tuiansi.WrapLine(base, width) + lines = append(lines, wrapped...) + } else { + line := base + if d.xOffset > 0 { + line = tuiansi.SliceHorizontal(line, d.xOffset, width) + line = tuiansi.PadExact(line, width) + } + lines = append(lines, line) + } + case diffview.RowReplace: + base1 := d.curTheme.DelText("- " + r.Left) + base2 := d.curTheme.AddText("+ " + r.Right) + if d.wrapLines { + wrapped1 := tuiansi.WrapLine(base1, width) + wrapped2 := tuiansi.WrapLine(base2, width) + lines = append(lines, wrapped1...) + lines = append(lines, wrapped2...) + } else { + line1 := base1 + if d.xOffset > 0 { + line1 = tuiansi.SliceHorizontal(line1, d.xOffset, width) + line1 = tuiansi.PadExact(line1, width) + } + lines = append(lines, line1) + line2 := base2 + if d.xOffset > 0 { + line2 = tuiansi.SliceHorizontal(line2, d.xOffset, width) + line2 = tuiansi.PadExact(line2, width) + } + lines = append(lines, line2) + } + } + } + + return lines +} + +func (d *DiffView) renderSideCell(r diffview.Row, side string, width int) string { + marker := " " + content := "" + + switch side { + case "left": + content = r.Left + switch r.Kind { + case diffview.RowContext: + marker = " " + case diffview.RowDel, diffview.RowReplace: + marker = d.curTheme.DelText("-") + content = d.curTheme.DelText(content) + case diffview.RowAdd: + marker = " " + content = "" + } + case "right": + content = r.Right + switch r.Kind { + case diffview.RowContext: + marker = " " + case diffview.RowAdd, diffview.RowReplace: + marker = d.curTheme.AddText("+") + content = d.curTheme.AddText(content) + case diffview.RowDel: + marker = " " + content = "" + } + } + + if width <= 2 { + return ansi.Truncate(marker+" ", width, "") + } + + bodyW := width - 2 + + clipped := tuiansi.SliceHorizontal(content, d.xOffset, bodyW) + return marker + " " + clipped +} + +func (d *DiffView) renderSideCellWrap(r diffview.Row, side string, width int) []string { + marker := " " + content := "" + + switch side { + case "left": + content = r.Left + switch r.Kind { + case diffview.RowContext: + marker = " " + case diffview.RowDel, diffview.RowReplace: + marker = d.curTheme.DelText("-") + content = d.curTheme.DelText(content) + case diffview.RowAdd: + marker = " " + content = "" + } + case "right": + content = r.Right + switch r.Kind { + case diffview.RowContext: + marker = " " + case diffview.RowAdd, diffview.RowReplace: + marker = d.curTheme.AddText("+") + content = d.curTheme.AddText(content) + case diffview.RowDel: + marker = " " + content = "" + } + } + + if width <= 2 { + return []string{ansi.Truncate(marker+" ", width, "")} + } + + bodyW := width - 2 + wrapped := tuiansi.WrapLine(content, bodyW) + out := make([]string, 0, len(wrapped)) + for _, p := range wrapped { + out = append(out, marker+" "+tuiansi.PadExact(p, bodyW)) + } + if len(out) == 0 { + out = append(out, marker+" "+strings.Repeat(" ", bodyW)) + } + return out +} diff --git a/internal/tui/components/filelist.go b/internal/tui/components/filelist.go new file mode 100644 index 0000000..9e6c34c --- /dev/null +++ b/internal/tui/components/filelist.go @@ -0,0 +1,234 @@ +package components + +import ( + "fmt" + "strings" + + "github.com/interpretive-systems/diffium/internal/gitx" +) + +// FileList manages the left pane file list. +type FileList struct { + files []gitx.FileChange + selected int + offset int +} + +// NewFileList creates a new file list. +func NewFileList() *FileList { + return &FileList{} +} + +// SetFiles updates the file list. +func (f *FileList) SetFiles(files []gitx.FileChange) { + f.files = files + if f.selected >= len(files) { + f.selected = len(files) - 1 + } + if f.selected < 0 { + f.selected = 0 + } +} + +// Files returns the current file list. +func (f *FileList) Files() []gitx.FileChange { + return f.files +} + +// Selected returns the currently selected file index. +func (f *FileList) Selected() int { + return f.selected +} + +// SelectedFile returns the currently selected file. +func (f *FileList) SelectedFile() *gitx.FileChange { + if len(f.files) == 0 || f.selected < 0 || f.selected >= len(f.files) { + return nil + } + return &f.files[f.selected] +} + +// MoveSelection moves the selection by delta. +func (f *FileList) MoveSelection(delta int) bool { + if len(f.files) == 0 { + return false + } + + newSel := f.selected + delta + if newSel < 0 { + newSel = 0 + } + if newSel >= len(f.files) { + newSel = len(f.files) - 1 + } + + changed := newSel != f.selected + f.selected = newSel + return changed +} + +// GoToTop moves selection to the first file. +func (f *FileList) GoToTop() bool { + if len(f.files) == 0 || f.selected == 0 { + return false + } + f.selected = 0 + return true +} + +// GoToBottom moves selection to the last file. +func (f *FileList) GoToBottom() bool { + if len(f.files) == 0 { + return false + } + last := len(f.files) - 1 + if f.selected == last { + return false + } + f.selected = last + return true +} + +// PageUp scrolls up one page. +func (f *FileList) PageUp(visibleCount int) { + if visibleCount <= 0 { + visibleCount = 10 + } + step := visibleCount - 1 + if step < 1 { + step = 1 + } + + newOffset := f.offset - step + if newOffset < 0 { + newOffset = 0 + } + + if f.selected < newOffset { + newOffset = f.selected + } + + maxStart := len(f.files) - visibleCount + if maxStart < 0 { + maxStart = 0 + } + if newOffset > maxStart { + newOffset = maxStart + } + + f.offset = newOffset +} + +// PageDown scrolls down one page. +func (f *FileList) PageDown(visibleCount int) { + if visibleCount <= 0 { + visibleCount = 10 + } + step := visibleCount - 1 + if step < 1 { + step = 1 + } + + maxStart := len(f.files) - visibleCount + if maxStart < 0 { + maxStart = 0 + } + + newOffset := f.offset + step + if newOffset > maxStart { + newOffset = maxStart + } + + if f.selected >= newOffset+visibleCount { + newOffset = f.selected - visibleCount + 1 + if newOffset < 0 { + newOffset = 0 + } + } + + f.offset = newOffset +} + +// EnsureVisible ensures the selected item is visible. +func (f *FileList) EnsureVisible(visibleCount int) { + if len(f.files) == 0 || visibleCount <= 0 { + return + } + + if f.offset < 0 { + f.offset = 0 + } + + maxStart := len(f.files) - visibleCount + if maxStart < 0 { + maxStart = 0 + } + if f.offset > maxStart { + f.offset = maxStart + } + + if f.selected < f.offset { + f.offset = f.selected + } else if f.selected >= f.offset+visibleCount { + f.offset = f.selected - visibleCount + 1 + if f.offset < 0 { + f.offset = 0 + } + } + + if f.offset > maxStart { + f.offset = maxStart + } +} + +// Render renders the file list to lines. +func (f *FileList) Render(height int) []string { + lines := make([]string, 0, height) + + if len(f.files) == 0 { + lines = append(lines, "No changes detected") + return lines + } + + f.EnsureVisible(height) + + start := f.offset + end := start + height + if end > len(f.files) { + end = len(f.files) + } + + for i := start; i < end; i++ { + file := f.files[i] + marker := " " + if i == f.selected { + marker = "> " + } + status := FileStatusLabel(file) + line := fmt.Sprintf("%s%s %s", marker, status, file.Path) + lines = append(lines, line) + } + + return lines +} + +// FileStatusLabel returns a short status label for a file. +func FileStatusLabel(f gitx.FileChange) string { + var tags []string + if f.Deleted { + tags = append(tags, "D") + } + if f.Untracked { + tags = append(tags, "U") + } + if f.Staged { + tags = append(tags, "S") + } + if f.Unstaged { + tags = append(tags, "M") + } + if len(tags) == 0 { + return "-" + } + return strings.Join(tags, "") +} diff --git a/internal/tui/components/statusbar.go b/internal/tui/components/statusbar.go new file mode 100644 index 0000000..7545d4f --- /dev/null +++ b/internal/tui/components/statusbar.go @@ -0,0 +1,68 @@ +package components + +import ( + _ "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +// StatusBar manages the bottom status bar. +type StatusBar struct { + lastRefresh time.Time + lastCommit string + keyBuffer string +} + +// NewStatusBar creates a new status bar. +func NewStatusBar() *StatusBar { + return &StatusBar{} +} + +// SetLastRefresh updates the refresh timestamp. +func (s *StatusBar) SetLastRefresh(t time.Time) { + s.lastRefresh = t +} + +// SetLastCommit updates the last commit message. +func (s *StatusBar) SetLastCommit(msg string) { + s.lastCommit = msg +} + +// SetKeyBuffer updates the key buffer display. +func (s *StatusBar) SetKeyBuffer(buf string) { + s.keyBuffer = buf +} + +// Render renders the status bar. +func (s *StatusBar) Render(width int) string { + leftText := "h: help" + if s.keyBuffer != "" { + leftText = s.keyBuffer + } + if s.lastCommit != "" { + leftText += " | last: " + s.lastCommit + } + + leftStyled := lipgloss.NewStyle().Faint(true).Render(leftText) + right := lipgloss.NewStyle().Faint(true). + Render("refreshed: " + s.lastRefresh.Format("15:04:05")) + + // Ensure right part is always visible + rightW := lipgloss.Width(right) + if rightW >= width { + return ansi.Truncate(right, width, "…") + } + + avail := width - rightW - 1 + leftRendered := leftStyled + if lipgloss.Width(leftRendered) > avail { + leftRendered = ansi.Truncate(leftRendered, avail, "…") + } else if lipgloss.Width(leftRendered) < avail { + leftRendered = leftRendered + strings.Repeat(" ", avail-lipgloss.Width(leftRendered)) + } + + return leftRendered + " " + right +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..8937c17 --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,175 @@ +package tui + +import ( + "strconv" + + tea "github.com/charmbracelet/bubbletea" +) + +// KeyAction represents an action triggered by a key press. +type KeyAction int + +const ( + ActionNone KeyAction = iota + ActionQuit + ActionToggleHelp + ActionOpenCommit + ActionOpenUncommit + ActionOpenBranch + ActionOpenPull + ActionOpenResetClean + ActionOpenSearch + ActionRefresh + ActionToggleSideBySide + ActionToggleDiffMode + ActionToggleWrap + ActionMoveUp + ActionMoveDown + ActionGoToTop + ActionGoToBottom + ActionPageUpLeft + ActionPageDownLeft + ActionScrollLeft + ActionScrollRight + ActionScrollHome + ActionPageDown + ActionPageUp + ActionHalfPageDown + ActionHalfPageUp + ActionLineDown + ActionLineUp + ActionAdjustLeftNarrower + ActionAdjustLeftWider + ActionSearchNext + ActionSearchPrevious +) + +// KeyHandler handles key input and maintains key buffer. +type KeyHandler struct { + keyBuffer string +} + +// NewKeyHandler creates a new key handler. +func NewKeyHandler() *KeyHandler { + return &KeyHandler{} +} + +// Handle processes a key message and returns the action. +func (k *KeyHandler) Handle(msg tea.KeyMsg) (KeyAction, int) { + key := msg.String() + + // Numeric keys build up the buffer + if isNumericKey(key) { + k.keyBuffer += key + return ActionNone, 0 + } + + // Get count from buffer + count := 1 + if k.keyBuffer != "" { + if n, err := strconv.Atoi(k.keyBuffer); err == nil { + count = n + } + } + + // Non-movement keys clear the buffer + if !isMovementKey(key) { + k.keyBuffer = "" + } + + action := k.keyToAction(key) + + // Clear buffer after movement + if isMovementKey(key) { + k.keyBuffer = "" + } + + return action, count +} + +// KeyBuffer returns the current key buffer. +func (k *KeyHandler) KeyBuffer() string { + return k.keyBuffer +} + +// ClearBuffer clears the key buffer. +func (k *KeyHandler) ClearBuffer() { + k.keyBuffer = "" +} + +func (k *KeyHandler) keyToAction(key string) KeyAction { + switch key { + case "ctrl+c", "q": + return ActionQuit + case "h": + return ActionToggleHelp + case "c": + return ActionOpenCommit + case "u": + return ActionOpenUncommit + case "b": + return ActionOpenBranch + case "p": + return ActionOpenPull + case "R": + return ActionOpenResetClean + case "/": + return ActionOpenSearch + case "r": + return ActionRefresh + case "s": + return ActionToggleSideBySide + case "t": + return ActionToggleDiffMode + case "w": + return ActionToggleWrap + case "j", "down": + return ActionMoveDown + case "k", "up": + return ActionMoveUp + case "g": + return ActionGoToTop + case "G": + return ActionGoToBottom + case "[": + return ActionPageUpLeft + case "]": + return ActionPageDownLeft + case "left", "{": + return ActionScrollLeft + case "right", "}": + return ActionScrollRight + case "home": + return ActionScrollHome + case "pgdown": + return ActionPageDown + case "pgup": + return ActionPageUp + case "J", "ctrl+d": + return ActionHalfPageDown + case "K", "ctrl+u": + return ActionHalfPageUp + case "ctrl+e": + return ActionLineDown + case "ctrl+y": + return ActionLineUp + case ">": + return ActionAdjustLeftWider + case "<": + return ActionAdjustLeftNarrower + case "n": + return ActionSearchNext + case "N": + return ActionSearchPrevious + default: + return ActionNone + } +} + +func isNumericKey(key string) bool { + return len(key) == 1 && key >= "0" && key <= "9" +} + +func isMovementKey(key string) bool { + return key == "j" || key == "k" || key == "down" || key == "up" +} diff --git a/internal/tui/layout.go b/internal/tui/layout.go new file mode 100644 index 0000000..7f2bb7c --- /dev/null +++ b/internal/tui/layout.go @@ -0,0 +1,184 @@ +package tui + +import ( + _ "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/interpretive-systems/diffium/internal/theme" +) + +// Layout manages screen layout calculations. +type Layout struct { + width int + height int + leftWidth int +} + +// NewLayout creates a new layout manager. +func NewLayout() *Layout { + return &Layout{} +} + +// SetSize updates the layout dimensions. +func (l *Layout) SetSize(width, height int) { + l.width = width + l.height = height +} + +// SetLeftWidth sets the left pane width. +func (l *Layout) SetLeftWidth(width int) { + l.leftWidth = width +} + +// Width returns the total width. +func (l *Layout) Width() int { + return l.width +} + +// Height returns the total height. +func (l *Layout) Height() int { + return l.height +} + +// LeftWidth returns the left pane width. +func (l *Layout) LeftWidth() int { + if l.leftWidth < 20 { + return 20 + } + return l.leftWidth +} + +// RightWidth returns the right pane width. +func (l *Layout) RightWidth() int { + leftW := l.LeftWidth() + rightW := l.width - leftW - 1 // 1 for divider + if rightW < 1 { + rightW = 1 + } + return rightW +} + +// ContentHeight returns the height available for content. +func (l *Layout) ContentHeight(overlayHeight int) int { + // top bar + top rule + bottom rule + bottom bar + overlays + h := l.height - 4 - overlayHeight + if h < 1 { + h = 1 + } + return h +} + +// AdjustLeftWidth adjusts the left width by delta. +func (l *Layout) AdjustLeftWidth(delta int) { + newWidth := l.leftWidth + delta + if newWidth < 20 { + newWidth = 20 + } + maxLeft := l.width - 20 + if maxLeft < 20 { + maxLeft = 20 + } + if newWidth > maxLeft { + newWidth = maxLeft + } + l.leftWidth = newWidth +} + +// RenderFrame renders the main frame with top bar, rules, and columns. +func (l *Layout) RenderFrame( + topLeft, topRight string, + leftLines, rightLines []string, + overlayLines []string, + bottomBar string, + theme theme.Theme, +) string { + var b strings.Builder + + // Row 1: Top bar + topBar := l.renderTopBar(topLeft, topRight) + b.WriteString(topBar) + b.WriteByte('\n') + + // Row 2: Horizontal rule + hr := theme.DividerText(strings.Repeat("─", l.width)) + b.WriteString(hr) + b.WriteByte('\n') + + // Row 3: Content columns + leftW := l.LeftWidth() + rightW := l.RightWidth() + sep := theme.DividerText("│") + + contentHeight := len(leftLines) + if len(rightLines) > contentHeight { + contentHeight = len(rightLines) + } + + for i := 0; i < contentHeight; i++ { + var left, right string + if i < len(leftLines) { + left = padToWidth(leftLines[i], leftW) + } else { + left = strings.Repeat(" ", leftW) + } + if i < len(rightLines) { + right = rightLines[i] + } else { + right = "" + } + b.WriteString(left) + b.WriteString(sep) + b.WriteString(padToWidth(right, rightW)) + if i < contentHeight-1 { + b.WriteByte('\n') + } + } + + // Optional overlay + if len(overlayLines) > 0 { + b.WriteByte('\n') + for i, line := range overlayLines { + b.WriteString(padToWidth(line, l.width)) + if i < len(overlayLines)-1 { + b.WriteByte('\n') + } + } + } + + // Bottom rule and bar + b.WriteByte('\n') + b.WriteString(strings.Repeat("─", l.width)) + b.WriteByte('\n') + b.WriteString(bottomBar) + + return b.String() +} + +func (l *Layout) renderTopBar(left, right string) string { + rightW := lipgloss.Width(right) + if rightW >= l.width { + return ansi.Truncate(right, l.width, "…") + } + + avail := l.width - rightW - 1 + if lipgloss.Width(left) > avail { + left = ansi.Truncate(left, avail, "…") + } else if lipgloss.Width(left) < avail { + left = left + strings.Repeat(" ", avail-lipgloss.Width(left)) + } + + return left + " " + right +} + +func padToWidth(s string, w int) string { + width := lipgloss.Width(s) + if width == w { + return s + } + if width < w { + return s + strings.Repeat(" ", w-width) + } + return ansi.Truncate(s, w, "…") +} diff --git a/internal/tui/messages.go b/internal/tui/messages.go new file mode 100644 index 0000000..b7902b8 --- /dev/null +++ b/internal/tui/messages.go @@ -0,0 +1,43 @@ +package tui + +import ( + _ "time" + + "github.com/interpretive-systems/diffium/internal/diffview" + "github.com/interpretive-systems/diffium/internal/gitx" + "github.com/interpretive-systems/diffium/internal/prefs" +) + +// tickMsg triggers periodic refresh. +type tickMsg struct{} + +// filesMsg contains loaded file changes. +type filesMsg struct { + files []gitx.FileChange + err error +} + +// diffMsg contains loaded diff rows. +type diffMsg struct { + path string + rows []diffview.Row + err error +} + +// lastCommitMsg contains the last commit summary. +type lastCommitMsg struct { + summary string + err error +} + +// currentBranchMsg contains the current branch name. +type currentBranchMsg struct { + name string + err error +} + +// prefsMsg contains loaded preferences. +type prefsMsg struct { + p prefs.Prefs + err error +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..8852c9f --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,69 @@ +package tui + +import ( + "time" + + "github.com/interpretive-systems/diffium/internal/gitx" + "github.com/interpretive-systems/diffium/internal/tui/components" + "github.com/interpretive-systems/diffium/internal/tui/search" + "github.com/interpretive-systems/diffium/internal/tui/wizards" + "github.com/interpretive-systems/diffium/internal/theme" +) + +// State holds all application state. +type State struct { + // Repository + RepoRoot string + Files []gitx.FileChange + CurrentBranch string + DiffMode string // "head" or "staged" + LastCommit string + + // UI State + Width int + Height int + LeftWidth int + SavedLeftWidth int + ShowHelp bool + LastRefresh time.Time + + // Active Wizard + ActiveWizard string // "", "commit", "uncommit", "branch", "resetclean", "pull" + + // Components + FileList *components.FileList + DiffView *components.DiffView + StatusBar *components.StatusBar + SearchEngine *search.Engine + + // Wizards + Wizards map[string]wizards.Wizard + + // Theme + Theme theme.Theme + + // Key handling + KeyBuffer string +} + +// NewState creates initial application state. +func NewState(repoRoot string) *State { + curTheme := theme.LoadThemeFromRepo(repoRoot) + + return &State{ + RepoRoot: repoRoot, + DiffMode: "head", + Theme: curTheme, + FileList: components.NewFileList(), + DiffView: components.NewDiffView(curTheme), + StatusBar: components.NewStatusBar(), + SearchEngine: search.New(), + Wizards: map[string]wizards.Wizard{ + "commit": wizards.NewCommitWizard(), + "uncommit": wizards.NewUncommitWizard(), + "branch": wizards.NewBranchWizard(), + "resetclean": wizards.NewResetCleanWizard(), + "pull": wizards.NewPullWizard(), + }, + } +} diff --git a/internal/tui/program.go b/internal/tui/program.go index a6b7110..b4d5755 100644 --- a/internal/tui/program.go +++ b/internal/tui/program.go @@ -1,1063 +1,563 @@ package tui import ( - "fmt" - "sort" - "strconv" + "fmt" "strings" "time" - "unicode/utf8" - - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" + + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - "github.com/interpretive-systems/diffium/internal/diffview" - "github.com/interpretive-systems/diffium/internal/gitx" - "github.com/interpretive-systems/diffium/internal/prefs" -) - -const ( - // Normal match: black on bright white - searchMatchStartSeq = "\x1b[30;107m" - // Current match: black on yellow - searchCurrentMatchStartSeq = "\x1b[30;43m" - // Reset all styles - searchMatchEndSeq = "\x1b[0m" + "github.com/interpretive-systems/diffium/internal/prefs" + "github.com/interpretive-systems/diffium/internal/tui/wizards" + "github.com/interpretive-systems/diffium/internal/tui/components" ) -type model struct { - repoRoot string - theme Theme - files []gitx.FileChange - selected int - rows []diffview.Row - sideBySide bool - diffMode string - width int - height int - status string - lastRefresh time.Time - showHelp bool - leftWidth int - savedLeftWidth int - leftOffset int - rightVP viewport.Model - rightXOffset int - wrapLines bool - - rightContent []string - - keyBuffer string - // commit wizard state - showCommit bool - commitStep int // 0: select files, 1: message, 2: confirm/progress - cwFiles []gitx.FileChange - cwSelected map[string]bool - cwIndex int - cwInput textinput.Model - cwInputActive bool - committing bool - commitErr string - commitDone bool - lastCommit string - - currentBranch string - // uncommit wizard state - showUncommit bool - ucStep int // 0: select files, 1: confirm/progress - ucFiles []gitx.FileChange // list ALL current changes (like commit wizard) - ucSelected map[string]bool // keyed by path - ucIndex int - ucEligible map[string]bool // paths that are part of HEAD (last commit) - uncommitting bool - uncommitErr string - uncommitDone bool - - // reset/clean wizard state - showResetClean bool - rcStep int // 0: select, 1: preview, 2: confirm (yellow), 3: confirm (red) - rcDoReset bool - rcDoClean bool - rcIncludeIgnored bool - rcIndex int - rcPreviewLines []string // from git clean -dn - rcPreviewErr string - rcRunning bool - rcErr string - rcDone bool - - // branch switch wizard - showBranch bool - brStep int // 0: list, 1: confirm/progress - brBranches []string - brCurrent string - brIndex int - brRunning bool - brErr string - brDone bool - brInput textinput.Model - brInputActive bool - - // pull wizard - showPull bool - plRunning bool - plErr string - plDone bool - plOutput string - - // search state - searchActive bool - searchInput textinput.Model - searchQuery string - searchMatches []int - searchIndex int -} - -// messages -type tickMsg struct{} - -type filesMsg struct { - files []gitx.FileChange - err error -} - -type diffMsg struct { - path string - rows []diffview.Row - err error +// Program is the main TUI program. +type Program struct { + state *State + layout *Layout + keyHandler *KeyHandler } -// Run instantiates and runs the Bubble Tea program. +// Run starts the TUI program. func Run(repoRoot string) error { - m := model{repoRoot: repoRoot, sideBySide: true, diffMode: "head", theme: loadThemeFromRepo(repoRoot)} - p := tea.NewProgram(m, tea.WithAltScreen()) - if _, err := p.Run(); err != nil { + state := NewState(repoRoot) + p := &Program{ + state: state, + layout: NewLayout(), + keyHandler: NewKeyHandler(), + } + + prog := tea.NewProgram(p, tea.WithAltScreen()) + if _, err := prog.Run(); err != nil { return err } return nil } -func (m model) Init() tea.Cmd { - return tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), loadCurrentBranch(m.repoRoot), loadPrefs(m.repoRoot), tickOnce()) +// Init implements tea.Model. +func (p *Program) Init() tea.Cmd { + return tea.Batch( + loadFiles(p.state.RepoRoot, p.state.DiffMode), + loadLastCommit(p.state.RepoRoot), + loadCurrentBranch(p.state.RepoRoot), + loadPrefs(p.state.RepoRoot), + tickOnce(), + ) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +// Update implements tea.Model. +func (p *Program) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - if m.searchActive { - return m.handleSearchKeys(msg) - } - if m.showHelp { - switch msg.String() { - case "q": - return m, tea.Quit - case "h", "esc": - (&m).closeSearch() - m.showHelp = false - return m, m.recalcViewport() - default: - return m, nil - } - } - if m.showCommit { - return m.handleCommitKeys(msg) - } - if m.showUncommit { - return m.handleUncommitKeys(msg) - } - if m.showResetClean { - return m.handleResetCleanKeys(msg) - } - if m.showBranch { - return m.handleBranchKeys(msg) - } - if m.showPull { - return m.handlePullKeys(msg) - } - - key := msg.String() - - if isNumericKey(key){ - m.keyBuffer += key - return m, nil - } - - if !isNumericKey(key) && !isMovementKey(key){ - m.keyBuffer = "" - } - - switch key { - case "ctrl+c", "q": - return m, tea.Quit - case "h": - (&m).closeSearch() - m.showHelp = true - return m, m.recalcViewport() - case "c": - // Open commit wizard - (&m).closeSearch() - m.openCommitWizard() - return m, m.recalcViewport() - case "u": - // Open uncommit wizard - m.openUncommitWizard() - return m, tea.Batch(loadUncommitFiles(m.repoRoot), loadUncommitEligible(m.repoRoot), m.recalcViewport()) - case "b": - m.openBranchWizard() - return m, tea.Batch(loadBranches(m.repoRoot), m.recalcViewport()) - case "p": - m.openPullWizard() - return m, m.recalcViewport() - case "R": - // Open reset/clean wizard - m.openResetCleanWizard() - return m, m.recalcViewport() - case "/": - (&m).openSearch() - return m, m.recalcViewport() - case "<", "H": - if m.leftWidth == 0 { - m.leftWidth = m.width / 3 - } - m.leftWidth -= 2 - if m.leftWidth < 20 { - m.leftWidth = 20 - } - _ = prefs.SaveLeftWidth(m.repoRoot, m.leftWidth) - return m, m.recalcViewport() - case ">", "L": - if m.leftWidth == 0 { - m.leftWidth = m.width / 3 - } - m.leftWidth += 2 - maxLeft := m.width - 20 - if maxLeft < 20 { - maxLeft = 20 - } - if m.leftWidth > maxLeft { - m.leftWidth = maxLeft - } - _ = prefs.SaveLeftWidth(m.repoRoot, m.leftWidth) - return m, m.recalcViewport() - case "j", "down": - if len(m.files) == 0 { - return m, nil - } - if m.selected < len(m.files)-1 { - if(m.keyBuffer == ""){ - m.selected++ - }else{ - jump, err := strconv.Atoi(m.keyBuffer) - if err != nil{ - m.selected++ - } else { - m.selected += jump - m.selected = min(m.selected, len(m.files) - 1) - } - m.keyBuffer = "" - } - m.rows = nil - // Reset scroll for new file - m.rightVP.GotoTop() - return m, tea.Batch(loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode), m.recalcViewport()) - } - case "k", "up": - if len(m.files) == 0 { - m.keyBuffer = "" - } - if m.selected > 0 { - if(m.keyBuffer == ""){ - m.selected-- - }else{ - jump, err := strconv.Atoi(m.keyBuffer) - if err != nil{ - m.selected-- - } else { - m.selected -= jump - m.selected = max(m.selected, 0) - } - m.keyBuffer = "" - } - m.rows = nil - m.rightVP.GotoTop() - return m, tea.Batch(loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode), m.recalcViewport()) - } - case "g": - if len(m.files) > 0 { - m.selected = 0 - m.rows = nil - m.rightVP.GotoTop() - return m, tea.Batch(loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode), m.recalcViewport()) - } - case "G": - if len(m.files) > 0 { - m.selected = len(m.files) - 1 - m.rows = nil - m.rightVP.GotoTop() - return m, tea.Batch(loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode), m.recalcViewport()) - } - case "[": - // Page up left pane - vis := m.rightVP.Height - if vis <= 0 { vis = 10 } - step := vis - 1 - if step < 1 { step = 1 } - newOffset := m.leftOffset - step - if newOffset < 0 { newOffset = 0 } - // Keep selection visible within new viewport - if m.selected < newOffset { - newOffset = m.selected - } - maxStart := len(m.files) - vis - if maxStart < 0 { maxStart = 0 } - if newOffset > maxStart { newOffset = maxStart } - m.leftOffset = newOffset - return m, m.recalcViewport() - case "]": - // Page down left pane - vis := m.rightVP.Height - if vis <= 0 { vis = 10 } - step := vis - 1 - if step < 1 { step = 1 } - maxStart := len(m.files) - vis - if maxStart < 0 { maxStart = 0 } - newOffset := m.leftOffset + step - if newOffset > maxStart { newOffset = maxStart } - // Keep selection visible within new viewport - if m.selected >= newOffset+vis { - newOffset = m.selected - vis + 1 - if newOffset < 0 { newOffset = 0 } - } - m.leftOffset = newOffset - return m, m.recalcViewport() - case "n": - return m, (&m).advanceSearch(1) - case "N": - return m, (&m).advanceSearch(-1) - case "r": - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadCurrentDiff(m)) - case "s": - m.sideBySide = !m.sideBySide - _ = prefs.SaveSideBySide(m.repoRoot, m.sideBySide) - return m, m.recalcViewport() - case "t": - if m.diffMode == "head" { - m.diffMode = "staged" - } else { - m.diffMode = "head" - } - m.rows = nil - m.selected = 0 - m.rightVP.GotoTop() - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), m.recalcViewport()) - case "w": - // Toggle wrap in diff pane - m.wrapLines = !m.wrapLines - if m.wrapLines { - m.rightXOffset = 0 - } - _ = prefs.SaveWrap(m.repoRoot, m.wrapLines) - return m, m.recalcViewport() - // Horizontal scroll for right pane - case "left", "{": - if m.wrapLines { return m, nil } - if m.rightXOffset > 0 { - m.rightXOffset -= 4 - if m.rightXOffset < 0 { m.rightXOffset = 0 } - return m, m.recalcViewport() - } - return m, nil - case "right", "}": - if m.wrapLines { return m, nil } - m.rightXOffset += 4 - return m, m.recalcViewport() - case "home": - if m.rightXOffset != 0 { - m.rightXOffset = 0 - return m, m.recalcViewport() - } - return m, nil - // Right pane scrolling - case "pgdown": - m.rightVP.PageDown() - return m, nil - case "pgup": - m.rightVP.PageUp() - return m, nil - case "J", "ctrl+d": - m.rightVP.HalfPageDown() - return m, nil - case "K", "ctrl+u": - m.rightVP.HalfPageUp() - return m, nil - case "ctrl+e": - m.rightVP.LineDown(1) - return m, nil - case "ctrl+y": - m.rightVP.LineUp(1) - return m, nil - } + return p.handleKeyMsg(msg) + case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - if m.leftWidth == 0 { - // Initialize left width once - if m.savedLeftWidth > 0 { - m.leftWidth = m.savedLeftWidth - } else { - m.leftWidth = m.width / 3 - } - if m.leftWidth < 24 { - m.leftWidth = 24 - } - // Also ensure it doesn't exceed available - maxLeft := m.width - 20 - if maxLeft < 20 { maxLeft = 20 } - if m.leftWidth > maxLeft { m.leftWidth = maxLeft } - } - return m, m.recalcViewport() + return p.handleWindowSize(msg) + case tickMsg: - // Periodic refresh - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadCurrentBranch(m.repoRoot), tickOnce()) + return p.handleTick() + case filesMsg: - if msg.err != nil { - m.status = fmt.Sprintf("status error: %v", msg.err) - return m, nil - } - // Stable-sort files by path for deterministic UI - sort.Slice(msg.files, func(i, j int) bool { return msg.files[i].Path < msg.files[j].Path }) - - // Preserve selection by path if possible - var selPath string - if len(m.files) > 0 && m.selected >= 0 && m.selected < len(m.files) { - selPath = m.files[m.selected].Path - } - m.files = msg.files - m.lastRefresh = time.Now() - - // Reselect - m.selected = 0 - if selPath != "" { - for i, f := range m.files { - if f.Path == selPath { - m.selected = i - break - } - } - } - // Load diff for selected if exists - if len(m.files) > 0 { - return m, tea.Batch(loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode), m.recalcViewport()) - } - m.rows = nil - return m, m.recalcViewport() + return p.handleFiles(msg) + case diffMsg: - if msg.err != nil { - m.status = fmt.Sprintf("diff error: %v", msg.err) - m.rows = nil - return m, m.recalcViewport() - } - // Only update if this diff is for the currently selected file - if len(m.files) > 0 && m.files[m.selected].Path == msg.path { - m.rows = msg.rows - } - return m, m.recalcViewport() + return p.handleDiff(msg) + case lastCommitMsg: - if msg.err == nil { - m.lastCommit = msg.summary - } - return m, nil + return p.handleLastCommit(msg) + case currentBranchMsg: - if msg.err == nil { - m.currentBranch = msg.name - } - return m, nil + return p.handleCurrentBranch(msg) + case prefsMsg: - if msg.err == nil { - if msg.p.SideSet { m.sideBySide = msg.p.SideBySide } - if msg.p.WrapSet { m.wrapLines = msg.p.Wrap; if m.wrapLines { m.rightXOffset = 0 } } - if msg.p.LeftSet { - m.savedLeftWidth = msg.p.LeftWidth - // If we already know the window size, apply immediately. - if m.width > 0 { - lw := m.savedLeftWidth - if lw < 24 { lw = 24 } - maxLeft := m.width - 20 - if maxLeft < 20 { maxLeft = 20 } - if lw > maxLeft { lw = maxLeft } - m.leftWidth = lw - return m, m.recalcViewport() - } + return p.handlePrefs(msg) + } + + // Forward wizard messages + if p.state.ActiveWizard != "" { + if wiz, ok := p.state.Wizards[p.state.ActiveWizard]; ok { + cmd := wiz.Update(msg) + if wiz.IsComplete() { + p.state.ActiveWizard = "" + return p, tea.Batch(cmd, p.refreshAfterWizard()) } + return p, cmd } - return m, nil - case pullResultMsg: - m.plRunning = false - // Always show result output in overlay; close with enter/esc - m.plOutput = msg.out - if msg.err != nil { - m.plErr = msg.err.Error() - } else { - m.plErr = "" - } - m.plDone = true - m.showPull = true - // Refresh repo state after pull - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), loadCurrentBranch(m.repoRoot), m.recalcViewport()) - case branchListMsg: - if msg.err != nil { - m.brErr = msg.err.Error() - m.brBranches = nil - m.brCurrent = "" - m.brIndex = 0 - return m, m.recalcViewport() - } - m.brBranches = msg.names - m.brCurrent = msg.current - m.brErr = "" - // Focus current if present - m.brIndex = 0 - for i, n := range m.brBranches { - if n == m.brCurrent { m.brIndex = i; break } - } - return m, m.recalcViewport() - case branchResultMsg: - m.brRunning = false - if msg.err != nil { - m.brErr = msg.err.Error() - m.brDone = false - return m, m.recalcViewport() - } - m.brErr = "" - m.brDone = true - m.showBranch = false - // refresh files after checkout - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), loadCurrentBranch(m.repoRoot), m.recalcViewport()) - case rcPreviewMsg: - m.rcPreviewErr = "" - if msg.err != nil { - m.rcPreviewErr = msg.err.Error() - m.rcPreviewLines = nil - } else { - m.rcPreviewLines = msg.lines - } - return m, m.recalcViewport() - case rcResultMsg: - m.rcRunning = false - if msg.err != nil { - m.rcErr = msg.err.Error() - m.rcDone = false - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), m.recalcViewport()) - } - m.rcErr = "" - m.rcDone = true - m.showResetClean = false - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), m.recalcViewport()) - case commitProgressMsg: - m.committing = true - m.commitErr = "" - return m, nil - case commitResultMsg: - m.committing = false - if msg.err != nil { - m.commitErr = msg.err.Error() - m.commitDone = false - // refresh even on error (commit may have succeeded but push failed) - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) - } else { - m.commitErr = "" - m.commitDone = true - m.showCommit = false - // refresh changes and last commit - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) - } - return m, nil - case uncommitFilesMsg: - if msg.err != nil { - m.uncommitErr = msg.err.Error() - m.ucFiles = nil - m.ucSelected = map[string]bool{} - m.ucIndex = 0 - return m, m.recalcViewport() - } - m.ucFiles = msg.files - m.ucSelected = map[string]bool{} - for _, f := range m.ucFiles { - m.ucSelected[f.Path] = true - } - m.ucIndex = 0 - return m, m.recalcViewport() - case uncommitEligibleMsg: - if msg.err != nil { - // No parent commit or other issue; treat as no eligible files. - m.ucEligible = map[string]bool{} - return m, m.recalcViewport() - } - m.ucEligible = map[string]bool{} - for _, p := range msg.paths { - m.ucEligible[p] = true - } - return m, m.recalcViewport() - case uncommitResultMsg: - m.uncommitting = false - if msg.err != nil { - m.uncommitErr = msg.err.Error() - m.uncommitDone = false - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) - } - m.uncommitErr = "" - m.uncommitDone = true - m.showUncommit = false - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) } - return m, nil + + return p, nil } -func (m model) View() string { - // Layout - if m.width == 0 || m.height == 0 { +// View implements tea.Model. +func (p *Program) View() string { + if p.state.Width == 0 || p.state.Height == 0 { return "Loading..." } - - // Column widths - leftW := m.leftWidth - if leftW < 20 { - leftW = 20 - } - rightW := m.width - leftW - 1 // vertical divider column - if rightW < 1 { - rightW = 1 + + // Calculate overlay height + overlayHeight := p.calculateOverlayHeight() + contentHeight := p.layout.ContentHeight(overlayHeight) + + // Update component dimensions + p.state.FileList.EnsureVisible(contentHeight) + p.state.DiffView.SetSize(p.layout.RightWidth(), contentHeight) + + // Render components + leftLines := p.state.FileList.Render(contentHeight) + + // Render diff content + selectedFile := p.state.FileList.SelectedFile() + isBinary := selectedFile != nil && selectedFile.Binary + diffContent := p.state.DiffView.RenderContent(p.layout.RightWidth(), isBinary) + + // Apply search highlights if active + if p.state.SearchEngine.IsActive() && p.state.SearchEngine.Query() != "" { + p.state.SearchEngine.SetContent(diffContent) + diffContent = p.state.SearchEngine.HighlightedContent() } - sep := m.theme.DividerText("│") - - // Row 1: top bar with right-aligned current branch - leftTop := "Changes | " + m.topRightTitle() - rightTop := m.currentBranch - if rightTop != "" { - rightTop = lipgloss.NewStyle().Faint(true).Render(rightTop) + + p.state.DiffView.SetContent(diffContent) + rightView := p.state.DiffView.View() + + // Collect overlay lines + overlayLines := p.collectOverlayLines() + + // Render top bar + topLeft := p.renderTopLeft() + topRight := p.renderTopRight() + + // Update status bar + p.state.StatusBar.SetKeyBuffer(p.keyHandler.KeyBuffer()) + bottomBar := p.state.StatusBar.Render(p.state.Width) + + // Assemble frame + return p.layout.RenderFrame( + topLeft, + topRight, + leftLines, + splitLines(rightView, contentHeight), + overlayLines, + bottomBar, + p.state.Theme, + ) +} + +func (p *Program) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Active wizard takes priority + if p.state.ActiveWizard != "" { + wiz := p.state.Wizards[p.state.ActiveWizard] + action, cmd := wiz.HandleKey(msg) + if action == wizards.ActionClose { + p.state.ActiveWizard = "" + return p, p.recalcViewport() + } + return p, cmd } - // Compose with right part visible and left truncated if needed - { - rightW := lipgloss.Width(rightTop) - if rightW >= m.width { - leftTop = ansi.Truncate(rightTop, m.width, "…") - } else { - avail := m.width - rightW - 1 - if lipgloss.Width(leftTop) > avail { - leftTop = ansi.Truncate(leftTop, avail, "…") - } else if lipgloss.Width(leftTop) < avail { - leftTop = leftTop + strings.Repeat(" ", avail-lipgloss.Width(leftTop)) + + // Search takes priority + if p.state.SearchEngine.IsActive() { + handled, cmd := p.state.SearchEngine.HandleKey(msg) + if handled { + if !p.state.SearchEngine.IsActive() { + // Search closed + return p, p.recalcViewport() + } + // Scroll to current match + if p.state.SearchEngine.MatchCount() > 0 { + line := p.state.SearchEngine.CurrentMatchLine() + p.scrollToLine(line) } - leftTop = leftTop + " " + rightTop + return p, cmd } } - // Row 2: horizontal rule - hr := m.theme.DividerText(strings.Repeat("─", m.width)) - - // Row 3: columns, then optional overlays, then bottom rule + bar - var overlay []string - if m.showHelp { - overlay = m.helpOverlayLines(m.width) - } - if m.showCommit { - overlay = append(overlay, m.commitOverlayLines(m.width)...) - } - if m.showUncommit { - overlay = append(overlay, m.uncommitOverlayLines(m.width)...) - } - if m.showResetClean { - overlay = append(overlay, m.resetCleanOverlayLines(m.width)...) - } - if m.showBranch { - overlay = append(overlay, m.branchOverlayLines(m.width)...) - } - if m.showPull { - overlay = append(overlay, m.pullOverlayLines(m.width)...) - } - if m.searchActive { - overlay = append(overlay, m.searchOverlayLines(m.width)...) + + // Help screen + if p.state.ShowHelp { + return p.handleHelpKeys(msg) } - overlayH := len(overlay) - - contentHeight := m.height - 4 - overlayH // top + top rule + bottom rule + bottom bar - if contentHeight < 1 { - contentHeight = 1 + + // Normal key handling + action, count := p.keyHandler.Handle(msg) + return p.executeAction(action, count) +} + +func (p *Program) handleHelpKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q": + return p, tea.Quit + case "h", "esc": + p.state.SearchEngine.Deactivate() + p.state.ShowHelp = false + return p, p.recalcViewport() + } + return p, nil +} + +func (p *Program) executeAction(action KeyAction, count int) (tea.Model, tea.Cmd) { + switch action { + case ActionQuit: + return p, tea.Quit + + case ActionToggleHelp: + p.state.SearchEngine.Deactivate() + p.state.ShowHelp = !p.state.ShowHelp + return p, p.recalcViewport() + + case ActionOpenCommit: + p.state.SearchEngine.Deactivate() + cmd := p.openWizard("commit") + return p, tea.Batch(cmd, p.recalcViewport()) + + case ActionOpenUncommit: + cmd := p.openWizard("uncommit") + return p, tea.Batch(cmd, p.recalcViewport()) + + case ActionOpenBranch: + cmd := p.openWizard("branch") + return p, tea.Batch(cmd, p.recalcViewport()) + + case ActionOpenPull: + cmd := p.openWizard("pull") + return p, tea.Batch(cmd, p.recalcViewport()) + + case ActionOpenResetClean: + cmd := p.openWizard("resetclean") + return p, tea.Batch(cmd, p.recalcViewport()) + + case ActionOpenSearch: + p.state.SearchEngine.Activate() + return p, p.recalcViewport() + + case ActionRefresh: + return p, tea.Batch( + loadFiles(p.state.RepoRoot, p.state.DiffMode), + p.loadCurrentDiff(), + ) + + case ActionToggleSideBySide: + sideBySide := !p.state.DiffView.GetSideBySide() // Toggle current mode + p.state.DiffView.SetSideBySide(sideBySide) + _ = prefs.SaveSideBySide(p.state.RepoRoot, sideBySide) + return p, p.recalcViewport() + + case ActionToggleDiffMode: + if p.state.DiffMode == "head" { + p.state.DiffMode = "staged" + } else { + p.state.DiffMode = "head" + } + p.state.FileList.GoToTop() + p.state.DiffView.Viewport().GotoTop() + return p, tea.Batch( + loadFiles(p.state.RepoRoot, p.state.DiffMode), + p.recalcViewport(), + ) + + case ActionToggleWrap: + wrap := !p.state.DiffView.GetWrap()// Invert current + p.state.DiffView.SetWrap(wrap) + _ = prefs.SaveWrap(p.state.RepoRoot, wrap) + return p, p.recalcViewport() + + case ActionMoveDown: + if p.state.FileList.MoveSelection(count) { + p.state.DiffView.Viewport().GotoTop() + return p, tea.Batch(p.loadCurrentDiff(), p.recalcViewport()) + } + + case ActionMoveUp: + if p.state.FileList.MoveSelection(-count) { + p.state.DiffView.Viewport().GotoTop() + return p, tea.Batch(p.loadCurrentDiff(), p.recalcViewport()) + } + + case ActionGoToTop: + if p.state.FileList.GoToTop() { + p.state.DiffView.Viewport().GotoTop() + return p, tea.Batch(p.loadCurrentDiff(), p.recalcViewport()) + } + + case ActionGoToBottom: + if p.state.FileList.GoToBottom() { + p.state.DiffView.Viewport().GotoTop() + return p, tea.Batch(p.loadCurrentDiff(), p.recalcViewport()) + } + + case ActionPageUpLeft: + p.state.FileList.PageUp(p.layout.ContentHeight(0)) + return p, p.recalcViewport() + + case ActionPageDownLeft: + p.state.FileList.PageDown(p.layout.ContentHeight(0)) + return p, p.recalcViewport() + + case ActionScrollLeft: + p.state.DiffView.ScrollLeft(1) + return p, p.recalcViewport() + + case ActionScrollRight: + p.state.DiffView.ScrollRight(1) + return p, p.recalcViewport() + + case ActionScrollHome: + p.state.DiffView.ScrollHome() + return p, p.recalcViewport() + + case ActionPageDown: + p.state.DiffView.Viewport().PageDown() + + case ActionPageUp: + p.state.DiffView.Viewport().PageUp() + + case ActionHalfPageDown: + p.state.DiffView.Viewport().HalfPageDown() + + case ActionHalfPageUp: + p.state.DiffView.Viewport().HalfPageUp() + + case ActionLineDown: + p.state.DiffView.Viewport().LineDown(1) + + case ActionLineUp: + p.state.DiffView.Viewport().LineUp(1) + + case ActionAdjustLeftNarrower: + p.layout.AdjustLeftWidth(-2) + p.state.LeftWidth = p.layout.LeftWidth() + _ = prefs.SaveLeftWidth(p.state.RepoRoot, p.state.LeftWidth) + return p, p.recalcViewport() + + case ActionAdjustLeftWider: + p.layout.AdjustLeftWidth(2) + p.state.LeftWidth = p.layout.LeftWidth() + _ = prefs.SaveLeftWidth(p.state.RepoRoot, p.state.LeftWidth) + return p, p.recalcViewport() + + case ActionSearchNext: + p.state.SearchEngine.Next() + if p.state.SearchEngine.MatchCount() > 0 { + line := p.state.SearchEngine.CurrentMatchLine() + p.scrollToLine(line) + } + + case ActionSearchPrevious: + p.state.SearchEngine.Previous() + if p.state.SearchEngine.MatchCount() > 0 { + line := p.state.SearchEngine.CurrentMatchLine() + p.scrollToLine(line) + } } + + return p, nil +} - leftLines := m.leftBodyLines(contentHeight) - // Right viewport already holds content and scroll state; ensure dims - // The viewport content is updated via recalcViewport() - m.rightVP.Width = rightW - m.rightVP.Height = contentHeight - rightView := m.rightVP.View() - rightLines := strings.Split(rightView, "\n") - maxLines := contentHeight - - var b strings.Builder - b.WriteString(leftTop) - b.WriteByte('\n') - b.WriteString(hr) - b.WriteByte('\n') - for i := 0; i < maxLines; i++ { - var l, r string - if i < len(leftLines) { - l = padToWidth(leftLines[i], leftW) +func (p *Program) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + p.state.Width = msg.Width + p.state.Height = msg.Height + p.layout.SetSize(msg.Width, msg.Height) + + if p.state.LeftWidth == 0 { + if p.state.SavedLeftWidth > 0 { + p.state.LeftWidth = p.state.SavedLeftWidth } else { - l = strings.Repeat(" ", leftW) + p.state.LeftWidth = msg.Width / 3 } - if i < len(rightLines) { - r = rightLines[i] - } else { - r = "" + if p.state.LeftWidth < 24 { + p.state.LeftWidth = 24 } - b.WriteString(l) - b.WriteString(sep) - b.WriteString(padToWidth(r, rightW)) - if i < maxLines-1 { - b.WriteByte('\n') + maxLeft := msg.Width - 20 + if maxLeft < 20 { + maxLeft = 20 } - } - // Optional overlay right above bottom bar - if overlayH > 0 { - b.WriteByte('\n') - for i, line := range overlay { - b.WriteString(padToWidth(line, m.width)) - if i < overlayH-1 { - b.WriteByte('\n') - } + if p.state.LeftWidth > maxLeft { + p.state.LeftWidth = maxLeft } + p.layout.SetLeftWidth(p.state.LeftWidth) } - // Bottom rule and bottom bar - b.WriteByte('\n') - b.WriteString(strings.Repeat("─", m.width)) - b.WriteByte('\n') - b.WriteString(m.bottomBar()) - return b.String() + + return p, p.recalcViewport() } -func (m model) leftBodyLines(max int) []string { - lines := make([]string, 0, max) - if len(m.files) == 0 { - lines = append(lines, "No changes detected") - return lines - } - start := m.leftOffset - if start < 0 { start = 0 } - if start > len(m.files) { start = len(m.files) } - end := start + max - if end > len(m.files) { end = len(m.files) } - for i := start; i < end; i++ { - f := m.files[i] - marker := " " - if i == m.selected { - marker = "> " - } - status := fileStatusLabel(f) - line := fmt.Sprintf("%s%s %s", marker, status, f.Path) - lines = append(lines, line) - } - return lines +func (p *Program) handleTick() (tea.Model, tea.Cmd) { + return p, tea.Batch( + loadFiles(p.state.RepoRoot, p.state.DiffMode), + loadCurrentBranch(p.state.RepoRoot), + tickOnce(), + ) } -func (m model) rightBodyLines(max, width int) []string { - lines := make([]string, 0, max) - if len(m.files) == 0 { - return lines - } - if m.files[m.selected].Binary { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("(Binary file; no text diff)")) - return lines +func (p *Program) handleFiles(msg filesMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + return p, nil } - if m.rows == nil { - lines = append(lines, "Loading diff…") - return lines + + // Preserve selection by path + var selPath string + if sel := p.state.FileList.SelectedFile(); sel != nil { + selPath = sel.Path } - if m.sideBySide { - colsW := (width - 1) / 2 - if colsW < 10 { - colsW = 10 - } - mid := m.theme.DividerText("│") - for _, r := range m.rows { - switch r.Kind { - case diffview.RowHunk: - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render(r.Meta)) - case diffview.RowMeta: - // skip - default: - l := padToWidth(m.colorizeLeft(r), colsW) - rr := padToWidth(m.colorizeRight(r), colsW) - lines = append(lines, l+mid+rr) - } - if len(lines) >= max { - break - } - } - } else { - for _, r := range m.rows { - switch r.Kind { - case diffview.RowHunk: - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render(r.Meta)) - case diffview.RowContext: - lines = append(lines, " "+r.Left) - case diffview.RowAdd: - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("34")).Render("+ "+r.Right)) - case diffview.RowDel: - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("- "+r.Left)) - case diffview.RowReplace: - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("- "+r.Left)) - if len(lines) >= max { break } - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("34")).Render("+ "+r.Right)) - } - if len(lines) >= max { + + p.state.FileList.SetFiles(msg.files) + p.state.Files = msg.files + p.state.StatusBar.SetLastRefresh(time.Now()) + + // Restore selection + if selPath != "" { + for i, f := range msg.files { + if f.Path == selPath { + p.state.FileList.MoveSelection(i - p.state.FileList.Selected()) break } } } - return lines -} - -func (m model) topRightTitle() string { - if len(m.files) == 0 { - return fmt.Sprintf("[%s]", strings.ToUpper(m.diffMode)) + + // Load diff for selected + if len(msg.files) > 0 { + return p, tea.Batch(p.loadCurrentDiff(), p.recalcViewport()) } - header := fmt.Sprintf("%s (%s) [%s]", m.files[m.selected].Path, fileStatusLabel(m.files[m.selected]), strings.ToUpper(m.diffMode)) - return header + + return p, p.recalcViewport() } -func (m model) bottomBar() string { - leftText := "h: help" - if m.keyBuffer != ""{ - leftText = m.keyBuffer - } - if m.lastCommit != "" { - leftText += " | last: " + m.lastCommit - } - leftStyled := lipgloss.NewStyle().Faint(true).Render(leftText) - right := lipgloss.NewStyle().Faint(true).Render("refreshed: " + m.lastRefresh.Format("15:04:05")) - w := m.width - // Ensure the right part is always visible; truncate left if needed - rightW := lipgloss.Width(right) - if rightW >= w { - // Degenerate case: screen too small; just show right truncated - return ansi.Truncate(right, w, "…") +func (p *Program) handleDiff(msg diffMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + return p, nil } - avail := w - rightW - 1 // 1 space gap - leftRendered := leftStyled - if lipgloss.Width(leftRendered) > avail { - leftRendered = ansi.Truncate(leftRendered, avail, "…") - } else if lipgloss.Width(leftRendered) < avail { - leftRendered = leftRendered + strings.Repeat(" ", avail-lipgloss.Width(leftRendered)) + + // Only update if this is for the current file + if sel := p.state.FileList.SelectedFile(); sel != nil && sel.Path == msg.path { + p.state.DiffView.SetRows(msg.rows) } - return leftRendered + " " + right + + return p, p.recalcViewport() } -func fileStatusLabel(f gitx.FileChange) string { - var tags []string - if f.Deleted { - tags = append(tags, "D") +func (p *Program) handleLastCommit(msg lastCommitMsg) (tea.Model, tea.Cmd) { + if msg.err == nil { + p.state.LastCommit = msg.summary + p.state.StatusBar.SetLastCommit(msg.summary) } - if f.Untracked { - tags = append(tags, "U") + return p, nil +} + +func (p *Program) handleCurrentBranch(msg currentBranchMsg) (tea.Model, tea.Cmd) { + if msg.err == nil { + p.state.CurrentBranch = msg.name } - if f.Staged { - tags = append(tags, "S") + return p, nil +} + +func (p *Program) handlePrefs(msg prefsMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + return p, nil } - if f.Unstaged { - tags = append(tags, "M") + + if msg.p.SideSet { + p.state.DiffView.SetSideBySide(msg.p.SideBySide) } - if len(tags) == 0 { - return "-" + if msg.p.WrapSet { + p.state.DiffView.SetWrap(msg.p.Wrap) } - return strings.Join(tags, "") -} - -func loadFiles(repoRoot, diffMode string) tea.Cmd { - return func() tea.Msg { - allFiles, err := gitx.ChangedFiles(repoRoot) - if err != nil { - return filesMsg{files: nil, err: err} - } - - // Filter files based on diff mode - var filteredFiles []gitx.FileChange - for _, file := range allFiles { - if diffMode == "staged" { - if file.Staged { - filteredFiles = append(filteredFiles, file) - } - } else { - if file.Unstaged || file.Untracked { - filteredFiles = append(filteredFiles, file) - } + if msg.p.LeftSet { + p.state.SavedLeftWidth = msg.p.LeftWidth + if p.state.Width > 0 { + lw := p.state.SavedLeftWidth + if lw < 24 { + lw = 24 + } + maxLeft := p.state.Width - 20 + if maxLeft < 20 { + maxLeft = 20 + } + if lw > maxLeft { + lw = maxLeft } + p.state.LeftWidth = lw + p.layout.SetLeftWidth(lw) + return p, p.recalcViewport() } - - return filesMsg{files: filteredFiles, err: nil} } + + return p, nil } -func loadDiff(repoRoot, path, diffMode string) tea.Cmd { - return func() tea.Msg { - var d string - var err error - if diffMode == "staged" { - d, err = gitx.DiffStaged(repoRoot, path) - } else { - d, err = gitx.DiffHEAD(repoRoot, path) - } - if err != nil { - return diffMsg{path: path, err: err} - } - rows := diffview.BuildRowsFromUnified(d) - return diffMsg{path: path, rows: rows} - } +func (p *Program) openWizard(name string) tea.Cmd{ + p.state.ActiveWizard = name + wiz := p.state.Wizards[name] + return wiz.Init(p.state.RepoRoot, p.state.Files) } -func loadCurrentDiff(m model) tea.Cmd { - if len(m.files) == 0 { - return nil +func (p *Program) loadCurrentDiff() tea.Cmd { + if sel := p.state.FileList.SelectedFile(); sel != nil { + return loadDiff(p.state.RepoRoot, sel.Path, p.state.DiffMode) } - return loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode) + return nil } -func tickOnce() tea.Cmd { - return tea.Tick(time.Second, func(time.Time) tea.Msg { return tickMsg{} }) +func (p *Program) recalcViewport() tea.Cmd { + // Force re-render + return nil } -func padToWidth(s string, w int) string { - width := lipgloss.Width(s) - if width == w { - return s - } - if width < w { - return s + strings.Repeat(" ", w-width) - } - return ansi.Truncate(s, w, "…") +func (p *Program) refreshAfterWizard() tea.Cmd { + return tea.Batch( + loadFiles(p.state.RepoRoot, p.state.DiffMode), + loadLastCommit(p.state.RepoRoot), + loadCurrentBranch(p.state.RepoRoot), + p.recalcViewport(), + ) } -func (m model) viewHelp() string { - // Full-screen simple help panel - var b strings.Builder - title := lipgloss.NewStyle().Bold(true).Render("Diffium Help") - lines := []string{ - "", - "j/k or arrows Move selection", - "J/K, PgDn/PgUp Scroll diff", - " or H/L Adjust left pane width", - "[/] Page left file list", - "{/} Horizontal scroll (diff)", - "b Switch branch (open wizard)", - "s Toggle side-by-side / inline", - "r Refresh now", - "g / G Top / Bottom", - "h or Esc Close help", - "q Quit", - "", - "Press 'h' or 'Esc' to close.", +func (p *Program) calculateOverlayHeight() int { + height := 0 + + if p.state.ShowHelp { + height += 18 // Help has fixed height } - // Center-ish: add left padding - pad := 4 - if m.width > 60 { - pad = (m.width - 60) / 2 - if pad < 4 { pad = 4 } + + if p.state.ActiveWizard != "" { + wiz := p.state.Wizards[p.state.ActiveWizard] + height += len(wiz.RenderOverlay(p.state.Width)) } - leftPad := strings.Repeat(" ", pad) - fmt.Fprintln(&b, leftPad+title) - for _, l := range lines { - fmt.Fprintln(&b, leftPad+l) + + if p.state.SearchEngine.IsActive() { + height += 3 // Search overlay } - // Bottom hint - hint := lipgloss.NewStyle().Faint(true).Render("h: help refreshed: " + m.lastRefresh.Format("15:04:05")) - fmt.Fprintln(&b) - fmt.Fprint(&b, padToWidth(hint, m.width)) - return b.String() + + return height } -// recalcViewport recalculates right viewport size and content based on current state. -func (m *model) recalcViewport() tea.Cmd { - if m.width == 0 || m.height == 0 { - return nil - } - leftW := m.leftWidth - if leftW < 20 { - leftW = 20 - } - rightW := m.width - leftW - 1 - if rightW < 1 { - rightW = 1 - } - overlayH := 0 - if m.showHelp { - overlayH += len(m.helpOverlayLines(m.width)) - } - if m.showCommit { - overlayH += len(m.commitOverlayLines(m.width)) - } - if m.showUncommit { - overlayH += len(m.uncommitOverlayLines(m.width)) - } - if m.showResetClean { - overlayH += len(m.resetCleanOverlayLines(m.width)) - } - if m.showBranch { - overlayH += len(m.branchOverlayLines(m.width)) - } - if m.showPull { - overlayH += len(m.pullOverlayLines(m.width)) - } - if m.searchActive { - overlayH += len(m.searchOverlayLines(m.width)) +func (p *Program) collectOverlayLines() []string { + var lines []string + + if p.state.ShowHelp { + lines = append(lines, p.renderHelpOverlay()...) } - contentHeight := m.height - 4 - overlayH - if contentHeight < 1 { - contentHeight = 1 + + if p.state.ActiveWizard != "" { + wiz := p.state.Wizards[p.state.ActiveWizard] + lines = append(lines, wiz.RenderOverlay(p.state.Width)...) } - // Clamp leftOffset and keep selection visible in left pane - vis := contentHeight - if vis < 1 { vis = 1 } - if m.leftOffset < 0 { m.leftOffset = 0 } - maxStart := len(m.files) - vis - if maxStart < 0 { maxStart = 0 } - if m.leftOffset > maxStart { m.leftOffset = maxStart } - if len(m.files) > 0 { - if m.selected < m.leftOffset { - m.leftOffset = m.selected - } else if m.selected >= m.leftOffset+vis { - m.leftOffset = m.selected - vis + 1 - if m.leftOffset < 0 { m.leftOffset = 0 } - } - if m.leftOffset > maxStart { m.leftOffset = maxStart } + + if p.state.SearchEngine.IsActive() { + lines = append(lines, p.state.SearchEngine.RenderOverlay( + p.state.Width, + p.state.Theme.DividerColor, + )...) } - - // Set dimensions - m.rightVP.Width = rightW - m.rightVP.Height = contentHeight - // Build content - m.rightContent = m.rightBodyLinesAll(rightW) - - // Update search matches + highlight state - if m.searchQuery == "" { - m.searchMatches = nil - m.searchIndex = 0 - } else { - m.recomputeSearchMatches(false) - } - m.refreshSearchHighlights() - - return nil + + return lines } -// helpOverlayLines returns the bottom overlay lines (without trailing newline). -func (m model) helpOverlayLines(width int) []string { - if !m.showHelp { - return nil - } - // Header - title := lipgloss.NewStyle().Bold(true).Render("Help — press 'h' or Esc to close") - // Keys +func (p *Program) renderHelpOverlay() []string { + lines := make([]string, 0, 20) + lines = append(lines, strings.Repeat("─", p.state.Width)) + + title := lipgloss.NewStyle().Bold(true). + Render("Help — press 'h' or Esc to close") + lines = append(lines, title) + keys := []string{ "j/k or arrows Move selection", "J/K, PgDn/PgUp Scroll diff", @@ -1072,1612 +572,69 @@ func (m model) helpOverlayLines(width int) []string { "s Toggle side-by-side / inline", "t Toggle HEAD / staged diffs", "w Toggle line wrap (diff)", + "/ Search in diff", + "n/N Next/previous search match", "r Refresh now", "g / G Top / Bottom", "q Quit", } - lines := make([]string, 0, 2+len(keys)) - // Overlay top rule - lines = append(lines, strings.Repeat("─", width)) - lines = append(lines, title) - for _, k := range keys { - lines = append(lines, k) - } - return lines -} - -func (m model) commitOverlayLines(width int) []string { - if !m.showCommit { - return nil - } - lines := make([]string, 0, 64) - lines = append(lines, strings.Repeat("─", width)) - switch m.commitStep { - case 0: - title := lipgloss.NewStyle().Bold(true).Render("Commit — Select files (space: toggle, a: all, enter: continue, esc: cancel)") - lines = append(lines, title) - if len(m.cwFiles) == 0 { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No changes to commit")) - return lines - } - for i, f := range m.cwFiles { - cur := " " - if i == m.cwIndex { - cur = "> " - } - mark := "[ ]" - if m.cwSelected[f.Path] { - mark = "[x]" - } - status := fileStatusLabel(f) - lines = append(lines, fmt.Sprintf("%s%s %s %s", cur, mark, status, f.Path)) - } - case 1: - mode := "action" - if m.cwInputActive { mode = "input" } - title := lipgloss.NewStyle().Bold(true).Render("Commit — Message (i: input, enter: continue, b: back, esc: " + map[bool]string{true:"leave input", false:"cancel"}[m.cwInputActive] + ") ["+mode+"]") - lines = append(lines, title) - lines = append(lines, m.cwInput.View()) - case 2: - title := lipgloss.NewStyle().Bold(true).Render("Commit — Confirm (y/enter: commit & push, b: back, esc: cancel)") - lines = append(lines, title) - // Summary - sel := m.selectedPaths() - lines = append(lines, fmt.Sprintf("Files: %d", len(sel))) - lines = append(lines, "Message: "+m.cwInput.Value()) - if m.committing { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Committing & pushing...")) - } - if m.commitErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.commitErr) - } - } + + lines = append(lines, keys...) return lines } -// --- Uncommit wizard --- - -type uncommitFilesMsg struct { - files []gitx.FileChange - err error -} - -// --- Reset/Clean wizard --- - -type rcPreviewMsg struct{ - lines []string - err error -} - -// --- Branch switch wizard --- - -type branchListMsg struct{ - names []string - current string - err error -} - -// --- Pull wizard --- - -type pullResultMsg struct{ out string; err error } - -func (m *model) openPullWizard() { - m.showPull = true - m.plRunning = false - m.plErr = "" - m.plDone = false - m.plOutput = "" -} - -func (m model) pullOverlayLines(width int) []string { - if !m.showPull { return nil } - lines := make([]string, 0, 32) - lines = append(lines, strings.Repeat("─", width)) - if m.plDone { - title := lipgloss.NewStyle().Bold(true).Render("Pull — Result (enter/esc: close)") - lines = append(lines, title) - if m.plErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.plErr) - } - if m.plOutput != "" { - // Show up to 12 lines of output - outLines := strings.Split(strings.TrimRight(m.plOutput, "\n"), "\n") - max := 12 - for i, l := range outLines { - if i >= max { break } - lines = append(lines, l) - } - if len(outLines) > max { - lines = append(lines, fmt.Sprintf("… and %d more", len(outLines)-max)) - } - } else if m.plErr == "" { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("(no output)")) - } +func (p *Program) renderTopLeft() string { + var title string + if sel := p.state.FileList.SelectedFile(); sel != nil { + status := components.FileStatusLabel(*sel) + title = fmt.Sprintf("Changes | %s (%s) [%s]", + sel.Path, status, strings.ToUpper(p.state.DiffMode)) } else { - title := lipgloss.NewStyle().Bold(true).Render("Pull — Confirm (y/enter: pull, esc: cancel)") - lines = append(lines, title) - if m.plRunning { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Pulling…")) - } - if m.plErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.plErr) - } + title = fmt.Sprintf("Changes | [%s]", strings.ToUpper(p.state.DiffMode)) } - return lines + return title } -func (m model) handlePullKeys(key tea.KeyMsg) (tea.Model, tea.Cmd) { - switch key.String() { - case "esc": - if m.plDone && !m.plRunning { - m.showPull = false - m.plDone = false - m.plOutput = "" - m.plErr = "" - return m, m.recalcViewport() - } - if !m.plRunning { m.showPull = false; return m, m.recalcViewport() } - return m, nil - case "y", "enter": - if m.plDone && !m.plRunning { - // Close results view - m.showPull = false - m.plDone = false - m.plOutput = "" - m.plErr = "" - return m, m.recalcViewport() - } - if !m.plRunning && !m.plDone { - m.plRunning = true - m.plErr = "" - return m, runPull(m.repoRoot) - } - return m, nil +func (p *Program) renderTopRight() string { + if p.state.CurrentBranch != "" { + return lipgloss.NewStyle().Faint(true).Render(p.state.CurrentBranch) } - return m, nil + return "" } -func runPull(repoRoot string) tea.Cmd { - return func() tea.Msg { - out, err := gitx.PullWithOutput(repoRoot) - if err != nil { - return pullResultMsg{out: out, err: err} - } - return pullResultMsg{out: out, err: nil} - } -} - -type branchResultMsg struct{ err error } - -func loadBranches(repoRoot string) tea.Cmd { - return func() tea.Msg { - names, current, err := gitx.ListBranches(repoRoot) - return branchListMsg{names: names, current: current, err: err} +func (p *Program) scrollToLine(line int) { + if line < 0 { + return } -} - -func (m *model) openBranchWizard() { - m.showBranch = true - m.brStep = 0 - m.brBranches = nil - m.brCurrent = "" - m.brIndex = 0 - m.brRunning = false - m.brErr = "" - m.brDone = false - m.brInput = textinput.Model{} - m.brInputActive = false -} - -func (m model) branchOverlayLines(width int) []string { - if !m.showBranch { return nil } - lines := make([]string, 0, 128) - lines = append(lines, strings.Repeat("─", width)) - switch m.brStep { - case 0: - title := lipgloss.NewStyle().Bold(true).Render("Branches — Select (enter: continue, n: new, esc: cancel)") - lines = append(lines, title) - if m.brErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.brErr) - } - if len(m.brBranches) == 0 && m.brErr == "" { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Loading branches…")) - return lines - } - for i, n := range m.brBranches { - cur := " " - if i == m.brIndex { cur = "> " } - mark := " " - if n == m.brCurrent { mark = "[*]" } - lines = append(lines, fmt.Sprintf("%s%s %s", cur, mark, n)) - } - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("[*] current branch")) - case 1: - title := lipgloss.NewStyle().Bold(true).Render("Checkout — Confirm (y/enter: checkout, b: back, esc: cancel)") - lines = append(lines, title) - if len(m.brBranches) > 0 { - name := m.brBranches[m.brIndex] - lines = append(lines, fmt.Sprintf("Branch: %s", name)) - } - if m.brRunning { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Checking out…")) - } - if m.brErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.brErr) - } - case 2: - // New branch: name input - mode := "action" - if m.brInputActive { mode = "input" } - title := lipgloss.NewStyle().Bold(true).Render("New Branch — Name (i: input, enter: continue, b: back, esc: " + map[bool]string{true:"leave input", false:"cancel"}[m.brInputActive] + ") ["+mode+"]") - lines = append(lines, title) - lines = append(lines, m.brInput.View()) - if m.brErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.brErr) - } - case 3: - // New branch: confirm - title := lipgloss.NewStyle().Bold(true).Render("New Branch — Confirm (y/enter: create, b: back, esc: cancel)") - lines = append(lines, title) - lines = append(lines, "Name: "+m.brInput.Value()) - if m.brRunning { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Creating…")) - } - if m.brErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.brErr) - } - } - return lines -} - -func (m model) handleBranchKeys(key tea.KeyMsg) (tea.Model, tea.Cmd) { - switch m.brStep { - case 0: - switch key.String() { - case "esc": - m.showBranch = false - return m, m.recalcViewport() - case "j", "down": - if len(m.brBranches) > 0 && m.brIndex < len(m.brBranches)-1 { m.brIndex++ } - return m, nil - case "k", "up": - if m.brIndex > 0 { m.brIndex-- } - return m, nil - case "n": - // New branch flow - ti := textinput.New() - ti.Placeholder = "Branch name" - ti.Prompt = "> " - // Start in action mode; 'i' toggles input focus - m.brInput = ti - m.brInputActive = false - m.brStep = 2 - m.brErr = "" - m.brDone = false - m.brRunning = false - return m, m.recalcViewport() - case "enter": - if len(m.brBranches) == 0 { return m, nil } - m.brStep = 1 - m.brErr = "" - m.brDone = false - m.brRunning = false - return m, m.recalcViewport() - } - case 1: - switch key.String() { - case "esc": - if !m.brRunning { m.showBranch = false; return m, m.recalcViewport() } - return m, nil - case "b": - if !m.brRunning && !m.brDone { m.brStep = 0; return m, m.recalcViewport() } - return m, nil - case "y", "enter": - if !m.brRunning && !m.brDone { - if len(m.brBranches) == 0 { return m, nil } - name := m.brBranches[m.brIndex] - m.brRunning = true - m.brErr = "" - return m, runCheckout(m.repoRoot, name) - } - return m, nil - } - case 2: // new branch name - switch key.String() { - case "esc": - if m.brInputActive { - m.brInputActive = false - return m, m.recalcViewport() - } - m.showBranch = false - return m, m.recalcViewport() - case "i": - if !m.brInputActive { - m.brInputActive = true - m.brInput.Focus() - return m, m.recalcViewport() - } - // already active, treat as input - case "b": - if !m.brInputActive { - m.brStep = 0 - return m, m.recalcViewport() - } - // else forward to input - case "enter": - if !m.brInputActive { - if strings.TrimSpace(m.brInput.Value()) == "" { - m.brErr = "empty branch name" - return m, nil - } - m.brStep = 3 - m.brErr = "" - m.brDone = false - m.brRunning = false - return m, m.recalcViewport() - } - // in input mode, forward to text input - } - if m.brInputActive { - var cmd tea.Cmd - m.brInput, cmd = m.brInput.Update(key) - return m, cmd - } - return m, nil - case 3: // confirm new branch - switch key.String() { - case "esc": - if !m.brRunning { m.showBranch = false; return m, m.recalcViewport() } - return m, nil - case "b": - if !m.brRunning && !m.brDone { m.brStep = 2; return m, m.recalcViewport() } - return m, nil - case "y", "enter": - if !m.brRunning && !m.brDone { - name := strings.TrimSpace(m.brInput.Value()) - if name == "" { - m.brErr = "empty branch name" - return m, nil - } - m.brRunning = true - m.brErr = "" - return m, runCreateBranch(m.repoRoot, name) - } - return m, nil - } - } - return m, nil -} - -func runCheckout(repoRoot, branch string) tea.Cmd { - return func() tea.Msg { - if err := gitx.Checkout(repoRoot, branch); err != nil { - return branchResultMsg{err: err} - } - return branchResultMsg{err: nil} - } -} - -func runCreateBranch(repoRoot, name string) tea.Cmd { - return func() tea.Msg { - if err := gitx.CheckoutNew(repoRoot, name); err != nil { - return branchResultMsg{err: err} - } - return branchResultMsg{err: nil} - } -} - -type rcResultMsg struct{ err error } - -func (m *model) openResetCleanWizard() { - m.showResetClean = true - m.rcStep = 0 - m.rcDoReset = false - m.rcDoClean = false - m.rcIncludeIgnored = false - m.rcIndex = 0 - m.rcPreviewLines = nil - m.rcPreviewErr = "" - m.rcRunning = false - m.rcErr = "" - m.rcDone = false -} - -func (m model) resetCleanOverlayLines(width int) []string { - if !m.showResetClean { return nil } - lines := make([]string, 0, 128) - lines = append(lines, strings.Repeat("─", width)) - switch m.rcStep { - case 0: // select actions/options - title := lipgloss.NewStyle().Bold(true).Render("Reset/Clean — Select actions (space: toggle, a: toggle both, enter: continue, esc: cancel)") - lines = append(lines, title) - items := []struct{ label string; on bool }{ - {"Reset working tree (git reset --hard)", m.rcDoReset}, - {"Clean untracked (git clean -d -f)", m.rcDoClean}, - {"Include ignored in clean (-x)", m.rcIncludeIgnored}, - } - for i, it := range items { - cur := " " - if i == m.rcIndex { cur = "> " } - lines = append(lines, fmt.Sprintf("%s%s %s", cur, checkbox(it.on), it.label)) - } - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("A preview will be shown before confirmation")) - if m.rcErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.rcErr) - } - case 1: // preview - title := lipgloss.NewStyle().Bold(true).Render("Reset/Clean — Preview (enter: continue, b: back, esc: cancel)") - lines = append(lines, title) - // Reset preview summary from current file list (tracked changes) - if m.rcDoReset { - tracked := 0 - for _, f := range m.files { - if !f.Untracked && (f.Staged || f.Unstaged || f.Deleted) { - tracked++ - } - } - lines = append(lines, fmt.Sprintf("Reset would discard tracked changes for ~%d file(s)", tracked)) - } else { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Reset: (not selected)")) - } - // Clean preview - if m.rcDoClean { - if m.rcPreviewErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Clean preview error: ")+m.rcPreviewErr) - } else if len(m.rcPreviewLines) == 0 { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Clean: nothing to remove")) - } else { - lines = append(lines, lipgloss.NewStyle().Bold(true).Render("Clean would remove:")) - max := 10 - for i, l := range m.rcPreviewLines { - if i >= max { break } - lines = append(lines, l) - } - if len(m.rcPreviewLines) > max { - lines = append(lines, fmt.Sprintf("… and %d more", len(m.rcPreviewLines)-max)) - } - if m.rcIncludeIgnored { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("(including ignored files)")) - } - } - } else { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Clean: (not selected)")) - } - // Show exact commands - var cmds []string - if m.rcDoReset { cmds = append(cmds, "git reset --hard") } - if m.rcDoClean { - c := "git clean -d -f" - if m.rcIncludeIgnored { c += " -x" } - cmds = append(cmds, c) - } - if len(cmds) > 0 { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Commands: "+strings.Join(cmds, " && "))) - } else { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No actions selected")) - } - case 2: // first (yellow) confirmation - title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("220")).Render("Confirm — This will discard local changes (enter: continue, b: back, esc: cancel)") - lines = append(lines, title) - lines = append(lines, "Proceed to final confirmation?") - case 3: // final (red) confirmation - title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("196")).Render("FINAL CONFIRMATION — Destructive action (y/enter: execute, b: back, esc: cancel)") - lines = append(lines, title) - if m.rcRunning { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Running…")) - } - if m.rcErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.rcErr) - } - } - return lines -} - -func checkbox(on bool) string { if on { return "[x]" } ; return "[ ]" } - -func (m model) handleResetCleanKeys(key tea.KeyMsg) (tea.Model, tea.Cmd) { - switch m.rcStep { - case 0: // select - switch key.String() { - case "esc": - m.showResetClean = false - return m, m.recalcViewport() - case "j", "down": - if m.rcIndex < 2 { m.rcIndex++ } - return m, nil - case "k", "up": - if m.rcIndex > 0 { m.rcIndex-- } - return m, nil - case " ": - switch m.rcIndex { - case 0: - m.rcDoReset = !m.rcDoReset - case 1: - m.rcDoClean = !m.rcDoClean - case 2: - m.rcIncludeIgnored = !m.rcIncludeIgnored - } - return m, nil - case "a": - both := m.rcDoReset && m.rcDoClean - m.rcDoReset = !both - m.rcDoClean = !both - return m, nil - case "enter": - if !m.rcDoReset && !m.rcDoClean { - m.rcErr = "no actions selected" - return m, m.recalcViewport() - } - m.rcStep = 1 - m.rcPreviewErr = "" - m.rcPreviewLines = nil - if m.rcDoClean { - return m, loadRCPreview(m.repoRoot, m.rcIncludeIgnored) - } - return m, m.recalcViewport() - } - case 1: // preview - switch key.String() { - case "esc": - m.showResetClean = false - return m, m.recalcViewport() - case "b": - m.rcStep = 0 - return m, m.recalcViewport() - case "enter": - m.rcStep = 2 - return m, m.recalcViewport() - } - case 2: // yellow confirm - switch key.String() { - case "esc": - m.showResetClean = false - return m, m.recalcViewport() - case "b": - m.rcStep = 1 - return m, m.recalcViewport() - case "enter": - m.rcStep = 3 - return m, m.recalcViewport() - } - case 3: // red confirm - switch key.String() { - case "esc": - if !m.rcRunning { - m.showResetClean = false - return m, m.recalcViewport() - } - return m, nil - case "b": - if !m.rcRunning { - m.rcStep = 2 - return m, m.recalcViewport() - } - return m, nil - case "y", "enter": - if !m.rcRunning && !m.rcDone { - m.rcRunning = true - m.rcErr = "" - return m, runResetClean(m.repoRoot, m.rcDoReset, m.rcDoClean, m.rcIncludeIgnored) - } - return m, nil - } - } - return m, nil -} - -func loadRCPreview(repoRoot string, includeIgnored bool) tea.Cmd { - return func() tea.Msg { - lines, err := gitx.CleanPreview(repoRoot, includeIgnored) - return rcPreviewMsg{lines: lines, err: err} - } -} - -func runResetClean(repoRoot string, doReset, doClean bool, includeIgnored bool) tea.Cmd { - return func() tea.Msg { - if err := gitx.ResetAndClean(repoRoot, doReset, doClean, includeIgnored); err != nil { - return rcResultMsg{err: err} - } - return rcResultMsg{err: nil} - } -} - -type uncommitResultMsg struct{ err error } - -func loadUncommitFiles(repoRoot string) tea.Cmd { - return func() tea.Msg { - files, err := gitx.ChangedFiles(repoRoot) - return uncommitFilesMsg{files: files, err: err} - } -} - -type uncommitEligibleMsg struct { - paths []string - err error -} - -func loadUncommitEligible(repoRoot string) tea.Cmd { - return func() tea.Msg { - ps, err := gitx.FilesInLastCommit(repoRoot) - return uncommitEligibleMsg{paths: ps, err: err} - } -} - -func (m *model) openUncommitWizard() { - m.showUncommit = true - m.ucStep = 0 - m.ucFiles = nil - m.ucSelected = map[string]bool{} - m.ucIndex = 0 - m.uncommitting = false - m.uncommitErr = "" - m.uncommitDone = false -} - -func (m model) uncommitOverlayLines(width int) []string { - if !m.showUncommit { - return nil - } - lines := make([]string, 0, 64) - lines = append(lines, strings.Repeat("─", width)) - switch m.ucStep { - case 0: - title := lipgloss.NewStyle().Bold(true).Render("Uncommit — Select files (space: toggle, a: all, enter: continue, esc: cancel)") - lines = append(lines, title) - if len(m.ucFiles) == 0 && m.uncommitErr == "" { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Loading files…")) - return lines - } - if m.uncommitErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.uncommitErr) - } - if len(m.ucFiles) == 0 && m.uncommitErr == "" { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No changes to choose from")) - return lines - } - for i, f := range m.ucFiles { - cur := " " - if i == m.ucIndex { cur = "> " } - mark := "[ ]" - if m.ucSelected[f.Path] { mark = "[x]" } - status := fileStatusLabel(f) - lines = append(lines, fmt.Sprintf("%s%s %s %s", cur, mark, status, f.Path)) - } - case 1: - title := lipgloss.NewStyle().Bold(true).Render("Uncommit — Confirm (y/enter: uncommit, b: back, esc: cancel)") - lines = append(lines, title) - sel := m.uncommitSelectedPaths() - total := len(sel) - elig := 0 - if m.ucEligible != nil { - for _, p := range sel { if m.ucEligible[p] { elig++ } } - } - inelig := total - elig - lines = append(lines, fmt.Sprintf("Selected: %d Eligible to uncommit: %d Ignored: %d", total, elig, inelig)) - if m.ucEligible == nil { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("(resolving eligibility…)")) - } - if m.uncommitting { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Uncommitting…")) - } - if m.uncommitErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.uncommitErr) - } - } - return lines -} - -func (m model) handleUncommitKeys(key tea.KeyMsg) (tea.Model, tea.Cmd) { - switch m.ucStep { - case 0: - switch key.String() { - case "esc": - m.showUncommit = false - return m, m.recalcViewport() - case "enter": - if len(m.ucFiles) == 0 { - return m, nil - } - m.ucStep = 1 - m.uncommitErr = "" - m.uncommitDone = false - m.uncommitting = false - return m, m.recalcViewport() - case "j", "down": - if len(m.ucFiles) > 0 && m.ucIndex < len(m.ucFiles)-1 { m.ucIndex++ } - return m, nil - case "k", "up": - if m.ucIndex > 0 { m.ucIndex-- } - return m, nil - case " ": - if len(m.ucFiles) > 0 { - p := m.ucFiles[m.ucIndex].Path - m.ucSelected[p] = !m.ucSelected[p] - } - return m, nil - case "a": - all := true - for _, f := range m.ucFiles { if !m.ucSelected[f.Path] { all = false; break } } - set := !all - for _, f := range m.ucFiles { m.ucSelected[f.Path] = set } - return m, nil - } - case 1: - switch key.String() { - case "esc": - if !m.uncommitting { - m.showUncommit = false - return m, m.recalcViewport() - } - return m, nil - case "b": - if !m.uncommitting && !m.uncommitDone { - m.ucStep = 0 - return m, m.recalcViewport() - } - return m, nil - case "y", "enter": - if !m.uncommitting && !m.uncommitDone { - sel := m.uncommitSelectedPaths() - if len(sel) == 0 { - m.uncommitErr = "no files selected" - return m, nil - } - m.uncommitErr = "" - m.uncommitting = true - return m, runUncommit(m.repoRoot, sel) - } - return m, nil - } - } - return m, nil -} - -func (m model) uncommitSelectedPaths() []string { - var out []string - for _, f := range m.ucFiles { - if m.ucSelected[f.Path] { out = append(out, f.Path) } - } - return out -} - -func runUncommit(repoRoot string, paths []string) tea.Cmd { - return func() tea.Msg { - // Filter to eligible paths (present in last commit) - eligible, err := gitx.FilesInLastCommit(repoRoot) - if err != nil { - return uncommitResultMsg{err: err} - } - eligSet := map[string]bool{} - for _, p := range eligible { eligSet[p] = true } - var toUncommit []string - for _, p := range paths { if eligSet[p] { toUncommit = append(toUncommit, p) } } - if len(toUncommit) == 0 { - return uncommitResultMsg{err: fmt.Errorf("no selected files are in the last commit")} - } - if err := gitx.UncommitFiles(repoRoot, toUncommit); err != nil { - return uncommitResultMsg{err: err} - } - return uncommitResultMsg{err: nil} - } -} -func (m model) rightBodyLinesAll(width int) []string { - lines := make([]string, 0, 1024) - if len(m.files) == 0 { - return lines - } - if m.files[m.selected].Binary { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("(Binary file; no text diff)")) - return lines - } - if m.rows == nil { - lines = append(lines, "Loading diff…") - return lines - } - if m.sideBySide { - colsW := (width - 1) / 2 - if colsW < 10 { - colsW = 10 - } - mid := m.theme.DividerText("│") - for _, r := range m.rows { - switch r.Kind { - case diffview.RowHunk: - // subtle separator fills full width - lines = append(lines, lipgloss.NewStyle().Faint(true).Render(strings.Repeat("·", width))) - case diffview.RowMeta: - // skip - default: - if m.wrapLines { - lLines := m.renderSideCellWrap(r, "left", colsW) - rLines := m.renderSideCellWrap(r, "right", colsW) - n := len(lLines) - if len(rLines) > n { n = len(rLines) } - for i := 0; i < n; i++ { - var l, rr string - if i < len(lLines) { l = lLines[i] } else { l = strings.Repeat(" ", colsW) } - if i < len(rLines) { rr = rLines[i] } else { rr = strings.Repeat(" ", colsW) } - lines = append(lines, l+mid+rr) - } - } else { - l := m.renderSideCell(r, "left", colsW) - rr := m.renderSideCell(r, "right", colsW) - l = padExact(l, colsW) - rr = padExact(rr, colsW) - lines = append(lines, l+mid+rr) - } - } - } - } else { - for _, r := range m.rows { - switch r.Kind { - case diffview.RowHunk: - lines = append(lines, lipgloss.NewStyle().Faint(true).Render(strings.Repeat("·", width))) - case diffview.RowContext: - base := " "+r.Left - if m.wrapLines { - wrapped := ansi.Hardwrap(base, width, false) - lines = append(lines, strings.Split(wrapped, "\n")...) - } else { - line := base - if m.rightXOffset > 0 { - line = sliceANSI(line, m.rightXOffset, width) - line = padExact(line, width) - } - lines = append(lines, line) - } - case diffview.RowAdd: - base := m.theme.AddText("+ "+r.Right) - if m.wrapLines { - wrapped := ansi.Hardwrap(base, width, false) - lines = append(lines, strings.Split(wrapped, "\n")...) - } else { - line := base - if m.rightXOffset > 0 { - line = sliceANSI(line, m.rightXOffset, width) - line = padExact(line, width) - } - lines = append(lines, line) - } - case diffview.RowDel: - base := m.theme.DelText("- "+r.Left) - if m.wrapLines { - wrapped := ansi.Hardwrap(base, width, false) - lines = append(lines, strings.Split(wrapped, "\n")...) - } else { - line := base - if m.rightXOffset > 0 { - line = sliceANSI(line, m.rightXOffset, width) - line = padExact(line, width) - } - lines = append(lines, line) - } - case diffview.RowReplace: - base1 := m.theme.DelText("- "+r.Left) - base2 := m.theme.AddText("+ "+r.Right) - if m.wrapLines { - wrapped1 := strings.Split(ansi.Hardwrap(base1, width, false), "\n") - wrapped2 := strings.Split(ansi.Hardwrap(base2, width, false), "\n") - lines = append(lines, wrapped1...) - lines = append(lines, wrapped2...) - } else { - line1 := base1 - if m.rightXOffset > 0 { - line1 = sliceANSI(line1, m.rightXOffset, width) - line1 = padExact(line1, width) - } - lines = append(lines, line1) - line2 := base2 - if m.rightXOffset > 0 { - line2 = sliceANSI(line2, m.rightXOffset, width) - line2 = padExact(line2, width) - } - lines = append(lines, line2) - } - } - } - } - return lines -} - -func (m *model) openSearch() { - ti := textinput.New() - ti.Placeholder = "Search diff" - ti.Prompt = "/ " - ti.CharLimit = 0 - ti.SetValue(m.searchQuery) - ti.CursorEnd() - ti.Focus() - m.searchInput = ti - m.searchActive = true -} - -func (m *model) closeSearch() { - if m.searchActive { - m.searchInput.Blur() - } - m.searchActive = false -} - -func (m model) handleSearchKeys(key tea.KeyMsg) (tea.Model, tea.Cmd) { - - if !m.searchActive { - return m, nil - } - - m.searchInput.Focus() - - switch key.String() { - case "esc": - m.closeSearch() - return m, m.recalcViewport() - case "ctrl+c": - return m, tea.Quit - } - + vp := p.state.DiffView.Viewport() + offset := line - // Navigation that does NOT leave input mode - switch key.Type { - case tea.KeyEnter: - return m, (&m).advanceSearch(1) - case tea.KeyDown: - return m, (&m).advanceSearch(1) - case tea.KeyUp: - return m, (&m).advanceSearch(-1) - } - - - // Fallback: always let input handle it - var cmd tea.Cmd - m.searchInput, cmd = m.searchInput.Update(key) - m.searchQuery = m.searchInput.Value() - m.recomputeSearchMatches(true) - m.refreshSearchHighlights() - - if scrollCmd := m.scrollToCurrentMatch(); scrollCmd != nil { - return m, tea.Batch(cmd, scrollCmd) - } - - return m, cmd -} - -func (m *model) advanceSearch(delta int) tea.Cmd { - if len(m.searchMatches) == 0 { - return nil - } - span := len(m.searchMatches) - m.searchIndex = (m.searchIndex + delta) % span - if m.searchIndex < 0 { - m.searchIndex += span - } - m.refreshSearchHighlights() - return m.scrollToCurrentMatch() -} - -func (m *model) recomputeSearchMatches(resetIndex bool) { - if m.searchQuery == "" { - m.searchMatches = nil - if resetIndex { - m.searchIndex = 0 - } - return - } - lowerQuery := strings.ToLower(m.searchQuery) - matches := make([]int, 0, len(m.rightContent)) - for i, line := range m.rightContent { - plain := strings.ToLower(ansi.Strip(line)) - if strings.Contains(plain, lowerQuery) { - matches = append(matches, i) - } - } - m.searchMatches = matches - if len(matches) == 0 { - if resetIndex { - m.searchIndex = 0 - } - return - } - if resetIndex || m.searchIndex >= len(matches) { - m.searchIndex = 0 - } -} - -func (m *model) refreshSearchHighlights() { - if len(m.rightContent) == 0 { - m.rightVP.SetContent("") - return - } - lines := m.rightContent - if m.searchQuery != "" { - lines = m.highlightSearchLines(lines) - } - m.rightVP.SetContent(strings.Join(lines, "\n")) -} - -type runeRange struct { - start int - end int -} - -func (m model) highlightSearchLines(lines []string) []string { - if len(lines) == 0 || m.searchQuery == "" { - return lines - } - lineHasMatch := make(map[int]struct{}, len(m.searchMatches)) - for _, idx := range m.searchMatches { - if idx >= 0 && idx < len(lines) { - lineHasMatch[idx] = struct{}{} - } - } - currentLine := -1 - if len(m.searchMatches) > 0 && m.searchIndex >= 0 && m.searchIndex < len(m.searchMatches) { - currentLine = m.searchMatches[m.searchIndex] - } - result := make([]string, len(lines)) - for i, line := range lines { - if _, ok := lineHasMatch[i]; !ok { - result[i] = line - continue - } - ranges := findQueryRanges(line, m.searchQuery) - if len(ranges) == 0 { - result[i] = line - continue - } - result[i] = applyANSIRangeHighlight(line, ranges, i == currentLine) - } - return result -} - -func findQueryRanges(line, query string) []runeRange { - plain := ansi.Strip(line) - if plain == "" || query == "" { - return nil - } - lowerRunes := []rune(strings.ToLower(plain)) - queryRunes := []rune(strings.ToLower(query)) - if len(queryRunes) == 0 || len(queryRunes) > len(lowerRunes) { - return nil - } - ranges := make([]runeRange, 0, 4) - for i := 0; i <= len(lowerRunes)-len(queryRunes); i++ { - match := true - for j := 0; j < len(queryRunes); j++ { - if lowerRunes[i+j] != queryRunes[j] { - match = false - break - } - } - if match { - ranges = append(ranges, runeRange{start: i, end: i + len(queryRunes)}) - } - } - if len(ranges) == 0 { - return nil - } - return mergeRuneRanges(ranges) -} - -func mergeRuneRanges(ranges []runeRange) []runeRange { - if len(ranges) <= 1 { - return ranges - } - sort.Slice(ranges, func(i, j int) bool { - if ranges[i].start == ranges[j].start { - return ranges[i].end < ranges[j].end - } - return ranges[i].start < ranges[j].start - }) - merged := make([]runeRange, 0, len(ranges)) - cur := ranges[0] - for _, r := range ranges[1:] { - if r.start <= cur.end { // overlap or adjacent - if r.end > cur.end { - cur.end = r.end - } - continue - } - merged = append(merged, cur) - cur = r - } - merged = append(merged, cur) - return merged -} - -func applyANSIRangeHighlight(line string, ranges []runeRange, isCurrent bool) string { - if len(ranges) == 0 { - return line - } - startSeq := searchMatchStartSeq - if isCurrent { - startSeq = searchCurrentMatchStartSeq - } - endSeq := searchMatchEndSeq - var b strings.Builder - matchIdx := 0 - inMatch := false - runePos := 0 - for i := 0; i < len(line); { - if line[i] == 0x1b { - next := consumeANSIEscape(line, i) - b.WriteString(line[i:next]) - i = next - continue - } - r, size := utf8.DecodeRuneInString(line[i:]) - _ = r // rune value unused beyond size and counting - if inMatch { - for matchIdx < len(ranges) && runePos >= ranges[matchIdx].end { - b.WriteString(endSeq) - inMatch = false - matchIdx++ - } - } - for !inMatch && matchIdx < len(ranges) && runePos >= ranges[matchIdx].end { - matchIdx++ - } - if !inMatch && matchIdx < len(ranges) && runePos == ranges[matchIdx].start { - b.WriteString(startSeq) - inMatch = true - } - b.WriteString(line[i : i+size]) - runePos++ - i += size - } - if inMatch { - b.WriteString(endSeq) - } - return b.String() -} - -func consumeANSIEscape(s string, i int) int { - if i >= len(s) || s[i] != 0x1b { - if i+1 > len(s) { - return len(s) - } - return i + 1 - } - j := i + 1 - if j >= len(s) { - return j - } - switch s[j] { - case '[': // CSI - j++ - for j < len(s) { - c := s[j] - if c >= 0x40 && c <= 0x7e { - j++ - break - } - j++ - } - case ']': // OSC - j++ - for j < len(s) && s[j] != 0x07 { - j++ - } - if j < len(s) { - j++ - } - case 'P', 'X', '^', '_': // DCS, SOS, PM, APC - j++ - for j < len(s) { - if s[j] == 0x1b { - j++ - break - } - j++ - } - default: - j++ - } - if j <= i { - return i + 1 - } - return j -} - -func (m *model) scrollToCurrentMatch() tea.Cmd { - if len(m.searchMatches) == 0 { - return nil - } - target := m.searchMatches[m.searchIndex] - if target < 0 { - target = 0 - } - offset := target - if m.rightVP.Height > 0 { - offset = target - m.rightVP.Height/2 - if offset < 0 { - offset = 0 - } - } - maxOffset := len(m.rightContent) - m.rightVP.Height - if maxOffset < 0 { - maxOffset = 0 - } - if offset > maxOffset { - offset = maxOffset - } - m.rightVP.SetYOffset(offset) - return nil -} - -func (m model) searchOverlayLines(width int) []string { - if !m.searchActive || width <= 0 { - return nil - } - lines := make([]string, 0, 3) - lines = append(lines, m.theme.DividerText(strings.Repeat("?", width))) - lines = append(lines, padToWidth(m.searchInput.View(), width)) - status := "Type to search (esc: close, enter: finish typing)" - if m.searchQuery != "" { - if len(m.searchMatches) == 0 { - status = "No matches (esc: close)" - } else { - status = fmt.Sprintf("Match %d of %d (Enter/↓: next, ↑: prev, Esc: close)", m.searchIndex+1, len(m.searchMatches)) - } - } - lines = append(lines, padToWidth(lipgloss.NewStyle().Faint(true).Render(status), width)) - return lines -} - -// --- Commit wizard --- - -type lastCommitMsg struct{ - summary string - err error -} - -func loadLastCommit(repoRoot string) tea.Cmd { - return func() tea.Msg { - s, err := gitx.LastCommitSummary(repoRoot) - return lastCommitMsg{summary: s, err: err} - } -} - -type currentBranchMsg struct{ - name string - err error -} - -func loadCurrentBranch(repoRoot string) tea.Cmd { - return func() tea.Msg { - name, err := gitx.CurrentBranch(repoRoot) - return currentBranchMsg{name: name, err: err} - } -} - -type prefsMsg struct{ - p prefs.Prefs - err error -} - -func loadPrefs(repoRoot string) tea.Cmd { - return func() tea.Msg { - // Loading never errors for now; returns zero-vals on missing keys - p := prefs.Load(repoRoot) - return prefsMsg{p: p, err: nil} - } -} - -func (m *model) openCommitWizard() { - m.showCommit = true - m.commitStep = 0 - // snapshot files list - m.cwFiles = append([]gitx.FileChange(nil), m.files...) - m.cwSelected = map[string]bool{} - for _, f := range m.cwFiles { - m.cwSelected[f.Path] = true // default include all - } - m.cwIndex = 0 - m.commitDone = false - m.commitErr = "" - m.committing = false - m.cwInput = textinput.Model{} - m.cwInput.Placeholder = "Commit message" - m.cwInput.CharLimit = 0 - m.cwInputActive = false -} - -// handle commit wizard keys -func (m model) handleCommitKeys(key tea.KeyMsg) (tea.Model, tea.Cmd) { - switch m.commitStep { - case 0: // select files - switch key.String() { - case "esc": - m.showCommit = false - return m, m.recalcViewport() - case "enter": - m.commitStep = 1 - // focus text input - ti := textinput.New() - ti.Placeholder = "Commit message" - ti.Prompt = "> " - ti.Focus() - m.cwInput = ti - return m, m.recalcViewport() - case "j", "down": - if len(m.cwFiles) > 0 && m.cwIndex < len(m.cwFiles)-1 { - m.cwIndex++ - } - return m, nil - case "k", "up": - if m.cwIndex > 0 { - m.cwIndex-- - } - return m, nil - case " ": - if len(m.cwFiles) > 0 { - p := m.cwFiles[m.cwIndex].Path - m.cwSelected[p] = !m.cwSelected[p] - } - return m, nil - case "a": - all := true - for _, f := range m.cwFiles { - if !m.cwSelected[f.Path] { all = false; break } - } - // toggle all - set := !all - for _, f := range m.cwFiles { - m.cwSelected[f.Path] = set - } - return m, nil - } - case 1: // message (input/action modes) - switch key.String() { - case "esc": - if m.cwInputActive { - // leave input mode - m.cwInputActive = false - return m, m.recalcViewport() - } - m.showCommit = false - return m, m.recalcViewport() - case "i": - if !m.cwInputActive { - m.cwInputActive = true - m.cwInput.Focus() - return m, m.recalcViewport() - } - // if already active, treat as input - case "b": - if !m.cwInputActive { - m.commitStep = 0 - return m, m.recalcViewport() - } - // in input mode, 'b' is literal - case "enter": - if !m.cwInputActive { - m.commitStep = 2 - m.commitDone = false - m.commitErr = "" - m.committing = false - return m, m.recalcViewport() - } - // in input mode, forward to input - } - // Default: if input mode, forward to text input; else ignore - if m.cwInputActive { - var cmd tea.Cmd - m.cwInput, cmd = m.cwInput.Update(key) - return m, cmd - } - return m, nil - case 2: // confirm/progress - switch key.String() { - case "esc": - // can't cancel mid-commit, but if not running: exit - if !m.committing { - m.showCommit = false - return m, m.recalcViewport() - } - return m, nil - case "b": - if !m.committing && !m.commitDone { - m.commitStep = 1 - return m, m.recalcViewport() - } - return m, nil - case "y", "enter": - if !m.committing && !m.commitDone { - sel := m.selectedPaths() - if len(sel) == 0 { - m.commitErr = "no files selected" - return m, nil - } - if strings.TrimSpace(m.cwInput.Value()) == "" { - m.commitErr = "empty commit message" - return m, nil - } - m.commitErr = "" - m.committing = true - return m, runCommit(m.repoRoot, sel, m.cwInput.Value()) - } - return m, nil + if vp.Height > 0 { + offset = line - vp.Height/2 + if offset < 0 { + offset = 0 } } - return m, nil -} - -func (m model) selectedPaths() []string { - var out []string - for _, f := range m.cwFiles { - if m.cwSelected[f.Path] { - out = append(out, f.Path) - } - } - return out -} - -type commitProgressMsg struct{} -type commitResultMsg struct{ err error } - -func runCommit(repoRoot string, paths []string, message string) tea.Cmd { - return func() tea.Msg { - // Stage selected files - if err := gitx.StageFiles(repoRoot, paths); err != nil { - return commitResultMsg{err: err} - } - // Commit - if err := gitx.Commit(repoRoot, message); err != nil { - return commitResultMsg{err: err} - } - // Push - if err := gitx.Push(repoRoot); err != nil { - return commitResultMsg{err: err} - } - return commitResultMsg{err: nil} - } -} - - - -func (m model) colorizeLeft(r diffview.Row) string { - switch r.Kind { - case diffview.RowContext: - return r.Left - case diffview.RowDel: - return m.theme.DelText(r.Left) - case diffview.RowReplace: - return m.theme.DelText(r.Left) - case diffview.RowAdd: - return "" - default: - return r.Left - } -} - -func (m model) colorizeRight(r diffview.Row) string { - switch r.Kind { - case diffview.RowContext: - return r.Right - case diffview.RowAdd: - return m.theme.AddText(r.Right) - case diffview.RowReplace: - return m.theme.AddText(r.Right) - case diffview.RowDel: - return "" - default: - return r.Right - } -} - -// renderSideCell renders a left or right cell with a colored marker and padding. -// side is "left" or "right". width is the total cell width. -func (m model) renderSideCell(r diffview.Row, side string, width int) string { - marker := " " - content := "" - switch side { - case "left": - content = r.Left - switch r.Kind { - case diffview.RowContext: - marker = " " - case diffview.RowDel, diffview.RowReplace: - marker = m.theme.DelText("-") - content = m.theme.DelText(content) - case diffview.RowAdd: - marker = " " - content = "" - } - case "right": - content = r.Right - switch r.Kind { - case diffview.RowContext: - marker = " " - case diffview.RowAdd, diffview.RowReplace: - marker = m.theme.AddText("+") - content = m.theme.AddText(content) - case diffview.RowDel: - marker = " " - content = "" - } + + maxOffset := len(p.state.DiffView.Content()) - vp.Height + if maxOffset < 0 { + maxOffset = 0 } - // Reserve 2 cols: marker + space - if width <= 2 { - return ansi.Truncate(marker+" ", width, "") + if offset > maxOffset { + offset = maxOffset } - bodyW := width - 2 - - clipped := sliceANSI(content, m.rightXOffset, bodyW) - return marker + " " + clipped + vp.SetYOffset(offset) } -// renderSideCellWrap renders a cell like renderSideCell but wraps the content -// to the given width and returns multiple visual lines. The marker is repeated -// on each wrapped line. -func (m model) renderSideCellWrap(r diffview.Row, side string, width int) []string { - marker := " " - content := "" - switch side { - case "left": - content = r.Left - switch r.Kind { - case diffview.RowContext: - marker = " " - case diffview.RowDel, diffview.RowReplace: - marker = m.theme.DelText("-") - content = m.theme.DelText(content) - case diffview.RowAdd: - marker = " " - content = "" - } - case "right": - content = r.Right - switch r.Kind { - case diffview.RowContext: - marker = " " - case diffview.RowAdd, diffview.RowReplace: - marker = m.theme.AddText("+") - content = m.theme.AddText(content) - case diffview.RowDel: - marker = " " - content = "" - } - } - // Reserve 2 cols for marker and a space - if width <= 2 { - return []string{ansi.Truncate(marker+" ", width, "")} - } - bodyW := width - 2 - wrapped := ansi.Hardwrap(content, bodyW, false) - parts := strings.Split(wrapped, "\n") - out := make([]string, 0, len(parts)) - for _, p := range parts { - out = append(out, marker+" "+padExact(p, bodyW)) - } - if len(out) == 0 { - out = append(out, marker+" "+strings.Repeat(" ", bodyW)) +func splitLines(s string, maxLines int) []string { + if s == "" { + return nil } - return out -} - -// sliceANSI returns a substring of s starting at visual column `start` with at most `w` columns, preserving ANSI escapes. -func sliceANSI(s string, start, w int) string { - if start <= 0 { - return ansi.Truncate(s, w, "") + lines := strings.Split(s, "\n") + if len(lines) > maxLines { + return lines[:maxLines] } - // First keep only the left portion up to start+w, then drop the first `start` columns. - head := ansi.Truncate(s, start+w, "") - return ansi.TruncateLeft(head, start, "") -} - -// padExact pads s with spaces to exactly width w (ANSI-aware width). -func padExact(s string, w int) string { - sw := lipgloss.Width(s) - if sw >= w { return s } - return s + strings.Repeat(" ", w-sw) -} - -func isMovementKey(key string) bool{ - return key == "j" || key == "k" -} - -func isNumericKey(key string) bool{ - return key <= "9" && key >= "0" + return lines } diff --git a/internal/tui/program_test.go b/internal/tui/program_test.go index 1f706bd..279ebbb 100644 --- a/internal/tui/program_test.go +++ b/internal/tui/program_test.go @@ -1,73 +1,97 @@ package tui import ( - "strings" - "testing" - "time" + "strings" + "testing" + "time" - "github.com/charmbracelet/x/ansi" - "github.com/interpretive-systems/diffium/internal/diffview" - "github.com/interpretive-systems/diffium/internal/gitx" + "github.com/charmbracelet/x/ansi" + "github.com/interpretive-systems/diffium/internal/diffview" + "github.com/interpretive-systems/diffium/internal/gitx" + "github.com/interpretive-systems/diffium/internal/theme" + "github.com/interpretive-systems/diffium/internal/tui/components" + "github.com/interpretive-systems/diffium/internal/tui/search" ) -func baseModelForTest() model { - m := model{} - m.repoRoot = "." - m.theme = defaultTheme() - m.files = []gitx.FileChange{ - {Path: "file1.txt", Unstaged: true}, - {Path: "file2.txt", Staged: true}, - } - m.selected = 0 - m.width = 80 - m.height = 16 - m.leftWidth = 24 - m.lastRefresh = time.Date(2024, 10, 1, 12, 34, 56, 0, time.UTC) - m.showHelp = false - m.showCommit = false - return m +func baseModelForTest() Program { + filesChanged := []gitx.FileChange{ + {Path: "file1.txt", Unstaged: true}, + {Path: "file2.txt", Staged: true}, + } + + sb := components.NewStatusBar() + curTime, _ := time.Parse(time.TimeOnly, "12:34:56") + sb.SetLastRefresh(curTime) + + fl := components.NewFileList() + fl.SetFiles(filesChanged) + + m := Program{ + state: &State{ + Width: 80, + Height: 16, + RepoRoot: ".", + SearchEngine: search.New(), + DiffView: components.NewDiffView(theme.LoadThemeFromRepo(".")), + Theme: theme.DefaultTheme(), + Files: filesChanged, + FileList: fl, + StatusBar: sb, + LastRefresh: time.Date(2024, 10, 1, 12, 34, 56, 0, time.UTC), + ShowHelp: false, + }, + layout: &Layout{ + width: 80, + height: 16, + leftWidth: 24, + }, + keyHandler: &KeyHandler{}, + } + + return m } func sampleUnified() string { - return "@@ -1,3 +1,3 @@\n line1\n-line2\n+line2 changed\n line3\n" + return "@@ -1,3 +1,3 @@\n line1\n-line2\n+line2 changed\n line3\n" } func TestView_SideBySide_Render(t *testing.T) { - m := baseModelForTest() - m.sideBySide = true - m.rows = diffview.BuildRowsFromUnified(sampleUnified()) - (&m).recalcViewport() - out := m.View() - plain := ansi.Strip(out) + m := baseModelForTest() + m.state.DiffView.SetSideBySide(true) + m.state.DiffView.SetRows(diffview.BuildRowsFromUnified(sampleUnified())) + m.recalcViewport() - // Basic snapshot-like assertions - if !strings.HasPrefix(plain, "Changes | file1.txt (M)") { - t.Fatalf("unexpected header: %q", strings.SplitN(plain, "\n", 2)[0]) - } - if !strings.Contains(plain, "│") { - t.Fatalf("expected vertical divider in view") - } - if !strings.Contains(plain, "line2 changed") { - t.Fatalf("expected changed text in right pane") - } - if !strings.Contains(plain, "refreshed: 12:34:56") { - t.Fatalf("expected bottom bar timestamp, got: %q", plain) - } + out := m.View() + plain := ansi.Strip(out) + + // Snapshot-like assertions + if !strings.HasPrefix(plain, "Changes | file1.txt (M)") { + t.Fatalf("unexpected header: %q", strings.SplitN(plain, "\n", 2)[0]) + } + if !strings.Contains(plain, "│") { + t.Fatalf("expected vertical divider in view") + } + if !strings.Contains(plain, "line2 changed") { + t.Fatalf("expected changed text in right pane") + } + if !strings.Contains(plain, "refreshed: 12:34:56") { + t.Fatalf("expected bottom bar timestamp, got: %q", plain) + } } func TestView_Inline_Render(t *testing.T) { - m := baseModelForTest() - m.sideBySide = false - m.rows = diffview.BuildRowsFromUnified(sampleUnified()) - (&m).recalcViewport() - out := m.View() - plain := ansi.Strip(out) + m := baseModelForTest() + m.state.DiffView.SetSideBySide(false) + m.state.DiffView.SetRows(diffview.BuildRowsFromUnified(sampleUnified())) + m.recalcViewport() - if !strings.Contains(plain, "+ line2 changed") { - t.Fatalf("expected inline added line, got: %q", plain) - } - if !strings.Contains(plain, "- line2") { - t.Fatalf("expected inline deleted line, got: %q", plain) - } -} + out := m.View() + plain := ansi.Strip(out) + if !strings.Contains(plain, "+ line2 changed") { + t.Fatalf("expected inline added line, got: %q", plain) + } + if !strings.Contains(plain, "- line2") { + t.Fatalf("expected inline deleted line, got: %q", plain) + } +} diff --git a/internal/tui/search/engine.go b/internal/tui/search/engine.go new file mode 100644 index 0000000..5725dc0 --- /dev/null +++ b/internal/tui/search/engine.go @@ -0,0 +1,159 @@ +package search + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/interpretive-systems/diffium/internal/tui/ansi" +) + +// Engine manages search state and operations. +type Engine struct { + query string + matches []int // Line indices with matches + index int // Current match index + input textinput.Model + active bool + highlighter *Highlighter + content []string // Current content being searched +} + +// New creates a new search engine. +func New() *Engine { + ti := textinput.New() + ti.Placeholder = "Search diff" + ti.Prompt = "/ " + ti.CharLimit = 0 + + return &Engine{ + highlighter: NewHighlighter(), + input: ti, + } +} + +// Activate opens the search input. +func (e *Engine) Activate() { + e.active = true + e.input.Focus() +} + +// Deactivate closes search. +func (e *Engine) Deactivate() { + e.active = false + e.input.Blur() +} + +// IsActive returns whether search is active. +func (e *Engine) IsActive() bool { + return e.active +} + +// HandleKey processes key input for search. +func (e *Engine) HandleKey(msg tea.KeyMsg) (bool, tea.Cmd) { + switch msg.String() { + case "esc": + e.Deactivate() + return true, nil + case "enter", "down": + e.Next() + return true, nil + case "up": + e.Previous() + return true, nil + } + + var cmd tea.Cmd + e.input, cmd = e.input.Update(msg) + e.query = e.input.Value() + e.recomputeMatches() + + return true, cmd +} + +// SetContent updates the content to search through. +func (e *Engine) SetContent(lines []string) { + e.content = lines + e.recomputeMatches() +} + +// Query returns the current search query. +func (e *Engine) Query() string { + return e.query +} + +// recomputeMatches finds all matching lines. +func (e *Engine) recomputeMatches() { + if e.query == "" { + e.matches = nil + e.index = 0 + return + } + + lowerQuery := strings.ToLower(e.query) + matches := make([]int, 0, len(e.content)) + + for i, line := range e.content { + plain := strings.ToLower(ansi.Strip(line)) + if strings.Contains(plain, lowerQuery) { + matches = append(matches, i) + } + } + + e.matches = matches + if len(matches) > 0 && e.index >= len(matches) { + e.index = 0 + } +} + +// Next advances to the next match. +func (e *Engine) Next() { + if len(e.matches) == 0 { + return + } + e.index = (e.index + 1) % len(e.matches) +} + +// Previous moves to the previous match. +func (e *Engine) Previous() { + if len(e.matches) == 0 { + return + } + e.index = (e.index - 1 + len(e.matches)) % len(e.matches) +} + +// CurrentMatchLine returns the line index of the current match. +func (e *Engine) CurrentMatchLine() int { + if len(e.matches) == 0 { + return -1 + } + return e.matches[e.index] +} + +// HighlightedContent returns content with search highlights applied. +func (e *Engine) HighlightedContent() []string { + if e.query == "" || len(e.content) == 0 { + return e.content + } + + currentLine := e.CurrentMatchLine() + return e.highlighter.HighlightLines(e.content, e.query, e.matches, currentLine) +} + +// MatchCount returns the number of matches. +func (e *Engine) MatchCount() int { + return len(e.matches) +} + +// CurrentMatchIndex returns the current match index (1-based). +func (e *Engine) CurrentMatchIndex() int { + if len(e.matches) == 0 { + return 0 + } + return e.index + 1 +} + +// InputView returns the text input view. +func (e *Engine) InputView() string { + return e.input.View() +} diff --git a/internal/tui/search/highlight.go b/internal/tui/search/highlight.go new file mode 100644 index 0000000..e22f0f0 --- /dev/null +++ b/internal/tui/search/highlight.go @@ -0,0 +1,191 @@ +package search + +import ( + "sort" + "strings" + "unicode/utf8" + + "github.com/interpretive-systems/diffium/internal/tui/ansi" +) + +const ( + // Normal match: black on bright white + matchStartSeq = "\x1b[30;107m" + // Current match: black on yellow + currentMatchStartSeq = "\x1b[30;43m" + // Reset all styles + matchEndSeq = "\x1b[0m" +) + +// Highlighter applies search highlights to text. +type Highlighter struct{} + +// NewHighlighter creates a new highlighter. +func NewHighlighter() *Highlighter { + return &Highlighter{} +} + +// HighlightLines applies highlights to matching lines. +func (h *Highlighter) HighlightLines(lines []string, query string, matches []int, currentLine int) []string { + if len(lines) == 0 || query == "" { + return lines + } + + // Build set of matching lines + matchSet := make(map[int]struct{}, len(matches)) + for _, idx := range matches { + if idx >= 0 && idx < len(lines) { + matchSet[idx] = struct{}{} + } + } + + result := make([]string, len(lines)) + for i, line := range lines { + if _, hasMatch := matchSet[i]; !hasMatch { + result[i] = line + continue + } + + ranges := findQueryRanges(line, query) + if len(ranges) == 0 { + result[i] = line + continue + } + + result[i] = h.applyRangeHighlight(line, ranges, i == currentLine) + } + + return result +} + +// RuneRange represents a range of runes in a string. +type RuneRange struct { + Start int + End int +} + +// findQueryRanges finds all occurrences of query in line (case-insensitive). +func findQueryRanges(line, query string) []RuneRange { + plain := ansi.Strip(line) + if plain == "" || query == "" { + return nil + } + + lowerRunes := []rune(strings.ToLower(plain)) + queryRunes := []rune(strings.ToLower(query)) + + if len(queryRunes) == 0 || len(queryRunes) > len(lowerRunes) { + return nil + } + + ranges := make([]RuneRange, 0, 4) + for i := 0; i <= len(lowerRunes)-len(queryRunes); i++ { + match := true + for j := 0; j < len(queryRunes); j++ { + if lowerRunes[i+j] != queryRunes[j] { + match = false + break + } + } + if match { + ranges = append(ranges, RuneRange{Start: i, End: i + len(queryRunes)}) + } + } + + if len(ranges) == 0 { + return nil + } + + return mergeRanges(ranges) +} + +// mergeRanges merges overlapping or adjacent ranges. +func mergeRanges(ranges []RuneRange) []RuneRange { + if len(ranges) <= 1 { + return ranges + } + + sort.Slice(ranges, func(i, j int) bool { + if ranges[i].Start == ranges[j].Start { + return ranges[i].End < ranges[j].End + } + return ranges[i].Start < ranges[j].Start + }) + + merged := make([]RuneRange, 0, len(ranges)) + cur := ranges[0] + + for _, r := range ranges[1:] { + if r.Start <= cur.End { + if r.End > cur.End { + cur.End = r.End + } + continue + } + merged = append(merged, cur) + cur = r + } + merged = append(merged, cur) + + return merged +} + +// applyRangeHighlight applies ANSI highlight codes to ranges in the line. +func (h *Highlighter) applyRangeHighlight(line string, ranges []RuneRange, isCurrent bool) string { + if len(ranges) == 0 { + return line + } + + startSeq := matchStartSeq + if isCurrent { + startSeq = currentMatchStartSeq + } + + var b strings.Builder + matchIdx := 0 + inMatch := false + runePos := 0 + + for i := 0; i < len(line); { + // Handle ANSI escape sequences + if line[i] == 0x1b { + next := ansi.ConsumeEscape(line, i) + b.WriteString(line[i:next]) + i = next + continue + } + + r, size := utf8.DecodeRuneInString(line[i:]) + _ = r + + // Close match if we've passed the end + if inMatch { + for matchIdx < len(ranges) && runePos >= ranges[matchIdx].End { + b.WriteString(matchEndSeq) + inMatch = false + matchIdx++ + } + } + + // Skip past completed ranges + for !inMatch && matchIdx < len(ranges) && runePos >= ranges[matchIdx].End { + matchIdx++ + } + + // Start match if we're at the start of a range + if !inMatch && matchIdx < len(ranges) && runePos == ranges[matchIdx].Start { + b.WriteString(startSeq) + inMatch = true + } + + b.WriteString(line[i : i+size]) + runePos++ + i += size + } + + if inMatch { + b.WriteString(matchEndSeq) + } + + return b.String() +} diff --git a/internal/tui/search/overlay.go b/internal/tui/search/overlay.go new file mode 100644 index 0000000..267f81e --- /dev/null +++ b/internal/tui/search/overlay.go @@ -0,0 +1,47 @@ +package search + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/interpretive-systems/diffium/internal/tui/ansi" +) + +// RenderOverlay renders the search overlay UI. +func (e *Engine) RenderOverlay(width int, dividerColor string) []string { + if !e.active || width <= 0 { + return nil + } + + lines := make([]string, 0, 3) + + // Divider + divider := lipgloss.NewStyle(). + Foreground(lipgloss.Color(dividerColor)). + Render(strings.Repeat("?", width)) + lines = append(lines, divider) + + // Input + inputView := e.InputView() + lines = append(lines, ansi.PadExact(inputView, width)) + + // Status + status := "Type to search (esc: close, enter: finish typing)" + if e.query != "" { + if len(e.matches) == 0 { + status = "No matches (esc: close)" + } else { + status = fmt.Sprintf( + "Match %d of %d (Enter/↓: next, ↑: prev, Esc: close)", + e.CurrentMatchIndex(), + e.MatchCount(), + ) + } + } + + statusStyled := lipgloss.NewStyle().Faint(true).Render(status) + lines = append(lines, ansi.PadExact(statusStyled, width)) + + return lines +} diff --git a/internal/tui/wizards/branch.go b/internal/tui/wizards/branch.go new file mode 100644 index 0000000..2bd5679 --- /dev/null +++ b/internal/tui/wizards/branch.go @@ -0,0 +1,398 @@ +package wizards + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/interpretive-systems/diffium/internal/gitx" +) + +// BranchListMsg contains the list of branches. +type BranchListMsg struct { + Names []string + Current string + Err error +} + +// BranchResultMsg is sent when branch operation completes. +type BranchResultMsg struct { + Err error +} + +// BranchWizard handles branch operations. +type BranchWizard struct { + repoRoot string + step int // 0: list, 1: checkout confirm, 2: new name, 3: new confirm + branches []string + current string + index int + input textinput.Model + inputActive bool + running bool + err string + done bool +} + +// NewBranchWizard creates a new branch wizard. +func NewBranchWizard() *BranchWizard { + return &BranchWizard{} +} + +// Init initializes the wizard. +func (w *BranchWizard) Init(repoRoot string, files []gitx.FileChange) tea.Cmd { + w.repoRoot = repoRoot + w.step = 0 + w.branches = nil + w.current = "" + w.index = 0 + w.running = false + w.err = "" + w.done = false + w.inputActive = false + + return w.loadBranches() +} + +// HandleKey processes keyboard input. +func (w *BranchWizard) HandleKey(msg tea.KeyMsg) (Action, tea.Cmd) { + switch w.step { + case 0: + return w.handleBranchList(msg) + case 1: + return w.handleCheckoutConfirm(msg) + case 2: + return w.handleNewBranchName(msg) + case 3: + return w.handleNewBranchConfirm(msg) + } + return ActionContinue, nil +} + +func (w *BranchWizard) handleBranchList(msg tea.KeyMsg) (Action, tea.Cmd) { + switch msg.String() { + case "esc": + return ActionClose, nil + case "j", "down": + if len(w.branches) > 0 && w.index < len(w.branches)-1 { + w.index++ + } + case "k", "up": + if w.index > 0 { + w.index-- + } + case "n": + // New branch + ti := textinput.New() + ti.Placeholder = "Branch name" + ti.Prompt = "> " + w.input = ti + w.inputActive = false + w.step = 2 + w.err = "" + return ActionContinue, nil + case "enter": + if len(w.branches) == 0 { + return ActionContinue, nil + } + w.step = 1 + w.err = "" + w.done = false + w.running = false + return ActionContinue, nil + } + return ActionContinue, nil +} + +func (w *BranchWizard) handleCheckoutConfirm(msg tea.KeyMsg) (Action, tea.Cmd) { + switch msg.String() { + case "esc": + if !w.running { + return ActionClose, nil + } + case "b": + if !w.running && !w.done { + w.step = 0 + return ActionContinue, nil + } + case "y", "enter": + if !w.running && !w.done { + if len(w.branches) == 0 { + return ActionContinue, nil + } + name := w.branches[w.index] + w.running = true + w.err = "" + return ActionContinue, w.runCheckout(name) + } + } + return ActionContinue, nil +} + +func (w *BranchWizard) handleNewBranchName(msg tea.KeyMsg) (Action, tea.Cmd) { + switch msg.String() { + case "esc": + if w.inputActive { + w.inputActive = false + w.input.Blur() + return ActionContinue, nil + } + return ActionClose, nil + case "i": + if !w.inputActive { + w.inputActive = true + w.input.Focus() + return ActionContinue, nil + } + case "b": + if !w.inputActive { + w.step = 0 + return ActionContinue, nil + } + case "enter": + if !w.inputActive { + if strings.TrimSpace(w.input.Value()) == "" { + w.err = "empty branch name" + return ActionContinue, nil + } + w.step = 3 + w.err = "" + w.done = false + w.running = false + return ActionContinue, nil + } + } + + if w.inputActive { + var cmd tea.Cmd + w.input, cmd = w.input.Update(msg) + return ActionContinue, cmd + } + + return ActionContinue, nil +} + +func (w *BranchWizard) handleNewBranchConfirm(msg tea.KeyMsg) (Action, tea.Cmd) { + switch msg.String() { + case "esc": + if !w.running { + return ActionClose, nil + } + case "b": + if !w.running && !w.done { + w.step = 2 + return ActionContinue, nil + } + case "y", "enter": + if !w.running && !w.done { + name := strings.TrimSpace(w.input.Value()) + if name == "" { + w.err = "empty branch name" + return ActionContinue, nil + } + w.running = true + w.err = "" + return ActionContinue, w.runCreateBranch(name) + } + } + return ActionContinue, nil +} + +// Update processes messages. +func (w *BranchWizard) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case BranchListMsg: + if msg.Err != nil { + w.err = msg.Err.Error() + w.branches = nil + w.current = "" + w.index = 0 + } else { + w.branches = msg.Names + w.current = msg.Current + w.err = "" + // Focus current + w.index = 0 + for i, n := range w.branches { + if n == w.current { + w.index = i + break + } + } + } + case BranchResultMsg: + w.running = false + if msg.Err != nil { + w.err = msg.Err.Error() + w.done = false + } else { + w.err = "" + w.done = true + } + } + return nil +} + +// RenderOverlay renders the wizard UI. +func (w *BranchWizard) RenderOverlay(width int) []string { + lines := make([]string, 0, 128) + lines = append(lines, strings.Repeat("─", width)) + + switch w.step { + case 0: + lines = append(lines, w.renderBranchList()...) + case 1: + lines = append(lines, w.renderCheckoutConfirm()...) + case 2: + lines = append(lines, w.renderNewBranchName()...) + case 3: + lines = append(lines, w.renderNewBranchConfirm()...) + } + + return lines +} + +func (w *BranchWizard) renderBranchList() []string { + title := lipgloss.NewStyle().Bold(true). + Render("Branches — Select (enter: continue, n: new, esc: cancel)") + + lines := []string{title} + + if w.err != "" { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Render("Error: ")+w.err) + } + + if len(w.branches) == 0 && w.err == "" { + lines = append(lines, lipgloss.NewStyle().Faint(true). + Render("Loading branches…")) + return lines + } + + for i, n := range w.branches { + cur := " " + if i == w.index { + cur = "> " + } + mark := " " + if n == w.current { + mark = "[*]" + } + lines = append(lines, fmt.Sprintf("%s%s %s", cur, mark, n)) + } + + lines = append(lines, lipgloss.NewStyle().Faint(true). + Render("[*] current branch")) + + return lines +} + +func (w *BranchWizard) renderCheckoutConfirm() []string { + title := lipgloss.NewStyle().Bold(true). + Render("Checkout — Confirm (y/enter: checkout, b: back, esc: cancel)") + + lines := []string{title} + + if len(w.branches) > 0 { + name := w.branches[w.index] + lines = append(lines, fmt.Sprintf("Branch: %s", name)) + } + + if w.running { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("63")). + Render("Checking out…")) + } + + if w.err != "" { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Render("Error: ")+w.err) + } + + return lines +} + +func (w *BranchWizard) renderNewBranchName() []string { + mode := "action" + if w.inputActive { + mode = "input" + } + escAction := "cancel" + if w.inputActive { + escAction = "leave input" + } + + title := lipgloss.NewStyle().Bold(true). + Render(fmt.Sprintf("New Branch — Name (i: input, enter: continue, b: back, esc: %s) [%s]", escAction, mode)) + + lines := []string{title, w.input.View()} + + if w.err != "" { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Render("Error: ")+w.err) + } + + return lines +} + +func (w *BranchWizard) renderNewBranchConfirm() []string { + title := lipgloss.NewStyle().Bold(true). + Render("New Branch — Confirm (y/enter: create, b: back, esc: cancel)") + + lines := []string{title} + lines = append(lines, "Name: "+w.input.Value()) + + if w.running { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("63")). + Render("Creating…")) + } + + if w.err != "" { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Render("Error: ")+w.err) + } + + return lines +} + +// IsComplete returns true if wizard finished successfully. +func (w *BranchWizard) IsComplete() bool { + return w.done +} + +// Error returns any error message. +func (w *BranchWizard) Error() string { + return w.err +} + +func (w *BranchWizard) loadBranches() tea.Cmd { + return func() tea.Msg { + names, current, err := gitx.ListBranches(w.repoRoot) + return BranchListMsg{Names: names, Current: current, Err: err} + } +} + +func (w *BranchWizard) runCheckout(branch string) tea.Cmd { + return func() tea.Msg { + if err := gitx.Checkout(w.repoRoot, branch); err != nil { + return BranchResultMsg{Err: err} + } + return BranchResultMsg{Err: nil} + } +} + +func (w *BranchWizard) runCreateBranch(name string) tea.Cmd { + return func() tea.Msg { + if err := gitx.CheckoutNew(w.repoRoot, name); err != nil { + return BranchResultMsg{Err: err} + } + return BranchResultMsg{Err: nil} + } +} diff --git a/internal/tui/wizards/commit.go b/internal/tui/wizards/commit.go new file mode 100644 index 0000000..ce39323 --- /dev/null +++ b/internal/tui/wizards/commit.go @@ -0,0 +1,334 @@ +package wizards + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/interpretive-systems/diffium/internal/gitx" +) + +// CommitResultMsg is sent when commit completes. +type CommitResultMsg struct { + Err error +} + +// CommitWizard handles the commit workflow. +type CommitWizard struct { + repoRoot string + step int // 0: select files, 1: message, 2: confirm + files []gitx.FileChange + selected map[string]bool + index int + input textinput.Model + inputActive bool + running bool + err string + done bool +} + +// NewCommitWizard creates a new commit wizard. +func NewCommitWizard() *CommitWizard { + return &CommitWizard{ + selected: make(map[string]bool), + } +} + +// Init initializes the wizard. +func (w *CommitWizard) Init(repoRoot string, files []gitx.FileChange) tea.Cmd { + w.repoRoot = repoRoot + w.step = 0 + w.files = append([]gitx.FileChange(nil), files...) + w.selected = make(map[string]bool) + for _, f := range w.files { + w.selected[f.Path] = true + } + w.index = 0 + w.running = false + w.err = "" + w.done = false + w.inputActive = false + return nil +} + +// HandleKey processes keyboard input. +func (w *CommitWizard) HandleKey(msg tea.KeyMsg) (Action, tea.Cmd) { + switch w.step { + case 0: // File selection + return w.handleFileSelection(msg) + case 1: // Message input + return w.handleMessageInput(msg) + case 2: // Confirm + return w.handleConfirm(msg) + } + return ActionContinue, nil +} + +func (w *CommitWizard) handleFileSelection(msg tea.KeyMsg) (Action, tea.Cmd) { + switch msg.String() { + case "esc": + return ActionClose, nil + case "enter": + w.step = 1 + ti := textinput.New() + ti.Placeholder = "Commit message" + ti.Prompt = "> " + ti.CharLimit = 0 + w.input = ti + w.inputActive = false + return ActionContinue, nil + case "j", "down": + if len(w.files) > 0 && w.index < len(w.files)-1 { + w.index++ + } + case "k", "up": + if w.index > 0 { + w.index-- + } + case " ": + if len(w.files) > 0 { + path := w.files[w.index].Path + w.selected[path] = !w.selected[path] + } + case "a": + all := true + for _, f := range w.files { + if !w.selected[f.Path] { + all = false + break + } + } + set := !all + for _, f := range w.files { + w.selected[f.Path] = set + } + } + return ActionContinue, nil +} + +func (w *CommitWizard) handleMessageInput(msg tea.KeyMsg) (Action, tea.Cmd) { + switch msg.String() { + case "esc": + if w.inputActive { + w.inputActive = false + w.input.Blur() + return ActionContinue, nil + } + return ActionClose, nil + case "i": + if !w.inputActive { + w.inputActive = true + w.input.Focus() + return ActionContinue, nil + } + case "b": + if !w.inputActive { + w.step = 0 + return ActionContinue, nil + } + case "enter": + if !w.inputActive { + w.step = 2 + w.err = "" + w.done = false + w.running = false + return ActionContinue, nil + } + } + + if w.inputActive { + var cmd tea.Cmd + w.input, cmd = w.input.Update(msg) + return ActionContinue, cmd + } + + return ActionContinue, nil +} + +func (w *CommitWizard) handleConfirm(msg tea.KeyMsg) (Action, tea.Cmd) { + switch msg.String() { + case "esc": + if !w.running { + return ActionClose, nil + } + case "b": + if !w.running && !w.done { + w.step = 1 + return ActionContinue, nil + } + case "y", "enter": + if !w.running && !w.done { + paths := w.selectedPaths() + if len(paths) == 0 { + w.err = "no files selected" + return ActionContinue, nil + } + if strings.TrimSpace(w.input.Value()) == "" { + w.err = "empty commit message" + return ActionContinue, nil + } + w.err = "" + w.running = true + return ActionContinue, w.runCommit(paths, w.input.Value()) + } + } + return ActionContinue, nil +} + +// Update processes messages. +func (w *CommitWizard) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case CommitResultMsg: + w.running = false + if msg.Err != nil { + w.err = msg.Err.Error() + w.done = false + } else { + w.err = "" + w.done = true + } + } + return nil +} + +// RenderOverlay renders the wizard UI. +func (w *CommitWizard) RenderOverlay(width int) []string { + lines := make([]string, 0, 64) + lines = append(lines, strings.Repeat("─", width)) + + switch w.step { + case 0: + lines = append(lines, w.renderFileSelection()...) + case 1: + lines = append(lines, w.renderMessageInput()...) + case 2: + lines = append(lines, w.renderConfirm()...) + } + + return lines +} + +func (w *CommitWizard) renderFileSelection() []string { + title := lipgloss.NewStyle().Bold(true). + Render("Commit — Select files (space: toggle, a: all, enter: continue, esc: cancel)") + + lines := []string{title} + + if len(w.files) == 0 { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No changes to commit")) + return lines + } + + for i, f := range w.files { + cur := " " + if i == w.index { + cur = "> " + } + mark := "[ ]" + if w.selected[f.Path] { + mark = "[x]" + } + status := fileStatusLabel(f) + lines = append(lines, fmt.Sprintf("%s%s %s %s", cur, mark, status, f.Path)) + } + + return lines +} + +func (w *CommitWizard) renderMessageInput() []string { + mode := "action" + if w.inputActive { + mode = "input" + } + escAction := "cancel" + if w.inputActive { + escAction = "leave input" + } + + title := lipgloss.NewStyle().Bold(true). + Render(fmt.Sprintf("Commit — Message (i: input, enter: continue, b: back, esc: %s) [%s]", escAction, mode)) + + return []string{title, w.input.View()} +} + +func (w *CommitWizard) renderConfirm() []string { + title := lipgloss.NewStyle().Bold(true). + Render("Commit — Confirm (y/enter: commit & push, b: back, esc: cancel)") + + lines := []string{title} + + sel := w.selectedPaths() + lines = append(lines, fmt.Sprintf("Files: %d", len(sel))) + lines = append(lines, "Message: "+w.input.Value()) + + if w.running { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("63")). + Render("Committing & pushing...")) + } + + if w.err != "" { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Render("Error: ")+w.err) + } + + return lines +} + +// IsComplete returns true if wizard finished successfully. +func (w *CommitWizard) IsComplete() bool { + return w.done +} + +// Error returns any error message. +func (w *CommitWizard) Error() string { + return w.err +} + +func (w *CommitWizard) selectedPaths() []string { + var out []string + for _, f := range w.files { + if w.selected[f.Path] { + out = append(out, f.Path) + } + } + return out +} + +func (w *CommitWizard) runCommit(paths []string, message string) tea.Cmd { + return func() tea.Msg { + if err := gitx.StageFiles(w.repoRoot, paths); err != nil { + return CommitResultMsg{Err: err} + } + if err := gitx.Commit(w.repoRoot, message); err != nil { + return CommitResultMsg{Err: err} + } + if err := gitx.Push(w.repoRoot); err != nil { + return CommitResultMsg{Err: err} + } + return CommitResultMsg{Err: nil} + } +} + +func fileStatusLabel(f gitx.FileChange) string { + var tags []string + if f.Deleted { + tags = append(tags, "D") + } + if f.Untracked { + tags = append(tags, "U") + } + if f.Staged { + tags = append(tags, "S") + } + if f.Unstaged { + tags = append(tags, "M") + } + if len(tags) == 0 { + return "-" + } + return strings.Join(tags, "") +} diff --git a/internal/tui/wizards/pull.go b/internal/tui/wizards/pull.go new file mode 100644 index 0000000..34db498 --- /dev/null +++ b/internal/tui/wizards/pull.go @@ -0,0 +1,149 @@ +package wizards + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/interpretive-systems/diffium/internal/gitx" +) + +// PullResultMsg is sent when pull completes. +type PullResultMsg struct { + Output string + Err error +} + +// PullWizard handles the pull workflow. +type PullWizard struct { + repoRoot string + running bool + err string + output string + done bool +} + +// NewPullWizard creates a new pull wizard. +func NewPullWizard() *PullWizard { + return &PullWizard{} +} + +// Init initializes the wizard. +func (w *PullWizard) Init(repoRoot string, files []gitx.FileChange) tea.Cmd { + w.repoRoot = repoRoot + w.running = false + w.err = "" + w.output = "" + w.done = false + return nil +} + +// HandleKey processes keyboard input. +func (w *PullWizard) HandleKey(msg tea.KeyMsg) (Action, tea.Cmd) { + switch msg.String() { + case "esc": + if w.done && !w.running { + return ActionClose, nil + } + if !w.running { + return ActionClose, nil + } + case "y", "enter": + if w.done && !w.running { + return ActionClose, nil + } + if !w.running && !w.done { + w.running = true + w.err = "" + return ActionContinue, w.runPull() + } + } + return ActionContinue, nil +} + +// Update processes messages. +func (w *PullWizard) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case PullResultMsg: + w.running = false + w.output = msg.Output + if msg.Err != nil { + w.err = msg.Err.Error() + } else { + w.err = "" + } + w.done = true + } + return nil +} + +// RenderOverlay renders the wizard UI. +func (w *PullWizard) RenderOverlay(width int) []string { + lines := make([]string, 0, 32) + lines = append(lines, strings.Repeat("─", width)) + + if w.done { + title := lipgloss.NewStyle().Bold(true). + Render("Pull — Result (enter/esc: close)") + lines = append(lines, title) + + if w.err != "" { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Render("Error: ")+w.err) + } + + if w.output != "" { + outLines := strings.Split(strings.TrimRight(w.output, "\n"), "\n") + max := 12 + for i, l := range outLines { + if i >= max { + break + } + lines = append(lines, l) + } + if len(outLines) > max { + lines = append(lines, lipgloss.NewStyle().Faint(true). + Render("… and more")) + } + } else if w.err == "" { + lines = append(lines, lipgloss.NewStyle().Faint(true). + Render("(no output)")) + } + } else { + title := lipgloss.NewStyle().Bold(true). + Render("Pull — Confirm (y/enter: pull, esc: cancel)") + lines = append(lines, title) + + if w.running { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("63")). + Render("Pulling…")) + } + + if w.err != "" { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Render("Error: ")+w.err) + } + } + + return lines +} + +// IsComplete returns true if wizard finished successfully. +func (w *PullWizard) IsComplete() bool { + return w.done && w.err == "" +} + +// Error returns any error message. +func (w *PullWizard) Error() string { + return w.err +} + +func (w *PullWizard) runPull() tea.Cmd { + return func() tea.Msg { + out, err := gitx.PullWithOutput(w.repoRoot) + return PullResultMsg{Output: out, Err: err} + } +} diff --git a/internal/tui/wizards/resetclean.go b/internal/tui/wizards/resetclean.go new file mode 100644 index 0000000..fb86c64 --- /dev/null +++ b/internal/tui/wizards/resetclean.go @@ -0,0 +1,377 @@ +package wizards + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/interpretive-systems/diffium/internal/gitx" +) + +// ResetCleanPreviewMsg contains preview output. +type ResetCleanPreviewMsg struct { + Lines []string + Err error +} + +// ResetCleanResultMsg is sent when operation completes. +type ResetCleanResultMsg struct { + Err error +} + +// ResetCleanWizard handles reset/clean operations. +type ResetCleanWizard struct { + repoRoot string + step int // 0: select, 1: preview, 2: yellow confirm, 3: red confirm + doReset bool + doClean bool + includeIgnored bool + index int + previewLines []string + previewErr string + running bool + err string + done bool + files []gitx.FileChange +} + +// NewResetCleanWizard creates a new reset/clean wizard. +func NewResetCleanWizard() *ResetCleanWizard { + return &ResetCleanWizard{} +} + +// Init initializes the wizard. +func (w *ResetCleanWizard) Init(repoRoot string, files []gitx.FileChange) tea.Cmd { + w.repoRoot = repoRoot + w.files = files + w.step = 0 + w.doReset = false + w.doClean = false + w.includeIgnored = false + w.index = 0 + w.previewLines = nil + w.previewErr = "" + w.running = false + w.err = "" + w.done = false + return nil +} + +// HandleKey processes keyboard input. +func (w *ResetCleanWizard) HandleKey(msg tea.KeyMsg) (Action, tea.Cmd) { + switch w.step { + case 0: + return w.handleSelect(msg) + case 1: + return w.handlePreview(msg) + case 2: + return w.handleYellowConfirm(msg) + case 3: + return w.handleRedConfirm(msg) + } + return ActionContinue, nil +} + +func (w *ResetCleanWizard) handleSelect(msg tea.KeyMsg) (Action, tea.Cmd) { + switch msg.String() { + case "esc": + return ActionClose, nil + case "j", "down": + if w.index < 2 { + w.index++ + } + case "k", "up": + if w.index > 0 { + w.index-- + } + case " ": + switch w.index { + case 0: + w.doReset = !w.doReset + case 1: + w.doClean = !w.doClean + case 2: + w.includeIgnored = !w.includeIgnored + } + case "a": + both := w.doReset && w.doClean + w.doReset = !both + w.doClean = !both + case "enter": + if !w.doReset && !w.doClean { + w.err = "no actions selected" + return ActionContinue, nil + } + w.step = 1 + w.previewErr = "" + w.previewLines = nil + if w.doClean { + return ActionContinue, w.loadPreview() + } + return ActionContinue, nil + } + return ActionContinue, nil +} + +func (w *ResetCleanWizard) handlePreview(msg tea.KeyMsg) (Action, tea.Cmd) { + switch msg.String() { + case "esc": + return ActionClose, nil + case "b": + w.step = 0 + return ActionContinue, nil + case "enter": + w.step = 2 + return ActionContinue, nil + } + return ActionContinue, nil +} + +func (w *ResetCleanWizard) handleYellowConfirm(msg tea.KeyMsg) (Action, tea.Cmd) { + switch msg.String() { + case "esc": + return ActionClose, nil + case "b": + w.step = 1 + return ActionContinue, nil + case "enter": + w.step = 3 + return ActionContinue, nil + } + return ActionContinue, nil +} + +func (w *ResetCleanWizard) handleRedConfirm(msg tea.KeyMsg) (Action, tea.Cmd) { + switch msg.String() { + case "esc": + if !w.running { + return ActionClose, nil + } + case "b": + if !w.running && !w.done { + w.step = 2 + return ActionContinue, nil + } + case "y", "enter": + if !w.running && !w.done { + w.running = true + w.err = "" + return ActionContinue, w.runResetClean() + } + } + return ActionContinue, nil +} + +// Update processes messages. +func (w *ResetCleanWizard) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case ResetCleanPreviewMsg: + w.previewErr = "" + if msg.Err != nil { + w.previewErr = msg.Err.Error() + w.previewLines = nil + } else { + w.previewLines = msg.Lines + } + case ResetCleanResultMsg: + w.running = false + if msg.Err != nil { + w.err = msg.Err.Error() + w.done = false + } else { + w.err = "" + w.done = true + } + } + return nil +} + +// RenderOverlay renders the wizard UI. +func (w *ResetCleanWizard) RenderOverlay(width int) []string { + lines := make([]string, 0, 128) + lines = append(lines, strings.Repeat("─", width)) + + switch w.step { + case 0: + lines = append(lines, w.renderSelect()...) + case 1: + lines = append(lines, w.renderPreview()...) + case 2: + lines = append(lines, w.renderYellowConfirm()...) + case 3: + lines = append(lines, w.renderRedConfirm()...) + } + + return lines +} + +func (w *ResetCleanWizard) renderSelect() []string { + title := lipgloss.NewStyle().Bold(true). + Render("Reset/Clean — Select actions (space: toggle, a: toggle both, enter: continue, esc: cancel)") + + lines := []string{title} + + items := []struct { + label string + on bool + }{ + {"Reset working tree (git reset --hard)", w.doReset}, + {"Clean untracked (git clean -d -f)", w.doClean}, + {"Include ignored in clean (-x)", w.includeIgnored}, + } + + for i, it := range items { + cur := " " + if i == w.index { + cur = "> " + } + check := "[ ]" + if it.on { + check = "[x]" + } + lines = append(lines, fmt.Sprintf("%s%s %s", cur, check, it.label)) + } + + lines = append(lines, lipgloss.NewStyle().Faint(true). + Render("A preview will be shown before confirmation")) + + if w.err != "" { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Render("Error: ")+w.err) + } + + return lines +} + +func (w *ResetCleanWizard) renderPreview() []string { + title := lipgloss.NewStyle().Bold(true). + Render("Reset/Clean — Preview (enter: continue, b: back, esc: cancel)") + + lines := []string{title} + + // Reset preview + if w.doReset { + tracked := 0 + for _, f := range w.files { + if !f.Untracked && (f.Staged || f.Unstaged || f.Deleted) { + tracked++ + } + } + lines = append(lines, fmt.Sprintf("Reset would discard tracked changes for ~%d file(s)", tracked)) + } else { + lines = append(lines, lipgloss.NewStyle().Faint(true). + Render("Reset: (not selected)")) + } + + // Clean preview + if w.doClean { + if w.previewErr != "" { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Render("Clean preview error: ")+w.previewErr) + } else if len(w.previewLines) == 0 { + lines = append(lines, lipgloss.NewStyle().Faint(true). + Render("Clean: nothing to remove")) + } else { + lines = append(lines, lipgloss.NewStyle().Bold(true). + Render("Clean would remove:")) + max := 10 + for i, l := range w.previewLines { + if i >= max { + break + } + lines = append(lines, l) + } + if len(w.previewLines) > max { + lines = append(lines, fmt.Sprintf("… and %d more", len(w.previewLines)-max)) + } + if w.includeIgnored { + lines = append(lines, lipgloss.NewStyle().Faint(true). + Render("(including ignored files)")) + } + } + } else { + lines = append(lines, lipgloss.NewStyle().Faint(true). + Render("Clean: (not selected)")) + } + + // Commands + var cmds []string + if w.doReset { + cmds = append(cmds, "git reset --hard") + } + if w.doClean { + c := "git clean -d -f" + if w.includeIgnored { + c += " -x" + } + cmds = append(cmds, c) + } + if len(cmds) > 0 { + lines = append(lines, lipgloss.NewStyle().Faint(true). + Render("Commands: "+strings.Join(cmds, " && "))) + } + + return lines +} + +func (w *ResetCleanWizard) renderYellowConfirm() []string { + title := lipgloss.NewStyle().Bold(true). + Foreground(lipgloss.Color("220")). + Render("Confirm — This will discard local changes (enter: continue, b: back, esc: cancel)") + + lines := []string{title} + lines = append(lines, "Proceed to final confirmation?") + + return lines +} + +func (w *ResetCleanWizard) renderRedConfirm() []string { + title := lipgloss.NewStyle().Bold(true). + Foreground(lipgloss.Color("196")). + Render("FINAL CONFIRMATION — Destructive action (y/enter: execute, b: back, esc: cancel)") + + lines := []string{title} + + if w.running { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("63")). + Render("Running…")) + } + + if w.err != "" { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Render("Error: ")+w.err) + } + + return lines +} + +// IsComplete returns true if wizard finished successfully. +func (w *ResetCleanWizard) IsComplete() bool { + return w.done +} + +// Error returns any error message. +func (w *ResetCleanWizard) Error() string { + return w.err +} + +func (w *ResetCleanWizard) loadPreview() tea.Cmd { + return func() tea.Msg { + lines, err := gitx.CleanPreview(w.repoRoot, w.includeIgnored) + return ResetCleanPreviewMsg{Lines: lines, Err: err} + } +} + +func (w *ResetCleanWizard) runResetClean() tea.Cmd { + return func() tea.Msg { + if err := gitx.ResetAndClean(w.repoRoot, w.doReset, w.doClean, w.includeIgnored); err != nil { + return ResetCleanResultMsg{Err: err} + } + return ResetCleanResultMsg{Err: nil} + } +} diff --git a/internal/tui/wizards/uncommit.go b/internal/tui/wizards/uncommit.go new file mode 100644 index 0000000..879edcc --- /dev/null +++ b/internal/tui/wizards/uncommit.go @@ -0,0 +1,292 @@ +package wizards + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/interpretive-systems/diffium/internal/gitx" +) + +// UncommitResultMsg is sent when uncommit completes. +type UncommitResultMsg struct { + Err error +} + +// UncommitEligibleMsg contains files in last commit. +type UncommitEligibleMsg struct { + Paths []string + Err error +} + +// UncommitWizard handles the uncommit workflow. +type UncommitWizard struct { + repoRoot string + step int + files []gitx.FileChange + selected map[string]bool + index int + eligible map[string]bool + running bool + err string + done bool +} + +// NewUncommitWizard creates a new uncommit wizard. +func NewUncommitWizard() *UncommitWizard { + return &UncommitWizard{ + selected: make(map[string]bool), + eligible: make(map[string]bool), + } +} + +// Init initializes the wizard. +func (w *UncommitWizard) Init(repoRoot string, files []gitx.FileChange) tea.Cmd { + w.repoRoot = repoRoot + w.step = 0 + w.files = append([]gitx.FileChange(nil), files...) + w.selected = make(map[string]bool) + for _, f := range w.files { + w.selected[f.Path] = true + } + w.index = 0 + w.running = false + w.err = "" + w.done = false + + // Load eligible files + return w.loadEligible() +} + +// HandleKey processes keyboard input. +func (w *UncommitWizard) HandleKey(msg tea.KeyMsg) (Action, tea.Cmd) { + switch w.step { + case 0: // File selection + return w.handleFileSelection(msg) + case 1: // Confirm + return w.handleConfirm(msg) + } + return ActionContinue, nil +} + +func (w *UncommitWizard) handleFileSelection(msg tea.KeyMsg) (Action, tea.Cmd) { + switch msg.String() { + case "esc": + return ActionClose, nil + case "enter": + w.step = 1 + w.err = "" + w.done = false + w.running = false + return ActionContinue, nil + case "j", "down": + if len(w.files) > 0 && w.index < len(w.files)-1 { + w.index++ + } + case "k", "up": + if w.index > 0 { + w.index-- + } + case " ": + if len(w.files) > 0 { + path := w.files[w.index].Path + w.selected[path] = !w.selected[path] + } + case "a": + all := true + for _, f := range w.files { + if !w.selected[f.Path] { + all = false + break + } + } + set := !all + for _, f := range w.files { + w.selected[f.Path] = set + } + } + return ActionContinue, nil +} + +func (w *UncommitWizard) handleConfirm(msg tea.KeyMsg) (Action, tea.Cmd) { + switch msg.String() { + case "esc": + if !w.running { + return ActionClose, nil + } + case "b": + if !w.running && !w.done { + w.step = 0 + return ActionContinue, nil + } + case "y", "enter": + if !w.running && !w.done { + paths := w.selectedPaths() + if len(paths) == 0 { + w.err = "no files selected" + return ActionContinue, nil + } + w.err = "" + w.running = true + return ActionContinue, w.runUncommit(paths) + } + } + return ActionContinue, nil +} + +// Update processes messages. +func (w *UncommitWizard) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case UncommitResultMsg: + w.running = false + if msg.Err != nil { + w.err = msg.Err.Error() + w.done = false + } else { + w.err = "" + w.done = true + } + case UncommitEligibleMsg: + if msg.Err == nil { + w.eligible = make(map[string]bool) + for _, p := range msg.Paths { + w.eligible[p] = true + } + } + } + return nil +} + +// RenderOverlay renders the wizard UI. +func (w *UncommitWizard) RenderOverlay(width int) []string { + lines := make([]string, 0, 64) + lines = append(lines, strings.Repeat("─", width)) + + switch w.step { + case 0: + lines = append(lines, w.renderFileSelection()...) + case 1: + lines = append(lines, w.renderConfirm()...) + } + + return lines +} + +func (w *UncommitWizard) renderFileSelection() []string { + title := lipgloss.NewStyle().Bold(true). + Render("Uncommit — Select files (space: toggle, a: all, enter: continue, esc: cancel)") + + lines := []string{title} + + if w.err != "" { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Render("Error: ")+w.err) + } + + if len(w.files) == 0 { + lines = append(lines, lipgloss.NewStyle().Faint(true). + Render("No changes to choose from")) + return lines + } + + for i, f := range w.files { + cur := " " + if i == w.index { + cur = "> " + } + mark := "[ ]" + if w.selected[f.Path] { + mark = "[x]" + } + status := fileStatusLabel(f) + lines = append(lines, fmt.Sprintf("%s%s %s %s", cur, mark, status, f.Path)) + } + + return lines +} + +func (w *UncommitWizard) renderConfirm() []string { + title := lipgloss.NewStyle().Bold(true). + Render("Uncommit — Confirm (y/enter: uncommit, b: back, esc: cancel)") + + lines := []string{title} + + sel := w.selectedPaths() + total := len(sel) + elig := 0 + for _, p := range sel { + if w.eligible[p] { + elig++ + } + } + inelig := total - elig + + lines = append(lines, fmt.Sprintf( + "Selected: %d Eligible to uncommit: %d Ignored: %d", + total, elig, inelig, + )) + + if w.running { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("63")). + Render("Uncommitting…")) + } + + if w.err != "" { + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Render("Error: ")+w.err) + } + + return lines +} + +// IsComplete returns true if wizard finished successfully. +func (w *UncommitWizard) IsComplete() bool { + return w.done +} + +// Error returns any error message. +func (w *UncommitWizard) Error() string { + return w.err +} + +func (w *UncommitWizard) selectedPaths() []string { + var out []string + for _, f := range w.files { + if w.selected[f.Path] { + out = append(out, f.Path) + } + } + return out +} + +func (w *UncommitWizard) loadEligible() tea.Cmd { + return func() tea.Msg { + paths, err := gitx.FilesInLastCommit(w.repoRoot) + return UncommitEligibleMsg{Paths: paths, Err: err} + } +} + +func (w *UncommitWizard) runUncommit(paths []string) tea.Cmd { + return func() tea.Msg { + // Filter to eligible only + var eligible []string + for _, p := range paths { + if w.eligible[p] { + eligible = append(eligible, p) + } + } + + if len(eligible) == 0 { + return UncommitResultMsg{Err: fmt.Errorf("no selected files are in the last commit")} + } + + if err := gitx.UncommitFiles(w.repoRoot, eligible); err != nil { + return UncommitResultMsg{Err: err} + } + return UncommitResultMsg{Err: nil} + } +} diff --git a/internal/tui/wizards/wizard.go b/internal/tui/wizards/wizard.go new file mode 100644 index 0000000..ca6d35c --- /dev/null +++ b/internal/tui/wizards/wizard.go @@ -0,0 +1,37 @@ +package wizards + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/interpretive-systems/diffium/internal/gitx" +) + +// Action represents what the wizard wants the parent to do. +type Action int + +const ( + ActionContinue Action = iota // Continue processing in wizard + ActionClose // Close the wizard + ActionBack // Go back a step (internal) +) + +// Wizard is the interface all wizards implement. +type Wizard interface { + // Init initializes the wizard with repo and file state. + Init(repoRoot string, files []gitx.FileChange) tea.Cmd + + // HandleKey processes keyboard input. + // Returns the action to take and any commands. + HandleKey(msg tea.KeyMsg) (Action, tea.Cmd) + + // Update processes tea messages (for async results). + Update(msg tea.Msg) tea.Cmd + + // RenderOverlay returns the wizard UI lines. + RenderOverlay(width int) []string + + // IsComplete returns true if wizard finished successfully. + IsComplete() bool + + // Error returns any error message. + Error() string +}