@@ -2,6 +2,8 @@ package shared
22
33import (
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+ }
0 commit comments