Skip to content

Commit 8e11fdb

Browse files
committed
unify and dedupe across view and modify tui
1 parent f8ec509 commit 8e11fdb

7 files changed

Lines changed: 230 additions & 169 deletions

File tree

cmd/modify.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ func runModify(cfg *config.Config) error {
8686
p := tea.NewProgram(
8787
model,
8888
tea.WithAltScreen(),
89+
tea.WithMouseAllMotion(),
8990
)
9091

9192
finalModel, err := p.Run()

cmd/submit.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -382,12 +382,12 @@ func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.S
382382
if errors.As(err, &httpErr) && httpErr.StatusCode == 404 {
383383
cfg.Printf("Previous stack already deleted on GitHub")
384384
} else {
385-
cfg.Warningf("Failed to delete previous stack: %v", err)
385+
cfg.Warningf("Failed to delete existing stack: %v", err)
386386
cfg.Printf("Run `%s` again to retry", cfg.ColorCyan("gh stack submit"))
387387
return err
388388
}
389389
} else {
390-
cfg.Successf("Cleared previous stack on GitHub")
390+
cfg.Successf("Cleared existing stack on GitHub")
391391
}
392392
// Clear the old stack ID so syncStack creates a new one
393393
s.ID = ""
@@ -403,7 +403,7 @@ func clearPendingModifyState(cfg *config.Config, gitDir string) {
403403
return
404404
}
405405
modify.ClearState(gitDir)
406-
cfg.Successf("Modify state cleared — stack recreated on GitHub")
406+
cfg.Successf("Stack recreated on GitHub to match local state")
407407
}
408408

409409
// syncStack creates or updates a stack on GitHub from the active PRs.

internal/tui/modifyview/model.go

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
211211
return m.updateRename(msg)
212212
}
213213
return m.updateNormal(msg)
214+
215+
case tea.MouseMsg:
216+
switch msg.Action {
217+
case tea.MouseActionPress:
218+
if msg.Button == tea.MouseButtonLeft {
219+
return m.handleMouseClick(msg.X, msg.Y)
220+
}
221+
if msg.Button == tea.MouseButtonWheelUp {
222+
if m.scrollOffset > 0 {
223+
m.scrollOffset--
224+
}
225+
return m, nil
226+
}
227+
if msg.Button == tea.MouseButtonWheelDown {
228+
m.scrollOffset++
229+
m.clampScroll()
230+
return m, nil
231+
}
232+
}
214233
}
215234

216235
return m, nil
@@ -803,12 +822,7 @@ func (m *Model) ensureVisible() {
803822
endLine := startLine + m.nodeLineCount(m.cursor)
804823

805824
viewHeight := m.contentViewHeight()
806-
if startLine < m.scrollOffset {
807-
m.scrollOffset = startLine
808-
}
809-
if endLine > m.scrollOffset+viewHeight {
810-
m.scrollOffset = endLine - viewHeight
811-
}
825+
m.scrollOffset = shared.EnsureVisible(startLine, endLine, m.scrollOffset, viewHeight)
812826
}
813827

814828
func (m Model) nodeLineCount(idx int) int {
@@ -833,16 +847,38 @@ func (m *Model) clampScroll() {
833847
total += m.nodeLineCount(i)
834848
}
835849
total++ // trunk line
836-
maxScroll := total - m.contentViewHeight()
837-
if maxScroll < 0 {
838-
maxScroll = 0
850+
m.scrollOffset = shared.ClampScroll(total, m.contentViewHeight(), m.scrollOffset)
851+
}
852+
853+
// --- Mouse handling ---
854+
855+
// handleMouseClick processes a mouse click at the given screen position.
856+
func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) {
857+
nodes := make([]shared.BranchNodeData, len(m.nodes))
858+
for i, n := range m.nodes {
859+
nodes[i] = toNodeData(n, i)
860+
}
861+
862+
result := shared.HandleClick(screenX, screenY, nodes, m.width, m.height, m.scrollOffset, shared.ShouldShowHeader(m.width, m.height), false)
863+
if result.NodeIndex < 0 {
864+
return m, nil
865+
}
866+
867+
m.cursor = result.NodeIndex
868+
869+
if result.OpenURL != "" {
870+
shared.OpenBrowserInBackground(result.OpenURL)
839871
}
840-
if m.scrollOffset > maxScroll {
841-
m.scrollOffset = maxScroll
872+
if result.ToggleFiles {
873+
m.nodes[result.NodeIndex].FilesExpanded = !m.nodes[result.NodeIndex].FilesExpanded
874+
m.clampScroll()
842875
}
843-
if m.scrollOffset < 0 {
844-
m.scrollOffset = 0
876+
if result.ToggleCommits {
877+
m.nodes[result.NodeIndex].CommitsExpanded = !m.nodes[result.NodeIndex].CommitsExpanded
878+
m.clampScroll()
845879
}
880+
881+
return m, nil
846882
}
847883

848884
// --- View ---
@@ -980,7 +1016,6 @@ func (m Model) View() string {
9801016
}
9811017

9821018
content := b.String()
983-
contentLines := strings.Split(content, "\n")
9841019

9851020
// Scrolling
9861021
reservedLines := 0
@@ -992,20 +1027,7 @@ func (m Model) View() string {
9921027
viewHeight = 1
9931028
}
9941029

995-
maxScroll := len(contentLines) - viewHeight
996-
if maxScroll < 0 {
997-
maxScroll = 0
998-
}
999-
start := m.scrollOffset
1000-
if start > maxScroll {
1001-
start = maxScroll
1002-
}
1003-
end := start + viewHeight
1004-
if end > len(contentLines) {
1005-
end = len(contentLines)
1006-
}
1007-
1008-
out.WriteString(strings.Join(contentLines[start:end], "\n"))
1030+
out.WriteString(shared.ApplyScrollToContent(content, m.scrollOffset, viewHeight))
10091031
out.WriteString("\n")
10101032

10111033
// Status line at the bottom

internal/tui/shared/render.go

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package shared
22

33
import (
44
"fmt"
5+
"os/exec"
6+
"runtime"
57
"strings"
68
"time"
79

@@ -158,7 +160,7 @@ func RenderPRHeader(b *strings.Builder, node BranchNodeData, isFocused bool, con
158160
default:
159161
stateLabel = " OPEN"
160162
}
161-
b.WriteString(style.Underline(true).Render(prLabel) + style.Render(stateLabel))
163+
b.WriteString(PRLinkStyle.Render(prLabel) + style.Render(stateLabel))
162164

163165
if annotation != nil {
164166
b.WriteString(" ")
@@ -394,3 +396,134 @@ func TimeAgo(t time.Time) string {
394396
return fmt.Sprintf("%d months ago", months)
395397
}
396398
}
399+
400+
// --- Mouse click helpers ---
401+
402+
// ClickResult describes what happened when a node was clicked.
403+
type ClickResult struct {
404+
NodeIndex int // which node was clicked (-1 if none)
405+
ToggleFiles bool // should toggle files expansion
406+
ToggleCommits bool // should toggle commits expansion
407+
OpenURL string // URL to open in browser (empty if none)
408+
}
409+
410+
// HandleClick maps a screen click to a node action.
411+
// nodes is the list of BranchNodeData in display order.
412+
// showHeader indicates whether the header is visible.
413+
// scrollOffset is the current scroll position.
414+
// hasSeparators controls whether merged/queued separator lines are accounted for.
415+
func HandleClick(screenX, screenY int, nodes []BranchNodeData, width, height, scrollOffset int, showHeader, hasSeparators bool) ClickResult {
416+
yOffset := 0
417+
if showHeader {
418+
if screenY < HeaderHeight {
419+
return ClickResult{NodeIndex: -1}
420+
}
421+
yOffset = HeaderHeight
422+
}
423+
424+
contentLine := (screenY - yOffset) + scrollOffset
425+
426+
line := 0
427+
prevWasMerged := false
428+
prevWasQueued := false
429+
for i := 0; i < len(nodes); i++ {
430+
if hasSeparators {
431+
isMerged := nodes[i].Ref.IsMerged()
432+
isQueued := nodes[i].Ref.IsQueued()
433+
if isMerged && !prevWasMerged && i > 0 {
434+
line++
435+
} else if isQueued && !prevWasQueued && !prevWasMerged && i > 0 {
436+
line++
437+
}
438+
prevWasMerged = isMerged
439+
prevWasQueued = isQueued
440+
}
441+
442+
nodeStart := line
443+
nodeLines := NodeLineCount(nodes[i])
444+
445+
if contentLine >= nodeStart && contentLine < nodeStart+nodeLines {
446+
result := ClickResult{NodeIndex: i}
447+
448+
// Click on PR header line — check if clicking the PR number
449+
if contentLine == nodeStart && nodes[i].PR != nil && nodes[i].PR.URL != "" {
450+
prStartX, prEndX := PRLabelColumns(nodes[i])
451+
if screenX >= prStartX && screenX < prEndX {
452+
result.OpenURL = nodes[i].PR.URL
453+
}
454+
}
455+
456+
// Click on files toggle line
457+
if len(nodes[i].FilesChanged) > 0 {
458+
if contentLine == nodeStart+FilesToggleLineOffset(nodes[i]) {
459+
result.ToggleFiles = true
460+
}
461+
}
462+
463+
// Click on commits toggle line
464+
if len(nodes[i].Commits) > 0 {
465+
if contentLine == nodeStart+CommitToggleLineOffset(nodes[i]) {
466+
result.ToggleCommits = true
467+
}
468+
}
469+
470+
return result
471+
}
472+
line += nodeLines
473+
}
474+
475+
return ClickResult{NodeIndex: -1}
476+
}
477+
478+
// FilesToggleLineOffset returns the line offset from node start to the files toggle.
479+
func FilesToggleLineOffset(node BranchNodeData) int {
480+
offset := 1 // after header
481+
if node.PR != nil {
482+
offset++
483+
}
484+
return offset
485+
}
486+
487+
// CommitToggleLineOffset returns the line offset from node start to the commits toggle.
488+
func CommitToggleLineOffset(node BranchNodeData) int {
489+
offset := 1
490+
if node.PR != nil {
491+
offset++
492+
}
493+
if len(node.FilesChanged) > 0 {
494+
offset++
495+
if node.FilesExpanded {
496+
offset += len(node.FilesChanged)
497+
}
498+
}
499+
return offset
500+
}
501+
502+
// PRLabelColumns returns the start and end X columns of the PR number label.
503+
func PRLabelColumns(node BranchNodeData) (int, int) {
504+
col := 2 // bullet + space
505+
icon := StatusIcon(node)
506+
if icon != "" {
507+
col += 2
508+
}
509+
prLabel := fmt.Sprintf("#%d", node.PR.Number)
510+
return col, col + len(prLabel)
511+
}
512+
513+
// OpenBrowserInBackground launches the system browser for the given URL.
514+
func OpenBrowserInBackground(url string) {
515+
cmd := BrowserCmd(url)
516+
_ = cmd.Start()
517+
}
518+
519+
// BrowserCmd returns an exec.Cmd to open a URL in the default browser.
520+
func BrowserCmd(url string) *exec.Cmd {
521+
switch runtime.GOOS {
522+
case "darwin":
523+
return exec.Command("open", url)
524+
case "windows":
525+
return exec.Command("cmd", "/c", "start", url)
526+
default:
527+
return exec.Command("xdg-open", url)
528+
}
529+
}

internal/tui/shared/scroll.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package shared
22

3+
import "strings"
4+
35
// ClampScroll ensures scrollOffset doesn't exceed content bounds.
46
func ClampScroll(totalLines, viewHeight, scrollOffset int) int {
57
maxScroll := totalLines - viewHeight
@@ -28,3 +30,22 @@ func EnsureVisible(startLine, endLine, scrollOffset, viewHeight int) int {
2830
}
2931
return scrollOffset
3032
}
33+
34+
// ApplyScrollToContent takes rendered content, splits into lines, applies
35+
// scroll offset, and returns the visible portion as a string.
36+
func ApplyScrollToContent(content string, scrollOffset, viewHeight int) string {
37+
lines := strings.Split(content, "\n")
38+
maxScroll := len(lines) - viewHeight
39+
if maxScroll < 0 {
40+
maxScroll = 0
41+
}
42+
start := scrollOffset
43+
if start > maxScroll {
44+
start = maxScroll
45+
}
46+
end := start + viewHeight
47+
if end > len(lines) {
48+
end = len(lines)
49+
}
50+
return strings.Join(lines[start:end], "\n")
51+
}

internal/tui/shared/styles.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ var (
1616
QueuedIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("130")).Render("◎")
1717

1818
// PR status styles
19+
PRLinkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Underline(true)
1920
PROpenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
2021
PRMergedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5"))
2122
PRClosedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))

0 commit comments

Comments
 (0)