detached 5cbd7d8

fix(cli): improve logs command output and session resolution

commit
5cbd7d8efa5c081054dde1994d03f58340fd641f
author
brice · · 2025-12-21 17:52 UTC
committer
brice · · 2025-12-21 20:20 UTC
parent
a33ce65
signature
unsigned
2 files · +221 -22
M internal/cli/cmd/logs.go +147 -22
@@ -2,6 +2,7 @@ package cmd
22
33 import (
44 "bufio"
5+ "context"
56 "encoding/json"
67 "fmt"
78 "io"
@@ -14,6 +15,7 @@ import (
1415 "github.com/spf13/cobra"
1516
1617 "github.com/bnema/dumber/internal/cli/styles"
18+ "github.com/bnema/dumber/internal/domain/entity"
1719 "github.com/bnema/dumber/internal/infrastructure/config"
1820 "github.com/bnema/dumber/internal/logging"
1921 )
@@ -57,6 +59,16 @@ type SessionInfo struct {
5759 Path string
5860 Size int64
5961 ModTime time.Time
62+
63+ // DB metadata (optional)
64+ StartedAt time.Time
65+ EndedAt *time.Time
66+ Type string
67+ FromDB bool
68+}
69+
70+type sessionManager interface {
71+ GetRecentSessions(ctx context.Context, limit int) ([]*entity.Session, error)
6072 }
6173
6274 func runLogs(_ *cobra.Command, args []string) error {
@@ -65,20 +77,28 @@ func runLogs(_ *cobra.Command, args []string) error {
6577 return fmt.Errorf("app not initialized")
6678 }
6779
80+ ctx := app.Ctx()
6881 logDir := getLogDir()
6982
7083 // List sessions if no argument provided
7184 if len(args) == 0 {
72- return listSessions(logDir, app.Theme)
85+ return listSessions(ctx, app.SessionUC, logDir, app.Theme)
7386 }
7487
7588 // Find session by partial match
7689 sessionQuery := args[0]
77- session, err := findSession(logDir, sessionQuery)
90+ session, err := findSession(ctx, app.SessionUC, logDir, sessionQuery)
7891 if err != nil {
7992 return err
8093 }
8194
95+ if _, err := os.Stat(session.Path); err != nil {
96+ if os.IsNotExist(err) {
97+ return fmt.Errorf("log file for session '%s' not found at %s", session.ShortID, session.Path)
98+ }
99+ return fmt.Errorf("stat log file: %w", err)
100+ }
101+
82102 if logsFollow {
83103 return tailSession(session.Path, app.Theme)
84104 }
@@ -102,8 +122,8 @@ func getLogDir() string {
102122 }
103123
104124 // listSessions displays all available log sessions.
105-func listSessions(logDir string, theme *styles.Theme) error {
106- sessions, err := getSessions(logDir)
125+func listSessions(ctx context.Context, sessionMgr sessionManager, logDir string, theme *styles.Theme) error {
126+ sessions, err := getSessionsMerged(ctx, sessionMgr, logDir)
107127 if err != nil {
108128 return err
109129 }
@@ -117,14 +137,28 @@ func listSessions(logDir string, theme *styles.Theme) error {
117137 fmt.Println(theme.Title.Render("Sessions (newest first):"))
118138 fmt.Println()
119139
120- // Table format: ShortID | DateTime | Size
140+ // Table format: ShortID | DateTime | Status | Size
121141 for _, s := range sessions {
122- dateStr := s.ModTime.Format("2006-01-02 15:04:05")
123- sizeStr := formatSize(s.Size)
142+ timeStr := sessionSortTime(s).Format("2006-01-02 15:04:05")
143+
144+ status := ""
145+ if s.FromDB {
146+ if s.EndedAt == nil {
147+ status = theme.SuccessStyle.Render("active")
148+ } else {
149+ status = theme.Subtle.Render("ended")
150+ }
151+ }
124152
125- line := fmt.Sprintf(" %s %s %s",
153+ sizeStr := "?"
154+ if s.Size > 0 {
155+ sizeStr = formatSize(s.Size)
156+ }
157+
158+ line := fmt.Sprintf(" %s %s %s %s",
126159 theme.Highlight.Render(s.ShortID),
127- theme.Subtle.Render(dateStr),
160+ theme.Subtle.Render(timeStr),
161+ status,
128162 theme.Subtle.Render(fmt.Sprintf("(%s)", sizeStr)),
129163 )
130164 fmt.Println(line)
@@ -179,9 +213,80 @@ func getSessions(logDir string) ([]SessionInfo, error) {
179213 return sessions, nil
180214 }
181215
216+func getSessionsMerged(ctx context.Context, sessionMgr sessionManager, logDir string) ([]SessionInfo, error) {
217+ fsSessions, err := getSessions(logDir)
218+ if err != nil {
219+ return nil, err
220+ }
221+ if sessionMgr == nil {
222+ return fsSessions, nil
223+ }
224+
225+ dbSessions, err := sessionMgr.GetRecentSessions(ctx, 200)
226+ if err != nil {
227+ if isMissingSessionsTable(err) {
228+ return fsSessions, nil
229+ }
230+ return nil, err
231+ }
232+
233+ byID := make(map[string]SessionInfo, len(dbSessions))
234+ merged := make([]SessionInfo, 0, len(dbSessions)+len(fsSessions))
235+ for _, s := range dbSessions {
236+ if s == nil {
237+ continue
238+ }
239+ filename := logging.SessionFilename(string(s.ID))
240+ path := filepath.Join(logDir, filename)
241+ info := SessionInfo{
242+ SessionID: string(s.ID),
243+ ShortID: s.ShortID(),
244+ Filename: filename,
245+ Path: path,
246+ StartedAt: s.StartedAt,
247+ EndedAt: s.EndedAt,
248+ Type: string(s.Type),
249+ FromDB: true,
250+ }
251+ if stat, statErr := os.Stat(path); statErr == nil {
252+ info.Size = stat.Size()
253+ info.ModTime = stat.ModTime()
254+ }
255+ byID[info.SessionID] = info
256+ merged = append(merged, info)
257+ }
258+
259+ for _, s := range fsSessions {
260+ if _, ok := byID[s.SessionID]; ok {
261+ continue
262+ }
263+ merged = append(merged, s)
264+ }
265+
266+ sort.Slice(merged, func(i, j int) bool {
267+ return sessionSortTime(merged[i]).After(sessionSortTime(merged[j]))
268+ })
269+ return merged, nil
270+}
271+
272+func sessionSortTime(s SessionInfo) time.Time {
273+ if !s.StartedAt.IsZero() {
274+ return s.StartedAt
275+ }
276+ return s.ModTime
277+}
278+
279+func isMissingSessionsTable(err error) bool {
280+ if err == nil {
281+ return false
282+ }
283+ // sqlite returns e.g. "no such table: sessions" before migrations run.
284+ return strings.Contains(err.Error(), "no such table: sessions")
285+}
286+
182287 // findSession finds a session by partial ID match.
183-func findSession(logDir, query string) (*SessionInfo, error) {
184- sessions, err := getSessions(logDir)
288+func findSession(ctx context.Context, sessionMgr sessionManager, logDir, query string) (*SessionInfo, error) {
289+ sessions, err := getSessionsMerged(ctx, sessionMgr, logDir)
185290 if err != nil {
186291 return nil, err
187292 }
@@ -190,19 +295,24 @@ func findSession(logDir, query string) (*SessionInfo, error) {
190295 return nil, fmt.Errorf("no sessions found")
191296 }
192297
193- queryLower := strings.ToLower(query)
298+ queryNormalized := strings.ToLower(strings.TrimSpace(query))
194299
195- // Try exact short ID match first
196- for _, s := range sessions {
197- if strings.EqualFold(s.ShortID, queryLower) {
198- return &s, nil
300+ // Try exact short ID match first.
301+ // Some entries may not have ShortID populated; derive it from SessionID.
302+ for i := range sessions {
303+ shortID := sessions[i].ShortID
304+ if shortID == "" {
305+ shortID = logging.ShortSessionID(sessions[i].SessionID)
306+ }
307+ if strings.ToLower(shortID) == queryNormalized {
308+ return &sessions[i], nil
199309 }
200310 }
201311
202312 // Try partial match on full session ID
203313 var matches []SessionInfo
204314 for _, s := range sessions {
205- if strings.Contains(strings.ToLower(s.SessionID), queryLower) {
315+ if strings.Contains(strings.ToLower(s.SessionID), queryNormalized) {
206316 matches = append(matches, s)
207317 }
208318 }
@@ -273,14 +383,29 @@ func tailSession(logPath string, theme *styles.Theme) error {
273383 fmt.Println()
274384
275385 reader := bufio.NewReader(file)
386+ pending := ""
276387 for {
277- line, err := reader.ReadString('\n')
388+ chunk, err := reader.ReadString('\n')
278389 if err != nil {
279- // No new data, wait a bit
280- time.Sleep(100 * time.Millisecond)
281- continue
390+ if err == io.EOF {
391+ // No full line yet; keep partial data.
392+ pending += chunk
393+ time.Sleep(100 * time.Millisecond)
394+ continue
395+ }
396+ return fmt.Errorf("read log file: %w", err)
397+ }
398+
399+ pending += chunk
400+ for {
401+ idx := strings.IndexByte(pending, '\n')
402+ if idx == -1 {
403+ break
404+ }
405+ line := pending[:idx]
406+ pending = pending[idx+1:]
407+ fmt.Println(colorizeLogLine(line, theme))
282408 }
283- fmt.Print(colorizeLogLine(line, theme))
284409 }
285410 }
286411
@@ -2,6 +2,7 @@ package cmd
2 2
3 import (3 import (
4 "bufio"4 "bufio"
5+ "context"
5 "encoding/json"6 "encoding/json"
6 "fmt"7 "fmt"
7 "io"8 "io"
@@ -14,6 +15,7 @@ import (
14 "github.com/spf13/cobra"15 "github.com/spf13/cobra"
15 16
16 "github.com/bnema/dumber/internal/cli/styles"17 "github.com/bnema/dumber/internal/cli/styles"
18+ "github.com/bnema/dumber/internal/domain/entity"
17 "github.com/bnema/dumber/internal/infrastructure/config"19 "github.com/bnema/dumber/internal/infrastructure/config"
18 "github.com/bnema/dumber/internal/logging"20 "github.com/bnema/dumber/internal/logging"
19 )21 )
@@ -57,6 +59,16 @@ type SessionInfo struct {
57 Path string59 Path string
58 Size int6460 Size int64
59 ModTime time.Time61 ModTime time.Time
62+
63+ // DB metadata (optional)
64+ StartedAt time.Time
65+ EndedAt *time.Time
66+ Type string
67+ FromDB bool
68+}
69+
70+type sessionManager interface {
71+ GetRecentSessions(ctx context.Context, limit int) ([]*entity.Session, error)
60 }72 }
61 73
62 func runLogs(_ *cobra.Command, args []string) error {74 func runLogs(_ *cobra.Command, args []string) error {
@@ -65,20 +77,28 @@ func runLogs(_ *cobra.Command, args []string) error {
65 return fmt.Errorf("app not initialized")77 return fmt.Errorf("app not initialized")
66 }78 }
67 79
80+ ctx := app.Ctx()
68 logDir := getLogDir()81 logDir := getLogDir()
69 82
70 // List sessions if no argument provided83 // List sessions if no argument provided
71 if len(args) == 0 {84 if len(args) == 0 {
72- return listSessions(logDir, app.Theme)85+ return listSessions(ctx, app.SessionUC, logDir, app.Theme)
73 }86 }
74 87
75 // Find session by partial match88 // Find session by partial match
76 sessionQuery := args[0]89 sessionQuery := args[0]
77- session, err := findSession(logDir, sessionQuery)90+ session, err := findSession(ctx, app.SessionUC, logDir, sessionQuery)
78 if err != nil {91 if err != nil {
79 return err92 return err
80 }93 }
81 94
95+ if _, err := os.Stat(session.Path); err != nil {
96+ if os.IsNotExist(err) {
97+ return fmt.Errorf("log file for session '%s' not found at %s", session.ShortID, session.Path)
98+ }
99+ return fmt.Errorf("stat log file: %w", err)
100+ }
101+
82 if logsFollow {102 if logsFollow {
83 return tailSession(session.Path, app.Theme)103 return tailSession(session.Path, app.Theme)
84 }104 }
@@ -102,8 +122,8 @@ func getLogDir() string {
102 }122 }
103 123
104 // listSessions displays all available log sessions.124 // listSessions displays all available log sessions.
105-func listSessions(logDir string, theme *styles.Theme) error {125+func listSessions(ctx context.Context, sessionMgr sessionManager, logDir string, theme *styles.Theme) error {
106- sessions, err := getSessions(logDir)126+ sessions, err := getSessionsMerged(ctx, sessionMgr, logDir)
107 if err != nil {127 if err != nil {
108 return err128 return err
109 }129 }
@@ -117,14 +137,28 @@ func listSessions(logDir string, theme *styles.Theme) error {
117 fmt.Println(theme.Title.Render("Sessions (newest first):"))137 fmt.Println(theme.Title.Render("Sessions (newest first):"))
118 fmt.Println()138 fmt.Println()
119 139
120- // Table format: ShortID | DateTime | Size140+ // Table format: ShortID | DateTime | Status | Size
121 for _, s := range sessions {141 for _, s := range sessions {
122- dateStr := s.ModTime.Format("2006-01-02 15:04:05")142+ timeStr := sessionSortTime(s).Format("2006-01-02 15:04:05")
123- sizeStr := formatSize(s.Size)143+
144+ status := ""
145+ if s.FromDB {
146+ if s.EndedAt == nil {
147+ status = theme.SuccessStyle.Render("active")
148+ } else {
149+ status = theme.Subtle.Render("ended")
150+ }
151+ }
124 152
125- line := fmt.Sprintf(" %s %s %s",153+ sizeStr := "?"
154+ if s.Size > 0 {
155+ sizeStr = formatSize(s.Size)
156+ }
157+
158+ line := fmt.Sprintf(" %s %s %s %s",
126 theme.Highlight.Render(s.ShortID),159 theme.Highlight.Render(s.ShortID),
127- theme.Subtle.Render(dateStr),160+ theme.Subtle.Render(timeStr),
161+ status,
128 theme.Subtle.Render(fmt.Sprintf("(%s)", sizeStr)),162 theme.Subtle.Render(fmt.Sprintf("(%s)", sizeStr)),
129 )163 )
130 fmt.Println(line)164 fmt.Println(line)
@@ -179,9 +213,80 @@ func getSessions(logDir string) ([]SessionInfo, error) {
179 return sessions, nil213 return sessions, nil
180 }214 }
181 215
216+func getSessionsMerged(ctx context.Context, sessionMgr sessionManager, logDir string) ([]SessionInfo, error) {
217+ fsSessions, err := getSessions(logDir)
218+ if err != nil {
219+ return nil, err
220+ }
221+ if sessionMgr == nil {
222+ return fsSessions, nil
223+ }
224+
225+ dbSessions, err := sessionMgr.GetRecentSessions(ctx, 200)
226+ if err != nil {
227+ if isMissingSessionsTable(err) {
228+ return fsSessions, nil
229+ }
230+ return nil, err
231+ }
232+
233+ byID := make(map[string]SessionInfo, len(dbSessions))
234+ merged := make([]SessionInfo, 0, len(dbSessions)+len(fsSessions))
235+ for _, s := range dbSessions {
236+ if s == nil {
237+ continue
238+ }
239+ filename := logging.SessionFilename(string(s.ID))
240+ path := filepath.Join(logDir, filename)
241+ info := SessionInfo{
242+ SessionID: string(s.ID),
243+ ShortID: s.ShortID(),
244+ Filename: filename,
245+ Path: path,
246+ StartedAt: s.StartedAt,
247+ EndedAt: s.EndedAt,
248+ Type: string(s.Type),
249+ FromDB: true,
250+ }
251+ if stat, statErr := os.Stat(path); statErr == nil {
252+ info.Size = stat.Size()
253+ info.ModTime = stat.ModTime()
254+ }
255+ byID[info.SessionID] = info
256+ merged = append(merged, info)
257+ }
258+
259+ for _, s := range fsSessions {
260+ if _, ok := byID[s.SessionID]; ok {
261+ continue
262+ }
263+ merged = append(merged, s)
264+ }
265+
266+ sort.Slice(merged, func(i, j int) bool {
267+ return sessionSortTime(merged[i]).After(sessionSortTime(merged[j]))
268+ })
269+ return merged, nil
270+}
271+
272+func sessionSortTime(s SessionInfo) time.Time {
273+ if !s.StartedAt.IsZero() {
274+ return s.StartedAt
275+ }
276+ return s.ModTime
277+}
278+
279+func isMissingSessionsTable(err error) bool {
280+ if err == nil {
281+ return false
282+ }
283+ // sqlite returns e.g. "no such table: sessions" before migrations run.
284+ return strings.Contains(err.Error(), "no such table: sessions")
285+}
286+
182 // findSession finds a session by partial ID match.287 // findSession finds a session by partial ID match.
183-func findSession(logDir, query string) (*SessionInfo, error) {288+func findSession(ctx context.Context, sessionMgr sessionManager, logDir, query string) (*SessionInfo, error) {
184- sessions, err := getSessions(logDir)289+ sessions, err := getSessionsMerged(ctx, sessionMgr, logDir)
185 if err != nil {290 if err != nil {
186 return nil, err291 return nil, err
187 }292 }
@@ -190,19 +295,24 @@ func findSession(logDir, query string) (*SessionInfo, error) {
190 return nil, fmt.Errorf("no sessions found")295 return nil, fmt.Errorf("no sessions found")
191 }296 }
192 297
193- queryLower := strings.ToLower(query)298+ queryNormalized := strings.ToLower(strings.TrimSpace(query))
194 299
195- // Try exact short ID match first300+ // Try exact short ID match first.
196- for _, s := range sessions {301+ // Some entries may not have ShortID populated; derive it from SessionID.
197- if strings.EqualFold(s.ShortID, queryLower) {302+ for i := range sessions {
198- return &s, nil303+ shortID := sessions[i].ShortID
304+ if shortID == "" {
305+ shortID = logging.ShortSessionID(sessions[i].SessionID)
306+ }
307+ if strings.ToLower(shortID) == queryNormalized {
308+ return &sessions[i], nil
199 }309 }
200 }310 }
201 311
202 // Try partial match on full session ID312 // Try partial match on full session ID
203 var matches []SessionInfo313 var matches []SessionInfo
204 for _, s := range sessions {314 for _, s := range sessions {
205- if strings.Contains(strings.ToLower(s.SessionID), queryLower) {315+ if strings.Contains(strings.ToLower(s.SessionID), queryNormalized) {
206 matches = append(matches, s)316 matches = append(matches, s)
207 }317 }
208 }318 }
@@ -273,14 +383,29 @@ func tailSession(logPath string, theme *styles.Theme) error {
273 fmt.Println()383 fmt.Println()
274 384
275 reader := bufio.NewReader(file)385 reader := bufio.NewReader(file)
386+ pending := ""
276 for {387 for {
277- line, err := reader.ReadString('\n')388+ chunk, err := reader.ReadString('\n')
278 if err != nil {389 if err != nil {
279- // No new data, wait a bit390+ if err == io.EOF {
280- time.Sleep(100 * time.Millisecond)391+ // No full line yet; keep partial data.
281- continue392+ pending += chunk
393+ time.Sleep(100 * time.Millisecond)
394+ continue
395+ }
396+ return fmt.Errorf("read log file: %w", err)
397+ }
398+
399+ pending += chunk
400+ for {
401+ idx := strings.IndexByte(pending, '\n')
402+ if idx == -1 {
403+ break
404+ }
405+ line := pending[:idx]
406+ pending = pending[idx+1:]
407+ fmt.Println(colorizeLogLine(line, theme))
282 }408 }
283- fmt.Print(colorizeLogLine(line, theme))
284 }409 }
285 }410 }
286 411
A internal/cli/cmd/logs_test.go +74 -0
@@ -0,0 +1,74 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/logging"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+type fakeSessionMgr struct {
17+ sessions []*entity.Session
18+ err error
19+}
20+
21+func (f *fakeSessionMgr) GetRecentSessions(_ context.Context, _ int) ([]*entity.Session, error) {
22+ if f.err != nil {
23+ return nil, f.err
24+ }
25+ return f.sessions, nil
26+}
27+
28+func TestGetSessionsMerged_DBAndFS(t *testing.T) {
29+ logDir := t.TempDir()
30+
31+ s1 := &entity.Session{ID: entity.SessionID("20251217_205106_a7b3"), Type: entity.SessionTypeBrowser, StartedAt: time.Date(2025, 12, 17, 20, 51, 6, 0, time.UTC)}
32+ s2 := &entity.Session{ID: entity.SessionID("20251218_205106_bbbb"), Type: entity.SessionTypeBrowser, StartedAt: time.Date(2025, 12, 18, 20, 51, 6, 0, time.UTC)}
33+
34+ require.NoError(t, os.WriteFile(filepath.Join(logDir, logging.SessionFilename(string(s1.ID))), []byte("hi\n"), 0o600))
35+ require.NoError(t, os.WriteFile(filepath.Join(logDir, logging.SessionFilename("legacy_only")), []byte("legacy\n"), 0o600))
36+
37+ mgr := &fakeSessionMgr{sessions: []*entity.Session{s1, s2}}
38+
39+ merged, err := getSessionsMerged(context.Background(), mgr, logDir)
40+ require.NoError(t, err)
41+ require.Len(t, merged, 3)
42+
43+ byID := map[string]SessionInfo{}
44+ for _, s := range merged {
45+ byID[s.SessionID] = s
46+ }
47+
48+ require.True(t, byID[string(s1.ID)].FromDB)
49+ require.True(t, byID[string(s2.ID)].FromDB)
50+ require.False(t, byID["legacy_only"].FromDB)
51+}
52+
53+func TestGetSessionsMerged_MissingTableFallsBack(t *testing.T) {
54+ logDir := t.TempDir()
55+ require.NoError(t, os.WriteFile(filepath.Join(logDir, logging.SessionFilename("legacy_only")), []byte("legacy\n"), 0o600))
56+
57+ mgr := &fakeSessionMgr{err: fmt.Errorf("no such table: sessions")}
58+ merged, err := getSessionsMerged(context.Background(), mgr, logDir)
59+ require.NoError(t, err)
60+ require.Len(t, merged, 1)
61+ require.Equal(t, "legacy_only", merged[0].SessionID)
62+}
63+
64+func TestFindSession_DBShortIDMatch(t *testing.T) {
65+ logDir := t.TempDir()
66+
67+ s1 := &entity.Session{ID: entity.SessionID("20251217_205106_a7b3"), Type: entity.SessionTypeBrowser, StartedAt: time.Date(2025, 12, 17, 20, 51, 6, 0, time.UTC)}
68+ mgr := &fakeSessionMgr{sessions: []*entity.Session{s1}}
69+
70+ info, err := findSession(context.Background(), mgr, logDir, "a7b3")
71+ require.NoError(t, err)
72+ require.Equal(t, string(s1.ID), info.SessionID)
73+ require.Equal(t, "a7b3", info.ShortID)
74+}
@@ -0,0 +1,74 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/logging"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+type fakeSessionMgr struct {
17+ sessions []*entity.Session
18+ err error
19+}
20+
21+func (f *fakeSessionMgr) GetRecentSessions(_ context.Context, _ int) ([]*entity.Session, error) {
22+ if f.err != nil {
23+ return nil, f.err
24+ }
25+ return f.sessions, nil
26+}
27+
28+func TestGetSessionsMerged_DBAndFS(t *testing.T) {
29+ logDir := t.TempDir()
30+
31+ s1 := &entity.Session{ID: entity.SessionID("20251217_205106_a7b3"), Type: entity.SessionTypeBrowser, StartedAt: time.Date(2025, 12, 17, 20, 51, 6, 0, time.UTC)}
32+ s2 := &entity.Session{ID: entity.SessionID("20251218_205106_bbbb"), Type: entity.SessionTypeBrowser, StartedAt: time.Date(2025, 12, 18, 20, 51, 6, 0, time.UTC)}
33+
34+ require.NoError(t, os.WriteFile(filepath.Join(logDir, logging.SessionFilename(string(s1.ID))), []byte("hi\n"), 0o600))
35+ require.NoError(t, os.WriteFile(filepath.Join(logDir, logging.SessionFilename("legacy_only")), []byte("legacy\n"), 0o600))
36+
37+ mgr := &fakeSessionMgr{sessions: []*entity.Session{s1, s2}}
38+
39+ merged, err := getSessionsMerged(context.Background(), mgr, logDir)
40+ require.NoError(t, err)
41+ require.Len(t, merged, 3)
42+
43+ byID := map[string]SessionInfo{}
44+ for _, s := range merged {
45+ byID[s.SessionID] = s
46+ }
47+
48+ require.True(t, byID[string(s1.ID)].FromDB)
49+ require.True(t, byID[string(s2.ID)].FromDB)
50+ require.False(t, byID["legacy_only"].FromDB)
51+}
52+
53+func TestGetSessionsMerged_MissingTableFallsBack(t *testing.T) {
54+ logDir := t.TempDir()
55+ require.NoError(t, os.WriteFile(filepath.Join(logDir, logging.SessionFilename("legacy_only")), []byte("legacy\n"), 0o600))
56+
57+ mgr := &fakeSessionMgr{err: fmt.Errorf("no such table: sessions")}
58+ merged, err := getSessionsMerged(context.Background(), mgr, logDir)
59+ require.NoError(t, err)
60+ require.Len(t, merged, 1)
61+ require.Equal(t, "legacy_only", merged[0].SessionID)
62+}
63+
64+func TestFindSession_DBShortIDMatch(t *testing.T) {
65+ logDir := t.TempDir()
66+
67+ s1 := &entity.Session{ID: entity.SessionID("20251217_205106_a7b3"), Type: entity.SessionTypeBrowser, StartedAt: time.Date(2025, 12, 17, 20, 51, 6, 0, time.UTC)}
68+ mgr := &fakeSessionMgr{sessions: []*entity.Session{s1}}
69+
70+ info, err := findSession(context.Background(), mgr, logDir, "a7b3")
71+ require.NoError(t, err)
72+ require.Equal(t, string(s1.ID), info.SessionID)
73+ require.Equal(t, "a7b3", info.ShortID)
74+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close