From ecf7809f47a6b9cac80aabbd7016ab89be9ee84e Mon Sep 17 00:00:00 2001 From: Aritra Sadhukhan Date: Sun, 5 Oct 2025 23:29:57 +0530 Subject: [PATCH 1/3] feat: scaffolding new revert wizard to revert back to a specific commit --- internal/gitx/gitx.go | 129 ++++++++++++++++++++++++++-- internal/tui/program.go | 183 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 304 insertions(+), 8 deletions(-) diff --git a/internal/gitx/gitx.go b/internal/gitx/gitx.go index 511b2f3..d13538b 100644 --- a/internal/gitx/gitx.go +++ b/internal/gitx/gitx.go @@ -1,13 +1,14 @@ package gitx import ( - "bytes" - "errors" - "fmt" - "os/exec" - "path/filepath" - "sort" - "strings" + "bytes" + "errors" + "fmt" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" ) // FileChange represents a changed file in the repo. @@ -20,6 +21,20 @@ type FileChange struct { Deleted bool } +// CommitInfo represents single git commit +type CommitInfo struct { + ID string + AbbreviatedHash string + Message string + AuthorEmail string + AuthorName string + AuthorDate time.Time + CommitterName string + CommitterEmail string + CommitterDate string + Body string +} + // RepoRoot resolves the git repository root from a given path (or current dir). func RepoRoot(path string) (string, error) { if path == "" { @@ -439,3 +454,103 @@ func CurrentBranch(repoRoot string) (string, error) { } return strings.TrimSpace(string(b)), nil } + +// ListCommits returns a slice of commits in reverse chronological order (latest first). +// The `from` and `to` parameters define a range of commits to fetch. This prevents +// loading thousands of commits at once, which could consume excessive memory. +// Example: ListCommits("/path/to/repo", 0, 50) retrieves the latest 50 commits. +func ListCommits(repoRoot string, from int, to int) ([]CommitInfo, error) { + if from < 0 || to < 0 || to < from { + return nil, fmt.Errorf("invalid range: from=%d, to=%d", from, to) + } + + const sep = "<--END-->" + format := strings.Join([]string{ + "%H", // full hash + "%s", // subject line + "%ae", // author email + "%an", // author name + "%ad", // author date + "%cn", // committer name + "%ce", // committer email + "%cd", // committer date + "%b", // body (commit description) + "%h", // abbreviated hash + }, "%n") + "%n" + sep + + count := to - from + + args := []string{ + "-C", repoRoot, + "log", + "--date=iso-strict", + "--skip", fmt.Sprint(from), + "-n", fmt.Sprint(count), + "--pretty=format:" + format, + } + + cmd := exec.Command("git", args...) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("list commits failed: %w", err) + } + + rawCommits := strings.Split(string(out), sep) + var commits []CommitInfo + + for _, raw := range rawCommits { + lines := strings.Split(strings.TrimSpace(raw), "\n") + if len(lines) < 10 { + continue + } + + authorDate, _ := time.Parse(time.RFC3339, strings.TrimSpace(lines[4])) + + commits = append(commits, CommitInfo{ + ID: lines[0], + Message: lines[1], + AuthorEmail: lines[2], + AuthorName: lines[3], + AuthorDate: authorDate, + CommitterName: lines[5], + CommitterEmail: lines[6], + CommitterDate: lines[7], + Body: strings.TrimSpace(strings.Join(lines[8:len(lines)-1], "\n")), + AbbreviatedHash: strings.TrimSpace(lines[len(lines)-1]), + }) + } + + return commits, nil +} + +// RevertBack reverts all commits made after the specified commit hash (exclusive). +// Internally, it runs `git revert --no-edit ..HEAD` to undo subsequent changes. +// Note: This creates new "revert" commits instead of rewriting history. +func RevertBack(repoRoot string, hash string) error { + if hash == "" { + return fmt.Errorf("hash cannot be empty") + } + + args := []string{ + "-C", repoRoot, + "revert", + "--no-edit", + hash + "..HEAD", + } + cmd := exec.Command("git", args...) + + b, err := cmd.Output() + + if err != nil { + return fmt.Errorf("git revert %s..HEAD: %w :%s", hash, err, string(b)) + } + + return nil +} + +// RevertPreview shows a what would change if the repository were reverted +// back to the specified commit. This runs a `git diff HEAD ` internally to +// preview the potential modifications before actually reverting. +func RevertPreview(repoRoot, hash string) ([]string, error) { + return listNames(repoRoot, []string{"diff", "HEAD", hash}) +} diff --git a/internal/tui/program.go b/internal/tui/program.go index a6b7110..fc716ce 100644 --- a/internal/tui/program.go +++ b/internal/tui/program.go @@ -113,6 +113,16 @@ type model struct { searchQuery string searchMatches []int searchIndex int + + // revert wizard + showRevert bool + revertStep int // 0: list commits, 1: preview, 2: confirm + rwCommits []gitx.CommitInfo + rwSelected int + rwPreview []string // preview of what will be changed + reverting bool + revertErr string + revertDone bool } // messages @@ -176,7 +186,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.showPull { return m.handlePullKeys(msg) } - + if m.showRevert { + return m.handleRevertKeys(msg) + } key := msg.String() if isNumericKey(key){ @@ -214,6 +226,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Open reset/clean wizard m.openResetCleanWizard() return m, m.recalcViewport() + case "V": + // Open revert wizard + m.openRevertWizard() + return m, m.recalcViewport() case "/": (&m).openSearch() return m, m.recalcViewport() @@ -611,6 +627,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.uncommitDone = true m.showUncommit = false return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) + case revertResultMessage: + m.reverting = false + if msg.err != nil { + m.revertErr = msg.err.Error() + m.revertDone = false + return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) + } + m.revertErr = "" + m.revertDone = true + m.showRevert = false } return m, nil } @@ -676,6 +702,9 @@ func (m model) View() string { if m.showPull { overlay = append(overlay, m.pullOverlayLines(m.width)...) } + if m.showRevert { + overlay = append(overlay, m.revertOverlayLines(m.width)...) + } if m.searchActive { overlay = append(overlay, m.searchOverlayLines(m.width)...) } @@ -1069,6 +1098,7 @@ func (m model) helpOverlayLines(width int) []string { "u Uncommit (open wizard)", "R Reset/Clean (open wizard)", "c Commit & push (open wizard)", + "V Revert (open wizard)", "s Toggle side-by-side / inline", "t Toggle HEAD / staged diffs", "w Toggle line wrap (diff)", @@ -2681,3 +2711,154 @@ func isMovementKey(key string) bool{ func isNumericKey(key string) bool{ return key <= "9" && key >= "0" } + + +// Revert Wizard + +// handle revert wizard keys +func (m model) handleRevertKeys(key tea.KeyMsg) (tea.Model, tea.Cmd) { + switch m.revertStep { + case 0: // list commits + switch key.String() { + case "esc": + m.showRevert = false + return m, m.recalcViewport() + case "enter": + m.revertStep = 1 + // load preview + m.loadRevertPreview() + return m, m.recalcViewport() + case "j", "down": + if len(m.rwCommits) > 0 && m.rwSelected < len(m.rwCommits)-1 { + m.rwSelected++ + } + return m, nil + case "k", "up": + if m.rwSelected > 0 { + m.rwSelected-- + } + return m, nil + } + case 1: // preview + switch key.String() { + case "esc": + m.showRevert = false + return m, m.recalcViewport() + case "b": + m.revertStep = 0 + return m, m.recalcViewport() + case "enter": + m.revertStep = 2 + m.revertDone = false + m.revertErr = "" + m.reverting = false + return m, m.recalcViewport() + } + case 2: // confirm/progress + switch key.String() { + case "esc": + // can't cancel mid-revert, but if not running: exit + if !m.reverting { + m.showRevert = false + return m, m.recalcViewport() + } + return m, nil + case "b": + if !m.reverting && !m.revertDone { + m.revertStep = 1 + return m, m.recalcViewport() + } + return m, nil + case "y", "enter": + if !m.reverting && !m.revertDone { + m.revertErr = "" + m.reverting = true + return m, runRevertBack(m.repoRoot, m.rwCommits[m.rwSelected].ID) + } + return m, nil + } + } + return m, nil +} + +func (m model) revertOverlayLines(width int) []string { + if !m.showRevert { + return nil + } + lines := make([]string, 0, 64) + lines = append(lines, strings.Repeat("─", width)) + switch m.revertStep { + case 0: + title := lipgloss.NewStyle().Bold(true).Render("Revert — Select commmit (enter: continue, esc: cancel)") + lines = append(lines, title) + if len(m.rwCommits) == 0 { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No commits to revert to.")) + return lines + } + for i, c := range m.rwCommits { + cur := " " + if i == m.rwSelected { + cur = "> " + } + mark := "[ ]" + if i == m.rwSelected { + mark = "[x]" + } + lines = append(lines, fmt.Sprintf("%s%s %s %s", cur, mark, c.AbbreviatedHash, c.Message)) + } + case 1: + title := lipgloss.NewStyle().Bold(true).Render("Revert — Preview (enter: continue, b: back, esc: cancel)") + lines = append(lines, title) + lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("220")).Render("The following changes will be done:")) + lines = append(lines, m.rwPreview...) + case 2: + title := lipgloss.NewStyle().Bold(true).Render("Revert - Confirm (y/enter: execute, b: back, esc: cancel)") + lines = append(lines, title) + lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("220")).Render(fmt.Sprintf("Please Confirm:"))) + if m.reverting { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Reverting...")) + } + if m.revertErr != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.revertErr) + } + } + return lines +} + +func (m *model) openRevertWizard() { + m.showRevert = true + // TODO: move to constant + commits, err := gitx.ListCommits(m.repoRoot, 0, 20) + if err != nil { + m.revertErr = err.Error() + } + m.rwCommits = commits + m.revertStep = 0 + m.rwSelected = 0 + m.revertErr = "" + m.revertDone = false + m.reverting = false +} + +func (m *model) loadRevertPreview() { + preview, err := gitx.RevertPreview(m.repoRoot, m.rwCommits[m.rwSelected].ID) + if err != nil { + m.revertErr = err.Error() + m.revertStep = 2 + return + } + m.rwPreview = preview +} + +type revertResultMessage struct { + err error +} + +func runRevertBack(repoRoot string, hash string) tea.Cmd { + return func() tea.Msg { + err := gitx.RevertBack(repoRoot, hash) + return revertResultMessage{ + err: err, + } + } +} \ No newline at end of file From b9f305045df3846c0957df9f2bd5d2a29a997b6d Mon Sep 17 00:00:00 2001 From: Aritra Sadhukhan Date: Tue, 7 Oct 2025 20:48:07 +0530 Subject: [PATCH 2/3] chore: updated readme.md added new revert wizard --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 271da0c..9b7e58a 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Watcher TUI that shows changed files on the left and a side-by-side diff on the - `<`/`>` or `H`/`L`: adjust left pane width - `c`: open commit flow (overlay) - `q`: quit +- `V`: open revert wizard (revert to a specified commit `git revert ..HEAD`) The top bar shows `Changes | ` with a horizontal rule below. The bottom bar shows `h: help` on the left and the last `refreshed` time on the right. Requires `git` in PATH. Binary files are listed but not rendered as text diffs yet. From 8d77ca9a92e58b07819e28936bdbdc35490f7f94 Mon Sep 17 00:00:00 2001 From: Aritra Sadhukhan Date: Wed, 8 Oct 2025 22:12:25 +0530 Subject: [PATCH 3/3] chore: formatted the code with go fmt --- internal/tui/program.go | 617 ++++++++++++++++++++-------------------- 1 file changed, 316 insertions(+), 301 deletions(-) diff --git a/internal/tui/program.go b/internal/tui/program.go index c65cbf4..5d34635 100644 --- a/internal/tui/program.go +++ b/internal/tui/program.go @@ -113,16 +113,16 @@ type model struct { searchQuery string searchMatches []int searchIndex int - - // revert wizard - showRevert bool - revertStep int // 0: list commits, 1: preview, 2: confirm - rwCommits []gitx.CommitInfo - rwSelected int - rwPreview []string // preview of what will be changed - reverting bool - revertErr string - revertDone bool + + // revert wizard + showRevert bool + revertStep int // 0: list commits, 1: preview, 2: confirm + rwCommits []gitx.CommitInfo + rwSelected int + rwPreview []string // preview of what will be changed + reverting bool + revertErr string + revertDone bool } // messages @@ -186,9 +186,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.showPull { return m.handlePullKeys(msg) } - if m.showRevert { - return m.handleRevertKeys(msg) - } + if m.showRevert { + return m.handleRevertKeys(msg) + } key := msg.String() if isNumericKey(key) { @@ -210,59 +210,59 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 "V": + 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 "V": // Open revert wizard m.openRevertWizard() 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 == ""){ + 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) @@ -483,181 +483,197 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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() - 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() - case lastCommitMsg: - if msg.err == nil { - m.lastCommit = msg.summary - } - return m, nil - case currentBranchMsg: - if msg.err == nil { - m.currentBranch = msg.name - } - return m, nil - 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 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()) - case revertResultMessage: + // 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() + 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() + case lastCommitMsg: + if msg.err == nil { + m.lastCommit = msg.summary + } + return m, nil + case currentBranchMsg: + if msg.err == nil { + m.currentBranch = msg.name + } + return m, nil + 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 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()) + case revertResultMessage: m.reverting = false if msg.err != nil { m.revertErr = msg.err.Error() @@ -667,8 +683,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.revertErr = "" m.revertDone = true m.showRevert = false - } - return m, nil + } + return m, nil } func (m model) View() string { @@ -712,33 +728,33 @@ func (m model) View() string { // 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.showRevert { + // 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.showRevert { overlay = append(overlay, m.revertOverlayLines(m.width)...) } - if m.searchActive { - overlay = append(overlay, m.searchOverlayLines(m.width)...) - } - overlayH := len(overlay) + if m.searchActive { + overlay = append(overlay, m.searchOverlayLines(m.width)...) + } + overlayH := len(overlay) contentHeight := m.height - 4 - overlayH // top + top rule + bottom rule + bottom bar if contentHeight < 1 { @@ -1133,39 +1149,39 @@ func (m *model) recalcViewport() tea.Cmd { // 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 - keys := []string{ - "j/k or arrows Move selection", - "J/K, PgDn/PgUp Scroll diff", - "{/} Horizontal scroll (diff)", - " or H/L Adjust left pane width", - "[/] Page left file list", - "b Switch branch (open wizard)", - "p Pull (open wizard)", - "u Uncommit (open wizard)", - "R Reset/Clean (open wizard)", - "c Commit & push (open wizard)", - "V Revert (open wizard)", - "s Toggle side-by-side / inline", - "t Toggle HEAD / staged diffs", - "w Toggle line wrap (diff)", - "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 + if !m.showHelp { + return nil + } + // Header + title := lipgloss.NewStyle().Bold(true).Render("Help — press 'h' or Esc to close") + // Keys + keys := []string{ + "j/k or arrows Move selection", + "J/K, PgDn/PgUp Scroll diff", + "{/} Horizontal scroll (diff)", + " or H/L Adjust left pane width", + "[/] Page left file list", + "b Switch branch (open wizard)", + "p Pull (open wizard)", + "u Uncommit (open wizard)", + "R Reset/Clean (open wizard)", + "c Commit & push (open wizard)", + "V Revert (open wizard)", + "s Toggle side-by-side / inline", + "t Toggle HEAD / staged diffs", + "w Toggle line wrap (diff)", + "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 { @@ -2864,7 +2880,6 @@ func isNumericKey(key string) bool { return key <= "9" && key >= "0" } - // Revert Wizard // handle revert wizard keys @@ -2966,7 +2981,7 @@ func (m model) revertOverlayLines(width int) []string { case 2: title := lipgloss.NewStyle().Bold(true).Render("Revert - Confirm (y/enter: execute, b: back, esc: cancel)") lines = append(lines, title) - lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("220")).Render(fmt.Sprintf("Please Confirm:"))) + lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("220")).Render(fmt.Sprintf("Please Confirm:"))) if m.reverting { lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Reverting...")) } @@ -3013,4 +3028,4 @@ func runRevertBack(repoRoot string, hash string) tea.Cmd { err: err, } } -} \ No newline at end of file +}