logs.go RawHistoryFolder

1package cmd
2
3import (
4	"bufio"
5	"context"
6	"encoding/json"
7	"fmt"
8	"io"
9	"os"
10	"path/filepath"
11	"sort"
12	"strings"
13	"time"
14
15	"github.com/spf13/cobra"
16
17	"github.com/bnema/dumber/internal/cli/styles"
18	"github.com/bnema/dumber/internal/domain/entity"
19	"github.com/bnema/dumber/internal/infrastructure/config"
20	"github.com/bnema/dumber/internal/logging"
21)
22
23var (
24	logsFollow   bool
25	logsLines    int
26	logsClearAll bool
27)
28
29const (
30	defaultLogsLines    = 50
31	recentSessionsLimit = 200
32)
33
34var logsCmd = &cobra.Command{
35	Use:   "logs [session]",
36	Short: "View application logs",
37	Long: `View dumber application logs by session.
38
39Without arguments, lists all available sessions.
40With a session ID (or partial match), shows logs for that session.
41
42Examples:
43  dumber logs                 # List all sessions
44  dumber logs a7b3            # View logs for session ending in 'a7b3'
45  dumber logs -f a7b3         # Follow logs in real-time
46  dumber logs -n 100 a7b3     # Show last 100 lines`,
47	RunE: runLogs,
48}
49
50func init() {
51	rootCmd.AddCommand(logsCmd)
52
53	logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "follow log output in real-time")
54	logsCmd.Flags().IntVarP(&logsLines, "lines", "n", defaultLogsLines, "number of lines to show")
55}
56
57// SessionInfo holds metadata about a log session.
58type SessionInfo struct {
59	SessionID string
60	ShortID   string
61	Filename  string
62	Path      string
63	Size      int64
64	ModTime   time.Time
65
66	// DB metadata (optional)
67	StartedAt time.Time
68	EndedAt   *time.Time
69	Type      string
70	FromDB    bool
71}
72
73type sessionManager interface {
74	GetRecentSessions(ctx context.Context, limit int) ([]*entity.Session, error)
75}
76
77func runLogs(_ *cobra.Command, args []string) error {
78	app := GetApp()
79	if app == nil {
80		return fmt.Errorf("app not initialized")
81	}
82
83	ctx := app.Ctx()
84	logDir := getLogDir()
85
86	// List sessions if no argument provided
87	if len(args) == 0 {
88		return listSessions(ctx, app.SessionUC, logDir, app.Theme)
89	}
90
91	// Find session by partial match
92	sessionQuery := args[0]
93	session, err := findSession(ctx, app.SessionUC, logDir, sessionQuery)
94	if err != nil {
95		return err
96	}
97
98	if _, err := os.Stat(session.Path); err != nil {
99		if os.IsNotExist(err) {
100			return fmt.Errorf("log file for session '%s' not found at %s", session.ShortID, session.Path)
101		}
102		return fmt.Errorf("stat log file: %w", err)
103	}
104
105	if logsFollow {
106		return tailSession(session.Path, app.Theme)
107	}
108
109	return showSession(session.Path, logsLines, app.Theme)
110}
111
112// getLogDir returns the log directory path.
113func getLogDir() string {
114	logDir, err := config.GetLogDir()
115	if err != nil {
116		// Fallback to XDG default
117		stateDir := os.Getenv("XDG_STATE_HOME")
118		if stateDir == "" {
119			home, _ := os.UserHomeDir()
120			stateDir = filepath.Join(home, ".local", "state")
121		}
122		return filepath.Join(stateDir, "dumber", "logs")
123	}
124	return logDir
125}
126
127// listSessions displays all available log sessions.
128func listSessions(ctx context.Context, sessionMgr sessionManager, logDir string, theme *styles.Theme) error {
129	sessions, err := getSessionsMerged(ctx, sessionMgr, logDir)
130	if err != nil {
131		return err
132	}
133
134	if len(sessions) == 0 {
135		fmt.Println(theme.Subtle.Render("No sessions found. Run 'dumber browse' to create logs."))
136		return nil
137	}
138
139	// Header
140	fmt.Println(theme.Title.Render("Sessions (newest first):"))
141	fmt.Println()
142
143	// Table format: ShortID | DateTime | Status | Size
144	for i := range sessions {
145		s := &sessions[i]
146		timeStr := sessionSortTime(*s).Format("2006-01-02 15:04:05")
147
148		status := ""
149		if s.FromDB {
150			if s.EndedAt == nil {
151				status = theme.SuccessStyle.Render("active")
152			} else {
153				status = theme.Subtle.Render("ended")
154			}
155		}
156
157		sizeStr := "?"
158		if s.Size > 0 {
159			sizeStr = formatSize(s.Size)
160		}
161
162		line := fmt.Sprintf("  %s  %s  %s  %s",
163			theme.Highlight.Render(s.ShortID),
164			theme.Subtle.Render(timeStr),
165			status,
166			theme.Subtle.Render(fmt.Sprintf("(%s)", sizeStr)),
167		)
168		fmt.Println(line)
169	}
170
171	fmt.Println()
172	fmt.Println(theme.Subtle.Render("Use 'dumber logs <id>' to view a session"))
173	return nil
174}
175
176// getSessions returns all session log files, sorted by modification time (newest first).
177func getSessions(logDir string) ([]SessionInfo, error) {
178	entries, err := os.ReadDir(logDir)
179	if err != nil {
180		if os.IsNotExist(err) {
181			return nil, nil
182		}
183		return nil, fmt.Errorf("read log directory: %w", err)
184	}
185
186	var sessions []SessionInfo
187	for _, entry := range entries {
188		if entry.IsDir() {
189			continue
190		}
191
192		sessionID, ok := logging.ParseSessionFilename(entry.Name())
193		if !ok {
194			continue
195		}
196
197		info, err := entry.Info()
198		if err != nil {
199			continue
200		}
201
202		sessions = append(sessions, SessionInfo{
203			SessionID: sessionID,
204			ShortID:   logging.ShortSessionID(sessionID),
205			Filename:  entry.Name(),
206			Path:      filepath.Join(logDir, entry.Name()),
207			Size:      info.Size(),
208			ModTime:   info.ModTime(),
209		})
210	}
211
212	// Sort by modification time (newest first)
213	sort.Slice(sessions, func(i, j int) bool {
214		return sessions[i].ModTime.After(sessions[j].ModTime)
215	})
216
217	return sessions, nil
218}
219
220func getSessionsMerged(ctx context.Context, sessionMgr sessionManager, logDir string) ([]SessionInfo, error) {
221	fsSessions, err := getSessions(logDir)
222	if err != nil {
223		return nil, err
224	}
225	if sessionMgr == nil {
226		return fsSessions, nil
227	}
228
229	dbSessions, err := sessionMgr.GetRecentSessions(ctx, recentSessionsLimit)
230	if err != nil {
231		if isMissingSessionsTable(err) {
232			return fsSessions, nil
233		}
234		return nil, err
235	}
236
237	byID := make(map[string]SessionInfo, len(dbSessions))
238	merged := make([]SessionInfo, 0, len(dbSessions)+len(fsSessions))
239	for _, s := range dbSessions {
240		if s == nil {
241			continue
242		}
243		filename := logging.SessionFilename(string(s.ID))
244		path := filepath.Join(logDir, filename)
245		info := SessionInfo{
246			SessionID: string(s.ID),
247			ShortID:   s.ShortID(),
248			Filename:  filename,
249			Path:      path,
250			StartedAt: s.StartedAt,
251			EndedAt:   s.EndedAt,
252			Type:      string(s.Type),
253			FromDB:    true,
254		}
255		if stat, statErr := os.Stat(path); statErr == nil {
256			info.Size = stat.Size()
257			info.ModTime = stat.ModTime()
258		}
259		byID[info.SessionID] = info
260		merged = append(merged, info)
261	}
262
263	for i := range fsSessions {
264		if _, ok := byID[fsSessions[i].SessionID]; ok {
265			continue
266		}
267		merged = append(merged, fsSessions[i])
268	}
269
270	sort.Slice(merged, func(i, j int) bool {
271		return sessionSortTime(merged[i]).After(sessionSortTime(merged[j]))
272	})
273	return merged, nil
274}
275
276func sessionSortTime(s SessionInfo) time.Time {
277	if !s.StartedAt.IsZero() {
278		return s.StartedAt
279	}
280	return s.ModTime
281}
282
283func isMissingSessionsTable(err error) bool {
284	if err == nil {
285		return false
286	}
287	// sqlite returns e.g. "no such table: sessions" before migrations run.
288	return strings.Contains(err.Error(), "no such table: sessions")
289}
290
291// findSession finds a session by partial ID match.
292func findSession(ctx context.Context, sessionMgr sessionManager, logDir, query string) (*SessionInfo, error) {
293	sessions, err := getSessionsMerged(ctx, sessionMgr, logDir)
294	if err != nil {
295		return nil, err
296	}
297
298	if len(sessions) == 0 {
299		return nil, fmt.Errorf("no sessions found")
300	}
301
302	queryNormalized := strings.ToLower(strings.TrimSpace(query))
303
304	// Try exact short ID match first.
305	// Some entries may not have ShortID populated; derive it from SessionID.
306	for i := range sessions {
307		shortID := sessions[i].ShortID
308		if shortID == "" {
309			shortID = logging.ShortSessionID(sessions[i].SessionID)
310		}
311		if strings.EqualFold(shortID, queryNormalized) {
312			return &sessions[i], nil
313		}
314	}
315
316	// Try partial match on full session ID
317	var matches []SessionInfo
318	for i := range sessions {
319		if strings.Contains(strings.ToLower(sessions[i].SessionID), queryNormalized) {
320			matches = append(matches, sessions[i])
321		}
322	}
323
324	switch len(matches) {
325	case 0:
326		return nil, fmt.Errorf("no session matching '%s' found", query)
327	case 1:
328		return &matches[0], nil
329	default:
330		// Multiple matches - show them and ask user to be more specific
331		var ids []string
332		for i := range matches {
333			ids = append(ids, matches[i].ShortID)
334		}
335		return nil, fmt.Errorf("multiple sessions match '%s': %s", query, strings.Join(ids, ", "))
336	}
337}
338
339// showSession displays the last N lines of a session log.
340func showSession(logPath string, lines int, theme *styles.Theme) (retErr error) {
341	file, err := os.Open(logPath)
342	if err != nil {
343		return fmt.Errorf("open log file: %w", err)
344	}
345	defer func() {
346		if closeErr := file.Close(); closeErr != nil && retErr == nil {
347			retErr = fmt.Errorf("close log file: %w", closeErr)
348		}
349	}()
350
351	// Read all lines
352	var allLines []string
353	scanner := bufio.NewScanner(file)
354	for scanner.Scan() {
355		allLines = append(allLines, scanner.Text())
356	}
357
358	if err := scanner.Err(); err != nil {
359		return fmt.Errorf("read log file: %w", err)
360	}
361
362	// Get last N lines
363	start := 0
364	if len(allLines) > lines {
365		start = len(allLines) - lines
366	}
367
368	for _, line := range allLines[start:] {
369		fmt.Println(colorizeLogLine(line, theme))
370	}
371
372	return nil
373}
374
375// tailSession follows a session log in real-time.
376func tailSession(logPath string, theme *styles.Theme) error {
377	file, err := os.Open(logPath)
378	if err != nil {
379		return fmt.Errorf("open log file: %w", err)
380	}
381	defer func() { _ = file.Close() }()
382
383	// Seek to end
384	_, _ = file.Seek(0, io.SeekEnd)
385
386	fmt.Println(theme.Subtle.Render("Following logs... (Ctrl+C to stop)"))
387	fmt.Println()
388
389	reader := bufio.NewReader(file)
390	pending := ""
391	for {
392		chunk, err := reader.ReadString('\n')
393		if err != nil {
394			if err == io.EOF {
395				// No full line yet; keep partial data.
396				pending += chunk
397				time.Sleep(100 * time.Millisecond)
398				continue
399			}
400			return fmt.Errorf("read log file: %w", err)
401		}
402
403		pending += chunk
404		for {
405			idx := strings.IndexByte(pending, '\n')
406			if idx == -1 {
407				break
408			}
409			line := pending[:idx]
410			pending = pending[idx+1:]
411			fmt.Println(colorizeLogLine(line, theme))
412		}
413	}
414}
415
416// logEntry represents a parsed JSON log entry.
417type logEntry struct {
418	Level   string `json:"level"`
419	Time    string `json:"time"`
420	Message string `json:"message"`
421	Session string `json:"session"`
422}
423
424// colorizeLogLine adds color based on log level.
425func colorizeLogLine(line string, theme *styles.Theme) string {
426	// Try to parse as JSON
427	var entry logEntry
428	if err := json.Unmarshal([]byte(line), &entry); err == nil {
429		return formatJSONLogLine(entry, theme)
430	}
431
432	// Fallback to pattern matching for non-JSON logs
433	switch {
434	case containsAny(line, "ERR", "ERROR", "error"):
435		return theme.ErrorStyle.Render(line)
436	case containsAny(line, "WRN", "WARN", "warn"):
437		return theme.WarningStyle.Render(line)
438	case containsAny(line, "DBG", "DEBUG", "debug"):
439		return theme.Subtle.Render(line)
440	default:
441		return line
442	}
443}
444
445// formatJSONLogLine formats a parsed JSON log entry with colors.
446func formatJSONLogLine(entry logEntry, theme *styles.Theme) string {
447	// Parse time if present
448	timeStr := ""
449	if entry.Time != "" {
450		if t, err := time.Parse(time.RFC3339, entry.Time); err == nil {
451			timeStr = t.Format("15:04:05")
452		} else {
453			timeStr = entry.Time
454		}
455	}
456
457	// Level styling
458	var levelStr string
459	switch entry.Level {
460	case "error":
461		levelStr = theme.ErrorStyle.Render("ERR")
462	case "warn":
463		levelStr = theme.WarningStyle.Render("WRN")
464	case "info":
465		levelStr = theme.Highlight.Render("INF")
466	case "debug":
467		levelStr = theme.Subtle.Render("DBG")
468	case "trace":
469		levelStr = theme.Subtle.Render("TRC")
470	default:
471		levelStr = entry.Level
472	}
473
474	return fmt.Sprintf("%s %s %s", theme.Subtle.Render(timeStr), levelStr, entry.Message)
475}
476
477// containsAny checks if s contains any of the substrings.
478func containsAny(s string, substrs ...string) bool {
479	sLower := strings.ToLower(s)
480	for _, substr := range substrs {
481		if strings.Contains(sLower, strings.ToLower(substr)) {
482			return true
483		}
484	}
485	return false
486}
487
488// logsClearCmd clears old session logs.
489var logsClearCmd = &cobra.Command{
490	Use:   "clear",
491	Short: "Clear old log files",
492	Long: `Remove old session log files.
493
494By default, removes sessions older than the configured MaxAge (default 7 days).
495Use --all to remove all sessions.`,
496	RunE: runLogsClear,
497}
498
499func init() {
500	logsCmd.AddCommand(logsClearCmd)
501	logsClearCmd.Flags().BoolVar(&logsClearAll, "all", false, "remove all session logs")
502}
503
504func runLogsClear(_ *cobra.Command, _ []string) error {
505	app := GetApp()
506	if app == nil {
507		return fmt.Errorf("app not initialized")
508	}
509
510	logDir := getLogDir()
511	sessions, err := getSessions(logDir)
512	if err != nil {
513		return err
514	}
515
516	if len(sessions) == 0 {
517		fmt.Println(app.Theme.Subtle.Render("No logs to clear"))
518		return nil
519	}
520
521	// Get MaxAge from config (default 7 days)
522	maxAge := 7
523	if app.Config != nil && app.Config.Logging.MaxAge > 0 {
524		maxAge = app.Config.Logging.MaxAge
525	}
526
527	cutoff := time.Now().AddDate(0, 0, -maxAge)
528	var removed int
529
530	for i := range sessions {
531		s := &sessions[i]
532		shouldRemove := logsClearAll || s.ModTime.Before(cutoff)
533		if !shouldRemove {
534			continue
535		}
536
537		if err := os.Remove(s.Path); err != nil {
538			fmt.Printf("%s %s: %v\n",
539				app.Theme.ErrorStyle.Render("\u2717"),
540				s.ShortID,
541				err,
542			)
543			continue
544		}
545
546		fmt.Printf("%s %s (%s)\n",
547			app.Theme.SuccessStyle.Render("\u2713"),
548			s.ShortID,
549			formatSize(s.Size),
550		)
551		removed++
552	}
553
554	if removed == 0 {
555		fmt.Println(app.Theme.Subtle.Render("No sessions older than " + fmt.Sprintf("%d days", maxAge)))
556	} else {
557		fmt.Printf("\n%s\n", app.Theme.SuccessStyle.Render(fmt.Sprintf("Cleared %d session(s)", removed)))
558	}
559
560	return nil
561}

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