v0.29.0 21b3150

history_actions.go RawHistoryFolder

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

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