v0.12.0 d035193

logs.go RawHistoryFolder

1package cli
2
3import (
4	"bufio"
5	"fmt"
6	"os"
7	"path/filepath"
8	"sort"
9	"strconv"
10	"strings"
11	"time"
12
13	"github.com/bnema/dumber/internal/config"
14	"github.com/spf13/cobra"
15)
16
17// NewLogsCmd creates the logs command and its subcommands
18func NewLogsCmd() *cobra.Command {
19	cmd := &cobra.Command{
20		Use:   "logs",
21		Short: "View and manage application logs",
22		Long:  "Access logs of the aplication, including viewing recent entries, listing log files, cleaning old logs, and showing the log directory path.",
23	}
24
25	cmd.AddCommand(newLogsTailCmd())
26	cmd.AddCommand(newLogsListCmd())
27	cmd.AddCommand(newLogsCleanCmd())
28	cmd.AddCommand(newLogsPathCmd())
29	cmd.AddCommand(newLogsConsoleCmd())
30
31	return cmd
32}
33
34// newLogsTailCmd creates the tail subcommand
35func newLogsTailCmd() *cobra.Command {
36	var lines int
37
38	cmd := &cobra.Command{
39		Use:   "tail [lines]",
40		Short: "Show last N lines of current log",
41		Long:  "Display the last N lines from the current log file. Defaults to 50 lines.",
42		Args:  cobra.MaximumNArgs(1),
43		RunE: func(cmd *cobra.Command, args []string) error {
44			// Parse lines argument if provided
45			if len(args) > 0 {
46				var err error
47				lines, err = strconv.Atoi(args[0])
48				if err != nil {
49					return fmt.Errorf("invalid number of lines: %s", args[0])
50				}
51			}
52
53			logDir, err := config.GetLogDir()
54			if err != nil {
55				return fmt.Errorf("failed to get log directory: %w", err)
56			}
57
58			currentLog := filepath.Join(logDir, "dumber.log")
59			return tailFile(currentLog, lines)
60		},
61	}
62
63	cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
64	return cmd
65}
66
67// newLogsListCmd creates the list subcommand
68func newLogsListCmd() *cobra.Command {
69	cmd := &cobra.Command{
70		Use:   "list",
71		Short: "List all available log files",
72		Long:  "Display all log files in the log directory with their sizes and modification times.",
73		RunE: func(cmd *cobra.Command, args []string) error {
74			logDir, err := config.GetLogDir()
75			if err != nil {
76				return fmt.Errorf("failed to get log directory: %w", err)
77			}
78
79			return listLogFiles(logDir)
80		},
81	}
82
83	return cmd
84}
85
86// newLogsCleanCmd creates the clean subcommand
87func newLogsCleanCmd() *cobra.Command {
88	var dryRun bool
89	var maxAge int
90
91	cmd := &cobra.Command{
92		Use:   "clean",
93		Short: "Remove old log files",
94		Long:  "Remove log files older than the specified age in days. Use --dry-run to see what would be deleted.",
95		RunE: func(cmd *cobra.Command, args []string) error {
96			logDir, err := config.GetLogDir()
97			if err != nil {
98				return fmt.Errorf("failed to get log directory: %w", err)
99			}
100
101			return cleanOldLogs(logDir, maxAge, dryRun)
102		},
103	}
104
105	cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be deleted without actually deleting")
106	cmd.Flags().IntVar(&maxAge, "max-age", 30, "Maximum age of log files in days")
107	return cmd
108}
109
110// newLogsPathCmd creates the path subcommand
111func newLogsPathCmd() *cobra.Command {
112	cmd := &cobra.Command{
113		Use:   "path",
114		Short: "Show the path to the log directory",
115		Long:  "Display the XDG-compliant path where logs are stored.",
116		RunE: func(cmd *cobra.Command, args []string) error {
117			logDir, err := config.GetLogDir()
118			if err != nil {
119				return fmt.Errorf("failed to get log directory: %w", err)
120			}
121
122			fmt.Println(logDir)
123			return nil
124		},
125	}
126
127	return cmd
128}
129
130// newLogsConsoleCmd creates the console subcommand for filtering console logs
131func newLogsConsoleCmd() *cobra.Command {
132	var lines int
133	var level string
134
135	cmd := &cobra.Command{
136		Use:   "console",
137		Short: "View console logs from WebKit",
138		Long:  "Display console.log, console.error, and other console messages from web pages.",
139		RunE: func(cmd *cobra.Command, args []string) error {
140			logDir, err := config.GetLogDir()
141			if err != nil {
142				return fmt.Errorf("failed to get log directory: %w", err)
143			}
144
145			currentLog := filepath.Join(logDir, "dumber.log")
146			return tailConsoleMessages(currentLog, lines, level)
147		},
148	}
149
150	cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
151	cmd.Flags().StringVarP(&level, "level", "l", "", "Filter by console level (error, warn, info, log, debug)")
152
153	return cmd
154}
155
156// tailFile displays the last N lines of a file
157func tailFile(filePath string, lines int) error {
158	file, err := os.Open(filePath)
159	if err != nil {
160		if os.IsNotExist(err) {
161			fmt.Printf("Log file does not exist: %s\n", filePath)
162			return nil
163		}
164		return fmt.Errorf("failed to open log file: %w", err)
165	}
166	defer file.Close()
167
168	// Read all lines
169	var allLines []string
170	scanner := bufio.NewScanner(file)
171	for scanner.Scan() {
172		allLines = append(allLines, scanner.Text())
173	}
174
175	if err := scanner.Err(); err != nil {
176		return fmt.Errorf("error reading log file: %w", err)
177	}
178
179	// Display last N lines
180	start := len(allLines) - lines
181	if start < 0 {
182		start = 0
183	}
184
185	for i := start; i < len(allLines); i++ {
186		fmt.Println(allLines[i])
187	}
188
189	return nil
190}
191
192// listLogFiles displays all log files with their information
193func listLogFiles(logDir string) error {
194	if _, err := os.Stat(logDir); os.IsNotExist(err) {
195		fmt.Printf("Log directory does not exist: %s\n", logDir)
196		return nil
197	}
198
199	files, err := os.ReadDir(logDir)
200	if err != nil {
201		return fmt.Errorf("failed to read log directory: %w", err)
202	}
203
204	// Filter and collect log files
205	var logFiles []os.FileInfo
206	for _, file := range files {
207		if file.IsDir() {
208			continue
209		}
210
211		name := file.Name()
212		if strings.HasSuffix(name, ".log") || strings.Contains(name, "dumber.log") {
213			info, err := file.Info()
214			if err != nil {
215				continue
216			}
217			logFiles = append(logFiles, info)
218		}
219	}
220
221	if len(logFiles) == 0 {
222		fmt.Println("No log files found")
223		return nil
224	}
225
226	// Sort by modification time (newest first)
227	sort.Slice(logFiles, func(i, j int) bool {
228		return logFiles[i].ModTime().After(logFiles[j].ModTime())
229	})
230
231	// Display files
232	fmt.Printf("%-30s %-20s %-10s\n", "FILENAME", "MODIFIED", "SIZE")
233	fmt.Printf("%-30s %-20s %-10s\n", "--------", "--------", "----")
234
235	for _, file := range logFiles {
236		size := formatSize(file.Size())
237		modTime := file.ModTime().Format("2006-01-02 15:04:05")
238		fmt.Printf("%-30s %-20s %-10s\n", file.Name(), modTime, size)
239	}
240
241	return nil
242}
243
244// cleanOldLogs removes log files older than maxAge days
245func cleanOldLogs(logDir string, maxAge int, dryRun bool) error {
246	if _, err := os.Stat(logDir); os.IsNotExist(err) {
247		fmt.Printf("Log directory does not exist: %s\n", logDir)
248		return nil
249	}
250
251	files, err := os.ReadDir(logDir)
252	if err != nil {
253		return fmt.Errorf("failed to read log directory: %w", err)
254	}
255
256	cutoffTime := time.Now().AddDate(0, 0, -maxAge)
257	var deletedCount int
258	var deletedSize int64
259
260	for _, file := range files {
261		if file.IsDir() {
262			continue
263		}
264
265		name := file.Name()
266		// Skip the current log file
267		if name == "dumber.log" {
268			continue
269		}
270
271		// Only process log files
272		if !strings.HasSuffix(name, ".log") && !strings.Contains(name, "dumber.log") {
273			continue
274		}
275
276		info, err := file.Info()
277		if err != nil {
278			continue
279		}
280
281		if info.ModTime().Before(cutoffTime) {
282			filePath := filepath.Join(logDir, name)
283
284			if dryRun {
285				fmt.Printf("Would delete: %s (%.2f KB, modified %s)\n",
286					name, float64(info.Size())/1024, info.ModTime().Format("2006-01-02 15:04:05"))
287			} else {
288				err := os.Remove(filePath)
289				if err != nil {
290					fmt.Printf("Warning: failed to delete %s: %v\n", name, err)
291				} else {
292					fmt.Printf("Deleted: %s (%.2f KB)\n", name, float64(info.Size())/1024)
293				}
294			}
295
296			deletedCount++
297			deletedSize += info.Size()
298		}
299	}
300
301	if dryRun {
302		fmt.Printf("\nDry run complete. Would delete %d files (%.2f KB total)\n",
303			deletedCount, float64(deletedSize)/1024)
304	} else {
305		fmt.Printf("\nCleaned %d files (%.2f KB total)\n",
306			deletedCount, float64(deletedSize)/1024)
307	}
308
309	return nil
310}
311
312// formatSize formats file size in human-readable format
313func formatSize(size int64) string {
314	const unit = 1024
315	if size < unit {
316		return fmt.Sprintf("%d B", size)
317	}
318
319	div, exp := int64(unit), 0
320	for n := size / unit; n >= unit; n /= unit {
321		div *= unit
322		exp++
323	}
324
325	return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
326}
327
328// tailConsoleMessages displays console messages from the log file
329func tailConsoleMessages(filePath string, lines int, level string) error {
330	file, err := os.Open(filePath)
331	if err != nil {
332		if os.IsNotExist(err) {
333			fmt.Printf("Log file does not exist: %s\n", filePath)
334			return nil
335		}
336		return fmt.Errorf("failed to open log file: %w", err)
337	}
338	defer file.Close()
339
340	// Read all lines and filter for console messages
341	var consoleLines []string
342	scanner := bufio.NewScanner(file)
343	for scanner.Scan() {
344		line := scanner.Text()
345
346		// Filter for [CONSOLE] tagged messages
347		if !strings.Contains(line, "[CONSOLE]") {
348			continue
349		}
350
351		// Filter by level if specified
352		if level != "" {
353			levelFilter := fmt.Sprintf("[%s]", strings.ToUpper(level))
354			if !strings.Contains(line, levelFilter) {
355				continue
356			}
357		}
358
359		consoleLines = append(consoleLines, line)
360	}
361
362	if err := scanner.Err(); err != nil {
363		return fmt.Errorf("error reading log file: %w", err)
364	}
365
366	if len(consoleLines) == 0 {
367		if level != "" {
368			fmt.Printf("No console messages found with level '%s'\n", level)
369		} else {
370			fmt.Println("No console messages found")
371		}
372		return nil
373	}
374
375	// Display last N lines
376	start := len(consoleLines) - lines
377	if start < 0 {
378		start = 0
379	}
380
381	for i := start; i < len(consoleLines); i++ {
382		fmt.Println(consoleLines[i])
383	}
384
385	return nil
386}

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