Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 29 additions & 15 deletions internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ type tickMsg time.Time

// Messages
type diffLoadedMsg struct {
content string
index int
content string
index int
resetScroll bool
}

type filesRefreshedMsg struct {
Expand Down Expand Up @@ -94,6 +95,8 @@ type Model struct {
ready bool
SelectedFile string // set on "open in editor" action, read after Run()

lastDiffContent string

// Branch picker state
branches []string
branchCursor int
Expand Down Expand Up @@ -192,7 +195,7 @@ func (m *Model) StartInCommitMode() {
}

func (m Model) Init() tea.Cmd {
cmds := []tea.Cmd{m.loadDiffCmd(), m.fetchUpstreamStatusCmd(), tickCmd()}
cmds := []tea.Cmd{m.loadDiffCmd(true), m.fetchUpstreamStatusCmd(), tickCmd()}
if m.mode == modeCommit {
cmds = append(cmds, textinput.Blink)
}
Expand Down Expand Up @@ -249,32 +252,41 @@ func (m Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
m.viewport = viewport.New(m.diffWidth(), m.contentHeight())
m.lastDiffContent = "" // force re-apply after viewport recreation
m.ready = true
return m, m.loadDiffCmd()
return m, m.loadDiffCmd(true)
}

func (m Model) handleDiffLoaded(msg diffLoadedMsg) (tea.Model, tea.Cmd) {
if msg.index == m.cursor {
m.viewport.SetContent(msg.content)
if msg.index != m.cursor {
return m, nil
}
if msg.content == m.lastDiffContent {
return m, nil
}
m.lastDiffContent = msg.content
m.viewport.SetContent(msg.content)
if msg.resetScroll {
m.viewport.GotoTop()
}
return m, nil
}

func (m Model) handleFilesRefreshed(msg filesRefreshedMsg) (tea.Model, tea.Cmd) {
if filesEqual(m.files, msg.files) {
return m, m.loadDiffCmd()
return m, m.loadDiffCmd(false)
}
m.files = msg.files
if m.cursor >= len(m.files) {
m.cursor = max(0, len(m.files)-1)
}
m.prevCurs = -1
m.lastDiffContent = ""
if len(m.files) == 0 {
m.viewport.SetContent("")
return m, nil
}
return m, m.loadDiffCmd()
return m, m.loadDiffCmd(true)
}

func (m Model) handleCommitDone(msg commitDoneMsg) (tea.Model, tea.Cmd) {
Expand Down Expand Up @@ -453,7 +465,8 @@ func (m Model) updateFileListMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "v":
m.splitDiff = !m.splitDiff
m.prevCurs = -1
return m, tea.Batch(m.loadDiffCmd(), m.saveSplitPrefCmd())
m.lastDiffContent = ""
return m, tea.Batch(m.loadDiffCmd(true), m.saveSplitPrefCmd())
case "F":
if m.upstream.Upstream == "" {
m.statusMsg = "no upstream configured"
Expand All @@ -464,7 +477,7 @@ func (m Model) updateFileListMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
if m.cursor != m.prevCurs {
m.prevCurs = m.cursor
return m, m.loadDiffCmd()
return m, m.loadDiffCmd(true)
}
return m, nil
}
Expand Down Expand Up @@ -493,7 +506,8 @@ func (m Model) updateDiffMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "v":
m.splitDiff = !m.splitDiff
m.prevCurs = -1
return m, tea.Batch(m.loadDiffCmd(), m.saveSplitPrefCmd())
m.lastDiffContent = ""
return m, tea.Batch(m.loadDiffCmd(true), m.saveSplitPrefCmd())
}
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
Expand Down Expand Up @@ -623,7 +637,7 @@ func (m Model) nextFile() (tea.Model, tea.Cmd) {
if m.cursor < len(m.files)-1 {
m.cursor++
m.prevCurs = m.cursor
return m, m.loadDiffCmd()
return m, m.loadDiffCmd(true)
}
return m, nil
}
Expand All @@ -632,13 +646,13 @@ func (m Model) prevFile() (tea.Model, tea.Cmd) {
if m.cursor > 0 {
m.cursor--
m.prevCurs = m.cursor
return m, m.loadDiffCmd()
return m, m.loadDiffCmd(true)
}
return m, nil
}

// Commands
func (m Model) loadDiffCmd() tea.Cmd {
func (m Model) loadDiffCmd(resetScroll bool) tea.Cmd {
if len(m.files) == 0 {
return nil
}
Expand Down Expand Up @@ -677,7 +691,7 @@ func (m Model) loadDiffCmd() tea.Cmd {
}
}
}
return diffLoadedMsg{content: content, index: idx}
return diffLoadedMsg{content: content, index: idx, resetScroll: resetScroll}
}
}

Expand Down
46 changes: 46 additions & 0 deletions internal/ui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,52 @@ func TestHandleBranchesLoaded_Error(t *testing.T) {
}
}

func TestHandleResize_ClearsDiffCache(t *testing.T) {
t.Parallel()
m := newTestModel(t, []fileItem{
{change: git.FileChange{Path: "a.go", Status: git.StatusModified}},
})
m.cursor = 0

// Simulate having cached diff content
m.lastDiffContent = "old diff"
m.viewport.SetContent("old diff")

// Resize creates new viewport — cache must be cleared
result, _ := m.handleResize(tea.WindowSizeMsg{Width: 100, Height: 40})
rm := result.(Model)

if rm.lastDiffContent != "" {
t.Error("handleResize should clear lastDiffContent to force re-apply")
}

// handleDiffLoaded with same content should apply (not skip) after resize
result2, _ := rm.handleDiffLoaded(diffLoadedMsg{content: "old diff", index: 0})
rm2 := result2.(Model)
if rm2.lastDiffContent != "old diff" {
t.Error("handleDiffLoaded should apply content after resize cleared cache")
}
if !strings.Contains(rm2.viewport.View(), "old diff") {
t.Errorf("viewport should contain reapplied content, got %q", rm2.viewport.View())
}
}

func TestHandleDiffLoaded_SkipsDuplicate(t *testing.T) {
t.Parallel()
m := newTestModel(t, []fileItem{
{change: git.FileChange{Path: "a.go", Status: git.StatusModified}},
})
m.cursor = 0
m.lastDiffContent = "same diff"

// Same content as cache — should be a no-op
result, _ := m.handleDiffLoaded(diffLoadedMsg{content: "same diff", index: 0})
rm := result.(Model)
if rm.lastDiffContent != "same diff" {
t.Error("cache should remain unchanged on duplicate")
}
}

func TestBranchListScroll(t *testing.T) {
t.Parallel()
m := newTestModel(t, nil)
Expand Down