diff --git a/CHANGELOG.md b/CHANGELOG.md index e5d1482..dd634c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/tui/tui.go b/tui/tui.go index 302cbe8..d712142 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -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 { diff --git a/tui/tui_test.go b/tui/tui_test.go index d741fed..14faec0 100644 --- a/tui/tui_test.go +++ b/tui/tui_test.go @@ -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) }