diff --git a/go.sum b/go.sum index 8720fdc..beffb1d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/internal/git/stage.go b/internal/git/stage.go index 3f5e034..8127d5a 100644 --- a/internal/git/stage.go +++ b/internal/git/stage.go @@ -128,3 +128,18 @@ func (g *GitCommands) Revert(commitHash string) (string, error) { return string(output), nil } + +// ResetToCommit resets the current HEAD to the specified commit. +func (g *GitCommands) ResetToCommit(commitHash string) (string, error) { + if commitHash == "" { + return "", fmt.Errorf("commit hash is required") + } + + cmd := exec.Command("git", "reset", "--hard", commitHash) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to reset to commit: %v", err) + } + + return string(output), nil +} diff --git a/internal/git/stash.go b/internal/git/stash.go index 6b743fb..7e8931f 100644 --- a/internal/git/stash.go +++ b/internal/git/stash.go @@ -107,3 +107,13 @@ func (g *GitCommands) Stash(options StashOptions) (string, error) { return string(output), nil } + +// StashAll stashes all changes, including untracked files. +func (g *GitCommands) StashAll() (string, error) { + cmd := exec.Command("git", "stash", "push", "-u", "-m", "gitx auto stash") + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to stash all changes: %v", err) + } + return string(output), nil +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 80eda90..2fac375 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -29,7 +29,6 @@ type KeyMap struct { StageItem key.Binding StageAll key.Binding Discard key.Binding - Reset key.Binding Stash key.Binding StashAll key.Binding Commit key.Binding @@ -73,7 +72,7 @@ func (k KeyMap) FullHelp() []HelpSection { Title: "Files", Bindings: []key.Binding{ k.Commit, k.Stash, k.StashAll, k.StageItem, - k.StageAll, k.Discard, k.Reset, + k.StageAll, k.Discard, }, }, { @@ -211,17 +210,13 @@ func DefaultKeyMap() KeyMap { key.WithKeys("d"), key.WithHelp("d", "Discard"), ), - Reset: key.NewBinding( - key.WithKeys("D"), - key.WithHelp("D", "Reset"), - ), Stash: key.NewBinding( key.WithKeys("s"), key.WithHelp("s", "Stash"), ), StashAll: key.NewBinding( key.WithKeys("S"), - key.WithHelp("S", "Stage all"), + key.WithHelp("S", "Stash all"), ), Commit: key.NewBinding( key.WithKeys("c"), diff --git a/internal/tui/model.go b/internal/tui/model.go index b65ad0e..3afb472 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -3,6 +3,7 @@ package tui import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -16,6 +17,7 @@ const ( modeNormal appMode = iota modeInput modeConfirm + modeCommit ) // Model represents the state of the TUI. @@ -37,12 +39,14 @@ type Model struct { repoName string branchName string // New fields for pop-ups - mode appMode - promptTitle string - confirmMessage string - textInput textinput.Model - inputCallback func(string) tea.Cmd - confirmCallback func(bool) tea.Cmd + mode appMode + promptTitle string + confirmMessage string + textInput textinput.Model + descriptionInput textarea.Model + inputCallback func(string) tea.Cmd + commitCallback func(title, description string) tea.Cmd + confirmCallback func(bool) tea.Cmd } // initialModel creates the initial state of the application. @@ -65,7 +69,12 @@ func initialModel() Model { ti := textinput.New() ti.Focus() ti.CharLimit = 256 - ti.Width = 50 + ti.Width = 80 + + ta := textarea.New() + ta.Placeholder = "Enter commit description" + ta.SetWidth(80) + ta.SetHeight(5) return Model{ theme: Themes[themeNames[0]], @@ -82,6 +91,7 @@ func initialModel() Model { panels: panels, mode: modeNormal, textInput: ti, + descriptionInput: ta, } } diff --git a/internal/tui/update.go b/internal/tui/update.go index 7cc5e84..1bb97cd 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -46,6 +46,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateInput(msg) case modeConfirm: return m.updateConfirm(msg) + case modeCommit: + return m.updateCommit(msg) } var cmd tea.Cmd @@ -148,15 +150,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) case tea.KeyMsg: + if m.showHelp { + switch { + case key.Matches(msg, keys.ToggleHelp): + m.toggleHelp() + return m, nil + case key.Matches(msg, keys.Escape): + m.toggleHelp() + return m, nil + default: + // Pass other keys to help viewport for scrolling + m.helpViewport, cmd = m.helpViewport.Update(msg) + return m, cmd + } + } + switch { case key.Matches(msg, keys.Quit): return m, tea.Quit + + case key.Matches(msg, keys.Escape): + return m, nil + case key.Matches(msg, keys.ToggleHelp): m.toggleHelp() - return m, nil + case key.Matches(msg, keys.SwitchTheme): m.nextTheme() - return m, nil + case key.Matches(msg, keys.FocusNext), key.Matches(msg, keys.FocusPrev), key.Matches(msg, keys.FocusZero), key.Matches(msg, keys.FocusOne), key.Matches(msg, keys.FocusTwo), key.Matches(msg, keys.FocusThree), @@ -218,6 +239,51 @@ func (m Model) updateInput(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +// updateCommit handles updates when in commit message input mode. +func (m Model) updateCommit(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + // Only submit if focused on title input + if m.textInput.Focused() { + title := m.textInput.Value() + description := m.descriptionInput.Value() + cmd = m.commitCallback(title, description) + m.mode = modeNormal + m.textInput.Reset() + m.descriptionInput.Reset() + return m, cmd + } else { + // If in description, allow newlines + m.descriptionInput, cmd = m.descriptionInput.Update(msg) + return m, cmd + } + case tea.KeyEsc: + m.mode = modeNormal + m.textInput.Reset() + m.descriptionInput.Reset() + return m, nil + case tea.KeyTab: + if m.textInput.Focused() { + m.textInput.Blur() + m.descriptionInput.Focus() + } else { + m.descriptionInput.Blur() + m.textInput.Focus() + } + return m, nil + } + } + if m.textInput.Focused() { + m.textInput, cmd = m.textInput.Update(msg) + } else { + m.descriptionInput, cmd = m.descriptionInput.Update(msg) + } + return m, cmd +} + // updateConfirm handles updates when in confirmation mode. func (m Model) updateConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd @@ -530,63 +596,79 @@ func (m *Model) handleFilesPanelKeys(msg tea.KeyMsg) tea.Cmd { switch { case key.Matches(msg, keys.Commit): - m.mode = modeInput - m.promptTitle = "Commit Message" - m.textInput.Placeholder = "Enter commit message" + m.mode = modeCommit + m.textInput.SetValue("") + m.descriptionInput.SetValue("") m.textInput.Focus() - m.inputCallback = func(message string) tea.Cmd { - if message == "" { - // Don't commit with an empty message - return nil - } + m.commitCallback = func(title, description string) tea.Cmd { return func() tea.Msg { - _, err := m.git.Commit(git.CommitOptions{Message: message}) + commitMsg := title + if description != "" { + commitMsg = title + "\n\n" + description + } + _, err := m.git.Commit(git.CommitOptions{Message: commitMsg}) if err != nil { return errMsg{err} } - return fileWatcherMsg{} + return tea.Batch( + m.fetchPanelContent(FilesPanel), + m.fetchPanelContent(CommitsPanel), + m.fetchPanelContent(SecondaryPanel), + ) } } - return nil + case key.Matches(msg, keys.StageItem): // If the item is unstaged, stage it, and vice-versa. - isStaged := len(status) > 0 && status[0] != ' ' && status[0] != '?' - return func() tea.Msg { - var err error - if isStaged { - _, err = m.git.ResetFiles([]string{filePath}) - } else { - _, err = m.git.AddFiles([]string{filePath}) - } + if status[0] == ' ' || status[0] == '?' { + _, err := m.git.AddFiles([]string{filePath}) if err != nil { - return errMsg{err} + return func() tea.Msg { return errMsg{err} } } - return fileWatcherMsg{} - } - case key.Matches(msg, keys.StageAll): - return func() tea.Msg { - _, err := m.git.AddFiles([]string{"."}) - if err != nil { - return errMsg{err} - } - return fileWatcherMsg{} - } - case key.Matches(msg, keys.Reset): - return func() tea.Msg { + } else { _, err := m.git.ResetFiles([]string{filePath}) if err != nil { - return errMsg{err} + return func() tea.Msg { return errMsg{err} } } - return fileWatcherMsg{} } + return m.fetchPanelContent(FilesPanel) + + case key.Matches(msg, keys.StageAll): + _, err := m.git.AddFiles([]string{"."}) + if err != nil { + return func() tea.Msg { return errMsg{err} } + } + return m.fetchPanelContent(FilesPanel) + case key.Matches(msg, keys.Discard): - return func() tea.Msg { - _, err := m.git.Restore(git.RestoreOptions{Paths: []string{filePath}, WorkingDir: true}) - if err != nil { - return errMsg{err} + m.mode = modeConfirm + m.confirmMessage = fmt.Sprintf("Discard changes to %s?", filePath) + m.confirmCallback = func(confirmed bool) tea.Cmd { + m.mode = modeNormal + if !confirmed { + return nil + } + return func() tea.Msg { + _, err := m.git.Restore(git.RestoreOptions{ + Paths: []string{filePath}, + WorkingDir: true, + }) + if err != nil { + return errMsg{err} + } + return m.fetchPanelContent(FilesPanel) } - return fileWatcherMsg{} } + + case key.Matches(msg, keys.StashAll): + _, err := m.git.StashAll() + if err != nil { + return func() tea.Msg { return errMsg{err} } + } + return tea.Batch( + m.fetchPanelContent(FilesPanel), + m.fetchPanelContent(StashPanel), + ) } return nil } @@ -608,17 +690,50 @@ func (m *Model) handleBranchesPanelKeys(msg tea.KeyMsg) tea.Cmd { switch { case key.Matches(msg, keys.Checkout): - return func() tea.Msg { - _, err := m.git.Checkout(branchName) - if err != nil { - return errMsg{err} + _, err := m.git.Checkout(branchName) + if err != nil { + return func() tea.Msg { return errMsg{err} } + } + return tea.Batch( + m.fetchPanelContent(StatusPanel), + m.fetchPanelContent(BranchesPanel), + m.fetchPanelContent(CommitsPanel), + ) + + case key.Matches(msg, keys.NewBranch): + m.mode = modeInput + m.promptTitle = "New Branch Name" + m.textInput.SetValue("") + m.textInput.Focus() + m.inputCallback = func(input string) tea.Cmd { + m.mode = modeNormal + if input == "" { + return nil + } + return func() tea.Msg { + // Create the new branch + _, err := m.git.ManageBranch(git.BranchOptions{Create: true, Name: input}) + if err != nil { + return errMsg{err} + } + // checkout to the new branch + _, err = m.git.Checkout(input) + if err != nil { + return errMsg{err} + } + return tea.Batch( + m.fetchPanelContent(BranchesPanel), + m.fetchPanelContent(StatusPanel), + m.fetchPanelContent(CommitsPanel), + ) } - return fileWatcherMsg{} } + case key.Matches(msg, keys.DeleteBranch): m.mode = modeConfirm - m.confirmMessage = fmt.Sprintf("Are you sure you want to delete branch '%s'?", branchName) + m.confirmMessage = fmt.Sprintf("Delete branch %s?", branchName) m.confirmCallback = func(confirmed bool) tea.Cmd { + m.mode = modeNormal if !confirmed { return nil } @@ -627,10 +742,31 @@ func (m *Model) handleBranchesPanelKeys(msg tea.KeyMsg) tea.Cmd { if err != nil { return errMsg{err} } - return fileWatcherMsg{} + return m.fetchPanelContent(BranchesPanel) + } + } + + case key.Matches(msg, keys.RenameBranch): + m.mode = modeInput + m.promptTitle = "New Branch Name" + m.textInput.SetValue(branchName) + m.textInput.Focus() + m.inputCallback = func(input string) tea.Cmd { + m.mode = modeNormal + if input == "" || input == branchName { + return nil + } + return func() tea.Msg { + _, err := m.git.RenameBranch(branchName, input) + if err != nil { + return errMsg{err} + } + return tea.Batch( + m.fetchPanelContent(BranchesPanel), + m.fetchPanelContent(StatusPanel), + ) } } - return nil } return nil } @@ -651,13 +787,68 @@ func (m *Model) handleCommitsPanelKeys(msg tea.KeyMsg) tea.Cmd { sha := parts[1] switch { + case key.Matches(msg, keys.AmendCommit): + m.mode = modeCommit + m.textInput.SetValue("") + m.descriptionInput.SetValue("") + m.textInput.Focus() + m.commitCallback = func(title, description string) tea.Cmd { + return func() tea.Msg { + commitMsg := title + if description != "" { + commitMsg = title + "\n\n" + description + } + _, err := m.git.Commit(git.CommitOptions{Message: commitMsg, Amend: true}) + if err != nil { + return errMsg{err} + } + return tea.Batch( + m.fetchPanelContent(CommitsPanel), + m.fetchPanelContent(FilesPanel), + m.fetchPanelContent(SecondaryPanel), + ) + } + } + case key.Matches(msg, keys.Revert): - return func() tea.Msg { - _, err := m.git.Revert(sha) - if err != nil { - return errMsg{err} + m.mode = modeConfirm + m.confirmMessage = fmt.Sprintf("Revert commit %s?", sha) + m.confirmCallback = func(confirmed bool) tea.Cmd { + m.mode = modeNormal + if !confirmed { + return nil + } + return func() tea.Msg { + _, err := m.git.Revert(sha) + if err != nil { + return errMsg{err} + } + return tea.Batch( + m.fetchPanelContent(CommitsPanel), + m.fetchPanelContent(FilesPanel), + ) + } + } + + case key.Matches(msg, keys.ResetToCommit): + m.mode = modeConfirm + m.confirmMessage = fmt.Sprintf("Hard reset to commit %s? This will discard all changes!", sha) + m.confirmCallback = func(confirmed bool) tea.Cmd { + m.mode = modeNormal + if !confirmed { + return nil + } + return func() tea.Msg { + _, err := m.git.ResetToCommit(sha) + if err != nil { + return errMsg{err} + } + return tea.Batch( + m.fetchPanelContent(CommitsPanel), + m.fetchPanelContent(FilesPanel), + m.fetchPanelContent(StatusPanel), + ) } - return fileWatcherMsg{} } } return nil @@ -680,28 +871,44 @@ func (m *Model) handleStashPanelKeys(msg tea.KeyMsg) tea.Cmd { switch { case key.Matches(msg, keys.StashApply): - return func() tea.Msg { - _, err := m.git.Stash(git.StashOptions{Apply: true, StashID: stashID}) - if err != nil { - return errMsg{err} - } - return fileWatcherMsg{} + _, err := m.git.Stash(git.StashOptions{Apply: true, StashID: stashID}) + if err != nil { + return func() tea.Msg { return errMsg{err} } } + return tea.Batch( + m.fetchPanelContent(FilesPanel), + m.fetchPanelContent(StashPanel), + ) + case key.Matches(msg, keys.StashPop): - return func() tea.Msg { - _, err := m.git.Stash(git.StashOptions{Pop: true, StashID: stashID}) - if err != nil { - return errMsg{err} - } - return fileWatcherMsg{} + _, err := m.git.Stash(git.StashOptions{Pop: true, StashID: stashID}) + if err != nil { + return func() tea.Msg { return errMsg{err} } } + return tea.Batch( + m.fetchPanelContent(FilesPanel), + m.fetchPanelContent(StashPanel), + ) + case key.Matches(msg, keys.StashDrop): - return func() tea.Msg { - _, err := m.git.Stash(git.StashOptions{Drop: true, StashID: stashID}) - if err != nil { - return errMsg{err} + m.mode = modeConfirm + m.confirmMessage = fmt.Sprintf("Drop stash %s?", stashID) + m.confirmCallback = func(confirmed bool) tea.Cmd { + m.mode = modeNormal + if !confirmed { + return nil + } + return func() tea.Msg { + _, err := m.git.Stash(git.StashOptions{Drop: true, StashID: stashID}) + if err != nil { + return errMsg{err} + } + // Reset cursor if we deleted the last item + if m.panels[StashPanel].cursor >= len(m.panels[StashPanel].lines)-1 && m.panels[StashPanel].cursor > 0 { + m.panels[StashPanel].cursor-- + } + return m.fetchPanelContent(StashPanel) } - return fileWatcherMsg{} } } return nil diff --git a/internal/tui/view.go b/internal/tui/view.go index a0449ba..cd20c8a 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -31,10 +31,13 @@ func (m Model) View() string { // If not in normal mode, render a pop-up on top. if m.mode != modeNormal { var popup string - if m.mode == modeInput { + switch m.mode { + case modeInput: popup = m.renderInputPopup() - } else { // modeConfirm + case modeConfirm: popup = m.renderConfirmPopup() + case modeCommit: + popup = m.renderCommitPopup() } return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup) } @@ -58,6 +61,23 @@ func (m Model) renderInputPopup() string { Render(content) } +// renderCommitPopup creates the view for the commit message pop-up. +func (m Model) renderCommitPopup() string { + content := lipgloss.JoinVertical( + lipgloss.Left, + m.theme.ActiveTitle.Render(" Commit Message "), + m.textInput.View(), + m.descriptionInput.View(), + m.theme.InactiveTitle.Render(" (Tab to switch, Enter to save, Esc to cancel) "), + ) + + return lipgloss.NewStyle(). + Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderForeground(m.theme.ActiveBorder.Style.GetForeground()). + Render(content) +} + // renderConfirmPopup creates the view for the confirmation pop-up. func (m Model) renderConfirmPopup() string { content := lipgloss.JoinVertical(