1package systemviews
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "sync"
9 "time"
10
11 "github.com/bnema/dumber/internal/application/dto"
12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/domain/entity"
14)
15
16type Dependencies struct {
17 DOM DOM
18 Favorites port.SystemviewFavoritesService
19 History port.SystemviewHistoryService
20 Config port.SystemviewConfigService
21 LocationURI string
22}
23
24type App struct {
25 // mu protects rendered route state; actionMu protects worker channels.
26 // The two locks must not be held at the same time.
27 mu sync.Mutex
28 actionMu sync.Mutex
29 deps Dependencies
30 currentRoute Route
31 shellTheme shellTheme
32 historyEntries []*entity.HistoryEntry
33 historyStats *entity.HistoryStats
34 historyAnalytics *entity.HistoryAnalytics
35 historyDomainStats []*entity.DomainStat
36 historyQuery string
37 historyDomainFilter string
38 historyOffset int
39 historyWindowBefore time.Time
40 historyWindowAfter time.Time
41 historyHasMore bool
42 historyNotice string
43 historyError string
44 favorites []*entity.Favorite
45 folders []*entity.Folder
46 tags []*entity.Tag
47 favoriteFolderFilter *entity.FolderID
48 favoriteTagFilter *entity.TagID
49 favoritesNotice string
50 favoritesError string
51 config *dto.SystemviewConfigPayload
52 keybindings port.KeybindingsConfig
53 configNotice string
54 configError string
55 renderedHTML string
56 renderGeneration uint64
57 closed bool
58 actionQueue chan DOMAction
59 actionErrorQueue chan error
60 actionCtx context.Context
61 actionCancel context.CancelFunc
62 actionClosed bool
63 actionWG sync.WaitGroup
64}
65
66const (
67 historyTimelineLimit = 0
68 historySearchLimit = 100
69)
70
71func (a *App) lockAction() {
72 // Lock order: actionMu is never acquired while App.mu is held.
73 a.actionMu.Lock()
74}
75
76func (a *App) unlockAction() {
77 a.actionMu.Unlock()
78}
79
80func (a *App) lockState() {
81 // Lock order: App.mu is never held while acquiring actionMu.
82 a.mu.Lock()
83}
84
85func (a *App) unlockState() {
86 a.mu.Unlock()
87}
88
89func NewApp(deps Dependencies) *App {
90 return &App{deps: deps, currentRoute: RouteUnknown}
91}
92
93func (a *App) Run() error {
94 return a.RunWithContext(context.Background())
95}
96
97func (a *App) RunWithContext(ctx context.Context) error {
98 if a == nil {
99 return errors.New("app is nil")
100 }
101 if ctx == nil {
102 return errors.New("context is nil")
103 }
104
105 a.currentRoute = ParseRoute(a.deps.LocationURI)
106 if a.deps.DOM == nil {
107 return errors.New("DOM not configured")
108 }
109 asyncInitial := a.shouldLoadInitialRouteAsync()
110 if asyncInitial {
111 a.LoadInitialShell(ctx)
112 } else if err := a.LoadInitial(ctx); err != nil {
113 return err
114 }
115
116 if err := a.deps.DOM.Mount(a.renderedHTML); err != nil {
117 return err
118 }
119 if binder, ok := a.deps.DOM.(DOMActionBinder); ok {
120 if err := a.bindDOMActions(ctx, binder); err != nil {
121 return err
122 }
123 }
124 if asyncInitial {
125 a.refreshCurrentRouteAsync(ctx)
126 }
127 return nil
128}
129
130func (a *App) shouldLoadInitialRouteAsync() bool {
131 return a != nil && a.currentRoute == RouteHistory && a.deps.History != nil
132}
133
134func (a *App) refreshCurrentRouteAsync(ctx context.Context) {
135 if a == nil {
136 return
137 }
138 a.lockState()
139 snapshot := a.currentHistoryRouteSnapshotLocked()
140 generation := a.renderGeneration
141 closed := a.closed
142 a.unlockState()
143 if closed || snapshot.route != RouteHistory {
144 return
145 }
146
147 a.lockAction()
148 asyncCtx := a.actionCtx
149 if asyncCtx == nil {
150 asyncCtx = ctx
151 }
152 ready := !a.actionClosed && asyncCtx != nil && asyncCtx.Err() == nil
153 if ready {
154 a.actionWG.Add(1)
155 }
156 a.unlockAction()
157 if !ready {
158 return
159 }
160
161 go func() {
162 defer a.actionWG.Done()
163 if asyncCtx.Err() != nil {
164 return
165 }
166 result, err := a.renderHistoryRouteSnapshot(asyncCtx, snapshot)
167
168 a.lockState()
169 if a.closed || asyncCtx.Err() != nil || a.currentRoute != snapshot.route || a.renderGeneration != generation {
170 a.unlockState()
171 return
172 }
173 if err != nil {
174 a.renderRouteError(err)
175 } else {
176 a.commitHistoryRouteResultLocked(result)
177 }
178 a.renderGeneration++
179 mountGeneration := a.renderGeneration
180 html := a.renderedHTML
181 a.unlockState()
182
183 if err := a.mountHTMLIfCurrent(asyncCtx, html, mountGeneration); err != nil {
184 logActionMountError(asyncCtx, err, nil)
185 }
186 }()
187}
188
189func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {
190 var (
191 created bool
192 workerCtx context.Context
193 cancel context.CancelFunc
194 queue chan DOMAction
195 errorQueue chan error
196 )
197
198 a.lockAction()
199 if a.actionClosed {
200 a.unlockAction()
201 return errors.New("systemview app is closed")
202 }
203 if a.actionQueue == nil {
204 workerCtx, cancel = context.WithCancel(ctx)
205 queue = make(chan DOMAction, 64)
206 errorQueue = make(chan error, 1)
207 a.actionQueue = queue
208 a.actionErrorQueue = errorQueue
209 a.actionCtx = workerCtx
210 a.actionCancel = cancel
211 created = true
212 }
213 a.unlockAction()
214
215 if err := binder.BindActions(func(action DOMAction) {
216 if !a.enqueueDOMAction(action) && a.actionWorkerActive() {
217 a.enqueueActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action))
218 }
219 }); err != nil {
220 if created {
221 a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue)
222 }
223 return err
224 }
225
226 if created && !a.startActionWorkers(workerCtx, queue, errorQueue) {
227 a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue)
228 a.releaseDOMBindings()
229 return errors.New("systemview action worker unavailable")
230 }
231 return nil
232}
233
234func (a *App) startActionWorkers(ctx context.Context, queue chan DOMAction, errorQueue chan error) bool {
235 if a == nil || ctx == nil || queue == nil || errorQueue == nil {
236 return false
237 }
238 a.lockAction()
239 ready := !a.actionClosed && a.actionCtx == ctx && a.actionQueue == queue && a.actionErrorQueue == errorQueue && ctx.Err() == nil
240 if ready {
241 a.actionWG.Add(2)
242 }
243 a.unlockAction()
244 if !ready {
245 return false
246 }
247 go func() {
248 defer a.actionWG.Done()
249 a.runActionWorker(ctx, queue)
250 }()
251 go func() {
252 defer a.actionWG.Done()
253 a.runActionErrorWorker(ctx, errorQueue)
254 }()
255 return true
256}
257
258func (a *App) discardPendingActionWorker(ctx context.Context, cancel context.CancelFunc, queue chan DOMAction, errorQueue chan error) {
259 if a == nil {
260 return
261 }
262 a.lockAction()
263 if a.actionCtx == ctx && a.actionQueue == queue {
264 a.actionCancel = nil
265 a.actionCtx = nil
266 a.actionQueue = nil
267 a.actionErrorQueue = nil
268 if cancel != nil {
269 cancel()
270 }
271 close(queue)
272 close(errorQueue)
273 }
274 a.unlockAction()
275}
276
277func (a *App) actionWorkerActive() bool {
278 if a == nil {
279 return false
280 }
281 a.lockAction()
282 closed := a.actionClosed
283 queue := a.actionQueue
284 ctx := a.actionCtx
285 a.unlockAction()
286 return !closed && queue != nil && ctx != nil && ctx.Err() == nil
287}
288
289func (a *App) enqueueDOMAction(action DOMAction) bool {
290 if a == nil {
291 return false
292 }
293 a.lockAction()
294 defer a.unlockAction()
295 queue := a.actionQueue
296 ctx := a.actionCtx
297 if a.actionClosed || queue == nil || ctx == nil {
298 return false
299 }
300 select {
301 case <-ctx.Done():
302 return false
303 case queue <- action:
304 return true
305 default:
306 return false
307 }
308}
309
310func (a *App) enqueueActionError(err error) {
311 if a == nil || err == nil {
312 return
313 }
314 a.lockAction()
315 defer a.unlockAction()
316 queue := a.actionErrorQueue
317 ctx := a.actionCtx
318 if a.actionClosed || queue == nil || ctx == nil {
319 return
320 }
321 select {
322 case <-ctx.Done():
323 return
324 case queue <- err:
325 default:
326 }
327}
328
329func (a *App) runActionWorker(ctx context.Context, queue <-chan DOMAction) {
330 for {
331 select {
332 case <-ctx.Done():
333 return
334 case action, ok := <-queue:
335 if !ok || ctx.Err() != nil {
336 return
337 }
338 if err := a.HandleDOMAction(ctx, action); err != nil {
339 a.enqueueActionError(err)
340 }
341 }
342 }
343}
344
345func (a *App) runActionErrorWorker(ctx context.Context, queue <-chan error) {
346 for {
347 select {
348 case <-ctx.Done():
349 return
350 case err, ok := <-queue:
351 if !ok || ctx.Err() != nil {
352 return
353 }
354 a.surfaceActionError(ctx, err)
355 }
356 }
357}
358
359func (a *App) surfaceActionError(ctx context.Context, err error) {
360 if a == nil || err == nil {
361 return
362 }
363 a.lockState()
364 a.renderRouteError(err)
365 a.renderGeneration++
366 generation := a.renderGeneration
367 html := a.renderedHTML
368 a.unlockState()
369 if mountErr := a.mountHTMLIfCurrent(ctx, html, generation); mountErr != nil {
370 logActionMountError(ctx, mountErr, err)
371 }
372}
373
374func (a *App) Close() {
375 if a == nil {
376 return
377 }
378 a.lockState()
379 a.closed = true
380 a.renderGeneration++
381 a.unlockState()
382
383 a.lockAction()
384 cancel := a.actionCancel
385 actionQueue := a.actionQueue
386 actionErrorQueue := a.actionErrorQueue
387 a.actionClosed = true
388 a.actionCancel = nil
389 a.actionCtx = nil
390 a.actionQueue = nil
391 a.actionErrorQueue = nil
392 a.unlockAction()
393
394 if cancel != nil {
395 cancel()
396 }
397 a.actionWG.Wait()
398 if actionQueue != nil {
399 close(actionQueue)
400 }
401 if actionErrorQueue != nil {
402 close(actionErrorQueue)
403 }
404 a.releaseDOMBindings()
405}
406
407func (a *App) releaseDOMBindings() {
408 if a == nil {
409 return
410 }
411 if releaser, ok := a.deps.DOM.(interface{ Release() }); ok {
412 releaser.Release()
413 }
414}
415
416func (a *App) LoadInitialShell(ctx context.Context) {
417 if a == nil {
418 return
419 }
420 a.loadShellTheme(ctx)
421 if a.currentRoute == "" || a.currentRoute == RouteUnknown {
422 a.currentRoute = ParseRoute(a.deps.LocationURI)
423 }
424 switch a.currentRoute {
425 case RouteHistory:
426 a.loadHistoryLoadingRoute()
427 default:
428 a.resetRouteState()
429 a.renderedHTML = renderAppFrame(renderedPage{
430 route: a.currentRoute,
431 title: routeDocumentTitle(a.currentRoute),
432 subtitle: routeSubtitle(a.currentRoute),
433 body: placeholderHTML(a.currentRoute),
434 }, a.shellTheme)
435 }
436}
437
438func (a *App) LoadInitial(ctx context.Context) error {
439 if a == nil {
440 return errors.New("app is nil")
441 }
442 a.loadShellTheme(ctx)
443 if a.currentRoute == "" || a.currentRoute == RouteUnknown {
444 a.currentRoute = ParseRoute(a.deps.LocationURI)
445 }
446
447 if err := a.loadCurrentRoute(ctx); err != nil {
448 a.renderRouteError(err)
449 }
450 return nil
451}
452
453func (a *App) loadCurrentRoute(ctx context.Context) error {
454 switch a.currentRoute {
455 case RouteHistory:
456 return a.loadHistoryRoute(ctx)
457 case RouteFavorites:
458 return a.loadFavoritesRoute(ctx)
459 case RouteConfig:
460 return a.loadConfigRoute(ctx)
461 default:
462 a.resetRouteState()
463 a.renderedHTML = renderAppFrame(renderedPage{
464 route: a.currentRoute,
465 title: routeDocumentTitle(a.currentRoute),
466 subtitle: string(a.currentRoute),
467 body: placeholderHTML(a.currentRoute),
468 }, a.shellTheme)
469 return nil
470 }
471}
472
473func (a *App) renderRouteError(err error) {
474 a.resetRouteState()
475 message := "unknown error"
476 if err != nil {
477 message = err.Error()
478 }
479 a.renderedHTML = renderAppFrame(renderedPage{
480 route: a.currentRoute,
481 title: routeDocumentTitle(a.currentRoute) + " — Error",
482 subtitle: routeSubtitle(a.currentRoute),
483 body: errorStateHTML(message),
484 }, a.shellTheme)
485}
486
487func routeDocumentTitle(route Route) string {
488 switch route {
489 case RouteHistory:
490 return "History"
491 case RouteFavorites:
492 return "Favorites"
493 case RouteConfig:
494 return "Config"
495 default:
496 return "Dumber System View"
497 }
498}
499
500func routeSubtitle(route Route) string {
501 switch route {
502 case RouteHistory:
503 return "Recent visits"
504 case RouteFavorites:
505 return "Saved bookmarks"
506 case RouteConfig:
507 return "Browser settings"
508 default:
509 return string(route)
510 }
511}
512
513func (a *App) loadHistoryLoadingRoute() {
514 a.favorites = nil
515 a.folders = nil
516 a.tags = nil
517 a.historyEntries = nil
518 a.historyStats = nil
519 a.historyAnalytics = nil
520 a.historyDomainStats = nil
521 a.historyNotice = ""
522 a.historyError = ""
523 data := historyRenderData{
524 Query: a.historyQuery,
525 DomainFilter: a.historyDomainFilter,
526 Offset: a.historyOffset,
527 Limit: historyTimelineLimit,
528 WindowBefore: a.historyWindowBefore,
529 WindowAfter: a.historyWindowAfter,
530 HasMore: a.historyHasMore,
531 Notice: a.historyNotice,
532 Error: a.historyError,
533 Loading: true,
534 }
535 title := historyDocumentTitle(data)
536 a.renderedHTML = renderAppFrame(renderedPage{
537 route: RouteHistory,
538 title: title,
539 subtitle: "History",
540 subtitleDetail: historyTitleDetail(data),
541 body: historyHTML(data),
542 }, a.shellTheme)
543}
544
545func (a *App) loadHistoryRoute(ctx context.Context) error {
546 if a.deps.History == nil {
547 a.resetRouteState()
548 a.renderedHTML = renderAppFrame(renderedPage{
549 route: a.currentRoute,
550 title: routeDocumentTitle(RouteHistory),
551 subtitle: routeDocumentTitle(RouteHistory),
552 body: placeholderHTML(a.currentRoute),
553 }, a.shellTheme)
554 return nil
555 }
556
557 result, err := a.renderHistoryRouteSnapshot(ctx, a.currentHistoryRouteSnapshotLocked())
558 if err != nil {
559 return err
560 }
561 a.commitHistoryRouteResultLocked(result)
562 return nil
563}
564
565type historyRouteSnapshot struct {
566 route Route
567 history port.SystemviewHistoryService
568 shellTheme shellTheme
569 query string
570 domainFilter string
571 offset int
572 windowBefore time.Time
573 windowAfter time.Time
574 notice string
575 error string
576}
577
578type historyRouteResult struct {
579 entries []*entity.HistoryEntry
580 stats *entity.HistoryStats
581 domains []*entity.DomainStat
582 windowBefore time.Time
583 windowAfter time.Time
584 hasMore bool
585 html string
586}
587
588func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot {
589 return historyRouteSnapshot{
590 route: a.currentRoute,
591 history: a.deps.History,
592 shellTheme: a.shellTheme,
593 query: a.historyQuery,
594 domainFilter: a.historyDomainFilter,
595 offset: a.historyOffset,
596 windowBefore: a.historyWindowBefore,
597 windowAfter: a.historyWindowAfter,
598 notice: a.historyNotice,
599 error: a.historyError,
600 }
601}
602
603func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (historyRouteResult, error) {
604 if snapshot.history == nil {
605 return historyRouteResult{
606 html: renderAppFrame(renderedPage{
607 route: snapshot.route,
608 title: routeDocumentTitle(RouteHistory),
609 subtitle: routeDocumentTitle(RouteHistory),
610 body: placeholderHTML(snapshot.route),
611 }, snapshot.shellTheme),
612 }, nil
613 }
614
615 window, entries, err := loadHistoryWindowSnapshot(ctx, snapshot)
616 if err != nil {
617 return historyRouteResult{}, err
618 }
619 stats, err := snapshot.history.Stats(ctx)
620 if err != nil {
621 return historyRouteResult{}, fmt.Errorf("failed to load history stats: %w", err)
622 }
623 domains, domainsErr := snapshot.history.DomainStats(ctx, 10)
624 if domainsErr != nil {
625 domains = nil
626 }
627 result := historyRouteResult{
628 entries: entries,
629 stats: stats,
630 domains: domains,
631 windowBefore: snapshot.windowBefore,
632 windowAfter: snapshot.windowAfter,
633 }
634 if window != nil {
635 result.windowBefore = window.Before
636 result.windowAfter = window.After
637 result.hasMore = window.HasMore
638 }
639 data := historyRenderData{
640 Entries: entries,
641 Stats: stats,
642 Analytics: nil,
643 Domains: domains,
644 Query: snapshot.query,
645 DomainFilter: snapshot.domainFilter,
646 Offset: snapshot.offset,
647 Limit: historyTimelineLimit,
648 WindowBefore: result.windowBefore,
649 WindowAfter: result.windowAfter,
650 HasMore: result.hasMore,
651 Notice: snapshot.notice,
652 Error: snapshot.error,
653 }
654 title := historyDocumentTitle(data)
655 result.html = renderAppFrame(renderedPage{
656 route: RouteHistory,
657 title: title,
658 subtitle: "History",
659 subtitleDetail: historyTitleDetail(data),
660 body: historyHTML(data),
661 }, snapshot.shellTheme)
662 return result, nil
663}
664
665func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
666 query := strings.TrimSpace(snapshot.query)
667 domain := strings.TrimSpace(snapshot.domainFilter)
668 if query != "" {
669 entries, err := snapshot.history.Search(ctx, query, historySearchLimit)
670 return nil, entries, err
671 }
672 window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowBefore, domain)
673 if err != nil {
674 return nil, nil, err
675 }
676 if window == nil {
677 return nil, nil, nil
678 }
679 return window, window.Entries, nil
680}
681
682func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {
683 a.favorites = nil
684 a.folders = nil
685 a.tags = nil
686 a.historyEntries = result.entries
687 a.historyStats = result.stats
688 a.historyAnalytics = nil
689 a.historyDomainStats = result.domains
690 a.historyWindowBefore = result.windowBefore
691 a.historyWindowAfter = result.windowAfter
692 a.historyHasMore = result.hasMore
693 a.renderedHTML = result.html
694}
695
696func (a *App) loadFavoritesRoute(ctx context.Context) error {
697 if a.deps.Favorites == nil {
698 a.resetRouteState()
699 a.renderedHTML = renderAppFrame(renderedPage{
700 route: a.currentRoute,
701 title: routeDocumentTitle(RouteFavorites),
702 subtitle: "Saved bookmarks",
703 body: placeholderHTML(a.currentRoute),
704 }, a.shellTheme)
705 return nil
706 }
707
708 favorites, err := a.deps.Favorites.List(ctx)
709 if err != nil {
710 return err
711 }
712 folders, err := a.deps.Favorites.ListFolders(ctx)
713 if err != nil {
714 return err
715 }
716 tags, err := a.deps.Favorites.ListTags(ctx)
717 if err != nil {
718 return err
719 }
720
721 a.historyEntries = nil
722 favorites = filterFavorites(favorites, a.favoriteFolderFilter, a.favoriteTagFilter)
723 a.favorites = favorites
724 a.folders = folders
725 a.tags = tags
726 data := favoritesRenderData{
727 Favorites: favorites,
728 Folders: folders,
729 Tags: tags,
730 FolderFilter: a.favoriteFolderFilter,
731 TagFilter: a.favoriteTagFilter,
732 Notice: a.favoritesNotice,
733 Error: a.favoritesError,
734 }
735 a.renderedHTML = renderAppFrame(renderedPage{
736 route: RouteFavorites,
737 title: favoritesDocumentTitle(data),
738 subtitle: "Saved bookmarks",
739 body: favoritesHTML(data),
740 }, a.shellTheme)
741 return nil
742}
743
744func (a *App) loadConfigRoute(ctx context.Context) error {
745 if a.deps.Config == nil {
746 a.resetRouteState()
747 a.renderedHTML = renderAppFrame(renderedPage{
748 route: a.currentRoute,
749 title: routeDocumentTitle(RouteConfig),
750 subtitle: "Browser settings",
751 body: placeholderHTML(a.currentRoute),
752 }, a.shellTheme)
753 return nil
754 }
755
756 config, err := a.deps.Config.Current(ctx)
757 if err != nil {
758 return err
759 }
760 keybindings, err := a.deps.Config.GetKeybindings(ctx)
761 if err != nil {
762 return err
763 }
764
765 a.historyEntries = nil
766 a.favorites = nil
767 a.folders = nil
768 a.tags = nil
769 a.config = &config
770 a.keybindings = keybindings
771 a.renderedHTML = renderAppFrame(renderedPage{
772 route: RouteConfig,
773 title: "Config — Dumber",
774 subtitle: "Browser settings",
775 body: configHTML(configRenderData{
776 Config: config,
777 Keybindings: keybindings,
778 Notice: a.configNotice,
779 Error: a.configError,
780 }),
781 }, a.shellTheme)
782 return nil
783}
784
785func (a *App) loadShellTheme(ctx context.Context) {
786 if a == nil {
787 return
788 }
789
790 a.shellTheme = shellTheme{}
791 if a.deps.Config == nil {
792 return
793 }
794
795 config, err := a.deps.Config.Current(ctx)
796 if err == nil {
797 a.shellTheme = resolveShellTheme(config.Appearance)
798 }
799}
800
801func (a *App) resetRouteState() {
802 a.historyEntries = nil
803 a.historyStats = nil
804 a.historyAnalytics = nil
805 a.historyDomainStats = nil
806 a.historyQuery = ""
807 a.historyDomainFilter = ""
808 a.historyOffset = 0
809 a.historyWindowBefore = time.Time{}
810 a.historyWindowAfter = time.Time{}
811 a.historyHasMore = false
812 a.historyNotice = ""
813 a.historyError = ""
814 a.favorites = nil
815 a.folders = nil
816 a.tags = nil
817 a.favoriteFolderFilter = nil
818 a.favoriteTagFilter = nil
819 a.favoritesNotice = ""
820 a.favoritesError = ""
821 a.config = nil
822 a.keybindings = port.KeybindingsConfig{}
823 a.configNotice = ""
824 a.configError = ""
825}
826
827func (a *App) CurrentRoute() Route {
828 if a == nil {
829 return RouteUnknown
830 }
831 return a.currentRoute
832}