fix(tui): match gitcrawl action shortcuts

This commit is contained in:
Vincent Koc 2026-05-03 02:22:34 -07:00
parent c579adb4da
commit eebe35ff8d
No known key found for this signature in database
3 changed files with 131 additions and 17 deletions

View File

@ -18,4 +18,5 @@
- Add a gitcrawl-style `d` detail-mode toggle so noisy metadata can collapse behind compact chat/document previews.
- Add a shared `v` group-view toggle so chat archives can pivot left pane by channel, person, or thread, and document archives by parent, database, or workspace.
- Add gitcrawl-style selected-row actions for opening URLs and copying URLs, titles, or rendered detail text from the TUI action menu.
- Add gitcrawl-style `a` action-menu shortcut, context-specific action menu titles, and double-click-to-open selected archive rows.
- Rename the public package nouns to `config`, `store`, `snapshot`, `mirror`, `state`, `output`, `tui`, and `cache`.

View File

@ -39,6 +39,7 @@ var (
const (
wheelScrollDelay = 16 * time.Millisecond
wheelMaxBufferedDelta = 6
doubleClickWindow = 450 * time.Millisecond
rowsPaneAccent = "#8fb8d8"
contextPaneAccent = "#a8b8a0"
detailPaneAccent = "#d3b35f"
@ -443,6 +444,11 @@ type model struct {
menuOff int
menuFloating bool
menuRect rect
lastClickFocus paneFocus
lastClickIndex int
lastClickX int
lastClickY int
lastClickAt time.Time
}
type sortMode int
@ -703,7 +709,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.startFilter()
case "s":
m.openSortMenuFor(m.focus)
case "m":
case "a", "m":
m.openActionMenu()
case "?":
m.openHelpMenu()
@ -734,10 +740,17 @@ func (m *model) handleLeftClick(x, y int) {
m.closeMenu()
focus := m.paneAt(x, y)
m.focus = focus
now := time.Now()
if focus == focusRows {
m.selectGroupAt(layout.rows, x, y)
if index, ok := m.selectGroupAt(layout.rows, x, y); ok {
m.finishRowClick(focusRows, index, x, y, now)
}
} else if focus == focusContext {
m.selectMemberAt(layout.context, x, y)
if index, ok := m.selectMemberAt(layout.context, x, y); ok {
m.finishRowClick(focusContext, index, x, y, now)
}
} else {
m.clearLastClick()
}
}
@ -753,37 +766,41 @@ func (m *model) handleRightClick(x, y int) {
m.placeFloatingMenu(x, y)
}
func (m *model) selectGroupAt(rect rect, x, y int) {
func (m *model) selectGroupAt(rect rect, x, y int) (int, bool) {
row := y - rect.y - 3
if row == -1 {
m.sortGroupsFromHeader(x-rect.x-2, paneContentWidth(rect.w))
return
m.clearLastClick()
return 0, false
}
if row < 0 || row >= rowsViewportHeight(rect.h) {
return
return 0, false
}
groupIndex := m.offset + row
if groupIndex < 0 || groupIndex >= len(m.groups) {
return
return 0, false
}
m.selectGroup(groupIndex)
return groupIndex, true
}
func (m *model) selectMemberAt(rect rect, x, y int) {
func (m *model) selectMemberAt(rect rect, x, y int) (int, bool) {
row := y - rect.y - 3
if row == -1 {
m.sortMembersFromHeader(x-rect.x-2, paneContentWidth(rect.w))
return
m.clearLastClick()
return 0, false
}
members := m.currentGroupMembers()
memberOffset := m.contextOffset + row
if row < 0 || row >= rowsViewportHeight(rect.h) || memberOffset < 0 || memberOffset >= len(members) {
return
return 0, false
}
itemIndex := members[memberOffset]
m.selectItemIndex(itemIndex)
m.detailView.GotoTop()
m.ensureVisible()
return itemIndex, true
}
func (m *model) selectGroup(groupIndex int) {
@ -796,6 +813,32 @@ func (m *model) selectGroup(groupIndex int) {
m.ensureVisible()
}
func (m *model) finishRowClick(focus paneFocus, index, x, y int, now time.Time) {
if m.isDoubleClick(focus, index, x, y, now) {
m.clearLastClick()
m.openSelectedURL()
return
}
m.lastClickFocus = focus
m.lastClickIndex = index
m.lastClickX = x
m.lastClickY = y
m.lastClickAt = now
}
func (m model) isDoubleClick(focus paneFocus, index, x, y int, now time.Time) bool {
return !m.lastClickAt.IsZero() &&
m.lastClickFocus == focus &&
m.lastClickIndex == index &&
m.lastClickX == x &&
m.lastClickY == y &&
now.Sub(m.lastClickAt) <= doubleClickWindow
}
func (m *model) clearLastClick() {
m.lastClickAt = time.Time{}
}
func (m *model) handleMenuMouse(msg tea.MouseMsg) {
switch {
case msg.Type == tea.MouseWheelUp || msg.Button == tea.MouseButtonWheelUp:
@ -941,7 +984,7 @@ func (m *model) openActionMenuFor(context paneFocus) {
menuItem{label: "Close menu", action: actionClose},
)
m.menuContext = context
m.openMenu("Actions", items)
m.openMenu(actionMenuTitle(context), items)
}
func (m *model) openSortMenuFor(context paneFocus) {
@ -970,7 +1013,7 @@ func (m *model) openHelpMenu() {
menuSection("Mouse"),
{label: "Tab/arrow: select pane", action: actionClose},
{label: "Mouse click: select pane/row", action: actionClose},
{label: "Right click or m: floating actions", action: actionClose},
{label: "Right click or a/m: floating actions", action: actionClose},
{label: "Click row header: sort", action: actionClose},
menuSection("Keyboard"),
{label: "o: open selected URL", action: actionClose},
@ -1404,6 +1447,19 @@ func actionMenuSubtitle(context paneFocus) string {
}
}
func actionMenuTitle(context paneFocus) string {
switch context {
case focusRows:
return "Row Actions"
case focusContext:
return "Context Actions"
case focusDetail:
return "Detail Actions"
default:
return "Actions"
}
}
func (m model) renderFloatingMenu(view string) string {
if m.menuRect.w <= 0 || m.menuRect.h <= 0 {
return view
@ -1541,15 +1597,15 @@ func (m *model) keepMenuVisible() {
}
func footerControls(width int) string {
full := "Tab focus click select header sort right-click menu m actions o open c copy s sort v group d detail l layout wheel scroll / filter ? help q quit"
full := "Tab focus click select header sort right-click menu a/m actions o open c copy s sort v group d detail l layout wheel scroll / filter ? help q quit"
if lipgloss.Width(full) <= maxInt(1, width-2) {
return full
}
compact := "Tab focus click select right-click menu o open c copy s sort v group d detail / filter ? help q quit"
compact := "Tab focus click select right-click menu a actions o open c copy s sort v group d detail / filter ? help q quit"
if lipgloss.Width(compact) <= maxInt(1, width-2) {
return compact
}
return "Tab panes click menu o open c copy s sort v group d detail / filter ? help q quit"
return "Tab panes click menu a actions o open c copy s sort v group d detail / filter ? help q quit"
}
func (m model) footerLocation() string {

View File

@ -575,7 +575,7 @@ func TestRightClickOpensSharedActionMenu(t *testing.T) {
Action: tea.MouseActionPress,
})
m = updated.(model)
if !m.menuOpen || m.menuTitle != "Actions" {
if !m.menuOpen || m.menuTitle != "Row Actions" {
t.Fatalf("menu open=%v title=%q", m.menuOpen, m.menuTitle)
}
if m.selected != 1 {
@ -592,6 +592,63 @@ func TestRightClickOpensSharedActionMenu(t *testing.T) {
}
}
func TestKeyboardActionShortcutAliasOpensMenu(t *testing.T) {
m := newModel(Options{
Title: "archive",
Items: []Item{
Row{Kind: "message", Title: "alpha", URL: "https://example.com/alpha"}.ItemForLayout(LayoutChat),
},
})
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
m = updated.(model)
if !m.menuOpen || m.menuTitle != "Row Actions" {
t.Fatalf("action shortcut menu open=%v title=%q", m.menuOpen, m.menuTitle)
}
}
func TestMouseDoubleClickOpensSelectedRowURL(t *testing.T) {
previousOpen := openURL
var opened []string
openURL = func(value string) error {
opened = append(opened, value)
return nil
}
t.Cleanup(func() {
openURL = previousOpen
})
m := newModel(Options{
Title: "archive",
Items: []Item{
Row{Kind: "message", Title: "alpha", URL: "https://example.com/alpha"}.ItemForLayout(LayoutChat),
Row{Kind: "message", Title: "bravo", URL: "https://example.com/bravo"}.ItemForLayout(LayoutChat),
},
})
m.width = 100
m.height = 16
layout := m.layout()
msg := tea.MouseMsg{
X: layout.rows.x + 2,
Y: layout.rows.y + 4,
Button: tea.MouseButtonLeft,
Action: tea.MouseActionPress,
}
updated, _ := m.Update(msg)
m = updated.(model)
if len(opened) != 0 {
t.Fatalf("single click opened URL: %#v", opened)
}
updated, _ = m.Update(msg)
m = updated.(model)
if len(opened) != 1 || opened[0] != "https://example.com/bravo" || m.status != "Opened selected URL" {
t.Fatalf("double click opened=%#v status=%q", opened, m.status)
}
}
func TestActionMenuCopyAndOpenSelectedRow(t *testing.T) {
previousCopy := copyText
previousOpen := openURL
@ -734,7 +791,7 @@ func TestHelpMenuRendersUniversalControls(t *testing.T) {
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
m = updated.(model)
view := stripANSI(m.View())
for _, want := range []string{"Help", "Right click or m", "o: open selected URL", "c: copy selected URL", "s: sort focused pane", "v: cycle group view", "Mouse click: select pane/row"} {
for _, want := range []string{"Help", "Right click or a/m", "o: open selected URL", "c: copy selected URL", "s: sort focused pane", "v: cycle group view", "Mouse click: select pane/row"} {
if !strings.Contains(view, want) {
t.Fatalf("help menu missing %q:\n%s", want, view)
}