feat(tui): add gitcrawl-style link picker

This commit is contained in:
Vincent Koc 2026-05-03 03:18:42 -07:00
parent 68a591e29b
commit 604415f50e
No known key found for this signature in database
2 changed files with 109 additions and 7 deletions

View File

@ -539,9 +539,14 @@ const (
actionCopyURL
actionCopyTitle
actionCopyDetail
actionOpenLinkMenu
actionCopyLinkMenu
actionOpenPickedLink
actionCopyPickedLink
actionOpenFirstLink
actionCopyFirstLink
actionCopyAllLinks
actionBackToActions
actionQuit
actionSortDefault
actionSortNewest
@ -556,6 +561,7 @@ const (
type menuItem struct {
label string
action menuAction
value string
}
func (item menuItem) selectable() bool {
@ -969,7 +975,7 @@ func (m *model) handleMenuMouse(msg tea.MouseMsg) {
}
m.menuIndex = index
m.keepMenuVisible()
_ = m.runMenuAction(m.menuItems[m.menuIndex].action)
_ = m.runMenuItem(m.menuItems[m.menuIndex])
}
func (m model) menuIndexAtMouse(x, y int) (int, bool) {
@ -988,7 +994,7 @@ func (m *model) updateMenuKey(key tea.KeyMsg) tea.Cmd {
page := maxInt(1, m.menuVisibleCount())
if index, ok := visibleMenuShortcutIndex(key.String(), m.menuItems, m.menuOff, page); ok {
m.menuIndex = index
return m.runMenuAction(m.menuItems[m.menuIndex].action)
return m.runMenuItem(m.menuItems[m.menuIndex])
}
switch key.String() {
case "ctrl+c":
@ -1017,7 +1023,11 @@ func (m *model) updateMenuKey(key tea.KeyMsg) tea.Cmd {
m.keepMenuVisible()
case "enter", " ":
if len(m.menuItems) > 0 {
return m.runMenuAction(m.menuItems[m.menuIndex].action)
return m.runMenuItem(m.menuItems[m.menuIndex])
}
case "b":
if m.menuTitle == "Open Link" || m.menuTitle == "Copy Link" {
m.openActionMenuFor(m.menuContext)
}
case "s":
m.openSortMenuFor(m.focus)
@ -1059,8 +1069,8 @@ func (m *model) openActionMenuFor(context paneFocus) {
if links := m.selectedReferenceLinks(); len(links) > 0 {
items = append(items,
menuSection("Links"),
menuItem{label: "Open first body link", action: actionOpenFirstLink},
menuItem{label: "Copy first body link", action: actionCopyFirstLink},
menuItem{label: "Open body link...", action: actionOpenLinkMenu},
menuItem{label: "Copy body link...", action: actionCopyLinkMenu},
)
if len(links) > 1 {
items = append(items, menuItem{label: "Copy all body links", action: actionCopyAllLinks})
@ -1133,6 +1143,31 @@ func (m *model) openHelpMenu() {
})
}
func (m *model) openReferenceLinkMenu(mode string) {
links := m.selectedReferenceLinks()
if len(links) == 0 {
m.status = "No body links found"
return
}
title := "Copy Link"
action := actionCopyPickedLink
if mode == "open" {
title = "Open Link"
action = actionOpenPickedLink
}
items := make([]menuItem, 0, len(links)+1)
for index, link := range links {
items = append(items, menuItem{
label: formatLinkChoiceLabel(link, index),
action: action,
value: link,
})
}
items = append(items, menuItem{label: "Back to actions", action: actionBackToActions})
m.openMenu(title, items)
m.status = title
}
func (m *model) openMenu(title string, items []menuItem) {
m.menuOpen = true
m.menuTitle = title
@ -1156,7 +1191,11 @@ func (m *model) closeMenu() {
}
func (m *model) runMenuAction(action menuAction) tea.Cmd {
switch action {
return m.runMenuItem(menuItem{action: action})
}
func (m *model) runMenuItem(item menuItem) tea.Cmd {
switch item.action {
case actionClose:
m.closeMenu()
case actionFocusRows:
@ -1172,6 +1211,36 @@ func (m *model) runMenuAction(action menuAction) tea.Cmd {
m.openSortMenuFor(m.menuContext)
case actionHelpMenu:
m.openHelpMenu()
case actionOpenLinkMenu:
m.openReferenceLinkMenu("open")
case actionCopyLinkMenu:
m.openReferenceLinkMenu("copy")
case actionOpenPickedLink:
if strings.TrimSpace(item.value) == "" {
m.status = "No body link found"
m.closeMenu()
return nil
}
if err := openURL(item.value); err != nil {
m.status = err.Error()
} else {
m.status = "Opened " + item.value
}
m.closeMenu()
case actionCopyPickedLink:
if strings.TrimSpace(item.value) == "" {
m.status = "No body link found"
m.closeMenu()
return nil
}
if err := copyText(item.value); err != nil {
m.status = err.Error()
} else {
m.status = "Copied body link"
}
m.closeMenu()
case actionBackToActions:
m.openActionMenuFor(m.menuContext)
case actionClearFilter:
m.query = ""
m.applyFilter()
@ -1425,6 +1494,10 @@ func (m model) selectedReferenceLinks() []string {
return itemReferenceLinks(item)
}
func formatLinkChoiceLabel(url string, index int) string {
return fmt.Sprintf("%2d %s", index+1, url)
}
func (m model) View() string {
width := maxInt(m.width, 40)
height := maxInt(m.height, 24)

View File

@ -718,13 +718,42 @@ func TestRightClickOpensSharedActionMenu(t *testing.T) {
if !strings.Contains(view, "Open selected URL") || !strings.Contains(view, "Copy selected detail") || !strings.Contains(view, "Links") {
t.Fatalf("action menu missing expected commands:\n%s", view)
}
for _, want := range []string{"Open first body link", "Focus detail pane", "Sort focused pane", "Jump to row..."} {
for _, want := range []string{"Open body link...", "Copy body link...", "Focus detail pane", "Sort focused pane", "Jump to row..."} {
if !menuContainsLabel(m.menuItems, want) {
t.Fatalf("action menu items missing %q: %#v", want, m.menuItems)
}
}
}
func TestActionMenuUsesGitcrawlStyleLinkPicker(t *testing.T) {
m := newModel(Options{
Title: "archive",
Items: []Item{
Row{Kind: "message", Title: "alpha", Text: "see https://example.com/a and https://example.com/b"}.ItemForLayout(LayoutChat),
},
})
m.width = 100
m.height = 16
m.openActionMenuFor(focusRows)
if !menuContainsLabel(m.menuItems, "Open body link...") {
t.Fatalf("action menu missing link picker: %#v", m.menuItems)
}
m.openReferenceLinkMenu("open")
if m.menuTitle != "Open Link" {
t.Fatalf("menu title = %q, want Open Link", m.menuTitle)
}
if len(m.menuItems) < 3 {
t.Fatalf("link menu items = %#v", m.menuItems)
}
if m.menuItems[0].value != "https://example.com/a" || m.menuItems[1].value != "https://example.com/b" {
t.Fatalf("link menu values = %#v", m.menuItems)
}
if !menuContainsLabel(m.menuItems, "Back to actions") {
t.Fatalf("link menu missing back action: %#v", m.menuItems)
}
}
func TestJumpModeSelectsFocusedPaneRows(t *testing.T) {
m := newModel(Options{
Title: "discrawl archive",