history.go RawHistoryFolder

1package homepage
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"strings"
8	"time"
9
10	"github.com/bnema/dumber/internal/application/dto"
11	"github.com/bnema/dumber/internal/application/port"
12	"github.com/bnema/dumber/internal/domain/entity"
13	"github.com/bnema/dumber/internal/logging"
14)
15
16// HistoryHandlers handles history-related messages from the homepage.
17type HistoryHandlers struct {
18	historyUC port.HomepageHistory
19}
20
21// NewHistoryHandlers creates a new HistoryHandlers instance.
22func NewHistoryHandlers(historyUC port.HomepageHistory) *HistoryHandlers {
23	return &HistoryHandlers{historyUC: historyUC}
24}
25
26// timelineRequest is the payload for history_timeline messages.
27type timelineRequest struct {
28	RequestID string `json:"requestId"`
29	Limit     int    `json:"limit"`
30	Offset    int    `json:"offset"`
31}
32
33type timelineByDomainRequest struct {
34	RequestID string `json:"requestId"`
35	Domain    string `json:"domain"`
36	Limit     int    `json:"limit"`
37	Offset    int    `json:"offset"`
38}
39
40type timelineWindowRequest struct {
41	RequestID string `json:"requestId"`
42	Before    string `json:"before"`
43	BeforeID  int64  `json:"beforeId"`
44	Domain    string `json:"domain"`
45}
46
47// HandleTimeline handles history_timeline messages.
48func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
49	return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
50		log := logging.FromContext(ctx)
51
52		var req timelineRequest
53		if err := json.Unmarshal(payload, &req); err != nil {
54			return NewErrorResponse("", err), nil
55		}
56
57		log.Debug().
58			Str("request_id", req.RequestID).
59			Int("limit", req.Limit).
60			Int("offset", req.Offset).
61			Msg("handling history_timeline")
62		if req.Limit <= 0 {
63			return NewErrorResponse(
64				req.RequestID,
65				fmt.Errorf("history_timeline requires a positive limit; use history_timeline_window for lazy history loading"),
66			), nil
67		}
68
69		entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset)
70		if err != nil {
71			return NewErrorResponse(req.RequestID, err), nil
72		}
73
74		return NewSuccessResponse(req.RequestID, entries), nil
75	})
76}
77
78// HandleTimelineByDomain handles history_timeline_by_domain messages.
79func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler {
80	return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
81		log := logging.FromContext(ctx)
82
83		var req timelineByDomainRequest
84		if err := json.Unmarshal(payload, &req); err != nil {
85			return NewErrorResponse("", err), nil
86		}
87
88		req.Domain = strings.TrimSpace(req.Domain)
89		if req.Domain == "" {
90			return NewErrorResponse(req.RequestID, fmt.Errorf("domain is required")), nil
91		}
92
93		log.Debug().
94			Str("request_id", req.RequestID).
95			Str("domain", req.Domain).
96			Int("limit", req.Limit).
97			Int("offset", req.Offset).
98			Msg("handling history_timeline_by_domain")
99		if req.Limit <= 0 {
100			return NewErrorResponse(
101				req.RequestID,
102				fmt.Errorf("history_timeline_by_domain requires a positive limit; use history_timeline_window for lazy history loading"),
103			), nil
104		}
105
106		entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset)
107		if err != nil {
108			return NewErrorResponse(req.RequestID, err), nil
109		}
110
111		return NewSuccessResponse(req.RequestID, entries), nil
112	})
113}
114
115// HandleTimelineWindow handles history_timeline_window messages.
116func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler {
117	return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
118		log := logging.FromContext(ctx)
119
120		var req timelineWindowRequest
121		if err := json.Unmarshal(payload, &req); err != nil {
122			return NewErrorResponse("", err), nil
123		}
124
125		domain := strings.TrimSpace(req.Domain)
126
127		var before time.Time
128		hasBefore := strings.TrimSpace(req.Before) != ""
129		if req.BeforeID < 0 {
130			return NewErrorResponse(req.RequestID, fmt.Errorf("history window cursor id must be non-negative")), nil
131		}
132		hasBeforeID := req.BeforeID > 0
133		if hasBefore != hasBeforeID {
134			return NewErrorResponse(req.RequestID, fmt.Errorf("history window cursor requires before and beforeId")), nil
135		}
136		if hasBefore {
137			parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.Before))
138			if err != nil {
139				return NewErrorResponse(req.RequestID, fmt.Errorf("invalid history window cursor")), nil
140			}
141			before = parsed
142		}
143
144		log.Debug().
145			Str("request_id", req.RequestID).
146			Str("domain", domain).
147			Time("before", before).
148			Msg("handling history_timeline_window")
149
150		window, err := h.historyUC.GetRecentWindow(ctx, before, req.BeforeID, domain)
151		if err != nil {
152			return NewErrorResponse(req.RequestID, err), nil
153		}
154
155		return NewSuccessResponse(req.RequestID, window), nil
156	})
157}
158
159// searchRequest is the payload for history_search_fts messages.
160type searchRequest struct {
161	RequestID string `json:"requestId"`
162	Query     string `json:"query"`
163	Limit     int    `json:"limit"`
164}
165
166// HandleSearchFTS handles history_search_fts messages.
167func (h *HistoryHandlers) HandleSearchFTS() port.WebUIMessageHandler {
168	return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
169		log := logging.FromContext(ctx)
170
171		var req searchRequest
172		if err := json.Unmarshal(payload, &req); err != nil {
173			return NewErrorResponse("", err), nil
174		}
175
176		log.Debug().
177			Str("request_id", req.RequestID).
178			Str("query", req.Query).
179			Msg("handling history_search_fts")
180
181		output, err := h.historyUC.Search(ctx, dto.HistorySearchInput{
182			Query: req.Query,
183			Limit: req.Limit,
184		})
185		if err != nil {
186			return NewErrorResponse(req.RequestID, err), nil
187		}
188
189		// Convert matches to entries for frontend compatibility
190		entries := make([]*entity.HistoryEntry, 0, len(output.Matches))
191		for _, m := range output.Matches {
192			entries = append(entries, m.Entry)
193		}
194
195		return NewSuccessResponse(req.RequestID, entries), nil
196	})
197}
198
199// deleteEntryRequest is the payload for history_delete_entry messages.
200type deleteEntryRequest struct {
201	RequestID string `json:"requestId"`
202	ID        int64  `json:"id"`
203}
204
205// HandleDeleteEntry handles history_delete_entry messages.
206func (h *HistoryHandlers) HandleDeleteEntry() port.WebUIMessageHandler {
207	return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
208		log := logging.FromContext(ctx)
209
210		var req deleteEntryRequest
211		if err := json.Unmarshal(payload, &req); err != nil {
212			return NewErrorResponse("", err), nil
213		}
214
215		log.Debug().
216			Str("request_id", req.RequestID).
217			Int64("id", req.ID).
218			Msg("handling history_delete_entry")
219
220		if err := h.historyUC.Delete(ctx, req.ID); err != nil {
221			return NewErrorResponse(req.RequestID, err), nil
222		}
223
224		return NewSuccessResponse(req.RequestID, nil), nil
225	})
226}
227
228// deleteRangeRequest is the payload for history_delete_range messages.
229type deleteRangeRequest struct {
230	RequestID string `json:"requestId"`
231	Range     string `json:"range"` // "hour", "day", "week", "month", "all"
232}
233
234// HandleDeleteRange handles history_delete_range messages.
235func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler {
236	return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
237		log := logging.FromContext(ctx)
238
239		var req deleteRangeRequest
240		if err := json.Unmarshal(payload, &req); err != nil {
241			return NewErrorResponse("", err), nil
242		}
243
244		req.Range = strings.TrimSpace(req.Range)
245		log.Debug().
246			Str("request_id", req.RequestID).
247			Str("range", req.Range).
248			Msg("handling history_delete_range")
249
250		if err := h.historyUC.ClearRange(ctx, req.Range); err != nil {
251			return NewErrorResponse(req.RequestID, err), nil
252		}
253
254		return NewSuccessResponse(req.RequestID, nil), nil
255	})
256}
257
258// HandleClearAll handles history_clear_all messages.
259func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler {
260	return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
261		log := logging.FromContext(ctx)
262
263		requestID := ParseRequestID(payload)
264
265		log.Debug().
266			Str("request_id", requestID).
267			Msg("handling history_clear_all")
268
269		if err := h.historyUC.ClearAll(ctx); err != nil {
270			return NewErrorResponse(requestID, err), nil
271		}
272
273		return NewSuccessResponse(requestID, nil), nil
274	})
275}
276
277// HandleStats handles history_stats messages.
278func (h *HistoryHandlers) HandleStats() port.WebUIMessageHandler {
279	return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
280		log := logging.FromContext(ctx)
281
282		var req struct {
283			RequestID string `json:"requestId"`
284		}
285		if err := json.Unmarshal(payload, &req); err != nil {
286			return NewErrorResponse("", err), nil
287		}
288
289		log.Debug().Str("request_id", req.RequestID).Msg("handling history_stats")
290
291		stats, err := h.historyUC.GetStats(ctx)
292		if err != nil {
293			return NewErrorResponse(req.RequestID, err), nil
294		}
295
296		return NewSuccessResponse(req.RequestID, stats), nil
297	})
298}
299
300// HandleAnalytics handles history_analytics messages.
301func (h *HistoryHandlers) HandleAnalytics() port.WebUIMessageHandler {
302	return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
303		log := logging.FromContext(ctx)
304
305		requestID := ParseRequestID(payload)
306
307		log.Debug().
308			Str("request_id", requestID).
309			Msg("handling history_analytics")
310
311		analytics, err := h.historyUC.GetAnalytics(ctx)
312		if err != nil {
313			return NewErrorResponse(requestID, err), nil
314		}
315
316		return NewSuccessResponse(requestID, analytics), nil
317	})
318}
319
320// domainStatsRequest is the payload for history_domain_stats messages.
321type domainStatsRequest struct {
322	RequestID string `json:"requestId"`
323	Limit     int    `json:"limit"`
324}
325
326// HandleDomainStats handles history_domain_stats messages.
327func (h *HistoryHandlers) HandleDomainStats() port.WebUIMessageHandler {
328	return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
329		log := logging.FromContext(ctx)
330
331		var req domainStatsRequest
332		if err := json.Unmarshal(payload, &req); err != nil {
333			return NewErrorResponse("", err), nil
334		}
335
336		log.Debug().
337			Str("request_id", req.RequestID).
338			Int("limit", req.Limit).
339			Msg("handling history_domain_stats")
340
341		stats, err := h.historyUC.GetDomainStats(ctx, req.Limit)
342		if err != nil {
343			return NewErrorResponse(req.RequestID, err), nil
344		}
345
346		return NewSuccessResponse(req.RequestID, stats), nil
347	})
348}
349
350// deleteDomainRequest is the payload for history_delete_domain messages.
351type deleteDomainRequest struct {
352	RequestID string `json:"requestId"`
353	Domain    string `json:"domain"`
354}
355
356// HandleDeleteDomain handles history_delete_domain messages.
357func (h *HistoryHandlers) HandleDeleteDomain() port.WebUIMessageHandler {
358	return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
359		log := logging.FromContext(ctx)
360
361		var req deleteDomainRequest
362		if err := json.Unmarshal(payload, &req); err != nil {
363			return NewErrorResponse("", err), nil
364		}
365
366		log.Debug().
367			Str("request_id", req.RequestID).
368			Str("domain", req.Domain).
369			Msg("handling history_delete_domain")
370
371		if err := h.historyUC.DeleteByDomain(ctx, req.Domain); err != nil {
372			return NewErrorResponse(req.RequestID, err), nil
373		}
374
375		return NewSuccessResponse(req.RequestID, nil), nil
376	})
377}

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