v0.33.2 57f21d6

history_actions.go RawHistoryFolder

1package systemviews
2
3import (
4	"context"
5	"fmt"
6	"strconv"
7	"strings"
8	"time"
9
10	"github.com/bnema/dumber/internal/application/port"
11	"github.com/bnema/dumber/internal/domain/entity"
12)
13
14const (
15	historyActionSearch       = "history.search"
16	historyActionClear        = "history.clear"
17	historyActionFilterDomain = "history.filterDomain"
18	historyActionClearDomain  = "history.clearDomain"
19	historyActionPage         = "history.page"
20	historyActionLoadMore     = "history.loadMore"
21	historyActionDeleteEntry  = "history.deleteEntry"
22	historyActionDeleteRange  = "history.deleteRange"
23	historyActionDeleteDomain = "history.deleteDomain"
24)
25
26// HandleDOMAction applies an action delegated from the browser DOM and refreshes
27// the mounted view. It keeps UI orchestration in the systemview edge and calls
28// only application ports for data changes.
29func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
30	if a == nil {
31		return fmt.Errorf("app is nil")
32	}
33	a.lockState()
34	if a.closed {
35		a.unlockState()
36		return nil
37	}
38	a.renderGeneration++
39	if a.currentRoute == RouteUnknown || a.currentRoute == "" {
40		a.currentRoute = ParseRoute(a.deps.LocationURI)
41	}
42	route := a.currentRoute
43	if route == RouteHistory && event.Action == historyActionLoadMore {
44		a.unlockState()
45		return a.handleHistoryLoadMore(ctx, event)
46	}
47	defer a.unlockState()
48
49	switch route {
50	case RouteHistory:
51		a.historyError = ""
52		if err := a.handleHistoryAction(ctx, event); err != nil {
53			a.historyNotice = ""
54			a.historyError = err.Error()
55		}
56		if err := a.loadHistoryRoute(ctx); err != nil {
57			a.renderRouteError(err)
58			_ = a.mountRenderedHTML()
59			return err
60		}
61		return a.mountRenderedHTML()
62	case RouteFavorites:
63		a.favoritesError = ""
64		if err := a.handleFavoriteAction(ctx, event); err != nil {
65			a.favoritesNotice = ""
66			a.favoritesError = err.Error()
67		}
68		if err := a.loadFavoritesRoute(ctx); err != nil {
69			a.renderRouteError(err)
70			_ = a.mountRenderedHTML()
71			return err
72		}
73		return a.mountRenderedHTML()
74	case RouteConfig:
75		a.configError = ""
76		if err := a.handleConfigAction(ctx, event); err != nil {
77			a.configNotice = ""
78			a.configError = err.Error()
79		}
80		a.loadShellTheme(ctx)
81		if err := a.loadConfigRoute(ctx); err != nil {
82			a.renderRouteError(err)
83			_ = a.mountRenderedHTML()
84			return err
85		}
86		return a.mountRenderedHTML()
87	default:
88		return nil
89	}
90}
91
92//nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place.
93func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
94	if a.deps.History == nil {
95		return fmt.Errorf("history service not configured")
96	}
97
98	data := event.Data
99	switch event.Action {
100	case historyActionSearch:
101		a.historyQuery = strings.TrimSpace(data["query"])
102		a.historyDomainFilter = ""
103		a.resetHistoryWindowState()
104		a.historyNotice = ""
105	case historyActionClear:
106		a.historyQuery = ""
107		a.historyDomainFilter = ""
108		a.resetHistoryWindowState()
109		a.historyNotice = ""
110	case historyActionFilterDomain:
111		domain := strings.TrimSpace(data["domain"])
112		if domain == "" {
113			return fmt.Errorf("domain is required")
114		}
115		a.historyDomainFilter = domain
116		a.historyQuery = ""
117		a.resetHistoryWindowState()
118		a.historyNotice = ""
119	case historyActionClearDomain:
120		a.historyDomainFilter = ""
121		a.resetHistoryWindowState()
122		a.historyNotice = ""
123	case historyActionPage:
124		offset, err := strconv.Atoi(strings.TrimSpace(data["offset"]))
125		if err != nil || offset < 0 {
126			return fmt.Errorf("invalid history offset")
127		}
128		a.resetHistoryWindowState()
129		a.historyOffset = offset
130		a.historyNotice = ""
131	case historyActionDeleteEntry:
132		id, err := strconv.ParseInt(strings.TrimSpace(data["id"]), 10, 64)
133		if err != nil || id <= 0 {
134			return fmt.Errorf("invalid history entry id")
135		}
136		if err := a.deps.History.DeleteEntry(ctx, id); err != nil {
137			return err
138		}
139		a.resetHistoryWindowState()
140		a.historyNotice = "Deleted history entry"
141	case historyActionDeleteRange:
142		rangeID := strings.TrimSpace(data["range"])
143		if rangeID == "" {
144			return fmt.Errorf("history range is required")
145		}
146		if !isKnownHistoryCleanupRange(rangeID) {
147			return fmt.Errorf("invalid history range")
148		}
149		if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil {
150			return err
151		}
152		a.resetHistoryWindowState()
153		a.historyNotice = historyRangeNotice(rangeID)
154	case historyActionDeleteDomain:
155		domain := strings.TrimSpace(data["domain"])
156		if domain == "" {
157			return fmt.Errorf("domain is required")
158		}
159		if err := a.deps.History.DeleteDomain(ctx, domain); err != nil {
160			return err
161		}
162		if strings.EqualFold(a.historyDomainFilter, domain) {
163			a.historyDomainFilter = ""
164		}
165		a.resetHistoryWindowState()
166		a.historyNotice = "Deleted history for " + domain
167	default:
168		return fmt.Errorf("unknown history action: %q", event.Action)
169	}
170	return nil
171}
172
173type historyLoadMoreRequest struct {
174	before   time.Time
175	beforeID int64
176	domain   string
177	history  port.SystemviewHistoryService
178	dom      DOM
179}
180
181func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error {
182	a.lockState()
183	req, ok, err := a.historyLoadMoreRequestLocked(event)
184	a.unlockState()
185	if err != nil || !ok {
186		return err
187	}
188
189	window, err := req.history.TimelineWindow(ctx, req.before, req.beforeID, req.domain)
190	if err != nil {
191		return err
192	}
193	if window == nil {
194		a.lockState()
195		a.historyHasMore = false
196		a.unlockState()
197		return nil
198	}
199
200	a.lockState()
201	fragment, fallbackHTML := a.appendHistoryWindowLocked(window, req.dom)
202	a.unlockState()
203
204	if appender, ok := req.dom.(DOMHistoryTimelineAppender); ok {
205		return appender.AppendHistoryTimeline(fragment)
206	}
207	return a.mountHTML(fallbackHTML)
208}
209
210func (a *App) historyLoadMoreRequestLocked(event DOMAction) (historyLoadMoreRequest, bool, error) {
211	if a.deps.History == nil {
212		return historyLoadMoreRequest{}, false, fmt.Errorf("history service not configured")
213	}
214	if strings.TrimSpace(a.historyQuery) != "" || !a.historyHasMore {
215		return historyLoadMoreRequest{}, false, nil
216	}
217
218	before := a.historyWindowAfter
219	beforeID := a.historyCursorID
220	if requested := strings.TrimSpace(event.Data["before"]); requested != "" {
221		parsed, err := time.Parse(time.RFC3339Nano, requested)
222		if err != nil {
223			return historyLoadMoreRequest{}, false, fmt.Errorf("invalid history cursor")
224		}
225		if !before.IsZero() && !parsed.Equal(before) {
226			return historyLoadMoreRequest{}, false, nil
227		}
228		before = parsed
229	}
230	if before.IsZero() || beforeID <= 0 {
231		a.historyWindowAfter = time.Time{}
232		a.historyCursorID = 0
233		a.historyHasMore = false
234		return historyLoadMoreRequest{}, false, nil
235	}
236
237	return historyLoadMoreRequest{
238		before:   before,
239		beforeID: beforeID,
240		domain:   a.historyDomainFilter,
241		history:  a.deps.History,
242		dom:      a.deps.DOM,
243	}, true, nil
244}
245
246func (a *App) appendHistoryWindowLocked(window *entity.HistoryWindow, dom DOM) (string, string) {
247	appendSkipFirstDate := lastHistoryDateKey(a.historyEntries)
248	a.historyEntries = append(a.historyEntries, window.Entries...)
249	a.historyWindowBefore = window.Before
250	a.historyWindowAfter, a.historyCursorID, a.historyHasMore = historyWindowCursor(window)
251	data := historyRenderData{
252		Entries:             window.Entries,
253		Stats:               a.historyStats,
254		Domains:             a.historyDomainStats,
255		Query:               a.historyQuery,
256		DomainFilter:        a.historyDomainFilter,
257		Offset:              a.historyOffset,
258		Limit:               historyTimelineLimit,
259		WindowBefore:        a.historyWindowBefore,
260		WindowAfter:         a.historyWindowAfter,
261		HasMore:             a.historyHasMore,
262		AppendSkipFirstDate: appendSkipFirstDate,
263		Notice:              a.historyNotice,
264		Error:               a.historyError,
265	}
266	fragment := historyTimelineAppendHTML(data)
267	if _, ok := dom.(DOMHistoryTimelineAppender); ok {
268		return fragment, ""
269	}
270
271	fullData := data
272	fullData.Entries = a.historyEntries
273	title := historyDocumentTitle(fullData)
274	a.renderedHTML = renderAppFrame(renderedPage{
275		route:          RouteHistory,
276		title:          title,
277		subtitle:       "History",
278		subtitleDetail: historyTitleDetail(fullData),
279		body:           historyHTML(fullData),
280	}, a.shellTheme)
281	return fragment, a.renderedHTML
282}
283
284func (a *App) resetHistoryWindowState() {
285	a.historyOffset = 0
286	a.historyWindowBefore = time.Time{}
287	a.historyWindowAfter = time.Time{}
288	a.historyCursorID = 0
289	a.historyHasMore = false
290}
291
292func (a *App) mountRenderedHTML() error {
293	return a.mountHTML(a.renderedHTML)
294}
295
296func (a *App) mountHTML(html string) error {
297	if a == nil || a.deps.DOM == nil {
298		return nil
299	}
300	return a.deps.DOM.Mount(html)
301}
302
303func (a *App) mountHTMLIfCurrent(ctx context.Context, html string, generation uint64) error {
304	if a == nil || a.deps.DOM == nil {
305		return nil
306	}
307	if ctx != nil && ctx.Err() != nil {
308		return nil
309	}
310	a.lockState()
311	current := !a.closed && a.renderGeneration == generation
312	a.unlockState()
313	if !current {
314		return nil
315	}
316	return a.deps.DOM.Mount(html)
317}
318
319func isKnownHistoryCleanupRange(rangeID string) bool {
320	for _, item := range historyCleanupItems() {
321		if item.RangeID == rangeID {
322			return true
323		}
324	}
325	return false
326}
327
328func historyRangeNotice(rangeID string) string {
329	for _, item := range historyCleanupItems() {
330		if item.RangeID == rangeID {
331			return item.Notice
332		}
333	}
334	return "Deleted history"
335}

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