main 5041353

crashes.go RawHistoryFolder

1package cmd
2
3import (
4	"encoding/json"
5	"fmt"
6	"os"
7	"path/filepath"
8	"sort"
9	"strings"
10	"time"
11
12	"github.com/spf13/cobra"
13
14	"github.com/bnema/dumber/internal/cli/styles"
15	"github.com/bnema/dumber/internal/infrastructure/config"
16)
17
18type crashReportSummary struct {
19	SessionID      string `json:"session_id"`
20	GeneratedAt    string `json:"generated_at"`
21	Classification struct {
22		Class  string `json:"class"`
23		Reason string `json:"reason"`
24	} `json:"classification"`
25}
26
27var crashesCmd = &cobra.Command{
28	Use:   "crashes",
29	Short: "List unexpected-close crash reports",
30	Long: `List crash reports generated after unexpected browser closes.
31
32Use subcommands to inspect full details or print an issue template.
33
34Examples:
35  dumber crashes
36  dumber crashes show latest
37  dumber crashes issue latest`,
38	RunE: runCrashes,
39}
40
41var crashesShowCmd = &cobra.Command{
42	Use:   "show <report|latest>",
43	Short: "Show a crash report",
44	Args:  cobra.ExactArgs(1),
45	RunE:  runCrashesShow,
46}
47
48var crashesIssueCmd = &cobra.Command{
49	Use:   "issue <report|latest>",
50	Short: "Print GitHub-ready issue body from report",
51	Args:  cobra.ExactArgs(1),
52	RunE:  runCrashesIssue,
53}
54
55func init() {
56	rootCmd.AddCommand(crashesCmd)
57	crashesCmd.AddCommand(crashesShowCmd)
58	crashesCmd.AddCommand(crashesIssueCmd)
59}
60
61func runCrashes(_ *cobra.Command, _ []string) error {
62	app := GetApp()
63	if app == nil {
64		return fmt.Errorf("app not initialized")
65	}
66
67	crashDir := getCrashReportsDir(app.Config)
68	reports, err := loadCrashReports(crashDir)
69	if err != nil {
70		return err
71	}
72	if len(reports) == 0 {
73		fmt.Println(app.Theme.Subtle.Render("No crash reports found."))
74		return nil
75	}
76
77	fmt.Println(app.Theme.Title.Render("Crash reports (newest first):"))
78	fmt.Println()
79	for _, item := range reports {
80		generated := item.GeneratedAt
81		if ts, err := time.Parse(time.RFC3339Nano, item.GeneratedAt); err == nil {
82			generated = ts.Format("2006-01-02 15:04:05")
83		}
84		shortID := shortCrashID(item.Path)
85		line := fmt.Sprintf("  %s  %s  %s", app.Theme.Highlight.Render(shortID), app.Theme.Subtle.Render(generated), item.Classification.Class)
86		fmt.Println(line)
87	}
88
89	fmt.Println()
90	fmt.Println(app.Theme.Subtle.Render("Use 'dumber crashes show <id|latest>' for details"))
91	return nil
92}
93
94func runCrashesShow(_ *cobra.Command, args []string) error {
95	app := GetApp()
96	if app == nil {
97		return fmt.Errorf("app not initialized")
98	}
99	renderer := styles.NewCrashesCLIRenderer(app.Theme)
100
101	report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
102	if err != nil {
103		_, _ = fmt.Fprintf(os.Stderr, "%s\n%s\n", renderer.RenderError(err), renderer.RenderHintList())
104		return wrapPrintedError(fmt.Errorf("resolve crash report: %w", err))
105	}
106
107	body, err := os.ReadFile(report.MarkdownPath)
108	if err != nil {
109		_, _ = fmt.Fprintf(os.Stderr, "%s\n", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
110		return wrapPrintedError(fmt.Errorf("read crash report markdown: %w", err))
111	}
112	fmt.Print(string(body))
113	return nil
114}
115
116func runCrashesIssue(_ *cobra.Command, args []string) error {
117	app := GetApp()
118	if app == nil {
119		return fmt.Errorf("app not initialized")
120	}
121	renderer := styles.NewCrashesCLIRenderer(app.Theme)
122
123	report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
124	if err != nil {
125		_, _ = fmt.Fprintf(os.Stderr, "%s\n%s\n", renderer.RenderError(err), renderer.RenderHintList())
126		return wrapPrintedError(fmt.Errorf("resolve crash report: %w", err))
127	}
128
129	body, err := os.ReadFile(report.MarkdownPath)
130	if err != nil {
131		_, _ = fmt.Fprintf(os.Stderr, "%s\n", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
132		return wrapPrintedError(fmt.Errorf("read crash report markdown: %w", err))
133	}
134	all := string(body)
135	start := strings.Index(all, "## GitHub Issue Template")
136	if start < 0 {
137		fmt.Print(all)
138		return nil
139	}
140	fmt.Print(strings.TrimSpace(all[start:]) + "\n")
141	return nil
142}
143
144type crashReportFile struct {
145	Path         string
146	MarkdownPath string
147	crashReportSummary
148}
149
150func getCrashReportsDir(cfg *config.Config) string {
151	logDir := ""
152	if cfg != nil {
153		logDir = cfg.Logging.LogDir
154	}
155	if logDir == "" {
156		resolved, err := config.GetLogDir()
157		if err == nil {
158			logDir = resolved
159		}
160	}
161	if logDir == "" {
162		stateDir := os.Getenv("XDG_STATE_HOME")
163		if stateDir == "" {
164			home, _ := os.UserHomeDir()
165			stateDir = filepath.Join(home, ".local", "state")
166		}
167		logDir = filepath.Join(stateDir, "dumber", "logs")
168	}
169	return filepath.Join(logDir, "crashes")
170}
171
172func loadCrashReports(crashDir string) ([]crashReportFile, error) {
173	entries, err := os.ReadDir(crashDir)
174	if err != nil {
175		if os.IsNotExist(err) {
176			return nil, nil
177		}
178		return nil, err
179	}
180
181	reports := make([]crashReportFile, 0, len(entries))
182	for _, entry := range entries {
183		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".crash.json") {
184			continue
185		}
186		path := filepath.Join(crashDir, entry.Name())
187		raw, readErr := os.ReadFile(path)
188		if readErr != nil {
189			continue
190		}
191		var parsed crashReportSummary
192		if unmarshalErr := json.Unmarshal(raw, &parsed); unmarshalErr != nil {
193			continue
194		}
195		reports = append(reports, crashReportFile{
196			Path:               path,
197			MarkdownPath:       strings.TrimSuffix(path, ".json") + ".md",
198			crashReportSummary: parsed,
199		})
200	}
201
202	sort.Slice(reports, func(i, j int) bool {
203		left, errLeft := time.Parse(time.RFC3339Nano, reports[i].GeneratedAt)
204		right, errRight := time.Parse(time.RFC3339Nano, reports[j].GeneratedAt)
205		if errLeft == nil && errRight == nil {
206			return left.After(right)
207		}
208		return reports[i].Path > reports[j].Path
209	})
210
211	return reports, nil
212}
213
214func resolveCrashReport(crashDir, query string) (*crashReportFile, error) {
215	reports, err := loadCrashReports(crashDir)
216	if err != nil {
217		return nil, err
218	}
219	if len(reports) == 0 {
220		return nil, fmt.Errorf("no crash reports found")
221	}
222	if strings.EqualFold(query, "latest") {
223		return &reports[0], nil
224	}
225
226	query = strings.TrimSpace(strings.ToLower(query))
227	for i := range reports {
228		id := strings.ToLower(shortCrashID(reports[i].Path))
229		if id == query {
230			return &reports[i], nil
231		}
232	}
233
234	matches := make([]int, 0, len(reports))
235	for i := range reports {
236		full := strings.ToLower(filepath.Base(reports[i].Path))
237		if strings.Contains(full, query) || strings.Contains(strings.ToLower(reports[i].SessionID), query) {
238			matches = append(matches, i)
239		}
240	}
241
242	if len(matches) == 1 {
243		return &reports[matches[0]], nil
244	}
245	if len(matches) > 1 {
246		ids := make([]string, 0, len(matches))
247		for _, idx := range matches {
248			ids = append(ids, shortCrashID(reports[idx].Path))
249		}
250		return nil, fmt.Errorf("multiple reports match '%s': %s", query, strings.Join(ids, ", "))
251	}
252
253	return nil, fmt.Errorf("no crash report matching '%s' found", query)
254}
255
256func shortCrashID(path string) string {
257	base := filepath.Base(path)
258	base = strings.TrimSuffix(base, ".crash.json")
259	base = strings.TrimPrefix(base, "session_")
260	return base
261}

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