main 5041353

app.go RawHistoryFolder

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	historyCursorID        int64
42	historyHasMore         bool
43	historyNotice          string
44	historyError           string
45	favorites              []*entity.Favorite
46	tags                   []*entity.Tag
47	favoriteTagFilter      *entity.TagID
48	favoriteUntaggedFilter bool
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		a.signalShellError(err)
118		return err
119	}
120	if binder, ok := a.deps.DOM.(DOMActionBinder); ok {
121		if err := a.bindDOMActions(ctx, binder); err != nil {
122			a.signalShellError(err)
123			return err
124		}
125	}
126	// Readiness fires after the initial mount and action binding only:
127	// async history-data completion refreshes content separately and
128	// never gates it.
129	if err := a.signalShellReady(); err != nil {
130		return err
131	}
132	if asyncInitial {
133		a.refreshCurrentRouteAsync(ctx)
134	}
135	return nil
136}
137
138// signalShellReady clears the shell loading state when the adapter
139// supports it. Adapters without a signaler keep legacy behavior.
140func (a *App) signalShellReady() error {
141	signaler, ok := a.deps.DOM.(DOMReadinessSignaler)
142	if !ok {
143		return nil
144	}
145	return signaler.SignalReady()
146}
147
148// signalShellError reports a fatal mount/bind failure to the shell when
149// the adapter supports it. The original error is always returned;
150// signaling is best-effort.
151func (a *App) signalShellError(err error) {
152	if err == nil {
153		return
154	}
155	if signaler, ok := a.deps.DOM.(DOMReadinessSignaler); ok {
156		_ = signaler.SignalError(err.Error())
157	}
158}
159
160func (a *App) shouldLoadInitialRouteAsync() bool {
161	return a != nil && a.currentRoute == RouteHistory && a.deps.History != nil
162}
163
164func (a *App) refreshCurrentRouteAsync(ctx context.Context) {
165	if a == nil {
166		return
167	}
168	a.lockState()
169	snapshot := a.currentHistoryRouteSnapshotLocked()
170	generation := a.renderGeneration
171	closed := a.closed
172	a.unlockState()
173	if closed || snapshot.route != RouteHistory {
174		return
175	}
176
177	a.lockAction()
178	asyncCtx := a.actionCtx
179	if asyncCtx == nil {
180		asyncCtx = ctx
181	}
182	ready := !a.actionClosed && asyncCtx != nil && asyncCtx.Err() == nil
183	if ready {
184		a.actionWG.Add(1)
185	}
186	a.unlockAction()
187	if !ready {
188		return
189	}
190
191	go func() {
192		defer a.actionWG.Done()
193		if asyncCtx.Err() != nil {
194			return
195		}
196		result, err := a.renderHistoryRouteSnapshot(asyncCtx, snapshot)
197
198		a.lockState()
199		if a.closed || asyncCtx.Err() != nil || a.currentRoute != snapshot.route || a.renderGeneration != generation {
200			a.unlockState()
201			return
202		}
203		if err != nil {
204			a.renderRouteError(err)
205		} else {
206			a.commitHistoryRouteResultLocked(result)
207		}
208		a.renderGeneration++
209		mountGeneration := a.renderGeneration
210		html := a.renderedHTML
211		a.unlockState()
212
213		if err := a.mountHTMLIfCurrent(asyncCtx, html, mountGeneration); err != nil {
214			logActionMountError(asyncCtx, err, nil)
215		}
216	}()
217}
218
219func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {
220	var (
221		created    bool
222		workerCtx  context.Context
223		cancel     context.CancelFunc
224		queue      chan DOMAction
225		errorQueue chan error
226	)
227
228	a.lockAction()
229	if a.actionClosed {
230		a.unlockAction()
231		return errors.New("systemview app is closed")
232	}
233	if a.actionQueue == nil {
234		workerCtx, cancel = context.WithCancel(ctx)
235		queue = make(chan DOMAction, 64)
236		errorQueue = make(chan error, 1)
237		a.actionQueue = queue
238		a.actionErrorQueue = errorQueue
239		a.actionCtx = workerCtx
240		a.actionCancel = cancel
241		created = true
242	}
243	a.unlockAction()
244
245	if err := binder.BindActions(func(action DOMAction) {
246		if !a.enqueueDOMAction(action) && a.actionWorkerActive() {
247			a.enqueueActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action))
248		}
249	}); err != nil {
250		if created {
251			a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue)
252		}
253		return err
254	}
255
256	if created && !a.startActionWorkers(workerCtx, queue, errorQueue) {
257		a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue)
258		a.releaseDOMBindings()
259		return errors.New("systemview action worker unavailable")
260	}
261	return nil
262}
263
264func (a *App) startActionWorkers(ctx context.Context, queue chan DOMAction, errorQueue chan error) bool {
265	if a == nil || ctx == nil || queue == nil || errorQueue == nil {
266		return false
267	}
268	a.lockAction()
269	ready := !a.actionClosed && a.actionCtx == ctx && a.actionQueue == queue && a.actionErrorQueue == errorQueue && ctx.Err() == nil
270	if ready {
271		a.actionWG.Add(2)
272	}
273	a.unlockAction()
274	if !ready {
275		return false
276	}
277	go func() {
278		defer a.actionWG.Done()
279		a.runActionWorker(ctx, queue)
280	}()
281	go func() {
282		defer a.actionWG.Done()
283		a.runActionErrorWorker(ctx, errorQueue)
284	}()
285	return true
286}
287
288func (a *App) discardPendingActionWorker(ctx context.Context, cancel context.CancelFunc, queue chan DOMAction, errorQueue chan error) {
289	if a == nil {
290		return
291	}
292	a.lockAction()
293	if a.actionCtx == ctx && a.actionQueue == queue {
294		a.actionCancel = nil
295		a.actionCtx = nil
296		a.actionQueue = nil
297		a.actionErrorQueue = nil
298		if cancel != nil {
299			cancel()
300		}
301		close(queue)
302		close(errorQueue)
303	}
304	a.unlockAction()
305}
306
307func (a *App) actionWorkerActive() bool {
308	if a == nil {
309		return false
310	}
311	a.lockAction()
312	closed := a.actionClosed
313	queue := a.actionQueue
314	ctx := a.actionCtx
315	a.unlockAction()
316	return !closed && queue != nil && ctx != nil && ctx.Err() == nil
317}
318
319func (a *App) enqueueDOMAction(action DOMAction) bool {
320	if a == nil {
321		return false
322	}
323	a.lockAction()
324	defer a.unlockAction()
325	queue := a.actionQueue
326	ctx := a.actionCtx
327	if a.actionClosed || queue == nil || ctx == nil {
328		return false
329	}
330	select {
331	case <-ctx.Done():
332		return false
333	case queue <- action:
334		return true
335	default:
336		return false
337	}
338}
339
340func (a *App) enqueueActionError(err error) {
341	if a == nil || err == nil {
342		return
343	}
344	a.lockAction()
345	defer a.unlockAction()
346	queue := a.actionErrorQueue
347	ctx := a.actionCtx
348	if a.actionClosed || queue == nil || ctx == nil {
349		return
350	}
351	select {
352	case <-ctx.Done():
353		return
354	case queue <- err:
355	default:
356	}
357}
358
359func (a *App) runActionWorker(ctx context.Context, queue <-chan DOMAction) {
360	for {
361		select {
362		case <-ctx.Done():
363			return
364		case action, ok := <-queue:
365			if !ok || ctx.Err() != nil {
366				return
367			}
368			if err := a.HandleDOMAction(ctx, action); err != nil {
369				a.enqueueActionError(err)
370			}
371		}
372	}
373}
374
375func (a *App) runActionErrorWorker(ctx context.Context, queue <-chan error) {
376	for {
377		select {
378		case <-ctx.Done():
379			return
380		case err, ok := <-queue:
381			if !ok || ctx.Err() != nil {
382				return
383			}
384			a.surfaceActionError(ctx, err)
385		}
386	}
387}
388
389func (a *App) surfaceActionError(ctx context.Context, err error) {
390	if a == nil || err == nil {
391		return
392	}
393	a.lockState()
394	a.renderRouteError(err)
395	a.renderGeneration++
396	generation := a.renderGeneration
397	html := a.renderedHTML
398	a.unlockState()
399	if mountErr := a.mountHTMLIfCurrent(ctx, html, generation); mountErr != nil {
400		logActionMountError(ctx, mountErr, err)
401	}
402}
403
404func (a *App) Close() {
405	if a == nil {
406		return
407	}
408	a.lockState()
409	a.closed = true
410	a.renderGeneration++
411	a.unlockState()
412
413	a.lockAction()
414	cancel := a.actionCancel
415	actionQueue := a.actionQueue
416	actionErrorQueue := a.actionErrorQueue
417	a.actionClosed = true
418	a.actionCancel = nil
419	a.actionCtx = nil
420	a.actionQueue = nil
421	a.actionErrorQueue = nil
422	a.unlockAction()
423
424	if cancel != nil {
425		cancel()
426	}
427	a.actionWG.Wait()
428	if actionQueue != nil {
429		close(actionQueue)
430	}
431	if actionErrorQueue != nil {
432		close(actionErrorQueue)
433	}
434	a.releaseDOMBindings()
435}
436
437func (a *App) releaseDOMBindings() {
438	if a == nil {
439		return
440	}
441	if releaser, ok := a.deps.DOM.(interface{ Release() }); ok {
442		releaser.Release()
443	}
444}
445
446func (a *App) LoadInitialShell(ctx context.Context) {
447	if a == nil {
448		return
449	}
450	a.loadShellTheme(ctx)
451	if a.currentRoute == "" || a.currentRoute == RouteUnknown {
452		a.currentRoute = ParseRoute(a.deps.LocationURI)
453	}
454	switch a.currentRoute {
455	case RouteHistory:
456		a.loadHistoryLoadingRoute()
457	default:
458		a.resetRouteState()
459		a.renderedHTML = renderAppFrame(renderedPage{
460			route:    a.currentRoute,
461			title:    routeDocumentTitle(a.currentRoute),
462			subtitle: routeSubtitle(a.currentRoute),
463			body:     placeholderHTML(a.currentRoute),
464		}, a.shellTheme)
465	}
466}
467
468func (a *App) LoadInitial(ctx context.Context) error {
469	if a == nil {
470		return errors.New("app is nil")
471	}
472	a.loadShellTheme(ctx)
473	if a.currentRoute == "" || a.currentRoute == RouteUnknown {
474		a.currentRoute = ParseRoute(a.deps.LocationURI)
475	}
476
477	if err := a.loadCurrentRoute(ctx); err != nil {
478		a.renderRouteError(err)
479	}
480	return nil
481}
482
483func (a *App) loadCurrentRoute(ctx context.Context) error {
484	switch a.currentRoute {
485	case RouteHistory:
486		return a.loadHistoryRoute(ctx)
487	case RouteFavorites:
488		return a.loadFavoritesRoute(ctx)
489	case RouteConfig:
490		return a.loadConfigRoute(ctx)
491	default:
492		a.resetRouteState()
493		a.renderedHTML = renderAppFrame(renderedPage{
494			route:    a.currentRoute,
495			title:    routeDocumentTitle(a.currentRoute),
496			subtitle: string(a.currentRoute),
497			body:     placeholderHTML(a.currentRoute),
498		}, a.shellTheme)
499		return nil
500	}
501}
502
503func (a *App) renderRouteError(err error) {
504	a.resetRouteState()
505	message := "unknown error"
506	if err != nil {
507		message = err.Error()
508	}
509	a.renderedHTML = renderAppFrame(renderedPage{
510		route:    a.currentRoute,
511		title:    routeDocumentTitle(a.currentRoute) + " — Error",
512		subtitle: routeSubtitle(a.currentRoute),
513		body:     errorStateHTML(message),
514	}, a.shellTheme)
515}
516
517func routeDocumentTitle(route Route) string {
518	switch route {
519	case RouteHistory:
520		return "History"
521	case RouteFavorites:
522		return "Favorites"
523	case RouteConfig:
524		return "Config"
525	default:
526		return "Dumber System View"
527	}
528}
529
530func routeSubtitle(route Route) string {
531	switch route {
532	case RouteHistory:
533		return "Recent visits"
534	case RouteFavorites:
535		return "Saved bookmarks"
536	case RouteConfig:
537		return "Browser settings"
538	default:
539		return string(route)
540	}
541}
542
543func (a *App) loadHistoryLoadingRoute() {
544	a.favorites = nil
545	a.tags = nil
546	a.historyEntries = nil
547	a.historyStats = nil
548	a.historyAnalytics = nil
549	a.historyDomainStats = nil
550	a.historyNotice = ""
551	a.historyError = ""
552	data := historyRenderData{
553		Query:        a.historyQuery,
554		DomainFilter: a.historyDomainFilter,
555		Offset:       a.historyOffset,
556		Limit:        historyTimelineLimit,
557		WindowBefore: a.historyWindowBefore,
558		WindowAfter:  a.historyWindowAfter,
559		HasMore:      a.historyHasMore,
560		Notice:       a.historyNotice,
561		Error:        a.historyError,
562		Loading:      true,
563	}
564	title := historyDocumentTitle(data)
565	a.renderedHTML = renderAppFrame(renderedPage{
566		route:          RouteHistory,
567		title:          title,
568		subtitle:       "History",
569		subtitleDetail: historyTitleDetail(data),
570		body:           historyHTML(data),
571	}, a.shellTheme)
572}
573
574func (a *App) loadHistoryRoute(ctx context.Context) error {
575	if a.deps.History == nil {
576		a.resetRouteState()
577		a.renderedHTML = renderAppFrame(renderedPage{
578			route:    a.currentRoute,
579			title:    routeDocumentTitle(RouteHistory),
580			subtitle: routeDocumentTitle(RouteHistory),
581			body:     placeholderHTML(a.currentRoute),
582		}, a.shellTheme)
583		return nil
584	}
585
586	result, err := a.renderHistoryRouteSnapshot(ctx, a.currentHistoryRouteSnapshotLocked())
587	if err != nil {
588		return err
589	}
590	a.commitHistoryRouteResultLocked(result)
591	return nil
592}
593
594type historyRouteSnapshot struct {
595	route        Route
596	history      port.SystemviewHistoryService
597	shellTheme   shellTheme
598	query        string
599	domainFilter string
600	offset       int
601	windowBefore time.Time
602	windowAfter  time.Time
603	cursorID     int64
604	notice       string
605	error        string
606}
607
608type historyRouteResult struct {
609	entries      []*entity.HistoryEntry
610	stats        *entity.HistoryStats
611	domains      []*entity.DomainStat
612	windowBefore time.Time
613	windowAfter  time.Time
614	cursorID     int64
615	hasMore      bool
616	html         string
617}
618
619func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot {
620	return historyRouteSnapshot{
621		route:        a.currentRoute,
622		history:      a.deps.History,
623		shellTheme:   a.shellTheme,
624		query:        a.historyQuery,
625		domainFilter: a.historyDomainFilter,
626		offset:       a.historyOffset,
627		windowBefore: a.historyWindowBefore,
628		windowAfter:  a.historyWindowAfter,
629		cursorID:     a.historyCursorID,
630		notice:       a.historyNotice,
631		error:        a.historyError,
632	}
633}
634
635func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (historyRouteResult, error) {
636	if snapshot.history == nil {
637		return historyRouteResult{
638			html: renderAppFrame(renderedPage{
639				route:    snapshot.route,
640				title:    routeDocumentTitle(RouteHistory),
641				subtitle: routeDocumentTitle(RouteHistory),
642				body:     placeholderHTML(snapshot.route),
643			}, snapshot.shellTheme),
644		}, nil
645	}
646
647	window, entries, err := loadHistoryWindowSnapshot(ctx, snapshot)
648	if err != nil {
649		return historyRouteResult{}, err
650	}
651	stats, err := snapshot.history.Stats(ctx)
652	if err != nil {
653		return historyRouteResult{}, fmt.Errorf("failed to load history stats: %w", err)
654	}
655	domains, domainsErr := snapshot.history.DomainStats(ctx, 10)
656	if domainsErr != nil {
657		domains = nil
658	}
659	result := historyRouteResult{
660		entries:      entries,
661		stats:        stats,
662		domains:      domains,
663		windowBefore: snapshot.windowBefore,
664		windowAfter:  snapshot.windowAfter,
665	}
666	if window != nil {
667		result.windowBefore = window.Before
668		result.windowAfter, result.cursorID, result.hasMore = historyWindowCursor(window)
669	}
670	data := historyRenderData{
671		Entries:      entries,
672		Stats:        stats,
673		Analytics:    nil,
674		Domains:      domains,
675		Query:        snapshot.query,
676		DomainFilter: snapshot.domainFilter,
677		Offset:       snapshot.offset,
678		Limit:        historyTimelineLimit,
679		WindowBefore: result.windowBefore,
680		WindowAfter:  result.windowAfter,
681		HasMore:      result.hasMore,
682		Notice:       snapshot.notice,
683		Error:        snapshot.error,
684	}
685	title := historyDocumentTitle(data)
686	result.html = renderAppFrame(renderedPage{
687		route:          RouteHistory,
688		title:          title,
689		subtitle:       "History",
690		subtitleDetail: historyTitleDetail(data),
691		body:           historyHTML(data),
692	}, snapshot.shellTheme)
693	return result, nil
694}
695
696func historyWindowCursor(window *entity.HistoryWindow) (time.Time, int64, bool) {
697	if window == nil || !window.HasMore {
698		return time.Time{}, 0, false
699	}
700	cursorTime := window.CursorLastVisited
701	if cursorTime.IsZero() {
702		cursorTime = window.After
703	}
704	if cursorTime.IsZero() || window.CursorID <= 0 {
705		return time.Time{}, 0, false
706	}
707	return cursorTime, window.CursorID, true
708}
709
710func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
711	query := strings.TrimSpace(snapshot.query)
712	domain := strings.TrimSpace(snapshot.domainFilter)
713	if query != "" {
714		entries, err := snapshot.history.Search(ctx, query, historySearchLimit)
715		return nil, entries, err
716	}
717	window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowAfter, snapshot.cursorID, domain)
718	if err != nil {
719		return nil, nil, err
720	}
721	if window == nil {
722		return nil, nil, nil
723	}
724	return window, window.Entries, nil
725}
726
727func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {
728	a.favorites = nil
729	a.tags = nil
730	a.historyEntries = result.entries
731	a.historyStats = result.stats
732	a.historyAnalytics = nil
733	a.historyDomainStats = result.domains
734	a.historyWindowBefore = result.windowBefore
735	a.historyWindowAfter = result.windowAfter
736	a.historyCursorID = result.cursorID
737	a.historyHasMore = result.hasMore
738	a.renderedHTML = result.html
739}
740
741func (a *App) loadFavoritesRoute(ctx context.Context) error {
742	if a.deps.Favorites == nil {
743		a.resetRouteState()
744		a.renderedHTML = renderAppFrame(renderedPage{
745			route:    a.currentRoute,
746			title:    routeDocumentTitle(RouteFavorites),
747			subtitle: "Saved bookmarks",
748			body:     placeholderHTML(a.currentRoute),
749		}, a.shellTheme)
750		return nil
751	}
752
753	favorites, err := a.deps.Favorites.List(ctx)
754	if err != nil {
755		return err
756	}
757	tags, err := a.deps.Favorites.ListTags(ctx)
758	if err != nil {
759		return err
760	}
761
762	a.historyEntries = nil
763	favorites = filterFavorites(favorites, a.favoriteTagFilter, a.favoriteUntaggedFilter)
764	a.favorites = favorites
765	a.tags = tags
766	data := favoritesRenderData{
767		Favorites:      favorites,
768		Tags:           tags,
769		TagFilter:      a.favoriteTagFilter,
770		UntaggedFilter: a.favoriteUntaggedFilter,
771		Notice:         a.favoritesNotice,
772		Error:          a.favoritesError,
773	}
774	a.renderedHTML = renderAppFrame(renderedPage{
775		route:    RouteFavorites,
776		title:    favoritesDocumentTitle(data),
777		subtitle: "Saved bookmarks",
778		body:     favoritesHTML(data),
779	}, a.shellTheme)
780	return nil
781}
782
783func (a *App) loadConfigRoute(ctx context.Context) error {
784	if a.deps.Config == nil {
785		a.resetRouteState()
786		a.renderedHTML = renderAppFrame(renderedPage{
787			route:    a.currentRoute,
788			title:    routeDocumentTitle(RouteConfig),
789			subtitle: "Browser settings",
790			body:     placeholderHTML(a.currentRoute),
791		}, a.shellTheme)
792		return nil
793	}
794
795	config, err := a.deps.Config.Current(ctx)
796	if err != nil {
797		return err
798	}
799	keybindings, err := a.deps.Config.GetKeybindings(ctx)
800	if err != nil {
801		return err
802	}
803
804	a.historyEntries = nil
805	a.favorites = nil
806	a.tags = nil
807	a.config = &config
808	a.keybindings = keybindings
809	a.renderedHTML = renderAppFrame(renderedPage{
810		route:    RouteConfig,
811		title:    "Config — Dumber",
812		subtitle: "Browser settings",
813		body: configHTML(configRenderData{
814			Config:      config,
815			Keybindings: keybindings,
816			Notice:      a.configNotice,
817			Error:       a.configError,
818		}),
819	}, a.shellTheme)
820	return nil
821}
822
823func (a *App) loadShellTheme(ctx context.Context) {
824	if a == nil {
825		return
826	}
827
828	a.shellTheme = shellTheme{}
829	if a.deps.Config == nil {
830		return
831	}
832
833	config, err := a.deps.Config.Current(ctx)
834	if err == nil {
835		appearance := config.Appearance
836		if config.ResolvedAppearance != nil {
837			appearance = *config.ResolvedAppearance
838		}
839		a.shellTheme = resolveShellTheme(appearance)
840	}
841}
842
843func (a *App) resetRouteState() {
844	a.historyEntries = nil
845	a.historyStats = nil
846	a.historyAnalytics = nil
847	a.historyDomainStats = nil
848	a.historyQuery = ""
849	a.historyDomainFilter = ""
850	a.historyOffset = 0
851	a.historyWindowBefore = time.Time{}
852	a.historyWindowAfter = time.Time{}
853	a.historyCursorID = 0
854	a.historyHasMore = false
855	a.historyNotice = ""
856	a.historyError = ""
857	a.favorites = nil
858	a.tags = nil
859	a.favoriteTagFilter = nil
860	a.favoriteUntaggedFilter = false
861	a.favoritesNotice = ""
862	a.favoritesError = ""
863	a.config = nil
864	a.keybindings = port.KeybindingsConfig{}
865	a.configNotice = ""
866	a.configError = ""
867}
868
869func (a *App) CurrentRoute() Route {
870	if a == nil {
871		return RouteUnknown
872	}
873	return a.currentRoute
874}

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