v0.30.0 eb3068f

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

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