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}