v0.31.0 bf0756b

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		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.tags = nil
516	a.historyEntries = nil
517	a.historyStats = nil
518	a.historyAnalytics = nil
519	a.historyDomainStats = nil
520	a.historyNotice = ""
521	a.historyError = ""
522	data := historyRenderData{
523		Query:        a.historyQuery,
524		DomainFilter: a.historyDomainFilter,
525		Offset:       a.historyOffset,
526		Limit:        historyTimelineLimit,
527		WindowBefore: a.historyWindowBefore,
528		WindowAfter:  a.historyWindowAfter,
529		HasMore:      a.historyHasMore,
530		Notice:       a.historyNotice,
531		Error:        a.historyError,
532		Loading:      true,
533	}
534	title := historyDocumentTitle(data)
535	a.renderedHTML = renderAppFrame(renderedPage{
536		route:          RouteHistory,
537		title:          title,
538		subtitle:       "History",
539		subtitleDetail: historyTitleDetail(data),
540		body:           historyHTML(data),
541	}, a.shellTheme)
542}
543
544func (a *App) loadHistoryRoute(ctx context.Context) error {
545	if a.deps.History == nil {
546		a.resetRouteState()
547		a.renderedHTML = renderAppFrame(renderedPage{
548			route:    a.currentRoute,
549			title:    routeDocumentTitle(RouteHistory),
550			subtitle: routeDocumentTitle(RouteHistory),
551			body:     placeholderHTML(a.currentRoute),
552		}, a.shellTheme)
553		return nil
554	}
555
556	result, err := a.renderHistoryRouteSnapshot(ctx, a.currentHistoryRouteSnapshotLocked())
557	if err != nil {
558		return err
559	}
560	a.commitHistoryRouteResultLocked(result)
561	return nil
562}
563
564type historyRouteSnapshot struct {
565	route        Route
566	history      port.SystemviewHistoryService
567	shellTheme   shellTheme
568	query        string
569	domainFilter string
570	offset       int
571	windowBefore time.Time
572	windowAfter  time.Time
573	cursorID     int64
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	cursorID     int64
585	hasMore      bool
586	html         string
587}
588
589func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot {
590	return historyRouteSnapshot{
591		route:        a.currentRoute,
592		history:      a.deps.History,
593		shellTheme:   a.shellTheme,
594		query:        a.historyQuery,
595		domainFilter: a.historyDomainFilter,
596		offset:       a.historyOffset,
597		windowBefore: a.historyWindowBefore,
598		windowAfter:  a.historyWindowAfter,
599		cursorID:     a.historyCursorID,
600		notice:       a.historyNotice,
601		error:        a.historyError,
602	}
603}
604
605func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (historyRouteResult, error) {
606	if snapshot.history == nil {
607		return historyRouteResult{
608			html: renderAppFrame(renderedPage{
609				route:    snapshot.route,
610				title:    routeDocumentTitle(RouteHistory),
611				subtitle: routeDocumentTitle(RouteHistory),
612				body:     placeholderHTML(snapshot.route),
613			}, snapshot.shellTheme),
614		}, nil
615	}
616
617	window, entries, err := loadHistoryWindowSnapshot(ctx, snapshot)
618	if err != nil {
619		return historyRouteResult{}, err
620	}
621	stats, err := snapshot.history.Stats(ctx)
622	if err != nil {
623		return historyRouteResult{}, fmt.Errorf("failed to load history stats: %w", err)
624	}
625	domains, domainsErr := snapshot.history.DomainStats(ctx, 10)
626	if domainsErr != nil {
627		domains = nil
628	}
629	result := historyRouteResult{
630		entries:      entries,
631		stats:        stats,
632		domains:      domains,
633		windowBefore: snapshot.windowBefore,
634		windowAfter:  snapshot.windowAfter,
635	}
636	if window != nil {
637		result.windowBefore = window.Before
638		result.windowAfter, result.cursorID, result.hasMore = historyWindowCursor(window)
639	}
640	data := historyRenderData{
641		Entries:      entries,
642		Stats:        stats,
643		Analytics:    nil,
644		Domains:      domains,
645		Query:        snapshot.query,
646		DomainFilter: snapshot.domainFilter,
647		Offset:       snapshot.offset,
648		Limit:        historyTimelineLimit,
649		WindowBefore: result.windowBefore,
650		WindowAfter:  result.windowAfter,
651		HasMore:      result.hasMore,
652		Notice:       snapshot.notice,
653		Error:        snapshot.error,
654	}
655	title := historyDocumentTitle(data)
656	result.html = renderAppFrame(renderedPage{
657		route:          RouteHistory,
658		title:          title,
659		subtitle:       "History",
660		subtitleDetail: historyTitleDetail(data),
661		body:           historyHTML(data),
662	}, snapshot.shellTheme)
663	return result, nil
664}
665
666func historyWindowCursor(window *entity.HistoryWindow) (time.Time, int64, bool) {
667	if window == nil || !window.HasMore {
668		return time.Time{}, 0, false
669	}
670	cursorTime := window.CursorLastVisited
671	if cursorTime.IsZero() {
672		cursorTime = window.After
673	}
674	if cursorTime.IsZero() || window.CursorID <= 0 {
675		return time.Time{}, 0, false
676	}
677	return cursorTime, window.CursorID, true
678}
679
680func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
681	query := strings.TrimSpace(snapshot.query)
682	domain := strings.TrimSpace(snapshot.domainFilter)
683	if query != "" {
684		entries, err := snapshot.history.Search(ctx, query, historySearchLimit)
685		return nil, entries, err
686	}
687	window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowAfter, snapshot.cursorID, domain)
688	if err != nil {
689		return nil, nil, err
690	}
691	if window == nil {
692		return nil, nil, nil
693	}
694	return window, window.Entries, nil
695}
696
697func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {
698	a.favorites = nil
699	a.tags = nil
700	a.historyEntries = result.entries
701	a.historyStats = result.stats
702	a.historyAnalytics = nil
703	a.historyDomainStats = result.domains
704	a.historyWindowBefore = result.windowBefore
705	a.historyWindowAfter = result.windowAfter
706	a.historyCursorID = result.cursorID
707	a.historyHasMore = result.hasMore
708	a.renderedHTML = result.html
709}
710
711func (a *App) loadFavoritesRoute(ctx context.Context) error {
712	if a.deps.Favorites == nil {
713		a.resetRouteState()
714		a.renderedHTML = renderAppFrame(renderedPage{
715			route:    a.currentRoute,
716			title:    routeDocumentTitle(RouteFavorites),
717			subtitle: "Saved bookmarks",
718			body:     placeholderHTML(a.currentRoute),
719		}, a.shellTheme)
720		return nil
721	}
722
723	favorites, err := a.deps.Favorites.List(ctx)
724	if err != nil {
725		return err
726	}
727	tags, err := a.deps.Favorites.ListTags(ctx)
728	if err != nil {
729		return err
730	}
731
732	a.historyEntries = nil
733	favorites = filterFavorites(favorites, a.favoriteTagFilter, a.favoriteUntaggedFilter)
734	a.favorites = favorites
735	a.tags = tags
736	data := favoritesRenderData{
737		Favorites:      favorites,
738		Tags:           tags,
739		TagFilter:      a.favoriteTagFilter,
740		UntaggedFilter: a.favoriteUntaggedFilter,
741		Notice:         a.favoritesNotice,
742		Error:          a.favoritesError,
743	}
744	a.renderedHTML = renderAppFrame(renderedPage{
745		route:    RouteFavorites,
746		title:    favoritesDocumentTitle(data),
747		subtitle: "Saved bookmarks",
748		body:     favoritesHTML(data),
749	}, a.shellTheme)
750	return nil
751}
752
753func (a *App) loadConfigRoute(ctx context.Context) error {
754	if a.deps.Config == nil {
755		a.resetRouteState()
756		a.renderedHTML = renderAppFrame(renderedPage{
757			route:    a.currentRoute,
758			title:    routeDocumentTitle(RouteConfig),
759			subtitle: "Browser settings",
760			body:     placeholderHTML(a.currentRoute),
761		}, a.shellTheme)
762		return nil
763	}
764
765	config, err := a.deps.Config.Current(ctx)
766	if err != nil {
767		return err
768	}
769	keybindings, err := a.deps.Config.GetKeybindings(ctx)
770	if err != nil {
771		return err
772	}
773
774	a.historyEntries = nil
775	a.favorites = nil
776	a.tags = nil
777	a.config = &config
778	a.keybindings = keybindings
779	a.renderedHTML = renderAppFrame(renderedPage{
780		route:    RouteConfig,
781		title:    "Config — Dumber",
782		subtitle: "Browser settings",
783		body: configHTML(configRenderData{
784			Config:      config,
785			Keybindings: keybindings,
786			Notice:      a.configNotice,
787			Error:       a.configError,
788		}),
789	}, a.shellTheme)
790	return nil
791}
792
793func (a *App) loadShellTheme(ctx context.Context) {
794	if a == nil {
795		return
796	}
797
798	a.shellTheme = shellTheme{}
799	if a.deps.Config == nil {
800		return
801	}
802
803	config, err := a.deps.Config.Current(ctx)
804	if err == nil {
805		appearance := config.Appearance
806		if config.ResolvedAppearance != nil {
807			appearance = *config.ResolvedAppearance
808		}
809		a.shellTheme = resolveShellTheme(appearance)
810	}
811}
812
813func (a *App) resetRouteState() {
814	a.historyEntries = nil
815	a.historyStats = nil
816	a.historyAnalytics = nil
817	a.historyDomainStats = nil
818	a.historyQuery = ""
819	a.historyDomainFilter = ""
820	a.historyOffset = 0
821	a.historyWindowBefore = time.Time{}
822	a.historyWindowAfter = time.Time{}
823	a.historyCursorID = 0
824	a.historyHasMore = false
825	a.historyNotice = ""
826	a.historyError = ""
827	a.favorites = nil
828	a.tags = nil
829	a.favoriteTagFilter = nil
830	a.favoriteUntaggedFilter = false
831	a.favoritesNotice = ""
832	a.favoritesError = ""
833	a.config = nil
834	a.keybindings = port.KeybindingsConfig{}
835	a.configNotice = ""
836	a.configError = ""
837}
838
839func (a *App) CurrentRoute() Route {
840	if a == nil {
841		return RouteUnknown
842	}
843	return a.currentRoute
844}

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