Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
}
return a, nil
case "j", "k":
case "j", "k", "g", "G":
switch a.activeTab {
case 0:
a.analysisView.Update(msg)
Expand Down
162 changes: 162 additions & 0 deletions internal/tui/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -595,3 +595,165 @@ func TestApp_YankKeyIgnoredWhenFilterActive(t *testing.T) {
t.Error("expected 'y' to be forwarded to filter, not trigger copy")
}
}

// TestApp_SessionsGGJumpsToStart verifies that pressing 'g' twice on the Sessions tab
// routes through app.Update() and jumps the selection to the first item.
// This complements TestSessionsView_GGJumpsToStart in sessions_test.go, which tests
// the view's key handling in isolation. This test catches routing bugs in app.Update()
// where keys may not be forwarded to the view at all.
func TestApp_SessionsGGJumpsToStart(t *testing.T) {
s := store.New()
now := time.Now()
for i := range 5 {
s.Add(&model.SessionMeta{
UUID: fmt.Sprintf("u%d", i),
Slug: fmt.Sprintf("session-%d", i),
ProjectPath: "-p",
StartTime: now.Add(time.Duration(i) * time.Hour),
Models: map[string]int{},
ToolUsage: map[string]int{},
SkillsUsed: map[string]int{},
CommandsUsed: map[string]int{},
FileOps: map[string]int{},
})
}

app := NewApp(s, "")
updated, _ := app.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
app = updated.(App)
app.activeTab = 2
app.View() // populate rows

// Navigate down
updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown})
app = updated.(App)
updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown})
app = updated.(App)

// Press gg
updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
app = updated.(App)
updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
app = updated.(App)

if app.sessionsView.Selected() != 0 {
t.Errorf("expected selected=0 after gg, got %d", app.sessionsView.Selected())
}
}

// TestApp_SessionsGJumpsToEnd verifies that pressing 'G' on the Sessions tab
// routes through app.Update() and jumps the selection to the last item.
// This complements TestSessionsView_GJumpsToEnd in sessions_test.go, which tests
// the view's key handling in isolation. This test catches routing bugs in app.Update()
// where keys may not be forwarded to the view at all.
func TestApp_SessionsGJumpsToEnd(t *testing.T) {
s := store.New()
now := time.Now()
for i := range 5 {
s.Add(&model.SessionMeta{
UUID: fmt.Sprintf("u%d", i),
Slug: fmt.Sprintf("session-%d", i),
ProjectPath: "-p",
StartTime: now.Add(time.Duration(i) * time.Hour),
Models: map[string]int{},
ToolUsage: map[string]int{},
SkillsUsed: map[string]int{},
CommandsUsed: map[string]int{},
FileOps: map[string]int{},
})
}

app := NewApp(s, "")
updated, _ := app.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
app = updated.(App)
app.activeTab = 2
app.View() // populate rows

updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
app = updated.(App)

if app.sessionsView.Selected() != 4 {
t.Errorf("expected selected=4 after G, got %d", app.sessionsView.Selected())
}
}

// TestApp_ProjectsGGJumpsToStart verifies that pressing 'g' twice on the Projects tab
// routes through app.Update() and jumps the selection to the first item.
// This complements TestProjectsView_GGJumpsToStart in projects_test.go, which tests
// the view's key handling in isolation. This test catches routing bugs in app.Update()
// where keys may not be forwarded to the view at all.
func TestApp_ProjectsGGJumpsToStart(t *testing.T) {
s := store.New()
now := time.Now()
for i := range 5 {
s.Add(&model.SessionMeta{
UUID: fmt.Sprintf("u%d", i),
Slug: fmt.Sprintf("s%d", i),
ProjectPath: fmt.Sprintf("-p%d", i),
StartTime: now.Add(time.Duration(i) * time.Hour),
Models: map[string]int{},
ToolUsage: map[string]int{},
SkillsUsed: map[string]int{},
CommandsUsed: map[string]int{},
FileOps: map[string]int{},
})
}

app := NewApp(s, "")
updated, _ := app.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
app = updated.(App)
app.activeTab = 1
app.View() // populate lastRows

// Navigate down
updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown})
app = updated.(App)
updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown})
app = updated.(App)

// Press gg
updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
app = updated.(App)
updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
app = updated.(App)

if app.projectsView.Selected() != 0 {
t.Errorf("expected selected=0 after gg, got %d", app.projectsView.Selected())
}
}

// TestApp_ProjectsGJumpsToEnd verifies that pressing 'G' on the Projects tab
// routes through app.Update() and jumps the selection to the last item.
// This complements TestProjectsView_GJumpsToEnd in projects_test.go, which tests
// the view's key handling in isolation. This test catches routing bugs in app.Update()
// where keys may not be forwarded to the view at all.
func TestApp_ProjectsGJumpsToEnd(t *testing.T) {
s := store.New()
now := time.Now()
for i := range 5 {
s.Add(&model.SessionMeta{
UUID: fmt.Sprintf("u%d", i),
Slug: fmt.Sprintf("s%d", i),
ProjectPath: fmt.Sprintf("-p%d", i),
StartTime: now.Add(time.Duration(i) * time.Hour),
Models: map[string]int{},
ToolUsage: map[string]int{},
SkillsUsed: map[string]int{},
CommandsUsed: map[string]int{},
FileOps: map[string]int{},
})
}

app := NewApp(s, "")
updated, _ := app.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
app = updated.(App)
app.activeTab = 1
app.View() // populate lastRows

updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
app = updated.(App)

if app.projectsView.Selected() != 4 {
t.Errorf("expected selected=4 after G, got %d", app.projectsView.Selected())
}
}
20 changes: 20 additions & 0 deletions internal/tui/views/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type ProjectsView struct {
selected int
filter *components.Filter
lastRows []ProjectRow
lastKey string // track last key for gg detection
}

// NewProjectsView creates a new ProjectsView.
Expand All @@ -36,9 +37,11 @@ func (v *ProjectsView) Update(msg tea.KeyMsg) {
// Forward to filter first
if v.filter.Update(msg) {
v.selected = 0
v.lastKey = ""
return
}

maxIdx := len(v.lastRows) - 1
switch msg.Type {
case tea.KeyUp:
if v.selected > 0 {
Expand All @@ -54,15 +57,32 @@ func (v *ProjectsView) Update(msg tea.KeyMsg) {
}
case "j":
v.selected++
case "g":
if v.lastKey == "g" {
v.selected = 0
} else {
v.lastKey = "g"
return
}
case "G":
if maxIdx >= 0 {
v.selected = maxIdx
}
}
}
v.lastKey = ""
}

// FilterActive returns true if the filter input is active.
func (v *ProjectsView) FilterActive() bool {
return v.filter.Active
}

// Selected returns the current selected index.
func (v *ProjectsView) Selected() int {
return v.selected
}

// SelectedProject returns the Path of the currently selected project,
// or "" if no projects are available.
func (v *ProjectsView) SelectedProject() string {
Expand Down
114 changes: 114 additions & 0 deletions internal/tui/views/projects_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package views

import (
"fmt"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -165,3 +166,116 @@ func TestProjectsView_Empty(t *testing.T) {
t.Error("expected non-empty view even when empty")
}
}

// TestProjectsView_GGJumpsToStart verifies that pressing 'g' twice (vim gg)
// jumps the selection to the first item in the list, regardless of current position.
func TestProjectsView_GGJumpsToStart(t *testing.T) {
s := store.New()
now := time.Now()
for i := range 5 {
s.Add(&model.SessionMeta{
UUID: fmt.Sprintf("u%d", i),
Slug: fmt.Sprintf("s%d", i),
ProjectPath: fmt.Sprintf("-p%d", i),
StartTime: now.Add(time.Duration(i) * time.Hour),
EndTime: now.Add(time.Duration(i)*time.Hour + time.Minute),
Models: map[string]int{},
ToolUsage: map[string]int{},
SkillsUsed: map[string]int{},
CommandsUsed: map[string]int{},
FileOps: map[string]int{},
MessageCount: 1,
})
}

v := NewProjectsView(s)
v.View(80, 24) // populate lastRows

// Navigate down
v.Update(tea.KeyMsg{Type: tea.KeyDown})
v.Update(tea.KeyMsg{Type: tea.KeyDown})
if v.selected != 2 {
t.Fatalf("expected selected=2 after navigation, got %d", v.selected)
}

// Press 'g' twice to jump to start
v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})

if v.selected != 0 {
t.Errorf("expected selected=0 after gg, got %d", v.selected)
}
}

// TestProjectsView_GJumpsToEnd verifies that pressing 'G' (vim G)
// jumps the selection to the last item in the list.
func TestProjectsView_GJumpsToEnd(t *testing.T) {
s := store.New()
now := time.Now()
for i := range 5 {
s.Add(&model.SessionMeta{
UUID: fmt.Sprintf("u%d", i),
Slug: fmt.Sprintf("s%d", i),
ProjectPath: fmt.Sprintf("-p%d", i),
StartTime: now.Add(time.Duration(i) * time.Hour),
EndTime: now.Add(time.Duration(i)*time.Hour + time.Minute),
Models: map[string]int{},
ToolUsage: map[string]int{},
SkillsUsed: map[string]int{},
CommandsUsed: map[string]int{},
FileOps: map[string]int{},
MessageCount: 1,
})
}

v := NewProjectsView(s)
v.View(80, 24) // populate lastRows

// Press 'G' to jump to end
v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})

if v.selected != 4 {
t.Errorf("expected selected=4 after G, got %d", v.selected)
}
}

// TestProjectsView_GGAfterNavigation verifies that gg works correctly
// after navigating with other keys, resetting position to start.
func TestProjectsView_GGAfterNavigation(t *testing.T) {
s := store.New()
now := time.Now()
for i := range 5 {
s.Add(&model.SessionMeta{
UUID: fmt.Sprintf("u%d", i),
Slug: fmt.Sprintf("s%d", i),
ProjectPath: fmt.Sprintf("-p%d", i),
StartTime: now.Add(time.Duration(i) * time.Hour),
EndTime: now.Add(time.Duration(i)*time.Hour + time.Minute),
Models: map[string]int{},
ToolUsage: map[string]int{},
SkillsUsed: map[string]int{},
CommandsUsed: map[string]int{},
FileOps: map[string]int{},
MessageCount: 1,
})
}

v := NewProjectsView(s)
v.View(80, 24)

// Navigate down with j
v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
if v.selected != 3 {
t.Fatalf("expected selected=3 after j navigation, got %d", v.selected)
}

// Press gg to jump back to start
v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})

if v.selected != 0 {
t.Errorf("expected selected=0 after gg, got %d", v.selected)
}
}
Loading
Loading