Skip to content

Commit 5168dae

Browse files
committed
Select/Open sessions with the mouse
Signed-off-by: David Gageot <david.gageot@docker.com>
1 parent a041dca commit 5168dae

2 files changed

Lines changed: 145 additions & 0 deletions

File tree

pkg/tui/dialog/session_browser.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ type sessionBrowserDialog struct {
4747
keyMap sessionBrowserKeyMap
4848
openedAt time.Time // when dialog was opened, for stable time display
4949
starFilter int // 0 = all, 1 = starred only, 2 = unstarred only
50+
51+
// Double-click detection
52+
lastClickTime time.Time
53+
lastClickIndex int
5054
}
5155

5256
// NewSessionBrowserDialog creates a new session browser dialog
@@ -106,6 +110,26 @@ func (d *sessionBrowserDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
106110
d.filterSessions()
107111
return d, cmd
108112

113+
case tea.MouseClickMsg:
114+
// Scrollbar clicks already handled above; this handles list item clicks
115+
if msg.Button == tea.MouseLeft {
116+
if idx := d.mouseYToSessionIndex(msg.Y); idx >= 0 {
117+
now := time.Now()
118+
if idx == d.lastClickIndex && now.Sub(d.lastClickTime) < styles.DoubleClickThreshold {
119+
d.selected = idx
120+
d.lastClickTime = time.Time{}
121+
return d, tea.Sequence(
122+
core.CmdHandler(CloseDialogMsg{}),
123+
core.CmdHandler(messages.LoadSessionMsg{SessionID: d.filtered[d.selected].ID}),
124+
)
125+
}
126+
d.selected = idx
127+
d.lastClickTime = now
128+
d.lastClickIndex = idx
129+
}
130+
}
131+
return d, nil
132+
109133
case tea.KeyPressMsg:
110134
if cmd := HandleQuit(msg); cmd != nil {
111135
return d, cmd
@@ -216,6 +240,24 @@ func (d *sessionBrowserDialog) filterSessions() {
216240
d.scrollview.SetScrollOffset(0)
217241
}
218242

243+
// mouseYToSessionIndex converts a mouse Y position to a session index in the filtered list.
244+
// Returns -1 if the position is not on a session.
245+
func (d *sessionBrowserDialog) mouseYToSessionIndex(y int) int {
246+
dialogRow, _ := d.Position()
247+
visLines := d.scrollview.VisibleHeight()
248+
listStartY := dialogRow + sessionBrowserListStartY
249+
250+
if y < listStartY || y >= listStartY+visLines {
251+
return -1
252+
}
253+
lineInView := y - listStartY
254+
idx := d.scrollview.ScrollOffset() + lineInView
255+
if idx < 0 || idx >= len(d.filtered) {
256+
return -1
257+
}
258+
return idx
259+
}
260+
219261
func (d *sessionBrowserDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) {
220262
dialogWidth = max(min(d.Width()*85/100, 96), 60)
221263
maxHeight = min(d.Height()*70/100, 30)

pkg/tui/dialog/session_browser_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,106 @@ func TestSessionBrowserScrolling(t *testing.T) {
202202
expectedTitle := fmt.Sprintf("Session %d", d.selected+1)
203203
require.Contains(t, view, expectedTitle, "view should contain selected session")
204204
}
205+
206+
func TestSessionBrowserMouseClickSelectsSession(t *testing.T) {
207+
sessions := []session.Summary{
208+
{ID: "1", Title: "Session 1", CreatedAt: time.Now()},
209+
{ID: "2", Title: "Session 2", CreatedAt: time.Now()},
210+
{ID: "3", Title: "Session 3", CreatedAt: time.Now()},
211+
}
212+
213+
dialog := NewSessionBrowserDialog(sessions)
214+
d := dialog.(*sessionBrowserDialog)
215+
d.Init()
216+
d.Update(tea.WindowSizeMsg{Width: 100, Height: 50})
217+
218+
// Initially selected should be 0
219+
require.Equal(t, 0, d.selected)
220+
221+
// Get the dialog position to calculate where to click
222+
dialogRow, _ := d.Position()
223+
listStartY := dialogRow + sessionBrowserListStartY
224+
225+
// Single-click on the second session (line index 1)
226+
clickMsg := tea.MouseClickMsg{
227+
X: 20,
228+
Y: listStartY + 1,
229+
Button: tea.MouseLeft,
230+
}
231+
updated, cmd := d.Update(clickMsg)
232+
d = updated.(*sessionBrowserDialog)
233+
234+
// Selection should have moved to session 2
235+
require.Equal(t, 1, d.selected, "single click should select session")
236+
// Single click should not produce a load command
237+
require.Nil(t, cmd, "single click should not trigger load")
238+
239+
// Single-click on the third session
240+
clickMsg = tea.MouseClickMsg{
241+
X: 20,
242+
Y: listStartY + 2,
243+
Button: tea.MouseLeft,
244+
}
245+
updated, cmd = d.Update(clickMsg)
246+
d = updated.(*sessionBrowserDialog)
247+
248+
require.Equal(t, 2, d.selected, "single click should select third session")
249+
require.Nil(t, cmd, "single click on different session should not trigger load")
250+
}
251+
252+
func TestSessionBrowserDoubleClickOpensSession(t *testing.T) {
253+
sessions := []session.Summary{
254+
{ID: "sess-1", Title: "Session 1", CreatedAt: time.Now()},
255+
{ID: "sess-2", Title: "Session 2", CreatedAt: time.Now()},
256+
{ID: "sess-3", Title: "Session 3", CreatedAt: time.Now()},
257+
}
258+
259+
dialog := NewSessionBrowserDialog(sessions)
260+
d := dialog.(*sessionBrowserDialog)
261+
d.Init()
262+
d.Update(tea.WindowSizeMsg{Width: 100, Height: 50})
263+
264+
dialogRow, _ := d.Position()
265+
listStartY := dialogRow + sessionBrowserListStartY
266+
267+
// First click selects
268+
clickMsg := tea.MouseClickMsg{
269+
X: 20,
270+
Y: listStartY + 1,
271+
Button: tea.MouseLeft,
272+
}
273+
updated, _ := d.Update(clickMsg)
274+
d = updated.(*sessionBrowserDialog)
275+
require.Equal(t, 1, d.selected)
276+
277+
// Second click on the same item (double-click) should trigger load
278+
updated, cmd := d.Update(clickMsg)
279+
d = updated.(*sessionBrowserDialog)
280+
require.Equal(t, 1, d.selected, "selection should stay on double-clicked session")
281+
require.NotNil(t, cmd, "double-click should produce a command to load the session")
282+
}
283+
284+
func TestSessionBrowserClickOutsideListIgnored(t *testing.T) {
285+
sessions := []session.Summary{
286+
{ID: "1", Title: "Session 1", CreatedAt: time.Now()},
287+
{ID: "2", Title: "Session 2", CreatedAt: time.Now()},
288+
}
289+
290+
dialog := NewSessionBrowserDialog(sessions)
291+
d := dialog.(*sessionBrowserDialog)
292+
d.Init()
293+
d.Update(tea.WindowSizeMsg{Width: 100, Height: 50})
294+
295+
// Click way outside the list area
296+
clickMsg := tea.MouseClickMsg{
297+
X: 5,
298+
Y: 0,
299+
Button: tea.MouseLeft,
300+
}
301+
updated, cmd := d.Update(clickMsg)
302+
d = updated.(*sessionBrowserDialog)
303+
304+
// Selection should remain at 0
305+
require.Equal(t, 0, d.selected, "click outside list should not change selection")
306+
require.Nil(t, cmd, "click outside list should not produce a command")
307+
}

0 commit comments

Comments
 (0)