feat(tui): open rows on double click

This commit is contained in:
Vincent Koc 2026-04-29 19:41:32 -07:00
parent c462ff5ee2
commit 6327ecdc68
No known key found for this signature in database
2 changed files with 128 additions and 1 deletions

View File

@ -125,6 +125,11 @@ type clusterBrowserModel struct {
memberRows []memberRow
memberOff int
memberIndex int
lastClickFocus tuiFocus
lastClickIndex int
lastClickX int
lastClickY int
lastClickAt time.Time
detailView viewport.Model
searchInput textinput.Model
detailCache map[int64]store.ClusterDetail
@ -148,6 +153,7 @@ type tuiMenuItem struct {
}
const tuiMenuSeparatorAction = "separator"
const tuiDoubleClickWindow = 450 * time.Millisecond
func (item tuiMenuItem) selectable() bool {
return item.action != "" && item.action != tuiMenuSeparatorAction
@ -1033,6 +1039,7 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) {
if msg.Action != tea.MouseActionPress {
return
}
now := time.Now()
switch {
case layout.clusters.contains(msg.X, msg.Y):
m.focus = focusClusters
@ -1049,6 +1056,7 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) {
m.selected = index
m.loadSelectedCluster()
m.status = fmt.Sprintf("Cluster %d", m.payload.Clusters[m.selected].ID)
m.finishRowClick(focusClusters, index, msg.X, msg.Y, now)
}
case layout.members.contains(msg.X, msg.Y):
m.focus = focusMembers
@ -1065,6 +1073,7 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) {
if !m.memberRows[index].selectable {
m.memberIndex = index
m.status = m.memberRows[index].label
m.clearLastClick()
return
}
previous := m.memberIndex
@ -1073,6 +1082,7 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) {
m.detailView.GotoTop()
}
m.status = fmt.Sprintf("Selected #%d", m.memberRows[m.memberIndex].thread().Number)
m.finishRowClick(focusMembers, index, msg.X, msg.Y, now)
}
case layout.detail.contains(msg.X, msg.Y):
m.focus = focusDetail
@ -1087,6 +1097,32 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) {
}
}
func (m *clusterBrowserModel) finishRowClick(focus tuiFocus, index, x, y int, now time.Time) {
if m.isDoubleClick(focus, index, x, y, now) {
m.clearLastClick()
m.runAction("open")
return
}
m.lastClickFocus = focus
m.lastClickIndex = index
m.lastClickX = x
m.lastClickY = y
m.lastClickAt = now
}
func (m *clusterBrowserModel) isDoubleClick(focus tuiFocus, 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) <= tuiDoubleClickWindow
}
func (m *clusterBrowserModel) clearLastClick() {
m.lastClickAt = time.Time{}
}
func (m *clusterBrowserModel) handleMenuMouse(layout tuiLayout, msg tea.MouseMsg) {
if msg.Action == tea.MouseActionMotion {
index, ok := m.menuIndexAtMouse(layout, msg.X, msg.Y)
@ -3377,7 +3413,7 @@ func (r memberRow) thread() store.Thread {
return r.member.Thread
}
func openURL(url string) error {
var openURL = func(url string) error {
if strings.TrimSpace(url) == "" {
return fmt.Errorf("no URL selected")
}

View File

@ -284,6 +284,77 @@ func TestTUIMouseSelectsClusterRows(t *testing.T) {
}
}
func TestTUIMouseDoubleClickOpensClusterRepresentative(t *testing.T) {
model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{
Repository: "openclaw/openclaw",
Sort: "recent",
Clusters: sampleTUIClusters(),
})
model.width = 140
model.height = 32
layout := model.layout()
restoreOpenURL, opened := captureOpenURL(t)
msg := tea.MouseMsg{
X: layout.clusters.x + 2,
Y: layout.clusters.y + 4,
Action: tea.MouseActionPress,
Button: tea.MouseButtonLeft,
}
model.handleMouse(msg)
if len(*opened) != 0 {
t.Fatalf("single click opened URL: %#v", *opened)
}
model.handleMouse(msg)
restoreOpenURL()
if got := *opened; len(got) != 1 || got[0] != "https://github.com/openclaw/openclaw/issues/11" {
t.Fatalf("opened URLs = %#v", got)
}
}
func TestTUIMouseDoubleClickOpensMemberThread(t *testing.T) {
model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{
Repository: "openclaw/openclaw",
Sort: "recent",
Clusters: sampleTUIClusters(),
})
model.width = 140
model.height = 32
model.memberRows = []memberRow{
{label: "ISSUES (1)"},
{selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{
ID: 42,
Number: 42,
Kind: "issue",
State: "open",
Title: "Selected issue",
HTMLURL: "https://github.com/openclaw/openclaw/issues/42",
UpdatedAtGitHub: "2026-04-27T10:00:00Z",
}}},
}
model.memberIndex = 0
layout := model.layout()
restoreOpenURL, opened := captureOpenURL(t)
msg := tea.MouseMsg{
X: layout.members.x + 2,
Y: layout.members.y + 4,
Action: tea.MouseActionPress,
Button: tea.MouseButtonLeft,
}
model.handleMouse(msg)
if len(*opened) != 0 {
t.Fatalf("single click opened URL: %#v", *opened)
}
model.handleMouse(msg)
restoreOpenURL()
if got := *opened; len(got) != 1 || got[0] != "https://github.com/openclaw/openclaw/issues/42" {
t.Fatalf("opened URLs = %#v", got)
}
}
func TestTUIMouseSelectsVisibleClusterWindow(t *testing.T) {
clusters := make([]store.ClusterSummary, 0, 30)
for i := 0; i < 30; i++ {
@ -2537,6 +2608,26 @@ func TestTUIPanePositionLabels(t *testing.T) {
}
}
func captureOpenURL(t *testing.T) (func(), *[]string) {
t.Helper()
previous := openURL
opened := []string{}
openURL = func(url string) error {
opened = append(opened, url)
return nil
}
restored := false
restore := func() {
if restored {
return
}
openURL = previous
restored = true
}
t.Cleanup(restore)
return restore, &opened
}
func sampleTUIClusters() []store.ClusterSummary {
return []store.ClusterSummary{
{