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}