From c0fc5dfd60b44f97ad00d70801fbd9f37b8a7554 Mon Sep 17 00:00:00 2001 From: Jan Smrcka Date: Sun, 1 Mar 2026 13:36:23 +0100 Subject: [PATCH] refactor: split ui model into focused modules --- internal/ui/mode_branch.go | 151 +++ internal/ui/mode_diff.go | 36 + internal/ui/mode_filelist.go | 100 ++ internal/ui/model.go | 1116 +-------------------- internal/ui/model_test.go | 129 +++ internal/ui/render_layout.go | 266 +++++ internal/ui/update_dispatch.go | 170 ++++ internal/ui/workflow_commit_stage_sync.go | 253 +++++ 8 files changed, 1125 insertions(+), 1096 deletions(-) create mode 100644 internal/ui/mode_branch.go create mode 100644 internal/ui/mode_diff.go create mode 100644 internal/ui/mode_filelist.go create mode 100644 internal/ui/render_layout.go create mode 100644 internal/ui/update_dispatch.go create mode 100644 internal/ui/workflow_commit_stage_sync.go diff --git a/internal/ui/mode_branch.go b/internal/ui/mode_branch.go new file mode 100644 index 0000000..fb42012 --- /dev/null +++ b/internal/ui/mode_branch.go @@ -0,0 +1,151 @@ +package ui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +// Branch picker/create mode state transitions and actions. + +func (m Model) activeBranches() []string { + if m.filteredBranches != nil { + return m.filteredBranches + } + return m.branches +} + +func filterBranches(branches []string, query string) []string { + if query == "" { + return nil + } + q := strings.ToLower(query) + out := []string{} + for _, b := range branches { + if strings.Contains(strings.ToLower(b), q) { + out = append(out, b) + } + } + return out +} + +func (m Model) enterBranchMode() (tea.Model, tea.Cmd) { + repo := m.repo + return m, func() tea.Msg { + branches, err := repo.ListBranches() + if err != nil { + return branchesLoadedMsg{err: err} + } + current := repo.BranchName() + return branchesLoadedMsg{branches: branches, current: current} + } +} + +func (m Model) updateBranchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.branchCreating { + return m.updateBranchCreateMode(msg) + } + switch msg.String() { + case "ctrl+n": + m.branchCreating = true + m.branchInput.Reset() + m.branchInput.Focus() + m.branchFilter.Blur() + return m, textinput.Blink + case "esc": + if m.branchFilter.Value() != "" { + m.branchFilter.Reset() + m.filteredBranches = nil + m.branchCursor = 0 + m.branchOffset = 0 + return m, nil + } + m.mode = modeFileList + m.branchFilter.Blur() + return m, nil + case "ctrl+c": + return m, tea.Quit + case "up", "ctrl+k": + if m.branchCursor > 0 { + m.branchCursor-- + } + m = m.clampBranchScroll() + return m, nil + case "down", "ctrl+j": + list := m.activeBranches() + if m.branchCursor < len(list)-1 { + m.branchCursor++ + } + m = m.clampBranchScroll() + return m, nil + case "enter": + list := m.activeBranches() + if m.branchCursor >= len(list) || len(list) == 0 { + return m, nil + } + selected := list[m.branchCursor] + m.branchFilter.Blur() + if selected == m.currentBranch { + m.mode = modeFileList + return m, nil + } + repo := m.repo + return m, func() tea.Msg { + return branchSwitchedMsg{err: repo.CheckoutBranch(selected)} + } + } + prevVal := m.branchFilter.Value() + var cmd tea.Cmd + m.branchFilter, cmd = m.branchFilter.Update(msg) + if m.branchFilter.Value() != prevVal { + m.filteredBranches = filterBranches(m.branches, m.branchFilter.Value()) + m.branchCursor = 0 + m.branchOffset = 0 + } + return m, cmd +} + +func (m Model) updateBranchCreateMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "ctrl+c": + m.branchCreating = false + m.branchInput.Reset() + m.branchFilter.Focus() + return m, nil + case "enter": + name := strings.TrimSpace(m.branchInput.Value()) + if name == "" { + m.statusMsg = "empty branch name" + return m, nil + } + return m, m.createBranchCmd(name) + } + var cmd tea.Cmd + m.branchInput, cmd = m.branchInput.Update(msg) + return m, cmd +} + +func (m Model) clampBranchScroll() Model { + h := m.contentHeight() - 1 + if h <= 0 { + return m + } + if m.branchCursor < m.branchOffset { + m.branchOffset = m.branchCursor + } else if m.branchCursor >= m.branchOffset+h { + m.branchOffset = m.branchCursor - h + 1 + } + return m +} + +func (m Model) createBranchCmd(name string) tea.Cmd { + repo := m.repo + return func() tea.Msg { + if err := repo.CreateBranch(name); err != nil { + return branchCreatedMsg{name: name, err: err} + } + err := repo.CheckoutBranch(name) + return branchCreatedMsg{name: name, err: err} + } +} diff --git a/internal/ui/mode_diff.go b/internal/ui/mode_diff.go new file mode 100644 index 0000000..be9f4a6 --- /dev/null +++ b/internal/ui/mode_diff.go @@ -0,0 +1,36 @@ +package ui + +import tea "github.com/charmbracelet/bubbletea" + +// Diff mode key handling and viewport delegation. + +func (m Model) updateDiffMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "esc", "h", "left": + m.mode = modeFileList + return m, nil + case "n": + return m.nextFile() + case "p": + return m.prevFile() + case "e": + if m.cursor < len(m.files) { + m.SelectedFile = m.files[m.cursor].change.Path + } + return m, tea.Quit + case "b": + return m.enterBranchMode() + case "tab": + return m.toggleStage() + case "v": + m.splitDiff = !m.splitDiff + m.prevCurs = -1 + m.lastDiffContent = "" + return m, tea.Batch(m.loadDiffCmd(true), m.saveSplitPrefCmd()) + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} diff --git a/internal/ui/mode_filelist.go b/internal/ui/mode_filelist.go new file mode 100644 index 0000000..b623b21 --- /dev/null +++ b/internal/ui/mode_filelist.go @@ -0,0 +1,100 @@ +package ui + +import tea "github.com/charmbracelet/bubbletea" + +// File-list mode input handling and file navigation actions. + +func (m Model) updateFileListMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + m.statusMsg = "" + if msg.String() == "P" { + if m.pushConfirm { + m.pushConfirm = false + m.statusMsg = "pushing..." + if m.upstream.Upstream == "" { + return m, m.pushSetUpstreamCmd() + } + return m, m.pushCmd() + } + if m.upstream.Upstream == "" { + branch := m.currentBranch + if branch == "" { + branch = m.repo.BranchName() + } + m.pushConfirm = true + m.statusMsg = "press P again to push --set-upstream origin " + branch + return m, nil + } + m.pushConfirm = true + m.statusMsg = "press P again to push to " + m.upstream.Upstream + return m, nil + } + m.pushConfirm = false + + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "j", "down": + if m.cursor < len(m.files)-1 { + m.cursor++ + } + case "k", "up": + if m.cursor > 0 { + m.cursor-- + } + case "g": + m.cursor = 0 + case "G": + m.cursor = max(0, len(m.files)-1) + case "enter", "l", "right": + m.mode = modeDiff + return m, nil + case "e": + if m.cursor < len(m.files) { + m.SelectedFile = m.files[m.cursor].change.Path + } + return m, tea.Quit + case "tab": + return m.toggleStage() + case "a": + return m.stageAll() + case "c": + return m.enterCommitMode() + case "b": + return m.enterBranchMode() + case "v": + m.splitDiff = !m.splitDiff + m.prevCurs = -1 + m.lastDiffContent = "" + return m, tea.Batch(m.loadDiffCmd(true), m.saveSplitPrefCmd()) + case "F": + if m.upstream.Upstream == "" { + m.statusMsg = "no upstream configured" + return m, nil + } + m.statusMsg = "pulling..." + return m, m.pullCmd() + } + if m.cursor != m.prevCurs { + m.prevCurs = m.cursor + return m, m.loadDiffCmd(true) + } + return m, nil +} + +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(true) + } + return m, nil +} + +func (m Model) prevFile() (tea.Model, tea.Cmd) { + if m.cursor > 0 { + m.cursor-- + m.prevCurs = m.cursor + return m, m.loadDiffCmd(true) + } + return m, nil +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 4ab996e..f3c7565 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -1,16 +1,12 @@ package ui import ( - "fmt" - "os/exec" - "path/filepath" "strings" "time" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/jansmrcka/differ/internal/config" "github.com/jansmrcka/differ/internal/git" "github.com/jansmrcka/differ/internal/theme" @@ -28,22 +24,21 @@ const ( const fileListWidth = 35 const pollInterval = 2 * time.Second +const ( + minWidth = 60 + minHeight = 10 +) + type tickMsg time.Time -// Messages type diffLoadedMsg struct { content string index int resetScroll bool } -type filesRefreshedMsg struct { - files []fileItem -} - -type commitDoneMsg struct { - err error -} +type filesRefreshedMsg struct{ files []fileItem } +type commitDoneMsg struct{ err error } type commitMsgGeneratedMsg struct { message string @@ -56,23 +51,19 @@ type branchesLoadedMsg struct { err error } -type branchSwitchedMsg struct { - err error -} +type branchSwitchedMsg struct{ err error } -type upstreamStatusMsg struct { - info git.UpstreamInfo -} - -type pushDoneMsg struct { - err error -} +type upstreamStatusMsg struct{ info git.UpstreamInfo } +type pushDoneMsg struct{ err error } +type pullDoneMsg struct{ err error } +type savePrefDoneMsg struct{ err error } -type pullDoneMsg struct { - err error +type branchCreatedMsg struct { + name string + err error } -// Model is the main Bubble Tea model for the diff viewer. +// Model holds all UI state; behavior split across focused files. type Model struct { repo *git.Repo cfg config.Config @@ -93,13 +84,12 @@ type Model struct { width int height int ready bool - SelectedFile string // set on "open in editor" action, read after Run() + SelectedFile string lastDiffContent string - // Branch picker state branches []string - filteredBranches []string // nil = show all + filteredBranches []string branchCursor int branchOffset int currentBranch string @@ -107,7 +97,6 @@ type Model struct { branchCreating bool branchInput textinput.Model - // Push/pull state upstream git.UpstreamInfo pushConfirm bool } @@ -117,17 +106,7 @@ type fileItem struct { untracked bool } -// NewModel creates the main diff viewer model. -func NewModel( - repo *git.Repo, - cfg config.Config, - changes []git.FileChange, - untracked []string, - styles Styles, - t theme.Theme, - stagedOnly bool, - ref string, -) Model { +func NewModel(repo *git.Repo, cfg config.Config, changes []git.FileChange, untracked []string, styles Styles, t theme.Theme, stagedOnly bool, ref string) Model { files := buildFileItems(repo, changes, untracked) ti := textinput.New() @@ -172,10 +151,7 @@ func buildFileItems(repo *git.Repo, changes []git.FileChange, untracked []string added = countLines(raw) } } - files = append(files, fileItem{ - change: git.FileChange{Path: path, Status: git.StatusUntracked, AddedLines: added, DeletedLines: 0}, - untracked: true, - }) + files = append(files, fileItem{change: git.FileChange{Path: path, Status: git.StatusUntracked, AddedLines: added}, untracked: true}) } return files } @@ -203,7 +179,6 @@ func filesEqual(a, b []fileItem) bool { return true } -// StartInCommitMode sets the model to open directly in commit mode. func (m *Model) StartInCommitMode() { m.mode = modeCommit m.commitInput.Focus() @@ -217,1056 +192,5 @@ func (m Model) Init() tea.Cmd { return tea.Batch(cmds...) } -// Update dispatches messages to the appropriate mode handler. -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - return m.handleResize(msg) - case tickMsg: - return m.handleTick() - case diffLoadedMsg: - return m.handleDiffLoaded(msg) - case filesRefreshedMsg: - return m.handleFilesRefreshed(msg) - case commitDoneMsg: - return m.handleCommitDone(msg) - case commitMsgGeneratedMsg: - return m.handleCommitMsgGenerated(msg) - case branchesLoadedMsg: - return m.handleBranchesLoaded(msg) - case branchSwitchedMsg: - return m.handleBranchSwitched(msg) - case branchCreatedMsg: - return m.handleBranchCreated(msg) - case upstreamStatusMsg: - m.upstream = msg.info - return m, nil - case pushDoneMsg: - return m.handlePushDone(msg) - case pullDoneMsg: - return m.handlePullDone(msg) - case savePrefDoneMsg: - if msg.err != nil { - m.statusMsg = "config save failed" - } - return m, nil - case tea.KeyMsg: - switch m.mode { - case modeFileList: - return m.updateFileListMode(msg) - case modeDiff: - return m.updateDiffMode(msg) - case modeCommit: - return m.updateCommitMode(msg) - case modeBranchPicker: - return m.updateBranchMode(msg) - } - } - return m, nil -} - -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(true) -} - -func (m Model) handleDiffLoaded(msg diffLoadedMsg) (tea.Model, tea.Cmd) { - 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(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(true) -} - -func (m Model) handleCommitDone(msg commitDoneMsg) (tea.Model, tea.Cmd) { - m.mode = modeFileList - if msg.err != nil { - m.statusMsg = "commit failed: " + msg.err.Error() - return m, nil - } - m.statusMsg = "committed!" - m.commitInput.Reset() - return m, m.refreshFilesCmd() -} - -func (m Model) handleCommitMsgGenerated(msg commitMsgGeneratedMsg) (tea.Model, tea.Cmd) { - m.generatingMsg = false - if msg.err != nil { - m.statusMsg = "ai msg failed: " + msg.err.Error() - return m, nil - } - m.commitInput.SetValue(msg.message) - m.commitInput.CursorEnd() - return m, nil -} - -func (m Model) activeBranches() []string { - if m.filteredBranches != nil { - return m.filteredBranches - } - return m.branches -} - -func filterBranches(branches []string, query string) []string { - if query == "" { - return nil - } - q := strings.ToLower(query) - out := []string{} - for _, b := range branches { - if strings.Contains(strings.ToLower(b), q) { - out = append(out, b) - } - } - return out -} - -func (m Model) enterBranchMode() (tea.Model, tea.Cmd) { - repo := m.repo - return m, func() tea.Msg { - branches, err := repo.ListBranches() - if err != nil { - return branchesLoadedMsg{err: err} - } - current := repo.BranchName() - return branchesLoadedMsg{branches: branches, current: current} - } -} - -func (m Model) handleBranchesLoaded(msg branchesLoadedMsg) (tea.Model, tea.Cmd) { - if msg.err != nil { - m.statusMsg = "branch list failed: " + msg.err.Error() - return m, nil - } - if len(msg.branches) == 0 { - m.statusMsg = "no branches" - return m, nil - } - m.mode = modeBranchPicker - m.branches = msg.branches - m.currentBranch = msg.current - m.branchCursor = 0 - m.branchOffset = 0 - for i, b := range m.branches { - if b == msg.current { - m.branchCursor = i - break - } - } - m.filteredBranches = nil - m.branchFilter.Reset() - m.branchFilter.Focus() - return m, textinput.Blink -} - -func (m Model) handleBranchSwitched(msg branchSwitchedMsg) (tea.Model, tea.Cmd) { - m.mode = modeFileList - m.filteredBranches = nil - m.branchFilter.Reset() - m.branchFilter.Blur() - if msg.err != nil { - m.statusMsg = "switch failed: " + msg.err.Error() - return m, nil - } - m.statusMsg = "switched to " + m.repo.BranchName() - m.prevCurs = -1 - m.cursor = 0 - return m, m.refreshFilesCmd() -} - -func (m Model) handleBranchCreated(msg branchCreatedMsg) (tea.Model, tea.Cmd) { - m.branchCreating = false - m.branchInput.Reset() - if msg.err != nil { - m.statusMsg = "create failed: " + msg.err.Error() - return m, nil - } - m.mode = modeFileList - m.statusMsg = "created & switched to " + msg.name - m.prevCurs = -1 - m.cursor = 0 - return m, m.refreshFilesCmd() -} - -func (m Model) handlePushDone(msg pushDoneMsg) (tea.Model, tea.Cmd) { - if msg.err != nil { - m.statusMsg = "push failed: " + msg.err.Error() - return m, nil - } - m.statusMsg = "pushed!" - return m, m.fetchUpstreamStatusCmd() -} - -func (m Model) handlePullDone(msg pullDoneMsg) (tea.Model, tea.Cmd) { - if msg.err != nil { - m.statusMsg = "pull failed: " + msg.err.Error() - return m, nil - } - m.statusMsg = "pulled!" - return m, tea.Batch(m.refreshFilesCmd(), m.fetchUpstreamStatusCmd()) -} - -func (m Model) fetchUpstreamStatusCmd() tea.Cmd { - repo := m.repo - return func() tea.Msg { - return upstreamStatusMsg{info: repo.UpstreamStatus()} - } -} - -func (m Model) pushCmd() tea.Cmd { - repo := m.repo - return func() tea.Msg { - return pushDoneMsg{err: repo.Push()} - } -} - -func (m Model) pushSetUpstreamCmd() tea.Cmd { - repo := m.repo - branch := m.currentBranch - if branch == "" { - branch = repo.BranchName() - } - return func() tea.Msg { - return pushDoneMsg{err: repo.PushSetUpstream("origin", branch)} - } -} - -func (m Model) pullCmd() tea.Cmd { - repo := m.repo - return func() tea.Msg { - return pullDoneMsg{err: repo.Pull()} - } -} - -func tickCmd() tea.Cmd { - return tea.Tick(pollInterval, func(t time.Time) tea.Msg { - return tickMsg(t) - }) -} - -func (m Model) handleTick() (tea.Model, tea.Cmd) { - if m.mode == modeCommit || m.mode == modeBranchPicker || m.generatingMsg { - return m, tickCmd() - } - return m, tea.Batch(m.refreshFilesCmd(), m.fetchUpstreamStatusCmd(), tickCmd()) -} - -// File list mode -func (m Model) updateFileListMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - m.statusMsg = "" - - // Handle push confirmation before clearing state - if msg.String() == "P" { - if m.pushConfirm { - m.pushConfirm = false - m.statusMsg = "pushing..." - if m.upstream.Upstream == "" { - return m, m.pushSetUpstreamCmd() - } - return m, m.pushCmd() - } - if m.upstream.Upstream == "" { - branch := m.currentBranch - if branch == "" { - branch = m.repo.BranchName() - } - m.pushConfirm = true - m.statusMsg = "press P again to push --set-upstream origin " + branch - return m, nil - } - m.pushConfirm = true - m.statusMsg = "press P again to push to " + m.upstream.Upstream - return m, nil - } - m.pushConfirm = false - - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "j", "down": - if m.cursor < len(m.files)-1 { - m.cursor++ - } - case "k", "up": - if m.cursor > 0 { - m.cursor-- - } - case "g": - m.cursor = 0 - case "G": - m.cursor = max(0, len(m.files)-1) - case "enter", "l", "right": - m.mode = modeDiff - return m, nil - case "e": - if m.cursor < len(m.files) { - m.SelectedFile = m.files[m.cursor].change.Path - } - return m, tea.Quit - case "tab": - return m.toggleStage() - case "a": - return m.stageAll() - case "c": - return m.enterCommitMode() - case "b": - return m.enterBranchMode() - case "v": - m.splitDiff = !m.splitDiff - m.prevCurs = -1 - m.lastDiffContent = "" - return m, tea.Batch(m.loadDiffCmd(true), m.saveSplitPrefCmd()) - case "F": - if m.upstream.Upstream == "" { - m.statusMsg = "no upstream configured" - return m, nil - } - m.statusMsg = "pulling..." - return m, m.pullCmd() - } - if m.cursor != m.prevCurs { - m.prevCurs = m.cursor - return m, m.loadDiffCmd(true) - } - return m, nil -} - -// Diff view mode -func (m Model) updateDiffMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "esc", "h", "left": - m.mode = modeFileList - return m, nil - case "n": - return m.nextFile() - case "p": - return m.prevFile() - case "e": - if m.cursor < len(m.files) { - m.SelectedFile = m.files[m.cursor].change.Path - } - return m, tea.Quit - case "b": - return m.enterBranchMode() - case "tab": - return m.toggleStage() - case "v": - m.splitDiff = !m.splitDiff - m.prevCurs = -1 - m.lastDiffContent = "" - return m, tea.Batch(m.loadDiffCmd(true), m.saveSplitPrefCmd()) - } - var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) - return m, cmd -} - -// Branch picker mode -func (m Model) updateBranchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if m.branchCreating { - return m.updateBranchCreateMode(msg) - } - - switch msg.String() { - case "ctrl+n": - m.branchCreating = true - m.branchInput.Reset() - m.branchInput.Focus() - m.branchFilter.Blur() - return m, textinput.Blink - case "esc": - if m.branchFilter.Value() != "" { - m.branchFilter.Reset() - m.filteredBranches = nil - m.branchCursor = 0 - m.branchOffset = 0 - return m, nil - } - m.mode = modeFileList - m.branchFilter.Blur() - return m, nil - case "ctrl+c": - return m, tea.Quit - case "up", "ctrl+k": - if m.branchCursor > 0 { - m.branchCursor-- - } - m = m.clampBranchScroll() - return m, nil - case "down", "ctrl+j": - list := m.activeBranches() - if m.branchCursor < len(list)-1 { - m.branchCursor++ - } - m = m.clampBranchScroll() - return m, nil - case "enter": - list := m.activeBranches() - if m.branchCursor >= len(list) || len(list) == 0 { - return m, nil - } - selected := list[m.branchCursor] - m.branchFilter.Blur() - if selected == m.currentBranch { - m.mode = modeFileList - return m, nil - } - repo := m.repo - return m, func() tea.Msg { - return branchSwitchedMsg{err: repo.CheckoutBranch(selected)} - } - } - - // All other keys go to the filter input - prevVal := m.branchFilter.Value() - var cmd tea.Cmd - m.branchFilter, cmd = m.branchFilter.Update(msg) - if m.branchFilter.Value() != prevVal { - m.filteredBranches = filterBranches(m.branches, m.branchFilter.Value()) - m.branchCursor = 0 - m.branchOffset = 0 - } - return m, cmd -} - -// Branch create sub-mode -func (m Model) updateBranchCreateMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "esc", "ctrl+c": - m.branchCreating = false - m.branchInput.Reset() - m.branchFilter.Focus() - return m, nil - case "enter": - name := strings.TrimSpace(m.branchInput.Value()) - if name == "" { - m.statusMsg = "empty branch name" - return m, nil - } - return m, m.createBranchCmd(name) - } - var cmd tea.Cmd - m.branchInput, cmd = m.branchInput.Update(msg) - return m, cmd -} - -func (m Model) clampBranchScroll() Model { - h := m.contentHeight() - 1 // -1 for filter bar - if h <= 0 { - return m - } - if m.branchCursor < m.branchOffset { - m.branchOffset = m.branchCursor - } else if m.branchCursor >= m.branchOffset+h { - m.branchOffset = m.branchCursor - h + 1 - } - return m -} - -// Commit mode -func (m Model) updateCommitMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "esc": - m.mode = modeFileList - m.commitInput.Reset() - return m, nil - case "enter": - msg := m.commitInput.Value() - if strings.TrimSpace(msg) == "" { - m.statusMsg = "empty commit message" - return m, nil - } - return m, m.commitCmd(msg) - } - var cmd tea.Cmd - m.commitInput, cmd = m.commitInput.Update(msg) - return m, cmd -} - -// Stage/unstage operations -func (m Model) toggleStage() (tea.Model, tea.Cmd) { - if m.stagedOnly || m.ref != "" || len(m.files) == 0 { - return m, nil - } - f := m.files[m.cursor] - repo := m.repo - path := f.change.Path - - return m, func() tea.Msg { - if f.change.Staged { - _ = repo.UnstageFile(path) - } else { - _ = repo.StageFile(path) - } - return m.buildRefreshedFiles() - } -} - -func (m Model) stageAll() (tea.Model, tea.Cmd) { - if m.stagedOnly || m.ref != "" { - return m, nil - } - repo := m.repo - return m, func() tea.Msg { - _ = repo.StageAll() - return m.buildRefreshedFiles() - } -} - -func (m Model) enterCommitMode() (tea.Model, tea.Cmd) { - if m.ref != "" { - return m, nil - } - hasStaged := false - for _, f := range m.files { - if f.change.Staged { - hasStaged = true - break - } - } - if !hasStaged { - m.statusMsg = "no staged files" - return m, nil - } - m.mode = modeCommit - m.generatingMsg = true - m.statusMsg = "generating commit message..." - m.commitInput.Focus() - return m, tea.Batch(textinput.Blink, m.generateCommitMsgCmd()) -} - -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(true) - } - return m, nil -} - -func (m Model) prevFile() (tea.Model, tea.Cmd) { - if m.cursor > 0 { - m.cursor-- - m.prevCurs = m.cursor - return m, m.loadDiffCmd(true) - } - return m, nil -} - -// Commands -func (m Model) loadDiffCmd(resetScroll bool) tea.Cmd { - if len(m.files) == 0 { - return nil - } - idx := m.cursor - f := m.files[idx] - repo := m.repo - styles := m.styles - t := m.theme - staged := f.change.Staged - ref := m.ref - diffW := m.diffWidth() - filename := f.change.Path - splitMode := m.splitDiff && diffW >= minSplitWidth - - return func() tea.Msg { - var content string - if f.untracked { - raw, err := repo.ReadFileContent(filename) - if err != nil { - content = styles.DiffHunkHeader.Render("Error: " + err.Error()) - } else if splitMode { - content = RenderNewFileSplit(raw, filename, styles, t, diffW) - } else { - content = RenderNewFile(raw, filename, styles, t, diffW) - } - } else { - raw, err := repo.DiffFile(filename, staged, ref) - if err != nil { - content = styles.DiffHunkHeader.Render("Error: " + err.Error()) - } else { - parsed := ParseDiff(raw) - if splitMode { - content = RenderSplitDiff(parsed, filename, styles, t, diffW) - } else { - content = RenderDiff(parsed, filename, styles, t, diffW) - } - } - } - return diffLoadedMsg{content: content, index: idx, resetScroll: resetScroll} - } -} - -func (m Model) refreshFilesCmd() tea.Cmd { - repo := m.repo - stagedOnly := m.stagedOnly - ref := m.ref - - return func() tea.Msg { - files, _ := repo.ChangedFiles(stagedOnly, ref) - var untracked []string - if !stagedOnly && ref == "" { - untracked, _ = repo.UntrackedFiles() - } - return filesRefreshedMsg{files: buildFileItems(repo, files, untracked)} - } -} - -func (m Model) buildRefreshedFiles() filesRefreshedMsg { - files, _ := m.repo.ChangedFiles(m.stagedOnly, m.ref) - var untracked []string - if !m.stagedOnly && m.ref == "" { - untracked, _ = m.repo.UntrackedFiles() - } - return filesRefreshedMsg{files: buildFileItems(m.repo, files, untracked)} -} - -type branchCreatedMsg struct { - name string - err error -} - -func (m Model) createBranchCmd(name string) tea.Cmd { - repo := m.repo - return func() tea.Msg { - if err := repo.CreateBranch(name); err != nil { - return branchCreatedMsg{name: name, err: err} - } - err := repo.CheckoutBranch(name) - return branchCreatedMsg{name: name, err: err} - } -} - -type savePrefDoneMsg struct{ err error } - -func (m Model) saveSplitPrefCmd() tea.Cmd { - cfg := m.cfg - split := m.splitDiff - return func() tea.Msg { - cfg.SplitDiff = split - return savePrefDoneMsg{err: config.Save(cfg)} - } -} - -func (m Model) commitCmd(message string) tea.Cmd { - repo := m.repo - return func() tea.Msg { - err := repo.Commit(message) - return commitDoneMsg{err: err} - } -} - -const defaultCommitMsgCmd = "claude -p" -const defaultCommitMsgPrompt = "Write a concise git commit message (one line, no quotes, use conventional commit prefixes like feat:, fix:, chore:, refactor: etc when appropriate) for this diff:" - -func (m Model) generateCommitMsgCmd() tea.Cmd { - repo := m.repo - cfg := m.cfg - return func() tea.Msg { - diff, err := repo.StagedDiff() - if err != nil { - return commitMsgGeneratedMsg{err: fmt.Errorf("git diff: %w", err)} - } - if strings.TrimSpace(diff) == "" { - return commitMsgGeneratedMsg{err: fmt.Errorf("empty staged diff")} - } - const maxDiff = 8000 - if len(diff) > maxDiff { - diff = diff[:maxDiff] + "\n... (truncated)" - } - - promptPrefix := defaultCommitMsgPrompt - if cfg.CommitMsgPrompt != "" { - promptPrefix = cfg.CommitMsgPrompt - } - prompt := promptPrefix + "\n\n" + diff - - cmdStr := defaultCommitMsgCmd - if cfg.CommitMsgCmd != "" { - cmdStr = cfg.CommitMsgCmd - } - parts := strings.Fields(cmdStr) - args := append(parts[1:], prompt) - cmd := exec.Command(parts[0], args...) - out, err := cmd.Output() - if err != nil { - return commitMsgGeneratedMsg{err: fmt.Errorf("%s: %w", parts[0], err)} - } - msg := strings.TrimSpace(string(out)) - return commitMsgGeneratedMsg{message: msg} - } -} - -// Layout: cards(content+2 border) + status(1) + help(1) = height func (m Model) contentHeight() int { return m.height - 4 } func (m Model) diffWidth() int { return m.width - fileListWidth - 2 - 1 - 2 } - -const ( - minWidth = 60 - minHeight = 10 -) - -// View renders the full UI. -func (m Model) View() string { - if m.width == 0 || !m.ready { - return "" - } - if m.width < minWidth || m.height < minHeight { - return fmt.Sprintf("Terminal too small (%dx%d). Minimum: %dx%d", - m.width, m.height, minWidth, minHeight) - } - - contentH := m.contentHeight() - - var fileContent string - if m.mode == modeBranchPicker { - fileContent = m.renderBranchList(contentH) - } else { - fileContent = m.renderFileList(contentH) - } - fileCard := m.renderCard( - m.fileCardTitle(), fileContent, - m.mode == modeFileList || m.mode == modeBranchPicker, - fileListWidth, contentH, - ) - - diffCard := m.renderCard( - m.diffCardTitle(), m.viewport.View(), - m.mode == modeDiff, - m.diffWidth(), contentH, - ) - - main := lipgloss.JoinHorizontal(lipgloss.Top, fileCard, " ", diffCard) - statusBar := m.renderStatusBar() - - if m.mode == modeCommit { - return lipgloss.JoinVertical(lipgloss.Left, main, statusBar, m.renderCommitBar()) - } - if m.mode == modeBranchPicker && m.branchCreating { - return lipgloss.JoinVertical(lipgloss.Left, main, statusBar, m.renderBranchCreateBar()) - } - helpBar := m.renderHelpBar() - return lipgloss.JoinVertical(lipgloss.Left, main, statusBar, helpBar) -} - -func (m Model) renderCard(title, content string, focused bool, w, h int) string { - return renderCard(m.theme, title, content, focused, w, h) -} - -// renderCard builds a bordered card with Unicode box chars. -// Focused cards use AccentFg, unfocused use BorderFg. -func renderCard(t theme.Theme, title, content string, focused bool, w, h int) string { - borderColor := lipgloss.Color(t.BorderFg) - if focused { - borderColor = lipgloss.Color(t.AccentFg) - } - bs := lipgloss.NewStyle().Foreground(borderColor) - - // Top border: ╭─ Title ───────╮ - titleStr := "" - if title != "" { - titleStr = " " + title + " " - } - topFill := w - lipgloss.Width(titleStr) - 1 // -1: ╭─(2) + title + fill + ╮(1) = w+2 - if topFill < 0 { - topFill = 0 - } - top := bs.Render("╭─" + titleStr + strings.Repeat("─", topFill) + "╮") - - // Content lines: │line│ (pad/truncate to w) - lines := strings.Split(content, "\n") - for len(lines) < h { - lines = append(lines, "") - } - cardBg := lipgloss.Color(t.CardBg) - var rows []string - for i := 0; i < h; i++ { - line := lines[i] - pad := w - lipgloss.Width(line) - if pad > 0 { - line += lipgloss.NewStyle().Background(cardBg).Render(strings.Repeat(" ", pad)) - } - rows = append(rows, bs.Render("│")+line+bs.Render("│")) - } - - // Bottom border: ╰───────────╯ - bottom := bs.Render("╰" + strings.Repeat("─", w) + "╯") - - return lipgloss.JoinVertical(lipgloss.Left, top, strings.Join(rows, "\n"), bottom) -} - -func (m Model) fileCardTitle() string { - if m.mode == modeBranchPicker { - return "Branches" - } - title := m.repo.BranchName() - if m.ref != "" { - title += " ref:" + m.ref - } else if m.stagedOnly { - title += " staged" - } - return title -} - -func (m Model) diffCardTitle() string { - if len(m.files) == 0 || m.cursor >= len(m.files) { - return "" - } - f := m.files[m.cursor] - name := f.change.Path - if f.change.Staged { - name += " [staged]" - } - return name -} - -func (m Model) renderFileList(height int) string { - var b strings.Builder - for i, f := range m.files { - if i >= height { - break - } - b.WriteString(m.renderFileItem(f, i == m.cursor)) - if i < len(m.files)-1 { - b.WriteByte('\n') - } - } - return b.String() -} - -func (m Model) renderFileItem(f fileItem, selected bool) string { - status := string(f.change.Status) - stagedRaw := " " - if f.change.Staged { - stagedRaw = "● " - } - - stats := fmt.Sprintf("+%d -%d", f.change.AddedLines, f.change.DeletedLines) - name := filepath.Base(f.change.Path) - if f.change.OldPath != "" { - name = filepath.Base(f.change.OldPath) + " → " + filepath.Base(f.change.Path) - } - nameMaxW := fileListWidth - lipgloss.Width(stagedRaw) - lipgloss.Width(status) - 1 - lipgloss.Width(stats) - 1 - if nameMaxW < 1 { - nameMaxW = 1 - } - name = truncatePath(name, nameMaxW) - - if selected { - // Plain text only — no inner ANSI, so FileSelected color applies uniformly - line := fmt.Sprintf("%s%s %s %s", stagedRaw, status, name, stats) - return m.styles.FileSelected.Width(fileListWidth).Render(line) - } - staged := stagedRaw - if f.change.Staged { - staged = m.styles.StagedIcon.Render("● ") - } - statusStyled := m.styleStatus(status, f.change.Status) - line := fmt.Sprintf("%s%s %s %s", staged, statusStyled, name, stats) - return m.styles.FileItem.Width(fileListWidth).Render(line) -} - -func (m Model) renderBranchList(height int) string { - var b strings.Builder - - // Filter bar (row 0) - b.WriteString(m.renderBranchFilterBar()) - b.WriteByte('\n') - - list := m.activeBranches() - itemH := height - 1 // reserve 1 row for filter bar - - if len(list) == 0 { - noMatch := m.styles.HelpDesc.Render(" no matches") - b.WriteString(m.styles.FileItem.Width(fileListWidth).Render(noMatch)) - return b.String() - } - - end := m.branchOffset + itemH - if end > len(list) { - end = len(list) - } - for i := m.branchOffset; i < end; i++ { - branch := list[i] - b.WriteString(m.renderBranchItem(branch, i == m.branchCursor, branch == m.currentBranch)) - if i < end-1 { - b.WriteByte('\n') - } - } - return b.String() -} - -func (m Model) renderBranchFilterBar() string { - list := m.activeBranches() - count := fmt.Sprintf("%d/%d", len(list), len(m.branches)) - countStyled := m.styles.HelpDesc.Render(count) - countW := lipgloss.Width(countStyled) - - input := m.branchFilter.View() - inputW := lipgloss.Width(input) - - gap := fileListWidth - inputW - countW - 1 - if gap < 0 { - gap = 0 - } - line := input + strings.Repeat(" ", gap) + countStyled - return lipgloss.NewStyle().Width(fileListWidth).Render(line) -} - -func (m Model) renderBranchItem(name string, selected, current bool) string { - prefix := " " - if current { - prefix = m.styles.StagedIcon.Render("* ") - } - name = truncatePath(name, fileListWidth-4) - line := prefix + name - if selected { - return m.styles.FileSelected.Width(fileListWidth).Render(line) - } - return m.styles.FileItem.Width(fileListWidth).Render(line) -} - -func truncatePath(path string, maxW int) string { - if lipgloss.Width(path) <= maxW { - return path - } - for lipgloss.Width(path) > maxW-1 && len(path) > 1 { - path = path[1:] - } - return "…" + path -} - -func (m Model) styleStatus(icon string, status git.FileStatus) string { - switch status { - case git.StatusModified: - return m.styles.StatusModified.Render(icon) - case git.StatusAdded: - return m.styles.StatusAdded.Render(icon) - case git.StatusDeleted: - return m.styles.StatusDeleted.Render(icon) - case git.StatusRenamed: - return m.styles.StatusRenamed.Render(icon) - case git.StatusUntracked: - return m.styles.StatusUntracked.Render(icon) - default: - return icon - } -} - -func (m Model) renderStatusBar() string { - stagedCount := 0 - for _, f := range m.files { - if f.change.Staged { - stagedCount++ - } - } - - left := fmt.Sprintf(" %d staged %d files", stagedCount, len(m.files)) - if m.upstream.Upstream != "" && (m.upstream.Ahead > 0 || m.upstream.Behind > 0) { - left += fmt.Sprintf(" ↑%d ↓%d", m.upstream.Ahead, m.upstream.Behind) - } - if m.splitDiff { - left += " split" - } - if m.statusMsg != "" { - left += " " + m.statusMsg - } - - return m.styles.StatusBar.Width(m.width).Render(left) -} - -func (m Model) renderHelpBar() string { - var pairs []struct{ key, desc string } - switch m.mode { - case modeDiff: - pairs = []struct{ key, desc string }{ - {"j/k", "scroll"}, - {"d/u", "½ page"}, - {"n/p", "next/prev"}, - {"v", "split"}, - {"tab", "stage"}, - {"e", "edit"}, - {"b", "branches"}, - {"esc", "back"}, - {"q", "quit"}, - } - case modeBranchPicker: - pairs = []struct{ key, desc string }{ - {"type", "filter"}, - {"↑/↓/^j/^k", "navigate"}, - {"enter", "switch"}, - {"^n", "new"}, - {"esc", "clear/close"}, - } - default: - pairs = []struct{ key, desc string }{ - {"j/k", "navigate"}, - {"enter", "view diff"}, - {"v", "split"}, - {"tab", "stage/unstage"}, - {"a", "stage all"}, - {"e", "edit"}, - {"b", "branches"}, - {"c", "commit"}, - {"P", "push"}, - {"F", "pull"}, - {"q", "quit"}, - } - } - - var parts []string - for _, p := range pairs { - parts = append(parts, - m.styles.HelpKey.Render(p.key)+" "+m.styles.HelpDesc.Render(p.desc)) - } - bar := " " + strings.Join(parts, " · ") - return lipgloss.NewStyle().Width(m.width).Render(bar) -} - -func (m Model) renderCommitBar() string { - prompt := m.styles.HelpKey.Render(" commit: ") - if m.generatingMsg { - hint := m.styles.HelpDesc.Render("generating... esc cancel") - return lipgloss.NewStyle().Width(m.width).Render(prompt + hint) - } - input := m.commitInput.View() - esc := " " + m.styles.HelpDesc.Render("esc cancel · enter commit") - return lipgloss.NewStyle().Width(m.width).Render(prompt + input + esc) -} - -func (m Model) renderBranchCreateBar() string { - prompt := m.styles.HelpKey.Render(" new branch: ") - input := m.branchInput.View() - esc := " " + m.styles.HelpDesc.Render("esc cancel · enter create") - return lipgloss.NewStyle().Width(m.width).Render(prompt + input + esc) -} diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index a0616bd..2277e5e 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -855,3 +855,132 @@ func TestPush_NoUpstream_ConfirmPushes(t *testing.T) { t.Error("expected push cmd") } } + +func TestEnterCommitMode_NoStaged_SetsStatus(t *testing.T) { + t.Parallel() + m := newTestModel(t, []fileItem{{change: git.FileChange{Path: "a.go", Staged: false}}}) + + result, cmd := m.enterCommitMode() + rm := result.(Model) + + if cmd != nil { + t.Error("expected no cmd") + } + if rm.mode != modeFileList { + t.Errorf("mode=%v, want file list", rm.mode) + } + if !strings.Contains(rm.statusMsg, "no staged files") { + t.Errorf("statusMsg=%q", rm.statusMsg) + } +} + +func TestEnterCommitMode_WithStaged_EntersCommitMode(t *testing.T) { + t.Parallel() + m := newTestModel(t, []fileItem{{change: git.FileChange{Path: "a.go", Staged: true}}}) + + result, cmd := m.enterCommitMode() + rm := result.(Model) + + if rm.mode != modeCommit { + t.Errorf("mode=%v, want commit", rm.mode) + } + if !rm.generatingMsg { + t.Error("generatingMsg should be true") + } + if cmd == nil { + t.Error("expected batch cmd") + } +} + +func TestUpdateCommitMode_EnterEmpty_ShowsError(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeCommit + + result, cmd := m.updateCommitMode(tea.KeyMsg{Type: tea.KeyEnter}) + rm := result.(Model) + + if cmd != nil { + t.Error("expected no cmd") + } + if !strings.Contains(rm.statusMsg, "empty commit message") { + t.Errorf("statusMsg=%q", rm.statusMsg) + } +} + +func TestToggleStage_DisabledInStagedOnlyOrRef(t *testing.T) { + t.Parallel() + files := []fileItem{{change: git.FileChange{Path: "a.go", Staged: false}}} + + m1 := newTestModel(t, files) + m1.stagedOnly = true + _, cmd1 := m1.toggleStage() + if cmd1 != nil { + t.Error("toggleStage should be disabled for stagedOnly") + } + + m2 := newTestModel(t, files) + m2.ref = "main" + _, cmd2 := m2.toggleStage() + if cmd2 != nil { + t.Error("toggleStage should be disabled for ref mode") + } +} + +func TestStageAll_DisabledInRefMode(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.ref = "main" + + _, cmd := m.stageAll() + if cmd != nil { + t.Error("stageAll should be disabled in ref mode") + } +} + +func TestHandleFilesRefreshed_EmptyClearsViewport(t *testing.T) { + t.Parallel() + m := newTestModel(t, []fileItem{{change: git.FileChange{Path: "a.go"}}}) + m.viewport.SetContent("old") + + result, cmd := m.handleFilesRefreshed(filesRefreshedMsg{files: nil}) + rm := result.(Model) + + if cmd != nil { + t.Error("expected no cmd") + } + if rm.cursor != 0 { + t.Errorf("cursor=%d, want 0", rm.cursor) + } + if rm.viewport.View() != "" { + t.Errorf("viewport=%q, want empty", rm.viewport.View()) + } +} + +func TestHandleTick_SkipsPollingDuringCommit(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeCommit + + _, cmd := m.handleTick() + if cmd == nil { + t.Fatal("expected tick cmd") + } + msg := cmd() + if _, ok := msg.(tickMsg); !ok { + t.Errorf("msg type=%T, want tickMsg", msg) + } +} + +func TestPushConfirm_ResetOnNonPKey(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + m.mode = modeFileList + m.pushConfirm = true + + result, _ := m.updateFileListMode(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + rm := result.(Model) + if rm.pushConfirm { + t.Error("pushConfirm should reset on non-P key") + } +} diff --git a/internal/ui/render_layout.go b/internal/ui/render_layout.go new file mode 100644 index 0000000..fcb28a2 --- /dev/null +++ b/internal/ui/render_layout.go @@ -0,0 +1,266 @@ +package ui + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/jansmrcka/differ/internal/git" + "github.com/jansmrcka/differ/internal/theme" +) + +// View composition and all rendering helpers. + +func (m Model) View() string { + if m.width == 0 || !m.ready { + return "" + } + if m.width < minWidth || m.height < minHeight { + return fmt.Sprintf("Terminal too small (%dx%d). Minimum: %dx%d", m.width, m.height, minWidth, minHeight) + } + contentH := m.contentHeight() + var fileContent string + if m.mode == modeBranchPicker { + fileContent = m.renderBranchList(contentH) + } else { + fileContent = m.renderFileList(contentH) + } + fileCard := m.renderCard(m.fileCardTitle(), fileContent, m.mode == modeFileList || m.mode == modeBranchPicker, fileListWidth, contentH) + diffCard := m.renderCard(m.diffCardTitle(), m.viewport.View(), m.mode == modeDiff, m.diffWidth(), contentH) + main := lipgloss.JoinHorizontal(lipgloss.Top, fileCard, " ", diffCard) + statusBar := m.renderStatusBar() + if m.mode == modeCommit { + return lipgloss.JoinVertical(lipgloss.Left, main, statusBar, m.renderCommitBar()) + } + if m.mode == modeBranchPicker && m.branchCreating { + return lipgloss.JoinVertical(lipgloss.Left, main, statusBar, m.renderBranchCreateBar()) + } + return lipgloss.JoinVertical(lipgloss.Left, main, statusBar, m.renderHelpBar()) +} + +func (m Model) renderCard(title, content string, focused bool, w, h int) string { + return renderCard(m.theme, title, content, focused, w, h) +} + +func renderCard(t theme.Theme, title, content string, focused bool, w, h int) string { + borderColor := lipgloss.Color(t.BorderFg) + if focused { + borderColor = lipgloss.Color(t.AccentFg) + } + bs := lipgloss.NewStyle().Foreground(borderColor) + titleStr := "" + if title != "" { + titleStr = " " + title + " " + } + topFill := w - lipgloss.Width(titleStr) - 1 + if topFill < 0 { + topFill = 0 + } + top := bs.Render("╭─" + titleStr + strings.Repeat("─", topFill) + "╮") + lines := strings.Split(content, "\n") + for len(lines) < h { + lines = append(lines, "") + } + cardBg := lipgloss.Color(t.CardBg) + var rows []string + for i := 0; i < h; i++ { + line := lines[i] + pad := w - lipgloss.Width(line) + if pad > 0 { + line += lipgloss.NewStyle().Background(cardBg).Render(strings.Repeat(" ", pad)) + } + rows = append(rows, bs.Render("│")+line+bs.Render("│")) + } + bottom := bs.Render("╰" + strings.Repeat("─", w) + "╯") + return lipgloss.JoinVertical(lipgloss.Left, top, strings.Join(rows, "\n"), bottom) +} + +func (m Model) fileCardTitle() string { + if m.mode == modeBranchPicker { + return "Branches" + } + title := m.repo.BranchName() + if m.ref != "" { + title += " ref:" + m.ref + } else if m.stagedOnly { + title += " staged" + } + return title +} + +func (m Model) diffCardTitle() string { + if len(m.files) == 0 || m.cursor >= len(m.files) { + return "" + } + f := m.files[m.cursor] + name := f.change.Path + if f.change.Staged { + name += " [staged]" + } + return name +} + +func (m Model) renderFileList(height int) string { + var b strings.Builder + for i, f := range m.files { + if i >= height { + break + } + b.WriteString(m.renderFileItem(f, i == m.cursor)) + if i < len(m.files)-1 { + b.WriteByte('\n') + } + } + return b.String() +} + +func (m Model) renderFileItem(f fileItem, selected bool) string { + status := string(f.change.Status) + stagedRaw := " " + if f.change.Staged { + stagedRaw = "● " + } + stats := fmt.Sprintf("+%d -%d", f.change.AddedLines, f.change.DeletedLines) + name := filepath.Base(f.change.Path) + if f.change.OldPath != "" { + name = filepath.Base(f.change.OldPath) + " → " + filepath.Base(f.change.Path) + } + nameMaxW := fileListWidth - lipgloss.Width(stagedRaw) - lipgloss.Width(status) - 1 - lipgloss.Width(stats) - 1 + if nameMaxW < 1 { + nameMaxW = 1 + } + name = truncatePath(name, nameMaxW) + if selected { + return m.styles.FileSelected.Width(fileListWidth).Render(fmt.Sprintf("%s%s %s %s", stagedRaw, status, name, stats)) + } + staged := stagedRaw + if f.change.Staged { + staged = m.styles.StagedIcon.Render("● ") + } + line := fmt.Sprintf("%s%s %s %s", staged, m.styleStatus(status, f.change.Status), name, stats) + return m.styles.FileItem.Width(fileListWidth).Render(line) +} + +func (m Model) renderBranchList(height int) string { + var b strings.Builder + b.WriteString(m.renderBranchFilterBar()) + b.WriteByte('\n') + list := m.activeBranches() + itemH := height - 1 + if len(list) == 0 { + b.WriteString(m.styles.FileItem.Width(fileListWidth).Render(m.styles.HelpDesc.Render(" no matches"))) + return b.String() + } + end := m.branchOffset + itemH + if end > len(list) { + end = len(list) + } + for i := m.branchOffset; i < end; i++ { + b.WriteString(m.renderBranchItem(list[i], i == m.branchCursor, list[i] == m.currentBranch)) + if i < end-1 { + b.WriteByte('\n') + } + } + return b.String() +} + +func (m Model) renderBranchFilterBar() string { + list := m.activeBranches() + countStyled := m.styles.HelpDesc.Render(fmt.Sprintf("%d/%d", len(list), len(m.branches))) + input := m.branchFilter.View() + gap := fileListWidth - lipgloss.Width(input) - lipgloss.Width(countStyled) - 1 + if gap < 0 { + gap = 0 + } + return lipgloss.NewStyle().Width(fileListWidth).Render(input + strings.Repeat(" ", gap) + countStyled) +} + +func (m Model) renderBranchItem(name string, selected, current bool) string { + prefix := " " + if current { + prefix = m.styles.StagedIcon.Render("* ") + } + line := prefix + truncatePath(name, fileListWidth-4) + if selected { + return m.styles.FileSelected.Width(fileListWidth).Render(line) + } + return m.styles.FileItem.Width(fileListWidth).Render(line) +} + +func truncatePath(path string, maxW int) string { + if lipgloss.Width(path) <= maxW { + return path + } + for lipgloss.Width(path) > maxW-1 && len(path) > 1 { + path = path[1:] + } + return "…" + path +} + +func (m Model) styleStatus(icon string, status git.FileStatus) string { + switch status { + case git.StatusModified: + return m.styles.StatusModified.Render(icon) + case git.StatusAdded: + return m.styles.StatusAdded.Render(icon) + case git.StatusDeleted: + return m.styles.StatusDeleted.Render(icon) + case git.StatusRenamed: + return m.styles.StatusRenamed.Render(icon) + case git.StatusUntracked: + return m.styles.StatusUntracked.Render(icon) + default: + return icon + } +} + +func (m Model) renderStatusBar() string { + stagedCount := 0 + for _, f := range m.files { + if f.change.Staged { + stagedCount++ + } + } + left := fmt.Sprintf(" %d staged %d files", stagedCount, len(m.files)) + if m.upstream.Upstream != "" && (m.upstream.Ahead > 0 || m.upstream.Behind > 0) { + left += fmt.Sprintf(" ↑%d ↓%d", m.upstream.Ahead, m.upstream.Behind) + } + if m.splitDiff { + left += " split" + } + if m.statusMsg != "" { + left += " " + m.statusMsg + } + return m.styles.StatusBar.Width(m.width).Render(left) +} + +func (m Model) renderHelpBar() string { + var pairs []struct{ key, desc string } + switch m.mode { + case modeDiff: + pairs = []struct{ key, desc string }{{"j/k", "scroll"}, {"d/u", "½ page"}, {"n/p", "next/prev"}, {"v", "split"}, {"tab", "stage"}, {"e", "edit"}, {"b", "branches"}, {"esc", "back"}, {"q", "quit"}} + case modeBranchPicker: + pairs = []struct{ key, desc string }{{"type", "filter"}, {"↑/↓/^j/^k", "navigate"}, {"enter", "switch"}, {"^n", "new"}, {"esc", "clear/close"}} + default: + pairs = []struct{ key, desc string }{{"j/k", "navigate"}, {"enter", "view diff"}, {"v", "split"}, {"tab", "stage/unstage"}, {"a", "stage all"}, {"e", "edit"}, {"b", "branches"}, {"c", "commit"}, {"P", "push"}, {"F", "pull"}, {"q", "quit"}} + } + parts := make([]string, 0, len(pairs)) + for _, p := range pairs { + parts = append(parts, m.styles.HelpKey.Render(p.key)+" "+m.styles.HelpDesc.Render(p.desc)) + } + return lipgloss.NewStyle().Width(m.width).Render(" " + strings.Join(parts, " · ")) +} + +func (m Model) renderCommitBar() string { + prompt := m.styles.HelpKey.Render(" commit: ") + if m.generatingMsg { + return lipgloss.NewStyle().Width(m.width).Render(prompt + m.styles.HelpDesc.Render("generating... esc cancel")) + } + return lipgloss.NewStyle().Width(m.width).Render(prompt + m.commitInput.View() + " " + m.styles.HelpDesc.Render("esc cancel · enter commit")) +} + +func (m Model) renderBranchCreateBar() string { + prompt := m.styles.HelpKey.Render(" new branch: ") + return lipgloss.NewStyle().Width(m.width).Render(prompt + m.branchInput.View() + " " + m.styles.HelpDesc.Render("esc cancel · enter create")) +} diff --git a/internal/ui/update_dispatch.go b/internal/ui/update_dispatch.go new file mode 100644 index 0000000..f564868 --- /dev/null +++ b/internal/ui/update_dispatch.go @@ -0,0 +1,170 @@ +package ui + +import ( + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +// Update stays dispatcher-only; behavior lives in focused modules. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + return m.handleResize(msg) + case tickMsg: + return m.handleTick() + case diffLoadedMsg: + return m.handleDiffLoaded(msg) + case filesRefreshedMsg: + return m.handleFilesRefreshed(msg) + case commitDoneMsg: + return m.handleCommitDone(msg) + case commitMsgGeneratedMsg: + return m.handleCommitMsgGenerated(msg) + case branchesLoadedMsg: + return m.handleBranchesLoaded(msg) + case branchSwitchedMsg: + return m.handleBranchSwitched(msg) + case branchCreatedMsg: + return m.handleBranchCreated(msg) + case upstreamStatusMsg: + m.upstream = msg.info + return m, nil + case pushDoneMsg: + return m.handlePushDone(msg) + case pullDoneMsg: + return m.handlePullDone(msg) + case savePrefDoneMsg: + if msg.err != nil { + m.statusMsg = "config save failed" + } + return m, nil + case tea.KeyMsg: + switch m.mode { + case modeFileList: + return m.updateFileListMode(msg) + case modeDiff: + return m.updateDiffMode(msg) + case modeCommit: + return m.updateCommitMode(msg) + case modeBranchPicker: + return m.updateBranchMode(msg) + } + } + return m, nil +} + +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 = "" + m.ready = true + return m, m.loadDiffCmd(true) +} + +func (m Model) handleDiffLoaded(msg diffLoadedMsg) (tea.Model, tea.Cmd) { + if msg.index != m.cursor || 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(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(true) +} + +func (m Model) handleCommitDone(msg commitDoneMsg) (tea.Model, tea.Cmd) { + m.mode = modeFileList + if msg.err != nil { + m.statusMsg = "commit failed: " + msg.err.Error() + return m, nil + } + m.statusMsg = "committed!" + m.commitInput.Reset() + return m, m.refreshFilesCmd() +} + +func (m Model) handleCommitMsgGenerated(msg commitMsgGeneratedMsg) (tea.Model, tea.Cmd) { + m.generatingMsg = false + if msg.err != nil { + m.statusMsg = "ai msg failed: " + msg.err.Error() + return m, nil + } + m.commitInput.SetValue(msg.message) + m.commitInput.CursorEnd() + return m, nil +} + +func (m Model) handleBranchesLoaded(msg branchesLoadedMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.statusMsg = "branch list failed: " + msg.err.Error() + return m, nil + } + if len(msg.branches) == 0 { + m.statusMsg = "no branches" + return m, nil + } + m.mode = modeBranchPicker + m.branches = msg.branches + m.currentBranch = msg.current + m.branchCursor = 0 + m.branchOffset = 0 + for i, b := range m.branches { + if b == msg.current { + m.branchCursor = i + break + } + } + m.filteredBranches = nil + m.branchFilter.Reset() + m.branchFilter.Focus() + return m, textinput.Blink +} + +func (m Model) handleBranchSwitched(msg branchSwitchedMsg) (tea.Model, tea.Cmd) { + m.mode = modeFileList + m.filteredBranches = nil + m.branchFilter.Reset() + m.branchFilter.Blur() + if msg.err != nil { + m.statusMsg = "switch failed: " + msg.err.Error() + return m, nil + } + m.statusMsg = "switched to " + m.repo.BranchName() + m.prevCurs = -1 + m.cursor = 0 + return m, m.refreshFilesCmd() +} + +func (m Model) handleBranchCreated(msg branchCreatedMsg) (tea.Model, tea.Cmd) { + m.branchCreating = false + m.branchInput.Reset() + if msg.err != nil { + m.statusMsg = "create failed: " + msg.err.Error() + return m, nil + } + m.mode = modeFileList + m.statusMsg = "created & switched to " + msg.name + m.prevCurs = -1 + m.cursor = 0 + return m, m.refreshFilesCmd() +} diff --git a/internal/ui/workflow_commit_stage_sync.go b/internal/ui/workflow_commit_stage_sync.go new file mode 100644 index 0000000..4432a66 --- /dev/null +++ b/internal/ui/workflow_commit_stage_sync.go @@ -0,0 +1,253 @@ +package ui + +import ( + "fmt" + "os/exec" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/jansmrcka/differ/internal/config" +) + +// Commit, staging, polling, sync, and async command workflows. + +func (m Model) updateCommitMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.mode = modeFileList + m.commitInput.Reset() + return m, nil + case "enter": + message := m.commitInput.Value() + if strings.TrimSpace(message) == "" { + m.statusMsg = "empty commit message" + return m, nil + } + return m, m.commitCmd(message) + } + var cmd tea.Cmd + m.commitInput, cmd = m.commitInput.Update(msg) + return m, cmd +} + +func (m Model) toggleStage() (tea.Model, tea.Cmd) { + if m.stagedOnly || m.ref != "" || len(m.files) == 0 { + return m, nil + } + f := m.files[m.cursor] + repo := m.repo + path := f.change.Path + return m, func() tea.Msg { + if f.change.Staged { + _ = repo.UnstageFile(path) + } else { + _ = repo.StageFile(path) + } + return m.buildRefreshedFiles() + } +} + +func (m Model) stageAll() (tea.Model, tea.Cmd) { + if m.stagedOnly || m.ref != "" { + return m, nil + } + repo := m.repo + return m, func() tea.Msg { + _ = repo.StageAll() + return m.buildRefreshedFiles() + } +} + +func (m Model) enterCommitMode() (tea.Model, tea.Cmd) { + if m.ref != "" { + return m, nil + } + hasStaged := false + for _, f := range m.files { + if f.change.Staged { + hasStaged = true + break + } + } + if !hasStaged { + m.statusMsg = "no staged files" + return m, nil + } + m.mode = modeCommit + m.generatingMsg = true + m.statusMsg = "generating commit message..." + m.commitInput.Focus() + return m, tea.Batch(textinput.Blink, m.generateCommitMsgCmd()) +} + +func (m Model) fetchUpstreamStatusCmd() tea.Cmd { + repo := m.repo + return func() tea.Msg { return upstreamStatusMsg{info: repo.UpstreamStatus()} } +} + +func (m Model) pushCmd() tea.Cmd { + repo := m.repo + return func() tea.Msg { return pushDoneMsg{err: repo.Push()} } +} + +func (m Model) pushSetUpstreamCmd() tea.Cmd { + repo := m.repo + branch := m.currentBranch + if branch == "" { + branch = repo.BranchName() + } + return func() tea.Msg { return pushDoneMsg{err: repo.PushSetUpstream("origin", branch)} } +} + +func (m Model) pullCmd() tea.Cmd { + repo := m.repo + return func() tea.Msg { return pullDoneMsg{err: repo.Pull()} } +} + +func tickCmd() tea.Cmd { + return tea.Tick(pollInterval, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +func (m Model) handleTick() (tea.Model, tea.Cmd) { + if m.mode == modeCommit || m.mode == modeBranchPicker || m.generatingMsg { + return m, tickCmd() + } + return m, tea.Batch(m.refreshFilesCmd(), m.fetchUpstreamStatusCmd(), tickCmd()) +} + +func (m Model) handlePushDone(msg pushDoneMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.statusMsg = "push failed: " + msg.err.Error() + return m, nil + } + m.statusMsg = "pushed!" + return m, m.fetchUpstreamStatusCmd() +} + +func (m Model) handlePullDone(msg pullDoneMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.statusMsg = "pull failed: " + msg.err.Error() + return m, nil + } + m.statusMsg = "pulled!" + return m, tea.Batch(m.refreshFilesCmd(), m.fetchUpstreamStatusCmd()) +} + +func (m Model) loadDiffCmd(resetScroll bool) tea.Cmd { + if len(m.files) == 0 { + return nil + } + idx := m.cursor + f := m.files[idx] + repo := m.repo + styles := m.styles + t := m.theme + staged := f.change.Staged + ref := m.ref + diffW := m.diffWidth() + filename := f.change.Path + splitMode := m.splitDiff && diffW >= minSplitWidth + return func() tea.Msg { + var content string + if f.untracked { + raw, err := repo.ReadFileContent(filename) + if err != nil { + content = styles.DiffHunkHeader.Render("Error: " + err.Error()) + } else if splitMode { + content = RenderNewFileSplit(raw, filename, styles, t, diffW) + } else { + content = RenderNewFile(raw, filename, styles, t, diffW) + } + } else { + raw, err := repo.DiffFile(filename, staged, ref) + if err != nil { + content = styles.DiffHunkHeader.Render("Error: " + err.Error()) + } else { + parsed := ParseDiff(raw) + if splitMode { + content = RenderSplitDiff(parsed, filename, styles, t, diffW) + } else { + content = RenderDiff(parsed, filename, styles, t, diffW) + } + } + } + return diffLoadedMsg{content: content, index: idx, resetScroll: resetScroll} + } +} + +func (m Model) refreshFilesCmd() tea.Cmd { + repo := m.repo + stagedOnly := m.stagedOnly + ref := m.ref + return func() tea.Msg { + files, _ := repo.ChangedFiles(stagedOnly, ref) + var untracked []string + if !stagedOnly && ref == "" { + untracked, _ = repo.UntrackedFiles() + } + return filesRefreshedMsg{files: buildFileItems(repo, files, untracked)} + } +} + +func (m Model) buildRefreshedFiles() filesRefreshedMsg { + files, _ := m.repo.ChangedFiles(m.stagedOnly, m.ref) + var untracked []string + if !m.stagedOnly && m.ref == "" { + untracked, _ = m.repo.UntrackedFiles() + } + return filesRefreshedMsg{files: buildFileItems(m.repo, files, untracked)} +} + +func (m Model) saveSplitPrefCmd() tea.Cmd { + cfg := m.cfg + split := m.splitDiff + return func() tea.Msg { + cfg.SplitDiff = split + return savePrefDoneMsg{err: config.Save(cfg)} + } +} + +func (m Model) commitCmd(message string) tea.Cmd { + repo := m.repo + return func() tea.Msg { return commitDoneMsg{err: repo.Commit(message)} } +} + +const defaultCommitMsgCmd = "claude -p" +const defaultCommitMsgPrompt = "Write a concise git commit message (one line, no quotes, use conventional commit prefixes like feat:, fix:, chore:, refactor: etc when appropriate) for this diff:" + +func (m Model) generateCommitMsgCmd() tea.Cmd { + repo := m.repo + cfg := m.cfg + return func() tea.Msg { + diff, err := repo.StagedDiff() + if err != nil { + return commitMsgGeneratedMsg{err: fmt.Errorf("git diff: %w", err)} + } + if strings.TrimSpace(diff) == "" { + return commitMsgGeneratedMsg{err: fmt.Errorf("empty staged diff")} + } + const maxDiff = 8000 + if len(diff) > maxDiff { + diff = diff[:maxDiff] + "\n... (truncated)" + } + promptPrefix := defaultCommitMsgPrompt + if cfg.CommitMsgPrompt != "" { + promptPrefix = cfg.CommitMsgPrompt + } + prompt := promptPrefix + "\n\n" + diff + cmdStr := defaultCommitMsgCmd + if cfg.CommitMsgCmd != "" { + cmdStr = cfg.CommitMsgCmd + } + parts := strings.Fields(cmdStr) + args := append(parts[1:], prompt) + cmd := exec.Command(parts[0], args...) + out, err := cmd.Output() + if err != nil { + return commitMsgGeneratedMsg{err: fmt.Errorf("%s: %w", parts[0], err)} + } + return commitMsgGeneratedMsg{message: strings.TrimSpace(string(out))} + } +}