diff --git a/internal/tui/keys.go b/internal/tui/keys.go index a7a46d6..c1d2cb5 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -18,7 +18,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.cursor++ } m.adjustOffset() - return m, nil + return m, m.maybeFetchMore() case tea.KeyUp: if m.cursor > 0 { m.cursor-- @@ -52,7 +52,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.cursor++ } m.adjustOffset() - return m, nil + return m, m.maybeFetchMore() case "k": if m.cursor > 0 { m.cursor-- @@ -68,7 +68,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.cursor = listLen - 1 } m.adjustOffset() - return m, nil + return m, m.maybeFetchMore() case "l": return m.navigateForward() case "h": diff --git a/internal/tui/model.go b/internal/tui/model.go index 3dc4e74..7ce106b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -25,11 +25,29 @@ const ( modeTimeInput ) -// logGroupsMsg is sent when log groups are fetched. -type logGroupsMsg []aws.LogGroup +// logGroupsPageMsg is sent when the first page of log groups is fetched. +type logGroupsPageMsg struct { + groups []aws.LogGroup + nextToken *string +} + +// logStreamsPageMsg is sent when the first page of log streams is fetched. +type logStreamsPageMsg struct { + streams []aws.LogStream + nextToken *string +} + +// moreGroupsPageMsg is sent when additional pages of log groups are fetched. +type moreGroupsPageMsg struct { + groups []aws.LogGroup + nextToken *string +} -// logStreamsMsg is sent when log streams are fetched. -type logStreamsMsg []aws.LogStream +// moreStreamsPageMsg is sent when additional pages of log streams are fetched. +type moreStreamsPageMsg struct { + streams []aws.LogStream + nextToken *string +} // logEventsMsg is sent when log events are fetched. type logEventsMsg []aws.LogEvent @@ -67,8 +85,9 @@ type Model struct { groupsNextToken *string streamsNextToken *string - loading bool - err error + loading bool + loadingMore bool + err error width int height int @@ -132,18 +151,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height return m, nil - case logGroupsMsg: - m.logGroups = []aws.LogGroup(msg) + case logGroupsPageMsg: + m.logGroups = msg.groups + m.groupsNextToken = msg.nextToken m.loading = false return m, nil - case logStreamsMsg: - m.logStreams = []aws.LogStream(msg) + case logStreamsPageMsg: + m.logStreams = msg.streams + m.streamsNextToken = msg.nextToken m.loading = false m.cursor = 0 m.offset = 0 return m, nil + case moreGroupsPageMsg: + m.logGroups = append(m.logGroups, msg.groups...) + m.groupsNextToken = msg.nextToken + m.loadingMore = false + return m, nil + + case moreStreamsPageMsg: + m.logStreams = append(m.logStreams, msg.streams...) + m.streamsNextToken = msg.nextToken + m.loadingMore = false + return m, nil + case logEventsMsg: m.loading = false return m, m.openEditor([]aws.LogEvent(msg)) @@ -175,21 +208,44 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) fetchLogGroups() tea.Cmd { return func() tea.Msg { - groups, err := m.client.ListLogGroups(m.ctx) + groups, nextToken, err := m.client.ListLogGroupsPage(m.ctx, nil) if err != nil { return errMsg{err} } - return logGroupsMsg(groups) + return logGroupsPageMsg{groups: groups, nextToken: nextToken} + } +} + +func (m Model) fetchMoreGroups() tea.Cmd { + token := m.groupsNextToken + return func() tea.Msg { + groups, nextToken, err := m.client.ListLogGroupsPage(m.ctx, token) + if err != nil { + return errMsg{err} + } + return moreGroupsPageMsg{groups: groups, nextToken: nextToken} } } func (m Model) fetchLogStreams(groupName string) tea.Cmd { return func() tea.Msg { - streams, err := m.client.ListLogStreams(m.ctx, groupName) + streams, nextToken, err := m.client.ListLogStreamsPage(m.ctx, groupName, nil) + if err != nil { + return errMsg{err} + } + return logStreamsPageMsg{streams: streams, nextToken: nextToken} + } +} + +func (m Model) fetchMoreStreams() tea.Cmd { + token := m.streamsNextToken + groupName := m.selectedGroup + return func() tea.Msg { + streams, nextToken, err := m.client.ListLogStreamsPage(m.ctx, groupName, token) if err != nil { return errMsg{err} } - return logStreamsMsg(streams) + return moreStreamsPageMsg{streams: streams, nextToken: nextToken} } } @@ -254,6 +310,27 @@ func (m Model) hasMoreStreams() bool { return m.streamsNextToken != nil } +// maybeFetchMore returns a command to fetch the next page if the cursor +// is at the last item and more pages are available. +func (m *Model) maybeFetchMore() tea.Cmd { + if m.loadingMore { + return nil + } + switch m.currentView { + case viewGroups: + if m.hasMoreGroups() && m.cursor >= len(m.logGroups)-1 { + m.loadingMore = true + return m.fetchMoreGroups() + } + case viewStreams: + if m.hasMoreStreams() && m.cursor >= len(m.logStreams)-1 { + m.loadingMore = true + return m.fetchMoreStreams() + } + } + return nil +} + func (m Model) openEditor(events []aws.LogEvent) tea.Cmd { content := formatter.Format(events) path, cleanup, err := editor.WriteTempFile(content) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 3943368..371325d 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -39,7 +39,7 @@ func TestModel_ReceiveLogGroups(t *testing.T) { {Name: "/aws/ecs/service-b", RetentionDays: 7, StoredBytes: 2048}, } - model, _ := update(m, logGroupsMsg(groups)) + model, _ := update(m, logGroupsPageMsg{groups: groups}) if len(model.logGroups) != 2 { t.Fatalf("expected 2 log groups, got %d", len(model.logGroups)) @@ -182,7 +182,7 @@ func TestModel_ReceiveLogStreams(t *testing.T) { {Name: "stream-002", LastEventTimestamp: time.Now().Add(-time.Minute)}, } - m, _ = update(m, logStreamsMsg(streams)) + m, _ = update(m, logStreamsPageMsg{streams: streams}) if len(m.logStreams) != 2 { t.Fatalf("expected 2 streams, got %d", len(m.logStreams)) @@ -1102,6 +1102,145 @@ func TestModel_PaneHeight_ExactMatch(t *testing.T) { } } +// --- Pagination: fetch all pages --- + +func TestModel_FetchLogGroups_StoresNextToken(t *testing.T) { + m := NewModel(nil) + token := "next-page-token" + + m, _ = update(m, logGroupsPageMsg{ + groups: []aws.LogGroup{{Name: "group-1"}}, + nextToken: &token, + }) + + if len(m.logGroups) != 1 { + t.Fatalf("expected 1 group, got %d", len(m.logGroups)) + } + if m.groupsNextToken == nil || *m.groupsNextToken != token { + t.Errorf("expected groupsNextToken=%q, got %v", token, m.groupsNextToken) + } +} + +func TestModel_FetchLogGroups_NilTokenWhenNoMore(t *testing.T) { + m := NewModel(nil) + + m, _ = update(m, logGroupsPageMsg{ + groups: []aws.LogGroup{{Name: "group-1"}}, + nextToken: nil, + }) + + if m.groupsNextToken != nil { + t.Errorf("expected nil groupsNextToken, got %v", m.groupsNextToken) + } +} + +func TestModel_FetchLogStreams_StoresNextToken(t *testing.T) { + m := NewModel(nil) + m.currentView = viewStreams + token := "stream-token" + + m, _ = update(m, logStreamsPageMsg{ + streams: []aws.LogStream{{Name: "s1"}}, + nextToken: &token, + }) + + if len(m.logStreams) != 1 { + t.Fatalf("expected 1 stream, got %d", len(m.logStreams)) + } + if m.streamsNextToken == nil || *m.streamsNextToken != token { + t.Errorf("expected streamsNextToken=%q, got %v", token, m.streamsNextToken) + } +} + +func TestModel_MoreGroupsPage_AppendsToList(t *testing.T) { + m := NewModel(nil) + m.logGroups = []aws.LogGroup{{Name: "group-1"}} + token := "page3" + + m, _ = update(m, moreGroupsPageMsg{ + groups: []aws.LogGroup{{Name: "group-2"}, {Name: "group-3"}}, + nextToken: &token, + }) + + if len(m.logGroups) != 3 { + t.Fatalf("expected 3 groups after append, got %d", len(m.logGroups)) + } + if m.groupsNextToken == nil || *m.groupsNextToken != token { + t.Errorf("expected groupsNextToken=%q, got %v", token, m.groupsNextToken) + } +} + +func TestModel_MoreStreamsPage_AppendsToList(t *testing.T) { + m := NewModel(nil) + m.currentView = viewStreams + m.logStreams = []aws.LogStream{{Name: "s1"}} + + m, _ = update(m, moreStreamsPageMsg{ + streams: []aws.LogStream{{Name: "s2"}, {Name: "s3"}}, + nextToken: nil, + }) + + if len(m.logStreams) != 3 { + t.Fatalf("expected 3 streams after append, got %d", len(m.logStreams)) + } + if m.streamsNextToken != nil { + t.Errorf("expected nil streamsNextToken, got %v", m.streamsNextToken) + } +} + +func TestModel_CursorAtBottom_TriggersMoreGroupsFetch(t *testing.T) { + m := NewModel(nil) + m.logGroups = makeGroups(5) + token := "more" + m.groupsNextToken = &token + m.cursor = 3 + m.width = 100 + m.height = 24 + + // Move cursor to last item + m, cmd := update(m, keyMsg('G')) + if m.cursor != 4 { + t.Errorf("expected cursor 4, got %d", m.cursor) + } + // Should trigger a fetch for more groups + if cmd == nil { + t.Error("expected command to fetch more groups when cursor at bottom with more pages") + } +} + +func TestModel_CursorAtBottom_NoFetchWhenNoToken(t *testing.T) { + m := NewModel(nil) + m.logGroups = makeGroups(5) + m.groupsNextToken = nil + m.width = 100 + m.height = 24 + + m, cmd := update(m, keyMsg('G')) + if cmd != nil { + t.Error("expected no command when no more pages") + } +} + +func TestModel_CursorAtBottom_TriggersMoreStreamsFetch(t *testing.T) { + m := NewModel(nil) + m.currentView = viewStreams + m.selectedGroup = "/aws/test" + m.logStreams = makeStreams(5) + token := "more-streams" + m.streamsNextToken = &token + m.cursor = 3 + m.width = 100 + m.height = 24 + + m, cmd := update(m, keyMsg('G')) + if m.cursor != 4 { + t.Errorf("expected cursor 4, got %d", m.cursor) + } + if cmd == nil { + t.Error("expected command to fetch more streams when cursor at bottom with more pages") + } +} + func TestModel_PaneHeight_FewItemsBothSides(t *testing.T) { // When both panes have few items, they must still have the same height. // This verifies padding survives TrimSuffix (not TrimRight which strips all). diff --git a/todo.md b/todo.md index eb104ba..d35f8ee 100644 --- a/todo.md +++ b/todo.md @@ -71,3 +71,17 @@ ## ペインサイズ不一致修正 (#3) - [x] 2026-04-14 ペイン高さ/幅テスト作成(5テスト) - [x] 2026-04-14 render関数の末尾改行によるHeight超過を修正(strings.TrimRight) + +## WSLペインサイズ不一致修正 (#6) +- [x] 2026-04-14 render関数のmaxHeightパディングテスト作成(2テスト) +- [x] 2026-04-14 render関数でmaxHeight行にパディング + lipgloss Height()依存を除去 + +## WSLレイアウト崩れ根本修正 (#7, #8) +- [x] 2026-04-14 strings.TrimRight→TrimSuffixに変更(パディング全除去を防止) +- [x] 2026-04-14 lipgloss JoinHorizontal/JoinVerticalを廃止、行ごとに直接出力組み立て +- [x] 2026-04-14 最終出力をm.height-1行にハードキャップ + +## ページネーション修正 (F9) +- [x] 2026-04-14 fetchLogGroups/fetchLogStreamsをPage API対応に変更(NextToken返却) +- [x] 2026-04-14 カーソル末尾到達時に次ページを非同期追加取得(maybeFetchMore) +- [x] 2026-04-14 追加ページはリストにappend(既存データを保持)