v0.6.0 5c50dd3

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 stored in XDG-compliant directory for debugging purposes",
23	}
24
25	cmd.AddCommand(newLogsTailCmd())
26	cmd.AddCommand(newLogsListCmd())
27	cmd.AddCommand(newLogsCleanCmd())
28	cmd.AddCommand(newLogsPathCmd())
29
30	return cmd
31}
32
33// newLogsTailCmd creates the tail subcommand
34func newLogsTailCmd() *cobra.Command {
35	var lines int
36
37	cmd := &cobra.Command{
38		Use:   "tail [lines]",
39		Short: "Show last N lines of current log",
40		Long:  "Display the last N lines from the current log file. Defaults to 50 lines.",
41		Args:  cobra.MaximumNArgs(1),
42		RunE: func(cmd *cobra.Command, args []string) error {
43			// Parse lines argument if provided
44			if len(args) > 0 {
45				var err error
46				lines, err = strconv.Atoi(args[0])
47				if err != nil {
48					return fmt.Errorf("invalid number of lines: %s", args[0])
49				}
50			}
51
52			logDir, err := config.GetLogDir()
53			if err != nil {
54				return fmt.Errorf("failed to get log directory: %w", err)
55			}
56
57			currentLog := filepath.Join(logDir, "dumber.log")
58			return tailFile(currentLog, lines)
59		},
60	}
61
62	cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
63	return cmd
64}
65
66// newLogsListCmd creates the list subcommand
67func newLogsListCmd() *cobra.Command {
68	cmd := &cobra.Command{
69		Use:   "list",
70		Short: "List all available log files",
71		Long:  "Display all log files in the log directory with their sizes and modification times.",
72		RunE: func(cmd *cobra.Command, args []string) error {
73			logDir, err := config.GetLogDir()
74			if err != nil {
75				return fmt.Errorf("failed to get log directory: %w", err)
76			}
77
78			return listLogFiles(logDir)
79		},
80	}
81
82	return cmd
83}
84
85// newLogsCleanCmd creates the clean subcommand
86func newLogsCleanCmd() *cobra.Command {
87	var dryRun bool
88	var maxAge int
89
90	cmd := &cobra.Command{
91		Use:   "clean",
92		Short: "Remove old log files",
93		Long:  "Remove log files older than the specified age in days. Use --dry-run to see what would be deleted.",
94		RunE: func(cmd *cobra.Command, args []string) error {
95			logDir, err := config.GetLogDir()
96			if err != nil {
97				return fmt.Errorf("failed to get log directory: %w", err)
98			}
99
100			return cleanOldLogs(logDir, maxAge, dryRun)
101		},
102	}
103
104	cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be deleted without actually deleting")
105	cmd.Flags().IntVar(&maxAge, "max-age", 30, "Maximum age of log files in days")
106	return cmd
107}
108
109// newLogsPathCmd creates the path subcommand
110func newLogsPathCmd() *cobra.Command {
111	cmd := &cobra.Command{
112		Use:   "path",
113		Short: "Show the path to the log directory",
114		Long:  "Display the XDG-compliant path where logs are stored.",
115		RunE: func(cmd *cobra.Command, args []string) error {
116			logDir, err := config.GetLogDir()
117			if err != nil {
118				return fmt.Errorf("failed to get log directory: %w", err)
119			}
120
121			fmt.Println(logDir)
122			return nil
123		},
124	}
125
126	return cmd
127}
128
129// tailFile displays the last N lines of a file
130func tailFile(filePath string, lines int) error {
131	file, err := os.Open(filePath)
132	if err != nil {
133		if os.IsNotExist(err) {
134			fmt.Printf("Log file does not exist: %s\n", filePath)
135			return nil
136		}
137		return fmt.Errorf("failed to open log file: %w", err)
138	}
139	defer file.Close()
140
141	// Read all lines
142	var allLines []string
143	scanner := bufio.NewScanner(file)
144	for scanner.Scan() {
145		allLines = append(allLines, scanner.Text())
146	}
147
148	if err := scanner.Err(); err != nil {
149		return fmt.Errorf("error reading log file: %w", err)
150	}
151
152	// Display last N lines
153	start := len(allLines) - lines
154	if start < 0 {
155		start = 0
156	}
157
158	for i := start; i < len(allLines); i++ {
159		fmt.Println(allLines[i])
160	}
161
162	return nil
163}
164
165// listLogFiles displays all log files with their information
166func listLogFiles(logDir string) error {
167	if _, err := os.Stat(logDir); os.IsNotExist(err) {
168		fmt.Printf("Log directory does not exist: %s\n", logDir)
169		return nil
170	}
171
172	files, err := os.ReadDir(logDir)
173	if err != nil {
174		return fmt.Errorf("failed to read log directory: %w", err)
175	}
176
177	// Filter and collect log files
178	var logFiles []os.FileInfo
179	for _, file := range files {
180		if file.IsDir() {
181			continue
182		}
183
184		name := file.Name()
185		if strings.HasSuffix(name, ".log") || strings.Contains(name, "dumber.log") {
186			info, err := file.Info()
187			if err != nil {
188				continue
189			}
190			logFiles = append(logFiles, info)
191		}
192	}
193
194	if len(logFiles) == 0 {
195		fmt.Println("No log files found")
196		return nil
197	}
198
199	// Sort by modification time (newest first)
200	sort.Slice(logFiles, func(i, j int) bool {
201		return logFiles[i].ModTime().After(logFiles[j].ModTime())
202	})
203
204	// Display files
205	fmt.Printf("%-30s %-20s %-10s\n", "FILENAME", "MODIFIED", "SIZE")
206	fmt.Printf("%-30s %-20s %-10s\n", "--------", "--------", "----")
207
208	for _, file := range logFiles {
209		size := formatSize(file.Size())
210		modTime := file.ModTime().Format("2006-01-02 15:04:05")
211		fmt.Printf("%-30s %-20s %-10s\n", file.Name(), modTime, size)
212	}
213
214	return nil
215}
216
217// cleanOldLogs removes log files older than maxAge days
218func cleanOldLogs(logDir string, maxAge int, dryRun bool) error {
219	if _, err := os.Stat(logDir); os.IsNotExist(err) {
220		fmt.Printf("Log directory does not exist: %s\n", logDir)
221		return nil
222	}
223
224	files, err := os.ReadDir(logDir)
225	if err != nil {
226		return fmt.Errorf("failed to read log directory: %w", err)
227	}
228
229	cutoffTime := time.Now().AddDate(0, 0, -maxAge)
230	var deletedCount int
231	var deletedSize int64
232
233	for _, file := range files {
234		if file.IsDir() {
235			continue
236		}
237
238		name := file.Name()
239		// Skip the current log file
240		if name == "dumber.log" {
241			continue
242		}
243
244		// Only process log files
245		if !strings.HasSuffix(name, ".log") && !strings.Contains(name, "dumber.log") {
246			continue
247		}
248
249		info, err := file.Info()
250		if err != nil {
251			continue
252		}
253
254		if info.ModTime().Before(cutoffTime) {
255			filePath := filepath.Join(logDir, name)
256
257			if dryRun {
258				fmt.Printf("Would delete: %s (%.2f KB, modified %s)\n",
259					name, float64(info.Size())/1024, info.ModTime().Format("2006-01-02 15:04:05"))
260			} else {
261				err := os.Remove(filePath)
262				if err != nil {
263					fmt.Printf("Warning: failed to delete %s: %v\n", name, err)
264				} else {
265					fmt.Printf("Deleted: %s (%.2f KB)\n", name, float64(info.Size())/1024)
266				}
267			}
268
269			deletedCount++
270			deletedSize += info.Size()
271		}
272	}
273
274	if dryRun {
275		fmt.Printf("\nDry run complete. Would delete %d files (%.2f KB total)\n",
276			deletedCount, float64(deletedSize)/1024)
277	} else {
278		fmt.Printf("\nCleaned %d files (%.2f KB total)\n",
279			deletedCount, float64(deletedSize)/1024)
280	}
281
282	return nil
283}
284
285// formatSize formats file size in human-readable format
286func formatSize(size int64) string {
287	const unit = 1024
288	if size < unit {
289		return fmt.Sprintf("%d B", size)
290	}
291
292	div, exp := int64(unit), 0
293	for n := size / unit; n >= unit; n /= unit {
294		div *= unit
295		exp++
296	}
297
298	return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
299}

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